Files
Recordly/electron/ipc/captions/silence.test.ts

142 lines
5.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { CaptionCuePayload } from "../types";
import { parseSilenceIntervals, resegmentCuesBySilence } from "./silence";
describe("parseSilenceIntervals", () => {
it("pairs silence_start / silence_end lines into ms intervals", () => {
const stderr = [
"[silencedetect @ 0x1] silence_start: 0",
"[silencedetect @ 0x1] silence_end: 3.2 | silence_duration: 3.2",
"[silencedetect @ 0x1] silence_start: 8.5",
"[silencedetect @ 0x1] silence_end: 9.3 | silence_duration: 0.8",
].join("\n");
expect(parseSilenceIntervals(stderr)).toEqual([
{ startMs: 0, endMs: 3_200 },
{ startMs: 8_500, endMs: 9_300 },
]);
});
it("treats a trailing silence_start with no end as running to end-of-audio", () => {
const stderr = "[silencedetect] silence_start: 12.0";
expect(parseSilenceIntervals(stderr)).toEqual([
{ startMs: 12_000, endMs: Number.POSITIVE_INFINITY },
]);
});
it("returns an empty array when there is no silence output", () => {
expect(parseSilenceIntervals("some unrelated ffmpeg log\n")).toEqual([]);
});
});
describe("resegmentCuesBySilence", () => {
it("trims leading silence off a word-less cue", () => {
const cues: CaptionCuePayload[] = [
{ id: "caption-1", startMs: 0, endMs: 13_920, text: "Hello, this is a test" },
];
const result = resegmentCuesBySilence(cues, [{ startMs: 0, endMs: 3_000 }]);
expect(result).toHaveLength(1);
// speech span [3000, 13920]; only an 80ms pad re-enters the silence
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("does NOT split on a short pause (slowing down stays one phrase)", () => {
const cues: CaptionCuePayload[] = [
{ id: "caption-1", startMs: 0, endMs: 8_000, text: "one two three four" },
];
// 800ms pause is below the 1500ms split threshold -> absorbed, stays one phrase
const result = resegmentCuesBySilence(cues, [{ startMs: 3_000, endMs: 3_800 }]);
expect(result).toHaveLength(1);
expect(result[0].text).toBe("one two three four");
});
it("merges adjacent cues that are not separated by a long pause", () => {
const cues: CaptionCuePayload[] = [
{ id: "a", startMs: 0, endMs: 3_000, text: "hello world" },
{ id: "b", startMs: 3_000, endMs: 6_000, text: "how are you" },
];
// continuous speech, no silence -> one merged phrase
const result = resegmentCuesBySilence(cues, []);
expect(result).toHaveLength(1);
expect(result[0].text).toBe("hello world how are you");
});
it("splits only at a long pause (>= 1500ms)", () => {
const cues: CaptionCuePayload[] = [
{ id: "caption-1", startMs: 0, endMs: 10_000, text: "one two three four" },
];
// 2000ms pause -> a real boundary
const result = resegmentCuesBySilence(cues, [{ startMs: 4_000, endMs: 6_000 }]);
expect(result).toHaveLength(2);
expect(result[0].endMs).toBeLessThanOrEqual(result[1].startMs);
expect(`${result[0].text} ${result[1].text}`).toBe("one two three four");
});
it("does not over-split: two short pauses inside a sentence stay one phrase", () => {
const cues: CaptionCuePayload[] = [
{ id: "caption-1", startMs: 0, endMs: 9_000, text: "so this is a slow sentence" },
];
const result = resegmentCuesBySilence(cues, [
{ startMs: 2_000, endMs: 2_700 }, // 700ms
{ startMs: 5_000, endMs: 5_900 }, // 900ms
]);
expect(result).toHaveLength(1);
expect(result[0].text).toBe("so this is a slow sentence");
});
it("drops a cue that falls entirely inside silence (hallucination)", () => {
const cues: CaptionCuePayload[] = [
{ id: "caption-1", startMs: 5_000, endMs: 7_000, text: "Thank you." },
];
const result = resegmentCuesBySilence(cues, [{ startMs: 4_000, endMs: 9_000 }]);
expect(result).toHaveLength(0);
});
it("keeps a continuous cue (no silence) as a single phrase", () => {
const cues: CaptionCuePayload[] = [
{ id: "caption-1", startMs: 1_000, endMs: 4_000, text: "continuous speech" },
];
const result = resegmentCuesBySilence(cues, []);
expect(result).toHaveLength(1);
expect(result[0].text).toBe("continuous speech");
});
it("splits a word-timed cue by assigning each word to its speech region", () => {
const cues: CaptionCuePayload[] = [
{
id: "caption-1",
startMs: 0,
endMs: 10_000,
text: "alpha beta gamma",
words: [
{ text: "alpha", startMs: 500, endMs: 1_000 },
{ text: "beta", startMs: 1_000, endMs: 1_500, leadingSpace: true },
{ text: "gamma", startMs: 7_000, endMs: 7_500, leadingSpace: true },
],
},
];
// 4000ms pause puts alpha+beta in region 1, gamma in region 2
const result = resegmentCuesBySilence(cues, [{ startMs: 2_000, endMs: 6_000 }]);
expect(result).toHaveLength(2);
expect(result[0].text).toBe("alpha beta");
expect(result[1].text).toBe("gamma");
expect(result[0].endMs).toBeLessThanOrEqual(result[1].startMs);
});
it("produces sorted, non-overlapping cues with sequential ids", () => {
const cues: CaptionCuePayload[] = [
{ id: "x", startMs: 0, endMs: 6_000, text: "first phrase here" },
{ id: "y", startMs: 6_000, endMs: 14_000, text: "second phrase here" },
];
const result = resegmentCuesBySilence(cues, [{ startMs: 6_000, endMs: 8_000 }]);
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}`),
);
});
});