Skip to content

Commit 75f3f70

Browse files
committed
fix(classroom): P0-4/P0-5/P0-6 离线ASR重复/CPU/准确率修复
P0-4: 端点规则调优(rule2 静音1.2s→2.0s、minUtteranceLength 8→10)+ 跨final重叠去重 + 跨标点两字词重复压缩(确认语白名单)+ flush尾句去重 P0-5: 线程数默认min(4,cpuCount)上限8 + 静音块隔块喂入 + 非16kHz前置阻断 P0-6: AI课程识别术语动态热词注入 + local_asr_stream_set_hotwords(下一断句重建流生效)+ P1-1重打分接入点标注
1 parent 8d28984 commit 75f3f70

9 files changed

Lines changed: 221 additions & 19 deletions

File tree

‎client/electron/ai/local-asr/SherpaAsrService.ts‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,10 @@ export function getOnlineRecognizer(): OnlineRecognizer | null {
126126
const modelDir = getModelDir();
127127
const config = getLocalAsrConfig();
128128
const cpuCount = Math.max(1, os.cpus().length);
129-
const rawThreads = config.threads > 0 ? config.threads : Math.max(1, cpuCount - 1);
130-
// 线程数上限:不超过 CPU 核心数(防止用户配置过大值导致 CPU 过载)
131-
const threads = Math.min(rawThreads, cpuCount);
129+
// P0-5 CPU 优化:默认 min(4, cpuCount)(zipformer 小模型线程扩展性差,
130+
// 占满 CPU-1 线程是内测「离线 ASR CPU 100%」主因),用户配置硬上限 8
131+
const rawThreads = config.threads > 0 ? config.threads : Math.min(4, cpuCount);
132+
const threads = Math.min(rawThreads, 8);
132133

133134
try {
134135
_onlineRecognizer = instantiateOnline(sherpa, {
@@ -151,10 +152,11 @@ export function getOnlineRecognizer(): OnlineRecognizer | null {
151152
enableEndpoint: true,
152153
endpointConfig: {
153154
rule1: { minTrailingSilence: 2.4 },
154-
// rule2 必须配 minUtteranceLength:仅给 minTrailingSilence 时默认 0,
155-
// 等于"1.2s 停顿即断句"——中文口语停顿频繁,短句被切碎会导致
156-
// 相邻句边界词重复观感。8s 覆盖绝大多数短句,兼顾实时性
157-
rule2: { minTrailingSilence: 1.2, minUtteranceLength: 8 },
155+
// P0-4 重复修复:rule2 静音 1.2s→2.0s、minUtteranceLength 8→10——
156+
// 中文口语句内停顿普遍 1-2s,1.2s 阈值误断句会把同一句切成两段、
157+
// 段首重复段尾(内测「识别偶发重复」主要来源);2.0s 覆盖绝大多数
158+
// 句内停顿,minUtteranceLength=10 保证短句不被切碎
159+
rule2: { minTrailingSilence: 2.0, minUtteranceLength: 10 },
158160
rule3: { minUtteranceLength: 20 },
159161
},
160162
});
@@ -236,6 +238,8 @@ export async function transcribeStreaming(
236238

237239
const result = recognizer.getResult(stream);
238240
// 输出后处理:相邻重复压缩 + 幻觉过滤
241+
// P1-1 两遍重打分接入点:此处为本地按段转写最终文本出口,
242+
// SenseVoice 重打分将在此处对 text 做句末复核(高置信度者胜出)
239243
const text = cleanAsrResult(result.text ?? '');
240244
const durationMs = Date.now() - startTime;
241245

@@ -259,9 +263,11 @@ export async function transcribeLocal(
259263
const config = getLocalAsrConfig();
260264
const language = options?.language ?? config.language;
261265

262-
// 防御性校验:非 16kHz 采样率会严重降低识别质量
266+
// P0-5 前置阻断:非 16kHz 采样率会严重降低识别质量(模型按 16k 训练),
267+
// 此前仅 warn 放行——内测「离线 ASR 识别不准确」的隐性来源之一。
268+
// 此处直接拒绝,渲染进程在采集启动前亦有前置校验(asrTranscriber)
263269
if (options?.sampleRate && options.sampleRate !== 16000) {
264-
logger.warn(`[LocalASR] 非预期采样率: ${options.sampleRate}Hz,本地 ASR 要求 16kHz 单声道 Float32 PCM`);
270+
throw new Error(`本地 ASR 要求 16kHz 单声道 Float32 PCM,收到 ${options.sampleRate}Hz`);
265271
}
266272

267273
// base64 → Float32Array

‎client/electron/ai/local-asr/index.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
import {
3030
startStreamingAsr,
3131
stopStreamingAsr,
32+
updateStreamingHotwords,
3233
} from './streamingAsr.js';
3334
import {
3435
downloadModel,
@@ -108,6 +109,16 @@ export function registerLocalAsrHandlers(): void {
108109
return { success: true };
109110
});
110111

112+
// P0-6:会话热词动态更新——课程识别成功后渲染进程调用,
113+
// 下一个端点断句时以最新热词重建流(无需重启、不丢当前句)
114+
safeHandle(
115+
'local_asr_stream_set_hotwords',
116+
async (_event, args: { hotwords?: string }) => {
117+
updateStreamingHotwords(args?.hotwords);
118+
return { success: true };
119+
},
120+
);
121+
111122
// ── 模型管理 ──
112123
safeHandle('local_asr_get_models', async () => {
113124
return {

‎client/electron/ai/local-asr/streamingAsr.ts‎

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,17 @@
1212

1313
import type { BrowserWindow } from 'electron';
1414
import { logger } from '../../logger.js';
15-
import { cleanAsrResult } from '../../../src/lib/capture/asrFilters.js';
15+
import { cleanAsrResult, computeRms, SILENCE_RMS_THRESHOLD } from '../../../src/lib/capture/asrFilters.js';
1616
import { getOnlineRecognizer, feedWaveform, type OnlineStream } from './SherpaAsrService.js';
1717

1818
/** partial 推送节流:两次 partial 推送的最小间隔(ms) */
1919
const PARTIAL_EMIT_INTERVAL_MS = 150;
20+
/**
21+
* P0-5 CPU 优化:静音块隔块喂入——静音期模型反复 decode 无新内容却持续
22+
* 消耗 CPU,每隔 1 个静音块喂 1 次(端点检测延迟最多增加一个采集块粒度,
23+
* 可接受);非静音块全部喂入保证识别实时性。
24+
*/
25+
const SILENT_FEED_SKIP_COUNT = 1;
2026

2127
// ================================================================
2228
// 单例状态
@@ -28,6 +34,16 @@ let _sampleRate = 16000;
2834
/** 当前已识别文本(用于变化检测,reset 后清空) */
2935
let _lastPartialText = '';
3036
let _lastPartialEmitAt = 0;
37+
/**
38+
* 会话级最新热词(P0-6):渲染进程课程识别成功后经
39+
* local_asr_stream_set_hotwords 更新;每个端点断句后以最新热词重建流
40+
* (createStream(hotwords)),无需重启即可让新词条生效。
41+
*/
42+
let _latestHotwords: string | undefined;
43+
/** 最近一次推送的 final 文本(停止 flush 时与尾句去重) */
44+
let _lastFinalText = '';
45+
/** 静音块跳过计数(隔块喂入,P0-5) */
46+
let _silentSkipCounter = 0;
3147

3248
/** 流式 ASR 是否激活 */
3349
export function isStreamingActive(): boolean {
@@ -66,11 +82,14 @@ export function startStreamingAsr(
6682

6783
try {
6884
// 透传热词增强字符串(zipformer-transducer 支持 createStream(hotwords))
85+
_latestHotwords = hotwords;
6986
_stream = recognizer.createStream(hotwords);
7087
_win = win;
7188
_sampleRate = sampleRate;
7289
_lastPartialText = '';
7390
_lastPartialEmitAt = 0;
91+
_lastFinalText = '';
92+
_silentSkipCounter = 0;
7493
logger.info(`[StreamingASR] 已启动 (sampleRate=${sampleRate}${hotwords ? `, hotwords=${hotwords}` : ''})`);
7594
return { success: true, sampleRate };
7695
} catch (err) {
@@ -80,6 +99,15 @@ export function startStreamingAsr(
8099
}
81100
}
82101

102+
/**
103+
* 更新会话热词(P0-6):渲染进程课程识别成功后调用。
104+
* 不立即重启流(会丢当前句),在下一个端点断句时以最新热词重建流生效。
105+
*/
106+
export function updateStreamingHotwords(hotwords: string | undefined): void {
107+
_latestHotwords = hotwords;
108+
logger.info(`[StreamingASR] 热词已更新,将在下一断句生效${hotwords ? ` (${hotwords.length} chars)` : ''}`);
109+
}
110+
83111
/**
84112
* 喂入一个音频块(Float32 PCM,16k 单声道):解码并推送 partial / final。
85113
*
@@ -100,18 +128,30 @@ export function feedStreamingAsr(audioBuffer: ArrayBuffer, sampleRate?: number):
100128
if (samples.length === 0) return;
101129
const rate = sampleRate ?? _sampleRate;
102130

131+
// P0-5 静音隔块喂入:静音块每隔 1 块喂 1 次(端点检测延迟 +1 块粒度),
132+
// 降低静音期(课堂大部分时间)无效 decode 的 CPU 占用
133+
if (computeRms(audioBuffer) < SILENCE_RMS_THRESHOLD) {
134+
_silentSkipCounter++;
135+
if (_silentSkipCounter <= SILENT_FEED_SKIP_COUNT) return;
136+
_silentSkipCounter = 0;
137+
} else {
138+
_silentSkipCounter = 0;
139+
}
140+
103141
try {
104142
// 统一适配层:新旧版 sherpa-onnx-node 的 acceptWaveform 签名不同
105143
feedWaveform(_stream, rate, samples);
106144
while (recognizer.isReady(_stream)) {
107145
recognizer.decode(_stream);
108146
}
109147

110-
// 端点检测:断句 → 推送 final 并 reset
148+
// 端点检测:断句 → 推送 final 并以最新热词重建流(P0-6 热词生效点)
111149
if (recognizer.isEndpoint(_stream)) {
112150
// 输出后处理:相邻重复压缩 + 幻觉过滤(静音段重复输出防护)
113151
const finalText = cleanAsrResult(recognizer.getResult(_stream).text ?? '');
114-
recognizer.reset(_stream);
152+
_lastFinalText = finalText;
153+
// 重建流:以会话最新热词 createStream(热词变化无需重启即可生效)
154+
_stream = recognizer.createStream(_latestHotwords);
115155
_lastPartialText = '';
116156
_lastPartialEmitAt = 0;
117157
if (finalText) {
@@ -153,7 +193,9 @@ export function stopStreamingAsr(): void {
153193
const tailText = recognizer
154194
? cleanAsrResult(recognizer.getResult(_stream).text ?? '')
155195
: '';
156-
if (tailText) {
196+
// P0-4 flush 去重:尾句与最近一次 final 完全一致时不再推送
197+
// (端点已推送过该句,flush 重复上屏是停止瞬间重复的兜底场景)
198+
if (tailText && tailText !== _lastFinalText) {
157199
emit('asr_stream_final', { text: tailText, timestamp: Date.now() });
158200
}
159201
}
@@ -167,5 +209,7 @@ export function stopStreamingAsr(): void {
167209
_win = null;
168210
_lastPartialText = '';
169211
_lastPartialEmitAt = 0;
212+
_lastFinalText = '';
213+
_silentSkipCounter = 0;
170214
logger.info('[StreamingASR] 已停止');
171215
}

‎client/electron/preload.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ const ALLOWED_CHANNELS = [
132132
'local_asr_stream_available',
133133
'local_asr_stream_start',
134134
'local_asr_stream_stop',
135+
// 本地 ASR 流式会话热词动态更新(P0-6)
136+
'local_asr_stream_set_hotwords',
135137
// 本地 Silero VAD(主进程 onnxruntime 推理,P0-2)
136138
'vad_silero_process',
137139
// MCP 学习记忆服务器应用内授权开关

‎client/src/env.d.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ declare global {
9797
audio_capture_start: (options: { microphone: boolean; chunkDurationMs?: number; sampleRate?: number; channels?: number }) => Promise<{ success: boolean; error?: string }>;
9898
local_asr_stream_start: (options: { sampleRate?: number }) => Promise<{ success: boolean; error?: string }>;
9999
local_asr_stream_stop: () => Promise<{ success: boolean }>;
100+
/** P0-6: 会话热词动态更新(课程识别成功后调用,下一断句生效) */
101+
local_asr_stream_set_hotwords: (args: { hotwords?: string }) => Promise<{ success: boolean }>;
100102
audio_capture_stop: () => Promise<{ success: boolean }>;
101103
/** P0-2: 本地 Silero VAD 推理(主进程 onnxruntime,16kHz Float32 PCM 块) */
102104
vad_silero_process: (args: { samples: ArrayBuffer; sampleRate?: number; reset?: boolean }) => Promise<{ probability: number | null; available: boolean }>;

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ import { detectCourseFromFrame } from '@/lib/ai/courseDetector';
3333
import { remapKeyframeMarkers } from '../utils/tipTapImageUtils';
3434
import { persistKeyframeImage } from '../utils/keyframePersistence';
3535
import { transcribeWithRetry, toAsrLanguage, useAsrSemaphore, isLocalAsrReady, setOnAsrFallback } from '../utils/asrTranscriber';
36-
import { applySessionReplaces, getSessionHotwordsString } from '../utils/hotwordRuntime';
37-
import { cleanAsrResult } from '@/lib/capture/asrFilters';
36+
import { applySessionReplaces, getSessionHotwordsString, addDynamicBoosts } from '../utils/hotwordRuntime';
37+
import { cleanAsrResult, dedupeAcrossFinals } from '@/lib/capture/asrFilters';
3838

3939
/** 触发一次增量分析所需的关键帧数 */
4040
const INCREMENTAL_BATCH_SIZE = 5;
@@ -94,6 +94,8 @@ export function useClassroomEvents({
9494
/** 真流式激活标志的 ref 桥接:供按段转写订阅器读取(避免重订阅) */
9595
const streamingAsrActiveRef = useRef(streamingAsrActive);
9696
streamingAsrActiveRef.current = streamingAsrActive;
97+
/** P0-4 跨 final 去重:上一 final 清洗后文本(端点误断句时前句尾=后句头) */
98+
const lastFinalTextRef = useRef('');
9799
/** 会话状态 ref 桥接:流式 partial/final 仅在 capturing 时上屏(暂停时不更新) */
98100
const statusRef = useRef(status);
99101
statusRef.current = status;
@@ -173,6 +175,15 @@ export function useClassroomEvents({
173175
.then((detected) => {
174176
if (detected) {
175177
setCourseMeta((prev) => ({ ...prev, ...detected, detectedBy: 'ai' }));
178+
// P0-6 准确率即时提升:识别出的术语注入动态热词
179+
// (课程名/学科/建议术语),并通知主进程流式 ASR 更新
180+
// 会话热词(下一断句重建流生效,无需重启、不丢当前句)
181+
if (detected.customTerms?.length) {
182+
addDynamicBoosts(detected.customTerms);
183+
const hotwords = getSessionHotwordsString();
184+
window.electronAPI?.local_asr_stream_set_hotwords({ hotwords })
185+
.catch(() => { /* 静默:热词更新失败不阻断识别 */ });
186+
}
176187
}
177188
})
178189
.catch(() => { /* 静默降级到规则模式 */ });
@@ -321,7 +332,12 @@ export function useClassroomEvents({
321332
const data = args[0] as { text: string; timestamp: number };
322333
setPartialText('');
323334
// 双保险:主进程已 clean,此处兜底云端/旧版本主进程的未清洗输出
324-
const text = cleanAsrResult(data?.text ?? '');
335+
const cleaned = cleanAsrResult(data?.text ?? '');
336+
if (!cleaned) return;
337+
// P0-4 跨 final 重叠去重:端点误断句时前句尾词重复出现在后句开头
338+
// ("今天讲矩阵"+"矩阵的特征值"),去重截断后句重叠前缀
339+
const text = dedupeAcrossFinals(lastFinalTextRef.current, cleaned);
340+
lastFinalTextRef.current = text || lastFinalTextRef.current;
325341
if (!text) return;
326342
const id = crypto.randomUUID();
327343
const timestamp = data.timestamp || Date.now();

‎client/src/features/classroom/utils/hotwordRuntime.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,20 @@ export function getSessionHotwordsString(): string {
7373
const joined = activeBoosts.join(' ');
7474
return joined.length > 200 ? joined.slice(0, 200) : joined;
7575
}
76+
77+
/**
78+
* P0-6:动态注入课程热词(AI 课程识别成功后调用)。
79+
* 与用户静态词表(hotwordStore)区分:动态词不持久化、会话结束清空;
80+
* 去重 + 上限保护(30 条),防止 AI 识别返回超长术语表撑爆 200 字符热词串。
81+
*/
82+
export function addDynamicBoosts(terms: string[]): void {
83+
const cleaned = [
84+
...new Set(
85+
(terms ?? [])
86+
.map((t) => t.trim())
87+
.filter((t) => t.length > 0 && t.length <= 20),
88+
),
89+
];
90+
if (cleaned.length === 0) return;
91+
activeBoosts = [...new Set([...activeBoosts, ...cleaned])].slice(0, 30);
92+
}

‎client/src/lib/capture/asrFilters.test.ts‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
isLikelyHallucination,
1111
collapseAdjacentDuplicates,
1212
cleanAsrResult,
13+
dedupeAcrossFinals,
1314
SILENCE_RMS_THRESHOLD,
1415
} from './asrFilters';
1516

@@ -79,10 +80,20 @@ describe('collapseAdjacentDuplicates', () => {
7980
expect(collapseAdjacentDuplicates('这个知识点知识点很重要')).toBe('这个知识点很重要');
8081
});
8182

82-
it('标点分隔的重复不压缩(真实语言确认语:"对,对""好,好")', () => {
83+
it('标点分隔的单字确认语不压缩(真实语言:"对,对""好,好")', () => {
8384
expect(collapseAdjacentDuplicates('对,对,你说得对')).toBe('对,对,你说得对');
8485
expect(collapseAdjacentDuplicates('好,好,我知道了')).toBe('好,好,我知道了');
85-
expect(collapseAdjacentDuplicates('就是,就是,那这样吧')).toBe('就是,就是,那这样吧');
86+
});
87+
88+
it('形态 3(P0-4):跨单个标点的两字词重复压缩("就是,就是"→"就是")', () => {
89+
expect(collapseAdjacentDuplicates('就是,就是')).toBe('就是');
90+
expect(collapseAdjacentDuplicates('就是,就是,那这样吧')).toBe('就是,那这样吧');
91+
expect(collapseAdjacentDuplicates('矩阵,矩阵的特征值')).toBe('矩阵的特征值');
92+
});
93+
94+
it('形态 3 白名单:两字确认语不压缩("是的,是的"是真实确认强调)', () => {
95+
expect(collapseAdjacentDuplicates('是的,是的')).toBe('是的,是的');
96+
expect(collapseAdjacentDuplicates('好的,好的')).toBe('好的,好的');
8697
});
8798

8899
it('正常文本不受影响', () => {
@@ -125,3 +136,29 @@ describe('cleanAsrResult', () => {
125136
expect(cleanAsrResult('软腭和咽部肌肉松弛导致气流受阻')).toBe('软腭和咽部肌肉松弛导致气流受阻');
126137
});
127138
});
139+
140+
describe('dedupeAcrossFinals(P0-4 跨 final 重叠去重)', () => {
141+
it('完全一致的重复推送丢弃', () => {
142+
expect(dedupeAcrossFinals('今天讲矩阵', '今天讲矩阵')).toBe('');
143+
});
144+
145+
it('后缀-前缀重叠截断(端点误断句:"…矩阵"+"矩阵的特征值…")', () => {
146+
expect(dedupeAcrossFinals('今天讲矩阵', '矩阵的特征值很重要')).toBe('的特征值很重要');
147+
expect(dedupeAcrossFinals('特征值', '特征值和特征向量')).toBe('和特征向量');
148+
});
149+
150+
it('单字重叠(长度 1)不截断——避免误伤正常连接', () => {
151+
expect(dedupeAcrossFinals('我们开始', '始解这个问题')).toBe('始解这个问题');
152+
});
153+
154+
it('截断后与前句高度相似视为整体重复丢弃', () => {
155+
expect(dedupeAcrossFinals('矩阵的特征值', '矩阵的特征值矩阵的特征值')).toBe('');
156+
expect(dedupeAcrossFinals('线性代数很重要', '线性代数很重要线性代数很重要')).toBe('');
157+
});
158+
159+
it('无重叠时原样返回', () => {
160+
expect(dedupeAcrossFinals('今天讲矩阵', '接下来看特征值')).toBe('接下来看特征值');
161+
expect(dedupeAcrossFinals('', '新句子')).toBe('新句子');
162+
expect(dedupeAcrossFinals('前句', '')).toBe('');
163+
});
164+
});

0 commit comments

Comments
 (0)