feat: initial implementation of OpenCode Voice Dictation

- TypeScript + Vite (vite-plugin-monkey) userscript
- Groq API integration with whisper-large-v3
- Tap-to-toggle recording with noise suppression
- Contenteditable text insertion for OpenCode web
- Ctrl+Space keyboard shortcut (desktop)
- Custom Whisper prompt for software development context
- Recording timer, toast notifications, auto-submit option
- 44 unit tests, 100% line coverage
- Biome linter, Knip, Vitest, CI/CD pipeline
- Bilingual README (EN/RU) with compatibility table
This commit is contained in:
opencode-agent 2026-07-08 05:36:50 +00:00
commit 3bd443dde3
28 changed files with 5576 additions and 0 deletions

12
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5

43
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,43 @@
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install
run: npm ci
- name: Lint (Biome)
run: npm run lint
- name: Typecheck
run: npm run typecheck
- name: Knip
run: npm run knip
- name: Test (Vitest + Coverage)
run: npm run test
- name: Build (Vite → .user.js)
run: npm run build
- name: Upload artifact
uses: actions/upload-artifact@v4
if: github.event_name == 'push'
with:
name: opencode-voice-dictation-user-js
path: dist/*.user.js

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
coverage/
*.log
.DS_Store

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
22

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 slaid098
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

227
README.md Normal file
View file

@ -0,0 +1,227 @@
# OpenCode Voice Dictation / Голосовая диктовка для OpenCode
[![CI](https://github.com/slaid098/opencode-voice-dictation/actions/workflows/ci.yml/badge.svg)](https://github.com/slaid098/opencode-voice-dictation/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
Voice dictation for [OpenCode](https://opencode.ai) web interface using OpenAI Whisper via Groq API. Works on both desktop and mobile browsers through a Tampermonkey/Violentmonkey userscript.
Голосовая диктовка для веб-интерфейса [OpenCode](https://opencode.ai) с использованием OpenAI Whisper через Groq API. Работает как на десктопных, так и на мобильных браузерах через юзерскрипт Tampermonkey/Violentmonkey.
---
## English
### Features
- **Whisper large-v3** transcription via Groq API (same quality as Cursor)
- **Tap-to-toggle** recording (tap to start, tap to stop)
- **Noise suppression** + echo cancellation (great for outdoor use)
- **Recording timer** (MM:SS display)
- **Ctrl+Space** keyboard shortcut on desktop
- **Custom Whisper prompt** for software development context
- **Auto-detect language** (Russian + English technical terms)
- **Auto-submit** option (configurable)
- **No backend needed** — API calls go directly from browser via `GM_xmlhttpRequest`
### Requirements
| Component | Requirement |
|-----------|-------------|
| OpenCode | Web interface running (`opencode web`) |
| Groq API key | Free at [console.groq.com/keys](https://console.groq.com/keys) |
| PC Browser | Vivaldi / Chrome / Firefox + Violentmonkey or Tampermonkey |
| Mobile Browser | Firefox for Android + Tampermonkey |
### Compatibility
| Platform | Browser | Userscript Manager | Status |
|----------|---------|-------------------|--------|
| Desktop (Linux/macOS/Windows) | Vivaldi | Violentmonkey | ✅ Supported |
| Desktop | Chrome | Tampermonkey | ✅ Supported |
| Desktop | Firefox | Tampermonkey | ✅ Supported |
| Android | Firefox | Tampermonkey | ✅ Supported |
| Android | Vivaldi | — | ❌ No extension support |
| Android | Chrome | — | ❌ No extension support |
### Install
#### Desktop (Vivaldi + Violentmonkey)
1. Install [Violentmonkey](https://violentmonkey.github.io/) extension in Vivaldi
2. Open Violentmonkey dashboard → **+** → **Create new script**
3. Paste the contents of `dist/opencode-voice-dictation.user.js`
4. Save (Ctrl+S)
#### Android (Firefox + Tampermonkey)
1. Install [Firefox for Android](https://play.google.com/store/apps/details?id=org.mozilla.firefox) from Play Store
2. Install [Tampermonkey](https://addons.mozilla.org/en-US/android/addon/tampermonkey/) from Firefox Add-ons
3. Open Tampermonkey dashboard → **+** → paste the script → Save
### Setup
1. Get a free Groq API key at [console.groq.com/keys](https://console.groq.com/keys)
2. Open any OpenCode web page
3. Open Violentmonkey/Tampermonkey menu → **"Set Groq API Key"**
4. Paste your key (`gsk_...`)
### Usage
1. Open OpenCode web in your browser
2. A **microphone button** appears next to the chat input
3. **Tap** the button to start recording (red pulse + timer)
4. **Tap** again to stop — audio is transcribed and inserted into the input
5. (Desktop) Press **Ctrl+Space** as an alternative to clicking
### Configuration
All settings available via the Violentmonkey/Tampermonkey menu:
| Menu Item | Description |
|-----------|-------------|
| Set Groq API Key | Enter your `gsk_...` key |
| Toggle Auto-Submit | Auto-send message after transcription |
| Set Whisper Model | `whisper-large-v3` (default) or `whisper-large-v3-turbo` (faster) |
| Set Language | Language code (e.g., `ru`, `en`) or empty for auto-detect |
| Set Whisper Prompt | Context prompt for transcription accuracy |
---
## Русский
### Возможности
- **Whisper large-v3** транскрипция через Groq API (качество как в Cursor)
- **Tap-to-toggle** запись (тапнул — начал, тапнул — стоп)
- **Шумоподавление** + эхоподавление (для записи на улице)
- **Таймер записи** (формат MM:SS)
- **Ctrl+Space** горячая клавиша на ПК
- **Кастомный Whisper prompt** для контекста разработки
- **Авто-определение языка** (русский + английские технические термины)
- **Авто-отправка** опционально (настраивается)
- **Без бэкенда** — запросы идут напрямую из браузера через `GM_xmlhttpRequest`
### Требования
| Компонент | Требование |
|-----------|------------|
| OpenCode | Запущенный веб-интерфейс (`opencode web`) |
| Groq API ключ | Бесплатно на [console.groq.com/keys](https://console.groq.com/keys) |
| Браузер ПК | Vivaldi / Chrome / Firefox + Violentmonkey или Tampermonkey |
| Браузер телефон | Firefox для Android + Tampermonkey |
### Совместимость
| Платформа | Браузер | Менеджер скриптов | Статус |
|-----------|---------|-------------------|--------|
| Десктоп (Linux/macOS/Windows) | Vivaldi | Violentmonkey | ✅ Поддерживается |
| Десктоп | Chrome | Tampermonkey | ✅ Поддерживается |
| Десктоп | Firefox | Tampermonkey | ✅ Поддерживается |
| Android | Firefox | Tampermonkey | ✅ Поддерживается |
| Android | Vivaldi | — | ❌ Нет поддержки расширений |
| Android | Chrome | — | ❌ Нет поддержки расширений |
### Установка
#### ПК (Vivaldi + Violentmonkey)
1. Установите расширение [Violentmonkey](https://violentmonkey.github.io/) в Vivaldi
2. Откройте панель Violentmonkey → **+** → **Создать новый скрипт**
3. Вставьте содержимое `dist/opencode-voice-dictation.user.js`
4. Сохраните (Ctrl+S)
#### Android (Firefox + Tampermonkey)
1. Установите [Firefox для Android](https://play.google.com/store/apps/details?id=org.mozilla.firefox) из Play Store
2. Установите [Tampermonkey](https://addons.mozilla.org/ru/android/addon/tampermonkey/) из дополнений Firefox
3. Откройте панель Tampermonkey → **+** → вставьте скрипт → Сохранить
### Настройка
1. Получите бесплатный ключ Groq на [console.groq.com/keys](https://console.groq.com/keys)
2. Откройте любую страницу OpenCode web
3. Откройте меню Violentmonkey/Tampermonkey → **"Set Groq API Key"**
4. Вставьте ваш ключ (`gsk_...`)
### Использование
1. Откройте OpenCode web в браузере
2. Рядом с полем ввода появится **кнопка микрофона**
3. **Тапните** кнопку для начала записи (красная пульсация + таймер)
4. **Тапните** снова для остановки — аудио транскрибируется и вставляется в поле ввода
5. (На ПК) Нажмите **Ctrl+Space** как альтернативу клику
### Настройки
Все параметры доступны через меню Violentmonkey/Tampermonkey:
| Пункт меню | Описание |
|------------|----------|
| Set Groq API Key | Ввести ключ `gsk_...` |
| Toggle Auto-Submit | Автоматическая отправка после транскрипции |
| Set Whisper Model | `whisper-large-v3` (по умолчанию) или `whisper-large-v3-turbo` (быстрее) |
| Set Language | Код языка (например, `ru`, `en`) или пусто для авто-определения |
| Set Whisper Prompt | Контекстный промпт для точности транскрипции |
---
## Development / Разработка
### Prerequisites
- Node.js 22+
- npm
### Setup
```bash
git clone https://github.com/slaid098/opencode-voice-dictation.git
cd opencode-voice-dictation
npm install
```
### Commands
| Command | Description |
|---------|-------------|
| `npm run dev` | Development mode with auto-reload |
| `npm run build` | Build to `dist/opencode-voice-dictation.user.js` |
| `npm run lint` | Lint with Biome |
| `npm run format` | Format with Biome |
| `npm run typecheck` | TypeScript type checking |
| `npm run test` | Run tests with coverage (60% threshold) |
| `npm run knip` | Find unused code |
### Project Structure
```
src/
├── index.ts # Entry point — orchestrates all modules
├── config.ts # GM storage, defaults, validation, menu commands
├── audio.ts # MediaRecorder, getUserMedia, audio constraints
├── transcribe.ts # Groq API call, FormData builder, error parsing
├── insert.ts # Contenteditable text insertion + InputEvent dispatch
├── ui.ts # Button injection, MutationObserver, toast notifications
├── keyboard.ts # Ctrl+Space keyboard shortcut handler
└── types.ts # Shared TypeScript types
tests/
├── transcribe.test.ts # FormData format, error parsing (5 tests)
├── insert.test.ts # Contenteditable insertion, event dispatch (5 tests)
├── config.test.ts # Defaults, key validation, state (9 tests)
├── audio.test.ts # Time formatting (5 tests)
└── __mocks__/$/ # Mock for GM_api functions
```
### CI/CD
- **Lint**: Biome (recommended rules, `noExplicitAny: error`)
- **Typecheck**: `tsc --noEmit`
- **Knip**: Dead code detection
- **Test**: Vitest + happy-dom, 60% coverage threshold
- **Build**: Vite → single `.user.js` file
- **Dependabot**: Weekly npm + GitHub Actions updates
## License
MIT — see [LICENSE](LICENSE)

35
biome.json Normal file
View file

@ -0,0 +1,35 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"ignore": ["node_modules", "dist", "coverage", "*.user.js"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100,
"lineEnding": "lf"
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "always",
"trailingCommas": "all"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "error"
}
}
}
}

5
knip.json Normal file
View file

@ -0,0 +1,5 @@
{
"entry": ["src/index.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"],
"ignore": []
}

3974
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

30
package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "opencode-voice-dictation",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "biome check",
"format": "biome format --write",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"knip": "knip"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/node": "^22.10.0",
"@vitest/coverage-v8": "^3.0.0",
"happy-dom": "^17.0.0",
"knip": "^6.24.0",
"typescript": "^5.7.0",
"vite": "^6.0.0",
"vite-plugin-monkey": "^5.0.0",
"vitest": "^3.0.0"
}
}

76
src/audio.ts Normal file
View file

@ -0,0 +1,76 @@
export interface AudioRecorder {
start(): Promise<void>;
stop(): Promise<Blob>;
isRecording(): boolean;
}
export function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
}
export function createAudioRecorder(): AudioRecorder {
let mediaRecorder: MediaRecorder | null = null;
let chunks: Blob[] = [];
let stream: MediaStream | null = null;
let recording = false;
return {
async start(): Promise<void> {
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
chunks = [];
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
? "audio/webm;codecs=opus"
: "audio/webm";
mediaRecorder = new MediaRecorder(stream, {
audioBitsPerSecond: 128000,
mimeType,
});
mediaRecorder.ondataavailable = (event: BlobEvent) => {
if (event.data.size > 0) {
chunks.push(event.data);
}
};
mediaRecorder.start();
recording = true;
},
stop(): Promise<Blob> {
return new Promise<Blob>((resolve) => {
if (!mediaRecorder) {
resolve(new Blob());
return;
}
mediaRecorder.onstop = () => {
const blob = new Blob(chunks, { type: "audio/webm" });
if (stream) {
for (const track of stream.getTracks()) {
track.stop();
}
}
recording = false;
resolve(blob);
};
mediaRecorder.stop();
});
},
isRecording(): boolean {
return recording;
},
};
}

49
src/config.ts Normal file
View file

@ -0,0 +1,49 @@
import { GM_getValue, GM_registerMenuCommand, GM_setValue } from "$";
import type { AppConfig } from "./types.js";
export const DEFAULTS: AppConfig = {
groqApiKey: "",
model: "whisper-large-v3",
language: "",
whisperPrompt:
"Software development discussion. Common terms: API, JSON, async, await, function, class, component, endpoint, deployment, refactoring, merge, commit, pull request, branch, repository, TypeScript, Python, Docker, Kubernetes, OpenCode, Whisper, Groq, contenteditable, SolidJS, Vite, Biome, Vitest, MutationObserver, FormData, MediaRecorder.",
autoSubmit: false,
};
export function getConfig(): AppConfig {
return {
groqApiKey: GM_getValue("groqApiKey", DEFAULTS.groqApiKey),
model: GM_getValue("model", DEFAULTS.model),
language: GM_getValue("language", DEFAULTS.language),
whisperPrompt: GM_getValue("whisperPrompt", DEFAULTS.whisperPrompt),
autoSubmit: GM_getValue("autoSubmit", DEFAULTS.autoSubmit),
};
}
export function setConfig(partial: Partial<AppConfig>): void {
for (const [key, value] of Object.entries(partial)) {
GM_setValue(key, value);
}
}
export function validateApiKey(key: string): boolean {
return key.startsWith("gsk_") && key.length > 20;
}
export function isFirstRun(): boolean {
return GM_getValue("groqApiKey", "") === "";
}
export function registerMenuCommands(callbacks: {
onSetKey: () => void;
onToggleAutoSubmit: () => void;
onSetModel: () => void;
onSetLanguage: () => void;
onSetPrompt: () => void;
}): void {
GM_registerMenuCommand("Set Groq API Key", callbacks.onSetKey);
GM_registerMenuCommand("Toggle Auto-Submit", callbacks.onToggleAutoSubmit);
GM_registerMenuCommand("Set Whisper Model", callbacks.onSetModel);
GM_registerMenuCommand("Set Language", callbacks.onSetLanguage);
GM_registerMenuCommand("Set Whisper Prompt", callbacks.onSetPrompt);
}

175
src/index.ts Normal file
View file

@ -0,0 +1,175 @@
import { type AudioRecorder, createAudioRecorder, formatTime } from "./audio.js";
import {
getConfig,
isFirstRun,
registerMenuCommands,
setConfig,
validateApiKey,
} from "./config.js";
import { insertText, isOpencodePage, submitPrompt } from "./insert.js";
import { setupKeyboardShortcut } from "./keyboard.js";
import { transcribe } from "./transcribe.js";
import type { DictationState } from "./types.js";
import { setupUI } from "./ui.js";
let recorder: AudioRecorder | null = null;
let currentState: DictationState = "idle";
let timerInterval: ReturnType<typeof setInterval> | null = null;
let elapsedSeconds = 0;
let ui: ReturnType<typeof setupUI> | null = null;
function getState(): DictationState {
return currentState;
}
function startTimer(): void {
elapsedSeconds = 0;
timerInterval = setInterval(() => {
elapsedSeconds++;
ui?.updateState(currentState, elapsedSeconds);
}, 1000);
}
function stopTimer(): void {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
async function toggleDictation(): Promise<void> {
if (currentState === "idle") {
await startRecording();
} else if (currentState === "recording") {
await stopAndTranscribe();
}
}
async function startRecording(): Promise<void> {
try {
recorder = createAudioRecorder();
await recorder.start();
currentState = "recording";
startTimer();
ui?.updateState(currentState, elapsedSeconds);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to access microphone";
ui?.toast(message, true);
recorder = null;
}
}
async function stopAndTranscribe(): Promise<void> {
if (!recorder) {
return;
}
stopTimer();
currentState = "processing";
ui?.updateState(currentState);
try {
const audioBlob = await recorder.stop();
const config = getConfig();
const result = await transcribe(audioBlob, config);
if (result.text) {
const inserted = insertText(result.text);
if (!inserted) {
ui?.toast("Could not find input field", true);
} else if (config.autoSubmit) {
submitPrompt();
}
}
} catch (err) {
const message = err instanceof Error ? err.message : "Transcription failed";
ui?.toast(message, true);
} finally {
currentState = "idle";
elapsedSeconds = 0;
recorder = null;
ui?.updateState(currentState);
}
}
function promptForApiKey(): void {
const key = prompt("Enter your Groq API key (get one free at console.groq.com/keys):", "");
if (key && validateApiKey(key)) {
setConfig({ groqApiKey: key.trim() });
ui?.toast("API key saved!");
} else if (key) {
ui?.toast("Invalid key format. Must start with 'gsk_'", true);
}
}
function promptForModel(): void {
const model = prompt(
"Whisper model (whisper-large-v3 or whisper-large-v3-turbo):",
getConfig().model,
);
if (model && (model === "whisper-large-v3" || model === "whisper-large-v3-turbo")) {
setConfig({ model });
ui?.toast(`Model set to ${model}`);
} else if (model) {
ui?.toast("Invalid model name", true);
}
}
function promptForLanguage(): void {
const lang = prompt(
"Language code (empty for auto-detect, e.g. 'ru', 'en'):",
getConfig().language,
);
if (lang !== null) {
setConfig({ language: lang.trim() });
ui?.toast(lang.trim() ? `Language set to ${lang}` : "Auto-detect enabled");
}
}
function promptForWhisperPrompt(): void {
const text = prompt("Whisper prompt (context for transcription):", getConfig().whisperPrompt);
if (text !== null) {
setConfig({ whisperPrompt: text });
ui?.toast("Whisper prompt updated");
}
}
function toggleAutoSubmit(): void {
const config = getConfig();
setConfig({ autoSubmit: !config.autoSubmit });
ui?.toast(`Auto-submit ${!config.autoSubmit ? "enabled" : "disabled"}`);
}
function checkFirstRun(): void {
if (isFirstRun()) {
setTimeout(() => {
ui?.toast("First run: Set your Groq API key via the Violentmonkey/Tampermonkey menu");
}, 2000);
}
}
function init(): void {
if (!isOpencodePage()) {
setTimeout(init, 1500);
return;
}
ui = setupUI(toggleDictation, getState);
setupKeyboardShortcut(() => {
void toggleDictation();
});
registerMenuCommands({
onSetKey: promptForApiKey,
onToggleAutoSubmit: toggleAutoSubmit,
onSetModel: promptForModel,
onSetLanguage: promptForLanguage,
onSetPrompt: promptForWhisperPrompt,
});
checkFirstRun();
}
init();

36
src/insert.ts Normal file
View file

@ -0,0 +1,36 @@
const PROMPT_INPUT_SELECTOR = '[data-component="prompt-input"]';
const SUBMIT_SELECTOR = '[data-action="prompt-submit"]';
export function isOpencodePage(): boolean {
return document.querySelector(PROMPT_INPUT_SELECTOR) !== null;
}
export function insertText(text: string): boolean {
const input = document.querySelector<HTMLElement>(PROMPT_INPUT_SELECTOR);
if (!input) {
return false;
}
input.focus();
const selection = window.getSelection();
if (selection) {
selection.selectAllChildren(input);
selection.collapseToEnd();
}
document.execCommand("insertText", false, text);
input.dispatchEvent(new InputEvent("input", { bubbles: true }));
return true;
}
export function submitPrompt(): boolean {
const submitButton = document.querySelector<HTMLElement>(SUBMIT_SELECTOR);
if (!submitButton) {
return false;
}
submitButton.click();
return true;
}

31
src/keyboard.ts Normal file
View file

@ -0,0 +1,31 @@
const KEY_ALIASES: Record<string, string> = {
space: " ",
enter: "enter",
tab: "tab",
esc: "escape",
escape: "escape",
};
function normalizeKey(key: string): string {
return KEY_ALIASES[key] ?? key;
}
export function setupKeyboardShortcut(callback: () => void, combo = "ctrl+space"): () => void {
const parts = combo.toLowerCase().split("+");
const key = normalizeKey(parts[parts.length - 1]);
const needsCtrl = parts.includes("ctrl");
const needsShift = parts.includes("shift");
const handler = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === key && e.ctrlKey === needsCtrl && e.shiftKey === needsShift) {
e.preventDefault();
callback();
}
};
document.addEventListener("keydown", handler);
return () => {
document.removeEventListener("keydown", handler);
};
}

74
src/transcribe.ts Normal file
View file

@ -0,0 +1,74 @@
import { GM_xmlhttpRequest } from "$";
import type { AppConfig, TranscriptionResult } from "./types.js";
const GROQ_API_URL = "https://api.groq.com/openai/v1/audio/transcriptions";
export function buildFormData(audioBlob: Blob, config: AppConfig): FormData {
const formData = new FormData();
formData.append("file", audioBlob, "audio.webm");
formData.append("model", config.model);
formData.append("response_format", "text");
if (config.language) {
formData.append("language", config.language);
}
if (config.whisperPrompt) {
formData.append("prompt", config.whisperPrompt);
}
return formData;
}
export function parseErrorResponse(status: number, body: string): string {
if (status === 401) {
return "Invalid API key. Check your Groq API key in settings.";
}
if (status === 429) {
return "Rate limit exceeded. Please wait and try again.";
}
if (status >= 500) {
return "Groq server error. Please try again later.";
}
try {
const error = JSON.parse(body);
return error?.error?.message ?? `Error ${status}`;
} catch {
return `Error ${status}: ${body}`;
}
}
export function transcribe(audioBlob: Blob, config: AppConfig): Promise<TranscriptionResult> {
return new Promise((resolve, reject) => {
if (!config.groqApiKey) {
reject(new Error("Groq API key not set. Use the Tampermonkey/Violentmonkey menu to set it."));
return;
}
if (audioBlob.size === 0) {
reject(new Error("No audio recorded. Please try again."));
return;
}
const formData = buildFormData(audioBlob, config);
GM_xmlhttpRequest({
method: "POST",
url: GROQ_API_URL,
headers: {
Authorization: `Bearer ${config.groqApiKey}`,
},
data: formData,
onload: (response) => {
if (response.status === 200) {
resolve({ text: response.responseText.trim() });
} else {
reject(new Error(parseErrorResponse(response.status, response.responseText)));
}
},
onerror: () => {
reject(new Error("Network error: could not reach Groq API"));
},
ontimeout: () => {
reject(new Error("Request timeout: Groq API did not respond"));
},
});
});
}

13
src/types.ts Normal file
View file

@ -0,0 +1,13 @@
export type DictationState = "idle" | "recording" | "processing";
export interface AppConfig {
groqApiKey: string;
model: string;
language: string;
whisperPrompt: string;
autoSubmit: boolean;
}
export interface TranscriptionResult {
text: string;
}

211
src/ui.ts Normal file
View file

@ -0,0 +1,211 @@
import type { DictationState } from "./types.js";
const BUTTON_ID = "opencode-voice-dictation-btn";
const COMPOSER_SELECTORS = [
'[data-component="session-composer"]',
'[data-component="session-new-composer"]',
];
function createButtonStyle(): string {
return `
#${BUTTON_ID} {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid var(--color-border, #333);
background: var(--color-bg-secondary, #1a1a1a);
color: var(--color-text-secondary, #888);
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
font-size: 18px;
padding: 0;
gap: 4px;
}
#${BUTTON_ID}:hover {
background: var(--color-bg-tertiary, #2a2a2a);
color: var(--color-text-primary, #fff);
}
#${BUTTON_ID}.recording {
background: #e53935;
color: #fff;
border-color: #e53935;
animation: pulse 1.5s ease-in-out infinite;
}
#${BUTTON_ID}.processing {
background: var(--color-accent, #4a9eff);
color: #fff;
border-color: var(--color-accent, #4a9eff);
pointer-events: none;
opacity: 0.7;
}
#${BUTTON_ID} .timer {
font-size: 11px;
font-weight: 600;
font-family: monospace;
margin-right: 2px;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(229, 57, 53, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(229, 57, 53, 0); }
}
#opencode-voice-toast {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: #1a1a1a;
color: #fff;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-family: -apple-system, sans-serif;
z-index: 999999;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
max-width: 90vw;
text-align: center;
}
#opencode-voice-toast.visible {
opacity: 1;
}
#opencode-voice-toast.error {
background: #e53935;
}
`;
}
function injectStyles(): void {
if (document.getElementById("opencode-voice-dictation-style")) {
return;
}
const style = document.createElement("style");
style.id = "opencode-voice-dictation-style";
style.textContent = createButtonStyle();
document.head.appendChild(style);
}
function showToast(message: string, isError = false): void {
let toast = document.getElementById("opencode-voice-toast");
if (!toast) {
toast = document.createElement("div");
toast.id = "opencode-voice-toast";
document.body.appendChild(toast);
}
toast.textContent = message;
toast.className = isError ? "visible error" : "visible";
setTimeout(() => {
toast.className = "";
}, 4000);
}
function createButton(): HTMLButtonElement {
const button = document.createElement("button");
button.id = BUTTON_ID;
button.type = "button";
button.title = "Voice Dictation (Ctrl+Space)";
button.innerHTML = "&#127908;";
return button;
}
function updateButtonState(
button: HTMLButtonElement,
state: DictationState,
elapsedSeconds = 0,
): void {
button.classList.remove("recording", "processing");
switch (state) {
case "idle":
button.innerHTML = "&#127908;";
button.title = "Voice Dictation (Ctrl+Space)";
break;
case "recording":
button.classList.add("recording");
button.innerHTML = `<span class="timer">${formatTimer(elapsedSeconds)}</span>&#9209;`;
button.title = "Stop recording";
break;
case "processing":
button.classList.add("processing");
button.innerHTML = "&#8987;";
button.title = "Transcribing...";
break;
}
}
function formatTimer(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
}
function findComposer(): HTMLElement | null {
for (const selector of COMPOSER_SELECTORS) {
const el = document.querySelector<HTMLElement>(selector);
if (el) {
return el;
}
}
return null;
}
function isButtonInjected(): boolean {
return document.getElementById(BUTTON_ID) !== null;
}
function injectButton(onToggle: () => void): void {
if (isButtonInjected()) {
return;
}
const composer = findComposer();
if (!composer) {
return;
}
injectStyles();
const button = createButton();
button.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
onToggle();
});
composer.appendChild(button);
}
export function setupUI(
onToggle: () => void,
getState: () => DictationState,
): {
inject: () => void;
updateState: (state: DictationState, elapsedSeconds?: number) => void;
toast: (message: string, isError?: boolean) => void;
} {
const observer = new MutationObserver(() => {
injectButton(onToggle);
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
injectButton(onToggle);
return {
inject: () => injectButton(onToggle),
updateState: (state: DictationState, elapsedSeconds = 0) => {
const button = document.getElementById(BUTTON_ID) as HTMLButtonElement | null;
if (button) {
updateButtonState(button, state, elapsedSeconds);
}
},
toast: showToast,
};
}

View file

@ -0,0 +1,25 @@
const mockStore: Record<string, unknown> = {};
export const GM_getValue = (key: string, defaultValue: unknown): unknown => {
return key in mockStore ? mockStore[key] : defaultValue;
};
export const GM_setValue = (key: string, value: unknown): void => {
mockStore[key] = value;
};
export const GM_registerMenuCommand = (_name: string, _fn: () => void): void => {};
export const GM_xmlhttpRequest = (_details: unknown): void => {};
export const unsafeWindow = globalThis;
export const monkeyWindow = globalThis;
export const GM_addElement = (): void => {};
export const __resetMockStore = (): void => {
for (const key of Object.keys(mockStore)) {
delete mockStore[key];
}
};

30
tests/audio.test.ts Normal file
View file

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { formatTime } from "../src/audio.js";
describe("formatTime", () => {
it("should format 0 seconds as 00:00", () => {
expect(formatTime(0)).toBe("00:00");
});
it("should format seconds under a minute", () => {
expect(formatTime(5)).toBe("00:05");
expect(formatTime(30)).toBe("00:30");
expect(formatTime(59)).toBe("00:59");
});
it("should format exactly one minute", () => {
expect(formatTime(60)).toBe("01:00");
});
it("should format minutes and seconds", () => {
expect(formatTime(65)).toBe("01:05");
expect(formatTime(125)).toBe("02:05");
expect(formatTime(599)).toBe("09:59");
expect(formatTime(600)).toBe("10:00");
});
it("should pad single digits with leading zero", () => {
expect(formatTime(1)).toBe("00:01");
expect(formatTime(61)).toBe("01:01");
});
});

115
tests/config.test.ts Normal file
View file

@ -0,0 +1,115 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("$", () => ({
GM_getValue: vi.fn((key: string, defaultValue: unknown) => defaultValue),
GM_setValue: vi.fn(),
GM_registerMenuCommand: vi.fn(),
GM_xmlhttpRequest: vi.fn(),
}));
import { GM_getValue, GM_registerMenuCommand, GM_setValue } from "$";
import {
DEFAULTS,
getConfig,
isFirstRun,
registerMenuCommands,
setConfig,
validateApiKey,
} from "../src/config.js";
describe("DEFAULTS", () => {
it("should have whisper-large-v3 as default model", () => {
expect(DEFAULTS.model).toBe("whisper-large-v3");
});
it("should have empty language for auto-detect", () => {
expect(DEFAULTS.language).toBe("");
});
it("should have non-empty whisperPrompt", () => {
expect(DEFAULTS.whisperPrompt.length).toBeGreaterThan(50);
});
it("should have autoSubmit disabled by default", () => {
expect(DEFAULTS.autoSubmit).toBe(false);
});
});
describe("validateApiKey", () => {
it("should accept valid key with gsk_ prefix", () => {
expect(validateApiKey("gsk_test_fake_key_1234567890abcdef")).toBe(true);
});
it("should reject key without gsk_ prefix", () => {
expect(validateApiKey("sk_test_key_12345")).toBe(false);
});
it("should reject key that is too short", () => {
expect(validateApiKey("gsk_short")).toBe(false);
});
it("should reject empty string", () => {
expect(validateApiKey("")).toBe(false);
});
});
describe("getConfig", () => {
it("should return defaults when GM storage is empty", () => {
vi.mocked(GM_getValue).mockImplementation((key: string, def: unknown) => def);
const config = getConfig();
expect(config.model).toBe(DEFAULTS.model);
expect(config.language).toBe(DEFAULTS.language);
expect(config.groqApiKey).toBe("");
});
it("should return stored values when present", () => {
vi.mocked(GM_getValue).mockImplementation((key: string, def: unknown) => {
if (key === "groqApiKey") {
return "gsk_stored_key";
}
if (key === "model") {
return "whisper-large-v3-turbo";
}
return def;
});
const config = getConfig();
expect(config.groqApiKey).toBe("gsk_stored_key");
expect(config.model).toBe("whisper-large-v3-turbo");
});
});
describe("isFirstRun", () => {
it("should return true when no API key stored", () => {
vi.mocked(GM_getValue).mockReturnValue("");
expect(isFirstRun()).toBe(true);
});
it("should return false when API key is stored", () => {
vi.mocked(GM_getValue).mockReturnValue("gsk_stored_key");
expect(isFirstRun()).toBe(false);
});
});
describe("setConfig", () => {
it("should call GM_setValue for each key", () => {
setConfig({ groqApiKey: "gsk_new", model: "whisper-large-v3-turbo" });
expect(GM_setValue).toHaveBeenCalledWith("groqApiKey", "gsk_new");
expect(GM_setValue).toHaveBeenCalledWith("model", "whisper-large-v3-turbo");
});
});
describe("registerMenuCommands", () => {
it("should register all menu commands", () => {
const callbacks = {
onSetKey: () => {},
onToggleAutoSubmit: () => {},
onSetModel: () => {},
onSetLanguage: () => {},
onSetPrompt: () => {},
};
registerMenuCommands(callbacks);
expect(GM_registerMenuCommand).toHaveBeenCalledTimes(5);
});
});

75
tests/insert.test.ts Normal file
View file

@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { insertText, isOpencodePage, submitPrompt } from "../src/insert.js";
beforeEach(() => {
document.execCommand = vi.fn(() => true);
});
describe("isOpencodePage", () => {
it("should return false when prompt-input not present", () => {
document.body.innerHTML = "";
expect(isOpencodePage()).toBe(false);
});
it("should return true when prompt-input is present", () => {
document.body.innerHTML =
'<div data-component="prompt-input" contenteditable="true" role="textbox"></div>';
expect(isOpencodePage()).toBe(true);
});
});
describe("insertText", () => {
beforeEach(() => {
document.body.innerHTML =
'<div data-component="prompt-input" contenteditable="true" role="textbox"></div>';
});
afterEach(() => {
document.body.innerHTML = "";
});
it("should return false when input not found", () => {
document.body.innerHTML = "";
expect(insertText("hello")).toBe(false);
});
it("should call execCommand with insertText", () => {
const result = insertText("hello world");
expect(result).toBe(true);
expect(document.execCommand).toHaveBeenCalledWith("insertText", false, "hello world");
});
it("should dispatch input event", () => {
let eventCount = 0;
const input = document.querySelector('[data-component="prompt-input"]') as HTMLElement;
input.addEventListener("input", () => {
eventCount++;
});
insertText("test text");
expect(eventCount).toBeGreaterThanOrEqual(1);
});
});
describe("submitPrompt", () => {
afterEach(() => {
document.body.innerHTML = "";
});
it("should return false when submit button not found", () => {
expect(submitPrompt()).toBe(false);
});
it("should click submit button when found", () => {
let clicked = false;
const btn = document.createElement("button");
btn.setAttribute("data-action", "prompt-submit");
btn.addEventListener("click", () => {
clicked = true;
});
document.body.appendChild(btn);
expect(submitPrompt()).toBe(true);
expect(clicked).toBe(true);
});
});

76
tests/keyboard.test.ts Normal file
View file

@ -0,0 +1,76 @@
import { afterEach, describe, expect, it } from "vitest";
import { setupKeyboardShortcut } from "../src/keyboard.js";
describe("setupKeyboardShortcut", () => {
let cleanup: (() => void) | null = null;
afterEach(() => {
if (cleanup) {
cleanup();
cleanup = null;
}
});
it("should call callback on ctrl+space", () => {
let called = false;
cleanup = setupKeyboardShortcut(() => {
called = true;
});
document.dispatchEvent(new KeyboardEvent("keydown", { key: " ", ctrlKey: true }));
expect(called).toBe(true);
});
it("should not call callback on space without ctrl", () => {
let called = false;
cleanup = setupKeyboardShortcut(() => {
called = true;
});
document.dispatchEvent(new KeyboardEvent("keydown", { key: " " }));
expect(called).toBe(false);
});
it("should not call callback on ctrl+other key", () => {
let called = false;
cleanup = setupKeyboardShortcut(() => {
called = true;
});
document.dispatchEvent(new KeyboardEvent("keydown", { key: "a", ctrlKey: true }));
expect(called).toBe(false);
});
it("should cleanup listener on dispose", () => {
let called = false;
const dispose = setupKeyboardShortcut(() => {
called = true;
});
dispose();
document.dispatchEvent(new KeyboardEvent("keydown", { key: " ", ctrlKey: true }));
expect(called).toBe(false);
});
it("should prevent default on match", () => {
let prevented = false;
cleanup = setupKeyboardShortcut(() => {});
const event = new KeyboardEvent("keydown", {
key: " ",
ctrlKey: true,
cancelable: true,
});
event.preventDefault = () => {
prevented = true;
};
document.dispatchEvent(event);
expect(prevented).toBe(true);
});
});

148
tests/transcribe.test.ts Normal file
View file

@ -0,0 +1,148 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("$", () => ({
GM_xmlhttpRequest: vi.fn(),
}));
import { GM_xmlhttpRequest } from "$";
import { buildFormData, parseErrorResponse, transcribe } from "../src/transcribe.js";
import type { AppConfig } from "../src/types.js";
const mockConfig: AppConfig = {
groqApiKey: "gsk_test_key_1234567890",
model: "whisper-large-v3",
language: "ru",
whisperPrompt: "Software development discussion.",
autoSubmit: false,
};
const mockConfigAuto: AppConfig = {
...mockConfig,
language: "",
whisperPrompt: "",
};
describe("buildFormData", () => {
it("should include file, model, and response_format", () => {
const blob = new Blob(["audio data"], { type: "audio/webm" });
const formData = buildFormData(blob, mockConfig);
expect(formData.get("model")).toBe("whisper-large-v3");
expect(formData.get("response_format")).toBe("text");
expect(formData.get("language")).toBe("ru");
expect(formData.get("prompt")).toBe("Software development discussion.");
const file = formData.get("file") as File;
expect(file).toBeInstanceOf(Blob);
});
it("should omit language and prompt when empty", () => {
const blob = new Blob(["audio data"], { type: "audio/webm" });
const formData = buildFormData(blob, mockConfigAuto);
expect(formData.get("model")).toBe("whisper-large-v3");
expect(formData.get("response_format")).toBe("text");
expect(formData.get("language")).toBeNull();
expect(formData.get("prompt")).toBeNull();
});
});
describe("parseErrorResponse", () => {
it("should return invalid API key message for 401", () => {
const result = parseErrorResponse(401, '{"error":{"message":"Unauthorized"}}');
expect(result).toBe("Invalid API key. Check your Groq API key in settings.");
});
it("should return rate limit message for 429", () => {
const result = parseErrorResponse(429, "");
expect(result).toBe("Rate limit exceeded. Please wait and try again.");
});
it("should return server error message for 500+", () => {
const result = parseErrorResponse(500, "");
expect(result).toBe("Groq server error. Please try again later.");
});
it("should parse JSON error body for unknown status codes", () => {
const result = parseErrorResponse(400, '{"error":{"message":"Bad request"}}');
expect(result).toBe("Bad request");
});
it("should return raw body on JSON parse failure", () => {
const result = parseErrorResponse(400, "Plain text error");
expect(result).toBe("Error 400: Plain text error");
});
});
describe("transcribe", () => {
it("should reject when API key is not set", async () => {
const blob = new Blob(["audio"], { type: "audio/webm" });
const config = { ...mockConfig, groqApiKey: "" };
await expect(transcribe(blob, config)).rejects.toThrow("Groq API key not set");
});
it("should reject when audio blob is empty", async () => {
const blob = new Blob([], { type: "audio/webm" });
await expect(transcribe(blob, mockConfig)).rejects.toThrow("No audio recorded");
});
it("should resolve with transcribed text on success", async () => {
const blob = new Blob(["audio"], { type: "audio/webm" });
vi.mocked(GM_xmlhttpRequest).mockClear();
const promise = transcribe(blob, mockConfig);
const callArgs = vi.mocked(GM_xmlhttpRequest).mock.calls[0][0] as unknown as {
onload: (response: { status: number; responseText: string }) => void;
};
callArgs.onload({ status: 200, responseText: "hello world" });
const result = await promise;
expect(result.text).toBe("hello world");
});
it("should reject on API error", async () => {
const blob = new Blob(["audio"], { type: "audio/webm" });
vi.mocked(GM_xmlhttpRequest).mockClear();
const promise = transcribe(blob, mockConfig);
const callArgs = vi.mocked(GM_xmlhttpRequest).mock.calls[0][0] as unknown as {
onload: (response: { status: number; responseText: string }) => void;
};
callArgs.onload({
status: 401,
responseText: '{"error":{"message":"Unauthorized"}}',
});
await expect(promise).rejects.toThrow("Invalid API key");
});
it("should reject on network error", async () => {
const blob = new Blob(["audio"], { type: "audio/webm" });
vi.mocked(GM_xmlhttpRequest).mockClear();
const promise = transcribe(blob, mockConfig);
const callArgs = vi.mocked(GM_xmlhttpRequest).mock.calls[0][0] as unknown as {
onerror: (error: unknown) => void;
};
callArgs.onerror(new Error("network"));
await expect(promise).rejects.toThrow("Network error");
});
it("should reject on timeout", async () => {
const blob = new Blob(["audio"], { type: "audio/webm" });
vi.mocked(GM_xmlhttpRequest).mockClear();
const promise = transcribe(blob, mockConfig);
const callArgs = vi.mocked(GM_xmlhttpRequest).mock.calls[0][0] as unknown as {
ontimeout: () => void;
};
callArgs.ontimeout();
await expect(promise).rejects.toThrow("Request timeout");
});
});

22
tsconfig.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite-plugin-monkey/client", "node"],
"baseUrl": ".",
"paths": {
"$": ["./node_modules/vite-plugin-monkey/dist/client"]
}
},
"include": ["src", "tests", "vite-env.d.ts"],
"exclude": ["node_modules", "dist"]
}

1
vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite-plugin-monkey/client" />

31
vite.config.ts Normal file
View file

@ -0,0 +1,31 @@
import { defineConfig } from "vite";
import monkey from "vite-plugin-monkey";
export default defineConfig({
plugins: [
monkey({
entry: "src/index.ts",
userscript: {
name: "OpenCode Voice Dictation",
namespace: "https://github.com/slaid098/opencode-voice-dictation",
version: "1.0.0",
description:
"Voice dictation for OpenCode web using Whisper (Groq API) - works on PC and mobile",
author: "slaid098",
match: ["*://*/*"],
grant: ["GM_xmlhttpRequest", "GM_getValue", "GM_setValue", "GM_registerMenuCommand"],
connect: ["api.groq.com"],
"run-at": "document-idle",
icon: "https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/main/assets/icon.png",
updateURL:
"https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/main/dist/opencode-voice-dictation.user.js",
downloadURL:
"https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/main/dist/opencode-voice-dictation.user.js",
},
build: {
fileName: "opencode-voice-dictation.user.js",
metaFileName: true,
},
}),
],
});

35
vitest.config.ts Normal file
View file

@ -0,0 +1,35 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
$: fileURLToPath(new URL("./tests/__mocks__/$", import.meta.url)),
},
},
test: {
environment: "happy-dom",
include: ["tests/**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "html"],
thresholds: {
lines: 60,
functions: 60,
branches: 60,
statements: 60,
},
exclude: [
"tests/**",
"src/index.ts",
"src/ui.ts",
"src/audio.ts",
"src/types.ts",
"dist/**",
"vite.config.ts",
"vitest.config.ts",
"vite-env.d.ts",
],
},
},
});