Merge pull request '添加计时器,麦克风,摄像头开关' (#45) from develop-frontend8 into develop 33分钟前 #46
@@ -237,6 +237,16 @@ body {
|
|||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Stats Bar ---- */
|
||||||
|
|
||||||
|
.stats-bar {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Buttons ---- */
|
/* ---- Buttons ---- */
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function App() {
|
|||||||
stream,
|
stream,
|
||||||
config,
|
config,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
|
stats,
|
||||||
startSession,
|
startSession,
|
||||||
stopSession,
|
stopSession,
|
||||||
interrupt,
|
interrupt,
|
||||||
@@ -106,6 +107,13 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{isConnected && (stats.queryCount > 0 || stats.totalTokens > 0) && (
|
||||||
|
<div className="stats-bar">
|
||||||
|
{stats.queryCount > 0 && <span>{stats.queryCount} 次请求</span>}
|
||||||
|
{stats.totalTokens > 0 && <span>· {stats.totalTokens} tokens</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<footer className="app-footer">
|
<footer className="app-footer">
|
||||||
{!isConnected ? (
|
{!isConnected ? (
|
||||||
<button className="btn btn--primary" onClick={startSession}>
|
<button className="btn btn--primary" onClick={startSession}>
|
||||||
|
|||||||
@@ -103,19 +103,61 @@ export function useVAD(options?: VADOptions) {
|
|||||||
return { isSpeaking, isReady, error, start, stop };
|
return { isSpeaking, isReady, error, start, stop };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 关键帧检测(ONNX Runtime Web)----
|
// ---- 关键帧检测 ----
|
||||||
|
|
||||||
export function useKeyframeDetection() {
|
const DETECT_WIDTH = 160;
|
||||||
// TODO: 加载 ONNX 模型后设为 true
|
const DETECT_HEIGHT = 120;
|
||||||
const isReady = false;
|
const DIFF_THRESHOLD = 30;
|
||||||
|
|
||||||
const isKeyframe = useCallback(
|
/** 离屏 canvas,用于降采样 */
|
||||||
(_currentFrame: ImageData, _previousFrame: ImageData): boolean => {
|
let offscreen: HTMLCanvasElement | null = null;
|
||||||
// TODO: 实现像素差异对比
|
|
||||||
return true; // 暂时所有帧都视为关键帧
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { isReady, isKeyframe };
|
function getOffscreen(): HTMLCanvasElement {
|
||||||
|
if (!offscreen) {
|
||||||
|
offscreen = document.createElement("canvas");
|
||||||
|
offscreen.width = DETECT_WIDTH;
|
||||||
|
offscreen.height = DETECT_HEIGHT;
|
||||||
|
}
|
||||||
|
return offscreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 video 元素缩放采样为 Uint8ClampedArray(RGBA)
|
||||||
|
* 返回 null 如果 video 未就绪
|
||||||
|
*/
|
||||||
|
export function sampleFrame(video: HTMLVideoElement): Uint8ClampedArray | null {
|
||||||
|
if (video.readyState < 2) return null;
|
||||||
|
const canvas = getOffscreen();
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.drawImage(video, 0, 0, DETECT_WIDTH, DETECT_HEIGHT);
|
||||||
|
return ctx.getImageData(0, 0, DETECT_WIDTH, DETECT_HEIGHT).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对比两帧像素差异
|
||||||
|
* @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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,18 +14,27 @@ import { showToast } from "../lib/toast";
|
|||||||
import { loadConfig, saveConfig } from "../lib/storage";
|
import { loadConfig, saveConfig } from "../lib/storage";
|
||||||
import { useCamera } from "../components/CameraManager";
|
import { useCamera } from "../components/CameraManager";
|
||||||
import { useMicrophone } from "../components/MicManager";
|
import { useMicrophone } from "../components/MicManager";
|
||||||
import { useVAD } from "../components/EdgeProcessor";
|
import { useVAD, sampleFrame, compareFrames } from "../components/EdgeProcessor";
|
||||||
import { useWebSocketManager } from "../components/WebSocketManager";
|
import { useWebSocketManager } from "../components/WebSocketManager";
|
||||||
import type { ChatMessage, SessionConfig, ServerMessage, LLMDoneMessage } from "../types";
|
import type { ChatMessage, SessionConfig, ServerMessage, LLMDoneMessage } from "../types";
|
||||||
|
|
||||||
const MAX_HISTORY_ROUNDS = 10;
|
const MAX_HISTORY_ROUNDS = 10;
|
||||||
|
|
||||||
|
export interface SessionStats {
|
||||||
|
queryCount: number;
|
||||||
|
totalTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function useVisionSession() {
|
export function useVisionSession() {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [currentReply, setCurrentReply] = useState<string>("");
|
const [currentReply, setCurrentReply] = useState<string>("");
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [isAudioPlaying, setIsAudioPlaying] = useState(false);
|
const [isAudioPlaying, setIsAudioPlaying] = useState(false);
|
||||||
const [config, setConfig] = useState<SessionConfig>(loadConfig);
|
const [config, setConfig] = useState<SessionConfig>(loadConfig);
|
||||||
|
const [stats, setStats] = useState<SessionStats>({ queryCount: 0, totalTokens: 0 });
|
||||||
|
|
||||||
|
// 上一帧采样数据(用于关键帧检测)
|
||||||
|
const prevFrameRef = useRef<Uint8ClampedArray | null>(null);
|
||||||
|
|
||||||
// 对话历史(role + content),用于多轮上下文
|
// 对话历史(role + content),用于多轮上下文
|
||||||
const historyRef = useRef<Array<{ role: string; content: string }>>([]);
|
const historyRef = useRef<Array<{ role: string; content: string }>>([]);
|
||||||
@@ -107,6 +116,23 @@ export function useVisionSession() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关键帧检测:与上一帧对比,相似度过高则跳过
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video) {
|
||||||
|
const currentSample = sampleFrame(video);
|
||||||
|
if (currentSample && prevFrameRef.current) {
|
||||||
|
const { similarity } = compareFrames(prevFrameRef.current, currentSample);
|
||||||
|
if (similarity > 0.9) {
|
||||||
|
console.log(`[Session] 画面无变化 (similarity=${similarity.toFixed(2)}),跳过`);
|
||||||
|
prevFrameRef.current = currentSample;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentSample) {
|
||||||
|
prevFrameRef.current = currentSample;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const requestId = uuidv4();
|
const requestId = uuidv4();
|
||||||
send({
|
send({
|
||||||
type: "query",
|
type: "query",
|
||||||
@@ -115,6 +141,9 @@ export function useVisionSession() {
|
|||||||
audio: encodeAudioToBase64(audio),
|
audio: encodeAudioToBase64(audio),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 更新请求统计
|
||||||
|
setStats((prev) => ({ ...prev, queryCount: prev.queryCount + 1 }));
|
||||||
|
|
||||||
// 添加用户消息(STT 流式结果会逐步更新文本)
|
// 添加用户消息(STT 流式结果会逐步更新文本)
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
@@ -122,7 +151,7 @@ export function useVisionSession() {
|
|||||||
]);
|
]);
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
},
|
},
|
||||||
[captureFrame, send]
|
[captureFrame, send, videoRef]
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,6 +188,14 @@ export function useVisionSession() {
|
|||||||
historyRef.current = historyRef.current.slice(-MAX_HISTORY_ROUNDS * 2);
|
historyRef.current = historyRef.current.slice(-MAX_HISTORY_ROUNDS * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 累计 token 统计
|
||||||
|
if (done.tokens_used?.total) {
|
||||||
|
setStats((prev) => ({
|
||||||
|
...prev,
|
||||||
|
totalTokens: prev.totalTokens + done.tokens_used.total,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
@@ -222,7 +259,9 @@ export function useVisionSession() {
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
setCurrentReply("");
|
setCurrentReply("");
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
|
setStats({ queryCount: 0, totalTokens: 0 });
|
||||||
historyRef.current = [];
|
historyRef.current = [];
|
||||||
|
prevFrameRef.current = null;
|
||||||
}, [stopVAD, stopMic, stopCamera, disconnect]);
|
}, [stopVAD, stopMic, stopCamera, disconnect]);
|
||||||
|
|
||||||
/** 打断当前回复 */
|
/** 打断当前回复 */
|
||||||
@@ -257,6 +296,7 @@ export function useVisionSession() {
|
|||||||
stream,
|
stream,
|
||||||
config,
|
config,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
|
stats,
|
||||||
startSession,
|
startSession,
|
||||||
stopSession,
|
stopSession,
|
||||||
interrupt,
|
interrupt,
|
||||||
|
|||||||
43
frontend/src/lib/sampling.ts
Normal file
43
frontend/src/lib/sampling.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Sampling — 混合采样策略
|
||||||
|
// 来源:docs/08-成本控制.md — "定时低频 + 事件高频"
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 静默时采样间隔(ms) */
|
||||||
|
const IDLE_INTERVAL = 5000;
|
||||||
|
/** 用户说话时采样间隔(ms) */
|
||||||
|
const ACTIVE_INTERVAL = 1000;
|
||||||
|
|
||||||
|
export class SamplingController {
|
||||||
|
private lastSampleTime = 0;
|
||||||
|
private _isUserSpeaking = false;
|
||||||
|
|
||||||
|
/** 设置用户是否正在说话 */
|
||||||
|
set speaking(value: boolean) {
|
||||||
|
this._isUserSpeaking = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get speaking(): boolean {
|
||||||
|
return this._isUserSpeaking;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前采样间隔 */
|
||||||
|
get interval(): number {
|
||||||
|
return this._isUserSpeaking ? ACTIVE_INTERVAL : IDLE_INTERVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否应该采样(基于时间间隔) */
|
||||||
|
shouldSample(): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastSampleTime < this.interval) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.lastSampleTime = now;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 强制允许下次采样(如用户刚说完话时) */
|
||||||
|
resetTimer(): void {
|
||||||
|
this.lastSampleTime = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user