Files
sundynix-agentix/sundynix-desktop/frontend/src/lib/voice.ts
T
Blizzard fe5c4215f7 fix(voice): 麦克风约束兜底——WKWebView 不认高级约束("Invalid constraint")退 {audio:true}
原生壳(Wails WKWebView)的 getUserMedia 不认 channelCount/echoCancellation 等高级约束,
抛 "Invalid constraint"。先试高级约束(浏览器更好),失败退回最简 {audio:true}(最兼容)。
配合上一提交的 .app 打包+NSMicrophoneUsageDescription,原生壳麦克风打通。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 14:47:19 +08:00

299 lines
11 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)
onError?: (msg: string) => void;
}
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() 每帧读,别每帧新建)
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.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 };
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.setState("thinking");
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.setState("speaking");
break;
case "tts_end":
this.resetPlayback();
this.setState("ready");
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");
}
// bargeIn 打断当前朗读(用户又开口)。
bargeIn(): void {
this.send({ type: "barge_in" });
this.resetPlayback();
}
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 可直接说话。");
}
// 先试带回声消除等约束(浏览器更好),WKWebView(原生壳)不认高级约束会抛 "Invalid constraint"
// 退回最简 {audio:true}(最兼容;多数实现默认已开回声消除)。
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1 },
});
} catch {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
}
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;
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 {
this.sources.forEach((s) => {
try {
s.stop();
} catch {
/* 已停忽略 */
}
});
this.sources = [];
this.nextStart = 0;
}
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.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");
}
}