diff --git a/sundynix-desktop/frontend/src/lib/voice.ts b/sundynix-desktop/frontend/src/lib/voice.ts index ab08c37..d8c6ce7 100644 --- a/sundynix-desktop/frontend/src/lib/voice.ts +++ b/sundynix-desktop/frontend/src/lib/voice.ts @@ -263,6 +263,14 @@ export class VoiceClient { 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" }); diff --git a/sundynix-desktop/frontend/src/shell/VoiceDock.tsx b/sundynix-desktop/frontend/src/shell/VoiceDock.tsx index f38a451..f3f593b 100644 --- a/sundynix-desktop/frontend/src/shell/VoiceDock.tsx +++ b/sundynix-desktop/frontend/src/shell/VoiceDock.tsx @@ -27,8 +27,10 @@ interface Turn { const KEEP_TURNS = 3; // 对话流保留最近几轮(含当前轮) // hintText 状态提示(带人格位:名字来自每用户 JARVIS 设置)。 -function hintText(state: VoiceState, name: string, idleLeft: number | null): string { +// pttActive 时显示"松开发送"替代默认聆听提示。 +function hintText(state: VoiceState, name: string, idleLeft: number | null, ptt?: boolean): string { if (idleLeft !== null) return `${idleLeft}s 后自动退出 · 说话取消`; + if (ptt && state === "listening") return "按住说话中 · 松开发送"; switch (state) { case "connecting": return "连接中…"; @@ -39,7 +41,7 @@ function hintText(state: VoiceState, name: string, idleLeft: number | null): str case "speaking": return "朗读中 · 点击打断"; default: - return "点击开始对话"; + return "点击开始对话 · 空格键按住说话"; } } @@ -98,6 +100,8 @@ export function VoiceDock({ onTask, onNavigate }: Props) { const [fullscreen, setFullscreen] = useState(false); // 全屏 JARVIS 模式 const [name, setName] = useState("JARVIS"); // 助手名(提示/对话流/HUD 品牌位) const [idleLeft, setIdleLeft] = useState(null); // 空转退出倒计时(最后 10s 才非空) + const [pttActive, setPttActive] = useState(false); // PTT 按住空格中 + const pttRef = useRef(false); // 避免 blur 闭包拿旧值 // onNavigate 经 ref 供长寿命 VoiceClient 回调使用(client 只建一次,闭包别锁死旧 props)。 const onNavigateRef = useRef(onNavigate); @@ -181,6 +185,61 @@ export function VoiceDock({ onTask, onNavigate }: Props) { return () => window.removeEventListener("keydown", onKey); }, [fullscreen]); + // PTT:按住空格语音输入,松开发送(不在对话模式时生效)。 + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== " " && e.code !== "Space") return; + // 输入框/文本区里不抢空格 + const tag = (e.target as HTMLElement).tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement).isContentEditable) return; + e.preventDefault(); + + const c = clientRef.current; + if (!c || c.inConversation() || c.getState() === "listening") return; + + pttRef.current = true; + setPttActive(true); + c.startListening().catch((err: unknown) => { + pttRef.current = false; + setPttActive(false); + toast.push("error", (err as Error).message || "语音启动失败"); + }); + }; + + const onKeyUp = (e: KeyboardEvent) => { + if (e.key !== " " && e.code !== "Space") return; + e.preventDefault(); + pttRef.current = false; + setPttActive(false); + const c = clientRef.current; + if (c && c.getState() === "listening") c.stopPTT(); + }; + + // 窗口失焦时取消 PTT(防止空格一直按着弹不回来) + const onBlur = () => { + if (pttRef.current) { + pttRef.current = false; + setPttActive(false); + const c = clientRef.current; + if (c && c.getState() === "listening") c.stopPTT(); + } + }; + + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + window.addEventListener("blur", onBlur); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("blur", onBlur); + // 清理时如有 PTT 未释放也收掉 + if (pttRef.current) { + const c = clientRef.current; + if (c && c.getState() === "listening") c.stopPTT(); + } + }; + }, [toast]); + // 点按语义 = 对话开关:待命点 → 进入连续对话;聆听中点 → 退出;朗读中点 → 打断但留在对话。 const onMic = useCallback(async () => { const c = ensureClient(); @@ -214,7 +273,7 @@ export function VoiceDock({ onTask, onNavigate }: Props) { }, [ensureClient]); const busy = state === "connecting"; - const hint = hintText(state, name, idleLeft); + const hint = hintText(state, name, idleLeft, pttActive); const visibleTurns = turns.filter((t) => t.me || t.ai || t.taskId); const ringDur = conv ? RING_DUR[state] : undefined; // 倒计时环:SVG 周长 182.2(r=29),剩余秒数映射到 dashoffset(收缩)。