Files
SoulStream/src/lib/lyric-sync.js
T

109 lines
4.3 KiB
JavaScript

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