Skip to content

Commit 24bca6b

Browse files
committed
refactor(classroom): 六项审查修复 + 审查报告 + 计划状态更新
审查修复: SmartSampler 字段方法同名冲突(高)/ 漏捕检测定时器卸载清理(中)/ vadMarker 与 UnifiedTimeline 回 300 行内 / 形态3 单字灌水留给幻觉过滤 / 单字修正允许入库 审查报告: docs/knowledge/solutions/2026-08-classroom-recognition-review.md(六维复查+发现修复记录+遗留观察+门禁复核) 计划状态: P0/P1 全部完成、P2 按交付口径完成、实施偏差记录
1 parent 69d4fad commit 24bca6b

7 files changed

Lines changed: 228 additions & 70 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* RecentKeyframesStrip — 实时截图缩略流(P1-9,自 UnifiedTimeline 拆出)
3+
*
4+
* @ai-context: 最近 6 帧仍有 imageBase64 的关键帧横向缩略条(识别过程
5+
* 可见性);imageBase64 已被增量分析清空的帧自动跳过。纯展示组件。
6+
*/
7+
import { Camera } from 'lucide-react';
8+
import type { KeyFrame } from '@/lib/capture';
9+
10+
interface RecentKeyframesStripProps {
11+
keyframes: KeyFrame[];
12+
}
13+
14+
export function RecentKeyframesStrip({ keyframes }: RecentKeyframesStripProps) {
15+
const recentThumbs = keyframes.filter((kf) => kf.imageBase64).slice(-6);
16+
if (recentThumbs.length === 0) return null;
17+
return (
18+
<div className="flex items-center gap-1.5 px-3 py-1.5 border-t border-border/20 bg-bg-secondary/30 overflow-x-auto flex-shrink-0">
19+
<Camera className="w-3 h-3 text-text-quaternary flex-shrink-0" strokeWidth={1.5} />
20+
{recentThumbs.map((kf) => (
21+
<img
22+
key={kf.id}
23+
src={`data:image/jpeg;base64,${kf.imageBase64}`}
24+
alt={`关键帧 ${new Date(kf.timestamp).toLocaleTimeString()}`}
25+
title={new Date(kf.timestamp).toLocaleTimeString()}
26+
className="w-16 h-9 rounded-kb-xs object-cover border border-border/30 flex-shrink-0"
27+
/>
28+
))}
29+
</div>
30+
);
31+
}
32+
33+
export default RecentKeyframesStrip;

‎client/src/features/classroom/components/UnifiedTimeline.tsx‎

Lines changed: 7 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
/**
22
* UnifiedTimeline — smart 路径统一内容时间线
33
* 时间轴事件(关键帧/语音/书签/自动锚点)与实时转写文本按时间戳合并为
4-
* 一条内容流,替代原先"时间轴面板 + 独立转录列表"的分离展示(内测反馈
5-
* 体验割裂:事件与文本需上下对照两个列表)。
4+
* 一条内容流,替代原先"时间轴面板 + 独立转录列表"的分离展示。
65
*
7-
* @ai-context: 转写行保留课后内联编辑(搬自 LiveTranscript 的编辑交互);
8-
* 新条目/实时 partial 到达自动滚到底部;仅 smart 路径使用,不替代
9-
* notes 模块共享的 SmartCapturePanel。
10-
* @ai-context: Unified timeline merging timeline events and live transcript
6+
* @ai-context: 转写行保留课后内联编辑与说话人标注;新条目/实时 partial
7+
* 到达自动滚到底部;仅 smart 路径使用。
8+
* @ai-context EN: Unified timeline merging timeline events and transcript
119
* rows sorted by timestamp; edits stay available after the session ends.
1210
*/
1311
import { useEffect, useRef, useState, useCallback } from 'react';
@@ -16,6 +14,7 @@ import { cn } from '@/lib/utils';
1614
// 毫秒时间戳 → MM:SS(相对会话起始时间,D12 收敛至 lib/utils/time)
1715
import { formatSessionElapsed as formatRelativeTime } from '@/lib/utils/time';
1816
import type { SessionBundle, TimelineEntry, KeyFrame } from '@/lib/capture';
17+
import { RecentKeyframesStrip } from './RecentKeyframesStrip';
1918

