/** * The voice-conversation state machine (BOFF-7120). * * A live voice session is a loop — listen, submit, wait, speak, listen again — * and every interesting decision in it is about TIMING and INTERRUPTION rather * than about rendering. Those decisions live here, with no DOM, no audio and no * React, for the same reason `browserSpeechEngine` pushed its logic into * `speakableText`/`speechVoices`: this repo's vitest runs in the `node` * environment, so anything touching `window` cannot be tested at all. * * ★ **The microphone is CLOSED while the assistant speaks.** It was open, so * that talking over the assistant could interrupt it, guarded by matching the * transcript against the text being spoken. That guard was too weak to survive * contact with reality: speech recognition of synthesised speech mis-hears it * ("sow ragi" -> "so ragi"), so exact containment fails, the mis-heard echo is * accepted as a new question, submitted, answered, spoken — and round it goes. * The operator hit this loop on a real device. * * Browsers do not give the Web Speech API the acoustic echo cancellation that a * native duplex app has, so there is no text-level fix for this. Interruption is * now an explicit tap instead, which is reliable, and the echo check survives * only as a guard on the moments just after playback stops. * * ★ **Mute is NOT a phase.** It is a property of the microphone, orthogonal to * where the conversation is. Modelling it as a state meant muting while the * assistant was thinking discarded the `thinking` phase, so the reply — already * in flight — was then ignored, and unmuting landed in `listening` with nothing * pending and nothing coming. The session dead-ended and the user's question was * silently lost. * * What this deliberately does NOT model: the assistant is turn-based * (speech -> text -> LLM -> text -> speech), so `thinking` is a real, visible * phase lasting seconds. A native speech-to-speech API would collapse * listening/thinking/speaking into one duplex stream; we are not that, and * pretending otherwise would hide latency the UI must show. */ /** Where the CONVERSATION is. Independent of whether the mic is capturing. */ export type VoiceSessionPhase = 'idle' | 'listening' | 'thinking' | 'speaking' | 'ended'; export interface VoiceSessionSnapshot { phase: VoiceSessionPhase; /** Microphone off. Orthogonal to `phase` — see the header. */ muted: boolean; /** FINALISED speech for the turn not yet submitted, accumulated. */ pending: string; /** The recogniser's current guess, replaced wholesale until it finalises. */ interim: string; /** Why the session stopped, when it stopped abnormally. */ error: VoiceSessionError | null; } /** * Deliberately does NOT include a "playback blocked" variant. Nothing can * detect it — the speech provider does not surface a per-message playback * refusal — and the watchdog in `useVoiceSession` already recovers by handing * the turn back silently, which continues the conversation rather than killing * it. An error the code cannot raise is dead UI copy. */ export type VoiceSessionError = 'no-speech-support' | 'no-voice-support' | 'mic-denied'; export type VoiceSessionEvent = /** `final` distinguishes a committed phrase from a revisable guess. */ { type: 'start'; } | { type: 'speech'; transcript: string; final: boolean; } /** Nothing heard for `SILENCE_SUBMIT_MS`. */ | { type: 'silence'; } | { type: 'reply'; text: string; } | { type: 'speech-ended'; } | { type: 'mute'; muted: boolean; } /** The user tapped to cut the assistant off. Explicit, not inferred. */ | { type: 'interrupt'; } | { type: 'fail'; error: VoiceSessionError; } | { type: 'end'; }; /** * How long the user must stop talking before the turn is submitted. * * Long enough to survive a mid-sentence breath — a farmer describing a problem * pauses — and short enough that the session does not feel unresponsive. A * premature send truncates the question, which costs a whole 2-5s round trip to * recover from. * * ★ Raised from 1200ms because 1.2s does not survive an ordinary pause: the * operator reported being cut off mid-sentence, and the cost is worse than a * truncated prompt — once the turn is submitted the reducer discards every * `speech` event outside `listening`, so the rest of the sentence is not * queued, it is thrown away, and the assistant answers half a question. * * The asymmetry is what settles the number: waiting 800ms longer costs a moment * of silence, while cutting someone off costs the whole round trip AND makes * them repeat themselves. */ export declare const SILENCE_SUBMIT_MS = 2000; /** * Quiet needed before the pause is SHOWN as a pause. * * The submit timer above is armed the instant there is something submittable — * which is mid-sentence — so it cannot itself say whether the user has stopped. * This is the delay before the stage switches to `endpoint-grace`, leaving the * remaining ~1.6s of the window visibly readable as "still listening, finish * your thought" rather than as the session having already moved on. * * Short enough to be seen, long enough that an ordinary breath between words * does not flicker the stage. ★800, not 400: this file already records that * SILENCE_SUBMIT_MS had to go 1200 -> 2000 because ordinary pauses ran longer * than expected, so a 400ms threshold sits inside normal inter-clause silence * and would oscillate the stage mid-sentence. Still leaves ~1.2s visible. */ export declare const ENDPOINT_GRACE_MS = 800; /** * How long the `interrupting` stage may persist without the microphone coming * back. A ceiling, not a duration: the state normally ends the moment capture * resumes. It exists so that a session whose microphone never reopens — the * failure case — cannot latch this on and hide its own error message. */ export declare const INTERRUPT_MAX_MS = 1500; /** * How long the `interrupting` stage is held even once the recogniser reports * back. * * ★ `isListening` waits for the recogniser's own `onstart` as of #367, so it is * now an honest capture signal. The dwell stays: `onstart` can still fire a beat * before audio is usable, and a session whose microphone never opens must not * latch this on and hide its own error. */ export declare const INTERRUPT_MIN_MS = 500; /** A turn is submitted only when there is something worth sending. */ export declare function isSubmittable(text: string): boolean; /** Everything heard this turn: committed phrases plus the current guess. */ export declare function spokenSoFar(snapshot: VoiceSessionSnapshot): string; export declare function isEcho(transcript: string, spokenText: string | null): boolean; /** * What to do with a reply the app has just observed. * * Pure, because the rule is subtle and the hook that used to hold it has no * test file at all — which is how the original defect shipped. * * Three outcomes: * - already claimed -> do nothing (a re-render, or two identical answers) * - arrived while thinking -> claim it AND advance the turn * - arrived any other time -> ★ CLAIM IT, but do not advance * * That last case is the one that matters. Interrupting stops playback but * nothing cancels the model, so the abandoned answer still lands seconds later * while the phase is `listening`. Leaving it unclaimed meant the NEXT question * found it waiting and spoke the answer to the question the user had cut off. */ export declare function planReplyConsumption(replyId: string | null | undefined, phase: VoiceSessionPhase, consumedReplyId: string | null): { claim: boolean; advance: boolean; }; export declare const INITIAL_SESSION: Readonly; /** * The whole session loop, as one pure reduction. * * `spokenText` is what the assistant is currently saying, supplied so echo can * be told from interruption. It is an argument rather than snapshot state * because it belongs to the speech engine, not to the session. */ export declare function voiceSessionReducer(snapshot: VoiceSessionSnapshot, event: VoiceSessionEvent, spokenText?: string | null): VoiceSessionSnapshot; /** * Is the microphone capturing right now? * * False while the assistant speaks, which is the whole point: an open * microphone during playback is what created the feedback loop. False whenever * muted, whatever the phase. */ export declare function isCapturing(snapshot: VoiceSessionSnapshot): boolean; /** The orb's visual mode. Kept here so the UI has no state logic of its own. */ export declare function orbMode(snapshot: VoiceSessionSnapshot): 'idle' | 'listening' | 'thinking' | 'speaking'; //# sourceMappingURL=voiceSession.d.ts.map