## Changes ### Cancel button (✕) - New cancel button appears during recording next to stop button - Tapping ✕ stops recording and **discards audio** — no text inserted - Tapping ⏹ stops recording and **inserts text** (as before) ### Question prompt support - Mic button injected into `[data-slot=question-custom-input]` (custom answer textarea) - Text insertion via `selectionStart`/`selectionEnd` (textarea ≠ contenteditable) - MutationObserver detects when question prompt appears ### Solid timer - Timer background: `#e53935` (opaque red) with white text - Was `rgba(229, 57, 53, 0.1)` (semi-transparent — text showed through) - Now fully visible over any text ### Technical - Refactored UI to use CSS classes instead of single IDs (supports multiple buttons) - `insertText()` now takes `target: "composer" | "question"` - 47 tests, 100% line coverage Co-authored-by: opencode-agent <agent@slaid098.dev>
200 lines
5 KiB
TypeScript
200 lines
5 KiB
TypeScript
import { type AudioRecorder, createAudioRecorder } from "./audio.js";
|
|
import {
|
|
getConfig,
|
|
isFirstRun,
|
|
registerMenuCommands,
|
|
setConfig,
|
|
validateApiKey,
|
|
} from "./config.js";
|
|
import { type InsertTarget, 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;
|
|
let currentTarget: InsertTarget = "composer";
|
|
|
|
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(target: InsertTarget): Promise<void> {
|
|
currentTarget = target;
|
|
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, currentTarget);
|
|
if (!inserted) {
|
|
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?.toast(message, true);
|
|
} finally {
|
|
currentState = "idle";
|
|
elapsedSeconds = 0;
|
|
recorder = null;
|
|
ui?.updateState(currentState);
|
|
}
|
|
}
|
|
|
|
async function cancelRecording(): Promise<void> {
|
|
if (currentState !== "recording" || !recorder) {
|
|
return;
|
|
}
|
|
|
|
stopTimer();
|
|
|
|
try {
|
|
await recorder.stop();
|
|
} catch {
|
|
// discard audio
|
|
}
|
|
|
|
currentState = "idle";
|
|
elapsedSeconds = 0;
|
|
recorder = null;
|
|
ui?.updateState(currentState);
|
|
ui?.toast("Recording cancelled");
|
|
}
|
|
|
|
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({
|
|
onToggle: (target: InsertTarget) => {
|
|
void toggleDictation(target);
|
|
},
|
|
onCancel: () => {
|
|
void cancelRecording();
|
|
},
|
|
});
|
|
|
|
setupKeyboardShortcut(() => {
|
|
void toggleDictation("composer");
|
|
});
|
|
|
|
registerMenuCommands({
|
|
onSetKey: promptForApiKey,
|
|
onToggleAutoSubmit: toggleAutoSubmit,
|
|
onSetModel: promptForModel,
|
|
onSetLanguage: promptForLanguage,
|
|
onSetPrompt: promptForWhisperPrompt,
|
|
});
|
|
|
|
checkFirstRun();
|
|
}
|
|
|
|
init();
|