From 50da82e11bc03151d07fb4b555e4e8254f203bd8 Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:26:32 +0000 Subject: [PATCH] feat(sync): per-section alignment engine (#24) --- CHANGELOG.md | 10 + src/section-sync.js | 605 ++++++++++++++++++++++++++++++++++++ tests/section_sync.test.mjs | 386 +++++++++++++++++++++++ 3 files changed, 1001 insertions(+) create mode 100644 src/section-sync.js create mode 100644 tests/section_sync.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a4352e..9cbc46e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 an offline save/build/XML contract using the existing feedpak schema. Design only; alignment, baking and review UI remain in #24–#26. +- **Per-section alignment engine (#24): pure per-section sync analysis.** A + read-only engine (`src/section-sync.js`) runs the shipped onset-vote tempo + estimator per section and adds a DTW anchor pass, returning one independent + source→audio proposal per section (factor, affine slope/intercept, anchored + correspondences, confidence and reason). Sections chain into one continuous + piecewise-linear map via their shared boundaries, a mismatched or + low-confidence section is neither carried nor merged, and a single-section + song reduces to the existing uniform model. Analysis only — nothing bakes + timestamps (#25) or surfaces in the UI (#26) yet. + - **The undo stack is no longer invisible: a last-action label and a Revert button sit beside Undo/Redo (#43).** Every committed edit now names itself — the label is derived from the command's own class (or an explicit `label` on diff --git a/src/section-sync.js b/src/section-sync.js new file mode 100644 index 0000000..e1b3dd9 --- /dev/null +++ b/src/section-sync.js @@ -0,0 +1,605 @@ +/* Slopsmith Arrangement Editor — per-section sync alignment engine (#24). + * + * Sub-issue 3/5 of per-section syncing (#8). PURE ANALYSIS: it reads the chart + * grid and the audio onset strip, and returns one INDEPENDENT proposal per + * section. It never mutates note timestamps (baking is #25) and never touches + * `S`, the DOM or the host (the reviewed selection UI is #26). + * + * ── What it reuses, and what "chroma" means here ─────────────────────────── + * #24 was scoped to "reuse the chroma + DTW alignment from the current uniform + * sync redesign, per section". The shipped uniform model (src/sync-tempo.js) has + * no spectral-chroma front-end: its audio fingerprint is the banded + * spectral-flux onset strip (src/onsets.js), and its tempo estimate is the + * strength-weighted inter-onset vote `_detectTempoFromOnsetsPure`. The engine + * therefore runs THAT estimator once per section — the literal "run it per + * section instead of once for the whole song" — and adds a DTW pass over the + * section's onset sequence for the source→audio anchor correspondences the #23 + * design doc requires. A separate, unvalidated chroma extractor would be a + * bigger audio-feature change than this sub-issue owns. + * + * ── Contract (docs/PER-SECTION-SYNC-DESIGN.md) ───────────────────────────── + * - Boundaries come from an immutable snapshot of the canonical `sections`, as + * half-open intervals [start_i, start_(i+1)); the final interval includes the + * chart endpoint, and a leading region before the first marker is explicit. + * - Each region returns its own original interval, source→audio anchor + * correspondences in seconds, an affine slope/intercept (seconds-out / + * seconds-in, NOT the BPM ratio), a confidence and a failure reason. A + * mismatched region's evidence never reaches its neighbours — every region is + * estimated from its own events alone. + * - A single region reduces to the uniform analysis: the same song-wide tab BPM + * and the same onset vote. + */ +import { _detectTempoFromOnsetsPure } from './sync-tempo.js'; + +/* @pure:section-sync:start */ + +const EPS = 1e-9; + +function _clamp01(v) { return v < 0 ? 0 : (v > 1 ? 1 : v); } +function _round4(v) { return Math.round(v * 1e4) / 1e4; } +function _median(sortedOrRaw) { + const a = Array.isArray(sortedOrRaw) ? sortedOrRaw.slice() : []; + if (!a.length) return 0; + a.sort((x, y) => x - y); + const mid = a.length >> 1; + return a.length % 2 ? a[mid] : (a[mid - 1] + a[mid]) / 2; +} + +/** + * Split the canonical section markers into regions over `domain = {start,end}` + * (seconds). Returns `[{index, name, number, start, end, leading, final}]`. + * + * - A marker at or before the domain start is the baseline, not a boundary. + * - If the first marker is after the start, the gap is an explicit leading + * region (`leading: true`, empty name) — never silently discarded. + * - Intervals are half-open; `final` marks the region that owns the endpoint. + * - No markers (or a single marker at the start) yields one full-domain region, + * the single-section case that reduces to the uniform model. + */ +export function _sectionRegionsPure(sections, domain) { + const start = domain.start, end = domain.end; + const markers = (Array.isArray(sections) ? sections : []) + .filter((s) => s && Number.isFinite(s.start_time)) + .map((s) => ({ + name: s.name || '', + number: Number.isFinite(s.number) ? s.number : 0, + start: s.start_time, + })) + .filter((m) => m.start > start + EPS && m.start < end - EPS) + .sort((a, b) => a.start - b.start); + const bounds = []; + for (const m of markers) { + if (bounds.length && Math.abs(bounds[bounds.length - 1].start - m.start) < 1e-6) continue; + bounds.push(m); + } + const regions = []; + if (!bounds.length) { + regions.push({ index: 0, name: '', number: 0, start, end, leading: true, final: true }); + return regions; + } + if (bounds[0].start > start + EPS) { + regions.push({ index: 0, name: '', number: 0, start, end: bounds[0].start, leading: true, final: false }); + } + for (let i = 0; i < bounds.length; i++) { + const next = i + 1 < bounds.length ? bounds[i + 1].start : end; + regions.push({ + index: regions.length, + name: bounds[i].name, + number: bounds[i].number, + start: bounds[i].start, + end: next, + leading: false, + final: i === bounds.length - 1, + }); + } + return regions; +} + +/** The section's chart tempo as BPM, from the beat grid restricted to + * [start,end). Mirrors getTabBPM()'s downbeat-interval method so a full-domain + * region reproduces the uniform model; falls back to the mean beat interval on + * a region too short to hold two downbeats. 0 when unmeasurable. */ +export function _localBpmFromBeatsPure(beats, start, end, includeEnd) { + const inRegion = (t) => t >= start - 1e-6 && (t < end - 1e-6 || (includeEnd && t <= end + 1e-6)); + const bs = (Array.isArray(beats) ? beats : []) + .filter((b) => b && Number.isFinite(b.time) && inRegion(b.time)); + if (bs.length < 2) return 0; + const downbeats = bs.filter((b) => b.measure > 0); + if (downbeats.length >= 2) { + let sum = 0, n = 0; + for (let i = 1; i < downbeats.length; i++) { + const dt = downbeats[i].time - downbeats[i - 1].time; + const per = bs.filter((b) => b.time >= downbeats[i - 1].time && b.time < downbeats[i].time).length; + if (dt > EPS && per > 0) { sum += dt / per; n++; } + } + if (n > 0 && sum > 0) return 60 / (sum / n); + } + let sum = 0, n = 0; + for (let i = 1; i < bs.length; i++) { + const dt = bs[i].time - bs[i - 1].time; + if (dt > EPS) { sum += dt; n++; } + } + return n > 0 ? 60 / (sum / n) : 0; +} + +/** + * DTW-align a time-sorted source sequence to a time-sorted target sequence. + * Local cost is the |difference| of each sequence's time normalised to [0,1], + * so a tempo ratio is a linear stretch the path can follow while remaining + * monotone. Returns `{ pairs: [[sourceSec, targetSec], …], coverage }` where + * each source event carries the mean of the target times it matched (coverage + * is the matched-source fraction, 0..1). Endpoints are always paired. + */ +export function _dtwAlignPure(src, dst) { + const n = src.length, m = dst.length; + if (n < 2 || m < 2) return { pairs: [], coverage: 0 }; + const sn = new Float64Array(n), dn = new Float64Array(m); + const s0 = src[0], sSpan = src[n - 1] - src[0]; + const d0 = dst[0], dSpan = dst[m - 1] - dst[0]; + for (let i = 0; i < n; i++) sn[i] = sSpan > EPS ? (src[i] - s0) / sSpan : i / (n - 1); + for (let j = 0; j < m; j++) dn[j] = dSpan > EPS ? (dst[j] - d0) / dSpan : j / (m - 1); + const W = m + 1; + const D = new Float64Array((n + 1) * W).fill(Infinity); + D[0] = 0; + for (let i = 1; i <= n; i++) { + const si = sn[i - 1]; + for (let j = 1; j <= m; j++) { + const cost = Math.abs(si - dn[j - 1]); + const a = D[(i - 1) * W + j], b = D[i * W + (j - 1)], c = D[(i - 1) * W + (j - 1)]; + D[i * W + j] = cost + Math.min(a, b, c); + } + } + let i = n, j = m; + const bySrc = new Map(); + while (i > 0 && j > 0) { + const a = D[(i - 1) * W + (j - 1)], b = D[(i - 1) * W + j], c = D[i * W + (j - 1)]; + if (a <= b && a <= c) { i--; j--; } + else if (b <= c) { i--; } + else { j--; } + if (!bySrc.has(i)) bySrc.set(i, []); + if (bySrc.get(i).length === 0 || bySrc.get(i)[bySrc.get(i).length - 1] !== j) bySrc.get(i).push(j); + } + const pairs = []; + for (let s = 0; s < n; s++) { + const js = bySrc.get(s); + if (!js || !js.length) continue; + let sum = 0; + for (const jj of js) sum += dst[jj]; + pairs.push([src[s], sum / js.length]); + } + pairs.sort((p, q) => p[0] - q[0]); + return { pairs, coverage: pairs.length / n }; +} + +/** Theil-Sen slope over pairs: the median of all pairwise slopes, robust to the + * odd mis-matched correspondence. 0 when underdetermined. */ +export function _slopeFromPairsPure(pairs) { + const n = pairs.length; + if (n < 2) return 0; + const slopes = []; + // Cap the O(n²) walk on very long sections by sampling evenly. + const stride = n > 96 ? Math.ceil(n / 96) : 1; + const idx = []; + for (let i = 0; i < n; i += stride) idx.push(i); + if (idx[idx.length - 1] !== n - 1) idx.push(n - 1); + for (let a = 0; a < idx.length; a++) { + for (let b = a + 1; b < idx.length; b++) { + const dx = pairs[idx[b]][0] - pairs[idx[a]][0]; + if (dx > EPS) slopes.push((pairs[idx[b]][1] - pairs[idx[a]][1]) / dx); + } + } + return slopes.length ? _median(slopes) : 0; +} + +/** + * Fit `audio = slope * source + intercept` through the matched pairs. `slope` + * may be supplied (the onset-vote convention wins for the factor); otherwise a + * robust Theil-Sen slope is used. `intercept` is the median residual — robust to + * the handful of correspondences a tempo change near a boundary can mis-pair. + * Returns `{slope, intercept, rms, mad, n}` or null when unfit. + */ +export function _fitAffinePure(pairs, slope) { + const n = pairs.length; + if (n < 2) return null; + const a = slope > EPS ? slope : _slopeFromPairsPure(pairs); + if (!(a > EPS)) return null; + const intercept = _median(pairs.map(([x, y]) => y - a * x)); + let sum2 = 0; + const errs = []; + for (const [x, y] of pairs) { + const e = y - (a * x + intercept); + sum2 += e * e; + errs.push(e); + } + const med = _median(errs); + const mad = _median(errs.map((e) => Math.abs(e - med))); + return { slope: a, intercept, rms: Math.sqrt(sum2 / n), mad, n }; +} + +/** Keep the DTW correspondences that sit within `tol` of the fitted map — + * boundary onsets that jittered into a section's window are rejected, not + * blended into the fit. Returns the surviving pairs (all pairs when no tol). */ +export function _trimPairsPure(pairs, slope, intercept, tol) { + if (!(tol > EPS)) return pairs; + return pairs.filter(([x, y]) => Math.abs(y - (slope * x + intercept)) <= tol); +} + +/** The chart's analysable domain — the grid's own span. 0/0 when the grid is + * degenerate, which the caller refuses rather than analysing. */ +export function _chartDomainPure(beats) { + const bs = (Array.isArray(beats) ? beats : []).filter((b) => b && Number.isFinite(b.time)); + if (bs.length < 2) return { start: 0, end: 0 }; + const start = bs[0].time, end = bs[bs.length - 1].time; + return { start, end }; +} + +/** Every timed chart onset (notes, chord notes, anchors, phrases, drum hits), + * time-sorted and de-duplicated to 1 ms. This is the on-the-beat pulse the + * engine anchors on; the grid itself is the fallback when a section is sparse. */ +export function _collectSourceOnsetsPure(source) { + const out = []; + const push = (t) => { if (Number.isFinite(t)) out.push(t); }; + for (const a of ((source && source.arrangements) || [])) { + if (!a) continue; + for (const nt of (a.notes || [])) push(nt.time); + for (const ch of (a.chords || [])) { + push(ch.time); + for (const cn of (ch.notes || [])) push(cn.time); + } + for (const an of (a.anchors || [])) push(an.time); + for (const an of (a.anchors_user || [])) push(an.time); + for (const ph of (a.phrases || [])) push(ph.start_time); + } + if (source && source.drumTab && Array.isArray(source.drumTab.hits)) { + for (const h of source.drumTab.hits) push(h.t); + } + out.sort((x, y) => x - y); + const uniq = []; + for (const t of out) if (!uniq.length || t - uniq[uniq.length - 1] > 1e-3) uniq.push(t); + return uniq; +} + +const DEFAULTS = { + minEvents: 4, // fewest source onsets a region may anchor on + minOnsets: 8, // _detectTempoFromOnsetsPure's own floor + resTol: 0.25, // seconds of anchor spread that reads as "no fit" + tempoTol: 0.15, // relative disagreement allowed between vote and anchors + confidenceFloor: 0.5, // confidence >= this counts as a trustworthy proposal + slopeBand: 0.2, // ± fraction of the prior slope searched around a region + pad: 0.35, // absolute seconds of slack on each window edge + tightPad: 0.15, // seconds of slack on the refinement window +}; + +function _fail(region, reason) { + return { + ok: false, + index: region.index, + name: region.name, + number: region.number, + start: region.start, + end: region.end, + reason, + confidence: 0, + factor: 1, + slope: 1, + intercept: 0, + tabBpm: 0, + audioBpm: 0, + anchors: [], + rms: 0, + coverage: 0, + }; +} + +function _priorWindow(prior, region, span, opts) { + const slope = prior && prior.slope > EPS ? prior.slope : 1; + const intercept = prior && Number.isFinite(prior.intercept) ? prior.intercept : 0; + const a0 = slope * region.start + intercept; + const a1 = slope * region.end + intercept; + const pad = Math.max(opts.pad, opts.slopeBand * span * slope); + return { lo: Math.min(a0, a1) - pad, hi: Math.max(a0, a1) + pad }; +} + +/** The median beat interval of a time-sorted onset sequence (the grid step). */ +function _medianGridStepPure(times) { + const dt = []; + for (let i = 1; i < times.length; i++) { + const d = times[i] - times[i - 1]; + if (d > EPS) dt.push(d); + } + return dt.length ? _median(dt) : 0; +} + +/** The onset closest to `v`, or `v` itself when it is farther than `tol` away — + * a glue point for the chain, unwilling to jump a beat. */ +/** + * Snap the chained place to a real onset. The place predates this region's + * first onset; everything below it belongs to the previous section (its proper + * beats sit a full period away and stay out of `tol`, but a stray click near + * the boundary can edge a few dozen ms under a real onset). Within `tol` we + * therefore prefer the onset at-or-after the place when it is not decisively + * farther than the nearest one below (a `tieSlop`-sized margin covers detector + * jitter), so the window's lower edge glues to THIS section's boundary onset + * and the previous section's content never enters it. Falls back to the + * nearest onset, and to `v` itself when nothing is in range. + */ +function _nearestOnsetPure(audioOnsets, v, tol, tieSlop) { + if (!audioOnsets.length) return v; + let lo = 0, hi = audioOnsets.length - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (audioOnsets[mid].t < v) lo = mid + 1; else hi = mid; + } + const atOrAfter = audioOnsets[lo]; + const below = lo > 0 ? audioOnsets[lo - 1] : null; + const dAfter = Math.abs(atOrAfter.t - v); + const dBelow = below ? Math.abs(below.t - v) : Infinity; + const bestD = Math.min(dAfter, dBelow); + if (bestD > tol) return v; + if (below && dBelow < dAfter - (tieSlop || 0)) return below.t; + return atOrAfter.t; +} + +/** + * The tight-window DTW for a section whose PLACE is already known: `audioStart` + * is the chained place snapped to a real onset, so on-the-beat recordings the + * window is the section's OWN map — exactly `[audioStart, audioStart + + * slope·span + tailPad]`. `audioStart` is the detected onset of the region's + * FIRST note, so the window's lower edge is that very onset: everything before + * it belongs to the previous section and never enters. The tail pad covers the + * model's drift over the section while staying well under a beat period, so the + * next section's onsets — a beat beyond the section's own — stay out, and the + * intercept belongs to the chain, never to a minimisation over period-shifted + * windows (with on-the-beat clicks every period shift fits a straight line + * equally well). Relies on the coarse window fallback when the section is too + * sparse or the place could not be snapped. Returns the `_dtwAlignPure` result + * or null. + */ +function _tightDTWPure(sourceTimes, audioOnsets, slope, audioStart, tailPad, opts) { + if (!(slope > EPS) || !sourceTimes.length) return null; + const src0 = sourceTimes[0], srcN = sourceTimes[sourceTimes.length - 1]; + const tLo = audioStart; + const tHi = audioStart + slope * (srcN - src0) + tailPad; + const tight = audioOnsets + .filter((o) => o && Number.isFinite(o.t) && o.t >= tLo && o.t <= tHi) + .map((o) => o.t); + if (tight.length < opts.minOnsets) return null; + const align = _dtwAlignPure(sourceTimes, tight); + return _fitAffinePure(align.pairs, slope) ? { align, tight } : null; +} + +/** + * Align ONE region. Returns an independent proposal; a failure yields + * `{ok:false, reason, confidence:0}` and never a merged or guessed value. + * + * `sourceTimes` are the region's chart onsets (seconds, time-sorted); `beats` + * the grid; `audioOnsets` the whole onset strip ({t,s}, time-sorted); `prior` a + * source→audio affine used only to locate the region's audio window; `audioStart` + * the recording time of the region's first chart onset, carried over from the + * region before it (the first region snaps it to the prior). The factor follows + * the uniform convention `audioBpm / tabBpm`; `slope` is its reciprocal + * (seconds-out / seconds-in). + */ +export function _alignSectionPure(params) { + const opts = Object.assign({}, DEFAULTS, params.options || {}); + const { region, beats, audioOnsets, prior } = params; + const span = region.end - region.start; + if (!(span > EPS)) return _fail(region, 'empty-region'); + + const sourceTimes = (Array.isArray(params.sourceTimes) ? params.sourceTimes : []) + .filter((t) => Number.isFinite(t) && t >= region.start - 1e-6 + && (t < region.end - 1e-6 || (region.final && t <= region.end + 1e-6))); + if (sourceTimes.length < opts.minEvents) return _fail(region, 'too-few-notes'); + + const win = _priorWindow(prior, region, span, opts); + const inWin = (Array.isArray(audioOnsets) ? audioOnsets : []) + .filter((o) => o && Number.isFinite(o.t) && o.t >= win.lo && o.t <= win.hi); + if (inWin.length < opts.minOnsets) return _fail(region, 'no-audio'); + + const winTimes = inWin.map((o) => o.t); + const tabBpm = _localBpmFromBeatsPure(beats, region.start, region.end, region.final); + if (!(tabBpm > 0)) return _fail(region, 'no-grid-tempo'); + + // The uniform estimator, run per section — its vote is the tempo estimate, + // re-voted over the section's own onsets once the window is known. + let vote = _detectTempoFromOnsetsPure(inWin); + + // Slope: prefer the onset-vote convention (factor = audioBpm/tabBpm); fall + // back to the robust anchor slope when the section is too short to vote. + let factor = 0, audioBpm = 0, slope = 0; + if (vote && vote.bpm > 0) { + audioBpm = vote.bpm; + factor = audioBpm / tabBpm; + slope = 1 / factor; + } + if (!(slope > EPS)) { + const coarse = _dtwAlignPure(sourceTimes, winTimes); + const cs = _slopeFromPairsPure(coarse.pairs); + if (!(cs > EPS)) return _fail(region, 'no-tempo'); + slope = cs; + factor = 1 / slope; + audioBpm = factor * tabBpm; + } + + // Place the map: the chain's audioStart (or the prior's, for the first + // region) fixes the intercept; the DTW then runs on a tight window of the + // section's OWN onsets so its correspondences are not the next section's. + // The window's lower edge is `audioStart` itself — the detected onset the + // chained place snapped to — so the previous section's onsets never enter; + // the upper tail pad covers the model's drift over the section while + // staying under a beat period so the next section's onsets stay out. + const audioStart = Number.isFinite(params.audioStart) + ? params.audioStart + : ((prior && Number.isFinite(prior.slope)) ? prior.slope : 1) + * (sourceTimes.length ? sourceTimes[0] : region.start) + + ((prior && Number.isFinite(prior.intercept)) ? prior.intercept : 0); + const tailPad = Math.max(0.1, opts.tightPad); + let pass = _tightDTWPure(sourceTimes, audioOnsets, slope, audioStart, tailPad, opts); + // Re-vote over the section's OWN onsets, not the coarse window: a neighbour + // with unrelated content must not soften the section's own tempo estimate. + if (pass && pass.tight.length >= opts.minOnsets) { + const own = _detectTempoFromOnsetsPure(pass.tight.map((t) => ({ t }))); + if (own && own.bpm > 0) { + vote = own; + audioBpm = own.bpm; + factor = audioBpm / tabBpm; + slope = 1 / factor; + pass = _tightDTWPure(sourceTimes, audioOnsets, slope, audioStart, tailPad, opts); + } + } + let align = pass ? pass.align : null; + if (!align && winTimes.length >= opts.minOnsets) { + const coarse = _dtwAlignPure(sourceTimes, winTimes); + if (coarse.pairs.length >= 2) align = coarse; // place uncertain — anchors loosen + } + const fit = align ? _fitAffinePure(align.pairs, slope) : null; + if (!fit) return _fail(region, 'no-affine-fit'); + + // Drop correspondences the DTW mis-paired — a boundary onset from the next + // section that jittered into the window — then refit on what remains, so a + // handful of off-beat pairs cannot drag the intercept. A tiny region that + // trims to nothing falls back to its raw pairs and reads a lower confidence + // (and possibly 'low-confidence'): better to surface the data than blend a + // guessed line. + const kept = _trimPairsPure(align.pairs, slope, fit.intercept, opts.resTol); + const keptFit = kept.length >= Math.max(2, opts.minEvents) ? _fitAffinePure(kept, slope) : null; + const pairs = keptFit ? kept : align.pairs; + const fit2 = keptFit || fit; + const anchorSlope2 = _slopeFromPairsPure(pairs); + + // Confidence: how well the anchors support the vote, how concentrated the + // onset vote was, and how much of the section the anchors cover. A diffuse + // vote or a scattered anchor fit on an unrelated region collapses this. + const voteConf = vote && Number.isFinite(vote.confidence) ? _clamp01(vote.confidence) : 0; + const fitConf = _clamp01(1 - fit2.mad / opts.resTol); + const tempoAgree = anchorSlope2 > EPS + ? _clamp01(1 - Math.abs(anchorSlope2 - slope) / (slope * opts.tempoTol)) + : 0; + const confidence = _round4(_clamp01( + fitConf * (0.5 + 0.5 * voteConf) * (0.5 + 0.5 * tempoAgree) * (0.5 + 0.5 * fit2.n / sourceTimes.length) + )); + + const anchors = fit2.n > 64 + ? pairs.filter((_, i) => i % Math.ceil(fit2.n / 64) === 0) + : pairs; + + return { + ok: true, + index: region.index, + name: region.name, + number: region.number, + start: region.start, + end: region.end, + reason: confidence >= opts.confidenceFloor ? null : 'low-confidence', + confidence, + factor: _round4(factor), + slope, + intercept: _round4(fit2.intercept), + tabBpm: _round4(tabBpm), + audioBpm: _round4(audioBpm), + anchors: anchors.map(([source, audio]) => ({ source, audio })), + rms: fit2.rms, + coverage: align.coverage, + }; +} + +/** A whole-song uniform prior (factor + phase), the same estimate the shipped + * dialog computes, used only to locate each region's audio window. */ +export function _uniformPriorPure(beats, audioOnsets, domain) { + const tabBpm = _localBpmFromBeatsPure(beats, domain.start, domain.end, true); + const vote = _detectTempoFromOnsetsPure(audioOnsets); + if (!(tabBpm > 0) || !vote || !(vote.bpm > 0)) { + return { slope: 1, intercept: 0, factor: 1, tabBpm, audioBpm: 0, confidence: 0 }; + } + const factor = vote.bpm / tabBpm; + const slope = 1 / factor; + return { + slope, + intercept: vote.phase - slope * domain.start, + factor, + tabBpm, + audioBpm: vote.bpm, + confidence: Number.isFinite(vote.confidence) ? vote.confidence : 0, + }; +} + +/** + * The engine entry point. Analyses every section of `source` (arrangements, + * beats, drumTab) against `audioOnsets` and returns `{ok, domain, prior, + * global, proposals}`. `prior` may be supplied (so a caller can hold the window + * fixed while adding/removing sections); otherwise a uniform song-wide prior is + * derived. Analysis only — nothing here writes chart timestamps. + */ +export function _alignSectionsPure(params) { + const opts = Object.assign({}, DEFAULTS, params.options || {}); + const beats = params.beats || []; + const domain = params.domain || _chartDomainPure(beats); + if (!(domain.end - domain.start > EPS)) { + return { ok: false, reason: 'degenerate-grid', domain, prior: null, global: null, proposals: [] }; + } + const audioOnsets = (Array.isArray(params.audioOnsets) ? params.audioOnsets : []) + .filter((o) => o && Number.isFinite(o.t)) + .sort((a, b) => a.t - b.t); + const prior = params.prior || _uniformPriorPure(beats, audioOnsets, domain); + const regions = _sectionRegionsPure(params.sections || [], domain); + const allOnsets = params.sourceOnsets || _collectSourceOnsetsPure(params.source || {}); + const gridStep = _medianGridStepPure(allOnsets.length >= 2 ? allOnsets : beats.map((b) => b.time)); + + // The one continuous piecewise-linear map: each region's place in the + // recording is the previous region's map evaluated at their shared boundary. + // A region that fails or reads low-confidence is NOT allowed to drag the + // sections after it — its line is not carried, so the map stays the previous + // accepted region's (already continuous at this boundary by construction). + let carry = { + slope: Number.isFinite(prior.slope) ? prior.slope : 1, + xRef: domain.start, + yRef: Number.isFinite(prior.intercept) ? prior.intercept : 0, + }; + const proposals = []; + for (const region of regions) { + let sourceTimes = allOnsets.filter((t) => t >= region.start - 1e-6 + && (t < region.end - 1e-6 || (region.final && t <= region.end + 1e-6))); + if (sourceTimes.length < opts.minEvents) { + // Sparse region: fall back to the grid beats as the pulse. + sourceTimes = beats + .filter((b) => b && Number.isFinite(b.time) && b.time >= region.start - 1e-6 + && (b.time < region.end - 1e-6 || (region.final && b.time <= region.end + 1e-6))) + .map((b) => b.time); + // Guard the boundary: the first beat of the next region is not ours. + sourceTimes = sourceTimes.filter((t) => region.final || t < region.end - 1e-6); + } + + // The chain's audio time for this region's FIRST chart onset, snapped to + // the nearest real onset (within half a beat) so the place stays glued. + // The first note is the DTW window's anchor, not the boundary — a section + // whose notes start inside, past, or before its marker still lands on the + // map that mark's own start implies. + const x0 = sourceTimes.length ? sourceTimes[0] : region.start; + const base = carry.slope * (x0 - carry.xRef) + carry.yRef; + const tol = Math.max(0.05, (gridStep > EPS ? gridStep * carry.slope : 0.5) / 2); + const audioStart = _nearestOnsetPure(audioOnsets, base, tol, 0.02); + const prop = _alignSectionPure({ region, sourceTimes, beats, audioOnsets, prior, audioStart, options: opts }); + + if (prop.ok && prop.confidence >= opts.confidenceFloor) { + carry = { slope: prop.slope, xRef: region.start, yRef: prop.slope * region.start + prop.intercept }; + } + proposals.push(prop); + } + + return { + ok: true, + domain, + prior, + global: { + factor: _round4(prior.factor || 1), + intercept: _round4(prior.intercept || 0), + slope: prior.slope, + tabBpm: _round4(prior.tabBpm || 0), + audioBpm: _round4(prior.audioBpm || 0), + confidence: _round4(prior.confidence || 0), + }, + confidenceFloor: opts.confidenceFloor, + proposals, + }; +} +/* @pure:section-sync:end */ diff --git a/tests/section_sync.test.mjs b/tests/section_sync.test.mjs new file mode 100644 index 0000000..cfbd8d5 --- /dev/null +++ b/tests/section_sync.test.mjs @@ -0,0 +1,386 @@ +/* + * Per-section sync alignment engine (#24) — sub-issue 3/5 of per-section + * syncing (#8). + * + * The engine is PURE ANALYSIS: it reads the chart grid and the audio onset + * strip and returns one independent proposal per section. These tests pin the + * three acceptance criteria from the issue and the independence guarantee: + * + * 1. On a song whose audio tempo changes at a section boundary, the + * per-section factors differ, and each section's fitted map is closer to + * the true recording than a single global fit. METRIC = mean absolute + * error (MAE) in SECONDS between the fitted map's predicted audio time and + * the true audio time over the section's chart onsets; "global" is the + * least-squares affine fit over the whole song (the BEST single line, so + * beating it is a strong result). + * 2. A deliberately mismatched section reads as low confidence, and its + * presence does not change the good sections' proposals. + * 3. A single-section song reduces to the uniform model's convention: + * factor = onset-vote BPM / grid BPM, the same numbers the shipped dialog + * compares against. + * + * ── The fixture, and why it is synthetic ──────────────────────────────────── + * No binary fixtures are committed in this repo, and a synthetic click track is + * strictly better for a deterministic engine test: the chart grid, the two + * audio tempos and the section boundary are all known exactly, so a failure + * points at the engine and not the recording. The clicks are read through the + * SHIPPED onset path (src/onsets.js banded spectral flux), exactly as the + * uniform suite reads them — never a hand-written onset array. + * + * Run: node tests/section_sync.test.mjs + */ +import assert from 'node:assert'; + +// section-sync's import graph (onsets, sync-tempo) is DOM-free; the env stub +// is installed first for precedent with the other suites. +import './_history_env.mjs'; +import { _spectralFluxOnsetsPure } from '../src/onsets.js'; +import { _detectTempoFromOnsetsPure } from '../src/sync-tempo.js'; +import { + _sectionRegionsPure, _localBpmFromBeatsPure, _dtwAlignPure, + _slopeFromPairsPure, _fitAffinePure, _chartDomainPure, + _collectSourceOnsetsPure, _alignSectionPure, _alignSectionsPure, +} from '../src/section-sync.js'; + +let pass = 0, fail = 0; +function t(name, fn) { + try { fn(); pass++; console.log(' ok ' + name); } + catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } +} + +const SAMPLE_RATE = 44100; +const CHART_BPM = 120; // the chart is authored uniformly at 120 +const VERSE_AUDIO_BPM = 132; // the recording's verse runs fast +const SOLO_AUDIO_BPM = 108; // ... and its solo runs slow +const VERSE_BARS = 12, SOLO_BARS = 12; +const LEAD_BEATS = 4; // count-in, so the boundary is not at t=0 + +/** Chart grid: `leadBeats` count-in (measure 0), then uniform 4/4 bars, then a + * trailing downbeat — the shape an import leaves behind. */ +function buildGrid(leadBeats, bars, bpm) { + const beats = []; + let time = 0, measure = 1; + const spb = 60 / bpm; + for (let i = 0; i < leadBeats; i++) { beats.push({ time, measure: 0, den: 4 }); time += spb; } + for (let bar = 0; bar < bars; bar++) { + for (let i = 0; i < 4; i++) { beats.push({ time, measure: i === 0 ? measure++ : -1, den: 4 }); time += spb; } + } + beats.push({ time, measure, den: 4 }); + return beats; +} + +/** One 12 ms decaying click per requested time — the synthetic "recording". */ +function clickTrackAt(times) { + const n = Math.ceil((times[times.length - 1] + 0.5) * SAMPLE_RATE); + const data = new Float32Array(n); + const len = Math.round(0.012 * SAMPLE_RATE); + for (const tt of times) { + const at = Math.round(tt * SAMPLE_RATE); + for (let i = 0; i < len && at + i < n; i++) data[at + i] += Math.exp(-i / (len * 0.3)) * 0.9; + } + return data; +} + +// ── The tempo-varying fixture ────────────────────────────────────────────── +const BEATS = buildGrid(LEAD_BEATS, VERSE_BARS + SOLO_BARS, CHART_BPM); +const BEAT_T = BEATS.map((b) => b.time); +const VERSE_I = LEAD_BEATS; // first verse bar +const SOLO_I = LEAD_BEATS + VERSE_BARS * 4; // first solo bar +const VERSE_START = BEAT_T[VERSE_I]; +const SOLO_START = BEAT_T[SOLO_I]; +const END_T = BEAT_T[BEAT_T.length - 1]; +const RV = CHART_BPM / VERSE_AUDIO_BPM; // verse seconds-out / seconds-in +const RS = CHART_BPM / SOLO_AUDIO_BPM; // solo seconds-out / seconds-in + +/** The recording is ONE continuous take whose tempo kinks at the section + * boundary: everything before it is stretched by RV, everything after by RS. + * This is exactly a piecewise-linear time map, which no single factor fits. */ +function audioTime(x) { + const y0 = SOLO_START * RV; + return x < SOLO_START - 1e-9 ? x * RV : y0 + (x - SOLO_START) * RS; +} +const AUDIO_T = BEAT_T.map(audioTime); +const TRUTH = new Map(); +for (let i = 0; i < BEAT_T.length; i++) TRUTH.set(BEAT_T[i], AUDIO_T[i]); + +const SECTIONS = [ + { name: 'Verse', number: 1, start_time: VERSE_START }, + { name: 'Solo', number: 2, start_time: SOLO_START }, +]; + +// A prior that just centres each region's audio window; it is NOT the answer. +const UNIT_PRIOR = { slope: 1, intercept: 0, factor: 1, tabBpm: CHART_BPM, audioBpm: CHART_BPM, confidence: 1 }; + +function analyze({ sections = SECTIONS, audioOnsets, prior = UNIT_PRIOR, sourceOnsets = BEAT_T } = {}) { + return _alignSectionsPure({ + sections, + beats: BEATS, + audioOnsets: audioOnsets || _spectralFluxOnsetsPure(clickTrackAt(AUDIO_T), SAMPLE_RATE), + sourceOnsets, + prior, + }); +} + +function byName(res, name) { return res.proposals.find((p) => p.name === name); } + +/** Mean absolute error (s) of a proposal's fitted map over its chart onsets. */ +function sectionMae(prop) { + const xs = BEAT_T.filter((x) => x >= prop.start - 1e-6 + && (x < prop.end - 1e-6 || (prop.end >= END_T - 1e-6 && x <= prop.end + 1e-6))); + let sum = 0; + for (const x of xs) sum += Math.abs(prop.slope * x + prop.intercept - TRUTH.get(x)); + return sum / xs.length; +} + +/** The best possible single affine fit over the WHOLE song (least squares). */ +function globalFit() { + const n = BEAT_T.length; + let sx = 0, sy = 0, sxx = 0, sxy = 0; + for (let i = 0; i < n; i++) { sx += BEAT_T[i]; sy += AUDIO_T[i]; sxx += BEAT_T[i] * BEAT_T[i]; sxy += BEAT_T[i] * AUDIO_T[i]; } + const slope = (n * sxy - sx * sy) / (n * sxx - sx * sx); + return { slope, intercept: (sy - slope * sx) / n }; +} + +function globalMae(g, prop) { + const xs = BEAT_T.filter((x) => x >= prop.start - 1e-6 + && (x < prop.end - 1e-6 || (prop.end >= END_T - 1e-6 && x <= prop.end + 1e-6))); + let sum = 0; + for (const x of xs) sum += Math.abs(g.slope * x + g.intercept - TRUTH.get(x)); + return sum / xs.length; +} + +// ── 1. Pure pieces ───────────────────────────────────────────────────────── +t('regions: half-open intervals, an explicit leading region and a final endpoint', () => { + const domain = { start: 0, end: 50 }; + const regions = _sectionRegionsPure( + [{ name: 'Verse', number: 1, start_time: 2 }, { name: 'Solo', number: 2, start_time: 26 }], domain); + assert.strictEqual(regions.length, 3, 'leading + Verse + Solo'); + assert.deepStrictEqual(regions.map((r) => [r.start, r.end]), [[0, 2], [2, 26], [26, 50]]); + assert.strictEqual(regions[0].leading, true, 'the gap before the first marker is explicit'); + assert.strictEqual(regions[2].final, true, 'the last region owns the chart endpoint'); + assert.strictEqual(regions[0].name, '', 'the leading region is unnamed'); + + // A marker at (or before) the domain start is the baseline, not a boundary, + // and a marker at/after the end is not a boundary either. + const one = _sectionRegionsPure( + [{ name: 'A', number: 1, start_time: 0 }, { name: 'B', number: 2, start_time: 99 }], domain); + assert.strictEqual(one.length, 1, 'single full-domain region'); + assert.deepStrictEqual([one[0].start, one[0].end], [0, 50]); + assert.strictEqual(one[0].final, true); + + // Duplicate marker starts collapse to one boundary. + const dup = _sectionRegionsPure( + [{ name: 'A', number: 1, start_time: 10 }, { name: 'A2', number: 2, start_time: 10 }], domain); + assert.strictEqual(dup.length, 2, 'leading + one shared-start region'); +}); + +t('local tempo: a region reads its own grid BPM, a full span matches the song', () => { + const bpm = _localBpmFromBeatsPure(BEATS, 0, END_T, true); + assert.ok(Math.abs(bpm - CHART_BPM) < 1e-6, `full span reads ${bpm}`); + const verse = _localBpmFromBeatsPure(BEATS, VERSE_START, SOLO_START, false); + assert.ok(Math.abs(verse - CHART_BPM) < 1e-6, `verse reads ${verse}`); +}); + +t('dtw + affine: two proportional sequences recover the stretch exactly', () => { + const src = [0, 1, 2, 3, 4]; + const dst = src.map((x) => 2 * x + 1); // slope 2, intercept 1 + const { pairs, coverage } = _dtwAlignPure(src, dst); + assert.strictEqual(coverage, 1, 'every source event matched'); + const fit = _fitAffinePure(pairs); + assert.ok(Math.abs(fit.slope - 2) < 1e-6, `slope ${fit.slope}`); + assert.ok(Math.abs(fit.intercept - 1) < 1e-6, `intercept ${fit.intercept}`); + assert.ok(fit.mad < 1e-9, `residual ${fit.mad}`); +}); + +t('slope: Theil-Sen shrugs off a mis-matched correspondence', () => { + const pairs = [[0, 0], [1, 1.5], [2, 3], [3, 4.5], [4, 99]]; // last is garbage + assert.ok(Math.abs(_slopeFromPairsPure(pairs) - 1.5) < 1e-6, `slope ${_slopeFromPairsPure(pairs)}`); +}); + +t('domain: a degenerate grid is refused, not analysed', () => { + assert.deepStrictEqual(_chartDomainPure([{ time: 5 }]), { start: 0, end: 0 }); + const res = _alignSectionsPure({ sections: SECTIONS, beats: [{ time: 5 }], audioOnsets: [] }); + assert.strictEqual(res.ok, false); + assert.strictEqual(res.reason, 'degenerate-grid'); + assert.deepStrictEqual(res.proposals, []); +}); + +t('source onsets: notes, chords, anchors, phrases and drum hits all count', () => { + const onsets = _collectSourceOnsetsPure({ + arrangements: [{ + notes: [{ time: 0 }, { time: 0.5 }], + chords: [{ time: 1, notes: [{ time: 1 }] }], + anchors: [{ time: 1.5 }], anchors_user: [{ time: 2 }], + phrases: [{ start_time: 2.5 }], + }], + drumTab: { hits: [{ t: 3 }, { t: 3.0005 }] }, // sub-ms dupe collapses + }); + assert.deepStrictEqual(onsets, [0, 0.5, 1, 1.5, 2, 2.5, 3]); +}); + +t('one region: a region with too few onsets fails cleanly, never guesses', () => { + const region = { index: 0, name: 'X', number: 1, start: 0, end: 50, leading: true, final: true }; + const prop = _alignSectionPure({ + region, sourceTimes: [0, 1], beats: BEATS, + audioOnsets: _spectralFluxOnsetsPure(clickTrackAt(AUDIO_T), SAMPLE_RATE), prior: UNIT_PRIOR, + }); + assert.strictEqual(prop.ok, false); + assert.strictEqual(prop.reason, 'too-few-notes'); + assert.strictEqual(prop.confidence, 0); + assert.strictEqual(prop.factor, 1, 'a failure proposes no factor'); +}); + +// ── 2. Acceptance: per-section factors beat the single global fit ─────────── +t('regions differ: each section reads its own audio tempo, not the global one', () => { + const res = analyze(); + const verse = byName(res, 'Verse'), solo = byName(res, 'Solo'); + assert.ok(verse.ok && solo.ok, 'both sections aligned'); + assert.ok(Math.abs(verse.factor - VERSE_AUDIO_BPM / CHART_BPM) < 0.02, + `verse factor ${verse.factor} ~ ${VERSE_AUDIO_BPM / CHART_BPM}`); + assert.ok(Math.abs(solo.factor - SOLO_AUDIO_BPM / CHART_BPM) < 0.02, + `solo factor ${solo.factor} ~ ${SOLO_AUDIO_BPM / CHART_BPM}`); + assert.ok(Math.abs(verse.factor - solo.factor) > 0.1, + `the two sections really differ (${verse.factor} vs ${solo.factor})`); + // The factor is the BPM ratio; the affine slope is its reciprocal. + assert.ok(Math.abs(verse.slope - 1 / verse.factor) < 1e-3, 'slope is seconds-in per second-out'); +}); + +t('regions beat global: per-section MAE < the best single affine fit, on both', () => { + const res = analyze(); + const g = globalFit(); + const verse = byName(res, 'Verse'), solo = byName(res, 'Solo'); + const mv = sectionMae(verse), ms = sectionMae(solo), gv = globalMae(g, verse), gs = globalMae(g, solo); + assert.ok(mv < gv, `verse MAE ${(mv * 1000).toFixed(1)} ms beats global ${(gv * 1000).toFixed(1)} ms`); + assert.ok(ms < gs, `solo MAE ${(ms * 1000).toFixed(1)} ms beats global ${(gs * 1000).toFixed(1)} ms`); + // And the win is material, not a rounding artifact. + assert.ok(mv < 0.05 && ms < 0.05, `per-section fits are tight (verse ${(mv * 1000).toFixed(1)} ms, solo ${(ms * 1000).toFixed(1)} ms)`); + assert.ok(gv > 0.1 && gs > 0.1, `no single line can fit a kinked map (global ${(gv * 1000).toFixed(0)}/${(gs * 1000).toFixed(0)} ms)`); +}); + +// ── 3. Acceptance: a mismatched section is low-confidence and isolated ────── +const BREAK_START = END_T + 0.5; // next beat after the song's last downbeat +const BREAK_END = BREAK_START + 24 * 60 / CHART_BPM; // 6 bars of chart +const BREAK_BEATS = []; +for (let time = BREAK_START; time <= BREAK_END + 1e-9; time += 60 / CHART_BPM) { + BREAK_BEATS.push({ time, measure: 0, den: 4 }); +} +const OUTRO_END = BREAK_END + 24 * 60 / CHART_BPM; // 6 more bars of chart +const OUTRO_BEATS = []; +for (let time = BREAK_END; time <= OUTRO_END + 1e-9; time += 60 / CHART_BPM) { + OUTRO_BEATS.push({ time, measure: 0, den: 4 }); +} +const SONG_BEATS = BEATS.concat(BREAK_BEATS, OUTRO_BEATS); +const BREAK_AUDIO_LO = audioTime(BREAK_START); +const BREAK_AUDIO_HI = audioTime(BREAK_END); + +/** Clicks at pseudo-random, non-periodic times — the "wrong take" case. */ +function lcgClickTimes(start, end) { + const out = []; + let x = start + 0.13, seed = 12345; + while (x < end - 0.05) { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + out.push(x); + x += 0.18 + ((seed >>> 8) % 700) / 1000; // 0.18 .. 0.88 s + } + return out; +} + +/** The song's audio, with the Break either matching (good) or a wrong take. + * The Outro take stays on-tempo at Solo's tempo either way, so any change to + * the Outro proposal between runs is Break's fault, on the engine's head. */ +function breakAudio(mismatched) { + const pre = AUDIO_T.filter((t) => t < BREAK_AUDIO_LO - 1e-9); // the song so far + const mid = BREAK_BEATS.map((b) => audioTime(b.time)); // break on-tempo + const outro = OUTRO_BEATS.map((b) => audioTime(b.time)); // outro on-tempo + const break_ = mismatched ? lcgClickTimes(BREAK_AUDIO_LO, BREAK_AUDIO_HI) : mid; + return _spectralFluxOnsetsPure(clickTrackAt(pre.concat(break_, outro).sort((a, b) => a - b)), SAMPLE_RATE); +} + +const BREAK_SECTIONS = [...SECTIONS, + { name: 'Break', number: 3, start_time: BREAK_START }, + { name: 'Outro', number: 4, start_time: BREAK_END }, +]; +function analyzeBreak(mismatched) { + return _alignSectionsPure({ + sections: BREAK_SECTIONS, beats: SONG_BEATS, audioOnsets: breakAudio(mismatched), prior: UNIT_PRIOR, + }); +} + +t('mismatch: a wrong-take section reads low confidence', () => { + const good = analyzeBreak(false), bad = analyzeBreak(true); + const gb = byName(good, 'Break'), bb = byName(bad, 'Break'); + assert.ok(gb.ok && gb.confidence >= 0.5, `the matching Break is trusted (${gb.confidence})`); + assert.ok(bb && bb.ok === true, 'the wrong-take Break is analysed, not skipped'); + assert.strictEqual(bb.reason, 'low-confidence', 'a wrong take reads low-confidence, not a hard failure'); + assert.ok(bb.confidence < 0.5, `the wrong-take Break reads ${bb.confidence}, below the floor`); +}); + +t('mismatch: the bad section does not touch the good sections', () => { + const good = analyzeBreak(false), bad = analyzeBreak(true); + // Verse and Solo precede the Break; Outro FOLLOWS it — the hard case, where + // a failed Break could have dragged the map before it since the chain is + // forward-propagating. Both directions must stay byte-for-byte unchanged. + for (const name of ['Verse', 'Solo', 'Outro']) { + assert.deepStrictEqual(byName(bad, name), byName(good, name), + `${name} is byte-for-byte the same whether Break matches or not`); + } + // ... and the Break itself is the only proposal that changed. + assert.notDeepStrictEqual(byName(bad, 'Break'), byName(good, 'Break'), 'Break is where the difference lands'); +}); + +// ── 4. Acceptance: a single section reduces to the uniform model ──────────── +t('single section: one region reproduces the uniform factor', () => { + const res = _alignSectionsPure({ + sections: [], beats: BEATS, audioOnsets: _spectralFluxOnsetsPure(clickTrackAt(AUDIO_T), SAMPLE_RATE), + sourceOnsets: BEAT_T, prior: UNIT_PRIOR, + }); + assert.strictEqual(res.proposals.length, 1, 'one whole-song region'); + const prop = res.proposals[0]; + assert.ok(prop.ok, 'the whole song aligns'); + // The uniform convention the shipped dialog uses: onset-vote BPM / grid BPM. + const onsets = _spectralFluxOnsetsPure(clickTrackAt(AUDIO_T), SAMPLE_RATE); + const vote = _detectTempoFromOnsetsPure(onsets); + const tabBpm = _localBpmFromBeatsPure(BEATS, 0, END_T, true); + assert.ok(Math.abs(prop.factor - vote.bpm / tabBpm) < 1e-3, + `factor ${prop.factor} == vote ${vote.bpm} / grid ${tabBpm.toFixed(2)}`); +}); + +t('uniform reduction: a same-tempo song gives every section the same factor', () => { + // Both halves at the SAME audio tempo: per-section must agree with global. + const audio = BEAT_T.map((x) => x * RV); + const onsets = _spectralFluxOnsetsPure(clickTrackAt(audio), SAMPLE_RATE); + const res = _alignSectionsPure({ sections: SECTIONS, beats: BEATS, audioOnsets: onsets, sourceOnsets: BEAT_T, prior: UNIT_PRIOR }); + const verse = byName(res, 'Verse'), solo = byName(res, 'Solo'); + assert.ok(Math.abs(verse.factor - solo.factor) < 0.02, `both halves read the same factor (${verse.factor} vs ${solo.factor})`); + assert.ok(Math.abs(verse.factor - VERSE_AUDIO_BPM / CHART_BPM) < 0.02, 'and it is the true one'); +}); + +t('a section whose first note is not on its marker keeps the marker place', () => { + // 120 BPM grid 0..20 s, recording = identity (audio time == chart time), but + // the ONLY notes run 5..19.5 — the section marker (2) is 3 s before the + // first note. The window must anchor on the first REAL onset, not the + // boundary, or the whole map silently shifts by a beat count. + const gBeats = buildGrid(0, 10, CHART_BPM); + const notes = []; + for (let t = 5; t <= 19.5 + 1e-9; t += 0.5) notes.push(t); + const onsets = _spectralFluxOnsetsPure(clickTrackAt(notes.slice()), SAMPLE_RATE); + const res = _alignSectionsPure({ + sections: [{ name: 'Section', number: 1, start_time: 2 }], + beats: gBeats, audioOnsets: onsets, sourceOnsets: notes, prior: UNIT_PRIOR, + }); + const prop = byName(res, 'Section'); + assert.ok(prop && prop.ok, `the section aligns (${prop && (prop.reason || 'ok')})`); + // Sub-BPM vote jitter moves slope a hair off 1; the map must still land the + // first note on ITS OWN audio, not on the (2,2) place a boundary anchor + // would have given (intercept ≈ -3). + const atFirstNote = prop.slope * 5 + prop.intercept; + assert.ok(Math.abs(atFirstNote - 5) < 0.1, `map(5) ≈ 5, got ${atFirstNote.toFixed(3)} (intercept ${prop.intercept})`); + assert.ok(Math.abs(prop.intercept) < 0.2, `the map keeps the marker's place (intercept ${prop.intercept})`); + assert.ok(Math.abs(prop.slope - 1) < 0.01, `slope ${prop.slope.toFixed(4)} ≈ 1`); + const first = prop.anchors[0]; + assert.ok(first && Math.abs(first.source - 5) < 1e-9 && Math.abs(first.audio - 5) < 0.1, + `first anchor lands on the first note (5,5), got ${JSON.stringify(first)}`); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0);