Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/history.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@
editorApplySync, editorHideSyncDialog, editorSyncTempo, editorSyncUpdateFactor,
getTabBPM
} from './sync-tempo.js';
import { applyPerSectionSync, _applyPerSectionSyncPure } from './tempo.js';
import { _buildPiecewiseMapPure } from './section-sync.js';
import {
getMousePos, onDblClick, onMouseDown, onMouseMove, onMouseUp, onWheel
} from './mouse.js';
Expand Down Expand Up @@ -737,6 +739,9 @@
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;
Expand Down Expand Up @@ -2141,7 +2146,7 @@
// the same save path as the Save button (in-place sloppak write, not the
// heavy create-mode build).
if (S.sessionId) {
try { await saveCDLC(); } catch (e) { /* surfaced via setStatus */ }

Check warning on line 2149 in src/main.js

View workflow job for this annotation

GitHub Actions / lint

'e' is defined but never used. Allowed unused caught errors must match /^_/u
}
// Capture where we are so the return trip lands on the same spot.
const returnCtx = {
Expand Down
134 changes: 134 additions & 0 deletions src/section-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -602,4 +602,138 @@ export function _alignSectionsPure(params) {
proposals,
};
}

/**
* 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.
* 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) {
Comment thread
pullfrog[bot] marked this conversation as resolved.
Resolved
if (!domain || !Number.isFinite(domain.start) || !Number.isFinite(domain.end)) {
return { ok: false, reason: 'bad-domain' };
}
const sorted = [...proposals].sort((a, b) => {
if (a.start < b.start - EPS) return -1;
if (a.start > b.start + EPS) return 1;
return 0;
});
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,
};
});
const conflicts = [];
_enforceRunContinuityPure(intervals, domain, conflicts);
return {
ok: true,
domain,
intervals,
conflicts,
map: (t) => _piecewiseAtPure(intervals, t),
};
}
/* @pure:section-sync:end */
129 changes: 129 additions & 0 deletions src/tempo.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
_zonesGet, _zonesSelect, _zonesShow, _zonesStripHit, _zonesZoneAt,
} from './tempo-zones.js';
import { beatOf, timeOf } from './beats.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';
Expand Down Expand Up @@ -1704,48 +1705,48 @@
}
menu.innerHTML = html;
menu.querySelectorAll('[data-action]').forEach(btn => {
btn.onclick = () => {
host.hideContextMenu();
const a = btn.dataset.action;
if (a === 'bar1here') { _tempoSetBar1Here(); return; }
if (a === 'pickup') { _tempoPromptPickup(); return; }
if (a === 'holdbar') {
const mm = S.beats[onPole] ? S.beats[onPole].measure : -1;
if (editorToggleHoldBar(mm)) {
setStatus(_tempoHoldMeasures().has(mm)
? `Bar ${mm} held (fermata) — excluded from tempo stats and suggestions.`
: `Bar ${mm} hold removed.`);
}
return;
}
if (a === 'grouping') { _tempoPromptGrouping(onPole); return; }
if (a === 'feelhalf' || a === 'feeldouble' || a === 'feelstraight') {
const mm = S.beats[onPole] ? S.beats[onPole].measure : -1;
const ratio = a === 'feelhalf' ? 0.5 : a === 'feeldouble' ? 2 : 1;
if (editorSetFeelFromBar(mm, ratio)) {
const nowFeel = (S.tempoMarks || []).some(k => k.kind === 'feel' && k.measure === mm);
setStatus(!nowFeel ? `Bar ${mm}: feel mark cleared.`
: ratio === 0.5 ? `Half-time feel from bar ${mm} — tempo unchanged, the felt pulse halves.`
: ratio === 2 ? `Double-time feel from bar ${mm} — tempo unchanged, the felt pulse doubles.`
: `Straight time from bar ${mm}.`);
host.draw();
}
return;
}
if (a === 'ramp-range') { editorRampRange(); return; }
if (a === 'ramp-fit') { editorRampFitFromDrift(); return; }
if (a === 'half-range') _tempoHalveRange();
else if (a === 'double-range') _tempoDoubleRange();
else if (a === 'flatten-range') _tempoFlattenRange();
else if (a === 'delete-multi') _tempoDeleteSelection();
else if (a === 'delete') _tempoDeleteSyncPoint(onPole);
else if (a === 'togglelock') _editorToggleSyncLock();
else if (a === 'insert') _tempoInsertSyncPoint(xToTime(x));
else if (a === 'bpmedit') _tempoPromptMeasureBpm(onPole);
else if (a === 'tsedit') _tempoPromptTimeSignature(onPole);
else if (a === 'tsplus') _tempoSetBeatsPerMeasure(onPole, _tempoMeasureBeatCount(onPole) + 1);
else if (a === 'tsminus') _tempoSetBeatsPerMeasure(onPole, _tempoMeasureBeatCount(onPole) - 1);
};

Check notice on line 1749 in src/tempo.js

View check run for this annotation

codefactor.io / CodeFactor

src/tempo.js#L1708-L1749

Complex Method
});
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
Expand Down Expand Up @@ -3962,3 +3963,131 @@
this._syncOffsetInput();
}
}

export function _applyPerSectionSyncPure(mapResult, proposals, confidenceFloor = 0.7) {
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);
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 { applied: 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) => 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
// 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 (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;
}

// 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 || [])) {
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);
}
Loading
Loading