// ==UserScript== // @name OpenCode Voice Dictation // @namespace https://github.com/slaid098/opencode-voice-dictation // @version 1.0.0 // @author slaid098 // @description Voice dictation for OpenCode web using Whisper (Groq API) - works on PC and mobile // @icon https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/main/assets/icon.png // @downloadURL https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js // @updateURL https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.meta.js // @match *://*/* // @connect api.groq.com // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_setValue // @grant GM_xmlhttpRequest // @run-at document-idle // ==/UserScript== (function () { 'use strict'; function createAudioRecorder() { let mediaRecorder = null; let chunks = []; let stream = null; let recording = false; return { async start() { 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: 128e3, mimeType }); mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { chunks.push(event.data); } }; mediaRecorder.start(); recording = true; }, stop() { return new Promise((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() { return recording; } }; } var _GM_getValue = /* @__PURE__ */ (() => typeof GM_getValue != "undefined" ? GM_getValue : void 0)(); var _GM_registerMenuCommand = /* @__PURE__ */ (() => typeof GM_registerMenuCommand != "undefined" ? GM_registerMenuCommand : void 0)(); var _GM_setValue = /* @__PURE__ */ (() => typeof GM_setValue != "undefined" ? GM_setValue : void 0)(); var _GM_xmlhttpRequest = /* @__PURE__ */ (() => typeof GM_xmlhttpRequest != "undefined" ? GM_xmlhttpRequest : void 0)(); const DEFAULTS = { 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 }; function getConfig() { 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) }; } function setConfig(partial) { for (const [key, value] of Object.entries(partial)) { _GM_setValue(key, value); } } function validateApiKey(key) { return key.startsWith("gsk_") && key.length > 20; } function isFirstRun() { return _GM_getValue("groqApiKey", "") === ""; } function registerMenuCommands(callbacks) { _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); } const PROMPT_INPUT_SELECTOR = '[data-component="prompt-input"]'; const SUBMIT_SELECTOR = '[data-action="prompt-submit"]'; const QUESTION_INPUT_SELECTOR$1 = '[data-slot="question-custom-input"]'; function isOpencodePage() { return document.querySelector(PROMPT_INPUT_SELECTOR) !== null; } function isQuestionPromptOpen() { return document.querySelector(QUESTION_INPUT_SELECTOR$1) !== null; } function insertText(text, target = "composer") { if (target === "question") { return insertIntoTextarea(text); } return insertIntoContenteditable(text); } function insertIntoContenteditable(text) { const input = document.querySelector(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; } function insertIntoTextarea(text) { const textarea = document.querySelector(QUESTION_INPUT_SELECTOR$1); if (!textarea) { return false; } const start = textarea.selectionStart; const end = textarea.selectionEnd; const newValue = textarea.value.substring(0, start) + text + textarea.value.substring(end); textarea.value = newValue; textarea.selectionStart = textarea.selectionEnd = start + text.length; textarea.dispatchEvent(new Event("input", { bubbles: true })); textarea.focus(); return true; } function submitPrompt() { const submitButton = document.querySelector(SUBMIT_SELECTOR); if (!submitButton) { return false; } submitButton.click(); return true; } const KEY_ALIASES = { space: " ", enter: "enter", tab: "tab", esc: "escape", escape: "escape" }; function normalizeKey(key) { return KEY_ALIASES[key] ?? key; } function setupKeyboardShortcut(callback, combo = "ctrl+space") { 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) => { if (e.key.toLowerCase() === key && e.ctrlKey === needsCtrl && e.shiftKey === needsShift) { e.preventDefault(); callback(); } }; document.addEventListener("keydown", handler); return () => { document.removeEventListener("keydown", handler); }; } const GROQ_API_URL = "https://api.groq.com/openai/v1/audio/transcriptions"; function buildFormData(audioBlob, config) { 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; } function parseErrorResponse(status, body) { var _a; 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 ((_a = error == null ? void 0 : error.error) == null ? void 0 : _a.message) ?? `Error ${status}`; } catch { return `Error ${status}: ${body}`; } } function transcribe(audioBlob, config) { 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")); } }); }); } const BUTTON_CLASS = "ocvd-btn"; const CONTAINER_CLASS = "ocvd-container"; const TIMER_CLASS = "ocvd-timer"; const CANCEL_CLASS = "ocvd-cancel"; const COMPOSER_SELECTORS = [ '[data-component="session-composer"]', '[data-component="session-new-composer"]' ]; const QUESTION_INPUT_SELECTOR = '[data-slot="question-custom-input"]'; const ICON_MIC = ``; const ICON_STOP = ``; const ICON_CANCEL = ``; const ICON_SPINNER = ``; function createButtonStyle() { return ` .${CONTAINER_CLASS} { position: absolute !important; top: 8px; right: 8px; z-index: 999998; display: flex; align-items: center; gap: 6px; pointer-events: none; } .${CONTAINER_CLASS} > * { pointer-events: auto; } .${BUTTON_CLASS} { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; border: none; background: var(--color-bg-tertiary, rgba(128, 128, 128, 0.15)); color: var(--color-text-secondary, #888); cursor: pointer; transition: all 0.2s ease; padding: 0; flex-shrink: 0; } .${BUTTON_CLASS}:hover { background: var(--color-bg-hover, rgba(128, 128, 128, 0.25)); color: var(--color-text-primary, #fff); } .${BUTTON_CLASS}.recording { background: #e53935; color: #fff; animation: ocvd-pulse 1.5s ease-in-out infinite; } .${BUTTON_CLASS}.processing { background: var(--color-accent, #4a9eff); color: #fff; pointer-events: none; opacity: 0.8; } .${CANCEL_CLASS} { display: none; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; border: none; background: rgba(128, 128, 128, 0.5); color: #fff; cursor: pointer; transition: all 0.2s ease; padding: 0; flex-shrink: 0; } .${CANCEL_CLASS}:hover { background: rgba(128, 128, 128, 0.7); color: #fff; } .${CANCEL_CLASS}.visible { display: flex; } .${TIMER_CLASS} { display: none; font-family: monospace; font-size: 13px; font-weight: 600; color: #fff; background: #e53935; padding: 4px 10px; border-radius: 12px; align-items: center; gap: 5px; line-height: 1; white-space: nowrap; } .${TIMER_CLASS}.visible { display: inline-flex; } .${TIMER_CLASS}::before { content: ""; width: 7px; height: 7px; background: #fff; border-radius: 50%; animation: ocvd-blink 1s ease-in-out infinite; flex-shrink: 0; } @keyframes ocvd-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(229, 57, 53, 0.4); } 50% { box-shadow: 0 0 0 6px rgba(229, 57, 53, 0); } } @keyframes ocvd-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } @keyframes ocvd-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .ocvd-spin { animation: ocvd-spin 0.8s linear infinite; transform-origin: center; } #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() { 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, isError = false) { 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 = ""; }, 4e3); } function formatTimer(seconds) { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; } function createContainer() { const container = document.createElement("div"); container.className = CONTAINER_CLASS; const cancel = document.createElement("button"); cancel.className = CANCEL_CLASS; cancel.type = "button"; cancel.title = "Cancel recording"; cancel.innerHTML = ICON_CANCEL; const timer = document.createElement("span"); timer.className = TIMER_CLASS; timer.textContent = "00:00"; const button = document.createElement("button"); button.className = BUTTON_CLASS; button.type = "button"; button.title = "Voice Dictation (Ctrl+Space)"; button.innerHTML = ICON_MIC; container.appendChild(cancel); container.appendChild(timer); container.appendChild(button); return container; } function updateAllButtonStates(state, elapsedSeconds2 = 0) { const containers = document.querySelectorAll(`.${CONTAINER_CLASS}`); for (const container of containers) { const button = container.querySelector(`.${BUTTON_CLASS}`); const timer = container.querySelector(`.${TIMER_CLASS}`); const cancel = container.querySelector(`.${CANCEL_CLASS}`); if (!button || !timer || !cancel) continue; button.classList.remove("recording", "processing"); timer.classList.remove("visible"); cancel.classList.remove("visible"); switch (state) { case "idle": button.innerHTML = ICON_MIC; button.title = "Voice Dictation (Ctrl+Space)"; break; case "recording": button.classList.add("recording"); button.innerHTML = ICON_STOP; button.title = "Stop recording"; timer.textContent = formatTimer(elapsedSeconds2); timer.classList.add("visible"); cancel.classList.add("visible"); break; case "processing": button.classList.add("processing"); button.innerHTML = ICON_SPINNER; button.title = "Transcribing..."; break; } } } function findComposer() { for (const selector of COMPOSER_SELECTORS) { const el = document.querySelector(selector); if (el) { return el; } } return null; } function ensureRelative(el) { if (window.getComputedStyle(el).position === "static") { el.style.position = "relative"; } } function injectIntoElement(parent, onToggle, onCancel, target) { const existing = parent.querySelector(`.${CONTAINER_CLASS}`); if (existing) { return null; } injectStyles(); ensureRelative(parent); const container = createContainer(); const button = container.querySelector(`.${BUTTON_CLASS}`); button.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); onToggle(target); }); const cancel = container.querySelector(`.${CANCEL_CLASS}`); cancel.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); onCancel(); }); parent.appendChild(container); return container; } function injectIntoComposer(onToggle, onCancel) { const composer = findComposer(); if (composer) { injectIntoElement(composer, onToggle, onCancel, "composer"); } } function injectIntoQuestionPrompts(onToggle, onCancel) { const textareas = document.querySelectorAll(QUESTION_INPUT_SELECTOR); for (const textarea of textareas) { const parent = textarea.parentElement; if (parent) { injectIntoElement(parent, onToggle, onCancel, "question"); } } } function setupUI(callbacks) { const observer = new MutationObserver(() => { injectIntoComposer(callbacks.onToggle, callbacks.onCancel); injectIntoQuestionPrompts(callbacks.onToggle, callbacks.onCancel); }); observer.observe(document.body, { childList: true, subtree: true }); injectIntoComposer(callbacks.onToggle, callbacks.onCancel); injectIntoQuestionPrompts(callbacks.onToggle, callbacks.onCancel); return { inject: () => { injectIntoComposer(callbacks.onToggle, callbacks.onCancel); injectIntoQuestionPrompts(callbacks.onToggle, callbacks.onCancel); }, updateState: (state, elapsedSeconds2 = 0) => { updateAllButtonStates(state, elapsedSeconds2); }, toast: showToast }; } let recorder = null; let currentState = "idle"; let timerInterval = null; let elapsedSeconds = 0; let ui = null; let currentTarget = "composer"; function startTimer() { elapsedSeconds = 0; timerInterval = setInterval(() => { elapsedSeconds++; ui == null ? void 0 : ui.updateState(currentState, elapsedSeconds); }, 1e3); } function stopTimer() { if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } } async function toggleDictation(target) { if (currentState === "idle") { currentTarget = target; await startRecording(); } else if (currentState === "recording") { await stopAndTranscribe(); } } async function startRecording() { try { recorder = createAudioRecorder(); await recorder.start(); currentState = "recording"; startTimer(); ui == null ? void 0 : ui.updateState(currentState, elapsedSeconds); } catch (err) { const message = err instanceof Error ? err.message : "Failed to access microphone"; ui == null ? void 0 : ui.toast(message, true); recorder = null; } } async function stopAndTranscribe() { if (!recorder) { return; } stopTimer(); currentState = "processing"; ui == null ? void 0 : 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, currentTarget); if (!inserted) { ui == null ? void 0 : ui.toast("Could not find input field", true); } else if (config.autoSubmit && currentTarget === "composer") { submitPrompt(); } } } catch (err) { const message = err instanceof Error ? err.message : "Transcription failed"; ui == null ? void 0 : ui.toast(message, true); } finally { currentState = "idle"; elapsedSeconds = 0; recorder = null; ui == null ? void 0 : ui.updateState(currentState); } } async function cancelRecording() { if (currentState !== "recording" || !recorder) { return; } stopTimer(); try { await recorder.stop(); } catch { } currentState = "idle"; elapsedSeconds = 0; recorder = null; ui == null ? void 0 : ui.updateState(currentState); } function promptForApiKey() { 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 == null ? void 0 : ui.toast("API key saved!"); } else if (key) { ui == null ? void 0 : ui.toast("Invalid key format. Must start with 'gsk_'", true); } } function promptForModel() { 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 == null ? void 0 : ui.toast(`Model set to ${model}`); } else if (model) { ui == null ? void 0 : ui.toast("Invalid model name", true); } } function promptForLanguage() { const lang = prompt( "Language code (empty for auto-detect, e.g. 'ru', 'en'):", getConfig().language ); if (lang !== null) { setConfig({ language: lang.trim() }); ui == null ? void 0 : ui.toast(lang.trim() ? `Language set to ${lang}` : "Auto-detect enabled"); } } function promptForWhisperPrompt() { const text = prompt("Whisper prompt (context for transcription):", getConfig().whisperPrompt); if (text !== null) { setConfig({ whisperPrompt: text }); ui == null ? void 0 : ui.toast("Whisper prompt updated"); } } function toggleAutoSubmit() { const config = getConfig(); setConfig({ autoSubmit: !config.autoSubmit }); ui == null ? void 0 : ui.toast(`Auto-submit ${!config.autoSubmit ? "enabled" : "disabled"}`); } function checkFirstRun() { if (isFirstRun()) { setTimeout(() => { ui == null ? void 0 : ui.toast("First run: Set your Groq API key via the Violentmonkey/Tampermonkey menu"); }, 2e3); } } function init() { if (!isOpencodePage()) { setTimeout(init, 1500); return; } ui = setupUI({ onToggle: (target) => { void toggleDictation(target); }, onCancel: () => { void cancelRecording(); } }); setupKeyboardShortcut(() => { void toggleDictation(isQuestionPromptOpen() ? "question" : "composer"); }); registerMenuCommands({ onSetKey: promptForApiKey, onToggleAutoSubmit: toggleAutoSubmit, onSetModel: promptForModel, onSetLanguage: promptForLanguage, onSetPrompt: promptForWhisperPrompt }); checkFirstRun(); } init(); })();