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