diff --git a/main/helpers/asrRepetitionGuard.ts b/main/helpers/asrRepetitionGuard.ts new file mode 100644 index 00000000..2a41589d --- /dev/null +++ b/main/helpers/asrRepetitionGuard.ts @@ -0,0 +1,506 @@ +/** + * 模型无关的 ASR 重复护栏。 + * + * 解码器偶尔会在长音频后段进入「滑动窗口循环」:相邻字幕反复复用同一组词, + * 只改变起点或把开头挪到结尾。这里不匹配任何特定文案,只依据字幕文本、顺序和 + * 时间邻近性识别高置信度循环,并至少保留前两次出现,避免把正常强调、歌词副歌或 + * 很短的口头重复直接抹掉。 + * + * 本模块保持纯函数,所有引擎可在写字幕前共用,也便于无模型单测。 + */ + +export type AsrSubtitleCue = [string, string, string]; + +export type RepetitionGuardReason = 'sliding' | 'exact' | 'cycle'; + +export interface RepetitionGuardStats { + inputCues: number; + outputCues: number; + removedCues: number; + detectedRuns: number; + removedDurationSeconds: number; + removedByReason: Record; +} + +export interface RepetitionGuardResult { + cues: AsrSubtitleCue[]; + stats: RepetitionGuardStats; +} + +export interface RepetitionGuardOptions { + enabled?: boolean; + /** + * standard:只压制四连以上、词序呈滑动/轮转的循环。 + * aggressive:另压制五连完全重复与三轮窗口周期(clean/reduceRepetition 意图)。 + */ + mode?: RepetitionGuardMode; + /** 两条字幕之间允许的最大静音间隔;超过后不再视为同一循环。 */ + maxGapSeconds?: number; + /** 三轮窗口循环允许的最大总跨度。 */ + maxCycleSpanSeconds?: number; +} + +export type RepetitionGuardMode = 'standard' | 'aggressive'; +export type TaskRepetitionGuardMode = RepetitionGuardMode | 'off'; + +type TextSignature = { + key: string; + compact: string; + units: string[]; + contentChars: number; + cjkUnits: number; + wordUnits: number; +}; + +type SimilarityProfile = { + similar: boolean; + exact: boolean; + cyclic: boolean; + unigramDice: number; + shingleDice: number; +}; + +const DEFAULT_MAX_GAP_SECONDS = 20; +const DEFAULT_MAX_CYCLE_SPAN_SECONDS = 90; +const MIN_CONTENT_CHARS = 12; +const MIN_CJK_UNITS = 8; +const MIN_WORD_UNITS = 4; +const MIN_GENERAL_UNITS = 8; +const MIN_EXACT_RUN = 5; +const MIN_SLIDING_RUN = 4; +const CYCLE_REPETITIONS = 3; +const MAX_CYCLE_PERIOD = 3; +const LETTER_OR_NUMBER = /[\p{L}\p{N}]/u; + +function emptyReasonCounts(): Record { + return { sliding: 0, exact: 0, cycle: 0 }; +} + +function emptyStats(inputCues: number): RepetitionGuardStats { + return { + inputCues, + outputCues: inputCues, + removedCues: 0, + detectedRuns: 0, + removedDurationSeconds: 0, + removedByReason: emptyReasonCounts(), + }; +} + +function isCjkLike(code: number): boolean { + return ( + (code >= 0x3400 && code <= 0x9fff) || + (code >= 0xf900 && code <= 0xfaff) || + (code >= 0x20000 && code <= 0x2fa1f) || + (code >= 0x3040 && code <= 0x30ff) || + (code >= 0xac00 && code <= 0xd7a3) + ); +} + +function isLetterOrNumber(ch: string): boolean { + return LETTER_OR_NUMBER.test(ch); +} + +/** + * 拉丁文字按词、CJK/假名/谚文按字形成单元;标点和空白不参与比较。 + * NFKC 会把全角字母数字等价到半角,但不会改变原字幕。 + */ +export function normalizeAsrText(text: string): TextSignature { + let normalized = String(text ?? ''); + try { + normalized = normalized.normalize('NFKC'); + } catch { + // 极旧 JS 运行时没有 normalize 时仍可按原文工作。 + } + normalized = normalized.toLowerCase(); + + const units: string[] = []; + let pendingWord = ''; + let compact = ''; + let cjkUnits = 0; + let wordUnits = 0; + + const flushWord = () => { + if (!pendingWord) return; + units.push(pendingWord); + wordUnits += 1; + pendingWord = ''; + }; + + for (const ch of normalized) { + const code = ch.codePointAt(0) ?? 0; + if (isCjkLike(code)) { + flushWord(); + units.push(ch); + compact += ch; + cjkUnits += 1; + continue; + } + if (isLetterOrNumber(ch)) { + pendingWord += ch; + compact += ch; + continue; + } + flushWord(); + } + flushWord(); + + return { + key: units.join('\u0001'), + compact, + units, + contentChars: Array.from(compact).length, + cjkUnits, + wordUnits, + }; +} + +function isGuardable(signature: TextSignature): boolean { + return ( + signature.contentChars >= MIN_CONTENT_CHARS && + (signature.cjkUnits >= MIN_CJK_UNITS || + signature.wordUnits >= MIN_WORD_UNITS || + signature.units.length >= MIN_GENERAL_UNITS) + ); +} + +function multisetDice(a: string[], b: string[]): number { + if (!a.length || !b.length) return 0; + const counts = new Map(); + for (const item of a) counts.set(item, (counts.get(item) ?? 0) + 1); + let overlap = 0; + for (const item of b) { + const remaining = counts.get(item) ?? 0; + if (remaining <= 0) continue; + overlap += 1; + counts.set(item, remaining - 1); + } + return (2 * overlap) / (a.length + b.length); +} + +function shingles(units: string[]): string[] { + if (units.length < 2) return units; + const width = units.length >= 6 ? 3 : 2; + if (units.length < width) return units; + const result: string[] = []; + for (let i = 0; i <= units.length - width; i += 1) { + result.push(units.slice(i, i + width).join('\u0002')); + } + return result; +} + +function isCyclicContainment(a: string, b: string): boolean { + if (!a || !b) return false; + const shorter = a.length <= b.length ? a : b; + const longer = a.length <= b.length ? b : a; + if (shorter.length / longer.length < 0.75) return false; + return (longer + longer).includes(shorter); +} + +function similarity(a: TextSignature, b: TextSignature): SimilarityProfile { + const exact = a.key.length > 0 && a.key === b.key; + const unigramDice = multisetDice(a.units, b.units); + const shingleDice = multisetDice(shingles(a.units), shingles(b.units)); + const cyclic = !exact && isCyclicContainment(a.compact, b.compact); + const similar = + exact || (unigramDice >= 0.84 && (shingleDice >= 0.55 || cyclic)); + return { similar, exact, cyclic, unigramDice, shingleDice }; +} + +function parseTimeSeconds(value: string): number | null { + const parts = String(value ?? '') + .trim() + .replace(',', '.') + .split(':') + .map(Number); + if (!parts.length || parts.some((part) => !Number.isFinite(part))) { + return null; + } + if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; + if (parts.length === 2) return parts[0] * 60 + parts[1]; + return parts[0]; +} + +function cueStart(cue: AsrSubtitleCue): number | null { + return parseTimeSeconds(cue[0]); +} + +function cueEnd(cue: AsrSubtitleCue): number | null { + return parseTimeSeconds(cue[1]); +} + +function areAdjacent( + previous: AsrSubtitleCue, + current: AsrSubtitleCue, + maxGapSeconds: number, +): boolean { + const previousEnd = cueEnd(previous); + const currentStart = cueStart(current); + if (previousEnd === null || currentStart === null) return true; + return currentStart - previousEnd <= maxGapSeconds; +} + +function rangeSpanSeconds( + cues: AsrSubtitleCue[], + start: number, + end: number, +): number | null { + const first = cueStart(cues[start]); + const last = cueEnd(cues[end]); + if (first === null || last === null) return null; + return Math.max(0, last - first); +} + +function markRange( + removed: Map, + start: number, + end: number, + reason: RepetitionGuardReason, +): void { + for (let i = start; i <= end; i += 1) { + if (!removed.has(i)) removed.set(i, reason); + } +} + +function detectAdjacentRuns( + cues: AsrSubtitleCue[], + signatures: TextSignature[], + removed: Map, + maxGapSeconds: number, + mode: RepetitionGuardMode, +): void { + let runStart = 0; + + const inspectRun = (start: number, end: number) => { + const length = end - start + 1; + if (length < MIN_SLIDING_RUN) return; + const unique = new Set( + signatures.slice(start, end + 1).map((signature) => signature.key), + ); + + if (unique.size === 1) { + if (mode === 'aggressive' && length >= MIN_EXACT_RUN) { + // 即便是病态循环也保留前两条;短强调/歌词重复在达到五连前完全不动。 + markRange(removed, start + 2, end, 'exact'); + } + return; + } + + let strongSlidingEdges = 0; + for (let i = start + 1; i <= end; i += 1) { + const profile = similarity(signatures[i - 1], signatures[i]); + if ( + profile.cyclic || + (profile.unigramDice >= 0.82 && profile.shingleDice >= 0.8) + ) { + strongSlidingEdges += 1; + } + } + // 不因四句普通近义改写触发;要求大部分边都呈现词袋近乎不变且顺序滑动。 + if (strongSlidingEdges >= length - 2) { + markRange(removed, start + 2, end, 'sliding'); + } + }; + + for (let i = 1; i < cues.length; i += 1) { + const profile = similarity(signatures[i - 1], signatures[i]); + const continues = + isGuardable(signatures[i - 1]) && + isGuardable(signatures[i]) && + profile.similar && + areAdjacent(cues[i - 1], cues[i], maxGapSeconds); + if (continues) continue; + inspectRun(runStart, i - 1); + runStart = i; + } + inspectRun(runStart, cues.length - 1); +} + +function cycleBlockIsLongEnough( + signatures: TextSignature[], + start: number, + period: number, +): boolean { + const block = signatures.slice(start, start + period); + const contentChars = block.reduce( + (sum, signature) => sum + signature.contentChars, + 0, + ); + const units = block.reduce( + (sum, signature) => sum + signature.units.length, + 0, + ); + return contentChars >= 18 || units >= 8; +} + +function correspondingCycleCuesMatch( + signatures: TextSignature[], + first: number, + second: number, + period: number, +): boolean { + for (let offset = 0; offset < period; offset += 1) { + const a = signatures[first + offset]; + const b = signatures[second + offset]; + const profile = similarity(a, b); + // 短句允许精确匹配,但模糊匹配仍需达到单条长文本门槛。 + if ( + !profile.exact && + !(isGuardable(a) && isGuardable(b) && profile.similar) + ) { + return false; + } + } + return true; +} + +function detectWindowCycles( + cues: AsrSubtitleCue[], + signatures: TextSignature[], + removed: Map, + maxGapSeconds: number, + maxCycleSpanSeconds: number, +): void { + for (let period = 2; period <= MAX_CYCLE_PERIOD; period += 1) { + const windowSize = period * CYCLE_REPETITIONS; + for (let start = 0; start + windowSize <= cues.length; start += 1) { + const end = start + windowSize - 1; + let adjacent = true; + for (let i = start + 1; i <= end; i += 1) { + if (!areAdjacent(cues[i - 1], cues[i], maxGapSeconds)) { + adjacent = false; + break; + } + } + if (!adjacent || !cycleBlockIsLongEnough(signatures, start, period)) { + continue; + } + const span = rangeSpanSeconds(cues, start, end); + if (span !== null && span > maxCycleSpanSeconds) continue; + + const second = start + period; + const third = second + period; + if ( + correspondingCycleCuesMatch(signatures, start, second, period) && + correspondingCycleCuesMatch(signatures, second, third, period) + ) { + // 两个完整周期保留,仅从第三个周期开始压制。 + markRange(removed, third, end, 'cycle'); + } + } + } +} + +function countRemovedRuns(indices: number[]): number { + let runs = 0; + let previous = -2; + for (const index of indices) { + if (index !== previous + 1) runs += 1; + previous = index; + } + return runs; +} + +/** + * 对字幕 cue 应用高置信度重复护栏。禁用或未命中时文本和数组元素均原样返回。 + */ +export function applyAsrRepetitionGuard( + cues: AsrSubtitleCue[], + options: RepetitionGuardOptions = {}, +): RepetitionGuardResult { + const input = Array.isArray(cues) ? cues : []; + if (options.enabled === false || input.length === 0) { + return { cues: input, stats: emptyStats(input.length) }; + } + + const maxGapSeconds = + typeof options.maxGapSeconds === 'number' && + Number.isFinite(options.maxGapSeconds) + ? Math.max(0, options.maxGapSeconds) + : DEFAULT_MAX_GAP_SECONDS; + const maxCycleSpanSeconds = + typeof options.maxCycleSpanSeconds === 'number' && + Number.isFinite(options.maxCycleSpanSeconds) + ? Math.max(0, options.maxCycleSpanSeconds) + : DEFAULT_MAX_CYCLE_SPAN_SECONDS; + const mode = options.mode ?? 'standard'; + const signatures = input.map((cue) => normalizeAsrText(cue[2])); + const removed = new Map(); + + detectAdjacentRuns(input, signatures, removed, maxGapSeconds, mode); + if (mode === 'aggressive') { + detectWindowCycles( + input, + signatures, + removed, + maxGapSeconds, + maxCycleSpanSeconds, + ); + } + + const removedIndices = Array.from(removed.keys()).sort((a, b) => a - b); + if (!removedIndices.length) { + return { cues: input, stats: emptyStats(input.length) }; + } + + const removedByReason = emptyReasonCounts(); + let removedDurationSeconds = 0; + for (const index of removedIndices) { + removedByReason[removed.get(index)!] += 1; + const start = cueStart(input[index]); + const end = cueEnd(input[index]); + if (start !== null && end !== null && end > start) { + removedDurationSeconds += end - start; + } + } + const output = input.filter((_, index) => !removed.has(index)); + return { + cues: output, + stats: { + inputCues: input.length, + outputCues: output.length, + removedCues: removedIndices.length, + detectedRuns: countRemovedRuns(removedIndices), + removedDurationSeconds: Math.round(removedDurationSeconds * 1000) / 1000, + removedByReason, + }, + }; +} + +/** + * 默认 balanced / accurate 严格关闭,避免滚动歌词、提词器等合法轮转文本被误删。 + * 仅 clean 或显式 reduceRepetition=true 才启用 aggressive;clean 对 sherpa 系虽无 + * 解码层抗重复参数,仍能表达共享后处理意图。 + */ +export function getAsrRepetitionGuardMode( + formData: Record | undefined, + effectiveSettings: Record | undefined, +): TaskRepetitionGuardMode { + const outcome = + formData?.subtitleOutcome ?? effectiveSettings?.subtitleOutcome; + if (outcome === 'clean') return 'aggressive'; + if (outcome === 'accurate' || outcome === 'balanced') return 'off'; + if (typeof formData?.reduceRepetition === 'boolean') { + return formData.reduceRepetition ? 'aggressive' : 'off'; + } + return effectiveSettings?.reduceRepetition === true ? 'aggressive' : 'off'; +} + +/** 不记录用户文本的结构化诊断,便于日志确认护栏是否命中及误杀排查。 */ +export function formatRepetitionGuardDiagnostic( + engine: string, + stats: RepetitionGuardStats, +): string | null { + if (stats.removedCues === 0) return null; + const reasons = ( + Object.keys(stats.removedByReason) as RepetitionGuardReason[] + ) + .filter((reason) => stats.removedByReason[reason] > 0) + .map((reason) => `${reason}=${stats.removedByReason[reason]}`) + .join(','); + return ( + `ASR repetition guard (${engine}): removed ${stats.removedCues}/` + + `${stats.inputCues} cues in ${stats.detectedRuns} run(s), ` + + `removedDuration=${stats.removedDurationSeconds.toFixed(3)}s, ${reasons}` + ); +} diff --git a/main/helpers/engines/builtinEngine.ts b/main/helpers/engines/builtinEngine.ts index a5be21d6..90032c5e 100644 --- a/main/helpers/engines/builtinEngine.ts +++ b/main/helpers/engines/builtinEngine.ts @@ -34,6 +34,7 @@ import { getVadSettings, isReduceRepetitionEnabled, getNumericSetting, + guardAsrSubtitleCues, } from './transcribeShared'; import { resolveEffectiveSettings } from './outcomePresets'; import { @@ -370,7 +371,14 @@ async function transcribeBuiltin(ctx: TranscribeContext): Promise { tempAudioFile, ); } - const formattedSrt = formatSrtContent(subtitles); + const guarded = guardAsrSubtitleCues( + subtitles, + formData as Record, + settings, + 'builtin', + ); + if (guarded.diagnostic) logMessage(guarded.diagnostic, 'warning'); + const formattedSrt = formatSrtContent(guarded.cues); await fs.promises.writeFile(srtFile, formattedSrt); event.sender.send('taskFileChange', { ...file, extractSubtitle: 'done' }); diff --git a/main/helpers/engines/cloudAsrEngine.ts b/main/helpers/engines/cloudAsrEngine.ts index a65ea80a..bd00c79b 100644 --- a/main/helpers/engines/cloudAsrEngine.ts +++ b/main/helpers/engines/cloudAsrEngine.ts @@ -18,7 +18,7 @@ import { type CloudAudioChunk, } from '../audioProcessor'; import { formatSrtContent } from '../fileUtils'; -import { logMessage } from '../storeManager'; +import { logMessage, store } from '../storeManager'; import { getTaskContext, TaskCancelledError, @@ -35,6 +35,8 @@ import { import { resplitSubtitleCues } from '../subtitleSegmentation'; import type { AsrWord } from '../../service/asr/types'; import { getCloudProviderGate } from './cloudProviderGate'; +import { resolveEffectiveSettings } from './outcomePresets'; +import { guardAsrSubtitleCues } from './transcribeShared'; import type { TranscribeContext, TranscriptionEngineAdapter } from './types'; /** 无时间戳(text-only 模型)降级时,用更细的静音切片换取更细的粗粒度时间轴。 */ @@ -107,6 +109,10 @@ async function transcribeCloud(ctx: TranscribeContext): Promise { const { tempAudioFile, srtFile } = file; const signal = ctx.signal ?? getTaskContext()?.signal; + const settings = resolveEffectiveSettings( + formData, + store.get('settings') as Record, + ); const f = formData as { asrProviderId?: string; @@ -227,7 +233,14 @@ async function transcribeCloud(ctx: TranscribeContext): Promise { throwIfSignalCancelled(signal); // 词级/段级路径统一补一次「裁尾」护栏(基于原始 16kHz WAV 能量)。 const subtitles = trimSubtitleTrailingSilence(cues, tempAudioFile); - const formattedSrt = formatSrtContent(subtitles); + const guarded = guardAsrSubtitleCues( + subtitles, + formData as Record, + settings, + 'cloud', + ); + if (guarded.diagnostic) logMessage(guarded.diagnostic, 'warning'); + const formattedSrt = formatSrtContent(guarded.cues); await fs.promises.writeFile(srtFile, formattedSrt); event.sender.send('taskProgressChange', file, 'extractSubtitle', 100); diff --git a/main/helpers/engines/fasterWhisperEngine.ts b/main/helpers/engines/fasterWhisperEngine.ts index 670802b0..118b9636 100644 --- a/main/helpers/engines/fasterWhisperEngine.ts +++ b/main/helpers/engines/fasterWhisperEngine.ts @@ -33,6 +33,7 @@ import { getNumericSetting, getWhisperLanguage, getFasterWhisperAntiRepetitionParams, + guardAsrSubtitleCues, } from './transcribeShared'; import { resolveEffectiveSettings } from './outcomePresets'; import type { TranscribeContext, TranscriptionEngineAdapter } from './types'; @@ -336,7 +337,14 @@ async function transcribeFasterWhisper( subtitles = segments.map(subtitleCueFromSegment); } subtitles = trimSubtitleTrailingSilence(subtitles, tempAudioFile); - const formattedSrt = formatSrtContent(subtitles); + const guarded = guardAsrSubtitleCues( + subtitles, + formData as Record, + settings, + 'faster-whisper', + ); + if (guarded.diagnostic) logMessage(guarded.diagnostic, 'warning'); + const formattedSrt = formatSrtContent(guarded.cues); await fs.promises.writeFile(srtFile, formattedSrt); event.sender.send('taskProgressChange', file, 'extractSubtitle', 100); diff --git a/main/helpers/engines/fireRedEngine.ts b/main/helpers/engines/fireRedEngine.ts index 93cf00ba..9551633d 100644 --- a/main/helpers/engines/fireRedEngine.ts +++ b/main/helpers/engines/fireRedEngine.ts @@ -23,6 +23,7 @@ import { import { resplitSubtitleCues } from '../subtitleSegmentation'; import { buildFireRedParams } from './fireRedParams'; import { resolveEffectiveSettings } from './outcomePresets'; +import { guardAsrSubtitleCues } from './transcribeShared'; import type { TranscribeContext, TranscriptionEngineAdapter } from './types'; /** 在途转写 id 集合:任务级并发下可能同时存在多个(排队+执行),取消须精确到 id。 */ @@ -144,7 +145,14 @@ async function transcribeFireRed(ctx: TranscribeContext): Promise { ), tempAudioFile, ); - const formattedSrt = formatSrtContent(subtitles); + const guarded = guardAsrSubtitleCues( + subtitles, + formData as Record, + settings, + 'firered', + ); + if (guarded.diagnostic) logMessage(guarded.diagnostic, 'warning'); + const formattedSrt = formatSrtContent(guarded.cues); await fs.promises.writeFile(srtFile, formattedSrt); event.sender.send('taskProgressChange', file, 'extractSubtitle', 100); diff --git a/main/helpers/engines/funasrEngine.ts b/main/helpers/engines/funasrEngine.ts index ed514761..6976a4af 100644 --- a/main/helpers/engines/funasrEngine.ts +++ b/main/helpers/engines/funasrEngine.ts @@ -24,6 +24,7 @@ import { import { resplitSubtitleCues } from '../subtitleSegmentation'; import { buildFunasrParams } from './funasrParams'; import { resolveEffectiveSettings } from './outcomePresets'; +import { guardAsrSubtitleCues } from './transcribeShared'; import type { TranscribeContext, TranscriptionEngineAdapter } from './types'; /** 在途转写 id 集合:任务级并发下可能同时存在多个(排队+执行),取消须精确到 id。 */ @@ -154,7 +155,14 @@ async function transcribeFunasr(ctx: TranscribeContext): Promise { ), tempAudioFile, ); - const formattedSrt = formatSrtContent(subtitles); + const guarded = guardAsrSubtitleCues( + subtitles, + formData as Record, + settings, + 'funasr', + ); + if (guarded.diagnostic) logMessage(guarded.diagnostic, 'warning'); + const formattedSrt = formatSrtContent(guarded.cues); await fs.promises.writeFile(srtFile, formattedSrt); event.sender.send('taskProgressChange', file, 'extractSubtitle', 100); diff --git a/main/helpers/engines/localCliEngine.ts b/main/helpers/engines/localCliEngine.ts index 254d7eee..d32727a7 100644 --- a/main/helpers/engines/localCliEngine.ts +++ b/main/helpers/engines/localCliEngine.ts @@ -3,8 +3,11 @@ import path from 'path'; import fs from 'fs'; import type { EngineStatus } from '../../../types/engine'; import { logMessage, store } from '../storeManager'; +import { formatSrtContent, secondsToSubtitleTime } from '../fileUtils'; +import { parseSubtitleCues } from '../subtitleFormats'; import { getTaskContext, TaskCancelledError } from '../taskContext'; -import { getWhisperLanguage } from './transcribeShared'; +import { resolveEffectiveSettings } from './outcomePresets'; +import { getWhisperLanguage, guardAsrSubtitleCues } from './transcribeShared'; import type { TranscribeContext, TranscriptionEngineAdapter } from './types'; /** 在途 CLI 子进程集合:任务级并发下可能同时存在多个,取消须精确到进程。 */ @@ -39,8 +42,11 @@ function transcribeLocalCli(ctx: TranscribeContext): Promise { sourceLanguage?: string; }; const whisperModel = model?.toLowerCase(); - const settings = store.get('settings'); - const whisperCommand = settings?.whisperCommand; + const settings = resolveEffectiveSettings( + formData, + store.get('settings') as Record, + ); + const whisperCommand = String(settings?.whisperCommand ?? ''); const { tempAudioFile, srtFile, directory } = file; let runShell = whisperCommand @@ -130,6 +136,33 @@ function transcribeLocalCli(ctx: TranscribeContext): Promise { fs.renameSync(tempSrtFile, srtFile); } + // 外部 CLI 直接产出 SRT:解析成共享 cue 形状后走同一模型外重复护栏。 + // 未命中时不重写文件,保留 CLI 的原始格式;解析失败也不影响已成功的转写。 + if (fs.existsSync(srtFile)) { + try { + const parsed = parseSubtitleCues( + fs.readFileSync(srtFile, 'utf-8'), + 'srt', + ); + const guarded = guardAsrSubtitleCues( + parsed.map((cue) => [ + secondsToSubtitleTime(cue.startMs / 1000), + secondsToSubtitleTime(cue.endMs / 1000), + cue.text, + ]), + formData as Record, + settings, + 'local-cli', + ); + if (guarded.diagnostic) { + logMessage(guarded.diagnostic, 'warning'); + fs.writeFileSync(srtFile, formatSrtContent(guarded.cues), 'utf-8'); + } + } catch (error) { + logMessage(`local CLI repetition guard skipped: ${error}`, 'warning'); + } + } + event.sender.send('taskFileChange', { ...file, extractSubtitle: 'done' }); resolve(srtFile); }); diff --git a/main/helpers/engines/qwenEngine.ts b/main/helpers/engines/qwenEngine.ts index 92c0daba..a893ce56 100644 --- a/main/helpers/engines/qwenEngine.ts +++ b/main/helpers/engines/qwenEngine.ts @@ -23,6 +23,7 @@ import { import { resplitSubtitleCues } from '../subtitleSegmentation'; import { buildQwenParams } from './qwenParams'; import { resolveEffectiveSettings } from './outcomePresets'; +import { guardAsrSubtitleCues } from './transcribeShared'; import type { TranscribeContext, TranscriptionEngineAdapter } from './types'; /** 在途转写 id 集合:任务级并发下可能同时存在多个(排队+执行),取消须精确到 id。 */ @@ -142,7 +143,14 @@ async function transcribeQwen(ctx: TranscribeContext): Promise { ), tempAudioFile, ); - const formattedSrt = formatSrtContent(subtitles); + const guarded = guardAsrSubtitleCues( + subtitles, + formData as Record, + settings, + 'qwen', + ); + if (guarded.diagnostic) logMessage(guarded.diagnostic, 'warning'); + const formattedSrt = formatSrtContent(guarded.cues); await fs.promises.writeFile(srtFile, formattedSrt); event.sender.send('taskProgressChange', file, 'extractSubtitle', 100); diff --git a/main/helpers/engines/transcribeShared.ts b/main/helpers/engines/transcribeShared.ts index 4fdcf4bb..eda2edae 100644 --- a/main/helpers/engines/transcribeShared.ts +++ b/main/helpers/engines/transcribeShared.ts @@ -1,7 +1,14 @@ /** - * 各引擎转写实现共用的纯工具:数值兜底、语言归一、SRT 时间格式化、VAD 设置归一。 - * 不依赖任何引擎实现,供 builtin / faster-whisper / localCli 适配器复用。 + * 各引擎转写实现共用的纯工具:数值兜底、语言归一、SRT 时间格式化、VAD 设置归一、 + * 模型外重复护栏。不依赖任何具体引擎实现,供所有 ASR 适配器复用。 */ +import { + applyAsrRepetitionGuard, + formatRepetitionGuardDiagnostic, + getAsrRepetitionGuardMode, + type AsrSubtitleCue, + type RepetitionGuardStats, +} from '../asrRepetitionGuard'; export function getNumericSetting( value: unknown, @@ -72,6 +79,32 @@ export function getFasterWhisperAntiRepetitionParams( }; } +export interface GuardedAsrSubtitleCues { + cues: AsrSubtitleCue[]; + stats: RepetitionGuardStats; + diagnostic: string | null; +} + +/** + * 各引擎写 SRT 前的统一出口:根据任务意图启用模型外重复护栏,并生成不含用户文本的诊断。 + */ +export function guardAsrSubtitleCues( + cues: AsrSubtitleCue[], + formData: Record | undefined, + effectiveSettings: Record | undefined, + engine: string, +): GuardedAsrSubtitleCues { + const taskMode = getAsrRepetitionGuardMode(formData, effectiveSettings); + const result = applyAsrRepetitionGuard(cues, { + enabled: taskMode !== 'off', + mode: taskMode === 'off' ? 'standard' : taskMode, + }); + return { + ...result, + diagnostic: formatRepetitionGuardDiagnostic(engine, result.stats), + }; +} + /** 从 store 的 settings 归一化出 VAD 参数(各引擎再映射到自己的字段名)。 */ export function getVadSettings(settings: Record): VadSettings { return { diff --git a/package.json b/package.json index 30a2eb37..5d861426 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "native:fetch": "node scripts/fetch-sherpa-native.mjs && node scripts/fetch-whisper-addon.mjs", "size:check": "node scripts/check-bundle-size.mjs", "test:engines": "tsc scripts/test-engine-units.ts --outDir node_modules/.cache/engine-tests --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck --resolveJsonModule && node node_modules/.cache/engine-tests/scripts/test-engine-units.js", + "test:asr-repetition-guard": "tsc scripts/test-asr-repetition-guard.ts --outDir node_modules/.cache/asr-repetition-guard-tests --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck && node node_modules/.cache/asr-repetition-guard-tests/scripts/test-asr-repetition-guard.js", "test:ass-builder": "tsc scripts/test-ass-builder.ts --outDir node_modules/.cache/ass-builder-tests --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck --resolveJsonModule && node node_modules/.cache/ass-builder-tests/scripts/test-ass-builder.js", "test:compose": "tsc scripts/compose/test-compose-builder.ts --outDir node_modules/.cache/compose-tests --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck --resolveJsonModule && node node_modules/.cache/compose-tests/scripts/compose/test-compose-builder.js", "smoke:compose": "tsc scripts/compose/smoke-compose.ts --outDir node_modules/.cache/compose-tests --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck --resolveJsonModule && node node_modules/.cache/compose-tests/scripts/compose/smoke-compose.js", diff --git a/renderer/public/locales/en/tasks.json b/renderer/public/locales/en/tasks.json index 5b4aab33..2d6cc768 100644 --- a/renderer/public/locales/en/tasks.json +++ b/renderer/public/locales/en/tasks.json @@ -144,7 +144,7 @@ "title": "Custom", "desc": "Manually tune context length, VAD, repetition reduction and other low-level parameters.", "sectionTitle": "Custom parameters", - "sherpaNote": "For the current engine (FunASR / Qwen / FireRed), VAD is always on and there is no context-length / repetition concept. To fine-tune VAD sensitivity, use the Settings page." + "sherpaNote": "For the current engine (FunASR / Qwen / FireRed), VAD is always on and there are no decoder-level context or repetition controls. Cleanest & most stable enables the shared high-confidence repetition post-processor. To fine-tune VAD sensitivity, use the Settings page." }, "compare": { "toggle": "See how the effects differ", @@ -169,7 +169,7 @@ "label": "Reduce repetition / hallucinated lines", "on": "On: drops previous-text conditioning and suppresses repeats, greatly reducing looped lines over silence/repetition; coherence on clean speech may drop slightly.", "off": "Off: engine defaults (keeps cross-segment coherence); long videos or silence may produce repeated or hallucinated lines.", - "hint": "Turn on when subtitles loop or repeat heavily. Global setting; applies to both faster-whisper and whisper.cpp." + "hint": "Repetition post-processing runs only with Cleanest & most stable or when this custom option is enabled. In addition to engine-native controls, it blocks sliding, exact-repeat and multi-line window cycles." }, "subtitleLength": { "label": "Subtitle line breaking", diff --git a/renderer/public/locales/zh/tasks.json b/renderer/public/locales/zh/tasks.json index 3fe6c96d..7796499b 100644 --- a/renderer/public/locales/zh/tasks.json +++ b/renderer/public/locales/zh/tasks.json @@ -144,7 +144,7 @@ "title": "自定义", "desc": "手动调整上下文长度、VAD、抗重复等底层参数。", "sectionTitle": "自定义参数", - "sherpaNote": "当前引擎(FunASR / Qwen / FireRed)的 VAD 为结构性常开,且无「上下文长度 / 抗重复」概念。如需细调 VAD 灵敏度,请到「设置」页调整。" + "sherpaNote": "当前引擎(FunASR / Qwen / FireRed)的 VAD 为结构性常开,且无解码层「上下文长度 / 抗重复」参数;选择「最干净最稳」会启用共享的高置信度重复后处理。如需细调 VAD 灵敏度,请到「设置」页调整。" }, "compare": { "toggle": "看看不同效果的字幕差异", @@ -169,7 +169,7 @@ "label": "减少重复 / 幻觉字幕", "on": "已开启:断开上文条件并抑制重复,显著减少静音/重复段的循环字幕;干净语音的连贯性可能略降。", "off": "已关闭:使用引擎默认(保留上文连贯性);长视频或静音处可能出现重复或幻觉字幕。", - "hint": "遇到字幕大段重复/鬼畜时开启。全局设置,对 faster-whisper 与 whisper.cpp 同时生效。" + "hint": "仅在选择「最干净最稳」或自定义开启本项时运行重复后处理;除引擎自身的抗重复参数外,还会拦截滑动、完全重复与多条字幕窗口循环。" }, "subtitleLength": { "label": "字幕断句方式", diff --git a/scripts/test-asr-repetition-guard.ts b/scripts/test-asr-repetition-guard.ts new file mode 100644 index 00000000..ca4f88c0 --- /dev/null +++ b/scripts/test-asr-repetition-guard.ts @@ -0,0 +1,353 @@ +/** + * 模型无关 ASR 重复护栏单元测试(无 Electron / 无模型依赖)。 + * + * 运行:npm run test:asr-repetition-guard + */ +import { + applyAsrRepetitionGuard, + formatRepetitionGuardDiagnostic, + getAsrRepetitionGuardMode, + normalizeAsrText, + type AsrSubtitleCue, +} from '../main/helpers/asrRepetitionGuard'; +import { guardAsrSubtitleCues } from '../main/helpers/engines/transcribeShared'; + +let passed = 0; +let failed = 0; + +function eq(actual: unknown, expected: unknown, name: string): void { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a === e) { + passed += 1; + return; + } + failed += 1; + console.error(`✗ ${name}\n expected: ${e}\n actual: ${a}`); +} + +function cueTime(seconds: number): string { + const totalMs = Math.max(0, Math.round(seconds * 1000)); + const ms = totalMs % 1000; + const totalSeconds = Math.floor(totalMs / 1000); + const s = totalSeconds % 60; + const totalMinutes = Math.floor(totalSeconds / 60); + const m = totalMinutes % 60; + const h = Math.floor(totalMinutes / 60); + const pad = (value: number, length = 2) => + String(value).padStart(length, '0'); + return `${pad(h)}:${pad(m)}:${pad(s)}.${pad(ms, 3)}`; +} + +function repetitionCues(texts: string[], gapSeconds = 0): AsrSubtitleCue[] { + let cursor = 0; + return texts.map((text) => { + const start = cursor; + const end = start + 2; + cursor = end + gapSeconds; + return [cueTime(start), cueTime(end), text]; + }); +} + +eq( + applyAsrRepetitionGuard([]).stats, + { + inputCues: 0, + outputCues: 0, + removedCues: 0, + detectedRuns: 0, + removedDurationSeconds: 0, + removedByReason: { sliding: 0, exact: 0, cycle: 0 }, + }, + 'empty input', +); + +const blankCues = repetitionCues(['', ' ', '...', '']); +eq( + applyAsrRepetitionGuard(blankCues).cues, + blankCues, + 'empty and punctuation-only cues stay unchanged', +); + +const normalDialogue = repetitionCues([ + '欢迎来到今天的课程,我们先回顾上一节内容。', + '接下来会介绍安装步骤和常见配置。', + '如果看到这个页面,说明服务已经启动成功。', + '最后保存设置,然后重新运行一次任务。', +]); +eq( + applyAsrRepetitionGuard(normalDialogue).cues, + normalDialogue, + 'normal CJK dialogue stays unchanged', +); + +const intentionalShortRepeat = repetitionCues( + Array.from({ length: 8 }, () => '谢谢'), +); +eq( + applyAsrRepetitionGuard(intentionalShortRepeat).cues.length, + 8, + 'intentional short phrase repetition is preserved', +); +eq( + applyAsrRepetitionGuard(intentionalShortRepeat, { mode: 'aggressive' }).cues + .length, + 8, + 'aggressive mode still preserves intentional short repetition', +); + +// 复刻 issue #402 截图:同一长词窗每条向前滑动,首尾轮转。 +const issue402SlidingLoop = repetitionCues([ + '转发 打赏支持明镜与点栏目周末愉快 转发', + '打赏支持明镜与点栏目周末愉快 转发 打赏', + '支持明镜与点栏目周末愉快 转发 打赏支持', + '明镜与点栏目周末愉快 转发 打赏支持明镜与', + '点栏目周末愉快 转发 打赏支持明镜与点栏目', + '周末愉快 转发 打赏支持明镜与点栏目周末', +]); +eq( + applyAsrRepetitionGuard(issue402SlidingLoop.slice(0, 3)).cues.length, + 3, + 'three similar cues stay below the sliding-loop threshold', +); +const issue402Guarded = applyAsrRepetitionGuard(issue402SlidingLoop); +eq( + issue402Guarded.cues.length, + 2, + 'CJK sliding loop collapses after two cues in standard mode', +); +eq( + issue402Guarded.cues, + issue402SlidingLoop.slice(0, 2), + 'guard preserves the first two cues and their original timestamps/text', +); +eq( + issue402Guarded.stats.removedByReason.sliding, + 4, + 'CJK sliding loop reports diagnostic reason', +); + +const englishSlidingLoop = repetitionCues([ + 'Please like share and subscribe to the channel', + 'Like share and subscribe to the channel please', + 'Share and subscribe to the channel please like', + 'And subscribe to the channel please like share', + 'Subscribe to the channel please like share and', +]); +eq( + applyAsrRepetitionGuard(englishSlidingLoop).cues.length, + 2, + 'English sliding loop is detected', +); + +const longExactPhrase = 'Please remember to review the complete project notes'; +const exactSix = repetitionCues( + Array.from({ length: 6 }, () => longExactPhrase), +); +eq( + applyAsrRepetitionGuard(exactSix).cues.length, + 6, + 'public pure-function default is safest standard mode', +); +const exactGuarded = applyAsrRepetitionGuard(exactSix, { + mode: 'aggressive', +}); +eq( + exactGuarded.cues.length, + 2, + 'aggressive mode keeps two cues from a long exact loop', +); +eq( + exactGuarded.stats.removedByReason.exact, + 4, + 'exact-loop diagnosis is counted', +); + +const cycleLoop = repetitionCues([ + 'This is the first long sentence in the repeating window', + 'This is the second long sentence in the repeating window', + 'This is the first long sentence in the repeating window', + 'This is the second long sentence in the repeating window', + 'This is the first long sentence in the repeating window', + 'This is the second long sentence in the repeating window', + 'This is the first long sentence in the repeating window', + 'This is the second long sentence in the repeating window', +]); +eq( + applyAsrRepetitionGuard(cycleLoop).cues.length, + 8, + 'standard mode preserves exact multi-cue cycles', +); +eq( + applyAsrRepetitionGuard(cycleLoop, { mode: 'aggressive' }).cues.length, + 4, + 'aggressive mode keeps two complete multi-cue cycles', +); + +const separatedRepeats = repetitionCues( + Array.from({ length: 6 }, () => longExactPhrase), + 30, +); +eq( + applyAsrRepetitionGuard(separatedRepeats, { mode: 'aggressive' }).cues.length, + 6, + 'repeats separated by long gaps are preserved', +); + +eq( + applyAsrRepetitionGuard(issue402SlidingLoop, { enabled: false }).cues, + issue402SlidingLoop, + 'explicit disable is a no-op', +); +eq( + getAsrRepetitionGuardMode( + { subtitleOutcome: 'clean', transcriptionEngine: 'qwen' }, + {}, + ), + 'aggressive', + 'clean outcome enables aggressive guard for sherpa engines', +); +eq( + getAsrRepetitionGuardMode({}, { reduceRepetition: false }), + 'off', + 'legacy/default task without explicit intent disables guard', +); +eq( + getAsrRepetitionGuardMode( + { subtitleOutcome: 'balanced' }, + { reduceRepetition: false }, + ), + 'off', + 'balanced outcome disables post-processing', +); +eq( + getAsrRepetitionGuardMode( + { subtitleOutcome: 'balanced', transcriptionEngine: 'qwen' }, + { reduceRepetition: true }, + ), + 'off', + 'explicit balanced outcome overrides stale global repetition setting', +); +eq( + getAsrRepetitionGuardMode( + { subtitleOutcome: 'custom', reduceRepetition: true }, + {}, + ), + 'aggressive', + 'custom reduceRepetition enables aggressive guard', +); +eq( + getAsrRepetitionGuardMode( + { subtitleOutcome: 'custom', reduceRepetition: false }, + { reduceRepetition: true }, + ), + 'off', + 'explicit custom reduceRepetition=false disables guard', +); +eq( + guardAsrSubtitleCues( + issue402SlidingLoop, + { subtitleOutcome: 'balanced' }, + { reduceRepetition: false }, + 'test', + ).cues.length, + 6, + 'default balanced task path is a no-op', +); +eq( + guardAsrSubtitleCues( + issue402SlidingLoop, + {}, + { reduceRepetition: false }, + 'test', + ).cues, + issue402SlidingLoop, + 'legacy/default task path is a no-op', +); +eq( + guardAsrSubtitleCues( + exactSix, + { subtitleOutcome: 'balanced' }, + { reduceRepetition: false }, + 'test', + ).cues.length, + 6, + 'balanced task path preserves exact repeats', +); +eq( + guardAsrSubtitleCues( + issue402SlidingLoop, + { subtitleOutcome: 'clean' }, + { reduceRepetition: true }, + 'test', + ).cues.length, + 2, + 'clean task path collapses the issue #402 sliding loop', +); +eq( + guardAsrSubtitleCues( + exactSix, + { subtitleOutcome: 'clean' }, + { reduceRepetition: true }, + 'test', + ).cues.length, + 2, + 'clean task path additionally collapses exact loops', +); + +const rollingLyrics = repetitionCues([ + 'You are my sunshine my only sunshine', + 'Are my sunshine my only sunshine you', + 'My sunshine my only sunshine you are', + 'Sunshine my only sunshine you are my', +]); +eq( + guardAsrSubtitleCues( + rollingLyrics, + { subtitleOutcome: 'balanced' }, + { reduceRepetition: false }, + 'test', + ).cues, + rollingLyrics, + 'balanced preserves legitimate rolling lyrics', +); +eq( + guardAsrSubtitleCues( + rollingLyrics, + { subtitleOutcome: 'accurate' }, + { reduceRepetition: false }, + 'test', + ).cues, + rollingLyrics, + 'accurate preserves legitimate rolling lyrics', +); + +eq( + normalizeAsrText('ABC,週末 愉快!').compact, + 'abc週末愉快', + 'NFKC/case/punctuation normalization', +); +eq( + normalizeAsrText('مرحبا بالعالم ١٢٣!').units, + ['مرحبا', 'بالعالم', '١٢٣'], + 'Arabic letters and numbers are retained', +); +eq( + normalizeAsrText('שלום, עולם 42!').units, + ['שלום', 'עולם', '42'], + 'Hebrew letters and numbers are retained', +); +const diagnostic = formatRepetitionGuardDiagnostic( + 'test-engine', + issue402Guarded.stats, +); +eq( + Boolean( + diagnostic?.includes('removed 4/6 cues') && !diagnostic.includes('明镜'), + ), + true, + 'diagnostic is useful without logging subtitle text', +); + +console.log(`\nASR repetition guard tests: ${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1);