mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
88 lines
3.4 KiB
TypeScript
88 lines
3.4 KiB
TypeScript
import type { SubtitleCue } from './subtitle-cue-parser';
|
|
import { SPEECH_PASSAGE_SECONDS, type SpeechPassage } from './subtitle-generation-speech';
|
|
|
|
const CHUNK_CONTEXT_SECONDS = 0.25;
|
|
const WHISPER_WINDOW_SECONDS = 30;
|
|
const PAUSE_SEARCH_SECONDS = 5;
|
|
|
|
// Prefer detected speech starts, then quiet pauses. Context stays inside retained audio.
|
|
export function splitSpeechPassages(
|
|
passages: readonly SpeechPassage[],
|
|
pauses: readonly number[] = [],
|
|
speechStarts: readonly number[] = [],
|
|
): SpeechPassage[] {
|
|
return passages.flatMap((passage) => {
|
|
if (passage.endSeconds - passage.startSeconds <= WHISPER_WINDOW_SECONDS)
|
|
return [{ ...passage }];
|
|
const chunks: SpeechPassage[] = [];
|
|
let boundary = passage.startSeconds;
|
|
while (boundary < passage.endSeconds) {
|
|
const target = boundary + SPEECH_PASSAGE_SECONDS;
|
|
let end = Math.min(target, passage.endSeconds);
|
|
if (target < passage.endSeconds) {
|
|
// Starting in a long quiet lead-in can make Whisper place the next line
|
|
// several seconds early. A nearby VAD start gives the next chunk an anchor.
|
|
let nearestSpeechStart: number | undefined;
|
|
for (const time of speechStarts) {
|
|
if (
|
|
time >= target - PAUSE_SEARCH_SECONDS &&
|
|
time <= target + PAUSE_SEARCH_SECONDS &&
|
|
time < passage.endSeconds &&
|
|
(nearestSpeechStart === undefined ||
|
|
Math.abs(time - target) < Math.abs(nearestSpeechStart - target))
|
|
)
|
|
nearestSpeechStart = time;
|
|
}
|
|
let latestPause: number | undefined;
|
|
for (const time of pauses) {
|
|
if (
|
|
time >= target - PAUSE_SEARCH_SECONDS &&
|
|
time <= target &&
|
|
(latestPause === undefined || time > latestPause)
|
|
)
|
|
latestPause = time;
|
|
}
|
|
end = nearestSpeechStart ?? latestPause ?? end;
|
|
}
|
|
chunks.push({
|
|
startSeconds: Math.max(passage.startSeconds, boundary - CHUNK_CONTEXT_SECONDS),
|
|
endSeconds: Math.min(passage.endSeconds, end + CHUNK_CONTEXT_SECONDS),
|
|
});
|
|
boundary = end;
|
|
}
|
|
return chunks;
|
|
});
|
|
}
|
|
|
|
// Deduplicate only matching text substantially overlapping cues from earlier chunks.
|
|
// Repeated words within the current chunk or at separate times remain separate.
|
|
export function appendSpeechChunkCues(cues: SubtitleCue[], incoming: readonly SubtitleCue[]): void {
|
|
const previousCount = cues.length;
|
|
const matched = new Set<SubtitleCue>();
|
|
for (const cue of incoming) {
|
|
const text = cue.text.replace(/[\s\p{P}]+/gu, '');
|
|
let duplicate: SubtitleCue | undefined;
|
|
let greatestOverlap = 0;
|
|
for (const [index, previous] of cues.entries()) {
|
|
if (index >= previousCount) break;
|
|
if (!text || matched.has(previous) || previous.text.replace(/[\s\p{P}]+/gu, '') !== 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,
|
|
);
|
|
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);
|
|
matched.add(duplicate);
|
|
} else cues.push({ ...cue });
|
|
}
|
|
}
|