// 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"); });