Files
sundynix-agentix/sundynix-desktop/frontend/src/shell/JarvisHud.tsx
T
Blizzard e60679ed7b fix(voice): PTT 改为主交互——按住说话松开发送(修 PTT 形同虚设的 bug)
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 <noreply@anthropic.com>
2026-07-25 14:52:26 +08:00

301 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from "react";
import { X } from "lucide-react";
import type { VoiceState } from "../lib/voice";
// 全屏「JARVIS 模式」HUD:弧反应堆核心 + 随真实声音反应的环形频谱 + 遥测 + 解码文字 + 开机自检。
// 视觉数据全来自真实语音会话:level 由 VoiceClient 的麦克风/TTS 探针实时给出(getLevel),
// state 是会话状态机;transcript/reply 是当轮问答。点中心说话,右上角/ESC 退出。
interface Props {
name: string;
state: VoiceState;
getLevel: () => number;
transcript: string;
reply: string;
hint: string;
// 按住说话(与语音坞/空格键同一套 PTT 语义):按下开始听、松开发送。
onPttDown: () => void;
onPttUp: () => void;
onClose: () => void;
}
type Conf = { accent: number[]; activity: number; ringSpin: number; label: string };
const CY = [56, 225, 255];
const AM = [255, 182, 56];
const CONF: Record<VoiceState, Conf> = {
idle: { accent: CY, activity: 0.18, ringSpin: 0.1, label: "STANDBY" },
connecting: { accent: CY, activity: 0.22, ringSpin: 0.5, label: "LINKING" },
ready: { accent: CY, activity: 0.18, ringSpin: 0.1, label: "READY" },
listening: { accent: CY, activity: 0.6, ringSpin: 0.35, label: "LISTENING" },
thinking: { accent: AM, activity: 0.3, ringSpin: 0.8, label: "PROCESSING" },
speaking: { accent: CY, activity: 0.85, ringSpin: 0.22, label: "SPEAKING" },
};
const GLYPH = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%@*<>/\\";
export function JarvisHud({ name, state, getLevel, transcript, reply, hint, onPttDown, onPttUp, onClose }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const decRef = useRef<HTMLSpanElement>(null);
const sigRef = useRef<HTMLElement>(null);
const latRef = useRef<HTMLElement>(null);
const gainRef = useRef<HTMLElement>(null);
const [booting, setBooting] = useState(true);
// 用 ref 让 RAF 闭包读到最新 props,无需重启动画。
const P = useRef({ state, getLevel, transcript, reply });
P.current = { state, getLevel, transcript, reply };
// 开机自检:进场放一段 INITIALIZING…ONLINE,约 2.4s 后淡出(reduced-motion 直接跳过)。
useEffect(() => {
if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
setBooting(false);
return;
}
const id = setTimeout(() => setBooting(false), 2400);
return () => clearTimeout(id);
}, []);
useEffect(() => {
const cv = canvasRef.current!;
const ctx = cv.getContext("2d")!;
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
let W = 0, H = 0, raf = 0;
const resize = () => {
const r = cv.getBoundingClientRect();
const dpr = Math.min(2, devicePixelRatio || 1);
W = r.width; H = r.height;
cv.width = W * dpr; cv.height = H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(cv);
const rgba = (c: number[], a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const accent = CY.slice();
let level = 0.18, think = 0, t = 0, last = performance.now();
const parts = Array.from({ length: 64 }, () => ({ a: Math.random() * 6.28, r: Math.random(), s: 0.3 + Math.random() * 0.9 }));
// 解码文字状态:shown=已定稿字数,scr=当前字的乱码累积。
let decShown = 0, scr = 0;
const synth = (s: VoiceState) => (s === "thinking" ? 0.2 + 0.06 * Math.sin(t * 3) : 0.16 + 0.05 * Math.sin(t * 1.6));
const draw = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000); last = now; t += dt;
const st = P.current.state;
const c = CONF[st] ?? CONF.idle;
const activeAudio = st === "listening" || st === "speaking";
const tgt = activeAudio ? Math.max(P.current.getLevel(), 0) : synth(st);
level += (tgt - level) * Math.min(1, dt * (activeAudio ? 16 : 6));
for (let i = 0; i < 3; i++) accent[i] = lerp(accent[i], c.accent[i], Math.min(1, dt * 4));
think += ((st === "thinking" ? 1 : 0) - think) * Math.min(1, dt * 3);
const cx = W / 2, cy = H / 2, R = Math.min(W, H) * 0.16;
ctx.clearRect(0, 0, W, H);
ctx.globalCompositeOperation = "lighter";
let g = ctx.createRadialGradient(cx, cy, R * 0.2, cx, cy, R * 3.4);
g.addColorStop(0, rgba(accent, 0.1 + level * 0.1));
g.addColorStop(1, rgba(accent, 0));
ctx.fillStyle = g; ctx.fillRect(cx - R * 4, cy - R * 4, R * 8, R * 8);
const spin = reduce ? 0 : t * c.ringSpin;
[1.35, 1.7, 2.05, 2.5, 3.0].forEach((rr, i) => {
const rad = R * rr, dir = i % 2 ? -1 : 1, a0 = spin * dir * (1 + i * 0.25);
ctx.lineWidth = i === 0 ? 2.2 : 1.2;
ctx.strokeStyle = rgba(accent, (0.5 - i * 0.06) * (0.6 + level * 0.5));
ctx.shadowColor = rgba(accent, 0.8); ctx.shadowBlur = 12;
const segs: [number, number][] = i === 0 ? [[0, 5.0]] : [[a0, 1.3], [a0 + 2.3, 2.0], [a0 + 5.0, 0.8]];
segs.forEach(([s, len]) => { ctx.beginPath(); ctx.arc(cx, cy, rad, s, s + len); ctx.stroke(); });
});
ctx.shadowBlur = 0;
const NB = 104, inner = R * 1.12;
ctx.lineWidth = 2;
for (let i = 0; i < NB; i++) {
const ang = (i / NB) * 6.28 - Math.PI / 2 + spin * 0.15;
const seed = Math.sin(i * 12.9898) * 43758.5453; const nz = seed - Math.floor(seed);
const wob = 0.5 + 0.5 * Math.sin(t * 3 + i * 0.5);
const h = R * (0.06 + (0.1 + c.activity * 0.9 * level) * (0.35 + 0.65 * wob) * (0.5 + nz));
const x1 = cx + Math.cos(ang) * inner, y1 = cy + Math.sin(ang) * inner;
const x2 = cx + Math.cos(ang) * (inner + h), y2 = cy + Math.sin(ang) * (inner + h);
const bright = 0.35 + 0.65 * (h / (R * 0.9));
ctx.strokeStyle = rgba(accent, 0.25 + bright * 0.6);
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
}
if (think > 0.02) {
parts.forEach((p) => {
if (!reduce) p.a += dt * p.s * 1.2;
const rad = R * (1.15 + p.r * 1.7), x = cx + Math.cos(p.a) * rad, y = cy + Math.sin(p.a) * rad;
ctx.fillStyle = rgba(AM, 0.7 * think);
ctx.shadowColor = rgba(AM, 0.9); ctx.shadowBlur = 8;
ctx.beginPath(); ctx.arc(x, y, 1.6, 0, 6.28); ctx.fill();
});
const sa = t * 2.2;
ctx.save(); ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, R * 2.9, sa, sa + 0.5); ctx.closePath();
ctx.fillStyle = rgba(AM, 0.1 * think); ctx.fill(); ctx.restore();
ctx.shadowBlur = 0;
}
const cr = R * (0.62 + level * 0.3);
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, cr);
cg.addColorStop(0, rgba([255, 255, 255], 0.95));
cg.addColorStop(0.25, rgba(accent, 0.95));
cg.addColorStop(0.7, rgba(accent, 0.28));
cg.addColorStop(1, rgba(accent, 0));
ctx.fillStyle = cg; ctx.beginPath(); ctx.arc(cx, cy, cr, 0, 6.28); ctx.fill();
ctx.globalCompositeOperation = "source-over";
for (let k = 0; k < 3; k++) {
ctx.lineWidth = 1; ctx.strokeStyle = rgba([255, 255, 255], 0.5 - k * 0.13);
ctx.beginPath(); ctx.arc(cx, cy, R * (0.3 + k * 0.14), 0, 6.28); ctx.stroke();
}
ctx.globalCompositeOperation = "lighter";
ctx.strokeStyle = rgba([255, 255, 255], 0.85); ctx.lineWidth = 1.5; ctx.beginPath();
for (let k = 0; k < 3; k++) {
const a = spin * 2 + k * 2.094, rr = R * 0.22;
const x = cx + Math.cos(a) * rr, y = cy + Math.sin(a) * rr;
k ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
}
ctx.closePath(); ctx.stroke();
ctx.globalCompositeOperation = "source-over";
// 遥测读数
if (sigRef.current) sigRef.current.textContent = String(Math.round(level * 100)).padStart(2, "0");
if (latRef.current) latRef.current.textContent = String(90 + Math.round(level * 60 + Math.sin(t * 9) * 8)).padStart(3, "0");
if (gainRef.current) gainRef.current.textContent = (0.8 + level * 2.4).toFixed(1);
// 解码式回答:让文字逐字"定稿",前沿几个字符跳乱码。
const rep = P.current.reply;
if (decShown > rep.length) decShown = 0; // 新一轮回答,重置
if (decShown < rep.length) { scr += 0.5; if (scr >= 1) { scr = 0; decShown++; } }
if (decRef.current) {
let out = rep.slice(0, decShown);
const frontier = Math.min(6, rep.length - decShown);
for (let i = 0; i < frontier; i++) out += `<span class="jhud-scr">${GLYPH[(Math.random() * GLYPH.length) | 0]}</span>`;
decRef.current.innerHTML = out;
}
raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
}, []);
const c = CONF[state] ?? CONF.idle;
const amber = state === "thinking";
const NAME = (name || "JARVIS").toUpperCase();
return (
<div className="jhud" role="dialog" aria-label="JARVIS 全屏模式">
<style>{HUD_CSS}</style>
<canvas ref={canvasRef} className="jhud-canvas" />
<span className="jhud-corner tl" /><span className="jhud-corner tr" />
<span className="jhud-corner bl" /><span className="jhud-corner br" />
<div className="jhud-status">
<div className="jhud-brand">{NAME}</div>
<div className="jhud-row">
<span className={"jhud-dot" + (state === "listening" ? " live" : amber ? " amber" : "")} />
<b>{c.label}</b>
</div>
</div>
<div className="jhud-tel">
<div>SIGNAL <b ref={sigRef}>00</b>%</div>
<div>LATENCY <b ref={latRef}>000</b>ms</div>
<div>GAIN <b ref={gainRef}>0.0</b></div>
<div>SESSION <b>#7F2A</b></div>
</div>
<button className="jhud-close" onClick={onClose} aria-label="退出 JARVIS 模式">
<X className="h-4 w-4" /> ESC
</button>
<button
className="jhud-core"
onPointerDown={(e) => { e.preventDefault(); onPttDown(); }}
onPointerUp={onPttUp}
onPointerLeave={onPttUp}
onPointerCancel={onPttUp}
aria-label={hint}
title={hint}
/>
<div className={"jhud-word" + (amber ? " amber" : state === "listening" ? " live" : "")}>{hint}</div>
<div className="jhud-decode">
{transcript && <p className="jhud-me"><span></span>{transcript}</p>}
<p className="jhud-reply" style={{ visibility: reply ? "visible" : "hidden" }}>
<span>{NAME}</span><span ref={decRef} />
{(state === "thinking" || state === "speaking") && <i className="jhud-cursor" />}
</p>
</div>
{booting && (
<div className="jhud-boot" aria-hidden="true">
<div><span className="b"></span> INITIALIZING {NAME} CORE</div>
<div><span className="b"></span> AUDIO SUBSYSTEM <span className="ok"> OK</span></div>
<div><span className="b"></span> NEURAL UPLINK <span className="ok"> OK</span></div>
<div><span className="b"></span> VOICE MODEL · v4-flash <span className="ok"> OK</span></div>
<div className="big">ONLINE</div>
</div>
)}
</div>
);
}
const HUD_CSS = `
.jhud{position:fixed;inset:0;z-index:60;overflow:hidden;color:#d6f2fc;user-select:none;
background:radial-gradient(120% 80% at 50% 44%,#08131d 0%,#04070d 62%,#02040a 100%);
font-family:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;animation:jhudIn .5s ease}
@keyframes jhudIn{from{opacity:0}to{opacity:1}}
.jhud::before{content:"";position:absolute;inset:0;pointer-events:none;opacity:.5;
background-image:linear-gradient(transparent 0 3px,rgba(56,225,255,.035) 3px 4px),linear-gradient(90deg,transparent 0 3px,rgba(56,225,255,.028) 3px 4px);
background-size:100% 4px,4px 100%;mask-image:radial-gradient(100% 100% at 50% 45%,#000 55%,transparent 100%)}
.jhud::after{content:"";position:absolute;inset:0;pointer-events:none;z-index:3;
background:linear-gradient(rgba(56,225,255,0),rgba(56,225,255,.05) 50%,rgba(56,225,255,0));height:26%;animation:jhudSweep 6.5s linear infinite;opacity:.55}
@keyframes jhudSweep{0%{transform:translateY(-30%)}100%{transform:translateY(430%)}}
.jhud-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:1}
.jhud-corner{position:absolute;width:44px;height:44px;border:1.5px solid #0e5a70;z-index:4;opacity:.8}
.jhud-corner.tl{top:20px;left:20px;border-right:0;border-bottom:0}
.jhud-corner.tr{top:20px;right:20px;border-left:0;border-bottom:0}
.jhud-corner.bl{bottom:20px;left:20px;border-right:0;border-top:0}
.jhud-corner.br{bottom:20px;right:20px;border-left:0;border-top:0}
.jhud-status{position:absolute;top:30px;left:38px;z-index:5;letter-spacing:.14em;font-size:12px;line-height:1.9}
.jhud-brand{color:#38e1ff;font-size:15px;letter-spacing:.28em;text-shadow:0 0 14px rgba(56,225,255,.55)}
.jhud-row{display:flex;gap:8px;align-items:center;color:#5f8496}
.jhud-row b{color:#d6f2fc;font-weight:500}
.jhud-dot{width:7px;height:7px;border-radius:50%;background:#38e1ff;box-shadow:0 0 10px #38e1ff}
.jhud-dot.live{background:#ff4d5e;box-shadow:0 0 10px #ff4d5e;animation:jhudBlink 1s steps(2) infinite}
.jhud-dot.amber{background:#ffb638;box-shadow:0 0 10px #ffb638}
@keyframes jhudBlink{50%{opacity:.25}}
.jhud-tel{position:absolute;top:66px;right:40px;z-index:5;text-align:right;font-size:11px;line-height:2.1;letter-spacing:.14em;color:#5f8496}
.jhud-tel b{color:#38e1ff;font-weight:500;font-variant-numeric:tabular-nums}
.jhud-close{position:absolute;top:26px;right:34px;z-index:6;display:flex;align-items:center;gap:6px;
background:rgba(8,22,32,.6);border:1px solid #0e2a38;color:#7fa6b6;border-radius:999px;padding:7px 13px;
font-family:inherit;font-size:11px;letter-spacing:.14em;cursor:pointer;backdrop-filter:blur(6px);transition:.18s}
.jhud-close:hover{border-color:#38e1ff;color:#d6f2fc}
.jhud-core{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:26vmin;height:26vmin;border-radius:50%;background:transparent;border:0;cursor:pointer;z-index:5}
.jhud-word{position:absolute;left:50%;top:calc(50% + 20vmin);transform:translateX(-50%);z-index:5;font-size:13px;letter-spacing:.5em;color:#38e1ff;text-shadow:0 0 16px rgba(56,225,255,.5);text-transform:uppercase;white-space:nowrap}
.jhud-word.amber{color:#ffb638;text-shadow:0 0 16px rgba(255,182,56,.5)}
.jhud-word.live{color:#ff4d5e;text-shadow:0 0 16px rgba(255,77,94,.5)}
.jhud-decode{position:absolute;left:50%;bottom:56px;transform:translateX(-50%);z-index:5;width:min(760px,88vw);text-align:center;line-height:1.55}
.jhud-me{color:#5f8496;font-size:13px;margin:0 0 10px}
.jhud-me span,.jhud-reply>span:first-child{font-size:10px;letter-spacing:.24em;margin-right:8px;opacity:.75}
.jhud-reply{color:#eaf8ff;font-size:16px;margin:0;min-height:24px;text-shadow:0 0 10px rgba(56,225,255,.25)}
.jhud-reply>span:first-child{color:#38e1ff}
.jhud-scr{color:#38e1ff;opacity:.9}
.jhud-cursor{display:inline-block;width:8px;height:2px;background:#38e1ff;margin-left:3px;vertical-align:middle;box-shadow:0 0 8px #38e1ff;animation:jhudBlink .8s steps(2) infinite}
.jhud-boot{position:absolute;inset:0;z-index:8;background:#02040a;display:flex;flex-direction:column;justify-content:center;padding-left:12%;gap:6px;font-size:14px;letter-spacing:.06em;color:#38e1ff;animation:jhudBootOut .6s ease 1.8s forwards}
.jhud-boot>div{opacity:0;animation:jhudBootLine .3s ease forwards}
.jhud-boot>div:nth-child(1){animation-delay:.1s}
.jhud-boot>div:nth-child(2){animation-delay:.5s}
.jhud-boot>div:nth-child(3){animation-delay:.9s}
.jhud-boot>div:nth-child(4){animation-delay:1.2s}
.jhud-boot .b{color:#3d6475}.jhud-boot .ok{color:#38e1ff}
.jhud-boot .big{font-size:26px;letter-spacing:.4em;margin-top:14px;color:#eaf8ff;text-shadow:0 0 20px rgba(56,225,255,.6);animation-delay:1.5s}
@keyframes jhudBootLine{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:none}}
@keyframes jhudBootOut{to{opacity:0}}
@media (prefers-reduced-motion: reduce){.jhud::after,.jhud-boot{display:none}.jhud-dot.live,.jhud-cursor{animation:none}}
`;