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,108 @@
|
||||
// Rough line-timing estimation — plain lyrics + a measured vocal-activity
|
||||
// envelope → ESTIMATED line starts, as a starting point for the tap-to-sync
|
||||
// editor. Never presented as finished timing.
|
||||
//
|
||||
// The method deliberately stays above the research doc's forbidden floor
|
||||
// ("never estimate line positions by evenly distributing text across song
|
||||
// duration"): lines are laid out only across MEASURED vocal-active segments,
|
||||
// weighted by line length, and when the measurement shows nothing usable the
|
||||
// generator refuses instead of guessing. Real generated timing (ASR anchors
|
||||
// + forced alignment, per the research) is a later, model-gated feature.
|
||||
//
|
||||
// Pure functions, no imports — unit-tested by tests/lyrics/sync.test.mjs.
|
||||
|
||||
/** Hysteresis segmentation of a 0..1 activity envelope.
|
||||
* Returns [{ startMs, endMs }], merged and pruned. */
|
||||
export function activeSegments(frames, frameMs, opts = {}) {
|
||||
const on = opts.on ?? 0.32; // enter singing
|
||||
const off = opts.off ?? 0.16; // leave singing…
|
||||
const offRunMs = opts.offRunMs ?? 900; // …only after this long below
|
||||
const minSegMs = opts.minSegMs ?? 1200;
|
||||
const mergeGapMs = opts.mergeGapMs ?? 700;
|
||||
|
||||
const segments = [];
|
||||
let start = null;
|
||||
let belowRun = 0;
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const v = frames[i];
|
||||
if (start == null) {
|
||||
if (v >= on) {
|
||||
start = i;
|
||||
belowRun = 0;
|
||||
}
|
||||
} else if (v < off) {
|
||||
belowRun += frameMs;
|
||||
if (belowRun >= offRunMs) {
|
||||
const end = i * frameMs - belowRun + frameMs;
|
||||
segments.push({ startMs: start * frameMs, endMs: end });
|
||||
start = null;
|
||||
belowRun = 0;
|
||||
}
|
||||
} else {
|
||||
belowRun = 0;
|
||||
}
|
||||
}
|
||||
if (start != null) {
|
||||
segments.push({ startMs: start * frameMs, endMs: frames.length * frameMs });
|
||||
}
|
||||
// Merge close neighbours, then drop slivers.
|
||||
const merged = [];
|
||||
for (const seg of segments) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && seg.startMs - last.endMs <= mergeGapMs) {
|
||||
last.endMs = seg.endMs;
|
||||
} else {
|
||||
merged.push({ ...seg });
|
||||
}
|
||||
}
|
||||
return merged.filter((s) => s.endMs - s.startMs >= minSegMs);
|
||||
}
|
||||
|
||||
/** Plain text → the lines worth timing (section labels dropped). */
|
||||
export function lyricLinesOf(plainText) {
|
||||
return String(plainText ?? "")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0)
|
||||
.filter((l) => !/^\[.*\]$/.test(l));
|
||||
}
|
||||
|
||||
/** Lay `lines` across `segments` by character weight over the concatenated
|
||||
* active timeline. Returns [{ time_ms, text }] (monotonic, every start
|
||||
* inside an active segment), or null when there is nothing to lay onto. */
|
||||
export function distributeLines(lines, segments) {
|
||||
if (!lines.length || !segments.length) return null;
|
||||
const activeTotal = segments.reduce((sum, s) => sum + (s.endMs - s.startMs), 0);
|
||||
if (activeTotal < 4000) return null;
|
||||
const weight = (line) => Math.max(line.length, 8);
|
||||
const totalWeight = lines.reduce((sum, l) => sum + weight(l), 0);
|
||||
// virtual position (0..activeTotal) → real ms via the segment list.
|
||||
const toReal = (virtual) => {
|
||||
let left = virtual;
|
||||
for (const seg of segments) {
|
||||
const len = seg.endMs - seg.startMs;
|
||||
if (left <= len) return seg.startMs + left;
|
||||
left -= len;
|
||||
}
|
||||
return segments[segments.length - 1].endMs;
|
||||
};
|
||||
const out = [];
|
||||
let cumulative = 0;
|
||||
for (const line of lines) {
|
||||
const virtual = (cumulative / totalWeight) * activeTotal;
|
||||
out.push({ time_ms: Math.round(toReal(virtual)), text: line });
|
||||
cumulative += weight(line);
|
||||
}
|
||||
// Monotonic by construction, but rounding can tie neighbours; nudge.
|
||||
for (let i = 1; i < out.length; i++) {
|
||||
if (out[i].time_ms <= out[i - 1].time_ms) out[i].time_ms = out[i - 1].time_ms + 300;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The whole rough pass. Returns [{ time_ms, text }] or null (refused). */
|
||||
export function roughSync(plainText, frames, frameMs) {
|
||||
const lines = lyricLinesOf(plainText);
|
||||
const segments = activeSegments(frames ?? [], frameMs ?? 100);
|
||||
return distributeLines(lines, segments);
|
||||
}
|
||||
Reference in new issue
Block a user