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[] = [];