From e60679ed7bb19ef0e1f85e15e0de378a205f2a5e Mon Sep 17 00:00:00 2001 From: Blizzard Date: Sat, 25 Jul 2026 14:52:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(voice):=20PTT=20=E6=94=B9=E4=B8=BA=E4=B8=BB?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E2=80=94=E2=80=94=E6=8C=89=E4=BD=8F=E8=AF=B4?= =?UTF-8?q?=E8=AF=9D=E6=9D=BE=E5=BC=80=E5=8F=91=E9=80=81(=E4=BF=AE=20PTT?= =?UTF-8?q?=20=E5=BD=A2=E5=90=8C=E8=99=9A=E8=AE=BE=E7=9A=84=20bug)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VAD 自动断句在真实环境不够稳(环境音/停顿都会误触发),改回按住说话。 修掉 PTT 之前根本不生效的三处: - keydown 里 `if (!c) return`——用户没先点过麦克风按钮时 clientRef 为 null, 按空格什么都不发生。改为懒建客户端(keydown 本身就是用户手势,可启 AudioContext) - 被 `!inConversation()` 挡着:一旦点过麦进了连续对话,PTT 永久失效。改为 PTT 优先, 必要时先退出对话模式 - 缺 e.repeat 守卫:按住不放会连发 keydown 交互统一为一套 PTT 语义: - 按住空格 / 按住麦克风按钮(pointer 事件,覆盖鼠标触控) → 说话,松开发送 - 朗读中按按钮 = 打断 - 指针滑出/取消/窗口失焦都算松开,不会卡在录音态 - 录音中按钮变红缩小 + 电平条 + 呼吸环,一眼可见"正在听" 顺带:松开后标签改为"已停止收音"(准确说法——麦克风硬件仍开着以便快速重按, 只是不再上传音频,门控在 shouldSendMic);删掉已无人使用的 micTapAction 及其单测。 Co-Authored-By: Claude Opus 5 --- .../frontend/src/lib/voice.test.ts | 21 +--- sundynix-desktop/frontend/src/lib/voice.ts | 9 -- .../frontend/src/shell/JarvisHud.tsx | 16 ++- .../frontend/src/shell/VoiceDock.tsx | 109 +++++++++++------- 4 files changed, 83 insertions(+), 72 deletions(-) diff --git a/sundynix-desktop/frontend/src/lib/voice.test.ts b/sundynix-desktop/frontend/src/lib/voice.test.ts index 9e973ce..a2fbe6d 100644 --- a/sundynix-desktop/frontend/src/lib/voice.test.ts +++ b/sundynix-desktop/frontend/src/lib/voice.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { micTapAction, nextAfterDrain, shouldSendMic, type VoiceState } from "./voice"; +import { nextAfterDrain, shouldSendMic, type VoiceState } from "./voice"; // 连续对话的纯决策函数:状态门控 / 播完去向 / 点按语义。 // VoiceClient 本体依赖 WebSocket/AudioContext(jsdom 难直测),决策逻辑抽纯函数在这测。 @@ -27,22 +27,3 @@ describe("nextAfterDrain 播完去向", () => { }); }); -describe("micTapAction 点按语义(对话开关)", () => { - it("聆听中点 → 退出对话", () => { - expect(micTapAction("listening", true)).toBe("stop"); - expect(micTapAction("listening", false)).toBe("stop"); - }); - - it("朗读中点(对话里)→ 打断但留在对话", () => { - expect(micTapAction("speaking", true)).toBe("interrupt"); - }); - - it("朗读中点(非对话)→ 开始对话(startConversation 内部先打断)", () => { - expect(micTapAction("speaking", false)).toBe("start"); - }); - - it.each(["idle", "ready", "connecting", "thinking"])("%s 态点 → 开始对话", (s) => { - expect(micTapAction(s, false)).toBe("start"); - expect(micTapAction(s, true)).toBe("start"); - }); -}); diff --git a/sundynix-desktop/frontend/src/lib/voice.ts b/sundynix-desktop/frontend/src/lib/voice.ts index d8c6ce7..5e19a37 100644 --- a/sundynix-desktop/frontend/src/lib/voice.ts +++ b/sundynix-desktop/frontend/src/lib/voice.ts @@ -34,15 +34,6 @@ 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 没等到朗读 → 回聆听继续对话 diff --git a/sundynix-desktop/frontend/src/shell/JarvisHud.tsx b/sundynix-desktop/frontend/src/shell/JarvisHud.tsx index 5ff67f7..6b5ba69 100644 --- a/sundynix-desktop/frontend/src/shell/JarvisHud.tsx +++ b/sundynix-desktop/frontend/src/shell/JarvisHud.tsx @@ -13,7 +13,9 @@ interface Props { transcript: string; reply: string; hint: string; - onMic: () => void; + // 按住说话(与语音坞/空格键同一套 PTT 语义):按下开始听、松开发送。 + onPttDown: () => void; + onPttUp: () => void; onClose: () => void; } @@ -30,7 +32,7 @@ const CONF: Record = { }; const GLYPH = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%@*<>/\\"; -export function JarvisHud({ name, state, getLevel, transcript, reply, hint, onMic, onClose }: Props) { +export function JarvisHud({ name, state, getLevel, transcript, reply, hint, onPttDown, onPttUp, onClose }: Props) { const canvasRef = useRef(null); const decRef = useRef(null); const sigRef = useRef(null); @@ -210,7 +212,15 @@ export function JarvisHud({ name, state, getLevel, transcript, reply, hint, onMi ESC -