mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
142 lines
5.5 KiB
TypeScript
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}`),
|
|
);
|
|
});
|
|
});
|