77 lines
3.3 KiB
JavaScript
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");
|
|
});
|