fix(subtitles): match duplicate cues by greatest timing overlap

- Add regression coverage for repeated dialogue cues
This commit is contained in:
2026-09-07 23:51:44 -07:00
parent 773664db78
commit 9eb70f5ff2
3 changed files with 27 additions and 7 deletions
+1 -1
View File
@@ -3,4 +3,4 @@ area: subtitles
- Generate local Japanese SRT subtitles with whisper.cpp from a standalone modal opened with Ctrl+Shift+G, the empty subtitle sidebar's generation button, or `subminer generate-subs`, with shared progress reporting, cancellation, safe output files, and automatic loading into the matching mpv video. The sidebar button hides while subtitle lines are loaded.
- Configure an existing multilingual model in Settings or choose an official multilingual model, including quantized variants, in the modal or launcher. The modal shows download sizes, speed and accuracy guidance, and a recommended starting model before explicitly downloading a verified SubMiner-managed model. Executable paths are optional overrides; empty fields find whisper-cli, ffmpeg, and ffprobe on PATH.
- Optionally select Focus on spoken dialogue in the modal and use Download speech detection model to install the separate Silero model with progress and cancellation. The choice lasts for the session; a configured VAD model path sets the default. With the detector executable installed, retain brief utterances and extra audio around speech, split long passages near quiet pauses with overlapping context, and combine duplicate overlap cues. Keep original media timing and separate repeated dialogue without spanning omitted music breaks.
- Optionally select Focus on spoken dialogue in the modal and use Download speech detection model to install the separate Silero model with progress and cancellation. The choice lasts for the session; a configured VAD model path sets the default. With the detector executable installed, retain brief utterances and extra audio around speech, split long passages near quiet pauses with overlapping context, and combine duplicate cues by greatest timing overlap. Keep original media timing and separate repeated dialogue without spanning omitted music breaks.
@@ -36,3 +36,19 @@ test('chunk stitching removes matching overlap cues but retains repeated dialogu
{ startTime: 21.4, endTime: 22, text: 'はい' },
]);
});
test('chunk stitching matches repeated text to the greatest overlap without leaving a duplicate', () => {
const cues = [
{ startTime: 10, endTime: 14, text: 'はい' },
{ startTime: 13, endTime: 20, text: 'はい' },
];
appendSpeechChunkCues(cues, [
{ startTime: 12, endTime: 21, text: 'はい' },
{ startTime: 22, endTime: 23, text: 'はい' },
]);
assert.deepEqual(cues, [
{ startTime: 10, endTime: 14, text: 'はい' },
{ startTime: 12, endTime: 21, text: 'はい' },
{ startTime: 22, endTime: 23, text: 'はい' },
]);
});
@@ -44,18 +44,22 @@ export function appendSpeechChunkCues(cues: SubtitleCue[], incoming: readonly Su
const matched = new Set<SubtitleCue>();
for (const cue of incoming) {
const text = cue.text.replace(/\s+/g, '');
const duplicate = cues.find((previous, index) => {
if (index >= previousCount || matched.has(previous)) return false;
let duplicate: SubtitleCue | undefined;
let greatestOverlap = 0;
for (const [index, previous] of cues.entries()) {
if (index >= previousCount) break;
if (matched.has(previous) || previous.text.replace(/\s+/g, '') !== text) continue;
const overlap =
Math.min(previous.endTime, cue.endTime) - Math.max(previous.startTime, cue.startTime);
const shorterDuration = Math.min(
previous.endTime - previous.startTime,
cue.endTime - cue.startTime,
);
return (
overlap > 0 && overlap >= shorterDuration / 2 && previous.text.replace(/\s+/g, '') === text
);
});
if (overlap > greatestOverlap && overlap >= shorterDuration / 2) {
duplicate = previous;
greatestOverlap = overlap;
}
}
if (duplicate) {
duplicate.startTime = Math.min(duplicate.startTime, cue.startTime);
duplicate.endTime = Math.max(duplicate.endTime, cue.endTime);