Files
SoulStream/tests/lyrics/sync.test.mjs
T

77 lines
3.3 KiB
JavaScript

// Rough-sync generator invariants: segmentation hysteresis, weighted
// distribution inside measured segments only, and the refusal floor (no
// evidence → no guess — the research doc forbids naive even-spread).
import { test } from "node:test";
import assert from "node:assert/strict";
import {
activeSegments,
lyricLinesOf,
distributeLines,
roughSync,
} from "../../src/lib/lyric-sync.js";
const FRAME = 100;
const quiet = (n) => Array(n).fill(0.05);
const loud = (n) => Array(n).fill(0.8);
test("segments form around sustained activity with hysteresis", () => {
// 3s quiet, 5s loud, 3s quiet, 4s loud, 2s quiet.
const frames = [...quiet(30), ...loud(50), ...quiet(30), ...loud(40), ...quiet(20)];
const segs = activeSegments(frames, FRAME);
assert.equal(segs.length, 2);
assert.equal(segs[0].startMs, 3000);
assert.ok(Math.abs(segs[0].endMs - 8000) <= 1000, `end near 8s, got ${segs[0].endMs}`);
assert.equal(segs[1].startMs, 11000);
});
test("a single hot frame is not singing, a short dip is not a break", () => {
const blip = [...quiet(20), 0.9, ...quiet(30)];
assert.equal(activeSegments(blip, FRAME).length, 0, "blip ignored (min length)");
// 4s loud, 0.4s dip, 4s loud → ONE segment (dip shorter than offRun).
const dip = [...loud(40), ...quiet(4), ...loud(40)];
assert.equal(activeSegments(dip, FRAME).length, 1);
});
test("section labels are dropped from the timing lines", () => {
const lines = lyricLinesOf("[Verse 1]\nFirst line\n\nSecond line\n[Chorus]\nThird");
assert.deepEqual(lines, ["First line", "Second line", "Third"]);
});
test("lines land inside segments, in order, weighted by length", () => {
const segments = [
{ startMs: 5000, endMs: 15000 },
{ startMs: 30000, endMs: 40000 },
];
const lines = ["Short", "A much much much longer line of lyric text", "Mid line here", "End"];
const out = distributeLines(lines, segments);
assert.equal(out.length, 4);
assert.equal(out[0].time_ms, 5000, "first line starts at the first vocal onset");
for (let i = 1; i < out.length; i++) {
assert.ok(out[i].time_ms > out[i - 1].time_ms, "monotonic");
}
for (const line of out) {
const inside = segments.some((s) => line.time_ms >= s.startMs && line.time_ms <= s.endMs);
assert.ok(inside, `${line.time_ms} must sit inside a vocal segment`);
}
// The long second line pushes line 3 further than an even split would.
const even = 5000 + (2 / 4) * 20000;
assert.ok(out[2].time_ms > even - 2000);
});
test("no usable activity → refusal, never an even spread", () => {
assert.equal(roughSync("One\nTwo\nThree", quiet(600), FRAME), null);
assert.equal(roughSync("One\nTwo", [], FRAME), null);
assert.equal(distributeLines([], [{ startMs: 0, endMs: 9000 }]), null);
});
test("end to end over a synthetic song", () => {
// intro 8s quiet — verse 20s — break 6s — chorus 20s.
const frames = [...quiet(80), ...loud(200), ...quiet(60), ...loud(200)];
const text = "Line one\nLine two\nLine three\nLine four\nLine five\nLine six";
const out = roughSync(text, frames, FRAME);
assert.equal(out.length, 6);
assert.equal(out[0].time_ms, 8000, "starts at the verse, not at 0:00");
assert.ok(out[5].time_ms >= 34000, "later lines reach the chorus");
});