Skip to content

Commit 980ea39

Browse files
committed
feat(classroom): P0-7 识别统计条(引擎徽标+帧数+句数+VAD状态)
1 parent 6652b7f commit 980ea39

3 files changed

Lines changed: 183 additions & 0 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* RecognitionStatsBar 渲染测试(P0-7)
3+
*
4+
* @ai-context: 锁定识别统计条展示契约:引擎徽标三态(本地流式 > 本地按段 >
5+
* 云端)、关键帧/转写计数、VAD 语音状态(语音中/静默/已停止)。
6+
*/
7+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8+
import { render, screen } from '@testing-library/react';
9+
import { RecognitionStatsBar } from './RecognitionStatsBar';
10+
import type { VADStats } from '@/lib/capture/vadMarker';
11+
12+
function makeVad(lastVoiceTimestamp: number): VADStats {
13+
return {
14+
currentThreshold: 0.008,
15+
segmentCount: 0,
16+
lastVoiceTimestamp,
17+
calibrated: true,
18+
processedChunks: 0,
19+
};
20+
}
21+
22+
describe('RecognitionStatsBar', () => {
23+
beforeEach(() => {
24+
vi.useFakeTimers();
25+
vi.setSystemTime(new Date('2026-08-16T10:00:00Z'));
26+
});
27+
afterEach(() => vi.useRealTimers());
28+
29+
it('本地流式引擎徽标 + 帧数/句数统计', () => {
30+
render(
31+
<RecognitionStatsBar
32+
status="capturing"
33+
keyframeCount={12}
34+
transcribedCount={34}
35+
vadStats={makeVad(0)}
36+
streamingAsrActive
37+
localAsrReady
38+
/>,
39+
);
40+
expect(screen.getByText('本地流式')).toBeTruthy();
41+
expect(screen.getByText('12')).toBeTruthy();
42+
expect(screen.getByText('34')).toBeTruthy();
43+
});
44+
45+
it('云端转写引擎徽标(本地不可用且非流式)', () => {
46+
render(
47+
<RecognitionStatsBar
48+
status="capturing"
49+
keyframeCount={0}
50+
transcribedCount={0}
51+
vadStats={null}
52+
streamingAsrActive={false}
53+
localAsrReady={false}
54+
/>,
55+
);
56+
expect(screen.getByText('云端转写')).toBeTruthy();
57+
expect(screen.getByText('—')).toBeTruthy();
58+
});
59+
60+
it('VAD 语音状态:最近 2s 有语音 → 语音中;否则静默;停止态 → 已停止', () => {
61+
const now = Date.now();
62+
const { rerender } = render(
63+
<RecognitionStatsBar
64+
status="capturing"
65+
keyframeCount={0}
66+
transcribedCount={0}
67+
vadStats={makeVad(now - 500)}
68+
streamingAsrActive={false}
69+
localAsrReady={false}
70+
/>,
71+
);
72+
expect(screen.getByText('语音中')).toBeTruthy();
73+
74+
rerender(
75+
<RecognitionStatsBar
76+
status="capturing"
77+
keyframeCount={0}
78+
transcribedCount={0}
79+
vadStats={makeVad(now - 10_000)}
80+
streamingAsrActive={false}
81+
localAsrReady={false}
82+
/>,
83+
);
84+
expect(screen.getByText('静默')).toBeTruthy();
85+
86+
rerender(
87+
<RecognitionStatsBar
88+
status="idle"
89+
keyframeCount={0}
90+
transcribedCount={0}
91+
vadStats={makeVad(now - 500)}
92+
streamingAsrActive={false}
93+
localAsrReady={false}
94+
/>,
95+
);
96+
expect(screen.getByText('已停止')).toBeTruthy();
97+
});
98+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* RecognitionStatsBar — 识别统计条(P0-7)
3+
*
4+
* @ai-context: 识别过程可见性(UX-F 基础):会话中实时展示识别引擎徽标、
5+
* 已捕捉关键帧数、已转写句数、VAD 语音状态。数据全部来自既有会话状态
6+
* (smartBundle.keyframes / transcribedCount / vadStats),纯展示零逻辑,
7+
* 不引入新订阅。
8+
* @ai-context EN: Live recognition status bar: engine badge (local streaming /
9+
* local segment / cloud), captured keyframes, transcribed segments, and VAD
10+
* voice state. Pure presentation over existing session state.
11+
*/
12+
import { Cpu, Camera, Mic, Activity } from 'lucide-react';
13+
import type { SessionStatus } from '@/lib/capture';
14+
import type { VADStats } from '@/lib/capture/vadMarker';
15+
16+
interface RecognitionStatsBarProps {
17+
status: SessionStatus;
18+
keyframeCount: number;
19+
transcribedCount: number;
20+
vadStats: VADStats | null;
21+
/** 真流式 ASR 激活(smart 路径 Zipformer 在线识别) */
22+
streamingAsrActive: boolean;
23+
/** 本地 ASR 引擎就绪(否则转写走云端) */
24+
localAsrReady: boolean;
25+
}
26+
27+
/** 引擎徽标文案与配色(本地流式 > 本地按段 > 云端) */
28+
function engineBadge(streamingAsrActive: boolean, localAsrReady: boolean): { label: string; cls: string } {
29+
if (streamingAsrActive) {
30+
return { label: '本地流式', cls: 'text-emerald-600 bg-emerald-500/10' };
31+
}
32+
if (localAsrReady) {
33+
return { label: '本地按段', cls: 'text-emerald-600 bg-emerald-500/10' };
34+
}
35+
return { label: '云端转写', cls: 'text-accent-600 bg-accent-500/10' };
36+
}
37+
38+
/** VAD 语音状态:最近 2s 内有语音活动视为「语音中」(vadStats 5s 粒度) */
39+
function vadVoiceState(vadStats: VADStats | null): { label: string; speaking: boolean } {
40+
if (!vadStats) return { label: '—', speaking: false };
41+
const recent = Date.now() - vadStats.lastVoiceTimestamp < 2000;
42+
return recent ? { label: '语音中', speaking: true } : { label: '静默', speaking: false };
43+
}
44+
45+
export function RecognitionStatsBar({
46+
status, keyframeCount, transcribedCount, vadStats, streamingAsrActive, localAsrReady,
47+
}: RecognitionStatsBarProps) {
48+
const engine = engineBadge(streamingAsrActive, localAsrReady);
49+
const voice = vadVoiceState(vadStats);
50+
51+
return (
52+
<div className="mx-4 mt-2 flex items-center gap-3 px-3 py-1.5 rounded-kb-md bg-bg-elevated/40 border border-border/20 text-[11px] text-text-tertiary flex-wrap">
53+
<span className="flex items-center gap-1">
54+
<Cpu className="w-3 h-3" strokeWidth={1.5} />
55+
识别引擎
56+
<span className={`px-1.5 py-0.5 rounded-kb-sm font-medium ${engine.cls}`}>{engine.label}</span>
57+
</span>
58+
<span className="flex items-center gap-1">
59+
<Camera className="w-3 h-3" strokeWidth={1.5} />
60+
已捕捉 <strong className="text-text-secondary">{keyframeCount}</strong> 帧
61+
</span>
62+
<span className="flex items-center gap-1">
63+
<Mic className="w-3 h-3" strokeWidth={1.5} />
64+
已转写 <strong className="text-text-secondary">{transcribedCount}</strong> 句
65+
</span>
66+
<span className="flex items-center gap-1">
67+
<Activity className={`w-3 h-3 ${voice.speaking ? 'text-emerald-500' : ''}`} strokeWidth={1.5} />
68+
{status === 'capturing' ? voice.label : '已停止'}
69+
</span>
70+
</div>
71+
);
72+
}
73+
74+
export default RecognitionStatsBar;

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { useClassroomCapture } from '../hooks/useClassroomCapture';
1919
import { SegmentList } from './SegmentList';
2020
import { UnifiedTimeline } from './UnifiedTimeline';
2121
import { ClassroomStatusBanners } from './ClassroomStatusBanners';
22+
import { RecognitionStatsBar } from './RecognitionStatsBar';
23+
import { isLocalAsrReady } from '../utils/asrTranscriber';
2224
import { AnalysisPreview } from '@/features/notes/components/AnalysisPreview';
2325
import { VideoRecordPanel } from '@/features/notes/components/VideoRecordPanel';
2426
import SessionQAPanel from './SessionQAPanel';
@@ -84,6 +86,15 @@ export function SessionContentView({ capture, onOpenNoteDialog }: SessionContent
8486
audioHealth={capture.audioHealth}
8587
vadStats={capture.vadStats}
8688
/>
89+
{/* P0-7 识别统计条:引擎徽标 / 帧数 / 句数 / VAD 语音状态 */}
90+
<RecognitionStatsBar
91+
status={capture.status}
92+
keyframeCount={capture.smartBundle?.keyframes?.length ?? 0}
93+
transcribedCount={capture.transcribedCount}
94+
vadStats={capture.vadStats}
95+
streamingAsrActive={capture.streamingAsrActive}
96+
localAsrReady={isLocalAsrReady()}
97+
/>
8798
{/* M2 自动锚点以时间线行展示(autoAnchors 独立数据源) */}
8899
<UnifiedTimeline
89100
bundle={capture.smartBundle}

0 commit comments

Comments
 (0)