From 48949e9b41adfda29b5b4e57112833fbd5b63500 Mon Sep 17 00:00:00 2001 From: carochacs <79524656+carochacs@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:11:32 +0000 Subject: [PATCH 01/15] feat(sync): add per-section sync apply command and piecewise map builder (#25) Implements baking of per-section warp proposals into chart timing with undo/redo, including piecewise map construction, skip semantics (objects starting in skipped sections keep exact timing), span/sustain handling, beat grid remapping, and history integration. --- src/history.js | 1 + src/main.js | 4 ++ src/section-sync.js | 103 ++++++++++++++++++++++++++++++++++++++++++++ src/tempo.js | 97 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+) diff --git a/src/history.js b/src/history.js index 2278bb1..e8e12ad 100644 --- a/src/history.js +++ b/src/history.js @@ -30,6 +30,7 @@ import { isKeysMode, updatePianoRange, _rollReadOnly, _rollLockNotice } from './ const _COMMAND_LABEL_OVERRIDES = Object.freeze({ AudioShiftCmd: 'shift the recording', TempoOffsetCmd: 'nudge the audio offset', + TempoPerSectionCmd: 'apply per-section sync', TempoGridCmd: 'rebuild the beat grid', TempoMapCmd: 'edit the tempo map', TempoLockCmd: 'lock barlines', diff --git a/src/main.js b/src/main.js index d939a1a..84d64b7 100644 --- a/src/main.js +++ b/src/main.js @@ -86,6 +86,7 @@ import { editorApplySync, editorHideSyncDialog, editorSyncTempo, editorSyncUpdateFactor, getTabBPM } from './sync-tempo.js'; +import { applyPerSectionSync, _applyPerSectionSyncPure, _buildPiecewiseMapPure } from './tempo.js'; import { getMousePos, onDblClick, onMouseDown, onMouseMove, onMouseUp, onWheel } from './mouse.js'; @@ -737,6 +738,9 @@ window.editorToggleMapHealth = (force) => editorToggleMapHealth(force); window.editorSyncUpdateFactor = editorSyncUpdateFactor; window.editorHideSyncDialog = editorHideSyncDialog; window.editorApplySync = editorApplySync; +window.applyPerSectionSync = applyPerSectionSync; +window._applyPerSectionSyncPure = _applyPerSectionSyncPure; +window._buildPiecewiseMapPure = _buildPiecewiseMapPure; // Tab preview (tab-preview.js owns the logic; HTML calls these by name). window.editorShowTabPreview = _editorShowTabPreview; diff --git a/src/section-sync.js b/src/section-sync.js index e1b3dd9..6d73b9d 100644 --- a/src/section-sync.js +++ b/src/section-sync.js @@ -602,4 +602,107 @@ export function _alignSectionsPure(params) { proposals, }; } + +/** + * Build a piecewise map W(t) from accepted proposals for the given domain. + * For each section interval [start,end) that is accepted (confidence >= floor), + * the affine map is slope*x + intercept. Skipped intervals map as identity W(t)=t. + * The map is continuous at boundaries by construction of accepted proposals. + */ +export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7) { + if (!domain || !Number.isFinite(domain.start) || !Number.isFinite(domain.end)) { + return { ok: false, reason: 'bad-domain' }; + } + // Build intervals in order + const intervals = []; + const sorted = [...proposals].sort((a, b) => { + if (a.start < b.start - EPS) return -1; + if (a.start > b.start + EPS) return 1; + return 0; + }); + for (let i = 0; i < sorted.length; i++) { + const p = sorted[i]; + if (p.ok && p.confidence >= confidenceFloor && Number.isFinite(p.slope) && Number.isFinite(p.intercept)) { + intervals.push({ + start: p.start, + end: p.end, + slope: p.slope, + intercept: p.intercept, + accepted: true, + }); + } else { + intervals.push({ + start: p.start, + end: p.end, + slope: 1, + intercept: 0, + accepted: false, + }); + } + } + return { + ok: true, + domain, + intervals, + map: (t) => { + if (!Number.isFinite(t)) return t; + // Find the interval containing t + for (let j = 0; j < intervals.length; j++) { + const iv = intervals[j]; + if (t < iv.start - 1e-9) continue; + const isLast = j === intervals.length - 1; + // For interior points, t < iv.end means in this interval + if (t < iv.end - 1e-9) { + return iv.slope * t + iv.intercept; + } + // At boundary t >= iv.end-1e-9 and t <= iv.end+1e-9 + if (Math.abs(t - iv.end) < 1e-9) { + // Boundary goes to the next interval if it starts at this point, else current + if (!isLast && Math.abs(t - intervals[j+1].start) < 1e-9) { + continue; // let next interval handle it + } + return iv.slope * t + iv.intercept; + } + if (isLast && t <= domain.end + 1e-9) { + return iv.slope * t + iv.intercept; + } + } + // If t is before first interval start, use first interval's map if it starts at domain start? + if (intervals.length > 0 && t <= intervals[0].start + 1e-9) { + return intervals[0].slope * t + intervals[0].intercept; + } + // If t is after all, use last + if (intervals.length > 0) { + const last = intervals[intervals.length - 1]; + return last.slope * t + last.intercept; + } + return t; + }, + }; +} + +/** Apply the piecewise map to a time value, classifying by original onset section. + * Objects starting in a skipped section retain their timing fields exactly. + * For accepted sections, map the timestamp. + */ +export function _applyTimeWithMap(t, mapResult, proposals, confidenceFloor = 0.7) { + if (!Number.isFinite(t)) return t; + // Find which proposal/section the time originates from based on start + if (!proposals || proposals.length === 0) { + return mapResult.ok ? mapResult.map(t) : t; + } + // Find the interval containing this original time + let skip = false; + for (let i = 0; i < proposals.length; i++) { + const p = proposals[i]; + if (t >= p.start - 1e-9 && (t < p.end - 1e-9 || (p.end >= (mapResult.domain && mapResult.domain.end || t) - 1e-9 && t <= p.end + 1e-9))) { + if (!(p.ok && p.confidence >= confidenceFloor)) { + skip = true; + } + break; + } + } + if (skip) return t; + return mapResult.ok ? mapResult.map(t) : t; +} /* @pure:section-sync:end */ diff --git a/src/tempo.js b/src/tempo.js index cc434f3..a56ac28 100644 --- a/src/tempo.js +++ b/src/tempo.js @@ -31,6 +31,7 @@ import { _zonesGet, _zonesSelect, _zonesShow, _zonesStripHit, _zonesZoneAt, } from './tempo-zones.js'; import { beatOf, timeOf } from './beats.js'; +import { _alignSectionsPure, _buildPiecewiseMapPure, _chartDomainPure, _sectionRegionsPure } from './section-sync.js'; import { DPR, canvas, ctx } from './canvas.js'; import { _drumLaneIdxForPiece, _drumPieceCount } from './drum.js'; import { LABEL_W, TIMELINE_TOP, WAVEFORM_H, timeToX, xToTime } from './geometry.js'; @@ -3962,3 +3963,99 @@ export class TempoOffsetCmd extends TempoMapCmd { this._syncOffsetInput(); } } + +export function _applyPerSectionSyncPure(mapResult, proposals, confidenceFloor = 0.7) { + if (!mapResult || !mapResult.ok) return false; + const oldBeats = S.beats.map(b => ({ ...b })); + const newBeats = S.beats.map(b => { + const nt = mapResult.map(b.time); + return { ...b, time: typeof nt === 'number' && isFinite(nt) ? nt : b.time }; + }); + const preTimes = []; + _eachTimed((o, tf, endKind) => { + preTimes.push([o, tf, o[tf], + endKind === 'sustain' ? o.sustain : endKind === 'span' ? o.end_time : undefined, + endKind]); + }); + const cmd = new TempoPerSectionCmd(oldBeats, newBeats, preTimes, proposals, mapResult, confidenceFloor); + S.history.exec(cmd); + return true; +} + +export class TempoPerSectionCmd { + constructor(oldBeats, newBeats, preTimes, proposals, mapResult, confidenceFloor = 0.7, label = 'per-section sync') { + this.oldBeats = oldBeats.map(b => ({ ...b })); + this.newBeats = newBeats.map(b => ({ ...b })); + this.preTimes = preTimes || []; + this.proposals = proposals || []; + this.mapResult = mapResult || { ok: false }; + this.confidenceFloor = confidenceFloor; + this.label = label; + this.songScope = true; + } + exec() { + _liftAllBeats(this.oldBeats); + S.beats = this.newBeats.map(b => ({ ...b })); + if (this.mapResult && this.mapResult.ok) { + _eachTimed((o, tf, endKind) => { + if (typeof o[tf] !== 'number') return; + let skip = false; + const tOrig = o[tf]; + for (let i = 0; i < this.proposals.length; i++) { + const p = this.proposals[i]; + const pEnd = p.end; + const domEnd = this.mapResult.domain ? this.mapResult.domain.end : tOrig; + if (tOrig >= p.start - 1e-9 && (tOrig < pEnd - 1e-9 || (pEnd >= domEnd - 1e-9 && tOrig <= pEnd + 1e-9))) { + if (!(p.ok && p.confidence >= this.confidenceFloor)) { + skip = true; + } + break; + } + } + if (skip) return; + const nt = this.mapResult.map(tOrig); + if (typeof nt === 'number' && isFinite(nt)) { + o[tf] = _r3(nt); + if (endKind === 'sustain') { + if (typeof o.sustain === 'number' && o.sustain > 0) { + const ntEnd = this.mapResult.map(tOrig + o.sustain); + o.sustain = Math.max(0, _r3((typeof ntEnd === 'number' && isFinite(ntEnd)) ? (ntEnd - nt) : o.sustain)); + } + } else if (endKind === 'span') { + if (typeof o.end_time === 'number') { + const ntEnd = this.mapResult.map(o.end_time); + if (typeof ntEnd === 'number' && isFinite(ntEnd)) { + o.end_time = _r3(ntEnd); + } + } + } + } + }); + } + host.loopReprojectFromBeats(S.beats); + host.renderLoopStrip(); + host.updateLoopIn3DBtn(); + } + rollback() { + S.beats = this.oldBeats.map(b => ({ ...b })); + for (const [o, tf, sec, endVal, endKind] of (this.preTimes || [])) { + o[tf] = sec; + if (endKind === 'sustain') { + if (endVal !== undefined) o.sustain = endVal; + } else if (endKind === 'span') { + if (endVal !== undefined) o.end_time = endVal; + } + } + host.loopReprojectFromBeats(S.beats); + host.renderLoopStrip(); + host.updateLoopIn3DBtn(); + } +} + +export function applyPerSectionSync(params) { + const proposals = params.proposals; + const confidenceFloor = params.confidenceFloor != null ? params.confidenceFloor : 0.7; + const domain = params.domain || _chartDomainPure(S.beats); + const mapResult = _buildPiecewiseMapPure(proposals || [], domain, confidenceFloor); + return _applyPerSectionSyncPure(mapResult, proposals || [], confidenceFloor); +} From 4a05394b34f5c17838324710d0d0653a52054386 Mon Sep 17 00:00:00 2001 From: carochacs <79524656+carochacs@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:15:26 +0000 Subject: [PATCH 02/15] fix: clean up unused imports and simplify per-section apply logic (#25) --- src/tempo.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/tempo.js b/src/tempo.js index a56ac28..2273171 100644 --- a/src/tempo.js +++ b/src/tempo.js @@ -31,7 +31,7 @@ import { _zonesGet, _zonesSelect, _zonesShow, _zonesStripHit, _zonesZoneAt, } from './tempo-zones.js'; import { beatOf, timeOf } from './beats.js'; -import { _alignSectionsPure, _buildPiecewiseMapPure, _chartDomainPure, _sectionRegionsPure } from './section-sync.js'; +import { _buildPiecewiseMapPure, _chartDomainPure } from './section-sync.js'; import { DPR, canvas, ctx } from './canvas.js'; import { _drumLaneIdxForPiece, _drumPieceCount } from './drum.js'; import { LABEL_W, TIMELINE_TOP, WAVEFORM_H, timeToX, xToTime } from './geometry.js'; @@ -3999,16 +3999,14 @@ export class TempoPerSectionCmd { if (this.mapResult && this.mapResult.ok) { _eachTimed((o, tf, endKind) => { if (typeof o[tf] !== 'number') return; - let skip = false; const tOrig = o[tf]; + let skip = false; for (let i = 0; i < this.proposals.length; i++) { const p = this.proposals[i]; const pEnd = p.end; const domEnd = this.mapResult.domain ? this.mapResult.domain.end : tOrig; - if (tOrig >= p.start - 1e-9 && (tOrig < pEnd - 1e-9 || (pEnd >= domEnd - 1e-9 && tOrig <= pEnd + 1e-9))) { - if (!(p.ok && p.confidence >= this.confidenceFloor)) { - skip = true; - } + if (tOrig >= p.start - 1e-9 && (tOrig < pEnd - 1e-9 || (tOrig <= pEnd + 1e-9 && pEnd >= domEnd - 1e-9))) { + skip = !(p.ok && p.confidence >= this.confidenceFloor); break; } } From 9538d25d2d0e1a3686be6737cfdc0871d774d245 Mon Sep 17 00:00:00 2001 From: carochacs <79524656+carochacs@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:23:04 +0000 Subject: [PATCH 03/15] fix: export _buildPiecewiseMapPure from tempo.js for main import --- src/tempo.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/tempo.js b/src/tempo.js index 2273171..e5f263d 100644 --- a/src/tempo.js +++ b/src/tempo.js @@ -4001,11 +4001,11 @@ export class TempoPerSectionCmd { if (typeof o[tf] !== 'number') return; const tOrig = o[tf]; let skip = false; + const domEnd = this.mapResult.domain ? this.mapResult.domain.end : tOrig; for (let i = 0; i < this.proposals.length; i++) { const p = this.proposals[i]; - const pEnd = p.end; - const domEnd = this.mapResult.domain ? this.mapResult.domain.end : tOrig; - if (tOrig >= p.start - 1e-9 && (tOrig < pEnd - 1e-9 || (tOrig <= pEnd + 1e-9 && pEnd >= domEnd - 1e-9))) { + const inInterval = tOrig >= p.start - 1e-9 && (tOrig < p.end - 1e-9 || (tOrig <= p.end + 1e-9 && p.end >= domEnd - 1e-9)); + if (inInterval) { skip = !(p.ok && p.confidence >= this.confidenceFloor); break; } @@ -4050,6 +4050,7 @@ export class TempoPerSectionCmd { } } +export { _buildPiecewiseMapPure } from './section-sync.js'; export function applyPerSectionSync(params) { const proposals = params.proposals; const confidenceFloor = params.confidenceFloor != null ? params.confidenceFloor : 0.7; From 435eb7f50ae708f430fd1a1090f04423f4ff504c Mon Sep 17 00:00:00 2001 From: carochacs <79524656+carochacs@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:25:10 +0000 Subject: [PATCH 04/15] fix: enforce piecewise map continuity at skipped section boundaries --- src/section-sync.js | 50 ++++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/src/section-sync.js b/src/section-sync.js index 6d73b9d..8f99318 100644 --- a/src/section-sync.js +++ b/src/section-sync.js @@ -620,26 +620,44 @@ export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7) if (a.start > b.start + EPS) return 1; return 0; }); + // First pass: determine acceptance + const temp = []; for (let i = 0; i < sorted.length; i++) { const p = sorted[i]; - if (p.ok && p.confidence >= confidenceFloor && Number.isFinite(p.slope) && Number.isFinite(p.intercept)) { - intervals.push({ - start: p.start, - end: p.end, - slope: p.slope, - intercept: p.intercept, - accepted: true, - }); - } else { - intervals.push({ - start: p.start, - end: p.end, - slope: 1, - intercept: 0, - accepted: false, - }); + const accepted = p.ok && p.confidence >= confidenceFloor && Number.isFinite(p.slope) && Number.isFinite(p.intercept); + temp.push({ + start: p.start, + end: p.end, + slope: accepted ? p.slope : 1, + intercept: accepted ? p.intercept : 0, + accepted, + }); + } + // Enforce continuity: if a section is skipped, then W = identity there. + // An accepted section adjacent to skipped must be pinned to identity at the shared boundary. + for (let i = 0; i < temp.length; i++) { + const iv = temp[i]; + if (iv.accepted) { + // Check boundaries - if neighbor is skipped, constrain to pass through (boundary, boundary) + // Left boundary + if (i === 0) { + // Leftmost - if it would create jump from outside? domain start is identity by default + } else if (!temp[i - 1].accepted) { + // Previous is skipped; constrain iv so that W(iv.start) = iv.start (identity) + iv.intercept = iv.start - iv.slope * iv.start; + } + // Right boundary + if (i < temp.length - 1 && !temp[i + 1].accepted) { + // Next is skipped; constrain iv so that W(iv.end) = iv.end + iv.intercept = iv.end - iv.slope * iv.end; + } else if (i === temp.length - 1) { + // Last interval - also ensure continuity at domain end? not specified, but better to be consistent + } } } + for (let i = 0; i < temp.length; i++) { + intervals.push(temp[i]); + } return { ok: true, domain, From 5662d067f5d3860e235f3536be70816113924771 Mon Sep 17 00:00:00 2001 From: carochacs <79524656+carochacs@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:09:21 +0000 Subject: [PATCH 05/15] fix(sync): import _buildPiecewiseMapPure from section-sync --- src/main.js | 3 ++- src/tempo.js | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.js b/src/main.js index 84d64b7..5cf1ad2 100644 --- a/src/main.js +++ b/src/main.js @@ -86,7 +86,8 @@ import { editorApplySync, editorHideSyncDialog, editorSyncTempo, editorSyncUpdateFactor, getTabBPM } from './sync-tempo.js'; -import { applyPerSectionSync, _applyPerSectionSyncPure, _buildPiecewiseMapPure } from './tempo.js'; +import { applyPerSectionSync, _applyPerSectionSyncPure } from './tempo.js'; +import { _buildPiecewiseMapPure } from './section-sync.js'; import { getMousePos, onDblClick, onMouseDown, onMouseMove, onMouseUp, onWheel } from './mouse.js'; diff --git a/src/tempo.js b/src/tempo.js index e5f263d..32f0f4a 100644 --- a/src/tempo.js +++ b/src/tempo.js @@ -4050,7 +4050,6 @@ export class TempoPerSectionCmd { } } -export { _buildPiecewiseMapPure } from './section-sync.js'; export function applyPerSectionSync(params) { const proposals = params.proposals; const confidenceFloor = params.confidenceFloor != null ? params.confidenceFloor : 0.7; From 4cd190e69a7c13630fcb9fccde0fa04132ca70f8 Mon Sep 17 00:00:00 2001 From: carochacs <79524656+carochacs@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:10:30 +0000 Subject: [PATCH 06/15] fix(sync): reject zero/negative mapped sustain instead of clamping --- src/tempo.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tempo.js b/src/tempo.js index 32f0f4a..0b36e3d 100644 --- a/src/tempo.js +++ b/src/tempo.js @@ -4017,7 +4017,11 @@ export class TempoPerSectionCmd { if (endKind === 'sustain') { if (typeof o.sustain === 'number' && o.sustain > 0) { const ntEnd = this.mapResult.map(tOrig + o.sustain); - o.sustain = Math.max(0, _r3((typeof ntEnd === 'number' && isFinite(ntEnd)) ? (ntEnd - nt) : o.sustain)); + if (typeof ntEnd === 'number' && isFinite(ntEnd)) { + const mappedSustain = _r3(ntEnd - nt); + if (mappedSustain <= 0) return; + o.sustain = mappedSustain; + } } } else if (endKind === 'span') { if (typeof o.end_time === 'number') { From 79c79f5dbff8bd4767d426b61523a4c216e0b054 Mon Sep 17 00:00:00 2001 From: carochacs <79524656+carochacs@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:11:17 +0000 Subject: [PATCH 07/15] fix(sync): reject span end_time that maps before start --- src/tempo.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tempo.js b/src/tempo.js index 0b36e3d..ffa377c 100644 --- a/src/tempo.js +++ b/src/tempo.js @@ -4027,6 +4027,7 @@ export class TempoPerSectionCmd { if (typeof o.end_time === 'number') { const ntEnd = this.mapResult.map(o.end_time); if (typeof ntEnd === 'number' && isFinite(ntEnd)) { + if (ntEnd < nt) return; o.end_time = _r3(ntEnd); } } From 23e5dec1a07e48b493ee2247cce367477ef499a9 Mon Sep 17 00:00:00 2001 From: carochacs <79524656+carochacs@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:12:29 +0000 Subject: [PATCH 08/15] fix(sync): remove unused _applyTimeWithMap export --- src/section-sync.js | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/section-sync.js b/src/section-sync.js index 8f99318..2ba47d2 100644 --- a/src/section-sync.js +++ b/src/section-sync.js @@ -698,29 +698,4 @@ export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7) }, }; } - -/** Apply the piecewise map to a time value, classifying by original onset section. - * Objects starting in a skipped section retain their timing fields exactly. - * For accepted sections, map the timestamp. - */ -export function _applyTimeWithMap(t, mapResult, proposals, confidenceFloor = 0.7) { - if (!Number.isFinite(t)) return t; - // Find which proposal/section the time originates from based on start - if (!proposals || proposals.length === 0) { - return mapResult.ok ? mapResult.map(t) : t; - } - // Find the interval containing this original time - let skip = false; - for (let i = 0; i < proposals.length; i++) { - const p = proposals[i]; - if (t >= p.start - 1e-9 && (t < p.end - 1e-9 || (p.end >= (mapResult.domain && mapResult.domain.end || t) - 1e-9 && t <= p.end + 1e-9))) { - if (!(p.ok && p.confidence >= confidenceFloor)) { - skip = true; - } - break; - } - } - if (skip) return t; - return mapResult.ok ? mapResult.map(t) : t; -} /* @pure:section-sync:end */ From 652980653a0ad00ab8847a234d3be0557a04d354 Mon Sep 17 00:00:00 2001 From: carochacs <79524656+carochacs@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:13:53 +0000 Subject: [PATCH 09/15] fix(sync): enforce continuity pinning at domain edges and both-sided skips --- src/section-sync.js | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/section-sync.js b/src/section-sync.js index 2ba47d2..49670d4 100644 --- a/src/section-sync.js +++ b/src/section-sync.js @@ -633,26 +633,24 @@ export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7) accepted, }); } - // Enforce continuity: if a section is skipped, then W = identity there. - // An accepted section adjacent to skipped must be pinned to identity at the shared boundary. + // Enforce continuity: domain edges and skipped neighbours both impose W(t)=t + // at the shared seam. An accepted run pinned to identity at both edges is + // incompatible unless its slope is already 1 — in that case reject the run. for (let i = 0; i < temp.length; i++) { const iv = temp[i]; - if (iv.accepted) { - // Check boundaries - if neighbor is skipped, constrain to pass through (boundary, boundary) - // Left boundary - if (i === 0) { - // Leftmost - if it would create jump from outside? domain start is identity by default - } else if (!temp[i - 1].accepted) { - // Previous is skipped; constrain iv so that W(iv.start) = iv.start (identity) - iv.intercept = iv.start - iv.slope * iv.start; - } - // Right boundary - if (i < temp.length - 1 && !temp[i + 1].accepted) { - // Next is skipped; constrain iv so that W(iv.end) = iv.end - iv.intercept = iv.end - iv.slope * iv.end; - } else if (i === temp.length - 1) { - // Last interval - also ensure continuity at domain end? not specified, but better to be consistent - } + if (!iv.accepted) continue; + const leftPinned = i === 0 || !temp[i - 1].accepted; + const rightPinned = i === temp.length - 1 || !temp[i + 1].accepted; + if (leftPinned && rightPinned) { + iv.accepted = false; + iv.slope = 1; + iv.intercept = 0; + } else if (leftPinned) { + const boundary = i === 0 ? domain.start : iv.start; + iv.intercept = boundary - iv.slope * boundary; + } else if (rightPinned) { + const boundary = i === temp.length - 1 ? domain.end : iv.end; + iv.intercept = boundary - iv.slope * boundary; } } for (let i = 0; i < temp.length; i++) { From f33669ef19de589a31610ad16c98c051ef86080e Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:59:38 +0000 Subject: [PATCH 10/15] fix(sync): pin piecewise map continuity only at skipped seams, expose conflicts (#25) --- src/section-sync.js | 65 ++++++++++++++++++++++++++----------- src/tempo.js | 1 + tests/section_sync.test.mjs | 38 ++++++++++++++++++++++ 3 files changed, 85 insertions(+), 19 deletions(-) diff --git a/src/section-sync.js b/src/section-sync.js index 49670d4..2bc0d53 100644 --- a/src/section-sync.js +++ b/src/section-sync.js @@ -607,7 +607,12 @@ export function _alignSectionsPure(params) { * Build a piecewise map W(t) from accepted proposals for the given domain. * For each section interval [start,end) that is accepted (confidence >= floor), * the affine map is slope*x + intercept. Skipped intervals map as identity W(t)=t. - * The map is continuous at boundaries by construction of accepted proposals. + * Continuity is enforced per accepted RUN: a run abutting a skipped interval is + * shifted so the shared seam maps to identity, while a run with no such seam + * keeps its proposal exactly (a lone accepted section uses the uniform affine + * map). A run pinned to identity at BOTH ends by skipped neighbours is a + * conflict unless it is already identity — exposed in `conflicts` rather than + * silently demoted. */ export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7) { if (!domain || !Number.isFinite(domain.start) || !Number.isFinite(domain.end)) { @@ -633,25 +638,46 @@ export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7) accepted, }); } - // Enforce continuity: domain edges and skipped neighbours both impose W(t)=t - // at the shared seam. An accepted run pinned to identity at both edges is - // incompatible unless its slope is already 1 — in that case reject the run. - for (let i = 0; i < temp.length; i++) { - const iv = temp[i]; - if (!iv.accepted) continue; - const leftPinned = i === 0 || !temp[i - 1].accepted; - const rightPinned = i === temp.length - 1 || !temp[i + 1].accepted; - if (leftPinned && rightPinned) { - iv.accepted = false; - iv.slope = 1; - iv.intercept = 0; - } else if (leftPinned) { - const boundary = i === 0 ? domain.start : iv.start; - iv.intercept = boundary - iv.slope * boundary; - } else if (rightPinned) { - const boundary = i === temp.length - 1 ? domain.end : iv.end; - iv.intercept = boundary - iv.slope * boundary; + // Enforce continuity. A skipped interval imposes W(t)=t throughout, both + // edges included, so an accepted RUN abutting one is pinned to identity at + // that shared seam. Shifting the whole run by one constant preserves its + // internal continuity. A run pinned at BOTH ends must be identity at both; + // a slope != 1 run cannot be, which is a conflict we expose, never demote + // silently. Being first or last in the list is NOT itself a pin — a lone + // accepted section covering the whole chart keeps its uniform affine map. + // Retained content bordering the domain is a pin, and so is a skipped + // neighbour. + const conflicts = []; + let runStart = 0; + while (runStart < temp.length) { + if (!temp[runStart].accepted) { runStart++; continue; } + let runEnd = runStart; + while (runEnd + 1 < temp.length && temp[runEnd + 1].accepted) runEnd++; + const first = temp[runStart]; + const last = temp[runEnd]; + const leftPinned = (runStart > 0 && !temp[runStart - 1].accepted) + || first.start > domain.start + EPS; + const rightPinned = (runEnd < temp.length - 1 && !temp[runEnd + 1].accepted) + || last.end < domain.end - EPS; + if (leftPinned || rightPinned) { + const anchor = leftPinned ? first.start : last.end; + const at = leftPinned + ? first.slope * first.start + first.intercept + : last.slope * last.end + last.intercept; + for (let k = runStart; k <= runEnd; k++) temp[k].intercept += anchor - at; + if (leftPinned && rightPinned) { + const atRight = last.slope * last.end + last.intercept; + if (Math.abs(atRight - last.end) > 1e-6) { + conflicts.push({ start: first.start, end: last.end, reason: 'identity-conflict' }); + for (let k = runStart; k <= runEnd; k++) { + temp[k].accepted = false; + temp[k].slope = 1; + temp[k].intercept = 0; + } + } + } } + runStart = runEnd + 1; } for (let i = 0; i < temp.length; i++) { intervals.push(temp[i]); @@ -660,6 +686,7 @@ export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7) ok: true, domain, intervals, + conflicts, map: (t) => { if (!Number.isFinite(t)) return t; // Find the interval containing t diff --git a/src/tempo.js b/src/tempo.js index ffa377c..d338bdc 100644 --- a/src/tempo.js +++ b/src/tempo.js @@ -3966,6 +3966,7 @@ export class TempoOffsetCmd extends TempoMapCmd { export function _applyPerSectionSyncPure(mapResult, proposals, confidenceFloor = 0.7) { if (!mapResult || !mapResult.ok) return false; + if (Array.isArray(mapResult.conflicts) && mapResult.conflicts.length) return false; const oldBeats = S.beats.map(b => ({ ...b })); const newBeats = S.beats.map(b => { const nt = mapResult.map(b.time); diff --git a/tests/section_sync.test.mjs b/tests/section_sync.test.mjs index cfbd8d5..a405385 100644 --- a/tests/section_sync.test.mjs +++ b/tests/section_sync.test.mjs @@ -40,6 +40,7 @@ import { _sectionRegionsPure, _localBpmFromBeatsPure, _dtwAlignPure, _slopeFromPairsPure, _fitAffinePure, _chartDomainPure, _collectSourceOnsetsPure, _alignSectionPure, _alignSectionsPure, + _buildPiecewiseMapPure, } from '../src/section-sync.js'; let pass = 0, fail = 0; @@ -382,5 +383,42 @@ t('a section whose first note is not on its marker keeps the marker place', () = `first anchor lands on the first note (5,5), got ${JSON.stringify(first)}`); }); +// ── 5. Piecewise map builder: continuity only where skipped content pins it ── +function acceptedIv(start, end, slope, intercept) { + return { start, end, slope, intercept, ok: true, confidence: 0.9 }; +} +function skippedIv(start, end) { + return { start, end, slope: 1, intercept: 0, ok: false, confidence: 0 }; +} + +t('piecewise: a lone accepted section keeps its uniform affine map', () => { + const res = _buildPiecewiseMapPure([acceptedIv(0, 30, 2, 0)], { start: 0, end: 30 }); + assert.strictEqual(res.ok, true); + assert.deepStrictEqual(res.conflicts, []); + assert.strictEqual(res.map(10), 20, 'a domain edge is not an identity pin, so the sole section is not demoted'); +}); + +t('piecewise: adjacent accepted sections keep their proposals', () => { + const res = _buildPiecewiseMapPure([acceptedIv(0, 15, 2, 0), acceptedIv(15, 30, 2, 0)], { start: 0, end: 30 }); + assert.deepStrictEqual(res.conflicts, []); + assert.ok(Math.abs(res.map(14.999) - 29.998) < 1e-6, `map(14.999) ${res.map(14.999)}`); + assert.ok(Math.abs(res.map(15) - 30) < 1e-9, `map(15) ${res.map(15)} — no backwards jump at the seam`); +}); + +t('piecewise: a run abutting a skipped section is pinned to identity at the seam', () => { + const res = _buildPiecewiseMapPure([skippedIv(0, 10), acceptedIv(10, 30, 2, 0)], { start: 0, end: 30 }); + assert.deepStrictEqual(res.conflicts, []); + assert.ok(Math.abs(res.map(10) - 10) < 1e-9, `map(10) ${res.map(10)} — pinned to identity`); + assert.ok(Math.abs(res.map(20) - 30) < 1e-9, `map(20) ${res.map(20)} — run slope carried past the seam`); +}); + +t('piecewise: a both-sided-pinned incompatible run is exposed, never silently demoted', () => { + const res = _buildPiecewiseMapPure( + [skippedIv(0, 10), acceptedIv(10, 20, 2, 0), skippedIv(20, 30)], { start: 0, end: 30 }); + assert.strictEqual(res.conflicts.length, 1, 'the identity conflict is exposed'); + assert.strictEqual(res.conflicts[0].start, 10); + assert.strictEqual(res.conflicts[0].end, 20); +}); + console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); From 806c984dc8aa947cad615e7652649746035b4d6a Mon Sep 17 00:00:00 2001 From: carochacs <79524656+carochacs@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:11:44 +0000 Subject: [PATCH 11/15] fix(sync): validate full note mapping before mutating fields Prevents inconsistent note state where start time was mapped but sustain/end_time validation failed, leaving partial mutations. --- src/tempo.js | 46 ++++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/tempo.js b/src/tempo.js index d338bdc..5888aa9 100644 --- a/src/tempo.js +++ b/src/tempo.js @@ -3965,8 +3965,10 @@ export class TempoOffsetCmd extends TempoMapCmd { } export function _applyPerSectionSyncPure(mapResult, proposals, confidenceFloor = 0.7) { - if (!mapResult || !mapResult.ok) return false; - if (Array.isArray(mapResult.conflicts) && mapResult.conflicts.length) return false; + if (!mapResult || !mapResult.ok) return { applied: false, reason: 'bad-map' }; + if (Array.isArray(mapResult.conflicts) && mapResult.conflicts.length) { + return { applied: false, reason: 'conflict', conflicts: mapResult.conflicts }; + } const oldBeats = S.beats.map(b => ({ ...b })); const newBeats = S.beats.map(b => { const nt = mapResult.map(b.time); @@ -3980,7 +3982,7 @@ export function _applyPerSectionSyncPure(mapResult, proposals, confidenceFloor = }); const cmd = new TempoPerSectionCmd(oldBeats, newBeats, preTimes, proposals, mapResult, confidenceFloor); S.history.exec(cmd); - return true; + return { applied: true }; } export class TempoPerSectionCmd { @@ -4013,27 +4015,27 @@ export class TempoPerSectionCmd { } if (skip) return; const nt = this.mapResult.map(tOrig); - if (typeof nt === 'number' && isFinite(nt)) { - o[tf] = _r3(nt); - if (endKind === 'sustain') { - if (typeof o.sustain === 'number' && o.sustain > 0) { - const ntEnd = this.mapResult.map(tOrig + o.sustain); - if (typeof ntEnd === 'number' && isFinite(ntEnd)) { - const mappedSustain = _r3(ntEnd - nt); - if (mappedSustain <= 0) return; - o.sustain = mappedSustain; - } - } - } else if (endKind === 'span') { - if (typeof o.end_time === 'number') { - const ntEnd = this.mapResult.map(o.end_time); - if (typeof ntEnd === 'number' && isFinite(ntEnd)) { - if (ntEnd < nt) return; - o.end_time = _r3(ntEnd); - } - } + if (!(typeof nt === 'number' && isFinite(nt))) return; + let mappedSustain = null; + let mappedEndTime = null; + if (endKind === 'sustain') { + if (typeof o.sustain === 'number' && o.sustain > 0) { + const ntEnd = this.mapResult.map(tOrig + o.sustain); + if (!(typeof ntEnd === 'number' && isFinite(ntEnd))) return; + mappedSustain = _r3(ntEnd - nt); + if (mappedSustain <= 0) return; + } + } else if (endKind === 'span') { + if (typeof o.end_time === 'number') { + const ntEnd = this.mapResult.map(o.end_time); + if (!(typeof ntEnd === 'number' && isFinite(ntEnd))) return; + if (ntEnd < nt) return; + mappedEndTime = _r3(ntEnd); } } + o[tf] = _r3(nt); + if (mappedSustain !== null) o.sustain = mappedSustain; + if (mappedEndTime !== null) o.end_time = mappedEndTime; }); } host.loopReprojectFromBeats(S.beats); From 3bfd9bce5dfc85f934267df28bc4cc86784fc9fe Mon Sep 17 00:00:00 2001 From: carochacs <79524656+carochacs@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:12:09 +0000 Subject: [PATCH 12/15] fix(sync): validate adjacent accepted section seams for conflicts Independent per-section fits can propose different target times at shared boundaries. Now detects and exposes seam conflicts. --- src/section-sync.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/section-sync.js b/src/section-sync.js index 2bc0d53..cc876e7 100644 --- a/src/section-sync.js +++ b/src/section-sync.js @@ -677,6 +677,18 @@ export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7) } } } + // Validate interior seams between adjacent accepted intervals within the run. + // Independent per-section fits can propose different target times at the shared boundary. + for (let k = runStart; k < runEnd; k++) { + const iv = temp[k]; + const next = temp[k + 1]; + const seam = iv.end; + const mappedFromLeft = iv.slope * seam + iv.intercept; + const mappedFromRight = next.slope * seam + next.intercept; + if (Math.abs(mappedFromLeft - mappedFromRight) > 1e-6) { + conflicts.push({ start: seam, end: seam, reason: 'seam-conflict' }); + } + } runStart = runEnd + 1; } for (let i = 0; i < temp.length; i++) { From 942d22d01738d402f3bf02a067c0fbd459ad2ec6 Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:27:20 +0000 Subject: [PATCH 13/15] fix(sync): reconcile adjacent accepted seams instead of blocking Apply (#25) Independently fitted accepted sections disagree by ordinary fit noise at their shared boundary (~25 ms on the engine's two-section fixture). The interior 1e-6 seam check flagged that as a conflict, so _applyPerSectionSyncPure refused the canonical apply. Reconcile each interior seam by shifting the later line's phase to the earlier line's mapped edge, preserving one continuous piecewise map; keep the conflicts path for the both-ends-identity case. Add a regression test running _alignSectionsPure output through _buildPiecewiseMapPure, plus the #25 CHANGELOG entry. --- CHANGELOG.md | 10 +++++++ src/section-sync.js | 57 +++++++++++++++++++------------------ tests/section_sync.test.mjs | 13 +++++++++ 3 files changed, 53 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cbc46e..92016f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 song reduces to the existing uniform model. Analysis only — nothing bakes timestamps (#25) or surfaces in the UI (#26) yet. +- **Per-section sync apply (#25): bake the accepted map into the chart.** + `applyPerSectionSync` and the undoable `TempoPerSectionCmd` turn the accepted + proposals into one continuous piecewise map and apply it song-wide: the beat + grid is remapped and every timed object is walked once, while objects starting + in a skipped or low-confidence section keep their exact original seconds. + Adjacent accepted sections are reconciled to a single target anchor at their + shared boundary, so ordinary fit noise never blocks Apply; a run pinned to + identity at both ends by skipped neighbours is still reported as a conflict. + API only — no UI wiring yet (#26). + - **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 index cc876e7..1038c85 100644 --- a/src/section-sync.js +++ b/src/section-sync.js @@ -607,12 +607,14 @@ export function _alignSectionsPure(params) { * Build a piecewise map W(t) from accepted proposals for the given domain. * For each section interval [start,end) that is accepted (confidence >= floor), * the affine map is slope*x + intercept. Skipped intervals map as identity W(t)=t. - * Continuity is enforced per accepted RUN: a run abutting a skipped interval is - * shifted so the shared seam maps to identity, while a run with no such seam - * keeps its proposal exactly (a lone accepted section uses the uniform affine - * map). A run pinned to identity at BOTH ends by skipped neighbours is a - * conflict unless it is already identity — exposed in `conflicts` rather than - * silently demoted. + * Continuity is enforced per accepted RUN: an interior seam between two accepted + * sections is reconciled to one target anchor (the later line's phase is shifted + * to meet the earlier one), so independently fitted sections form one continuous + * map instead of jumping. A run abutting a skipped interval is then shifted so + * the shared seam maps to identity, while a run with no such seam keeps its + * (now continuous) map, a lone accepted section using the uniform affine map. A + * run pinned to identity at BOTH ends by skipped neighbours is a conflict unless + * it is already identity — exposed in `conflicts` rather than silently demoted. */ export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7) { if (!domain || !Number.isFinite(domain.start) || !Number.isFinite(domain.end)) { @@ -638,21 +640,34 @@ export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7) accepted, }); } - // Enforce continuity. A skipped interval imposes W(t)=t throughout, both - // edges included, so an accepted RUN abutting one is pinned to identity at - // that shared seam. Shifting the whole run by one constant preserves its - // internal continuity. A run pinned at BOTH ends must be identity at both; - // a slope != 1 run cannot be, which is a conflict we expose, never demote - // silently. Being first or last in the list is NOT itself a pin — a lone - // accepted section covering the whole chart keeps its uniform affine map. - // Retained content bordering the domain is a pin, and so is a skipped - // neighbour. + // Enforce continuity. Adjacent accepted sections are fitted independently, + // so their lines rarely agree at a shared boundary; reconcile each interior + // seam by shifting the later line's phase to meet the earlier line's mapped + // edge. Every section keeps its own slope (its tempo); only phase moves, and + // a constant shift preserves the run's already-reconciled interior. The fit + // noise this removes is not a conflict. A skipped interval then imposes + // W(t)=t throughout, both edges included, so an accepted RUN abutting one is + // pinned to identity at that shared seam. A run pinned at BOTH ends must be + // identity at both; a slope != 1 run cannot be, which is a conflict we + // expose, never demote silently. Being first or last in the list is NOT + // itself a pin — a lone accepted section covering the whole chart keeps its + // uniform affine map. Retained content bordering the domain is a pin, and so + // is a skipped neighbour. const conflicts = []; let runStart = 0; while (runStart < temp.length) { if (!temp[runStart].accepted) { runStart++; continue; } let runEnd = runStart; while (runEnd + 1 < temp.length && temp[runEnd + 1].accepted) runEnd++; + // One continuous line per accepted run: pin each later section to the + // mapped edge of the one before it at their shared seam. + for (let k = runStart; k < runEnd; k++) { + const seam = temp[k].end; + const left = temp[k].slope * seam + temp[k].intercept; + for (let j = k + 1; j <= runEnd; j++) { + temp[j].intercept += left - (temp[j].slope * seam + temp[j].intercept); + } + } const first = temp[runStart]; const last = temp[runEnd]; const leftPinned = (runStart > 0 && !temp[runStart - 1].accepted) @@ -677,18 +692,6 @@ export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7) } } } - // Validate interior seams between adjacent accepted intervals within the run. - // Independent per-section fits can propose different target times at the shared boundary. - for (let k = runStart; k < runEnd; k++) { - const iv = temp[k]; - const next = temp[k + 1]; - const seam = iv.end; - const mappedFromLeft = iv.slope * seam + iv.intercept; - const mappedFromRight = next.slope * seam + next.intercept; - if (Math.abs(mappedFromLeft - mappedFromRight) > 1e-6) { - conflicts.push({ start: seam, end: seam, reason: 'seam-conflict' }); - } - } runStart = runEnd + 1; } for (let i = 0; i < temp.length; i++) { diff --git a/tests/section_sync.test.mjs b/tests/section_sync.test.mjs index a405385..9a1241e 100644 --- a/tests/section_sync.test.mjs +++ b/tests/section_sync.test.mjs @@ -420,5 +420,18 @@ t('piecewise: a both-sided-pinned incompatible run is exposed, never silently de assert.strictEqual(res.conflicts[0].end, 20); }); +t('piecewise: the engine\'s own two-section fit reconciles its seam, never blocks Apply', () => { + // Regression: independently fitted sections disagree at their shared + // boundary by ordinary fit noise (~25 ms here). That is not a conflict — + // the builder must reconcile the seam into one continuous map, not refuse. + const res = analyze(); + const map = _buildPiecewiseMapPure(res.proposals, res.domain, 0.7); + assert.deepStrictEqual(map.conflicts, [], 'fit noise at an accepted seam is reconciled, not flagged'); + const seam = byName(res, 'Solo').start; + const left = map.map(seam - 1e-6), right = map.map(seam); + assert.ok(Math.abs(left - right) < 1e-3, + `continuous at the shared seam (${left.toFixed(4)} vs ${right.toFixed(4)})`); +}); + console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); From 416a483bd43d7717cf5136c86c8035a7816ae692 Mon Sep 17 00:00:00 2001 From: Kilo Code Date: Mon, 12 Oct 2026 16:00:51 +0000 Subject: [PATCH 14/15] refactor(sync): split the per-section map/apply methods, guard nonpositive slopes (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeFactor flagged two Complex Methods newly added by this PR — _buildPiecewiseMapPure and TempoPerSectionCmd.exec. Extract each into single-purpose helpers with no behavior change: - _buildPiecewiseMapPure -> _proposalAcceptedPure + _enforceRunContinuityPure (_reconcileRunSeamsPure / _pinRunToIdentityPure) + _piecewiseAtPure. - TempoPerSectionCmd.exec -> _mapTimed (_isSkipped / _mappedSustain / _mappedSpanEnd), which validates the whole mapping before any write. While extracting helpers, a proposal with a nonpositive slope is now treated as inapplicable (design L78-80): accepting one could bake a flat or backwards section and reverse event order on apply. Add apply-path tests (exact skip preservation, span mapping, undo/redo, a surfaced both-sided conflict) and map tests for the slope guard and seam monotonicity. --- src/section-sync.js | 200 +++++++++++++++---------------- src/tempo.js | 96 +++++++++------ tests/per_section_apply.test.mjs | 119 ++++++++++++++++++ tests/section_sync.test.mjs | 26 ++++ 4 files changed, 303 insertions(+), 138 deletions(-) create mode 100644 tests/per_section_apply.test.mjs diff --git a/src/section-sync.js b/src/section-sync.js index 1038c85..8287412 100644 --- a/src/section-sync.js +++ b/src/section-sync.js @@ -603,10 +603,101 @@ export function _alignSectionsPure(params) { }; } +/** + * A proposal is applicable when the engine marked it ok, it clears the + * confidence floor, and its affine line is finite with a strictly positive + * slope. A nonpositive slope would make the map non-monotone — event order + * could reverse — so it is treated as inapplicable and its section skipped, + * matching the design's rejection rule (PER-SECTION-SYNC-DESIGN.md L78-80). + */ +function _proposalAcceptedPure(p, confidenceFloor) { + return Boolean(p.ok) + && p.confidence >= confidenceFloor + && Number.isFinite(p.slope) && p.slope > 0 + && Number.isFinite(p.intercept); +} + +// Reconcile every interior seam of one accepted run in place: shift each later +// section's phase so its line meets the previous section's mapped edge at the +// shared boundary. Every section keeps its own slope (its tempo); only phase +// moves, and a constant shift preserves the run's already-reconciled interior. +// The fit noise this removes — independently fitted sections disagreeing by a +// few ms at their seam — is not a conflict. +function _reconcileRunSeamsPure(temp, runStart, runEnd) { + for (let k = runStart; k < runEnd; k++) { + const seam = temp[k].end; + const left = temp[k].slope * seam + temp[k].intercept; + for (let j = k + 1; j <= runEnd; j++) { + temp[j].intercept += left - (temp[j].slope * seam + temp[j].intercept); + } + } +} + +// Pin one accepted run to identity where it abuts retained content — a skipped +// neighbour or a domain edge — so a skipped interval's hard W(t)=t holds at the +// shared seam. A run pinned at BOTH ends must be identity at both; a slope != 1 +// run cannot be, which is a conflict we expose (never demote silently). Being +// first or last in the list is NOT itself a pin — a lone accepted section +// covering the whole chart keeps its uniform affine map. +function _pinRunToIdentityPure(temp, runStart, runEnd, domain, conflicts) { + const first = temp[runStart]; + const last = temp[runEnd]; + const leftPinned = (runStart > 0 && !temp[runStart - 1].accepted) + || first.start > domain.start + EPS; + const rightPinned = (runEnd < temp.length - 1 && !temp[runEnd + 1].accepted) + || last.end < domain.end - EPS; + if (!leftPinned && !rightPinned) return; + const anchor = leftPinned ? first.start : last.end; + const at = leftPinned + ? first.slope * first.start + first.intercept + : last.slope * last.end + last.intercept; + for (let k = runStart; k <= runEnd; k++) temp[k].intercept += anchor - at; + if (!leftPinned || !rightPinned) return; + const atRight = last.slope * last.end + last.intercept; + if (Math.abs(atRight - last.end) <= 1e-6) return; + conflicts.push({ start: first.start, end: last.end, reason: 'identity-conflict' }); + for (let k = runStart; k <= runEnd; k++) { + temp[k].accepted = false; + temp[k].slope = 1; + temp[k].intercept = 0; + } +} + +// Walk the accepted runs of `temp` in order, reconciling interior seams and +// pinning each run to identity at any retained-content border. Mutates `temp` +// in place; irreducible identity conflicts are appended to `conflicts`. +function _enforceRunContinuityPure(temp, domain, conflicts) { + let runStart = 0; + while (runStart < temp.length) { + if (!temp[runStart].accepted) { runStart++; continue; } + let runEnd = runStart; + while (runEnd + 1 < temp.length && temp[runEnd + 1].accepted) runEnd++; + _reconcileRunSeamsPure(temp, runStart, runEnd); + _pinRunToIdentityPure(temp, runStart, runEnd, domain, conflicts); + runStart = runEnd + 1; + } +} + +// Evaluate the piecewise map at `t`: the containing interval's affine line, the +// first line below the domain and the last line above it. Intervals are sorted +// and contiguous, so the first one whose end is past `t` contains it; a point on +// a shared seam falls to the interval that starts there (both agree when the +// seam was reconciled). +function _piecewiseAtPure(intervals, t) { + if (!Number.isFinite(t) || intervals.length === 0) return t; + const first = intervals[0]; + if (t <= first.start + 1e-9) return first.slope * t + first.intercept; + for (const iv of intervals) { + if (t < iv.end - 1e-9) return iv.slope * t + iv.intercept; + } + const last = intervals[intervals.length - 1]; + return last.slope * t + last.intercept; +} + /** * Build a piecewise map W(t) from accepted proposals for the given domain. * For each section interval [start,end) that is accepted (confidence >= floor), - * the affine map is slope*x + intercept. Skipped intervals map as identity W(t)=t. + * the affine map is slope*x + intercept; skipped intervals map as identity. * Continuity is enforced per accepted RUN: an interior seam between two accepted * sections is reconciled to one target anchor (the later line's phase is shifted * to meet the earlier one), so independently fitted sections form one continuous @@ -620,122 +711,29 @@ export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7) if (!domain || !Number.isFinite(domain.start) || !Number.isFinite(domain.end)) { return { ok: false, reason: 'bad-domain' }; } - // Build intervals in order - const intervals = []; const sorted = [...proposals].sort((a, b) => { if (a.start < b.start - EPS) return -1; if (a.start > b.start + EPS) return 1; return 0; }); - // First pass: determine acceptance - const temp = []; - for (let i = 0; i < sorted.length; i++) { - const p = sorted[i]; - const accepted = p.ok && p.confidence >= confidenceFloor && Number.isFinite(p.slope) && Number.isFinite(p.intercept); - temp.push({ + const intervals = sorted.map((p) => { + const accepted = _proposalAcceptedPure(p, confidenceFloor); + return { start: p.start, end: p.end, slope: accepted ? p.slope : 1, intercept: accepted ? p.intercept : 0, accepted, - }); - } - // Enforce continuity. Adjacent accepted sections are fitted independently, - // so their lines rarely agree at a shared boundary; reconcile each interior - // seam by shifting the later line's phase to meet the earlier line's mapped - // edge. Every section keeps its own slope (its tempo); only phase moves, and - // a constant shift preserves the run's already-reconciled interior. The fit - // noise this removes is not a conflict. A skipped interval then imposes - // W(t)=t throughout, both edges included, so an accepted RUN abutting one is - // pinned to identity at that shared seam. A run pinned at BOTH ends must be - // identity at both; a slope != 1 run cannot be, which is a conflict we - // expose, never demote silently. Being first or last in the list is NOT - // itself a pin — a lone accepted section covering the whole chart keeps its - // uniform affine map. Retained content bordering the domain is a pin, and so - // is a skipped neighbour. + }; + }); const conflicts = []; - let runStart = 0; - while (runStart < temp.length) { - if (!temp[runStart].accepted) { runStart++; continue; } - let runEnd = runStart; - while (runEnd + 1 < temp.length && temp[runEnd + 1].accepted) runEnd++; - // One continuous line per accepted run: pin each later section to the - // mapped edge of the one before it at their shared seam. - for (let k = runStart; k < runEnd; k++) { - const seam = temp[k].end; - const left = temp[k].slope * seam + temp[k].intercept; - for (let j = k + 1; j <= runEnd; j++) { - temp[j].intercept += left - (temp[j].slope * seam + temp[j].intercept); - } - } - const first = temp[runStart]; - const last = temp[runEnd]; - const leftPinned = (runStart > 0 && !temp[runStart - 1].accepted) - || first.start > domain.start + EPS; - const rightPinned = (runEnd < temp.length - 1 && !temp[runEnd + 1].accepted) - || last.end < domain.end - EPS; - if (leftPinned || rightPinned) { - const anchor = leftPinned ? first.start : last.end; - const at = leftPinned - ? first.slope * first.start + first.intercept - : last.slope * last.end + last.intercept; - for (let k = runStart; k <= runEnd; k++) temp[k].intercept += anchor - at; - if (leftPinned && rightPinned) { - const atRight = last.slope * last.end + last.intercept; - if (Math.abs(atRight - last.end) > 1e-6) { - conflicts.push({ start: first.start, end: last.end, reason: 'identity-conflict' }); - for (let k = runStart; k <= runEnd; k++) { - temp[k].accepted = false; - temp[k].slope = 1; - temp[k].intercept = 0; - } - } - } - } - runStart = runEnd + 1; - } - for (let i = 0; i < temp.length; i++) { - intervals.push(temp[i]); - } + _enforceRunContinuityPure(intervals, domain, conflicts); return { ok: true, domain, intervals, conflicts, - map: (t) => { - if (!Number.isFinite(t)) return t; - // Find the interval containing t - for (let j = 0; j < intervals.length; j++) { - const iv = intervals[j]; - if (t < iv.start - 1e-9) continue; - const isLast = j === intervals.length - 1; - // For interior points, t < iv.end means in this interval - if (t < iv.end - 1e-9) { - return iv.slope * t + iv.intercept; - } - // At boundary t >= iv.end-1e-9 and t <= iv.end+1e-9 - if (Math.abs(t - iv.end) < 1e-9) { - // Boundary goes to the next interval if it starts at this point, else current - if (!isLast && Math.abs(t - intervals[j+1].start) < 1e-9) { - continue; // let next interval handle it - } - return iv.slope * t + iv.intercept; - } - if (isLast && t <= domain.end + 1e-9) { - return iv.slope * t + iv.intercept; - } - } - // If t is before first interval start, use first interval's map if it starts at domain start? - if (intervals.length > 0 && t <= intervals[0].start + 1e-9) { - return intervals[0].slope * t + intervals[0].intercept; - } - // If t is after all, use last - if (intervals.length > 0) { - const last = intervals[intervals.length - 1]; - return last.slope * t + last.intercept; - } - return t; - }, + map: (t) => _piecewiseAtPure(intervals, t), }; } /* @pure:section-sync:end */ diff --git a/src/tempo.js b/src/tempo.js index 5888aa9..b8c4d65 100644 --- a/src/tempo.js +++ b/src/tempo.js @@ -4000,48 +4000,70 @@ export class TempoPerSectionCmd { _liftAllBeats(this.oldBeats); S.beats = this.newBeats.map(b => ({ ...b })); if (this.mapResult && this.mapResult.ok) { - _eachTimed((o, tf, endKind) => { - if (typeof o[tf] !== 'number') return; - const tOrig = o[tf]; - let skip = false; - const domEnd = this.mapResult.domain ? this.mapResult.domain.end : tOrig; - for (let i = 0; i < this.proposals.length; i++) { - const p = this.proposals[i]; - const inInterval = tOrig >= p.start - 1e-9 && (tOrig < p.end - 1e-9 || (tOrig <= p.end + 1e-9 && p.end >= domEnd - 1e-9)); - if (inInterval) { - skip = !(p.ok && p.confidence >= this.confidenceFloor); - break; - } - } - if (skip) return; - const nt = this.mapResult.map(tOrig); - if (!(typeof nt === 'number' && isFinite(nt))) return; - let mappedSustain = null; - let mappedEndTime = null; - if (endKind === 'sustain') { - if (typeof o.sustain === 'number' && o.sustain > 0) { - const ntEnd = this.mapResult.map(tOrig + o.sustain); - if (!(typeof ntEnd === 'number' && isFinite(ntEnd))) return; - mappedSustain = _r3(ntEnd - nt); - if (mappedSustain <= 0) return; - } - } else if (endKind === 'span') { - if (typeof o.end_time === 'number') { - const ntEnd = this.mapResult.map(o.end_time); - if (!(typeof ntEnd === 'number' && isFinite(ntEnd))) return; - if (ntEnd < nt) return; - mappedEndTime = _r3(ntEnd); - } - } - o[tf] = _r3(nt); - if (mappedSustain !== null) o.sustain = mappedSustain; - if (mappedEndTime !== null) o.end_time = mappedEndTime; - }); + _eachTimed((o, tf, endKind) => this._mapTimed(o, tf, endKind)); } host.loopReprojectFromBeats(S.beats); host.renderLoopStrip(); host.updateLoopIn3DBtn(); } + + // True when `tOrig` — an object's ORIGINAL onset — falls inside a section + // whose proposal was not accepted. Such an object keeps every timing field + // exactly (its span endpoint included), per the design's skip guarantee. + _isSkipped(tOrig) { + const domEnd = this.mapResult.domain ? this.mapResult.domain.end : tOrig; + for (let i = 0; i < this.proposals.length; i++) { + const p = this.proposals[i]; + const inInterval = tOrig >= p.start - 1e-9 + && (tOrig < p.end - 1e-9 || (tOrig <= p.end + 1e-9 && p.end >= domEnd - 1e-9)); + if (inInterval) return !(p.ok && p.confidence >= this.confidenceFloor); + } + return false; + } + + // Mapped sustain for a note starting at mapped `nt`; null when the mapped + // end is non-finite or the mapped duration is not strictly positive. The + // design rejects a zero/negative result rather than clamping or truncating + // a span that crosses into a skipped (identity) region. + _mappedSustain(o, tOrig, nt) { + const ntEnd = this.mapResult.map(tOrig + o.sustain); + if (!(typeof ntEnd === 'number' && isFinite(ntEnd))) return null; + const mapped = _r3(ntEnd - nt); + return mapped > 0 ? mapped : null; + } + + // Mapped end_time for a span starting at mapped `nt`; null when the mapped + // end is non-finite or lands before the mapped start. + _mappedSpanEnd(o, nt) { + const ntEnd = this.mapResult.map(o.end_time); + if (!(typeof ntEnd === 'number' && isFinite(ntEnd))) return null; + if (ntEnd < nt) return null; + return _r3(ntEnd); + } + + // Map one timed object in place. A skipped object is left untouched; a + // rejected sustain/span (or non-finite mapped start) refuses the whole + // object so no field is ever written from a partial mapping. + _mapTimed(o, tf, endKind) { + if (typeof o[tf] !== 'number') return; + const tOrig = o[tf]; + if (this._isSkipped(tOrig)) return; + const nt = this.mapResult.map(tOrig); + if (!(typeof nt === 'number' && isFinite(nt))) return; + let mappedSustain = null; + let mappedEndTime = null; + if (endKind === 'sustain' && typeof o.sustain === 'number' && o.sustain > 0) { + mappedSustain = this._mappedSustain(o, tOrig, nt); + if (mappedSustain === null) return; + } else if (endKind === 'span' && typeof o.end_time === 'number') { + mappedEndTime = this._mappedSpanEnd(o, nt); + if (mappedEndTime === null) return; + } + o[tf] = _r3(nt); + if (mappedSustain !== null) o.sustain = mappedSustain; + if (mappedEndTime !== null) o.end_time = mappedEndTime; + } + rollback() { S.beats = this.oldBeats.map(b => ({ ...b })); for (const [o, tf, sec, endVal, endKind] of (this.preTimes || [])) { diff --git a/tests/per_section_apply.test.mjs b/tests/per_section_apply.test.mjs new file mode 100644 index 0000000..a0a8dcc --- /dev/null +++ b/tests/per_section_apply.test.mjs @@ -0,0 +1,119 @@ +/* + * Per-section sync APPLY path (#25): baking the piecewise map into note, span, + * drum-hit and beat seconds, with exact skip preservation and an undoable, + * symmetric command. + * + * The map builder itself is unit-tested in section_sync.test.mjs; this pins the + * apply contract from PER-SECTION-SYNC-DESIGN.md that a pure builder test can't + * reach: + * • an object starting in a skipped section keeps every timing field exactly + * (its sustain/span endpoint included) while its neighbours are baked + * (design L130-136); + * • a both-sided identity-pinned run blocks Apply with a surfaced conflict + * rather than a silent demotion (design L90-98, L143-144); + * • exec → undo → redo is symmetric — exact rollback, reproduced redo. + * + * Run: node tests/per_section_apply.test.mjs + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EditHistory } from '../src/history.js'; +import { seedState } from './_history_env.mjs'; +import { _applyPerSectionSyncPure } from '../src/tempo.js'; +import { _buildPiecewiseMapPure } from '../src/section-sync.js'; + +function accept(start, end, slope, intercept) { + return { start, end, slope, intercept, ok: true, confidence: 0.9 }; +} +function skip(start, end) { + return { start, end, slope: 1, intercept: 0, ok: false, confidence: 0 }; +} + +// A 0..30 s grid and three notes spread across a skipped leading section and an +// accepted run, so skip preservation and mapping are both observable at once. +function scenario() { + const s = seedState(); + s.beats = []; + for (let t = 0; t <= 30; t += 5) { + s.beats.push({ time: t, measure: t % 20 === 0 ? (t / 20) + 1 : -1, den: 4 }); + } + s.sections = []; + s.arrangements = [{ + notes: [ + { time: 5, sustain: 3, midi: 60 }, // skipped section — must not move + { time: 12, sustain: 2, midi: 62 }, // accepted section + { time: 22, sustain: 1, midi: 64 }, // accepted section + ], + chords: [], anchors: [], anchors_user: [], handshapes: [], phrases: [], + }]; + s.drumTab = { hits: [{ t: 7 }] }; + s.history = new EditHistory(); + return s; +} + +const times = (arr, key) => arr.map((o) => o[key]); + +test('apply bakes accepted sections and leaves skipped ones bit-for-bit intact', () => { + const s = scenario(); + const props = [skip(0, 10), accept(10, 30, 2, 0)]; + const map = _buildPiecewiseMapPure(props, { start: 0, end: 30 }); + assert.deepStrictEqual(map.conflicts, []); + // The run abuts a skipped section, so it is pinned to identity at the seam. + assert.strictEqual(map.map(10), 10); + assert.strictEqual(map.map(20), 30); + + const preNote = { ...s.arrangements[0].notes[0] }; + const preHit = { ...s.drumTab.hits[0] }; + const res = _applyPerSectionSyncPure(map, props, 0.7); + assert.strictEqual(res.applied, true); + + const notes = s.arrangements[0].notes; + assert.strictEqual(notes[0].time, preNote.time, 'skipped note onset untouched'); + assert.strictEqual(notes[0].sustain, preNote.sustain, 'skipped note sustain untouched'); + assert.strictEqual(notes[1].time, 14, 'accepted note onset mapped'); + assert.strictEqual(notes[1].sustain, 4, 'sustain derived from the mapped endpoints'); + assert.strictEqual(notes[2].time, 34, 'later accepted note mapped'); + assert.strictEqual(s.drumTab.hits[0].t, preHit.t, 'skipped drum hit untouched'); + + const bt = times(s.beats, 'time'); + assert.ok(bt.every((t, i) => i === 0 || t > bt[i - 1]), `grid strictly increasing: ${bt}`); +}); + +test('apply maps span endpoints and round-trips through undo then redo', () => { + const s = scenario(); + s.arrangements[0].handshapes = [{ start_time: 15, end_time: 25 }]; + const props = [skip(0, 10), accept(10, 30, 2, 0)]; + const map = _buildPiecewiseMapPure(props, { start: 0, end: 30 }); + const before = { + beats: times(s.beats, 'time'), + notes: times(s.arrangements[0].notes, 'time'), + hit: s.drumTab.hits[0].t, + }; + + assert.strictEqual(_applyPerSectionSyncPure(map, props, 0.7).applied, true); + assert.strictEqual(s.arrangements[0].handshapes[0].start_time, 20, 'span start mapped'); + assert.strictEqual(s.arrangements[0].handshapes[0].end_time, 40, 'span end mapped'); + + s.history.doUndo(); + assert.deepStrictEqual(times(s.arrangements[0].notes, 'time'), before.notes, 'undo restores note onsets'); + assert.deepStrictEqual(times(s.beats, 'time'), before.beats, 'undo restores the grid'); + assert.strictEqual(s.drumTab.hits[0].t, before.hit, 'undo restores the skipped hit'); + + s.history.doRedo(); + assert.strictEqual(s.arrangements[0].notes[1].time, 14, 'redo reproduces the apply'); + assert.strictEqual(s.arrangements[0].handshapes[0].end_time, 40, 'redo reproduces the mapped span'); +}); + +test('a both-sided identity-pinned run blocks Apply with a surfaced conflict', () => { + const s = scenario(); + const props = [skip(0, 10), accept(10, 20, 2, 0), skip(20, 30)]; + const map = _buildPiecewiseMapPure(props, { start: 0, end: 30 }); + assert.strictEqual(map.conflicts.length, 1); + assert.strictEqual(map.conflicts[0].reason, 'identity-conflict'); + + const res = _applyPerSectionSyncPure(map, props, 0.7); + assert.strictEqual(res.applied, false); + assert.strictEqual(res.reason, 'conflict'); + assert.strictEqual(res.conflicts.length, 1); + assert.strictEqual(s.arrangements[0].notes[1].time, 12, 'nothing was baked when Apply was refused'); +}); diff --git a/tests/section_sync.test.mjs b/tests/section_sync.test.mjs index 9a1241e..57906f8 100644 --- a/tests/section_sync.test.mjs +++ b/tests/section_sync.test.mjs @@ -433,5 +433,31 @@ t('piecewise: the engine\'s own two-section fit reconciles its seam, never block `continuous at the shared seam (${left.toFixed(4)} vs ${right.toFixed(4)})`); }); +t('piecewise: a nonpositive-slope proposal is skipped, never used to reverse order', () => { + // The design makes a nonpositive slope inapplicable (L78-80): accepting it + // would bake a flat or backwards section and reverse event order on apply. + const res = _buildPiecewiseMapPure( + [acceptedIv(0, 15, 0, 5), acceptedIv(15, 30, 2, 0)], { start: 0, end: 30 }); + assert.deepStrictEqual(res.conflicts, []); + assert.strictEqual(res.map(10), 10, 'the slope-0 section falls back to identity, not the constant 5'); + assert.strictEqual(res.map(15), 15, 'the accepted run is pinned to identity at the skipped seam'); + assert.strictEqual(res.map(30), 45, 'and keeps its slope past the seam'); +}); + +t('piecewise: a reconciled seam of differing slopes stays strictly increasing', () => { + // Independently fitted sections with different tempos are reconciled to one + // continuous line; with positive slopes that guarantees no event-order + // reversal at the seam. + const res = _buildPiecewiseMapPure( + [acceptedIv(0, 15, 1.2, 0), acceptedIv(15, 30, 0.8, 0)], { start: 0, end: 30 }); + assert.deepStrictEqual(res.conflicts, []); + assert.ok(Math.abs(res.map(15) - 18) < 1e-9, `seam target ${res.map(15)}`); + const xs = [0, 5, 10, 14.999, 15, 15.001, 20, 25, 30]; + const ys = xs.map((x) => res.map(x)); + for (let i = 1; i < ys.length; i++) { + assert.ok(ys[i] > ys[i - 1], `strictly increasing at ${xs[i]} (${ys[i - 1]} -> ${ys[i]})`); + } +}); + console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); From c30fb2d2daaa5ea32fa97fcd4ad747663aa87762 Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:12:00 +0000 Subject: [PATCH 15/15] fix(sync): apply-side skip must mirror the builder's acceptance (#25) --- src/tempo.js | 18 +++++++++++------- tests/per_section_apply.test.mjs | 27 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/tempo.js b/src/tempo.js index b8c4d65..8e46361 100644 --- a/src/tempo.js +++ b/src/tempo.js @@ -4008,15 +4008,19 @@ export class TempoPerSectionCmd { } // True when `tOrig` — an object's ORIGINAL onset — falls inside a section - // whose proposal was not accepted. Such an object keeps every timing field - // exactly (its span endpoint included), per the design's skip guarantee. + // the builder did not accept (low confidence, nonpositive/non-finite slope, + // or a conflict demotion). Such an object keeps every timing field exactly + // (its span endpoint included), per the design's skip guarantee. Reading the + // builder's own per-interval decision keeps the apply predicate from + // drifting from the map: a section mapped as identity is left untouched. _isSkipped(tOrig) { + const intervals = this.mapResult.intervals; + if (!Array.isArray(intervals)) return false; const domEnd = this.mapResult.domain ? this.mapResult.domain.end : tOrig; - for (let i = 0; i < this.proposals.length; i++) { - const p = this.proposals[i]; - const inInterval = tOrig >= p.start - 1e-9 - && (tOrig < p.end - 1e-9 || (tOrig <= p.end + 1e-9 && p.end >= domEnd - 1e-9)); - if (inInterval) return !(p.ok && p.confidence >= this.confidenceFloor); + for (const iv of intervals) { + const inInterval = tOrig >= iv.start - 1e-9 + && (tOrig < iv.end - 1e-9 || (tOrig <= iv.end + 1e-9 && iv.end >= domEnd - 1e-9)); + if (inInterval) return !iv.accepted; } return false; } diff --git a/tests/per_section_apply.test.mjs b/tests/per_section_apply.test.mjs index a0a8dcc..f75af4c 100644 --- a/tests/per_section_apply.test.mjs +++ b/tests/per_section_apply.test.mjs @@ -104,6 +104,33 @@ test('apply maps span endpoints and round-trips through undo then redo', () => { assert.strictEqual(s.arrangements[0].handshapes[0].end_time, 40, 'redo reproduces the mapped span'); }); +test('a nonpositive-slope proposal is skipped on apply, not only by the builder', () => { + const s = scenario(); + s.arrangements[0].notes = [ + { time: 8, sustain: 5, midi: 60 }, // section A: ok but slope 0 → inapplicable + { time: 15, sustain: 1, midi: 62 }, // accepted section + ]; + const props = [ + { start: 0, end: 10, slope: 0, intercept: 5, ok: true, confidence: 0.9 }, + accept(10, 30, 2, 0), + ]; + const map = _buildPiecewiseMapPure(props, { start: 0, end: 30 }); + assert.deepStrictEqual(map.conflicts, []); + // Slope 0 makes the proposal inapplicable, so section A maps as identity + // even though the neighbour's line would extrapolate past the seam. + assert.strictEqual(map.map(8), 8); + assert.strictEqual(map.map(13), 16); + + const pre = { ...s.arrangements[0].notes[0] }; + const res = _applyPerSectionSyncPure(map, props, 0.7); + assert.strictEqual(res.applied, true); + + const notes = s.arrangements[0].notes; + assert.strictEqual(notes[0].time, pre.time, 'inapplicable-slope note onset untouched'); + assert.strictEqual(notes[0].sustain, pre.sustain, 'sustain not recomputed from the accepted neighbour'); + assert.strictEqual(notes[1].time, 20, 'accepted neighbour still baked'); +}); + test('a both-sided identity-pinned run blocks Apply with a surfaced conflict', () => { const s = scenario(); const props = [skip(0, 10), accept(10, 20, 2, 0), skip(20, 30)];