feat(captions): segment captions by sentence using punctuation and pauses

This commit is contained in:
Joe Hachem
2026-06-27 19:10:31 +03:00
parent 05072618c4
commit 1f2ddd49d3
6 changed files with 858 additions and 8 deletions
+14 -5
View File
@@ -9,9 +9,9 @@ import { getBundledWhisperExecutableCandidates } from "../paths/binaries";
import { resolveRecordingSession } from "../project/session";
import { normalizeVideoSourcePath } from "../utils";
import { parseSrtCues, parseWhisperJsonCues, shouldRetryWhisperWithoutJson } from "./parser";
import { segmentCuesIntoPhrases } from "./segment";
import {
parseSilenceIntervals,
resegmentCuesBySilence,
SILENCE_DETECT_MIN_S,
SILENCE_NOISE_DB,
type SilenceInterval,
@@ -259,19 +259,28 @@ export async function generateAutoCaptionsFromVideo(options: {
const timedCues = jsonEnabled
? parseWhisperJsonCues(await fs.readFile(jsonPath, "utf-8"))
: [];
if (jsonEnabled && timedCues.length === 0) {
// JSON ran but yielded no word-timed cues (empty/malformed output). We fall back
// to SRT, which has no word timings — captions are then split by sentence text and
// silence rather than precise word timing. Surface it for diagnosis.
console.warn(
"[auto-captions] Whisper JSON produced no word-timed cues; falling back to SRT (no word timings).",
);
}
const cues =
timedCues.length > 0 ? timedCues : parseSrtCues(await fs.readFile(srtPath, "utf-8"));
if (cues.length === 0) {
throw new Error("Whisper completed, but no caption cues were produced.");
}
// Whisper cues span silence and don't break on pauses. Re-segment them against
// ground-truth acoustic silence (ffmpeg `silencedetect`) so captions only cover
// real speech. Failure here must not block caption generation — fall back to raw.
// Whisper cues run sentences together and don't break on pauses. Re-segment them
// into one caption per sentence/phrase using Whisper's own word stream (punctuation
// + pauses), backed by ground-truth acoustic silence (ffmpeg `silencedetect`).
// Failure here must not block caption generation — fall back to raw.
let cuesToReturn = cues;
try {
const silences = await detectSilenceIntervals({ ffmpegPath, wavPath });
const resegmented = resegmentCuesBySilence(cues, silences);
const resegmented = segmentCuesIntoPhrases(cues, silences);
if (resegmented.length > 0) {
cuesToReturn = resegmented;
}
+347
View File
@@ -0,0 +1,347 @@
import { describe, expect, it } from "vitest";
import type { CaptionCuePayload } from "../types";
import { endsSentence, segmentCuesIntoPhrases } from "./segment";
describe("endsSentence", () => {
it("detects terminal punctuation, ignoring trailing closers", () => {
expect(endsSentence("world.")).toBe(true);
expect(endsSentence("you?")).toBe(true);
expect(endsSentence("stop!")).toBe(true);
expect(endsSentence("wait…")).toBe(true);
expect(endsSentence('said."')).toBe(true);
expect(endsSentence("done,")).toBe(false);
expect(endsSentence("hello")).toBe(false);
});
it("does not treat common abbreviations or initialisms as a sentence end", () => {
expect(endsSentence("Mr.")).toBe(false);
expect(endsSentence("Dr.")).toBe(false);
expect(endsSentence("etc.")).toBe(false);
expect(endsSentence("e.g.")).toBe(false);
expect(endsSentence("U.S.")).toBe(false);
expect(endsSentence("J.")).toBe(false);
// real sentence ends are unaffected
expect(endsSentence("Smith.")).toBe(true);
expect(endsSentence("5.")).toBe(true);
// `?`/`!` after an abbreviation-like token still end the sentence
expect(endsSentence("really?")).toBe(true);
});
});
describe("segmentCuesIntoPhrases", () => {
it("splits back-to-back sentences with no pause into separate captions", () => {
const cues: CaptionCuePayload[] = [
{
id: "caption-1",
startMs: 0,
endMs: 1_800,
text: "Hello world. How are you?",
words: [
{ text: "Hello", startMs: 0, endMs: 400 },
{ text: "world.", startMs: 400, endMs: 800, leadingSpace: true },
{ text: "How", startMs: 800, endMs: 1_100, leadingSpace: true },
{ text: "are", startMs: 1_100, endMs: 1_400, leadingSpace: true },
{ text: "you?", startMs: 1_400, endMs: 1_800, leadingSpace: true },
],
},
];
const result = segmentCuesIntoPhrases(cues, []);
expect(result).toHaveLength(2);
expect(result[0].text).toBe("Hello world.");
expect(result[1].text).toBe("How are you?");
expect(result[0].endMs).toBeLessThanOrEqual(result[1].startMs);
});
it("does not leak the first word of the next sentence into the previous caption", () => {
const cues: CaptionCuePayload[] = [
{
id: "caption-1",
startMs: 0,
endMs: 1_800,
text: "Hello world. How are you?",
words: [
{ text: "Hello", startMs: 0, endMs: 400 },
{ text: "world.", startMs: 400, endMs: 800, leadingSpace: true },
{ text: "How", startMs: 800, endMs: 1_100, leadingSpace: true },
{ text: "are", startMs: 1_100, endMs: 1_400, leadingSpace: true },
{ text: "you?", startMs: 1_400, endMs: 1_800, leadingSpace: true },
],
},
];
const result = segmentCuesIntoPhrases(cues, []);
expect(result[1].words?.[0].text).toBe("How");
expect(result[1].words?.[0].startMs).toBe(800);
// the first word of a phrase has no leading space
expect(result[1].words?.[0].leadingSpace).toBeUndefined();
});
it("merges rapid-fire short sentences into one caption (word-timed)", () => {
const cues: CaptionCuePayload[] = [
{
id: "caption-1",
startMs: 0,
endMs: 800,
text: "Okay. Great.",
words: [
{ text: "Okay.", startMs: 0, endMs: 400 },
{ text: "Great.", startMs: 400, endMs: 800, leadingSpace: true },
],
},
];
const result = segmentCuesIntoPhrases(cues, []);
expect(result).toHaveLength(1);
expect(result[0].text).toBe("Okay. Great.");
expect(result[0].words?.map((word) => word.text)).toEqual(["Okay.", "Great."]);
});
it("merges rapid-fire short sentences into one caption (word-less)", () => {
const cues: CaptionCuePayload[] = [
{ id: "caption-1", startMs: 0, endMs: 400, text: "Okay." },
{ id: "caption-2", startMs: 400, endMs: 800, text: "Great." },
];
const result = segmentCuesIntoPhrases(cues, []);
expect(result).toHaveLength(1);
expect(result[0].text).toBe("Okay. Great.");
});
it("does not merge a short sentence into a full-length one", () => {
const cues: CaptionCuePayload[] = [
{
id: "caption-1",
startMs: 0,
endMs: 2_400,
text: "Okay. This is a full length sentence that should stand alone.",
words: [
{ text: "Okay.", startMs: 0, endMs: 400 },
{ text: "This", startMs: 400, endMs: 700, leadingSpace: true },
{ text: "is", startMs: 700, endMs: 900, leadingSpace: true },
{ text: "a", startMs: 900, endMs: 1_050, leadingSpace: true },
{ text: "full", startMs: 1_050, endMs: 1_350, leadingSpace: true },
{ text: "length", startMs: 1_350, endMs: 1_650, leadingSpace: true },
{ text: "sentence", startMs: 1_650, endMs: 1_950, leadingSpace: true },
{ text: "that", startMs: 1_950, endMs: 2_100, leadingSpace: true },
{ text: "should", startMs: 2_100, endMs: 2_250, leadingSpace: true },
{ text: "stand", startMs: 2_250, endMs: 2_350, leadingSpace: true },
{ text: "alone.", startMs: 2_350, endMs: 2_400, leadingSpace: true },
],
},
];
const result = segmentCuesIntoPhrases(cues, []);
expect(result).toHaveLength(2);
expect(result[0].text).toBe("Okay.");
expect(result[1].text).toBe("This is a full length sentence that should stand alone.");
});
it("keeps a comma/clause inside one caption", () => {
const cues: CaptionCuePayload[] = [
{
id: "caption-1",
startMs: 0,
endMs: 1_500,
text: "When I'm done, I start.",
words: [
{ text: "When", startMs: 0, endMs: 300 },
{ text: "I'm", startMs: 300, endMs: 600, leadingSpace: true },
{ text: "done,", startMs: 600, endMs: 900, leadingSpace: true },
{ text: "I", startMs: 900, endMs: 1_100, leadingSpace: true },
{ text: "start.", startMs: 1_100, endMs: 1_500, leadingSpace: true },
],
},
];
const result = segmentCuesIntoPhrases(cues, []);
expect(result).toHaveLength(1);
expect(result[0].text).toBe("When I'm done, I start.");
});
it("does not split on an abbreviation, only on the real sentence end", () => {
const cues: CaptionCuePayload[] = [
{
id: "caption-1",
startMs: 0,
endMs: 1_800,
text: "Hello Mr. Smith. How are you?",
words: [
{ text: "Hello", startMs: 0, endMs: 300 },
{ text: "Mr.", startMs: 300, endMs: 600, leadingSpace: true },
{ text: "Smith.", startMs: 600, endMs: 900, leadingSpace: true },
{ text: "How", startMs: 900, endMs: 1_200, leadingSpace: true },
{ text: "are", startMs: 1_200, endMs: 1_500, leadingSpace: true },
{ text: "you?", startMs: 1_500, endMs: 1_800, leadingSpace: true },
],
},
];
const result = segmentCuesIntoPhrases(cues, []);
expect(result).toHaveLength(2);
expect(result[0].text).toBe("Hello Mr. Smith.");
expect(result[1].text).toBe("How are you?");
});
it("splits on a real pause even without punctuation", () => {
const cues: CaptionCuePayload[] = [
{
id: "caption-1",
startMs: 0,
endMs: 2_000,
text: "one two three four",
words: [
{ text: "one", startMs: 0, endMs: 300 },
{ text: "two", startMs: 300, endMs: 600, leadingSpace: true },
// 800ms gap (>= 700ms pause) -> phrase boundary
{ text: "three", startMs: 1_400, endMs: 1_700, leadingSpace: true },
{ text: "four", startMs: 1_700, endMs: 2_000, leadingSpace: true },
],
},
];
const result = segmentCuesIntoPhrases(cues, []);
expect(result).toHaveLength(2);
expect(result[0].text).toBe("one two");
expect(result[1].text).toBe("three four");
});
it("splits on a long acoustic silence in a word gap even when the word gap is short", () => {
const cues: CaptionCuePayload[] = [
{
id: "caption-1",
startMs: 0,
endMs: 2_600,
text: "alpha beta",
words: [
{ text: "alpha", startMs: 0, endMs: 600 },
{ text: "beta", startMs: 2_200, endMs: 2_600, leadingSpace: true },
],
},
];
// pauseMs high so the word gap (1600ms) wouldn't split on its own; the 1500ms
// silence sitting in the gap is what forces the break. beta starts after the
// silence ends, so it is not dropped as a hallucination.
const result = segmentCuesIntoPhrases(cues, [{ startMs: 600, endMs: 2_100 }], {
pauseMs: 5_000,
});
expect(result).toHaveLength(2);
expect(result[0].text).toBe("alpha");
expect(result[1].text).toBe("beta");
});
it("does not split that same input when there is no silence and the gap is below the pause", () => {
const cues: CaptionCuePayload[] = [
{
id: "caption-1",
startMs: 0,
endMs: 2_600,
text: "alpha beta",
words: [
{ text: "alpha", startMs: 0, endMs: 600 },
{ text: "beta", startMs: 2_200, endMs: 2_600, leadingSpace: true },
],
},
];
const result = segmentCuesIntoPhrases(cues, [], { pauseMs: 5_000 });
expect(result).toHaveLength(1);
expect(result[0].text).toBe("alpha beta");
});
it("splits a runaway phrase with no punctuation and no pause via the safety cap", () => {
const words = Array.from({ length: 12 }, (_, index) => ({
text: `w${index}`,
startMs: index * 300,
endMs: index * 300 + 300,
...(index > 0 ? { leadingSpace: true } : {}),
}));
const cues: CaptionCuePayload[] = [
{
id: "caption-1",
startMs: 0,
endMs: 3_600,
text: words.map((w) => w.text).join(" "),
words,
},
];
const result = segmentCuesIntoPhrases(cues, [], { maxPhraseMs: 2_000 });
expect(result.length).toBeGreaterThan(1);
expect(result.map((cue) => cue.text).join(" ")).toBe(words.map((w) => w.text).join(" "));
});
it("drops words that fall entirely inside a long silence (hallucination)", () => {
const cues: CaptionCuePayload[] = [
{
id: "caption-1",
startMs: 5_000,
endMs: 6_000,
text: "Thank you.",
words: [
{ text: "Thank", startMs: 5_000, endMs: 5_500 },
{ text: "you.", startMs: 5_500, endMs: 6_000, leadingSpace: true },
],
},
];
const result = segmentCuesIntoPhrases(cues, [{ startMs: 4_000, endMs: 9_000 }]);
expect(result).toHaveLength(0);
});
it("produces sorted, non-overlapping cues with sequential ids", () => {
const cues: CaptionCuePayload[] = [
{
id: "caption-1",
startMs: 0,
endMs: 2_400,
text: "First phrase here. Second phrase here. Third one.",
words: [
{ text: "First", startMs: 0, endMs: 300 },
{ text: "phrase", startMs: 300, endMs: 600, leadingSpace: true },
{ text: "here.", startMs: 600, endMs: 900, leadingSpace: true },
{ text: "Second", startMs: 900, endMs: 1_200, leadingSpace: true },
{ text: "phrase", startMs: 1_200, endMs: 1_500, leadingSpace: true },
{ text: "here.", startMs: 1_500, endMs: 1_800, leadingSpace: true },
{ text: "Third", startMs: 1_800, endMs: 2_100, leadingSpace: true },
{ text: "one.", startMs: 2_100, endMs: 2_400, leadingSpace: true },
],
},
];
const result = segmentCuesIntoPhrases(cues, []);
expect(result).toHaveLength(3);
for (let index = 0; index < result.length - 1; index += 1) {
expect(result[index].startMs).toBeLessThanOrEqual(result[index + 1].startMs);
expect(result[index].endMs).toBeLessThanOrEqual(result[index + 1].startMs);
}
expect(result.map((cue) => cue.id)).toEqual(
result.map((_, index) => `caption-${index + 1}`),
);
});
it("keeps a single-sentence word-less cue intact (legacy silence trim preserved)", () => {
const cues: CaptionCuePayload[] = [
{ id: "caption-1", startMs: 0, endMs: 13_920, text: "Hello, this is a test" },
];
// One sentence -> unchanged: trim leading silence, keep text (legacy behavior).
const result = segmentCuesIntoPhrases(cues, [{ startMs: 0, endMs: 3_000 }]);
expect(result).toHaveLength(1);
expect(result[0].startMs).toBe(3_000 - 80);
expect(result[0].endMs).toBe(13_920 + 80);
expect(result[0].text).toBe("Hello, this is a test");
});
it("splits a continuous word-less paragraph into one caption per sentence", () => {
// The reported bug: no word timings (SRT path) + continuous speech collapsed into one
// caption. It must now split on sentence punctuation even without word timings.
const cues: CaptionCuePayload[] = [
{ id: "caption-1", startMs: 0, endMs: 3_000, text: "This is one." },
{ id: "caption-2", startMs: 3_000, endMs: 6_000, text: "This is two." },
{ id: "caption-3", startMs: 6_000, endMs: 9_000, text: "This is three." },
];
const result = segmentCuesIntoPhrases(cues, []);
expect(result).toHaveLength(3);
expect(result.map((cue) => cue.text)).toEqual([
"This is one.",
"This is two.",
"This is three.",
]);
for (let index = 0; index < result.length - 1; index += 1) {
expect(result[index].endMs).toBeLessThanOrEqual(result[index + 1].startMs);
}
expect(result.map((cue) => cue.id)).toEqual(["caption-1", "caption-2", "caption-3"]);
});
it("returns an empty array for empty input", () => {
expect(segmentCuesIntoPhrases([], [])).toEqual([]);
});
});
+451
View File
@@ -0,0 +1,451 @@
import type { CaptionCuePayload, CaptionWordPayload } from "../types";
import { buildCaptionTextFromWords } from "./parser";
import { padSpans, resegmentCuesBySilence, type SilenceInterval } from "./silence";
/**
* Phrase-aware caption segmentation.
*
* Whisper breaks speech on its own internal boundaries — not on sentences — so a single
* cue can run two phrases together, and re-segmenting purely on acoustic silence merges
* back-to-back sentences and misassigns boundary words. We instead walk Whisper's own
* word stream (which carries punctuation) and start a new caption at a real boundary:
* - the end of a sentence (`.`, `?`, `!`, `…`), or
* - a real pause — a large gap between two consecutive words, or a long ffmpeg
* `silencedetect` interval sitting in that gap.
*
* Because every break happens *between two consecutive words*, a word can never leak into
* the wrong caption (the failure mode of center-time region assignment). Commas/clauses
* stay inside a caption and a whole sentence is allowed to be one caption; only a high
* safety cap splits a runaway phrase with no punctuation and no pause.
*
* When the transcript has no word timings (SRT fallback) we first re-segment by acoustic
* silence (`resegmentCuesBySilence`) and then split each cue on its sentence punctuation, so
* a continuous paragraph still becomes one caption per sentence (timing is proportional).
*/
/** A gap (ms) between two consecutive words this long or longer starts a new phrase. */
const DEFAULT_PHRASE_PAUSE_MS = 700;
/** An ffmpeg `silencedetect` interval this long inside a word gap also starts a new phrase. */
const DEFAULT_SPLIT_SILENCE_MS = 1_500;
/** Padding (ms) kept around each phrase so captions don't feel clipped. */
const DEFAULT_EDGE_PAD_MS = 80;
/** Safety cap: a phrase with no sentence end and no pause is split once it gets this long. */
const DEFAULT_MAX_PHRASE_MS = 12_000;
/**
* A caption shorter than this (ms) is "too quick" and may be merged with an adjacent short
* caption so rapid-fire one-word sentences ("Okay." "Great.") don't each flash by alone.
*/
const DEFAULT_MIN_CAPTION_MS = 800;
/** Only merge short captions separated by at most this gap (ms) — never across a real pause. */
const DEFAULT_MERGE_GAP_MS = 400;
/** A merged short-caption run never grows past this duration (ms) or character count. */
const DEFAULT_MAX_MERGED_MS = 2_500;
const DEFAULT_MAX_MERGED_CHARS = 80;
export interface SegmentOptions {
/** Word gap (ms) that splits one phrase into two. Lower = more, shorter captions. */
pauseMs?: number;
/** Minimum acoustic silence (ms) inside a word gap that also splits a phrase. */
splitSilenceMs?: number;
/** Padding (ms) kept around each phrase. */
edgePadMs?: number;
/** Safety cap (ms) that splits a punctuation-less, pause-less runaway phrase. */
maxPhraseMs?: number;
/** A caption shorter than this (ms) may be merged with an adjacent short caption. */
minCaptionMs?: number;
/** Only merge short captions separated by at most this gap (ms). */
mergeGapMs?: number;
/** A merged short-caption run never grows past this duration (ms). */
maxMergedMs?: number;
/** A merged short-caption run never grows past this character count. */
maxMergedChars?: number;
}
interface CaptionPiece {
startMs: number;
endMs: number;
text: string;
words: CaptionWordPayload[];
}
const SENTENCE_END = /[.?!…。!?]$/;
/** Closing quotes/brackets that can trail terminal punctuation, e.g. `said."` */
const TRAILING_CLOSERS = /[)\]}"'”’»」』)】]}>]+$/u;
/**
* Unambiguous English titles that take a trailing period mid-sentence. Kept deliberately
* short: only words that are never themselves a sentence (so we don't suppress a real
* break — e.g. "no" is excluded because "No." is a valid sentence). Dotted initialisms
* like "e.g."/"U.S."/"a.m." are handled by the regex below, not this list. `?`/`!`/`…`
* always end a sentence. For non-English audio this simply never matches.
*/
const ABBREVIATIONS = new Set(["mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st", "vs", "etc"]);
/** A trailing period belongs to an abbreviation/initialism rather than ending a sentence. */
function isAbbreviation(text: string): boolean {
const trimmed = text.trim().replace(TRAILING_CLOSERS, "").trim();
if (!trimmed.endsWith(".")) {
return false; // only a plain period can be an abbreviation marker
}
const core = trimmed.slice(0, -1).toLowerCase();
if (core.length === 0) {
return false;
}
// Single-letter initial ("J.", "U.") or dotted initialism ("U.S.", "e.g.", "a.m.").
if (/^[a-z]$/.test(core) || /^[a-z](\.[a-z])+$/.test(core)) {
return true;
}
return ABBREVIATIONS.has(core);
}
/**
* True when a word's text ends a sentence, ignoring trailing closing quotes/brackets and
* common abbreviations (so "Mr. Smith" or "e.g." don't start a new caption).
*/
export function endsSentence(text: string): boolean {
const trimmed = text.trim().replace(TRAILING_CLOSERS, "").trim();
if (!SENTENCE_END.test(trimmed)) {
return false;
}
return !isAbbreviation(text);
}
/** Every cue carries usable word timing, so we can segment on the word stream. */
function hasWordTimings(cues: CaptionCuePayload[]): boolean {
return cues.length > 0 && cues.every((cue) => Array.isArray(cue.words) && cue.words.length > 0);
}
/** Flatten all cues' words into one time-ordered stream, spacing across cue joins. */
function flattenWords(cues: CaptionCuePayload[]): CaptionWordPayload[] {
const stream: CaptionWordPayload[] = [];
for (const cue of cues) {
const words = (cue.words ?? []) as CaptionWordPayload[];
words.forEach((word, index) => {
// A new cue continues the speech, so its first word leads with a space.
const leadingSpace =
stream.length > 0 && (index === 0 ? true : word.leadingSpace !== false);
stream.push({
text: word.text,
startMs: word.startMs,
endMs: word.endMs,
...(leadingSpace ? { leadingSpace: true } : {}),
});
});
}
return stream.sort((left, right) => left.startMs - right.startMs || left.endMs - right.endMs);
}
/** Drop words that sit entirely inside a long detected silence (Whisper hallucinations). */
function dropHallucinations(
words: CaptionWordPayload[],
silences: SilenceInterval[],
splitSilenceMs: number,
): CaptionWordPayload[] {
const longSilences = silences.filter(
(silence) => silence.endMs - silence.startMs >= splitSilenceMs,
);
if (longSilences.length === 0) {
return words;
}
return words.filter(
(word) =>
!longSilences.some(
(silence) => silence.startMs <= word.startMs && word.endMs <= silence.endMs,
),
);
}
/** A long silence interval overlaps the gap between two consecutive words. */
function silenceInGap(
gapStartMs: number,
gapEndMs: number,
silences: SilenceInterval[],
splitSilenceMs: number,
): boolean {
return silences.some(
(silence) =>
silence.endMs - silence.startMs >= splitSilenceMs &&
silence.startMs < gapEndMs &&
silence.endMs > gapStartMs,
);
}
/** Reset the first word's leading space so a phrase reads as its own line. */
function normalizePhraseWords(words: CaptionWordPayload[]): CaptionWordPayload[] {
return words.map((word, index) => {
if (index === 0 && word.leadingSpace) {
const { leadingSpace: _leadingSpace, ...rest } = word;
return rest;
}
return word;
});
}
/** Group a cue's words into runs that each end on a sentence boundary. */
function groupWordsBySentence(words: CaptionWordPayload[]): CaptionWordPayload[][] {
const groups: CaptionWordPayload[][] = [];
let current: CaptionWordPayload[] = [];
words.forEach((word, index) => {
current.push(word);
if (endsSentence(word.text) && index < words.length - 1) {
groups.push(current);
current = [];
}
});
if (current.length > 0) {
groups.push(current);
}
return groups;
}
/**
* Split a word-less cue's text into one cue per sentence, distributing the cue's time span
* across sentences by character length. Used on the fallback (no word timing) path so a
* continuous paragraph still becomes one caption per sentence.
*/
function splitTextBySentence(cue: CaptionCuePayload): CaptionCuePayload[] {
const tokens = cue.text.trim().split(/\s+/).filter(Boolean);
if (tokens.length <= 1) {
return [cue];
}
const groups: string[][] = [];
let current: string[] = [];
tokens.forEach((token, index) => {
current.push(token);
if (endsSentence(token) && index < tokens.length - 1) {
groups.push(current);
current = [];
}
});
if (current.length > 0) {
groups.push(current);
}
if (groups.length <= 1) {
return [cue];
}
const texts = groups.map((group) => group.join(" "));
const totalChars = texts.reduce((sum, text) => sum + text.length, 0) || 1;
const spanMs = Math.max(1, cue.endMs - cue.startMs);
let cursorMs = cue.startMs;
return texts.map((text, index) => {
const startMs = cursorMs;
const endMs =
index === texts.length - 1
? cue.endMs
: Math.min(
cue.endMs - 1,
Math.round(startMs + (spanMs * text.length) / totalChars),
);
cursorMs = Math.max(startMs + 1, endMs);
return { id: cue.id, startMs, endMs: Math.max(startMs + 1, endMs), text };
});
}
/** Split one re-segmented cue into one cue per sentence (by words if present, else text). */
function splitCueBySentences(cue: CaptionCuePayload): CaptionCuePayload[] {
const words = Array.isArray(cue.words) ? (cue.words as CaptionWordPayload[]) : [];
if (words.length === 0) {
return splitTextBySentence(cue);
}
const groups = groupWordsBySentence(words);
if (groups.length <= 1) {
return [cue];
}
return groups.map((group) => {
const phraseWords = normalizePhraseWords(group);
return {
id: cue.id,
startMs: phraseWords[0].startMs,
endMs: phraseWords[phraseWords.length - 1].endMs,
text: buildCaptionTextFromWords(phraseWords),
words: phraseWords,
};
});
}
/** Concatenate two adjacent cues into one, joining words (with a space) when both have them. */
function mergeTwoCues(left: CaptionCuePayload, right: CaptionCuePayload): CaptionCuePayload {
const leftWords = Array.isArray(left.words) ? (left.words as CaptionWordPayload[]) : [];
const rightWords = Array.isArray(right.words) ? (right.words as CaptionWordPayload[]) : [];
if (leftWords.length > 0 && rightWords.length > 0) {
// The right cue's first word started its own phrase (no leading space) — restore it.
const joined = normalizePhraseWords([
...leftWords,
...rightWords.map((word, index) =>
index === 0 ? { ...word, leadingSpace: true } : word,
),
]);
return {
id: left.id,
startMs: left.startMs,
endMs: right.endMs,
text: buildCaptionTextFromWords(joined),
words: joined,
};
}
return {
id: left.id,
startMs: left.startMs,
endMs: right.endMs,
text: `${left.text} ${right.text}`.trim(),
};
}
interface MergeOptions {
minCaptionMs: number;
mergeGapMs: number;
maxMergedMs: number;
maxMergedChars: number;
}
/**
* Merge adjacent captions that are BOTH short and rapid-fire (tiny gap), so quick one-word
* sentences like "Okay." "Great." read as one caption instead of flashing by individually.
* Only merges when both sides are short, so a short caption never absorbs a full-length one,
* and never across a real pause or past the size caps.
*/
function mergeShortAdjacentCaptions(
cues: CaptionCuePayload[],
options: MergeOptions,
): CaptionCuePayload[] {
if (cues.length <= 1) {
return cues;
}
const merged: CaptionCuePayload[] = [];
let group = cues[0];
for (let index = 1; index < cues.length; index += 1) {
const next = cues[index];
const groupDurationMs = group.endMs - group.startMs;
const nextDurationMs = next.endMs - next.startMs;
const gapMs = next.startMs - group.endMs;
const combinedDurationMs = next.endMs - group.startMs;
const combinedChars = group.text.length + next.text.length + 1;
const canMerge =
groupDurationMs < options.minCaptionMs &&
nextDurationMs < options.minCaptionMs &&
gapMs <= options.mergeGapMs &&
combinedDurationMs <= options.maxMergedMs &&
combinedChars <= options.maxMergedChars;
if (canMerge) {
group = mergeTwoCues(group, next);
} else {
merged.push(group);
group = next;
}
}
merged.push(group);
return merged;
}
/** Assign sequential, stable ids to the final cue list. */
function renumberCues(cues: CaptionCuePayload[]): CaptionCuePayload[] {
return cues.map((cue, index) => ({ ...cue, id: `caption-${index + 1}` }));
}
/**
* Re-segment Whisper cues into one caption per sentence/phrase, then merge rapid-fire short
* sentences back together. Returns sorted, non-overlapping cues with fresh ids. Falls back to
* silence-only re-segmentation (plus sentence splitting) when the transcript has no word timings.
*/
export function segmentCuesIntoPhrases(
cues: CaptionCuePayload[],
silences: SilenceInterval[],
options: SegmentOptions = {},
): CaptionCuePayload[] {
const pauseMs = options.pauseMs ?? DEFAULT_PHRASE_PAUSE_MS;
const splitSilenceMs = options.splitSilenceMs ?? DEFAULT_SPLIT_SILENCE_MS;
const edgePadMs = options.edgePadMs ?? DEFAULT_EDGE_PAD_MS;
const maxPhraseMs = options.maxPhraseMs ?? DEFAULT_MAX_PHRASE_MS;
const mergeOptions: MergeOptions = {
minCaptionMs: options.minCaptionMs ?? DEFAULT_MIN_CAPTION_MS,
mergeGapMs: options.mergeGapMs ?? DEFAULT_MERGE_GAP_MS,
maxMergedMs: options.maxMergedMs ?? DEFAULT_MAX_MERGED_MS,
maxMergedChars: options.maxMergedChars ?? DEFAULT_MAX_MERGED_CHARS,
};
if (cues.length === 0) {
return [];
}
// No word timings (SRT path): the silence-only segmenter trims/merges by acoustic
// silence but can't see sentence boundaries, so a continuous paragraph would collapse
// into one caption. Re-segment by silence first, then split each cue on its sentence
// punctuation so we still get one caption per sentence.
if (!hasWordTimings(cues)) {
const base = resegmentCuesBySilence(cues, silences, { splitSilenceMs, edgePadMs });
const sentences = base.flatMap(splitCueBySentences);
return renumberCues(mergeShortAdjacentCaptions(sentences, mergeOptions));
}
const sortedCues = [...cues].sort(
(left, right) => left.startMs - right.startMs || left.endMs - right.endMs,
);
const stream = dropHallucinations(flattenWords(sortedCues), silences, splitSilenceMs);
if (stream.length === 0) {
return [];
}
const phrases: CaptionWordPayload[][] = [];
let current: CaptionWordPayload[] = [];
for (let index = 0; index < stream.length; index += 1) {
const word = stream[index];
current.push(word);
const next = stream[index + 1];
if (!next) {
break;
}
const gapMs = next.startMs - word.endMs;
const phraseDurationMs = word.endMs - current[0].startMs;
const shouldBreak =
endsSentence(word.text) ||
gapMs >= pauseMs ||
silenceInGap(word.endMs, next.startMs, silences, splitSilenceMs) ||
phraseDurationMs >= maxPhraseMs;
if (shouldBreak) {
phrases.push(current);
current = [];
}
}
if (current.length > 0) {
phrases.push(current);
}
const pieces: CaptionPiece[] = phrases
.map((words) => {
const phraseWords = normalizePhraseWords(words);
return {
startMs: phraseWords[0].startMs,
endMs: phraseWords[phraseWords.length - 1].endMs,
text: buildCaptionTextFromWords(phraseWords),
words: phraseWords,
};
})
.filter((piece) => piece.text.trim().length > 0);
if (pieces.length === 0) {
return [];
}
pieces.sort((left, right) => left.startMs - right.startMs || left.endMs - right.endMs);
padSpans(pieces, edgePadMs);
const sentenceCues: CaptionCuePayload[] = pieces.map((piece) => ({
id: "",
startMs: piece.startMs,
endMs: piece.endMs,
text: piece.text,
...(piece.words.length > 0 ? { words: piece.words } : {}),
}));
return renumberCues(mergeShortAdjacentCaptions(sentenceCues, mergeOptions));
}
+1 -1
View File
@@ -171,7 +171,7 @@ function splitTextProportionally(text: string, overlaps: Span[]): string[] {
}
/** Pad spans toward neighbors by at most half the silent gap, so cues never overlap. */
function padSpans(spans: Span[], edgePadMs: number): void {
export function padSpans(spans: Span[], edgePadMs: number): void {
for (let index = 0; index < spans.length; index += 1) {
const prevEndMs = index > 0 ? spans[index - 1].endMs : null;
const nextStartMs = index < spans.length - 1 ? spans[index + 1].startMs : null;
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { flattenCaptionWords } from "./captionLayout";
import type { CaptionCue } from "./types";
describe("flattenCaptionWords", () => {
it("forces a break at every cue boundary so each phrase shows on its own", () => {
const cues: CaptionCue[] = [
{
id: "a",
startMs: 0,
endMs: 1_000,
text: "hello world",
words: [
{ text: "hello", startMs: 0, endMs: 500 },
{ text: "world", startMs: 500, endMs: 1_000, leadingSpace: true },
],
},
{
// back-to-back with cue "a" (no gap) — would previously be re-packed by width
id: "b",
startMs: 1_000,
endMs: 2_000,
text: "next one",
words: [
{ text: "next", startMs: 1_000, endMs: 1_500 },
{ text: "one", startMs: 1_500, endMs: 2_000, leadingSpace: true },
],
},
];
const flattened = flattenCaptionWords(cues);
const firstWordOfSecondCue = flattened.find(
(word) => word.cueId === "b" && word.cueWordIndex === 0,
);
expect(firstWordOfSecondCue?.forcedBreakBefore).toBe(true);
expect(firstWordOfSecondCue?.leadingSpace).toBe(false);
// the very first word of the first cue never forces a break
expect(flattened[0].forcedBreakBefore).toBe(false);
});
});
+4 -2
View File
@@ -179,8 +179,10 @@ export function flattenCaptionWords(cues: CaptionCue[]) {
const cueDuration = Math.max(1, cue.endMs - cue.startMs);
const fallbackWordDuration = cueDuration / sourceWords.length;
const previousCue = cueIndex > 0 ? cues[cueIndex - 1] : null;
const shouldForceCueBreak =
previousCue !== null && cue.startMs - previousCue.endMs >= CAPTION_BLOCK_GAP_BREAK_MS;
// Each cue is its own phrase, so always start a new line/page at a cue boundary.
// Otherwise back-to-back phrases (a small gap) get re-packed together by width and
// their boundary disappears on screen — we want one phrase shown at a time.
const shouldForceCueBreak = previousCue !== null;
sourceWords.forEach((word, wordIndex) => {
const fallbackStartMs = cue.startMs + fallbackWordDuration * wordIndex;