Merge pull request 'feat(voice): 按住空格键语音输入(PTT),松开发送' (#14) from feat/site into main
deploy-132 / deploy (push) Successful in 3m1s
deploy-132 / deploy (push) Successful in 3m1s
Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
@@ -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" });
|
||||
|
||||
@@ -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<number | null>(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(收缩)。
|
||||
|
||||
Reference in New Issue
Block a user