feat(voice): 按住空格键语音输入(PTT),松开发送 #14
@@ -263,6 +263,14 @@ export class VoiceClient {
|
|||||||
if (this.state === "listening") this.setState("thinking");
|
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 打断当前朗读(用户又开口)。
|
||||||
bargeIn(): void {
|
bargeIn(): void {
|
||||||
this.send({ type: "barge_in" });
|
this.send({ type: "barge_in" });
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ interface Turn {
|
|||||||
const KEEP_TURNS = 3; // 对话流保留最近几轮(含当前轮)
|
const KEEP_TURNS = 3; // 对话流保留最近几轮(含当前轮)
|
||||||
|
|
||||||
// hintText 状态提示(带人格位:名字来自每用户 JARVIS 设置)。
|
// 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 (idleLeft !== null) return `${idleLeft}s 后自动退出 · 说话取消`;
|
||||||
|
if (ptt && state === "listening") return "按住说话中 · 松开发送";
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "connecting":
|
case "connecting":
|
||||||
return "连接中…";
|
return "连接中…";
|
||||||
@@ -39,7 +41,7 @@ function hintText(state: VoiceState, name: string, idleLeft: number | null): str
|
|||||||
case "speaking":
|
case "speaking":
|
||||||
return "朗读中 · 点击打断";
|
return "朗读中 · 点击打断";
|
||||||
default:
|
default:
|
||||||
return "点击开始对话";
|
return "点击开始对话 · 空格键按住说话";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +100,8 @@ export function VoiceDock({ onTask, onNavigate }: Props) {
|
|||||||
const [fullscreen, setFullscreen] = useState(false); // 全屏 JARVIS 模式
|
const [fullscreen, setFullscreen] = useState(false); // 全屏 JARVIS 模式
|
||||||
const [name, setName] = useState("JARVIS"); // 助手名(提示/对话流/HUD 品牌位)
|
const [name, setName] = useState("JARVIS"); // 助手名(提示/对话流/HUD 品牌位)
|
||||||
const [idleLeft, setIdleLeft] = useState<number | null>(null); // 空转退出倒计时(最后 10s 才非空)
|
const [idleLeft, setIdleLeft] = useState<number | null>(null); // 空转退出倒计时(最后 10s 才非空)
|
||||||
|
const [pttActive, setPttActive] = useState(false); // PTT 按住空格中
|
||||||
|
const pttRef = useRef(false); // 避免 blur 闭包拿旧值
|
||||||
|
|
||||||
// onNavigate 经 ref 供长寿命 VoiceClient 回调使用(client 只建一次,闭包别锁死旧 props)。
|
// onNavigate 经 ref 供长寿命 VoiceClient 回调使用(client 只建一次,闭包别锁死旧 props)。
|
||||||
const onNavigateRef = useRef(onNavigate);
|
const onNavigateRef = useRef(onNavigate);
|
||||||
@@ -181,6 +185,61 @@ export function VoiceDock({ onTask, onNavigate }: Props) {
|
|||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [fullscreen]);
|
}, [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 onMic = useCallback(async () => {
|
||||||
const c = ensureClient();
|
const c = ensureClient();
|
||||||
@@ -214,7 +273,7 @@ export function VoiceDock({ onTask, onNavigate }: Props) {
|
|||||||
}, [ensureClient]);
|
}, [ensureClient]);
|
||||||
|
|
||||||
const busy = state === "connecting";
|
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 visibleTurns = turns.filter((t) => t.me || t.ai || t.taskId);
|
||||||
const ringDur = conv ? RING_DUR[state] : undefined;
|
const ringDur = conv ? RING_DUR[state] : undefined;
|
||||||
// 倒计时环:SVG 周长 182.2(r=29),剩余秒数映射到 dashoffset(收缩)。
|
// 倒计时环:SVG 周长 182.2(r=29),剩余秒数映射到 dashoffset(收缩)。
|
||||||
|
|||||||
Reference in New Issue
Block a user