playlists + lyrics: setlists with pages of their own, and the words timed to the voice
This commit is contained in:
1 parent
c4ff6269a3
commit
df3d15553a
49 files changed
+14419
-599
No files matched your search
@@ -0,0 +1,76 @@
|
||||
// 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");
|
||||
});
|
||||
Reference in new issue
Block a user