2019
/** 转写条目(含 P1-2 用户修正文本,存在时优先显示) */
2120
export interface TranscriptEntry {
@@ -87,11 +86,6 @@ export function UnifiedTimeline({ bundle, liveTranscripts, autoAnchors = [], par
8786
const audioSegments = bundle.audioSegments ?? [];
8887
const sessionStartMs = timeline[0]?.timestamp ?? Date.now();
8988

90-
// P1-9 实时截图流:最近 6 帧仍有 imageBase64 的关键帧(增序展示)
91-
const recentThumbs = (bundle.keyframes ?? [])
92-
.filter((kf) => kf.imageBase64)
93-
.slice(-6);
94-
9589
// 事件 + 锚点 + 转写按时间戳合并排序
9690
const rows: Row[] = [
9791
...timeline.map((entry) => ({ kind: 'event' as const, ts: entry.timestamp, entry })),
@@ -305,21 +299,8 @@ export function UnifiedTimeline({ bundle, liveTranscripts, autoAnchors = [], par
305299
)}
306300
</div>
307301

308-
{/* P1-9 实时截图流:最近 6 帧缩略横条(识别过程可见性,imageBase64 已被分析清空的帧跳过) */}
309-
{recentThumbs.length > 0 && (
310-
<div className="flex items-center gap-1.5 px-3 py-1.5 border-t border-border/20 bg-bg-secondary/30 overflow-x-auto flex-shrink-0">
311-
<Camera className="w-3 h-3 text-text-quaternary flex-shrink-0" strokeWidth={1.5} />
312-
{recentThumbs.map((kf) => (
313-
<img
314-
key={kf.id}
315-
src={`data:image/jpeg;base64,${kf.imageBase64}`}
316-
alt={`关键帧 ${new Date(kf.timestamp).toLocaleTimeString()}`}
317-
title={new Date(kf.timestamp).toLocaleTimeString()}
318-
className="w-16 h-9 rounded-kb-xs object-cover border border-border/30 flex-shrink-0"
319-
/>
320-
))}
321-
</div>
322-
)}
302+
{/* P1-9 实时截图流:最近 6 帧缩略横条(识别过程可见性) */}
303+
<RecentKeyframesStrip keyframes={keyframes} />
323304

324305
{/* 底部统计栏 */}
325306
<div className={cn(

‎client/src/features/classroom/hooks/useClassroomEvents.ts‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ export function useClassroomEvents({
115115
const lastForceCaptureAtRef = useRef(0);
116116
/** P1-8 最近一次关键帧时间戳(漏捕检测依据:指令后无新帧即提示) */
117117
const latestKeyframeTsRef = useRef(0);
118+
/** P1-8 漏捕检测定时器集合(组件卸载时清理,避免卸载后 toast 泄漏) */
119+
const missedCaptureTimersRef = useRef<number[]>([]);
118120
/** @ai-context 会话时间基准(epoch ms):记录首帧 timestamp,供 analyzePartial 换算相对秒数 */
119121
const sessionStartMsRef = useRef<number | null>(null);
120122
/** 采集会话 ID(smart:keyframe 事件携带),供笔记持久化关联与关键帧图片清理 */
@@ -165,13 +167,19 @@ export function useClassroomEvents({
165167
lastForceCaptureAtRef.current = now;
166168
captureManager.requestForceCapture();
167169
}
168-
// P1-8 漏捕检测:指令后 3s 无新关键帧 → 提示手动补截
170+
// P1-8 漏捕检测:指令后 3s 无新关键帧 → 提示手动补截(定时器登记,卸载清理)
169171
const commandAt = now;
170-
window.setTimeout(() => {
172+
const timer = window.setTimeout(() => {
171173
if (latestKeyframeTsRef.current < commandAt) {
172174
toast({ type: 'warning', silent: true, message: '这一步的画面可能没捕捉到,可按 C 键手动截图' });
173175
}
174176
}, MISSED_CAPTURE_CHECK_MS);
177+
missedCaptureTimersRef.current.push(timer);
178+
// 上限保护:定时器集合仅保留最近 20 个(已触发的清出)
179+
if (missedCaptureTimersRef.current.length > 20) {
180+
const expired = missedCaptureTimersRef.current.splice(0, missedCaptureTimersRef.current.length - 20);
181+
expired.forEach((t) => window.clearTimeout(t));
182+
}
175183
}
176184
};
177185

@@ -181,6 +189,14 @@ export function useClassroomEvents({
181189
return () => setOnAsrFallback(null);
182190
}, [toast]);
183191

192+
// P1-8 卸载清理:漏捕检测定时器(防卸载后 toast 泄漏)
193+
useEffect(() => {
194+
return () => {
195+
for (const t of missedCaptureTimersRef.current) window.clearTimeout(t);
196+
missedCaptureTimersRef.current = [];
197+
};
198+
}, []);
199+
184200
// 会话结束回到 idle 时重置时间基准(暂停/恢复不重置,避免相对时间戳跳变)
185201
useEffect(() => {
186202
if (status === 'idle') {

‎client/src/lib/capture/sileroVad.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,37 @@
1313
* 造成推理滞后累积,合并策略保证概率新鲜度与 IPC 负载有界。
1414
*/
1515

16+
// ================================================================
17+
// Silero 精判参数(P0-2;VADMarker 消费)
18+
// ================================================================
19+
20+
/** RMS 超阈但 Silero 概率低于此值 → 候选噪声(连续 2 块确认后抑制) */
21+
export const SILERO_NOISE_MAX_PROB = 0.12;
22+
/** 静音复核:该时间窗内 Silero 平均概率高于此值 → 视为语音仍在(推迟分段) */
23+
export const SILERO_SPEECH_MIN_PROB = 0.5;
24+
/** 静音复核时间窗(ms) */
25+
export const SILERO_RECHECK_WINDOW_MS = 400;
26+
/** 静音复核推迟上限(ms):超限强制分段,防止 Silero 误判长噪声为语音导致段无限拉长 */
27+
export const SILERO_MAX_SILENCE_EXTENSION_MS = 3000;
28+
29+
/** Silero 三态分类结果 */
30+
export type SileroClass = 'noise' | 'speech' | 'unknown';
31+
32+
/**
33+
* Silero 三态分类(P0-2,VADMarker 委托的纯函数):
34+
* - 'noise':最近概率与窗口均值都明确低于阈值(键盘/空调/音乐等持续噪声)
35+
* - 'speech':概率明确高于噪声阈值(真实语音)
36+
* - 'unknown':概率源不可用或暂无结果(不干预,走纯 RMS 路径)
37+
*/
38+
export function classifySileroProb(
39+
latest: number | null,
40+
recent: number | null,
41+
): SileroClass {
42+
if (latest === null || recent === null) return 'unknown';
43+
if (latest < SILERO_NOISE_MAX_PROB && recent < SILERO_NOISE_MAX_PROB) return 'noise';
44+
return 'speech';
45+
}
46+
1647
/** Silero 概率源接口(可注入 mock 供单测) */
1748
export interface SileroProbSource {
1849
/** 最近一次推理得到的语音概率(0-1);尚无结果或不可用时为 null */

‎client/src/lib/capture/vadMarker.ts‎

Lines changed: 23 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,25 @@
11
/**
22
* VAD 音频标记器 — Path B 语音段检测与分段
33
*
4-
* @ai-context
5-
* Path B 通过 RMS 能量检测将连续语音切段,
6-
* 每段完成后立即触发 onSegmentReady 回调,支持流式 ASR 转写。
7-
* @ai-context: 校准仅对 microphone 源生效;loopback(网课系统环回)为数字
8-
* 信号无环境底噪,构造时直接标记已校准并使用预设阈值,不进入校准期
9-
* (避免 UI 出现无意义的“正在校准音频阈值”提示)。
10-
*
11-
* @ai-context: 背景噪声校准为现场课程麦克风输入场景设计——麦克风存在真实
12-
* 环境底噪(空调/键盘/人声嘴杂),以 sourceType: 'microphone' 构造时
13-
* 启用前 N 块自适应校准。后续可考虑将校准结果持久化供下次会话作为初始
14-
* 阈值,避免每次启动等待采样期。
4+
* @ai-context: RMS 能量检测将连续语音切段,每段完成后触发 onSegmentReady
5+
* 回调供流式 ASR 转写。校准仅对 microphone 源生效(前 N 块自适应阈值);
6+
* loopback(网课系统环回)为数字信号无环境底噪,构造即已校准用预设阈值。
7+
* P0-2 起叠加 Silero 精判:噪声抑制(暂存-确认两段式)+ 静音复核(低能量
8+
* 语音尾推迟分段,上限保护),概率源不可用时回退纯 RMS。
9+
* English: RMS-based speech segmentation for Path B; microphone sources get
10+
* adaptive calibration, loopback uses preset thresholds. Silero refinement
11+
* (P0-2) adds noise suppression and silence re-check with RMS fallback.
1512
*/
1613

1714
import type { AudioChunkData, AudioSegment, TimelineEntry } from './captureTypes';
1815
import { encodeWavBase64 } from './wavEncoder';
19-
import type { SileroProbSource } from './sileroVad';
20-
21-
// ================================================================
22-
// Silero 精判参数(P0-2)
23-
// ================================================================
24-
25-
/** RMS 超阈但 Silero 概率低于此值 → 候选噪声(连续 2 块确认后抑制) */
26-
const SILERO_NOISE_MAX_PROB = 0.12;
27-
/** 静音复核:该时间窗内 Silero 平均概率高于此值 → 视为语音仍在(推迟分段) */
28-
const SILERO_SPEECH_MIN_PROB = 0.5;
29-
/** 静音复核时间窗(ms) */
30-
const SILERO_RECHECK_WINDOW_MS = 400;
31-
/** 静音复核推迟上限(ms):超限强制分段,防止 Silero 误判长噪声为语音导致段无限拉长 */
32-
const SILERO_MAX_SILENCE_EXTENSION_MS = 3000;
16+
import {
17+
classifySileroProb,
18+
SILERO_RECHECK_WINDOW_MS,
19+
SILERO_SPEECH_MIN_PROB,
20+
SILERO_MAX_SILENCE_EXTENSION_MS,
21+
type SileroProbSource,
22+
} from './sileroVad';
3323

3424
// ================================================================
3525
// 配置类型
@@ -42,15 +32,9 @@ export interface VADMarkerConfig {
4232
silenceDurationMs: number;
4333
/** 最短语音时长(ms),低于则丢弃,默认 300 */
4434
minSpeechDurationMs: number;
45-
/**
46-
* 最长语音段时长(ms),达到即强制分段,默认 28000——
47-
* 保证段长兼容 GLM-ASR 备选(≤30s 硬限制),也避免长段拉高转写延迟
48-
*/
35+
/** 最长语音段时长(ms),达到即强制分段,默认 28000(兼容 GLM-ASR ≤30s 硬限制) */
4936
maxSpeechDurationMs: number;
50-
/**
51-
* 音频源类型:loopback(系统环回,网课默认)跳过背景噪声校准;
52-
* microphone(现场课程麦克风)启用前 N 块自适应校准
53-
*/
37+
/** 音频源类型:loopback 跳过背景噪声校准;microphone 启用前 N 块自适应校准 */
5438
sourceType: 'loopback' | 'microphone';
5539
}
5640

@@ -280,18 +264,15 @@ export class VADMarker {
280264
}
281265

282266
/**
283-
* Silero 三态分类(P0-2):
284-
* - 'noise':最近概率与窗口均值都明确低于阈值(键盘/空调/音乐等持续噪声)
285-
* - 'speech':概率明确高于噪声阈值(真实语音)
286-
* - 'unknown':概率源不可用或暂无结果(不干预,走纯 RMS 路径)
267+
* Silero 三态分类(P0-2,纯函数委托见 sileroVad.classifySileroProb):
268+
* 'noise'=持续噪声 / 'speech'=真实语音 / 'unknown'=概率源不可用(不干预)
287269
*/
288270
private classifySilero(): 'noise' | 'speech' | 'unknown' {
289271
if (!this.silero) return 'unknown';
290-
const latest = this.silero.latestProb();
291-
const recent = this.silero.recentProb(SILERO_RECHECK_WINDOW_MS);
292-
if (latest === null || recent === null) return 'unknown';
293-
if (latest < SILERO_NOISE_MAX_PROB && recent < SILERO_NOISE_MAX_PROB) return 'noise';
294-
return 'speech';
272+
return classifySileroProb(
273+
this.silero.latestProb(),
274+
this.silero.recentProb(SILERO_RECHECK_WINDOW_MS),
275+
);
295276
}
296277

297278
// ================================================================

0 commit comments

Comments
 (0)