diff --git a/packages/cli/src/whisper/normalize.test.ts b/packages/cli/src/whisper/normalize.test.ts index 95b4ca93040..f016b5c48fe 100644 --- a/packages/cli/src/whisper/normalize.test.ts +++ b/packages/cli/src/whisper/normalize.test.ts @@ -280,6 +280,47 @@ Short format }); }); +describe("subtitle line endings", () => { + it.each([ + ["srt", "\r\n"], + ["srt", "\r"], + ["vtt", "\r\n"], + ["vtt", "\r"], + ])("keeps separate %s cues with %j line endings", (ext, newline) => { + const timestamp = ext === "srt" ? "," : "."; + const lines = [ + ...(ext === "vtt" ? ["WEBVTT", ""] : []), + "1", + `00:00:01${timestamp}000 --> 00:00:03${timestamp}500`, + "Hello world", + "Again", + "", + "2", + `00:00:04${timestamp}000 --> 00:00:06${timestamp}000`, + "How are you", + "", + ]; + const { words, format } = loadTranscript(tmpFile(`captions.${ext}`, lines.join(newline))); + expect(format).toBe(ext); + expect(words).toEqual([ + { text: "Hello world Again", start: 1, end: 3.5, id: "w0" }, + { text: "How are you", start: 4, end: 6, id: "w1" }, + ]); + expect(formatSrt(words, { preGrouped: true })).toBe( + "1\n00:00:01,000 --> 00:00:03,500\nHello world Again\n\n2\n00:00:04,000 --> 00:00:06,000\nHow are you\n", + ); + }); + + it("preserves WebVTT cues when newline forms are mixed", () => { + const source = + "WEBVTT\r\n\r\n00:01.000 --> 00:02.000\rFirst phrase\r\r00:03.000 --> 00:04.000\nSecond phrase\n"; + expect(loadTranscript(tmpFile("mixed.vtt", source)).words).toEqual([ + { text: "First phrase", start: 1, end: 2, id: "w0" }, + { text: "Second phrase", start: 3, end: 4, id: "w1" }, + ]); + }); +}); + describe("caption formatting", () => { it.each([ ["R&D next", "R&D <config> next"], diff --git a/packages/cli/src/whisper/normalize.ts b/packages/cli/src/whisper/normalize.ts index 0db90b30200..f6c3ce0c565 100644 --- a/packages/cli/src/whisper/normalize.ts +++ b/packages/cli/src/whisper/normalize.ts @@ -190,7 +190,7 @@ function parseOpenAI(data: Record): Word[] { function parseSrt(content: string): Word[] { // SRT doesn't have word-level timestamps — parse as phrase-level entries. // Each cue becomes one "word" entry (the full phrase). - const blocks = content.trim().split(/\n\n+/); + const blocks = content.replace(/\r\n?/g, "\n").trim().split(/\n\n+/); const words: Word[] = []; for (const block of blocks) { @@ -227,7 +227,7 @@ function decodeVttText(text: string): string { } function parseVtt(content: string): Word[] { - const body = content.replace(/^WEBVTT[^\n]*\n/, ""); + const body = content.replace(/\r\n?/g, "\n").replace(/^WEBVTT[^\n]*\n/, ""); // VTT is structurally similar to SRT (without numeric indices) const blocks = body.trim().split(/\n\n+/); const words: Word[] = [];