Files
sundynix-agentix/sundynix-desktop/frontend/src/lib/voice.ts
T
Blizzard c66d178efe fix(voice): 思考看门狗覆盖 PTT 模式(别永远卡在"思考中")
此前 armThinkTimer 只在连续对话模式挂,PTT 下后端不回(dispatcher 未就绪/
模型没配/任务失败)就永远卡在"思考中",用户干等且毫无线索。

改:任何模式收到 final 转写都挂看门狗;超时后连续对话回聆听、PTT 回待命,
并明确提示可能原因 + 引导去运行页看任务状态。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 14:57:18 +08:00

527 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 语音会话客户端(JARVIS):一条 WebSocket 承载上行麦克风音频 + 下行转写 + 下行 TTS 音频。
// 协议与网关 internal/voice/protocol.go 对齐:
// - 二进制帧:上行=麦克风 PCM 16k;下行=TTS PCM 24k
// - 文本帧(JSON):控制/事件(ClientMsg / ServerMsg
//
// 麦克风采集用 ScriptProcessorNodeWKWebView 通吃,无需单独 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";
}
// nextAfterDrainTTS 排队音频播完后的去向。对话模式回聆听继续多轮,否则回 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<void> {
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
this.setState("connecting");
await new Promise<void>((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<void> {
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<void> {
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<void> {
if (this.micNode) return;
// WKWebViewWails 原生壳)等非安全上下文里 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");
}
}