// 语音会话客户端(JARVIS):一条 WebSocket 承载上行麦克风音频 + 下行转写 + 下行 TTS 音频。 // 协议与网关 internal/voice/protocol.go 对齐: // - 二进制帧:上行=麦克风 PCM 16k;下行=TTS PCM 24k // - 文本帧(JSON):控制/事件(ClientMsg / ServerMsg) // // 麦克风采集用 ScriptProcessorNode(WKWebView 通吃,无需单独 worklet 文件),48k→16k 降采样、 // Float32→Int16。下行播放用 AudioBufferSourceNode 排队调度,做无缝连续朗读。 import { GATEWAY, getToken } from "./api"; export type VoiceState = "idle" | "connecting" | "ready" | "listening" | "thinking" | "speaking"; export interface VoiceCallbacks { onState?: (s: VoiceState) => void; onTranscript?: (text: string, final: boolean) => void; onTask?: (taskId: string) => void; onReply?: (deltaText: string) => void; // Agent 回答增量文本(打字机,逐 token) onTurnStart?: () => void; // 新一轮开始(含自动重听)——UI 清上一轮转写/回答 onAction?: (action: string, view: string, taskId: string) => void; // JARVIS 界面动作(navigate 等,白名单执行) onAnnounce?: (text: string) => void; // 主动播报文本(随后照常来 speaking/音频/tts_end) onError?: (msg: string) => void; } // ---- 连续对话的纯决策函数(抽出便于单测;VoiceClient/VoiceDock 共用)---- // shouldSendMic:当前状态是否应上行麦克风音频。对话模式麦克风常开, // 但只在聆听时发——thinking/speaking 不发(防扬声器回声喂给 ASR + 省 ASR 时长计费)。 export function shouldSendMic(state: VoiceState): boolean { return state === "listening"; } // nextAfterDrain:TTS 排队音频播完后的去向。对话模式回聆听继续多轮,否则回 ready 待命。 export function nextAfterDrain(conversation: boolean): VoiceState { return conversation ? "listening" : "ready"; } // 健壮性定时器时长(仅对话模式生效) const IDLE_EXIT_MS = 30_000; // 聆听空转:30s 无任何转写 → 自动退出对话(防 ASR 长连接白烧计费) const THINK_WATCHDOG_MS = 90_000; // 思考看门狗:final 后 90s 没等到朗读 → 回聆听继续对话 const UP_SAMPLE_RATE = 16000; // 上行 ASR 采样率(与网关 voice.AudioSampleRate 一致) const DOWN_SAMPLE_RATE = 24000; // 下行 TTS 采样率(与网关 voice.TTSSampleRate 一致) function wsURL(): string { const base = GATEWAY.replace(/^http/, "ws"); const t = getToken(); const q = t ? `?token=${encodeURIComponent(t)}` : ""; return `${base}/api/v1/voice/stream${q}`; } // float32 → int16 PCM(小端)。 function floatToPCM16(f32: Float32Array): ArrayBuffer { const out = new DataView(new ArrayBuffer(f32.length * 2)); for (let i = 0; i < f32.length; i++) { let s = Math.max(-1, Math.min(1, f32[i])); out.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true); } return out.buffer; } // 线性降采样到 16k(浏览器 AudioContext 常跑 48k,火山 ASR 要 16k)。 function downsampleTo16k(input: Float32Array, inRate: number): Float32Array { if (inRate === UP_SAMPLE_RATE) return input; const ratio = inRate / UP_SAMPLE_RATE; const outLen = Math.floor(input.length / ratio); const out = new Float32Array(outLen); for (let i = 0; i < outLen; i++) out[i] = input[Math.floor(i * ratio)]; return out; } export class VoiceClient { private ws: WebSocket | null = null; private cb: VoiceCallbacks; private state: VoiceState = "idle"; // 上行采集 private micCtx: AudioContext | null = null; private micStream: MediaStream | null = null; private micNode: ScriptProcessorNode | null = null; private micAnalyser: AnalyserNode | null = null; // 下行播放 private playCtx: AudioContext | null = null; private nextStart = 0; // 下一段音频的调度起点(连续朗读用) private sources: AudioBufferSourceNode[] = []; private playAnalyser: AnalyserNode | null = null; private lvlBuf = new Uint8Array(512); // 复用的时域采样缓冲(level() 每帧读,别每帧新建) private drainTimer: number | null = null; // tts_end 后等排队音频放完的定时器 // 对话模式(免按键连续多轮):麦克风常开、VAD 自动断句提交、答完自动重听 private conversation = false; private idleTimer: number | null = null; // 聆听空转定时器 private idleDeadline = 0; // 空转退出时刻(epoch ms),UI 读它画最后 10s 倒计时环 private thinkTimer: number | null = null; // 思考看门狗定时器 constructor(cb: VoiceCallbacks) { this.cb = cb; } private setState(s: VoiceState) { this.state = s; this.cb.onState?.(s); } getState(): VoiceState { return this.state; } // connect 建立 WS,等待服务端 ready。 async connect(): Promise { if (this.ws && this.ws.readyState === WebSocket.OPEN) return; this.setState("connecting"); await new Promise((resolve, reject) => { const ws = new WebSocket(wsURL()); ws.binaryType = "arraybuffer"; this.ws = ws; ws.onmessage = (e) => this.onMessage(e); ws.onerror = () => { this.cb.onError?.("语音连接出错"); reject(new Error("ws error")); }; ws.onclose = () => { this.conversation = false; // 连接断了对话就断,别留定时器空转 this.clearIdleTimer(); this.clearThinkTimer(); this.stopMic(); if (this.state !== "idle") this.setState("idle"); }; ws.onopen = () => resolve(); }); } private onMessage(e: MessageEvent) { if (typeof e.data === "string") { let m: { type: string; text?: string; final?: boolean; task_id?: string; msg?: string; action?: string; view?: string }; try { m = JSON.parse(e.data); } catch { return; } switch (m.type) { case "ready": this.setState("ready"); break; case "transcript": this.cb.onTranscript?.(m.text ?? "", !!m.final); if (m.final) { this.clearIdleTimer(); this.setState("thinking"); this.armThinkTimer(); // 看门狗:后端不回/任务失败/无 TTS 时别永远卡在"思考中" } else if (this.conversation && this.state === "listening") { this.armIdleTimer(); // 有声音活动 → 空转计时重来 } break; case "task": if (m.task_id) this.cb.onTask?.(m.task_id); break; case "reply": if (m.text) this.cb.onReply?.(m.text); // 打字机:回答增量文本 break; case "speaking": this.clearThinkTimer(); this.setState("speaking"); break; case "tts_end": // 别 resetPlayback!服务端"音频发完" ≠ 客户端"播完":音频按 nextStart 预约到未来时刻播, // 而火山合成远快于真实语速,收到 tts_end 时大半音频还排在队列里没播。stop 掉就只剩前几个字。 // 让排队音频自然放完,最后一段结束再回 ready/聆听。 this.clearThinkTimer(); if (this.state === "speaking") { // 对话模式:此刻就补发 start——服务端重建 ASR 的握手(几百毫秒)与排队音频播放重叠, // 音频一播完立刻能听;上行音频门控在 shouldSendMic,回聆听态前不会发。 if (this.conversation) this.send({ type: "start" }); this.drainThenReady(); } else if (this.state === "thinking") { // 没进朗读就结束(TTS 启动失败/无音频):别干等看门狗,立刻进下一轮/回待命。 if (this.conversation) { this.send({ type: "start" }); this.cb.onTurnStart?.(); this.setState("listening"); this.armIdleTimer(); } else { this.setState("ready"); } } // 其余态(已打断回聆听等):忽略——这是上一轮 speak 收尾的迟到 tts_end, // 再发 start 会把用户已开口的新一轮重置掉。 break; case "action": // JARVIS 界面动作(P2 动作通道):交给 UI 层白名单执行,客户端不盲信。 this.cb.onAction?.(m.action ?? "", m.view ?? "", m.task_id ?? ""); break; case "announce": // 主动播报(P3):文本进对话流;音频随后照常走 speaking/二进制帧/tts_end, // 现有状态机零新分支(speaking 会关上行门控,播完 drain 回聆听)。 if (m.text) this.cb.onAnnounce?.(m.text); break; case "error": this.cb.onError?.(m.msg ?? "语音出错"); break; } return; } // 二进制帧 = 下行 TTS 音频(PCM 24k int16) this.enqueueAudio(e.data as ArrayBuffer); } // ---- 上行:麦克风 ---- // startListening 开一轮:连接(若需)→ 若正在朗读先打断 → 采麦克风 → 发 start。 async startListening(graph?: string): Promise { await this.connect(); // 关键:在**用户手势**(点麦克风)里就把播放 AudioContext 建好并 resume——否则它在 WS 回调里 // 惰性创建会处于 suspended 态,TTS 音频静默播不出(Chrome/WKWebView 的自动播放策略)。 this.ensurePlayCtx(); if (this.state === "speaking") this.bargeIn(); this.send({ type: "start", graph }); await this.startMic(); this.setState("listening"); } // ensurePlayCtx 建/复用下行播放 AudioContext,并在 suspended 时 resume(自动播放策略要求手势内唤醒)。 private ensurePlayCtx(): AudioContext { if (!this.playCtx) this.playCtx = new AudioContext(); if (!this.playAnalyser) { // 下行电平探针:所有 TTS 音频经它再到扬声器(驱动 HUD 的"说话"频谱)。 const an = this.playCtx.createAnalyser(); an.fftSize = 512; an.connect(this.playCtx.destination); this.playAnalyser = an; } if (this.playCtx.state === "suspended") void this.playCtx.resume(); return this.playCtx; } // level 返回当前"活跃"音频电平 0..1:说话读下行 TTS,其余读上行麦克风;无探针则 0。 // 供 JARVIS HUD 每帧读取,让可视化随真实声音起伏。 level(): number { const a = this.state === "speaking" ? this.playAnalyser : this.micAnalyser; if (!a) return 0; a.getByteTimeDomainData(this.lvlBuf); let s = 0; for (let i = 0; i < this.lvlBuf.length; i++) { const v = (this.lvlBuf[i] - 128) / 128; s += v * v; } return Math.min(1, Math.sqrt(s / this.lvlBuf.length) * 3.2); } // stopListening 结束本轮说话:停麦克风 + 发 end(服务端拿最终转写→提交任务)。 stopListening(): void { this.stopMic(); this.send({ type: "end" }); if (this.state === "listening") this.setState("thinking"); } // stopPTT 结束本轮说话但保留麦克风上下文:用于按住空格(PTT)松开发送场景。 // 不销毁 micNode/micStream,下次重按 startListening 时 startMic 直接早返回, // 避免 getUserMedia / AudioContext 重建延迟,实现快速重按。 stopPTT(): void { this.send({ type: "end" }); if (this.state === "listening") this.setState("thinking"); } // bargeIn 打断当前朗读(用户又开口)。 bargeIn(): void { this.send({ type: "barge_in" }); this.resetPlayback(); } // ---- 对话模式:免按键连续多轮 ---- inConversation(): boolean { return this.conversation; } // startConversation 进入连续对话:麦克风常开,之后 VAD 自动断句提交、答完自动重听, // 直到 stopConversation(点按退出 / 空转超时)。 async startConversation(graph?: string): Promise { await this.connect(); this.ensurePlayCtx(); // 用户手势内建好播放上下文(自动播放策略) if (this.state === "speaking") this.bargeIn(); this.send({ type: "start", graph }); await this.startMic(); // 失败(无麦/无权限)直接抛给 UI,不进对话模式 this.conversation = true; this.cb.onTurnStart?.(); this.setState("listening"); this.armIdleTimer(); } // stopConversation 退出连续对话:关麦、清定时器、回待命。幂等。 stopConversation(): void { this.conversation = false; this.clearIdleTimer(); this.clearThinkTimer(); this.stopMic(); if (this.state !== "idle") this.setState("ready"); } // interruptAndListen 对话模式里打断朗读并立即回聆听(点按打断,留在对话)。 interruptAndListen(): void { this.bargeIn(); // 服务端掐 TTS + 本地清排队音频/drain 定时器 this.clearThinkTimer(); this.send({ type: "start" }); // 服务端重置轮 + 重建 ASR this.cb.onTurnStart?.(); this.setState("listening"); this.armIdleTimer(); } // ---- 健壮性定时器(仅对话模式)---- private armIdleTimer(): void { this.clearIdleTimer(); this.idleDeadline = Date.now() + IDLE_EXIT_MS; this.idleTimer = window.setTimeout(() => { this.idleTimer = null; this.idleDeadline = 0; if (this.conversation && this.state === "listening") { this.stopConversation(); this.cb.onError?.("长时间没听到声音,已退出连续对话"); } }, IDLE_EXIT_MS); } private clearIdleTimer(): void { this.idleDeadline = 0; if (this.idleTimer !== null) { window.clearTimeout(this.idleTimer); this.idleTimer = null; } } // idleRemainingMs 距空转自动退出还剩多少毫秒;不在「对话中聆听」则返回 null。 // UI 用它画最后 10s 的倒计时环(开口即 armIdleTimer 重置,环自然消失)。 idleRemainingMs(): number | null { if (!this.conversation || this.state !== "listening" || this.idleDeadline === 0) return null; return Math.max(0, this.idleDeadline - Date.now()); } private armThinkTimer(): void { this.clearThinkTimer(); this.thinkTimer = window.setTimeout(() => { this.thinkTimer = null; if (this.state !== "thinking") return; if (this.conversation) { this.cb.onError?.("等回答超时,继续聆听"); this.send({ type: "start" }); this.cb.onTurnStart?.(); this.setState("listening"); this.armIdleTimer(); } else { // PTT:回待命并说清可能原因,别让用户对着"思考中"干等。 this.cb.onError?.("等了很久没有回答——可能后端任务没跑起来(dispatcher/模型未就绪)。去运行页看看这条任务的状态。"); this.setState("ready"); } }, THINK_WATCHDOG_MS); } private clearThinkTimer(): void { if (this.thinkTimer !== null) { window.clearTimeout(this.thinkTimer); this.thinkTimer = null; } } private async startMic(): Promise { if (this.micNode) return; // WKWebView(Wails 原生壳)等非安全上下文里 navigator.mediaDevices 可能未暴露—— // 直接调用会抛 "undefined is not an object" 崩掉。先探测,给出可读提示而非崩溃。 if (!navigator.mediaDevices?.getUserMedia) { throw new Error("此窗口暂不支持麦克风(原生壳需授权/安全上下文)。用浏览器打开 localhost:5173 可直接说话。"); } // 先看有没有麦克风设备:Mac Studio/Mini 等无内置麦克风的机器上,getUserMedia 会以 // OverconstrainedError "Invalid constraint" 报错(误导),提前给准确提示。 let devs: MediaDeviceInfo[] = []; try { devs = await navigator.mediaDevices.enumerateDevices(); } catch { /* 拿不到设备列表就跳过预检,直接试 getUserMedia */ } if (devs.length > 0 && !devs.some((d) => d.kind === "audioinput")) { throw new Error("没检测到麦克风设备。这台机器(如 Mac Studio/Mini)可能无内置麦克风——插个麦克风(带麦耳机 / USB 麦 / AirPods)再试。"); } // 依次尝试多种约束形式:WKWebView(原生壳)对约束挑剔,不同实现接受的形式不同。 // 全失败则把**真实错误名**抛出来(区分是"约束不认"Overconstrained 还是"权限被拒"NotAllowed)。 const tries: MediaStreamConstraints[] = [ { audio: true }, { audio: {} }, { audio: { echoCancellation: true, noiseSuppression: true } }, ]; let stream: MediaStream | null = null; let lastErr: unknown; for (const c of tries) { try { stream = await navigator.mediaDevices.getUserMedia(c); break; } catch (e) { lastErr = e; } } if (!stream) { const err = lastErr as { name?: string; message?: string }; const detail = `${err?.name ?? "Error"}: ${err?.message ?? String(lastErr)}`; if (err?.name === "NotAllowedError" || err?.name === "SecurityError") { throw new Error("麦克风权限被拒。请到 系统设置 → 隐私与安全性 → 麦克风 里允许本应用。"); } throw new Error("麦克风打开失败:" + detail); } this.micStream = stream; const ctx = new AudioContext(); this.micCtx = ctx; if (ctx.state === "suspended") await ctx.resume(); // 防采集上下文挂起(无回调=不上行音频) const src = ctx.createMediaStreamSource(stream); const an = ctx.createAnalyser(); // 上行电平探针(驱动 JARVIS HUD 的"聆听"反应) an.fftSize = 512; src.connect(an); this.micAnalyser = an; const node = ctx.createScriptProcessor(4096, 1, 1); node.onaudioprocess = (ev) => { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; // 对话模式麦克风常开,但只在聆听态上行——thinking/speaking 不发(防回声 + 省 ASR 计费)。 if (!shouldSendMic(this.state)) return; const f32 = ev.inputBuffer.getChannelData(0); const ds = downsampleTo16k(f32, ctx.sampleRate); this.ws.send(floatToPCM16(ds)); }; src.connect(node); node.connect(ctx.destination); // ScriptProcessor 需接上目的地才触发回调(下游静音) this.micNode = node; } private stopMic(): void { this.micNode?.disconnect(); this.micNode = null; this.micAnalyser = null; this.micStream?.getTracks().forEach((t) => t.stop()); this.micStream = null; this.micCtx?.close().catch(() => {}); this.micCtx = null; } // ---- 下行:TTS 音频播放(排队调度,无缝连续)---- private enqueueAudio(buf: ArrayBuffer): void { if (buf.byteLength === 0) return; const ctx = this.ensurePlayCtx(); // 建/复用并 resume(防 WS 回调里上下文仍 suspended → 静默) const i16 = new Int16Array(buf); const f32 = new Float32Array(i16.length); for (let i = 0; i < i16.length; i++) f32[i] = i16[i] / 0x8000; const audioBuf = ctx.createBuffer(1, f32.length, DOWN_SAMPLE_RATE); audioBuf.getChannelData(0).set(f32); const node = ctx.createBufferSource(); node.buffer = audioBuf; node.connect(this.playAnalyser ?? ctx.destination); // 经探针再到扬声器(读得到电平) const now = ctx.currentTime; if (this.nextStart < now) this.nextStart = now; node.start(this.nextStart); this.nextStart += audioBuf.duration; node.onended = () => { this.sources = this.sources.filter((s) => s !== node); }; this.sources.push(node); } private resetPlayback(): void { if (this.drainTimer !== null) { window.clearTimeout(this.drainTimer); this.drainTimer = null; } this.sources.forEach((s) => { try { s.stop(); } catch { /* 已停忽略 */ } }); this.sources = []; this.nextStart = 0; } // drainThenReady 处理 tts_end:不打断,等排队音频按调度自然放完,再去下一站—— // 对话模式回聆听(自动重听,新一轮),否则回 ready 待命。 // speaking 态要维持到真正播完(HUD 频谱靠它读 playAnalyser);播完把队列清干净。 private drainThenReady(): void { if (this.drainTimer !== null) window.clearTimeout(this.drainTimer); const ctx = this.playCtx; const remainMs = ctx ? Math.max(0, (this.nextStart - ctx.currentTime) * 1000) : 0; this.drainTimer = window.setTimeout(() => { this.drainTimer = null; this.sources = []; this.nextStart = 0; if (this.state !== "speaking") return; // 期间被打断/新一轮改了态就不覆盖 const next = nextAfterDrain(this.conversation); if (next === "listening") { this.cb.onTurnStart?.(); // 新一轮:UI 清上一轮转写/回答 this.setState("listening"); // start 已在 tts_end 时提前发,ASR 握手与播放重叠 this.armIdleTimer(); } else { this.setState("ready"); } }, remainMs + 120); // +120ms 余量,等末尾 source 真正 onended } private send(m: { type: string; graph?: string }): void { if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(m)); } // close 彻底关闭会话(发 bye、停采集/播放、断 WS)。 close(): void { this.conversation = false; this.clearIdleTimer(); this.clearThinkTimer(); this.send({ type: "bye" }); this.stopMic(); this.resetPlayback(); this.playCtx?.close().catch(() => {}); this.playCtx = null; this.playAnalyser = null; this.ws?.close(); this.ws = null; this.setState("idle"); } }