Files
web-terminal/public/voice.ts
Yaojia Wang e4c327e25e feat: voice command mapping — context-gated approve/reject over voice
While a tool-permission gate is held on the active tab, spoken confirm
phrases resolve it via the existing approve/reject WS channel — no server
change (byte-shuttle intact). Everything else stays ordinary dictation.

Safety: whole-utterance-exact matching + leading-negation guard + reject
precedence + confidence gate (approve only) + a cancellable 1.5s confirm
window whose commit re-validates gate identity/epoch/connectivity (TOCTOU
guard) + stale-gate epoch bound at PTT-start.

New pure modules public/voice-commands.ts and public/voice-confirm.ts at
100% coverage; wiring in voice/terminal-session/tabs/main. 1307 tests pass,
typecheck clean, build:web OK. Independent code + security reviews flagged a
confirm-window TOCTOU and an unwired cancel path — both fixed and covered.

Plan: docs/PLAN_VOICE_COMMANDS.md.
2026-07-01 10:38:32 +02:00

188 lines
5.9 KiB
TypeScript

/**
* 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),
* plus the top alternative's ASR confidence (0-1) as an
* optional 2nd arg when the engine reports one (VC decision D).
* @param opts See VoiceInputOptions.
*/
export function createVoiceInput(
onTranscript: (text: string, confidence?: number) => 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 = ''
let finalConfidence: number | undefined
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i]
const alt = result?.[0]
const text = alt?.transcript ?? ''
if (result?.isFinal) {
finalText += text
// VC (decision D): forward the top alternative's confidence so the
// caller can gate risk-asymmetric decisions (e.g. approve). Still
// transcription-only — no decision-making happens in this module.
finalConfidence = alt?.confidence ?? finalConfidence
} else {
interimText += text
}
}
if (interimText && opts?.onInterim) {
opts.onInterim(interimText)
}
if (finalText) {
onTranscript(opts?.autoSend ? finalText + '\r' : finalText, finalConfidence)
}
}
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
},
}
}