/** * Playback state for the assistant's spoken reply (BOFF-6290). * * A reducer rather than a pile of `useState` calls, because the awkward part of * speech playback is not "is it speaking" — it is that the engine's `end` and * `error` events arrive AFTER the state has already moved on: * * - tapping speak on message B while A is talking cancels A, and A's `end` * event lands a tick later. Without a guard it would clear B's speaking state * and leave a button that says "stop" next to silence — or worse, silence * next to a button that says "speak" while the voice keeps going; * - closing the panel cancels playback, and the cancellation itself fires * `error` on every queued utterance. * * Every transition therefore carries a monotonically increasing `requestId`, and * a terminal event is only honoured when it belongs to the CURRENT request. That * is the whole trick, and it is pure, so it is tested directly (the audio around * it is not testable in this repo's node-environment vitest). */ export type SpeechPlaybackState = { readonly status: "idle"; } | { readonly status: "speaking"; readonly messageId: string; readonly requestId: number; }; export type SpeechPlaybackAction = /** User pressed speak on a message (or auto-speak fired). */ { readonly type: "start"; readonly messageId: string; readonly requestId: number; } /** User pressed the control on `messageId`: stop if it is the one talking. */ | { readonly type: "toggle"; readonly messageId: string; readonly requestId: number; } /** Explicit stop: the stop button, panel close, conversation switch, unmount. */ | { readonly type: "stop"; } /** The engine finished the queue for `requestId`. */ | { readonly type: "finished"; readonly requestId: number; } /** The engine failed (or was cancelled) on `requestId`. */ | { readonly type: "failed"; readonly requestId: number; }; export declare const IDLE_PLAYBACK: SpeechPlaybackState; export declare function speechPlaybackReducer(state: SpeechPlaybackState, action: SpeechPlaybackAction): SpeechPlaybackState; /** Is THIS message the one currently being spoken? Drives one button's state. */ export declare function isSpeakingMessage(state: SpeechPlaybackState, messageId: string): boolean; /** * Does acting on `messageId` require cancelling what the engine is doing now? * * True whenever anything is playing — including the same message, where the tap * means "stop". The caller cancels first, then speaks; that ordering is what * makes "starting a new playback cancels the previous one" hold with a queue of * utterances rather than a single one. */ export declare function requiresCancel(state: SpeechPlaybackState): boolean; /** * The chunk progress that may be PUBLISHED right now. * * ★★ Progress OUTLIVES the utterance that produced it. Nothing clears * `spokenProgress` — not `stop`, not `finished`, not `failed`, not a * conversation switch — so after any playback ends the provider still holds * that reply's chunks with `index >= 0`. Both caption surfaces take their * synced branch on the STAGE alone (`spokenChunks.index >= 0`), with no message * identity anywhere, so the next reply that does not itself speak is captioned * with the PREVIOUS answer. * * That is the stale-output defect in the correction flow: interrupt Q1, ask a * correction, and if the correction errors — or has no installed voice for its * language, or no speakable text — the cancelled answer is what appears under * "Speaking…". The user reads the words they just cancelled. * * ★ The identity was always available on both sides and simply never compared: * `spokenProgress.requestId` and the speaking state's `requestId`. Gating on it * means progress can never outlive its own utterance — which closes the * interrupt, the natural end, the engine failure and the conversation switch in * ONE place, because all four reduce to `IDLE_PLAYBACK`. Clearing progress * inside `stop()` would close only the first of them, which is why this is a * predicate rather than a reset. */ export declare function publishedChunks(state: SpeechPlaybackState, progress: { requestId: number; chunks: string[]; index: number; } | null): { chunks: string[]; index: number; } | null; //# sourceMappingURL=speechPlayback.d.ts.map