feat(voice): 提升桌面端语音交互与本地任务执行支持
This commit is contained in:
@@ -15,9 +15,38 @@ export interface VoiceCallbacks {
|
||||
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";
|
||||
}
|
||||
|
||||
// micTapAction:点按麦克风的语义(对话开关)。
|
||||
// 聆听中点 → 退出对话;朗读中点(在对话里)→ 打断但留在对话;其余 → 开始对话。
|
||||
export type MicTapAction = "start" | "stop" | "interrupt";
|
||||
export function micTapAction(state: VoiceState, inConversation: boolean): MicTapAction {
|
||||
if (state === "listening") return "stop";
|
||||
if (state === "speaking" && inConversation) return "interrupt";
|
||||
return "start";
|
||||
}
|
||||
|
||||
// 健壮性定时器时长(仅对话模式生效)
|
||||
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 一致)
|
||||
|
||||
@@ -67,6 +96,12 @@ export class VoiceClient {
|
||||
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;
|
||||
}
|
||||
@@ -94,6 +129,9 @@ export class VoiceClient {
|
||||
reject(new Error("ws error"));
|
||||
};
|
||||
ws.onclose = () => {
|
||||
this.conversation = false; // 连接断了对话就断,别留定时器空转
|
||||
this.clearIdleTimer();
|
||||
this.clearThinkTimer();
|
||||
this.stopMic();
|
||||
if (this.state !== "idle") this.setState("idle");
|
||||
};
|
||||
@@ -103,7 +141,7 @@ export class VoiceClient {
|
||||
|
||||
private onMessage(e: MessageEvent) {
|
||||
if (typeof e.data === "string") {
|
||||
let m: { type: string; text?: string; final?: boolean; task_id?: string; msg?: 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 {
|
||||
@@ -115,7 +153,13 @@ export class VoiceClient {
|
||||
break;
|
||||
case "transcript":
|
||||
this.cb.onTranscript?.(m.text ?? "", !!m.final);
|
||||
if (m.final) this.setState("thinking");
|
||||
if (m.final) {
|
||||
this.clearIdleTimer();
|
||||
this.setState("thinking");
|
||||
if (this.conversation) 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);
|
||||
@@ -124,13 +168,41 @@ export class VoiceClient {
|
||||
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.drainThenReady();
|
||||
// 让排队音频自然放完,最后一段结束再回 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 ?? "语音出错");
|
||||
@@ -197,6 +269,96 @@ export class VoiceClient {
|
||||
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.conversation && this.state === "thinking") {
|
||||
this.cb.onError?.("等回答超时,继续聆听");
|
||||
this.send({ type: "start" });
|
||||
this.cb.onTurnStart?.();
|
||||
this.setState("listening");
|
||||
this.armIdleTimer();
|
||||
}
|
||||
}, 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;
|
||||
// WKWebView(Wails 原生壳)等非安全上下文里 navigator.mediaDevices 可能未暴露——
|
||||
@@ -253,6 +415,8 @@ export class VoiceClient {
|
||||
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));
|
||||
@@ -313,7 +477,8 @@ export class VoiceClient {
|
||||
this.nextStart = 0;
|
||||
}
|
||||
|
||||
// drainThenReady 处理 tts_end:不打断,等排队音频按调度自然放完,最后回 ready。
|
||||
// drainThenReady 处理 tts_end:不打断,等排队音频按调度自然放完,再去下一站——
|
||||
// 对话模式回聆听(自动重听,新一轮),否则回 ready 待命。
|
||||
// speaking 态要维持到真正播完(HUD 频谱靠它读 playAnalyser);播完把队列清干净。
|
||||
private drainThenReady(): void {
|
||||
if (this.drainTimer !== null) window.clearTimeout(this.drainTimer);
|
||||
@@ -323,7 +488,15 @@ export class VoiceClient {
|
||||
this.drainTimer = null;
|
||||
this.sources = [];
|
||||
this.nextStart = 0;
|
||||
if (this.state === "speaking") this.setState("ready"); // 期间被打断/新一轮改了态就不覆盖
|
||||
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
|
||||
}
|
||||
|
||||
@@ -333,6 +506,9 @@ export class VoiceClient {
|
||||
|
||||
// close 彻底关闭会话(发 bye、停采集/播放、断 WS)。
|
||||
close(): void {
|
||||
this.conversation = false;
|
||||
this.clearIdleTimer();
|
||||
this.clearThinkTimer();
|
||||
this.send({ type: "bye" });
|
||||
this.stopMic();
|
||||
this.resetPlayback();
|
||||
|
||||
Reference in New Issue
Block a user