feat(voice): 实时语音对话页(React 完全复刻原版 UI)

- /voice 页面:中间圆球 + 左右按钮 + 状态图标 + 右下角气泡(进场/淡出/阶梯字号/上限8)+ 噪声门弧线电平,结构类名与原版一致
- s2s WebSocket 客户端 + worklet 音频(mic-capture/audio-playback)+ orb 可视化
- 摄像头实时预览(默认开启)+ 视觉识别状态条(后端 YOLO 推理)
- 底部园区概览条(复用 ParkOverviewStrip),orb-wrap 视窗居中
- 页眉复用 ScreenLayout;左右键导航加入 /voice
- 文档:docs/voice-integration-plan.md
This commit is contained in:
Pine
2026-08-18 01:37:44 +08:00
parent 29bdad316f
commit 4e2224e9dc
26 changed files with 12566 additions and 1 deletions
+3
View File
@@ -3,6 +3,7 @@ import MediaScreen from './pages/MediaScreen';
import DataScreen from './pages/DataScreen';
import DigitalTwin from './pages/DigitalTwin';
import AiAssistant from './pages/AiAssistant';
import VoiceAssistant from './pages/VoiceAssistant';
import ScreenLayout from './components/ScreenLayout';
import './styles/tokens.css';
@@ -16,6 +17,8 @@ export default function App() {
<Route path="/twin" element={<DigitalTwin />} />
<Route path="/ai" element={<AiAssistant />} />
<Route path="/screen" element={<MediaScreen />} />
{/* 实时语音对话页(React 原生,复用页眉) */}
<Route path="/voice" element={<VoiceAssistant />} />
</Route>
{/* 管理后台已迁移至 FastAPI + JinjaGET /admin),不由 React 路由接管 */}
<Route path="*" element={<Navigate to="/" replace />} />
+1 -1
View File
@@ -10,7 +10,7 @@
const env = import.meta.env || {};
export const API_BASE = window.__DPM_API__ || env.VITE_API_BASE || 'http://localhost:10085';
export const API_BASE = window.__DPM_API__ || env.VITE_API_BASE || 'http://192.168.1.9:10085';
export const MQTT_URL = window.__DPM_MQTT__ || env.VITE_MQTT_URL || 'ws://192.168.1.3:8083/mqtt';
export const MQTT_USERNAME = window.__DPM_MQTT_USER__ || env.VITE_MQTT_USERNAME || '';
export const MQTT_PASSWORD = window.__DPM_MQTT_PASS__ || env.VITE_MQTT_PASSWORD || '';
+539
View File
@@ -0,0 +1,539 @@
/* =========================================================
实时语音对话页(/voice)—— React 完全复刻 live-avatar 对话 UI
· 无自带 topbarDPM 页眉由 ScreenLayout 注入)
· 中间圆球(状态机 + 指示器 + 噪声门圆环)、右下角气泡渲染、
底部文字输入 —— 结构与类名与原版一致,样式引入原版 style.css
· 逻辑:本地 VAD + 云 ASR(qwen3-asr-flash-realtime) + 云 LLM(qwen-plus) + 云 TTS(qwen3-tts-flash-realtime)
========================================================= */
import { useCallback, useEffect, useRef, useState } from 'react';
import { S2sWsRealtimeClient } from '../voice/s2s-ws-client.js';
import useVisionDetection, { VISION_DWELL_MS } from '../voice/useVisionDetection.js';
import ParkOverviewStrip from '../components/ParkOverviewStrip';
import { useParkSim } from '../utils/parkData';
import { API_BASE } from '../config';
import '../styles/datascreen.css';
import './voice-original.css';
import './voice-overrides.css';
const VOICE_URL = window.__DPM_VOICE_WS__ || `ws://${window.location.hostname}:8765/v1/realtime`;
const DEFAULT_INSTRUCTIONS = '你是园区智能助手,请用简洁专业的中文回答,不超过三句话。';
const DEFAULT_VOICE = 'Cherry';
const DEFAULT_GATE_DB = -50;
const STATE_VIEWS = {
idle: '点击开始',
connecting: '思考中',
listening: '正在聆听',
'user-speaking': '',
processing: '',
'ai-speaking': '',
error: '点击重试',
};
const STATE_CLASS = {
idle: 'state-idle', connecting: 'state-connecting', listening: 'state-listening',
'user-speaking': 'state-user-speaking', processing: 'state-processing',
'ai-speaking': 'state-ai-speaking', error: 'state-error',
};
const LIVE_STATES = new Set(['listening', 'user-speaking', 'processing', 'ai-speaking']);
const GATE_OFF_DB = -66;
const GATE_MAX_DB = -3;
// ── 噪声门弧线几何(复刻原版 main.js:200° 弧,缺口朝向圆球)──
const ARC_R = 40;
const ARC_SPAN_DEG = 200;
const ARC_START_DEG = 180 - ARC_SPAN_DEG / 2; // 80°:左下起点
function arcPoint(f, r = ARC_R) {
const deg = ARC_START_DEG + f * ARC_SPAN_DEG;
const rad = (deg * Math.PI) / 180;
return { x: 50 + r * Math.cos(rad), y: 50 + r * Math.sin(rad) };
}
function fullArcD() {
const a = arcPoint(0);
const b = arcPoint(1);
return `M ${a.x} ${a.y} A ${ARC_R} ${ARC_R} 0 1 1 ${b.x} ${b.y}`;
}
function dbToFraction(db) {
const clamped = Math.min(GATE_MAX_DB, Math.max(GATE_OFF_DB, db));
return (clamped - GATE_OFF_DB) / (GATE_MAX_DB - GATE_OFF_DB);
}
const icons = { micOn: <svg className="mic-on" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="2" width="6" height="12" rx="3" /><path d="M5 10a7 7 0 0 0 14 0" /><line x1="12" y1="19" x2="12" y2="22" /></svg>,
micOff: <svg className="mic-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><line x1="2" y1="2" x2="22" y2="22" /><path d="M9 5a3 3 0 0 1 6 0v4" /><path d="M9 10v1a3 3 0 0 0 5.1 2.1" /><path d="M19 10a7 7 0 0 1-1.24 3.97" /><path d="M5 10a7 7 0 0 0 11 5.67" /><line x1="12" y1="19" x2="12" y2="22" /></svg>,
indMic: <svg className="ind ind-mic" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3" fill="currentColor" stroke="none" /><path d="M5 10a7 7 0 0 0 14 0" /><line x1="12" y1="19" x2="12" y2="22" /><line x1="8" y1="22" x2="16" y2="22" /></svg>,
indError: <svg className="ind ind-error" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><line x1="12" y1="8" x2="12" y2="13" /><line x1="12" y1="16" x2="12" y2="16" /></svg>,
indVoice: <svg className="ind ind-voice" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 10v4a1 1 0 0 0 1 1h3l5 4V5L7 9H4a1 1 0 0 0-1 1z" fill="currentColor" stroke="none" /><path className="wave wave-1" d="M16 8a5 5 0 0 1 0 8" /><path className="wave wave-2" d="M19 5a9 9 0 0 1 0 14" /></svg>,
stop: <svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>,
};
// 单条气泡:挂载后 rAF 加 `.in` 触发淡入(复刻原版 _spawnBubble 的进场动画)
function Bubble({ m }) {
const [show, setShow] = useState(false);
useEffect(() => {
const raf = requestAnimationFrame(() => setShow(true));
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={`bubble ${m.role}${show ? ' in' : ''}${m.leaving ? ' out' : ''}`}>
<div className="bubble-role">{m.role === 'user' ? 'You' : 'Assistant'}</div>
<div className={`bubble-body${m.partial ? ' partial' : ''}`}>{m.text}</div>
</div>
);
}
export default function VoiceAssistant() {
const [state, setState] = useState('idle');
const [messages, setMessages] = useState([]);
const [muted, setMuted] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
// 园区数据(页脚概览条;后端 /api/dashboard/snapshot,离线本地模拟兜底)
const d = useParkSim();
const clientRef = useRef(null);
const audioCtxRef = useRef(null);
const streamRef = useRef(null);
// 噪声门弧线元素
const micGateRef = useRef(null);
const mgaTrackRef = useRef(null);
const mgaFillRef = useRef(null);
const mgaHitRef = useRef(null);
const mgaHandleRef = useRef(null);
// 气泡定时器:id -> { dismiss?, remove? }
const bubbleTimersRef = useRef(new Map());
const messagesRef = useRef([]);
const live = LIVE_STATES.has(state);
// ── 自动问候生命周期状态(须在 start 之前定义:start 依赖数组引用 sendGreeting)──
const GREETING_TEXT = '你好!欢迎来到园区,我是智能语音助手,请问有什么可以帮你的吗?';
const GREETING_WAIT_MS = 25000; // 问候播完后等待用户回答的超时
const voiceStateRef = useRef(state);
voiceStateRef.current = state;
const pendingGreetingRef = useRef(false); // 连接成功后待发送的问候
const greetingSentRef = useRef(false); // 问候已发出,等回复完成启动倒计时
const greetingTimerRef = useRef(null); // 等待回答的倒计时句柄
// 主动发送问候(sendUserText → 后端 LLM 生成 → TTS 语音播放)
const sendGreeting = useCallback(() => {
const c = clientRef.current;
if (!c) return;
pendingGreetingRef.current = false;
greetingSentRef.current = true;
c.sendUserText(GREETING_TEXT);
console.warn('[vision] 自动问候已发送:', GREETING_TEXT);
}, []);
// ── 右下角气泡渲染(复刻原版 ui/chat.js)───────────────────────────────
// 流程:新气泡插入 → rAF 加 `.in`(淡入)→ 4s 后加 `.out`(淡出)→ 400ms 移除。
// 持续更新的气泡(partial 逐字流)续期 4s;栈上限 8 条,最旧优先淡出,
// 但永不驱逐正在更新的用户气泡。全部动画定时器集中管理,卸载时清理。
const clearBubbleTimers = useCallback((id) => {
const t = bubbleTimersRef.current.get(id);
if (!t) return;
if (t.dismiss) clearTimeout(t.dismiss);
if (t.remove) clearTimeout(t.remove);
bubbleTimersRef.current.delete(id);
}, []);
/** 立即把气泡标记为 `.out`delay(默认400ms) 后从数组移除。 */
const markLeaving = useCallback((id, delay = 400) => {
setMessages((prev) => {
const m = prev.find((x) => x.id === id);
if (!m || m.leaving) return prev;
return prev.map((x) => (x.id === id ? { ...x, leaving: true } : x));
});
const remove = setTimeout(() => {
setMessages((prev) => prev.filter((x) => x.id !== id));
bubbleTimersRef.current.delete(id);
}, delay);
bubbleTimersRef.current.set(id, { remove });
}, []);
/** delay(默认4s) 后自动淡出;再次调用即续期(清掉旧计时重排)。 */
const scheduleBubbleDismiss = useCallback((id, delay = 4000) => {
clearBubbleTimers(id);
const dismiss = setTimeout(() => {
setMessages((prev) => {
const m = prev.find((x) => x.id === id);
if (!m || m.leaving) return prev;
return prev.map((x) => (x.id === id ? { ...x, leaving: true } : x));
});
const remove = setTimeout(() => {
setMessages((prev) => prev.filter((x) => x.id !== id));
bubbleTimersRef.current.delete(id);
}, 400);
bubbleTimersRef.current.set(id, { remove });
}, delay);
bubbleTimersRef.current.set(id, { dismiss });
}, [clearBubbleTimers]);
const pushMessage = useCallback((role, text, partial = false) => {
const key = role === 'user' ? 'user' : 'assistant';
let next = [...messagesRef.current];
const last = next.length ? next[next.length - 1] : null;
if (partial && last && last.role === key && !last.leaving) {
// 同一条逐字流:更新文本 + 续期 4s(气泡保持显示直到说完)
scheduleBubbleDismiss(last.id);
next[next.length - 1] = { ...last, text, partial: true };
} else {
const id = `${Date.now()}-${Math.random()}`;
next.push({ id, role: key, text, partial });
// 上限 8 条:最旧优先淡出(保留正在更新的用户气泡)
const visible = next.filter((m) => !m.leaving);
if (visible.length > 8) {
const activeUser = [...visible].reverse().find((m) => m.role === 'user' && m.partial);
const victim = visible.find((m) => m !== activeUser) ?? visible[0];
if (victim) {
next = next.map((m) => (m.id === victim.id ? { ...m, leaving: true } : m));
const remove = setTimeout(() => {
setMessages((prev) => prev.filter((x) => x.id !== victim.id));
bubbleTimersRef.current.delete(victim.id);
}, 400);
bubbleTimersRef.current.set(victim.id, { remove });
}
}
scheduleBubbleDismiss(id);
}
messagesRef.current = next;
setMessages(next);
}, [scheduleBubbleDismiss, markLeaving]);
// 结束会话:淡出全部气泡(原版 teardown 行为)
const dismissAllBubbles = useCallback(() => {
const ids = messagesRef.current.map((m) => m.id);
messagesRef.current = [];
ids.forEach((id) => {
if (id !== undefined) markLeaving(id);
});
}, [markLeaving]);
// 卸载时清理所有气泡定时器
useEffect(() => {
return () => {
for (const t of bubbleTimersRef.current.values()) {
if (t.dismiss) clearTimeout(t.dismiss);
if (t.remove) clearTimeout(t.remove);
}
bubbleTimersRef.current.clear();
};
}, []);
// 噪声门弧线:一次性几何(track/fill/hit 同一条 200° 弧 + 阈值圆点)
useEffect(() => {
const d = fullArcD();
mgaTrackRef.current?.setAttribute('d', d);
mgaFillRef.current?.setAttribute('d', d);
mgaHitRef.current?.setAttribute('d', d);
const fill = mgaFillRef.current;
if (fill) {
fill.setAttribute('pathLength', '100');
fill.style.strokeDasharray = '100 100';
fill.style.strokeDashoffset = '100'; // 无电平时空弧
}
const p = arcPoint(dbToFraction(DEFAULT_GATE_DB));
mgaHandleRef.current?.setAttribute('cx', String(p.x));
mgaHandleRef.current?.setAttribute('cy', String(p.y));
}, []);
// 实时麦克风电平 → 弧线填充 + 过门点亮(复刻原版 paintInputLevel
const paintInputLevel = useCallback((rms) => {
if (!mgaFillRef.current || !micGateRef.current) return;
const db = rms > 0 ? 20 * Math.log10(rms) : GATE_OFF_DB;
const f = dbToFraction(db);
mgaFillRef.current.style.strokeDashoffset = String(100 * (1 - f));
const enabled = DEFAULT_GATE_DB > GATE_OFF_DB;
micGateRef.current.classList.toggle('gate-open', enabled && f >= dbToFraction(DEFAULT_GATE_DB));
}, []);
const start = useCallback(async () => {
if (clientRef.current) return;
setState('connecting');
try {
if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) {
throw new Error('非安全上下文:请通过 http://localhost 或 HTTPS 访问,浏览器才允许麦克风');
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
});
streamRef.current = stream;
if (!audioCtxRef.current) audioCtxRef.current = new AudioContext();
if (audioCtxRef.current.state === 'suspended') await audioCtxRef.current.resume();
const client = new S2sWsRealtimeClient({
directUrl: VOICE_URL,
voice: DEFAULT_VOICE,
instructions: DEFAULT_INSTRUCTIONS,
tools: [],
noiseGate: { enabled: true, thresholdDb: DEFAULT_GATE_DB },
audioContext: audioCtxRef.current,
micStream: stream,
});
const statusMap = {
connected: 'listening', 'user-speaking': 'user-speaking',
processing: 'processing', 'ai-speaking': 'ai-speaking', idle: 'idle',
};
client.addEventListener('status', (e) => {
const s = e.detail?.status;
setState(statusMap[s] ?? 'listening');
});
client.addEventListener('transcript', (e) => {
const d = e.detail;
pushMessage(d.role === 'user' ? 'user' : 'assistant', d.text, d.partial);
// 用户开始说话(回答了问候)→ 取消挂断倒计时
if (d.role === 'user' && d.text.trim() && greetingTimerRef.current) {
clearTimeout(greetingTimerRef.current);
greetingTimerRef.current = null;
console.warn('[vision] 用户已回答,取消挂断倒计时');
}
});
client.addEventListener('input-level', (e) => paintInputLevel(e.detail?.rms));
client.addEventListener('response-finished', () => {
setState('listening');
// 自动问候的回复播放完毕 → 启动"等待用户回答"倒计时;无回答则自动挂断
if (greetingSentRef.current) {
greetingSentRef.current = false;
if (greetingTimerRef.current) clearTimeout(greetingTimerRef.current);
greetingTimerRef.current = setTimeout(() => {
console.warn('[vision] 问候后用户未回答,自动挂断回初始界面');
void stop();
}, GREETING_WAIT_MS);
}
});
client.addEventListener('error', () => setState('error'));
clientRef.current = client;
await client.connect();
setState('listening');
// 自动问候:连接成功后发送问候(语音)
if (pendingGreetingRef.current) sendGreeting();
} catch (err) {
setErrorMsg(err?.message || String(err));
setState('error');
}
}, [pushMessage, paintInputLevel, sendGreeting]);
const stop = useCallback(async () => {
// 清理自动问候相关状态/倒计时
if (greetingTimerRef.current) {
clearTimeout(greetingTimerRef.current);
greetingTimerRef.current = null;
}
pendingGreetingRef.current = false;
greetingSentRef.current = false;
const c = clientRef.current;
clientRef.current = null;
if (c) { try { await c.close(); } catch { /* noop */ } }
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
setMuted(false);
setErrorMsg('');
dismissAllBubbles();
setState('idle');
}, [dismissAllBubbles]);
const toggleMute = useCallback(() => {
const c = clientRef.current;
if (!c) return;
setMuted((m) => { c.setMuted(!m); return !m; });
}, []);
// ── 视觉识别触发:主动问候 + 后端记录/广播 ─────────────────────────────
// 完整生命周期:
// 触发(仅 idle 状态)→ 打开对话(start) → 连接成功发问候(sendUserText→LLM→语音)
// → 回复播放完(response-finished) → 等待用户回答 25s → 无回答则自动挂断回 idle
// → 静默 3 分钟(hook VISION_SILENT_MS)期间不再打扰
// 循环依赖:onVisionTrigger 需读取 vision.faces,而它又要作为参数传给 useVisionDetection。
// 解法:hook 的 onTrigger/onGesture 从 ref 取最新回调;回调在 vision 声明之后再定义。
const visionTriggerRef = useRef(null);
const gestureRef = useRef(null);
const vision = useVisionDetection({
onTrigger: () => visionTriggerRef.current?.(),
onGesture: (g) => gestureRef.current?.(g),
});
// 手势 → 对话控制:举手 toggle —— 无对话则开始,正在对话则结束
const onGesture = useCallback((g) => {
if (g !== 'raise') return;
if (clientRef.current) {
void stop();
} else {
void start();
}
}, [start, stop]);
useEffect(() => {
gestureRef.current = onGesture;
}, [onGesture]);
const onVisionTrigger = useCallback(() => {
// 仅 idle 允许自动问候:对话中/连接中/其他状态一律不打扰
if (voiceStateRef.current !== 'idle') {
console.warn('[vision] 自动问候跳过:当前状态', voiceStateRef.current);
return;
}
pendingGreetingRef.current = true;
if (clientRef.current) {
sendGreeting();
} else {
void start(); // start 连接成功后检查 pendingGreetingRef 再发问候
}
fetch(`${API_BASE}/api/vision/event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event: 'triggered', faces: vision.faces, dwell_ms: VISION_DWELL_MS }),
}).catch(() => { /* 上报失败不影响本地问候 */ });
}, [vision.faces, start, sendGreeting]);
useEffect(() => {
visionTriggerRef.current = onVisionTrigger;
}, [onVisionTrigger]);
// 摄像头识别状态变化 → 上报后端(详细日志定位)
useEffect(() => {
if (!vision.status || vision.status === 'off' || vision.status === 'loading') return;
fetch(`${API_BASE}/api/vision/event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: vision.status === 'model-error' ? 'model_error' : vision.status,
faces: vision.faces,
dwell_ms: vision.dwellMs,
detail: vision.errorInfo ? `${vision.errorInfo.name}: ${vision.errorInfo.message}` : '',
}),
}).catch(() => { /* 静默 */ });
}, [vision.status]); // eslint-disable-line react-hooks/exhaustive-deps
const visionStatusText = {
off: '摄像头关闭',
loading: '摄像头启动中…',
detecting: `识别中 · ${vision.faces}${vision.faces > 0 ? `(后端 ${vision.latency ?? '-'}ms` : ' · 未检到人脸'} · 链路${vision.modelPhase === 'ok' ? 'OK' : vision.modelPhase === 'failed' ? `失败:${(vision.modelErr || '').slice(0, 40)}` : '连接中'} · 亮度${vision.frameLum ?? '-'}${vision.detectErr ? ` · 异常:${String(vision.detectErr).slice(0, 50)}` : ''}`,
facing: `面向大屏 ${(vision.dwellMs / 1000).toFixed(1)}s / ${VISION_DWELL_MS / 1000}s`,
triggered: '已触发 · 主动问候',
silent: '静默中',
error: '摄像头不可用',
}[vision.status] ?? '';
// 摄像头失败的具体原因
const visionErrorText = (() => {
if (vision.status !== 'error') return null;
const name = vision.errorInfo?.name ?? '';
const msg = vision.errorInfo?.message ?? '';
if (!window.isSecureContext) return '非安全上下文:请用 localhost 或 HTTPS 访问';
if (name === 'NotFoundError') return `未检测到摄像头设备:${msg || '请确认 USB 摄像头已接入本机'}`;
if (name === 'NotAllowedError') return `摄像头权限被拒:请在浏览器地址栏允许 ${location.host} 使用摄像头`;
if (name === 'NotReadableError') return '摄像头被其他应用占用或不可用,请关闭占用程序后刷新';
return `摄像头启动失败(${name}):${msg}`;
})();
const caption = state === 'error' ? (errorMsg || 'Tap to retry') : (STATE_VIEWS[state] ?? '');
return (
<div id="app">
{/* 非安全上下文提示(局域网 IP 访问时浏览器禁用麦克风/摄像头) */}
{!window.isSecureContext && (
<div className="voice-secure-warn">
当前页面不是安全上下文浏览器禁止麦克风/摄像头
请改用 <b>http://localhost</b> 访问(或为后端配置 HTTPS)。
</div>
)}
{/* 主舞台:中间圆球 */}
<main className="stage">
<div className={`orb-wrap${live ? ' live' : ''}`}>
<div id="mic-gate" className="mic-gate" ref={micGateRef}>
<svg id="mic-gate-arc" className="mic-gate-arc" viewBox="0 0 100 100" aria-hidden="true">
<path id="mga-track" className="mga-track" fill="none" ref={mgaTrackRef} />
<path id="mga-fill" className="mga-fill" fill="none" ref={mgaFillRef} />
<path id="mga-hit" className="mga-hit" fill="none" ref={mgaHitRef} />
<circle id="mga-handle" className="mga-handle" r="3" ref={mgaHandleRef} />
</svg>
<button
id="mic-btn"
className={`side-btn${muted ? ' muted' : ''}`}
type="button"
aria-label={muted ? 'Unmute' : 'Mute'}
title={muted ? 'Unmute' : 'Mute'}
aria-hidden={!live}
tabIndex={live ? 0 : -1}
onClick={toggleMute}
>
{icons.micOn}
{icons.micOff}
</button>
</div>
<button
id="main-circle"
className={`circle ${STATE_CLASS[state] ?? 'state-idle'}`}
type="button"
aria-label="Start voice conversation"
disabled={state === 'connecting'}
onClick={live ? stop : start}
>
<span className="circle-glow" aria-hidden="true" />
<span className="circle-ring" aria-hidden="true" />
<span className="circle-ring-outer" aria-hidden="true" />
<span className="circle-core">
<span className="circle-indicator" aria-hidden="true">
{icons.indMic}
{icons.indError}
<span className="ind ind-spinner" />
<span className="ind ind-thinking"><span className="dot" /><span className="dot" /><span className="dot" /></span>
<span className="ind ind-bars"><span className="bar" /><span className="bar" /><span className="bar" /><span className="bar" /><span className="bar" /></span>
{icons.indVoice}
</span>
</span>
</button>
<button
id="stop-btn"
className="side-btn"
type="button"
aria-label="End"
title="End"
aria-hidden={!live}
tabIndex={live ? 0 : -1}
onClick={stop}
>
{icons.stop}
</button>
</div>
<p id="circle-caption" className={`circle-caption${caption ? '' : ' empty'}${state === 'error' ? ' error' : ''}`} role="status">{caption}</p>
<p id="circle-subcaption" className="circle-subcaption" hidden />
</main>
{/* 页脚:园区概览条(与数据大屏 / 媒体播放页一致) */}
<ParkOverviewStrip d={d} />
{/* 右下角气泡(复刻原版:进场 in / 4s 自动淡出 out / 阶梯字号) */}
<div id="bubble-stack" className="bubble-stack" aria-live="polite" aria-atomic="false">
{messages.map((m) => <Bubble key={m.id} m={m} />)}
</div>
{/* 视觉识别:摄像头默认开启;video 始终渲染(ref 先于启动存在),未开启时隐藏 */}
<div className={`voice-cam-pip${vision.cameraOn ? '' : ' hidden'}`}>
<video ref={vision.videoRef} autoPlay playsInline muted />
<div className="voice-cam-bar">
<span className="voice-cam-title">
<span className={`voice-cam-dot ${vision.status === 'triggered' ? 'trigger' : vision.status === 'facing' ? 'face' : 'on'}`} />
智能识别中
</span>
<span>
{vision.gesture === 'raise' && (
<b style={{ color: '#2f6bff' }}>您好呀 · {live ? '开始对话' : '结束对话'}</b>
)}
{visionStatusText}
</span>
</div>
</div>
{vision.status === 'error' && !vision.cameraOn && (
<div className="voice-vision-status" style={{ bottom: 62 }}> {visionErrorText}</div>
)}
{vision.status === 'facing' && (
<div className="voice-vision-status">
检测到 <b>{vision.faces} </b>正对摄像头 <b>{(vision.dwellMs / 1000).toFixed(1)}s</b>停留 {VISION_DWELL_MS / 1000}s 后自动问候
</div>
)}
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
/* 实时语音对话页:iframe 占满内容区(页眉由 ScreenLayout 注入) */
.voice-embed {
width: 100%;
height: 100%;
}
.voice-embed iframe {
width: 100%;
height: 100%;
border: none;
display: block;
background: #0d1117; /* 与原对话页深色背景一致 */
}
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
/* 实时语音对话页覆盖样式:
1) 圆球在内容区垂直+水平居中(原版 #app 是 100vh,在 DPM 页眉下会偏高)
2) 视觉识别:摄像头开关 / 预览 / 识别状态
3) 底部园区概览条(复用 bd-strip,样式来自 datascreen.css */
#app {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
/* stage 弹性撑满(页眉 + 页脚之外的剩余视窗),内部 flex 居中 orb-wrap */
.stage {
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ---- 圆球下方状态文字 ----
原版 --text-faint 是半透明白(为深色背景设计),在 DPM 浅蓝白背景下不可见。
改为 DPM 深蓝墨色,与页眉/概览条同源(--bd-ink),对比明显且不接近背景色。 */
.circle-caption {
color: var(--bd-ink, #1d2c44);
font-size: 13px;
font-weight: 600;
opacity: 1;
}
.circle-caption.empty {
opacity: 0;
}
.circle-caption.error {
color: #b42318;
opacity: 1;
}
.circle-caption.muted {
color: #4a5b74;
opacity: 1;
}
/* ---- 视觉识别 UI ---- */
.voice-cam-toggle {
position: fixed;
right: 18px;
top: 76px; /* DPM 页眉下方 */
z-index: 50;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border: 1px solid rgba(47, 107, 255, 0.35);
border-radius: 20px;
background: rgba(255, 255, 255, 0.92);
color: #1d2c44;
font-size: 12px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(29, 79, 216, 0.12);
}
.voice-cam-toggle:hover { border-color: #2f6bff; }
.voice-cam-toggle.on { background: #2f6bff; color: #fff; border-color: #2f6bff; }
/* 底部左侧摄像头实时窗口(放大,实时画面) */
.voice-cam-pip {
position: fixed;
left: 18px;
bottom: 62px; /* 上移避开底部园区概览条(bd-strip 约 44px 高 + 间隙) */
z-index: 50;
width: 380px;
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(47, 107, 255, 0.45);
box-shadow: 0 10px 28px rgba(29, 79, 216, 0.22);
background: #0d1117;
}
.voice-cam-pip video {
width: 100%;
height: 216px;
object-fit: cover;
display: block;
transform: scaleX(-1); /* 镜像自拍视角 */
}
.voice-cam-pip .voice-cam-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 7px 10px;
font-size: 12px;
color: #e6e8eb;
background: rgba(0, 0, 0, 0.6);
}
.voice-cam-pip .voice-cam-title {
display: inline-flex; align-items: center; gap: 6px; font-weight: 600;
}
.voice-cam-pip .voice-cam-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #7d8fa9;
}
.voice-cam-pip .voice-cam-dot.on { background: #17b26a; box-shadow: 0 0 0 3px rgba(23, 178, 106, 0.25); }
.voice-cam-pip .voice-cam-dot.face { background: #4cc2ff; box-shadow: 0 0 0 3px rgba(76, 194, 255, 0.25); }
.voice-cam-pip .voice-cam-dot.trigger { background: #f79009; box-shadow: 0 0 0 3px rgba(247, 144, 9, 0.3); }
/* 识别状态横幅(左下角预览上方) */
.voice-vision-status {
position: fixed;
left: 18px;
bottom: 200px; /* 跟随预览上移 44px,保持相对位置 */
z-index: 50;
font-size: 12px;
color: #1d2c44;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(47, 107, 255, 0.25);
border-radius: 8px;
padding: 6px 10px;
box-shadow: 0 4px 12px rgba(29, 79, 216, 0.12);
line-height: 1.5;
}
.voice-vision-status b { color: #2f6bff; }
/* 非安全上下文警告条 */
.voice-secure-warn {
position: fixed;
top: 76px;
left: 50%;
transform: translateX(-50%);
z-index: 60;
font-size: 12px;
color: #b42318;
background: #fef3f2;
border: 1px solid #fda29b;
border-radius: 8px;
padding: 6px 12px;
box-shadow: 0 4px 12px rgba(180, 35, 24, 0.12);
}
.voice-cam-pip.hidden { display: none; }
+1
View File
@@ -12,6 +12,7 @@ export const PAGE_ORDER = [
{ path: '/', label: '数据大屏' },
{ path: '/twin', label: '数字孪生' },
{ path: '/ai', label: 'AI 助手' },
{ path: '/voice', label: '语音对话' },
{ path: '/screen', label: '媒体轮播' },
];
+57
View File
@@ -0,0 +1,57 @@
// @ts-check
/**
* Pure, stateless helpers for the WebSocket realtime client: base64 <-> PCM
* conversion for the audio frames on the wire, transcript extraction from a
* `response.done` payload, and a tiny URL helper. Kept separate from the client
* so the protocol/state logic stays readable.
*/
/** @param {string} url */
export function trimTrailingSlash(url) {
return url.endsWith("/") ? url.slice(0, -1) : url;
}
/**
* Pull the assistant transcript out of a `response.done` payload. The text
* lives in `response.output[].content[].transcript` (audio) or `.text`. Used as
* the source of truth for interrupted replies, where the dedicated
* `*.transcript.done` event may never arrive.
* @param {any} response
* @returns {string}
*/
export function extractResponseTranscript(response) {
const output = response?.output;
if (!Array.isArray(output)) return "";
/** @type {string[]} */
const parts = [];
for (const item of output) {
for (const part of item?.content ?? []) {
const text = part?.transcript ?? part?.text;
if (typeof text === "string" && text.trim()) parts.push(text.trim());
}
}
return parts.join(" ").trim();
}
/** @param {ArrayBuffer} buf */
export function base64FromArrayBuffer(buf) {
const bytes = new Uint8Array(buf);
// Chunked encoding so we don't blow up the call stack on long buffers.
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, /** @type {number[]} */ (
/** @type {unknown} */ (bytes.subarray(i, i + chunk))
));
}
return btoa(binary);
}
/** @param {string} b64 */
export function base64ToBytes(b64) {
const binary = atob(b64);
const len = binary.length;
const out = new Uint8Array(len);
for (let i = 0; i < len; i++) out[i] = binary.charCodeAt(i);
return out;
}
+98
View File
@@ -0,0 +1,98 @@
// @ts-check
/**
* Orb spectrum visualiser. Each animation frame it reads two AnalyserNodes (the
* mic input and the TTS output) and maps the low-frequency speech energy onto
* the orb's CSS custom properties:
* - `--bar0`..`--bar4` the 5-band level meter
* - `--ai-audio-level` the global "Reachy talks" glow / scale pulse
*
* The bottom of the FFT is where speech energy lives, so the band edges stay
* low — that keeps the bars dancing on voice rather than on noise. While the AI
* is speaking we source the bars from the OUTPUT analyser so the orb pulses with
* Reachy's voice instead of sitting dead while the user is silent.
*/
// Exported so the client can size its AnalyserNodes to match our buffer.
export const VIS_FFT_SIZE = 256;
const VIS_BAND_COUNT = 5;
const VIS_BAND_EDGES = [2, 5, 9, 16, 28, 52];
const VIS_ATTACK = 0.6; // weight for new sample on upswing (snappy)
const VIS_RELEASE = 0.18; // weight for new sample on decay (gentle fade)
export class OrbVisualiser {
/**
* @param {AnalyserNode} micAnalyser
* @param {AnalyserNode} outAnalyser
* @param {() => boolean} isAiSpeaking Source the bars from the AI output when
* true, otherwise from the mic.
*/
constructor(micAnalyser, outAnalyser, isAiSpeaking) {
this._mic = micAnalyser;
this._out = outAnalyser;
this._isAiSpeaking = isAiSpeaking;
this._buf = new Uint8Array(micAnalyser.frequencyBinCount);
this._bands = new Float32Array(VIS_BAND_COUNT);
this._aiLevel = 0;
/** @type {number | null} */
this._frame = null;
}
/** Begin the rAF loop (idempotent). */
start() {
if (this._frame !== null) return;
const root = document.documentElement;
const tick = () => {
this._frame = requestAnimationFrame(tick);
this._update(root);
};
this._frame = requestAnimationFrame(tick);
}
/** Stop the loop and clear the CSS vars so the orb returns to rest. */
stop() {
if (this._frame !== null) {
cancelAnimationFrame(this._frame);
this._frame = null;
}
const root = document.documentElement;
for (let i = 0; i < VIS_BAND_COUNT; i++) root.style.removeProperty(`--bar${i}`);
root.style.removeProperty("--ai-audio-level");
}
/** @param {HTMLElement} root */
_update(root) {
// Mic bars: split FFT into 5 log-ish bands, smooth, write CSS vars.
const source = this._isAiSpeaking() ? this._out : this._mic;
source.getByteFrequencyData(this._buf);
for (let b = 0; b < VIS_BAND_COUNT; b++) {
const lo = VIS_BAND_EDGES[b];
const hi = VIS_BAND_EDGES[b + 1];
let sum = 0;
let n = 0;
for (let i = lo; i < hi && i < this._buf.length; i++) {
sum += this._buf[i];
n += 1;
}
const target = n > 0 ? sum / (n * 255) : 0;
const prev = this._bands[b];
const k = target > prev ? VIS_ATTACK : VIS_RELEASE;
const next = prev + (target - prev) * k;
this._bands[b] = next;
root.style.setProperty(`--bar${b}`, next.toFixed(3));
}
// Global AI audio level: peak of the output analyser, used by the CSS to
// make the orb's glow / scale react to Reachy's voice.
this._out.getByteFrequencyData(this._buf);
let peak = 0;
const limit = Math.min(this._buf.length, VIS_BAND_EDGES[VIS_BAND_COUNT]);
for (let i = 0; i < limit; i++) {
if (this._buf[i] > peak) peak = this._buf[i];
}
const aiTarget = peak / 255;
const k = aiTarget > this._aiLevel ? VIS_ATTACK : VIS_RELEASE;
this._aiLevel = this._aiLevel + (aiTarget - this._aiLevel) * k;
root.style.setProperty("--ai-audio-level", this._aiLevel.toFixed(3));
}
}
File diff suppressed because it is too large Load Diff
+243
View File
@@ -0,0 +1,243 @@
/* =========================================================
摄像头实时识别 Hook —— YOLO 人脸检测(后端推理)
链路:前端 getUserMedia 本地预览 → 每 ~700ms canvas 抽帧 → JPEG base64
POST {API_BASE}/api/vision/frame → 后端 YOLO(yolov8n-face) 推理
→ 返回人脸数/框 → 前端驱动状态机
- 状态机:检测到人脸(面向大屏)持续 ≥10s → onTrigger;触发后静默 60s
- 帧经局域网传到后端(大屏机同机部署则不出设备);画面预览仍本地
========================================================= */
import { useCallback, useEffect, useRef, useState } from 'react';
import { API_BASE } from '../config';
export const VISION_DWELL_MS = 10000; // 面向停留阈值
export const VISION_SILENT_MS = 180000; // 触发(自动问候)后静默 3 分钟,期间不重复触发
const DETECT_INTERVAL_MS = 700; // ~1.4fps(后端推理 ~100-200ms + 传输)
const FRAME_W = 480;
const FRAME_H = 360;
export default function useVisionDetection({ onTrigger, onGesture, enabled = true }) {
const [cameraOn, setCameraOn] = useState(false);
const [status, setStatus] = useState('off'); // off|loading|detecting|facing|triggered|silent|error
const [faces, setFaces] = useState(0);
const [dwellMs, setDwellMs] = useState(0);
const [gesture, setGesture] = useState(null); // raise(举手,toggle 对话)|null
const [errorInfo, setErrorInfo] = useState(null);
// 诊断:抽帧画面平均亮度(本地计算,<15 ≈ 黑屏)
const [frameLum, setFrameLum] = useState(null);
// 诊断:后端推理链路状态 loading|ok|failed
const [modelPhase, setModelPhase] = useState('loading');
const [modelErr, setModelErr] = useState(null);
// 诊断:最近一次推理/请求异常
const [detectErr, setDetectErr] = useState(null);
// 诊断:后端推理耗时 ms
const [latency, setLatency] = useState(null);
const videoRef = useRef(null);
const streamRef = useRef(null);
const timerRef = useRef(null);
const canvasRef = useRef(null);
const cancelledRef = useRef(false); // StrictMode/HMR 卸载标记
const stateRef = useRef({
facingSince: 0, silentUntil: 0, lastDetect: 0, lastDiag: 0, lastPlayRetry: 0, failCount: 0,
// 手势状态机:举手持续 1.5s → raise(前端据此 toggle 对话:无对话开、有对话停)
raiseActive: false, raiseStart: 0, raiseTriggered: false,
});
const triggerRef = useRef(onTrigger);
triggerRef.current = onTrigger;
const gestureRef = useRef(onGesture);
gestureRef.current = onGesture;
const loop = useCallback(async () => {
if (cancelledRef.current) return;
const video = videoRef.current;
const now = Date.now();
const st = stateRef.current;
if (!video || video.readyState < 2) {
// 视频未就绪:兜底重试 play()Safari 不自动播放 srcObject 流)
if (video && video.srcObject && now - st.lastPlayRetry > 2000) {
st.lastPlayRetry = now;
video.play().catch(() => { /* 继续等待 */ });
}
timerRef.current = setTimeout(loop, 400);
return;
}
if (now - st.lastDetect < DETECT_INTERVAL_MS) {
timerRef.current = setTimeout(loop, 120);
return;
}
st.lastDetect = now;
try {
// 抽帧
if (!canvasRef.current) {
canvasRef.current = document.createElement('canvas');
canvasRef.current.width = FRAME_W;
canvasRef.current.height = FRAME_H;
}
const ctx = canvasRef.current.getContext('2d', { willReadFrequently: true });
ctx.drawImage(video, 0, 0, FRAME_W, FRAME_H);
// 亮度诊断(本地,每 2s
if (now - st.lastDiag > 2000) {
st.lastDiag = now;
try {
const d = ctx.getImageData(0, 0, FRAME_W, FRAME_H).data;
let s = 0;
for (let i = 0; i < d.length; i += 4) s += d[i] + d[i + 1] + d[i + 2];
setFrameLum(Math.round(s / (d.length / 4) / 3));
} catch {
setFrameLum(-1);
}
}
// JPEG base64 → 后端 YOLO
const b64 = canvasRef.current.toDataURL('image/jpeg', 0.6).split(',')[1];
setModelPhase('ok');
const res = await fetch(`${API_BASE}/api/vision/frame`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: b64 }),
});
const data = await res.json();
if (!data?.ok) throw new Error(data?.error || `HTTP ${res.status}`);
setDetectErr(null);
const n = data.faces ?? 0;
setFaces(n);
setLatency(data.latency_ms);
st.failCount = 0;
// ── 手势状态机:举手持续 1.5s → raise;前端据此 toggle(无对话开 / 有对话停)──
const raised = data.raised?.[0]; // 取第一个举手的人/手
if (raised) {
if (!st.raiseActive) {
st.raiseActive = true;
st.raiseStart = now;
st.raiseTriggered = false;
}
if (!st.raiseTriggered && now - st.raiseStart >= 1500) {
st.raiseTriggered = true;
setGesture('raise');
console.warn(`[vision] 手势:举手(持续 ${Math.round((now - st.raiseStart) / 1000)}s)→ toggle 对话`);
gestureRef.current?.('raise');
}
} else if (st.raiseActive) {
// 举手消失:重置,允许下次举手再次触发
st.raiseActive = false;
st.raiseStart = 0;
st.raiseTriggered = false;
setTimeout(() => setGesture(null), 2500);
}
// 状态机:有人脸(面向大屏)持续 10s → 触发问候;触发后静默 60s
if (now < st.silentUntil) {
st.facingSince = 0;
setDwellMs(0);
setStatus('silent');
} else if (n > 0) {
st.facingSince = st.facingSince || now;
const d = now - st.facingSince;
setDwellMs(d);
setStatus('facing');
if (d >= VISION_DWELL_MS) {
st.silentUntil = now + VISION_SILENT_MS;
st.facingSince = 0;
setDwellMs(0);
setStatus('triggered');
triggerRef.current?.();
setTimeout(() => setStatus('detecting'), 3000);
}
} else {
st.facingSince = 0;
setDwellMs(0);
setStatus('detecting');
}
} catch (e) {
// 后端不可达/推理失败:错误显示到页面,连续 5 次标记链路失败
setDetectErr(e?.message || String(e));
st.failCount += 1;
if (st.failCount >= 5) {
setModelPhase('failed');
setModelErr(e?.message || String(e));
}
}
timerRef.current = setTimeout(loop, 120);
}, []);
const startCamera = useCallback(async () => {
if (cameraOn || !enabled) return;
cancelledRef.current = false;
setStatus('loading');
setModelPhase('loading');
setModelErr(null);
setDetectErr(null);
try {
if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) {
throw new Error('非安全上下文:请通过 http://localhost 或 HTTPS 访问(当前地址浏览器禁止摄像头)');
}
// 设备预检
try {
const devs = await navigator.mediaDevices.enumerateDevices();
if (!devs.some((d) => d.kind === 'videoinput')) {
const e = new Error('未检测到摄像头设备(请确认 USB 摄像头已接入本机)');
e.name = 'NotFoundError';
throw e;
}
} catch (e) {
if (e?.name === 'NotFoundError') throw e;
}
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 480 },
audio: false,
});
if (cancelledRef.current) {
stream.getTracks().forEach((t) => t.stop());
return;
}
streamRef.current = stream;
const video = videoRef.current;
if (!video) throw new Error('video element not mounted');
video.srcObject = stream;
try { await video.play(); } catch { /* muted autoplay 兜底 */ }
if (cancelledRef.current) return;
setCameraOn(true);
setStatus('detecting');
timerRef.current = setTimeout(loop, 300);
} catch (e) {
if (cancelledRef.current) return;
setErrorInfo({ name: e?.name ?? 'Error', message: e?.message ?? String(e) });
setStatus('error');
console.warn('[vision] 摄像头启动失败:', e?.name, e?.message);
}
}, [cameraOn, enabled, loop]);
const stopCamera = useCallback(() => {
cancelledRef.current = true;
if (timerRef.current) clearTimeout(timerRef.current);
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
stateRef.current = { facingSince: 0, silentUntil: 0, lastDetect: 0, lastDiag: 0, lastPlayRetry: 0, failCount: 0,
raiseActive: false, raiseStart: 0, raiseTriggered: false };
setCameraOn(false);
setFaces(0);
setDwellMs(0);
setGesture(null);
setFrameLum(null);
setModelPhase('loading');
setModelErr(null);
setDetectErr(null);
setLatency(null);
setStatus('off');
}, []);
// 默认开启:挂载即启动;卸载时释放
useEffect(() => {
if (enabled) void startCamera();
return () => stopCamera();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled]);
return {
cameraOn, status, faces, dwellMs, gesture, latency, frameLum, modelPhase, modelErr, detectErr,
errorInfo, startCamera, stopCamera, videoRef,
};
}