2026-06-12 17:46:25 +08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// EdgeProcessor — 边缘预处理(VAD + 关键帧检测)
|
|
|
|
|
|
// 职责:浏览器端语音活动检测、关键帧筛选
|
|
|
|
|
|
// 技术:@ricky0123/vad-web(VAD)、ONNX Runtime Web(关键帧检测)
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
2026-06-13 11:10:29 +08:00
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
|
|
import { MicVAD } from "@ricky0123/vad-web";
|
2026-06-14 14:34:30 +08:00
|
|
|
|
import { useI18n } from "../../lib/i18n";
|
2026-06-12 17:46:25 +08:00
|
|
|
|
|
|
|
|
|
|
export interface VADOptions {
|
2026-06-13 11:10:29 +08:00
|
|
|
|
/** 语音结束回调,携带录音 Float32Array(16kHz) */
|
2026-06-12 17:46:25 +08:00
|
|
|
|
onSpeechEnd?: (audio: Float32Array) => void;
|
|
|
|
|
|
/** 语音开始回调 */
|
|
|
|
|
|
onSpeechStart?: () => void;
|
2026-06-13 11:10:29 +08:00
|
|
|
|
/** 语音过短被忽略回调 */
|
|
|
|
|
|
onVADMisfire?: () => void;
|
2026-06-12 17:46:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 11:10:29 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 语音活动检测 Hook
|
|
|
|
|
|
* 基于 @ricky0123/vad-web 的 MicVAD,检测用户说话并回调
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function useVAD(options?: VADOptions) {
|
2026-06-14 14:34:30 +08:00
|
|
|
|
const { t } = useI18n();
|
2026-06-13 11:10:29 +08:00
|
|
|
|
const [isSpeaking, setIsSpeaking] = useState(false);
|
|
|
|
|
|
const [isReady, setIsReady] = useState(false);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
const vadRef = useRef<MicVAD | null>(null);
|
|
|
|
|
|
const optionsRef = useRef(options);
|
|
|
|
|
|
|
|
|
|
|
|
// 保持 options 引用最新,避免回调闭包问题
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
optionsRef.current = options;
|
|
|
|
|
|
}, [options]);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 初始化 VAD 并开始监听
|
|
|
|
|
|
* @param stream 麦克风 MediaStream(由外部管理)
|
|
|
|
|
|
*/
|
|
|
|
|
|
const start = useCallback(async (stream: MediaStream) => {
|
|
|
|
|
|
// 如果已有实例,先销毁
|
|
|
|
|
|
if (vadRef.current) {
|
|
|
|
|
|
await vadRef.current.destroy();
|
|
|
|
|
|
vadRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const vad = await MicVAD.new({
|
|
|
|
|
|
getStream: () => Promise.resolve(stream),
|
|
|
|
|
|
startOnLoad: true,
|
|
|
|
|
|
model: "legacy",
|
2026-06-13 22:20:31 +08:00
|
|
|
|
// 指向 node_modules 中的 WASM 文件(由 Vite 中间件提供)
|
|
|
|
|
|
onnxWASMBasePath: "/node_modules/onnxruntime-web/dist/",
|
2026-06-13 11:10:29 +08:00
|
|
|
|
|
|
|
|
|
|
onSpeechStart: () => {
|
|
|
|
|
|
setIsSpeaking(true);
|
|
|
|
|
|
optionsRef.current?.onSpeechStart?.();
|
|
|
|
|
|
},
|
2026-06-12 17:46:25 +08:00
|
|
|
|
|
2026-06-13 11:10:29 +08:00
|
|
|
|
onSpeechEnd: (audio: Float32Array) => {
|
|
|
|
|
|
setIsSpeaking(false);
|
|
|
|
|
|
optionsRef.current?.onSpeechEnd?.(audio);
|
|
|
|
|
|
},
|
2026-06-12 17:46:25 +08:00
|
|
|
|
|
2026-06-13 11:10:29 +08:00
|
|
|
|
onVADMisfire: () => {
|
|
|
|
|
|
setIsSpeaking(false);
|
|
|
|
|
|
optionsRef.current?.onVADMisfire?.();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// VAD 参数(对齐 docs/06-语音交互.md 推荐值)
|
|
|
|
|
|
positiveSpeechThreshold: 0.5,
|
|
|
|
|
|
negativeSpeechThreshold: 0.35,
|
|
|
|
|
|
redemptionMs: 300,
|
|
|
|
|
|
preSpeechPadMs: 300,
|
|
|
|
|
|
minSpeechMs: 250,
|
|
|
|
|
|
submitUserSpeechOnPause: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
vadRef.current = vad;
|
|
|
|
|
|
setIsReady(true);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
} catch (err) {
|
2026-06-14 14:34:30 +08:00
|
|
|
|
const message = err instanceof Error ? err.message : t("error.vadInit");
|
2026-06-13 11:10:29 +08:00
|
|
|
|
setError(message);
|
|
|
|
|
|
console.error("[VAD] 初始化失败:", err);
|
|
|
|
|
|
}
|
2026-06-12 17:46:25 +08:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-06-13 11:10:29 +08:00
|
|
|
|
/** 停止 VAD 并销毁实例 */
|
|
|
|
|
|
const stop = useCallback(async () => {
|
|
|
|
|
|
if (vadRef.current) {
|
|
|
|
|
|
await vadRef.current.destroy();
|
|
|
|
|
|
vadRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
setIsReady(false);
|
|
|
|
|
|
setIsSpeaking(false);
|
2026-06-12 17:46:25 +08:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-06-13 11:10:29 +08:00
|
|
|
|
// 组件卸载时清理
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
vadRef.current?.destroy();
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
return { isSpeaking, isReady, error, start, stop };
|
2026-06-12 17:46:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 14:38:15 +08:00
|
|
|
|
// ---- 关键帧检测 ----
|
|
|
|
|
|
|
|
|
|
|
|
const DETECT_WIDTH = 160;
|
|
|
|
|
|
const DETECT_HEIGHT = 120;
|
|
|
|
|
|
const DIFF_THRESHOLD = 30;
|
|
|
|
|
|
|
|
|
|
|
|
/** 离屏 canvas,用于降采样 */
|
|
|
|
|
|
let offscreen: HTMLCanvasElement | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
function getOffscreen(): HTMLCanvasElement {
|
|
|
|
|
|
if (!offscreen) {
|
|
|
|
|
|
offscreen = document.createElement("canvas");
|
|
|
|
|
|
offscreen.width = DETECT_WIDTH;
|
|
|
|
|
|
offscreen.height = DETECT_HEIGHT;
|
|
|
|
|
|
}
|
|
|
|
|
|
return offscreen;
|
|
|
|
|
|
}
|
2026-06-12 17:46:25 +08:00
|
|
|
|
|
2026-06-13 14:38:15 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 将 video 元素缩放采样为 Uint8ClampedArray(RGBA)
|
|
|
|
|
|
* 返回 null 如果 video 未就绪
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function sampleFrame(video: HTMLVideoElement): Uint8ClampedArray | null {
|
|
|
|
|
|
if (video.readyState < 2) return null;
|
|
|
|
|
|
const canvas = getOffscreen();
|
2026-06-14 19:54:22 +08:00
|
|
|
|
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
2026-06-13 14:38:15 +08:00
|
|
|
|
if (!ctx) return null;
|
|
|
|
|
|
ctx.drawImage(video, 0, 0, DETECT_WIDTH, DETECT_HEIGHT);
|
|
|
|
|
|
return ctx.getImageData(0, 0, DETECT_WIDTH, DETECT_HEIGHT).data;
|
|
|
|
|
|
}
|
2026-06-12 17:46:25 +08:00
|
|
|
|
|
2026-06-13 14:38:15 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 对比两帧像素差异
|
|
|
|
|
|
* @returns { isKeyframe: boolean, similarity: number }
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function compareFrames(
|
|
|
|
|
|
prev: Uint8ClampedArray,
|
|
|
|
|
|
curr: Uint8ClampedArray,
|
|
|
|
|
|
): { isKeyframe: boolean; similarity: number } {
|
|
|
|
|
|
let diffSum = 0;
|
|
|
|
|
|
const len = Math.min(prev.length, curr.length);
|
|
|
|
|
|
const pixelCount = len / 4;
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < len; i += 4) {
|
|
|
|
|
|
// 只比较 RGB,跳过 Alpha
|
|
|
|
|
|
diffSum += Math.abs(prev[i] - curr[i]);
|
|
|
|
|
|
diffSum += Math.abs(prev[i + 1] - curr[i + 1]);
|
|
|
|
|
|
diffSum += Math.abs(prev[i + 2] - curr[i + 2]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const avgDiff = diffSum / (pixelCount * 3);
|
|
|
|
|
|
const similarity = 1 - avgDiff / 255;
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
isKeyframe: avgDiff > DIFF_THRESHOLD,
|
|
|
|
|
|
similarity,
|
|
|
|
|
|
};
|
2026-06-12 17:46:25 +08:00
|
|
|
|
}
|