/** * public/voice.ts — Web Speech API wrapper for push-to-talk voice input (A2). * * Pure frontend module. Audio NEVER reaches this server — the browser's speech * engine handles it (Chrome sends audio to Google; see SEC-L2 / AC-A2.5). * The final transcript is delivered to the caller, who sends it as raw terminal * input via the existing WS input path (byte-shuttle unchanged). * * Usage: * const voice = createVoiceInput((text) => sendToTerminal(text), { autoSend: true }) * if (!voice) { /* hide mic UI *\/ } * micBtn.addEventListener('touchstart', () => voice.start()) * micBtn.addEventListener('touchend', () => voice.stop()) */ // ── Local SpeechRecognition interface (not in TypeScript 6.x DOM lib) ───────── /** Minimal interface for the Web Speech recognition object. */ interface ISpeechRecognition { continuous: boolean interimResults: boolean lang: string onresult: ((event: ISpeechRecognitionEvent) => void) | null onend: (() => void) | null onerror: ((event: { error: string }) => void) | null start(): void stop(): void abort(): void } interface ISpeechRecognitionEvent { resultIndex: number results: ISpeechRecognitionResultList } interface ISpeechRecognitionResultList { length: number [index: number]: ISpeechRecognitionResult | undefined } interface ISpeechRecognitionResult { isFinal: boolean [index: number]: ISpeechRecognitionAlternative | undefined } interface ISpeechRecognitionAlternative { transcript: string confidence: number } type SpeechRecognitionCtor = new () => ISpeechRecognition type SpeechRecognitionWindow = Window & { SpeechRecognition?: SpeechRecognitionCtor webkitSpeechRecognition?: SpeechRecognitionCtor } // ── Public API ──────────────────────────────────────────────────────────────── /** Options for createVoiceInput. */ export interface VoiceInputOptions { /** Called with intermediate (non-final) text while the user is still speaking. */ onInterim?: (text: string) => void /** * When true, the final transcript is suffixed with \r (carriage return) so * Claude receives it immediately without the user pressing Enter (A2-FR3). * Default: false — insert text only; user presses Enter manually. */ autoSend?: boolean /** * BCP-47 language tag, e.g. 'en-US', 'ja-JP'. * Default: navigator.language (the browser's UI language). */ lang?: string } /** * A live voice recognition session. Obtain via createVoiceInput(). * Call dispose() when the owning component unmounts. */ export interface VoiceInput { /** Begin listening (no-op if already active or disposed). */ start(): void /** Stop listening and emit the final result (no-op if not active). */ stop(): void /** Abort and clean up; no callbacks fire after this call. */ dispose(): void /** True while recognition is in progress. */ isActive(): boolean } /** True if this browser supports the Web Speech API (standard or webkit prefix). */ export function isSpeechSupported(): boolean { if (typeof window === 'undefined') return false return 'SpeechRecognition' in window || 'webkitSpeechRecognition' in window } /** Retrieve the SpeechRecognition constructor, handling the webkit prefix. */ function getSRConstructor(): SpeechRecognitionCtor | undefined { if (!isSpeechSupported()) return undefined const w = window as SpeechRecognitionWindow return w.SpeechRecognition ?? w.webkitSpeechRecognition } /** * Create a push-to-talk voice input session. * * Returns null when the browser does not support SpeechRecognition — the * caller should hide the mic UI in that case (AC-A2.3). * * @param onTranscript Receives the final recognised text (+ '\r' if autoSend). * @param opts See VoiceInputOptions. */ export function createVoiceInput( onTranscript: (text: string) => void, opts?: VoiceInputOptions, ): VoiceInput | null { const SRClass = getSRConstructor() if (!SRClass) return null const recognition = new SRClass() recognition.continuous = false recognition.interimResults = opts?.onInterim !== undefined recognition.lang = opts?.lang ?? navigator.language let active = false let disposed = false recognition.onresult = (event: ISpeechRecognitionEvent) => { if (disposed) return let interimText = '' let finalText = '' for (let i = event.resultIndex; i < event.results.length; i++) { const result = event.results[i] const text = result?.[0]?.transcript ?? '' if (result?.isFinal) { finalText += text } else { interimText += text } } if (interimText && opts?.onInterim) { opts.onInterim(interimText) } if (finalText) { onTranscript(opts?.autoSend ? finalText + '\r' : finalText) } } recognition.onend = () => { active = false } recognition.onerror = () => { active = false } return { start(): void { if (disposed || active) return active = true recognition.start() }, stop(): void { if (disposed || !active) return recognition.stop() active = false }, dispose(): void { if (disposed) return disposed = true if (active) recognition.abort() active = false }, isActive(): boolean { return active }, } }