// Voice mode in the browser, one voice room per conversation (p2's /voice/room, bridged to the speech service's // /room). The microphone goes to the room: each finished utterance becomes this person's message in the conversation, // and the others hear it. The owner's browser has the agent's new answers read aloud sentence by sentence (each with // a stable ID, `-`, so the owner's other tabs do not say it twice); the room synthesizes them once and // everyone hears them. A tap on the orb while it speaks stops the speech (for the room when the owner taps, else for // this browser); a message while the agent works stops that run first, and "stop" stops it without a message. // Transcripts wait in order until the conversation accepted them (transcripts.ts), so a link that drops for a while // loses none. // // The audio pipeline is pibot's (badlogic/pibot src/client at 2ef6b69), ported as a unit: its microphone constraints // (applied again after getUserMedia), a microphone AudioContext of its own, the playback stream with its voice chain // and reference tap (playback.ts), its input blocking (0 while speech starts, 500 ms after it ends or is cancelled), and // its barge-in detector and state transitions (barge-in.ts). Only the backend is p2's: the conversation instead of // pibot's server, and the room's speech instead of pibot's TTS frames. // // Barge-in is pibot's detector, while the agent's speech plays (policy.ts): the microphone is buffered, not streamed; // sound the playback does not explain streams the speaker from a second before, while the agent goes on speaking. // Only words interrupt (interruption.ts): once the room's interim (or final) transcript holds a real word, the speech // is cut and the run stopped (outside chat mode), whether the agent spoke or only worked; a trigger the room never // hears as words is dropped. A transcript without words is never sent. // // What happens is also logged to the server (POST /voice/log, batched): a phone's console is out of reach. import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { ConversationView } from "@earendil-works/pi-durable"; import { follow, type Watchable } from "@p2/app/v1"; import { BargeInDetector, type BargeInPhase, Pcm16kResampler } from "./barge-in.ts"; import { Interruption } from "./interruption.ts"; import { SpeechPlayback } from "./playback.ts"; import { bargeInPhase } from "./policy.ts"; import { AGENT_AUDIO, HUMAN_AUDIO, HUMAN_RATE, type LogBatch, MAX_LOG_EVENTS, MAX_LOG_MESSAGE, parseRoomFrame, type RoomCommand, type RoomEvent, type RoomMember, } from "./protocol.ts"; import { isStopCommand, SpeechChunker } from "./speech-text.ts"; import { PendingTranscripts, voiceMessage } from "./transcripts.ts"; /** * `preparing`: an answer's speech is asked for and nothing plays yet; `speaking`: the speech stream is open; * `talking`: someone else in the room is talking. */ export type VoicePhase = | "off" | "connecting" | "listening" | "hearing" | "thinking" | "talking" | "preparing" | "speaking" | "error"; /** The conversation whose voice room this is. */ export type VoiceRoom = { session: string; conversation: number }; /** * What voice mode needs of the conversation: its view, and sending (with a request ID kept across retries, so the * server admits a message once) and stopping as the owner. */ export type VoiceChat = { readonly view: Watchable; /** Chat mode is on: people talk with each other, Pi answers when addressed (`voiceMessage`). */ readonly chatMode: boolean; /** What people call the agent ("Pi" unless the owner renamed it): "Claude, …" addresses it by voice. */ readonly agentName: string; send(text: string, requestId: string): Promise; /** Aborts the run (as the Stop button); the caller's queued inputs come back as text. */ stop(): Promise<{ readonly restored: readonly string[] }>; }; /** The engine and voice of the speech service (IDs from its `/voices`), the speed, and the language (if it has several). */ export type VoiceSettings = { engine: string; voice: string; speed: number; language?: string }; const targetSttSampleRate = 16000; /** The room's largest microphone frame: 16000 bytes of PCM16. */ const MAX_MIC_SAMPLES = 8000; type ExtendedMicAudioConstraints = MediaTrackConstraints & { latency?: ConstrainDouble }; /** pibot's microphone constraints. */ const micAudioConstraints: ExtendedMicAudioConstraints = { echoCancellation: { ideal: true }, noiseSuppression: { ideal: false }, autoGainControl: { ideal: false }, channelCount: { ideal: 1 }, sampleRate: { ideal: 48000 }, latency: { ideal: 0.02 }, }; /** A sentence whose speech has not ended this long after it was asked for is given up (the room queues them). */ const SPEAK_TIMEOUT_MS = 120_000; /** * How many of the owner's sentences wait in the room at a time; the rest wait here. The room refuses more than 32 * pending, and an answer committed at once can bring more: each refused sentence was a gap in the speech. */ const MAX_IN_ROOM = 8; /** How long `AudioContext.resume()` may take before the audio counts as blocked (it stays pending without a gesture). */ const RESUME_WAIT_MS = 1500; /** How long the last transcript and a passing error stay visible. */ const HEARD_MS = 5000; const ERROR_MS = 8000; const AUDIO_BLOCKED = "Audio is blocked: tap the orb"; const LOG_FLUSH_MS = 2000; /** Events kept while the server cannot be reached; older ones are dropped (and counted). */ const LOG_BUFFER = 500; /** How many answer keys `#skip` and `#silenced` keep: older answers are below the baseline or done anyway. */ const KEPT_ANSWERS = 200; /** Adds `key` to `set`, dropping the oldest beyond KEPT_ANSWERS (a Set keeps insertion order). */ function remember(set: Set, key: string): void { set.delete(key); set.add(key); for (const old of set) { if (set.size <= KEPT_ANSWERS) break; set.delete(old); } } /** An agent answer; `entry` is its entry's ID once committed (the streaming one has none yet). */ export type Answer = { key: string; text: string; done: boolean; closed: boolean; entry?: number }; /** * The conversation's agent answers in the view: committed ones, then the one streaming. `closed`: its text so far is * complete, since another part (a tool call, thinking) follows it; its last sentence is read without waiting for the * answer to end (a tool call's arguments may stream for many seconds). */ export function responses(view: ConversationView): Answer[] { const out: Answer[] = []; const add = (message: AssistantMessage, done: boolean, entry?: number) => { const parts = message.content.filter((part) => part.type === "text"); const text = parts.map((part) => part.text).join("\n"); const closed = parts.length > 0 && message.content.at(-1)?.type !== "text"; out.push({ key: String(message.timestamp), text, done, closed, entry }); }; for (const entry of view.entries) { const message = entry.model?.[0]; if (message?.role === "assistant") add(message, true, entry.id); } const live = view.docs["pi.live"] as { run?: unknown; generation?: { message?: AssistantMessage } } | undefined; const partial = live?.generation?.message; if (partial?.role === "assistant") add(partial, false); return out; } /** A function, so that TypeScript does not keep a state narrowed across an `await`. */ const isRunning = (context: AudioContext): boolean => context.state === "running"; const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error)); /** What was there when voice mode started (never read): answers up to this entry, and the one streaming then. */ type Baseline = { entry: number; live: string | undefined }; function baselineOf(view: ConversationView): Baseline { let entry = -1; for (const record of view.entries) entry = Math.max(entry, record.id); return { entry, live: responses(view).find((answer) => !answer.done)?.key }; } export class VoiceEngine { phase: VoicePhase = "off"; /** Loudness 0..1 of what is heard or said now, for the orb. */ level = 0; error: string | undefined; /** The last transcript, for a few seconds after it arrived. */ heard: string | undefined; /** The last error and when it happened (kept after `error` clears, for the settings sheet). */ lastError: { message: string; at: number } | undefined; /** The browser holds the audio back (a suspended AudioContext): a tap on the orb calls `unblock()`. */ audioBlocked = false; readonly #chat: () => VoiceChat | undefined; readonly #settings: () => VoiceSettings; readonly #onChange: () => void; readonly #room: VoiceRoom; readonly #producer: () => boolean; #running = false; #micStream: MediaStream | undefined; #micAudioContext: AudioContext | undefined; #micSource: MediaStreamAudioSourceNode | undefined; #micProcessor: ScriptProcessorNode | undefined; #socket: WebSocket | undefined; #reconnect: ReturnType | undefined; #wakeLock: WakeLockSentinel | undefined; /** The view subscribed to, and how to end the subscription. */ #view: Watchable | undefined; #unsubscribe: (() => void) | undefined; #heardTimer: ReturnType | undefined; #errorTimer: ReturnType | undefined; readonly #bargeInDetector = new BargeInDetector(targetSttSampleRate); readonly #resampler = new Pcm16kResampler(); readonly #interruption = new Interruption({ state: () => { const chat = this.#chat(); return { busy: this.#busy, speaking: this.#speaking(), chatMode: chat?.chatMode ?? false, agentName: chat?.agentName, said: () => this.#recentAnswers(), }; }, cutSpeech: () => this.#muteCurrent("barge-in", true), stopRun: () => { const chat = this.#chat(); if (chat) this.#transcripts.stopping(chat.stop()); }, dropStream: () => { this.#bargeInDetector.resetStreaming(); this.#send({ type: "reset" }); this.#update(); }, log: (message) => this.#log(message), }); /** pibot's `ignoreMicUntil`. */ #ignoreMicUntil = 0; #busy = false; #userSpeaking = false; /** Answers never to read: those present when voice mode started, finished ones, and those talked over. */ readonly #skip = new Set(); readonly #chunkers = new Map(); /** Taken from the view when voice mode starts, or from its first view when it had none then. */ #baseline: Baseline | undefined; /** Utterances asked for (the owner's) or started in the room, and not ended, with their give-up timers. */ readonly #outstanding = new Map>(); /** The owner's sentences not yet sent to the room (MAX_IN_ROOM are there), in order. */ readonly #unsent: { id: string; text: string }[] = []; /** Each utterance's sample rate, from its `tts_start`. */ readonly #rates = new Map(); /** Answers (by key) whose speech this browser no longer plays: talked over, tapped, superseded. */ readonly #silenced = new Set(); /** The next sentence number per answer (the owner's utterance IDs). */ readonly #sentenceIndex = new Map(); /** The room's people, and who of them is talking now. */ readonly #members = new Map(); readonly #talking = new Set(); #me: string | undefined; /** The microphone is muted (each start begins unmuted). */ #muted = false; /** This person's pause before an utterance ends (seconds), sent to the room. */ #pause: number | undefined; /** Numbers the samples: a newer one (or closing) drops an older one still being fetched. */ #samples = 0; readonly #playback = new SpeechPlayback({ log: (message) => this.#log(message), setMicInputBlockedUntil: (time) => { this.#ignoreMicUntil = time; }, onSpeakingChange: () => this.#update(), onPlaybackAudio: (samples, sampleRate) => this.#bargeInDetector.handlePlaybackAudio(samples, sampleRate), onLevel: (level) => { this.level = level; }, onStalled: (context) => { this.#log(`speech stalled: ${this.#describe(context)}`); if (this.#running) this.#blockAudio(); }, onContextState: (context) => { this.#log(`playback context ${this.#describe(context)}`); if (this.#running && context.state === "running" && this.audioBlocked) { this.audioBlocked = false; this.#clearError(); this.#update(); } }, }); readonly #transcripts = new PendingTranscripts({ target: () => this.#chat(), busy: () => this.#busy, log: (message) => this.#log(message), onError: (message) => this.#report(message), onChange: () => this.#update(), }); readonly #onDevices = (): void => void this.#logDevices(); readonly #client = Math.random().toString(36).slice(2, 8); #logs: LogBatch["events"] = []; #dropped = 0; #logTimer: ReturnType | undefined; #flushing = false; readonly #onHide = (): void => { if (document.visibilityState === "hidden") this.#flush(); }; /** * `chat` is asked for the conversation's current reference each time (it is replaced after a server restart). * `producer`: this browser has the answers read aloud and sets the room's voice (the owner's); everyone else only * hears them. */ constructor( room: VoiceRoom, chat: () => VoiceChat | undefined, settings: () => VoiceSettings, onChange: () => void, producer: () => boolean, ) { this.#room = room; this.#chat = chat; this.#settings = settings; this.#onChange = onChange; this.#producer = producer; } /** The people of the room talking now (not this one), by name. */ get talking(): string[] { return [...this.#talking].map((id) => this.#members.get(id)?.name ?? id); } /** This person's "Pause before sending" (seconds of silence that end an utterance): the room is told. */ setPause(seconds: number): void { this.#pause = seconds; this.#sendPause(); } #sendPause(): void { if (this.#pause !== undefined) this.#send({ type: "vad", silence: this.#pause }); } /** The room's voice changed (the owner's choice): the room is told. */ voiceChanged(): void { if (this.#producer()) this.#sendConfig(); } get running(): boolean { return this.#running; } /** The microphone is muted: this person stays in the room and hears it, but sends nothing. */ get muted(): boolean { return this.#muted; } /** * Mutes or unmutes the microphone (voice mode stays on). Muting tells the room to drop what it heard of an * unfinished utterance. */ setMuted(muted: boolean): void { if (muted === this.#muted) return; this.#muted = muted; this.#log(muted ? "microphone muted" : "microphone unmuted"); if (muted) { this.#send({ type: "reset" }); this.#bargeInDetector.resetStreaming(); this.#forgetUtterances(); this.#userSpeaking = false; } this.#update(); } /** Transcripts held back until the conversation is reachable again. */ get waiting(): number { return this.#transcripts.waiting; } /** Starts listening; call from a user gesture (microphone permission, audio playback). */ async start(): Promise { if (this.#running) return; this.#running = true; this.error = undefined; this.audioBlocked = false; this.#muted = false; this.#set("connecting"); this.#baseline = undefined; this.#skip.clear(); const view = this.#currentView(); if (view) this.#prime(view, "at start"); this.#log(`start: view ${view ? "present" : "absent"}`); addEventListener("pagehide", this.#onHide); document.addEventListener("visibilitychange", this.#onHide); try { // pibot's startRecognition. const micStream = await navigator.mediaDevices.getUserMedia({ audio: micAudioConstraints, video: false }); this.#micStream = micStream; if (!this.#running) return this.#release(); const [audioTrack] = micStream.getAudioTracks(); await audioTrack?.applyConstraints(micAudioConstraints).catch((error: unknown) => { this.#log(`mic constraint apply failed: ${messageOf(error)}`); }); const settings = audioTrack?.getSettings(); if (settings) this.#log(`mic settings: ${JSON.stringify(settings)}`); const micAudioContext = new AudioContext(); this.#micAudioContext = micAudioContext; this.#micSource = micAudioContext.createMediaStreamSource(micStream); const micProcessor = micAudioContext.createScriptProcessor(1024, 1, 1); micProcessor.onaudioprocess = (event) => { const input = event.inputBuffer.getChannelData(0); this.#microphone(input, event.inputBuffer.sampleRate); }; this.#micSource.connect(micProcessor); micProcessor.connect(micAudioContext.destination); this.#micProcessor = micProcessor; this.#log(`local STT started: browserRate=${micAudioContext.sampleRate} ${this.#describe(micAudioContext)}`); // A suspended microphone context stops the microphone (its processor no longer runs). micAudioContext.onstatechange = () => { this.#log(`mic context ${this.#describe(micAudioContext)}`); this.#checkContext(micAudioContext); }; // Where the sound goes (labels are readable once the microphone is allowed), now and on every change. void this.#logDevices(); navigator.mediaDevices.addEventListener("devicechange", this.#onDevices); this.#wakeLock = await navigator.wakeLock?.request("screen").catch(() => undefined); } catch (error) { this.#fail(messageOf(error)); return; } if (!this.#running) return; this.rebind(); this.#connect(); this.#checkContext(this.#micAudioContext); } stop(): void { if (!this.#running) return; this.#log("stop"); this.#running = false; this.#transcripts.clear(); this.#release(); this.error = undefined; this.audioBlocked = false; this.#set("off"); this.#flush(); } /** Follows the conversation's current reference: call when it changes. */ rebind(): void { if (!this.#running) return; // What waits goes through the new reference. if (this.#transcripts.size > 0) void this.#transcripts.flush(); const view = this.#chat()?.view; if (view === this.#view) return; this.#unsubscribe?.(); this.#unsubscribe = undefined; this.#view = view; this.#busy = false; this.#log(view ? "conversation attached" : "conversation detached"); try { if (view) this.#unsubscribe = follow(view, (value) => this.#onView(value)); } catch (error) { this.#view = undefined; this.#report(`Conversation unavailable: ${messageOf(error)}`); } this.#update(); } /** Whether an answer is being read (asked for or playing): a tap on the orb then calls `interrupt()`. */ get reading(): boolean { return this.#running && this.#speaking(); } /** Stops reading the answers known so far; voice mode stays on, and the agent keeps working. */ interrupt(): void { if (!this.#speaking()) return; this.#log("tapped: speech stopped"); this.#muteCurrent("tap"); this.#update(); } /** Resumes blocked audio; call from a user gesture (a tap on the orb). */ unblock(): void { this.#log("unblock tapped"); for (const context of [this.#playback.existingContext, this.#micAudioContext]) void context ?.resume() .catch(() => {}) .then(() => this.#checkContext(context)); } /** * Plays the settings sheet's sample of the current voice in this browser only (POST /voice/sample, not the room, * which everyone hears), cutting what this browser plays now. */ sample(): void { // In the tap: the playback context may start here. const context = this.#playback.context(); void context.resume(); this.#cancelSpeech("sample", false, false); const generation = ++this.#samples; void fetch("/voice/sample", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(this.#settings()), }) .then(async (response) => { if (!response.ok) throw new Error(`HTTP ${response.status}`); const buffer = await context.decodeAudioData(await response.arrayBuffer()); if (generation !== this.#samples || this.#playback.context() !== context) return; this.#playback.startPcmStream(buffer.sampleRate); this.#playback.pushAudioBuffer(buffer); void this.#playback.finishPcmStream(); }) .catch((error: unknown) => this.#report(`Sample failed: ${messageOf(error)}`)); } /** Ends samples played without voice mode: closes the playback context. */ endSamples(): void { if (this.#running) return; this.#samples++; this.#playback.close(); } #release(): void { clearTimeout(this.#reconnect); clearTimeout(this.#heardTimer); clearTimeout(this.#errorTimer); this.#unsubscribe?.(); this.#unsubscribe = undefined; this.#view = undefined; this.#socket?.close(); this.#socket = undefined; this.#cancelSpeech("voice mode off"); this.#playback.close(); this.#micProcessor?.disconnect(); this.#micProcessor = undefined; this.#micSource?.disconnect(); this.#micSource = undefined; for (const track of this.#micStream?.getTracks() ?? []) track.stop(); this.#micStream = undefined; if (this.#micAudioContext) this.#micAudioContext.onstatechange = null; void this.#micAudioContext?.close().catch(() => {}); this.#micAudioContext = undefined; void this.#wakeLock?.release().catch(() => {}); this.#wakeLock = undefined; this.#skip.clear(); this.#chunkers.clear(); this.#silenced.clear(); this.#sentenceIndex.clear(); this.#members.clear(); this.#talking.clear(); this.#baseline = undefined; this.#userSpeaking = false; this.#busy = false; this.#bargeInDetector.resetStreaming(); this.#interruption.reset(); this.#resampler.reset(); this.heard = undefined; this.level = 0; removeEventListener("pagehide", this.#onHide); document.removeEventListener("visibilitychange", this.#onHide); navigator.mediaDevices?.removeEventListener("devicechange", this.#onDevices); } #fail(message: string): void { console.error(`[voice] ${message}`); this.#log(`failed: ${message}`); this.#flush(); this.#running = false; this.#release(); this.error = message; this.lastError = { message, at: Date.now() }; this.#set("error"); } /** Shows `message` (for a few seconds unless `sticky`) and logs it. */ #report(message: string, sticky = false): void { console.error(`[voice] ${message}`); this.#log(`error: ${message}`); this.#flush(); this.error = message; this.lastError = { message, at: Date.now() }; clearTimeout(this.#errorTimer); if (!sticky) this.#errorTimer = setTimeout(() => { if (this.error !== message) return; this.error = undefined; this.#update(); }, ERROR_MS); this.#update(); } #clearError(): void { if (this.audioBlocked) return; clearTimeout(this.#errorTimer); this.error = undefined; } /** A context that does not run is resumed if it can be; else the audio counts as blocked. */ #checkContext(context: AudioContext | undefined): void { if (!context || !this.#running) return; if (isRunning(context)) { if (!this.audioBlocked) return; const playback = this.#playback.existingContext; if (this.#micAudioContext && !isRunning(this.#micAudioContext)) return; if (playback && !isRunning(playback)) return; this.audioBlocked = false; this.#clearError(); this.#update(); return; } void this.#resume(context).then((running) => { if (!running && this.#running) this.#blockAudio(); }); } /** Whether `context` runs, resuming it if it can (without a gesture `resume()` may stay pending). */ async #resume(context: AudioContext): Promise { if (isRunning(context)) return true; const started = performance.now(); let timer: ReturnType | undefined; await Promise.race([ context.resume().catch(() => {}), new Promise((resolve) => { timer = setTimeout(resolve, RESUME_WAIT_MS); }), ]); clearTimeout(timer); this.#log(`resume: ${context.state} after ${Math.round(performance.now() - started)} ms`); return isRunning(context); } /** Audio cannot play (or the microphone not run): what is queued is dropped rather than waited for. */ #blockAudio(): void { this.#log("audio blocked"); this.#cancelSpeech("audio blocked"); this.audioBlocked = true; this.#report(AUDIO_BLOCKED, true); } #connect(): void { const { session, conversation } = this.#room; const query = new URLSearchParams({ session, conversation: String(conversation) }); const socket = new WebSocket(`${location.origin.replace(/^http/, "ws")}/voice/room?${query}`); socket.binaryType = "arraybuffer"; this.#socket = socket; socket.onopen = () => { this.#log("room socket open"); this.#update(); }; socket.onmessage = (event) => { if (this.#socket !== socket) return; if (event.data instanceof ArrayBuffer) { this.#onAudio(new Uint8Array(event.data)); return; } try { this.#onEvent(JSON.parse(String(event.data)) as RoomEvent); } catch (error) { console.error("[voice] bad room event", error); } }; socket.onerror = () => { if (this.#socket !== socket) return; console.error("[voice] speech service socket error"); this.#log("socket error"); }; socket.onclose = (event) => { if (this.#socket !== socket || !this.#running) return; this.#log(`socket closed: ${event.code} ${event.reason}`); this.#socket = undefined; this.#userSpeaking = false; this.#forgetUtterances(); this.#bargeInDetector.resetStreaming(); this.#talking.clear(); // What the room was saying ends with it (a new socket gets the current utterance in its welcome). this.#cancelSpeech("room disconnected", false, false); const code = event.code && event.code !== 1000 && event.code !== 1005 ? ` (${event.code})` : ""; this.#report(`Speech service disconnected${code}; reconnecting`, true); this.#reconnect = setTimeout(() => this.#running && this.#connect(), 2000); }; } /** Sends `command` to the room (dropped while it is not connected). */ #send(command: RoomCommand): void { if (this.#socket?.readyState === WebSocket.OPEN) this.#socket.send(JSON.stringify(command)); } /** The owner's voice for the room. */ #sendConfig(): void { const { engine, voice, speed, language } = this.#settings(); this.#send({ type: "config", ...(engine ? { engine } : {}), ...(voice ? { voice } : {}), speed, ...(language ? { language } : {}), }); } /** A binary room frame: the agent's speech, or someone's microphone. */ #onAudio(frame: Uint8Array): void { const audio = parseRoomFrame(frame); if (!audio) return; if (audio.kind === HUMAN_AUDIO) this.#playback.pushHumanAudio(audio.id, audio.pcm, HUMAN_RATE); else if (audio.kind === AGENT_AUDIO && this.#outstanding.has(audio.id) && !this.#isSilenced(audio.id)) this.#playback.pushPcmAudio(audio.pcm, this.#rates.get(audio.id)); } /** The answer an utterance ID belongs to (`-`), if it is one of ours. */ #isSilenced(id: string): boolean { const dash = id.lastIndexOf("-"); return dash > 0 && this.#silenced.has(id.slice(0, dash)); } #onEvent(event: RoomEvent): void { if (event.type === "welcome") { this.#log(`welcome: room ${event.room} as ${event.you.id}, ${event.members.length} members`); this.#clearError(); this.#me = event.you.id; this.#members.clear(); for (const member of event.members) this.#members.set(member.id, member); this.#sendPause(); if (this.#producer()) this.#sendConfig(); // Joined mid-utterance: its remaining audio follows. if (event.current) this.#onSpeechStart(event.current.id, event.current.sampleRate); } else if (event.type === "member_joined" || event.type === "member_updated") this.#members.set(event.member.id, event.member); else if (event.type === "member_left") { this.#members.delete(event.member.id); this.#talking.delete(event.member.id); } else if (event.type === "config") this.#log(`room voice ${event.engine}/${event.voice} ${event.speed}`); else if (event.type === "vad") this.#log(`pause before sending: ${event.silence} s`); else if (event.type === "speak_ack") { if (event.duplicate) this.#log(`sentence ${event.id}: already said (another tab)`); } else if (event.type === "tts_start") this.#onSpeechStart(event.id, event.sampleRate); else if (event.type === "tts_done") this.#onSpeechEnd(event.id, event.cancelled ? "cancelled" : "done"); else if (event.type === "tts_error") { this.#report(`Speech failed: ${event.message}`); this.#onSpeechEnd(event.id, "failed"); } else if (event.type === "talking") { if (event.participant === this.#me) return; if (event.on) this.#talking.add(event.participant); else this.#talking.delete(event.participant); } else if (event.type === "speech_start") { this.#log(`speech_start${this.#speaking() ? " (while speaking)" : ""}`); this.#userSpeaking = true; this.#interruption.speechStart(); } else if (event.type === "speech_end") { // The next utterance may start before this one's final arrives: speaking follows the VAD, not the finals. this.#log("speech_end"); this.#userSpeaking = false; } else if (event.type === "interim") { this.#log(`interim: ${event.text.length} chars, decoded in ${event.ms} ms`); this.#interruption.interim(event.text); } else if (event.type === "error") { if (event.id !== undefined) this.#onSpeechEnd(event.id, "failed"); // Frames over the room's rate are dropped there; worth a log line, not an alarm. if (event.code === "mic_rate") this.#log(`room: ${event.message}`); else this.#report(event.message); } else if (event.type === "final") { const text = event.text.trim(); this.#log(`final: ${text.length} chars, decoded in ${event.ms} ms`); // Words, or nothing: noise and the agent's own speech heard back are never sent; a final the next utterance // already follows waits for it (interruption.ts). const message = this.#interruption.final(text); if (message.text) this.#deliver(message.text, message.stopped); } this.#update(); } /** Forgets the utterances in progress; what was held of them is still this person's message. */ #forgetUtterances(): void { const held = this.#interruption.reset(); if (held.text) this.#deliver(held.text, held.stopped); } /** A final transcript with words: a stop command, or this person's message. */ #deliver(text: string, stopped: boolean): void { this.#hear(text); // "Stop" whose interim already stopped the agent is still a stop, not a message. if (isStopCommand(text) && (stopped || this.#busy || this.#speaking())) { this.#log("stop command: stopping"); this.#muteCurrent("stop command"); void this.#chat() ?.stop() .then(({ restored }) => this.#log(restored.length > 0 ? `stopped; ${restored.length} queued message(s) withdrawn` : "stopped"), ) .catch((error: unknown) => this.#report(`Stop failed: ${messageOf(error)}`)); return; } const chat = this.#chat(); const message = voiceMessage(text, chat?.chatMode ?? false, chat?.agentName); // What Pi was saying is superseded by a message for Pi (in chat mode, people talking with each other leave Pi // be). if (message.interrupts) this.#muteCurrent("new message"); // Talking to Pi interrupts, as in a conversation: a busy run is stopped before the first such message (unless // its words stopped it already), so it is answered now. Work handed to a doer subagent keeps going. this.#transcripts.push(message.text, message.interrupts && !stopped); } #hear(text: string): void { this.heard = text; clearTimeout(this.#heardTimer); this.#heardTimer = setTimeout(() => { this.heard = undefined; this.#update(); }, HEARD_MS); } /** The phase as pibot's detector knows it (its robot phases). */ #bargeInPhase(): BargeInPhase { return bargeInPhase({ hearing: this.#userSpeaking || this.#bargeInDetector.isStreaming(), playing: this.#playback.speaking, }); } /** pibot's sendMicAudio. */ #microphone(input: Float32Array, sampleRate: number): void { // Muted: nothing is sent, and the barge-in detector neither buffers nor listens. if (this.#muted) { if (!this.#playback.speaking) this.level = 0; return; } if (!this.#playback.speaking) { let sum = 0; for (const sample of input) sum += sample * sample; this.level = Math.min(1, Math.sqrt(sum / input.length) * 8); } const socket = this.#socket; if (!this.#running || socket?.readyState !== WebSocket.OPEN) return; const pcm = this.#resampler.push(input, sampleRate); this.#interruption.tick(); const phase = this.#bargeInPhase(); const barge = this.#bargeInDetector.observeMic(input, sampleRate, pcm, phase, this.#playback.speaking); if (barge.triggered) { const metrics = barge.metrics ? ` rms=${barge.metrics.micRms.toFixed(4)} residual=${barge.metrics.residualRatio.toFixed(3)}` : ""; this.#log(`barge-in during ${phase}: metrics${metrics}`); this.#startBargeInStream(socket, barge.preroll ?? new Int16Array()); return; } if ( this.#bargeInDetector.isStreaming() || (Date.now() >= this.#ignoreMicUntil && this.#bargeInDetector.shouldStreamMicNormally(phase)) ) socket.send(pcm); } /** * pibot's startBargeInStream, except that nothing stops yet: the speaker streams from a second before while the * agent goes on speaking, and only words the room hears interrupt (interruption.ts). */ #startBargeInStream(socket: WebSocket, preroll: Int16Array): void { this.#interruption.trigger(); this.#ignoreMicUntil = 0; // The room takes microphone frames of at most 16000 bytes: the second of preroll goes in parts. for (let at = 0; at < preroll.length; at += MAX_MIC_SAMPLES) socket.send(preroll.slice(at, Math.min(preroll.length, at + MAX_MIC_SAMPLES))); this.#log("barge-in candidate: forwarded mic preroll"); this.#update(); } /** The text of the latest answers, which the agent may be saying now (to tell its echo from the speaker). */ #recentAnswers(): string[] { const view = this.#currentView(); return view ? responses(view) .slice(-2) .map((answer) => answer.text) : []; } #onView(view: ConversationView | null): void { if (!view) return; const live = view.docs["pi.live"] as { run?: unknown } | undefined; this.#busy = Boolean(live?.run); // Only answers that start after voice mode did are read, and only by the owner's browser (the room says them). if (!this.#baseline) this.#prime(view, "on the first view"); if (!this.#producer()) { this.#update(); return; } const baseline = this.#baseline; for (const answer of responses(view)) { if (this.#skip.has(answer.key)) continue; if ( baseline && (answer.key === baseline.live || (answer.entry !== undefined && answer.entry <= baseline.entry)) ) { remember(this.#skip, answer.key); continue; } let chunker = this.#chunkers.get(answer.key); if (!chunker) { chunker = new SpeechChunker(); this.#chunkers.set(answer.key, chunker); this.#log(`answer ${answer.key}: new${answer.entry === undefined ? "" : ` (entry ${answer.entry})`}`); } const sentences = chunker.push(answer.text, answer.done || answer.closed); if (this.#producer()) for (const sentence of sentences) this.#enqueue(answer.key, sentence); if (sentences.length > 0 || answer.done) this.#log(`answer ${answer.key}: ${sentences.length} sentences queued${answer.done ? ", done" : ""}`); if (answer.done) { this.#chunkers.delete(answer.key); this.#sentenceIndex.delete(answer.key); remember(this.#skip, answer.key); } } this.#endStreamWhenDone(); this.#update(); } #prime(view: ConversationView, when: string): void { const baseline = baselineOf(view); this.#baseline = baseline; const old = responses(view).length; this.#log( `primed ${when}: ${old} answers present, up to entry ${baseline.entry}${baseline.live ? `, streaming ${baseline.live}` : ""}`, ); } #currentView(): ConversationView | null { try { return this.#chat()?.view.value ?? null; } catch { // The reference is not live. return null; } } /** Stops reading, and never reads the rest of the answers known so far. */ #muteCurrent(reason: string, bargeIn = false): void { if (this.#speaking() || this.#chunkers.size > 0) this.#log(`muted (${reason}): ${[...this.#chunkers.keys()].join(", ") || "the queue"}`); for (const key of this.#chunkers.keys()) { remember(this.#skip, key); remember(this.#silenced, key); } this.#chunkers.clear(); let view: ConversationView | null = null; try { view = this.#view?.value ?? null; } catch { // The reference ended; its answers are skipped once it is back (they are known by then). } for (const answer of view ? responses(view) : []) { remember(this.#skip, answer.key); remember(this.#silenced, answer.key); } // What plays now, too (a guest may not know its answer yet). for (const id of this.#outstanding.keys()) { const dash = id.lastIndexOf("-"); if (dash > 0) remember(this.#silenced, id.slice(0, dash)); } this.#cancelSpeech(reason, bargeIn); } #speaking(): boolean { return this.#playback.speaking || this.#outstanding.size > 0 || this.#unsent.length > 0; } /** Has the room say the `answer`'s next sentence (the owner's), under its stable ID. */ #enqueue(answer: string, text: string): void { const index = this.#sentenceIndex.get(answer) ?? 0; this.#sentenceIndex.set(answer, index + 1); const id = `${answer}-${index}`; // The room drops commands while it reconnects: a sentence asked for then is lost, not waited for (120 s). if (this.#socket?.readyState !== WebSocket.OPEN) { this.#log(`sentence ${id}: room not connected, skipped`); return; } this.#unsent.push({ id, text }); this.#sendSentences(); this.#update(); } /** Sends waiting sentences while the room holds fewer than MAX_IN_ROOM of them. */ #sendSentences(): void { while (this.#outstanding.size < MAX_IN_ROOM) { const next = this.#unsent.shift(); if (!next) return; const { id, text } = next; this.#expect(id); this.#send({ type: "speak", id, text }); this.#log(`sentence ${id}: ${text.length} chars, asked`); } } /** An utterance to wait for (asked, or started in the room), given up if it never ends. */ #expect(id: string): void { if (this.#outstanding.has(id)) return; this.#outstanding.set( id, setTimeout(() => { if (!this.#outstanding.has(id)) return; this.#log(`sentence ${id}: never ended; given up`); this.#onSpeechEnd(id, "failed"); }, SPEAK_TIMEOUT_MS), ); } #onSpeechStart(id: string, sampleRate: number): void { if (this.#isSilenced(id)) return; this.#expect(id); this.#rates.set(id, sampleRate); this.#log(`sentence ${id}: start ${sampleRate} Hz`); if (!this.#playback.speaking) this.#playback.startPcmStream(sampleRate); this.#update(); } #onSpeechEnd(id: string, result: "done" | "cancelled" | "failed"): void { clearTimeout(this.#outstanding.get(id)); if (!this.#outstanding.delete(id)) return; this.#rates.delete(id); this.#log(`sentence ${id}: ${result}`); if (this.#socket?.readyState === WebSocket.OPEN) this.#sendSentences(); this.#endStreamWhenDone(); this.#update(); } /** Nothing more to say for now (no utterance pending, no answer still streaming): the stream ends as it plays out. */ #endStreamWhenDone(): void { if (this.#outstanding.size > 0 || this.#unsent.length > 0 || this.#chunkers.size > 0 || !this.#playback.speaking) return; void this.#playback.finishPcmStream(); } /** * pibot's cancelSpeech (and cancelSpeechForBargeIn): drops what is pending and what plays. The owner's also cancels * the room's (everyone stops hearing it), unless only this browser's playback ends (`room` false). */ #cancelSpeech(reason: string, bargeIn = false, room = true): void { if (room && this.#producer() && this.#outstanding.size > 0) this.#send({ type: "cancel" }); for (const timer of this.#outstanding.values()) clearTimeout(timer); this.#outstanding.clear(); this.#unsent.length = 0; this.#rates.clear(); if (bargeIn) this.#playback.cancelSpeechForBargeIn(); else this.#playback.cancelSpeech(reason); } /** The audio devices by label (never their IDs), and the output the playback context uses where the browser says. */ async #logDevices(): Promise { try { const devices = await navigator.mediaDevices.enumerateDevices(); const labels = (kind: MediaDeviceKind) => devices .filter((device) => device.kind === kind) .map((device) => device.label || "(no label)") .join(" | ") || "none listed"; const context = this.#playback.existingContext as (AudioContext & { sinkId?: unknown }) | undefined; const sink = !context ? "no playback context yet" : !("sinkId" in context) ? "sinkId not exposed" : context.sinkId === "" ? "sinkId default" : typeof context.sinkId === "string" ? "sinkId chosen device" : "sinkId none"; this.#log(`devices: outputs ${labels("audiooutput")}; inputs ${labels("audioinput")}; ${sink}`); } catch (error) { this.#log(`devices unknown: ${messageOf(error)}`); } } #describe(context: AudioContext): string { const latency = (seconds: number | undefined) => (seconds === undefined ? "?" : Math.round(seconds * 1000)); return `${context.state} ${context.sampleRate} Hz, latency ${latency(context.baseLatency)}+${latency(context.outputLatency)} ms`; } /** A diagnostic event for the server's log (sent within 2 s, at once after an error). */ #log(message: string): void { console.debug(`[voice] ${message}`); this.#logs.push({ t: Date.now(), message: message.slice(0, MAX_LOG_MESSAGE) }); if (this.#logs.length > LOG_BUFFER) this.#dropped += this.#logs.splice(0, this.#logs.length - LOG_BUFFER).length; this.#logTimer ??= setTimeout(() => this.#flush(), LOG_FLUSH_MS); } #flush(): void { clearTimeout(this.#logTimer); this.#logTimer = undefined; if (this.#flushing || this.#logs.length === 0) return; if (this.#dropped > 0) { this.#logs.unshift({ t: Date.now(), message: `${this.#dropped} earlier events dropped` }); this.#dropped = 0; } const events = this.#logs.splice(0, MAX_LOG_EVENTS); const body: LogBatch = { client: this.#client, events }; this.#flushing = true; // `keepalive`: a batch sent as the page goes away still arrives. void fetch("/voice/log", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), keepalive: true, }) .then((response) => { // A refusal is final; a server error is tried again. if (response.status >= 500) throw new Error(`HTTP ${response.status}`); if (!response.ok) console.debug(`[voice] log refused (HTTP ${response.status})`); }) .catch((error: unknown) => { console.debug("[voice] log not sent", error); // Kept for the next try (the server may be restarting). this.#logs.unshift(...events); }) .finally(() => { this.#flushing = false; if (this.#logs.length > 0) this.#logTimer ??= setTimeout(() => this.#flush(), LOG_FLUSH_MS); }); } #update(): void { if (!this.#running) return; const phase: VoicePhase = this.#socket?.readyState !== WebSocket.OPEN ? "connecting" : this.#playback.speaking ? "speaking" : this.#userSpeaking ? "hearing" : this.#outstanding.size > 0 ? "preparing" : this.#talking.size > 0 ? "talking" : this.#busy ? "thinking" : "listening"; // pibot: the detector's barge-in stream ends when the phase leaves "hearing". // A barge-in candidate streams while the agent speaks, until words confirm it or it is dropped. if (phase !== "hearing" && !this.#userSpeaking && !this.#interruption.pending) this.#bargeInDetector.resetStreaming(); this.#set(phase); } #set(phase: VoicePhase): void { this.phase = phase; this.#onChange(); } }