109 lines
4.3 KiB
JavaScript
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);
|
|
}
|