From b6629dc4dff6581307117c68c398a17cfd7a76c4 Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:30:51 +0000 Subject: [PATCH 1/4] feat(sync): per-section sync results UI with confidence badges (#26) Add a Tempo/Grid menu item that opens a review list of the #24 per-section analysis: one row per section with a confidence badge, the scale factor/shift it would bake, and an Apply/Skip toggle. Low-confidence sections default to skip and are never applied unless explicitly selected; failed sections are not selectable. Apply all high-confidence re-selects just the trusted sections, and Apply bakes the accepted map through the same applyPerSectionSync path as #25, so preview and commit are one map. A single-section song collapses to the uniform model. The pure decision layer (_sectionSyncPlanPure, _proposalAcceptedPure selected override, SECTION_SYNC_CONFIDENCE_FLOOR) lives in section-sync.js; section-sync-ui.js only gathers onsets, renders and hands the plan over. --- CHANGELOG.md | 11 ++ screen.html | 27 +++++ src/main.js | 10 ++ src/menu-bar.js | 1 + src/section-sync-ui.js | 181 ++++++++++++++++++++++++++++++ src/section-sync.js | 118 +++++++++++++++++++- tests/section_sync_ui.test.mjs | 198 +++++++++++++++++++++++++++++++++ 7 files changed, 544 insertions(+), 2 deletions(-) create mode 100644 src/section-sync-ui.js create mode 100644 tests/section_sync_ui.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 92016f3..e00e03c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 identity at both ends by skipped neighbours is still reported as a conflict. API only — no UI wiring yet (#26). +- **Per-section sync review UI (#26): apply or skip each section.** A new + "Per-section sync (review sections)…" item in the Tempo/Grid menu opens a + review list: one row per section with its confidence badge, the scale + factor/shift it would apply, and an Apply/Skip toggle. Low-confidence sections + default to skip and are never applied unless the user explicitly selects them; + failed sections are not selectable. "Apply all high-confidence" re-selects just + the trusted sections and bakes the accepted map through the same + `applyPerSectionSync` path as #25, so the preview and the committed edit are + one map. A single-section song collapses to the uniform model (one full-domain + row). + - **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/screen.html b/screen.html index 27abca7..2de7994 100644 --- a/screen.html +++ b/screen.html @@ -881,6 +881,33 @@ + + + diff --git a/src/main.js b/src/main.js index 5cf1ad2..5d1fdad 100644 --- a/src/main.js +++ b/src/main.js @@ -88,6 +88,10 @@ import { } from './sync-tempo.js'; import { applyPerSectionSync, _applyPerSectionSyncPure } from './tempo.js'; import { _buildPiecewiseMapPure } from './section-sync.js'; +import { + editorApplySectionSync, editorHideSectionSync, editorShowSectionSync, + editorToggleSectionSync +} from './section-sync-ui.js'; import { getMousePos, onDblClick, onMouseDown, onMouseMove, onMouseUp, onWheel } from './mouse.js'; @@ -743,6 +747,12 @@ window.applyPerSectionSync = applyPerSectionSync; window._applyPerSectionSyncPure = _applyPerSectionSyncPure; window._buildPiecewiseMapPure = _buildPiecewiseMapPure; +// Per-section sync review (#26) — section-sync-ui.js owns the logic. +window.editorShowSectionSync = editorShowSectionSync; +window.editorHideSectionSync = editorHideSectionSync; +window.editorToggleSectionSync = editorToggleSectionSync; +window.editorApplySectionSync = editorApplySectionSync; + // Tab preview (tab-preview.js owns the logic; HTML calls these by name). window.editorShowTabPreview = _editorShowTabPreview; window.editorRefreshTabPreview = editorRefreshTabPreview; diff --git a/src/menu-bar.js b/src/menu-bar.js index 896613c..90bec6e 100644 --- a/src/menu-bar.js +++ b/src/menu-bar.js @@ -276,6 +276,7 @@ export const EDITOR_MENUS = Object.freeze([ { sep: true }, { cmd: 'songFit' }, { label: 'Sync tempo to audio', fn: 'editorSyncTempo', audioOnly: true }, + { label: 'Per-section sync (review sections)…', fn: 'editorShowSectionSync', audioOnly: true }, { label: 'Scan for tempo zones…', fn: 'editorScanTempoZones', audioOnly: true }, { label: 'Apply rough map from tempo zones', fn: 'editorApplyTempoZones', audioOnly: true }, { label: 'Map Health (grid-vs-recording drift)', fn: 'editorToggleMapHealth', audioOnly: true }, diff --git a/src/section-sync-ui.js b/src/section-sync-ui.js new file mode 100644 index 0000000..67aa248 --- /dev/null +++ b/src/section-sync-ui.js @@ -0,0 +1,181 @@ +// Per-section sync results UI (#26) — sub-issue 5/5 of per-section syncing (#8). +// +// Presents the #24 analysis one section at a time: a row with the section name, +// its confidence badge, and whether the section will be applied or skipped. +// Low-confidence sections default to SKIP and are never applied unless the user +// explicitly selects them; failed sections are not selectable at all. "Apply all +// high-confidence" re-selects just the trusted sections; "Apply" bakes the +// current selection through the same `applyPerSectionSync` path as #25, so a +// preview and the committed edit are one map. A single-section song collapses to +// the uniform model — one full-domain row. +// +// Pure decisions live in `section-sync.js` (`_sectionSyncPlanPure`); this module +// only gathers onsets, renders, and hands the plan to the apply path. The +// window.editor* entry points are attached by main.js (the repo convention). + +import { S } from './state.js'; +import { _ensureOnsetsShifted } from './audio.js'; +import { applyPerSectionSync } from './tempo.js'; +import { _alignSectionsPure, _sectionSyncPlanPure } from './section-sync.js'; +import { _editorEscHtml, _installModalKeyboard, setStatus } from './ui.js'; +import { host } from './host.js'; + +let _analysis = null; +let _decisions = null; // section index -> boolean (true = apply, false = skip) +let _prevFocus = null; +let _kbInstalled = false; + +const MODAL_ID = 'editor-section-sync-modal'; + +function _modal() { + return typeof document === 'undefined' ? null : document.getElementById(MODAL_ID); +} + +export function editorShowSectionSync() { + if (typeof document === 'undefined') return; + if (S.beats.length < 2) { setStatus('Need at least two beats to sync.'); return; } + if (!S.audioBuffer && !S.waveformPeaks) { + setStatus('Load audio before per-section sync.'); + return; + } + const onsets = _ensureOnsetsShifted() || []; + const analysis = _alignSectionsPure({ + sections: S.sections || [], + beats: S.beats, + source: { arrangements: S.arrangements, drumTab: S.drumTab }, + audioOnsets: onsets, + }); + if (!analysis.ok) { + setStatus(`Per-section sync unavailable (${analysis.reason}).`); + return; + } + _analysis = analysis; + _decisions = new Map(); + const modal = _modal(); + if (!modal) return; + _renderSectionSync(); + _prevFocus = document.activeElement; + modal.classList.remove('hidden'); + if (!_kbInstalled) { + _kbInstalled = true; + // The panel (firstElementChild) owns the focusables; the overlay root + // only needs to stay focusable so a backdrop click can be redirected in. + _installModalKeyboard(modal, modal.firstElementChild, editorHideSectionSync); + } + const applyBtn = document.getElementById('editor-section-sync-apply'); + if (applyBtn && applyBtn.focus) applyBtn.focus(); + setStatus('Review per-section sync results.'); +} + +export function editorHideSectionSync() { + const modal = _modal(); + if (modal) modal.classList.add('hidden'); + const prev = _prevFocus; + _prevFocus = null; + if (prev && prev.isConnected && prev.focus) prev.focus(); +} + +export function editorToggleSectionSync(index) { + if (!_analysis || !_decisions) return; + const row = _sectionSyncPlanPure(_analysis, _decisions) + .rows.find((r) => r.index === index); + if (!row) return; + if (!row.forceable) { + setStatus('That section has no usable alignment to apply.'); + return; + } + _decisions.set(index, !row.accepted); + _renderSectionSync(); +} + +export function editorApplySectionSync(allHighConfidence) { + if (!_analysis) return; + // "All high-confidence" is the default decision set: the confidence floor + // alone decides, i.e. low-confidence sections are skipped. + const decisions = allHighConfidence ? new Map() : _decisions; + const plan = _sectionSyncPlanPure(_analysis, decisions); + if (!plan.ok) { setStatus('Per-section sync analysis is unavailable.'); return; } + if (plan.conflicts.length) { + setStatus('Cannot apply: a skipped section conflicts with the fit.'); + return; + } + if (!plan.summary.applied) { setStatus('No sections selected to apply.'); return; } + const res = applyPerSectionSync({ + proposals: plan.proposals, + confidenceFloor: plan.confidenceFloor, + domain: plan.domain, + }); + if (!res || !res.applied) { + setStatus(`Per-section sync refused (${(res && res.reason) || 'unknown'}).`); + return; + } + const n = plan.summary.applied; + editorHideSectionSync(); + host.draw(); + setStatus(`Applied per-section sync to ${n} section${n === 1 ? '' : 's'}.`); +} + +// ── Rendering ────────────────────────────────────────────────────────────── + +function _tierBadge(row) { + const cls = row.tier === 'high' + ? 'bg-emerald-500/20 text-emerald-300' + : row.tier === 'low' + ? 'bg-amber-500/20 text-amber-300' + : 'bg-red-500/20 text-red-300'; + return `` + + `${row.confidencePct}%`; +} + +function _rowDetail(row) { + if (!row.accepted) { + return row.tier === 'high' ? 'skipped' + : row.tier === 'low' ? 'below threshold' : 'no match'; + } + const shift = row.shift >= 0 ? `+${row.shift.toFixed(2)}` : row.shift.toFixed(2); + return `×${row.factor.toFixed(3)} · ${shift}s`; +} + +function _sectionRowHtml(row) { + const toggle = row.forceable + ? `` + : '—'; + return '
' + + '' + + `${_editorEscHtml(row.label)}` + + `${_tierBadge(row)}` + + `${_rowDetail(row)}` + + `${toggle}
`; +} + +function _summaryText(plan) { + const s = plan.summary; + if (!s.sections) return 'No sections to sync.'; + const parts = [`${s.applied}/${s.sections} selected`]; + if (s.low) parts.push(`${s.low} low-confidence skipped`); + if (s.failed) parts.push(`${s.failed} unmatched`); + return parts.join(' · '); +} + +function _renderSectionSync() { + if (typeof document === 'undefined') return; + const plan = _sectionSyncPlanPure(_analysis, _decisions); + const rowsEl = document.getElementById('editor-section-sync-rows'); + if (rowsEl) rowsEl.innerHTML = plan.rows.map(_sectionRowHtml).join(''); + const summaryEl = document.getElementById('editor-section-sync-summary'); + if (summaryEl) summaryEl.textContent = _summaryText(plan); + const applyBtn = document.getElementById('editor-section-sync-apply'); + if (applyBtn) { + const n = plan.summary.applied; + applyBtn.disabled = n === 0; + applyBtn.textContent = n > 0 + ? `Apply ${n} section${n === 1 ? '' : 's'}` + : 'Apply selected'; + } + const highBtn = document.getElementById('editor-section-sync-apply-high'); + if (highBtn) highBtn.disabled = plan.summary.high === 0; +} diff --git a/src/section-sync.js b/src/section-sync.js index 8287412..1eeeac0 100644 --- a/src/section-sync.js +++ b/src/section-sync.js @@ -35,6 +35,14 @@ import { _detectTempoFromOnsetsPure } from './sync-tempo.js'; const EPS = 1e-9; +// The confidence a section must clear before it is trusted (applied) WITHOUT an +// explicit user choice (#26). The engine's own analysis floor is looser (0.5, +// so a low-confidence section still surfaces for review); applying/baking uses +// this stricter default so a "review and apply" run never silently carries a +// marginal section. The review UI may override it per section (see +// `_proposalAcceptedPure`'s `selected: true`), never for the whole run at once. +export const SECTION_SYNC_CONFIDENCE_FLOOR = 0.7; + function _clamp01(v) { return v < 0 ? 0 : (v > 1 ? 1 : v); } function _round4(v) { return Math.round(v * 1e4) / 1e4; } function _median(sortedOrRaw) { @@ -609,10 +617,18 @@ export function _alignSectionsPure(params) { * 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). + * + * An explicit `selected` override from the #26 review UI wins over the + * confidence floor: `selected: false` is a hard skip (never applied), and + * `selected: true` forces a low-confidence (but otherwise usable) section + * through. The affine-validity checks are NOT overridable — a section with no + * finite, positive-slope line can never be applied, forced or not. */ -function _proposalAcceptedPure(p, confidenceFloor) { +export function _proposalAcceptedPure(p, confidenceFloor) { + if (!p || p.selected === false) return false; + const forced = p.selected === true; return Boolean(p.ok) - && p.confidence >= confidenceFloor + && (forced || p.confidence >= confidenceFloor) && Number.isFinite(p.slope) && p.slope > 0 && Number.isFinite(p.intercept); } @@ -719,6 +735,7 @@ export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7) const intervals = sorted.map((p) => { const accepted = _proposalAcceptedPure(p, confidenceFloor); return { + index: p.index, start: p.start, end: p.end, slope: accepted ? p.slope : 1, @@ -736,4 +753,101 @@ export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7) map: (t) => _piecewiseAtPure(intervals, t), }; } + +/** + * A human label for a review row: the section's authored name, else its number, + * else a positional fallback. An unnamed region in a multi-region song is the + * explicit lead-in before the first marker; an unnamed single region is the + * whole song (the uniform case). + */ +function _sectionLabelPure(p, count) { + if (p.name) return p.name; + if (Number.isFinite(p.number) && p.number) return `Section ${p.number}`; + if (count > 1) return p.index === 0 ? 'Lead-in' : `Section ${p.index + 1}`; + return 'Full song'; +} + +/** + * The #26 review model: given an `_alignSectionsPure` result and the user's + * per-section decisions, build the map "Apply" would commit plus one row per + * section for the UI. `decisions` maps a section `index` to `true` (apply) or + * `false` (skip); an ABSENT index follows the confidence default, so a + * low-confidence section is never applied unless the user explicitly selects + * it — the "default never applies low confidence" acceptance rule. A section + * with no usable fit (`ok:false`) is not selectable at all. + * + * Returns `{ok, reason, domain, confidenceFloor, proposals, map, rows, + * conflicts, summary}`. `proposals` are the decisions folded in and are + * exactly what the apply path (`applyPerSectionSync`) takes, so the preview and + * the committed edit are the same map. + */ +export function _sectionSyncPlanPure(analysis, decisions, options = {}) { + const confidenceFloor = options.confidenceFloor != null + ? options.confidenceFloor : SECTION_SYNC_CONFIDENCE_FLOOR; + if (!analysis || !analysis.ok) { + return { + ok: false, + reason: analysis && analysis.reason ? analysis.reason : 'no-analysis', + domain: analysis ? analysis.domain : null, + confidenceFloor, + proposals: [], + map: null, + rows: [], + conflicts: [], + summary: { sections: 0, applied: 0, high: 0, low: 0, failed: 0 }, + }; + } + const overrides = decisions || {}; + const proposals = (analysis.proposals || []).map((p) => { + const over = overrides instanceof Map ? overrides.get(p.index) : overrides[p.index]; + return over === undefined ? p : { ...p, selected: !!over }; + }); + const map = _buildPiecewiseMapPure(proposals, analysis.domain, confidenceFloor); + const intervalByIndex = new Map((map.intervals || []).map((iv) => [iv.index, iv])); + const rows = proposals.map((p) => { + const iv = intervalByIndex.get(p.index); + const trusted = _proposalAcceptedPure({ ...p, selected: undefined }, confidenceFloor); + const accepted = Boolean(iv && iv.accepted); + const slope = accepted && Number.isFinite(iv.slope) ? iv.slope : 1; + const intercept = accepted && Number.isFinite(iv.intercept) ? iv.intercept : 0; + const confidence = _clamp01(p.confidence || 0); + return { + index: p.index, + name: p.name || '', + number: Number.isFinite(p.number) ? p.number : 0, + label: _sectionLabelPure(p, proposals.length), + start: p.start, + end: p.end, + confidence, + confidencePct: Math.round(confidence * 100), + reason: p.reason || null, + // high = trusted by the confidence floor, low = analysed but below + // it, failed = no usable fit. A low row can still be forced on (see + // _proposalAcceptedPure); a failed row is not selectable. + tier: trusted ? 'high' : (p.ok ? 'low' : 'failed'), + forceable: _proposalAcceptedPure({ ...p, selected: true }, confidenceFloor), + selected: accepted, + accepted, + factor: _round4(Number.isFinite(p.factor) ? p.factor : 1), + shift: _round4(slope * p.start + intercept - p.start), + }; + }); + return { + ok: true, + reason: null, + domain: analysis.domain, + confidenceFloor, + proposals, + map, + conflicts: map.conflicts || [], + rows, + summary: { + sections: rows.length, + applied: rows.filter((r) => r.accepted).length, + high: rows.filter((r) => r.tier === 'high').length, + low: rows.filter((r) => r.tier === 'low').length, + failed: rows.filter((r) => r.tier === 'failed').length, + }, + }; +} /* @pure:section-sync:end */ diff --git a/tests/section_sync_ui.test.mjs b/tests/section_sync_ui.test.mjs new file mode 100644 index 0000000..7e20b09 --- /dev/null +++ b/tests/section_sync_ui.test.mjs @@ -0,0 +1,198 @@ +/* + * Per-section sync REVIEW model (#26): the pure decision layer the review UI + * renders and applies. + * + * Pins the acceptance rules from PER-SECTION-SYNC-DESIGN.md L150-158 that a + * builder test alone can't reach: + * • a low-confidence section is skipped by DEFAULT — the default action never + * applies it (design's do-no-harm rule, #22); + * • explicitly selecting a low-confidence section applies it; deselecting a + * trusted one skips it; + * • a section with no usable fit is never selectable; + * • a single-section song collapses to one full-domain row; + * • the review plan is the SAME map `applyPerSectionSync` commits, so a + * skipped section's notes keep their exact seconds while selected ones bake. + * + * Run: node tests/section_sync_ui.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 { applyPerSectionSync } from '../src/tempo.js'; +import { + SECTION_SYNC_CONFIDENCE_FLOOR, _proposalAcceptedPure, _sectionSyncPlanPure, +} from '../src/section-sync.js'; + +function prop(index, start, end, opts = {}) { + const ok = opts.ok !== false; + const confidence = opts.confidence != null ? opts.confidence : 0.9; + return { + ok, + index, + name: opts.name !== undefined ? opts.name : `Part ${index}`, + number: opts.number !== undefined ? opts.number : index + 1, + start, + end, + reason: opts.reason || (ok && confidence >= SECTION_SYNC_CONFIDENCE_FLOOR ? null : 'low-confidence'), + confidence, + factor: opts.factor != null ? opts.factor : 2, + slope: opts.slope != null ? opts.slope : 0.5, + intercept: opts.intercept != null ? opts.intercept : 0, + }; +} + +function analysis(props) { + return { ok: true, reason: null, domain: { start: 0, end: 30 }, proposals: props }; +} + +test('the default decision applies trusted sections and skips low-confidence ones', () => { + const a = analysis([ + prop(0, 0, 10, { confidence: 0.9, name: 'Intro' }), + prop(1, 10, 20, { confidence: 0.3, name: 'Solo' }), + prop(2, 20, 30, { confidence: 0.8, name: 'Outro' }), + ]); + const plan = _sectionSyncPlanPure(a); + assert.strictEqual(plan.ok, true); + assert.strictEqual(plan.confidenceFloor, SECTION_SYNC_CONFIDENCE_FLOOR); + const [intro, solo, outro] = plan.rows; + assert.strictEqual(intro.tier, 'high'); + assert.strictEqual(intro.accepted, true, 'trusted section applies by default'); + assert.strictEqual(solo.tier, 'low'); + assert.strictEqual(solo.accepted, false, 'low-confidence section is skipped by default'); + assert.strictEqual(solo.selected, false); + assert.strictEqual(solo.forceable, true, 'a low-confidence section can be forced on'); + assert.strictEqual(outro.accepted, true); + assert.deepStrictEqual(plan.conflicts, []); +}); + +test('an explicit decision overrides the confidence default both ways', () => { + const a = analysis([ + prop(0, 0, 10, { confidence: 0.9 }), + prop(1, 10, 20, { confidence: 0.3 }), + ]); + // Force the low-confidence section on. + const forced = _sectionSyncPlanPure(a, new Map([[1, true]])); + assert.strictEqual(forced.rows[1].accepted, true, 'selected low-confidence section applies'); + assert.ok(forced.rows[1].tier === 'low', 'it stays labelled low even when forced'); + // Skip the trusted section. + const skipped = _sectionSyncPlanPure(a, new Map([[0, false]])); + assert.strictEqual(skipped.rows[0].accepted, false, 'deselected trusted section is skipped'); +}); + +test('a section with no usable fit is never selectable, even when forced', () => { + const a = analysis([ + prop(0, 0, 10, { ok: false, confidence: 0, name: 'Broken' }), + ]); + const plan = _sectionSyncPlanPure(a, new Map([[0, true]])); + const row = plan.rows[0]; + assert.strictEqual(row.tier, 'failed'); + assert.strictEqual(row.forceable, false); + assert.strictEqual(row.accepted, false, 'forcing a failed section does nothing'); + assert.strictEqual(plan.summary.applied, 0); +}); + +test('a single-section song collapses to one full-domain row', () => { + const a = analysis([prop(0, 0, 30, { confidence: 0.85, name: '', number: 0 })]); + const plan = _sectionSyncPlanPure(a); + assert.strictEqual(plan.rows.length, 1); + assert.strictEqual(plan.rows[0].label, 'Full song'); + assert.strictEqual(plan.rows[0].accepted, true); + assert.strictEqual(plan.summary.applied, 1); + // Uniform flow: the lone accepted section keeps its affine map (no pins). + assert.strictEqual(plan.map.map(10), 5); + assert.strictEqual(plan.map.map(20), 10); +}); + +test('a run pinned to identity at both ends surfaces a conflict the UI can block on', () => { + const a = analysis([ + prop(0, 0, 10, { confidence: 0.3 }), + prop(1, 10, 20, { confidence: 0.9, slope: 2 }), + prop(2, 20, 30, { confidence: 0.3 }), + ]); + const plan = _sectionSyncPlanPure(a); + assert.strictEqual(plan.conflicts.length, 1); + assert.strictEqual(plan.conflicts[0].reason, 'identity-conflict'); + assert.strictEqual(plan.summary.applied, 0); +}); + +test('_proposalAcceptedPure honours selected overrides but never affine validity', () => { + assert.strictEqual(_proposalAcceptedPure(prop(0, 0, 10, { confidence: 0.1 }), 0.7), false); + assert.strictEqual(_proposalAcceptedPure({ ...prop(0, 0, 10, { confidence: 0.1 }), selected: true }, 0.7), true); + assert.strictEqual(_proposalAcceptedPure({ ...prop(0, 0, 10), selected: false }, 0.7), false); + // A nonpositive slope stays inapplicable even when forced. + assert.strictEqual(_proposalAcceptedPure({ ...prop(0, 0, 10, { slope: 0 }), selected: true }, 0.7), false); +}); + +// ── Integration: the review plan drives the same apply path as #25 ────────── + +function scenario() { + const s = seedState(); + s.beats = []; + for (let t = 0; t <= 30; t += 5) s.beats.push({ time: t, measure: -1, den: 4 }); + s.sections = [ + { name: 'Intro', number: 1, start_time: 0 }, + { name: 'Solo', number: 2, start_time: 10 }, + ]; + s.arrangements = [{ + notes: [ + { time: 5, sustain: 1, midi: 60 }, // Intro [0,10) — low confidence + { time: 12, sustain: 1, midi: 62 }, // Solo [10,30) — trusted + { time: 22, sustain: 1, midi: 64 }, // Solo [10,30) — trusted + ], + chords: [], anchors: [], anchors_user: [], handshapes: [], phrases: [], + }]; + s.drumTab = null; + s.history = new EditHistory(); + return s; +} + +test('a skipped low-confidence section keeps its notes while neighbours bake', () => { + const s = scenario(); + const a = { + ok: true, + domain: { start: 0, end: 30 }, + proposals: [ + prop(0, 0, 10, { confidence: 0.2, slope: 2, name: 'Intro' }), + prop(1, 10, 30, { confidence: 0.9, slope: 2, name: 'Solo' }), + ], + }; + const plan = _sectionSyncPlanPure(a); + assert.strictEqual(plan.rows[0].accepted, false, 'Intro defaults to skip'); + assert.strictEqual(plan.rows[1].accepted, true, 'Solo applies'); + assert.deepStrictEqual(plan.conflicts, []); + + const res = applyPerSectionSync({ + proposals: plan.proposals, + confidenceFloor: plan.confidenceFloor, + domain: plan.domain, + }); + assert.strictEqual(res.applied, true); + const notes = s.arrangements[0].notes; + assert.strictEqual(notes[0].time, 5, 'skipped section note untouched'); + assert.strictEqual(notes[1].time, 14, 'trusted section note baked'); + assert.strictEqual(notes[2].time, 34, 'trusted section note baked'); +}); + +test('selecting the low-confidence section applies it through the same plan', () => { + const s = scenario(); + const a = { + ok: true, + domain: { start: 0, end: 30 }, + proposals: [ + prop(0, 0, 10, { confidence: 0.2, slope: 2, name: 'Intro' }), + prop(1, 10, 30, { confidence: 0.9, slope: 2, name: 'Solo' }), + ], + }; + const plan = _sectionSyncPlanPure(a, new Map([[0, true]])); + assert.strictEqual(plan.rows[0].accepted, true, 'selected low-confidence section applies'); + const res = applyPerSectionSync({ + proposals: plan.proposals, + confidenceFloor: plan.confidenceFloor, + domain: plan.domain, + }); + assert.strictEqual(res.applied, true); + const notes = s.arrangements[0].notes; + assert.strictEqual(notes[0].time, 10, 'selected section note baked (2× at 0)'); + assert.strictEqual(notes[1].time, 24, 'selected run maps continuously'); +}); From 332a1146caf9ef9c16bf8c4f0a7e1aea4e44d437 Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:31:41 +0000 Subject: [PATCH 2/4] fix(sync): guard per-section review when no onsets are detected --- src/section-sync-ui.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/section-sync-ui.js b/src/section-sync-ui.js index 67aa248..fdd5ce2 100644 --- a/src/section-sync-ui.js +++ b/src/section-sync-ui.js @@ -39,6 +39,10 @@ export function editorShowSectionSync() { return; } const onsets = _ensureOnsetsShifted() || []; + if (!onsets.length) { + setStatus('No audio onsets detected yet — cannot per-section sync.'); + return; + } const analysis = _alignSectionsPure({ sections: S.sections || [], beats: S.beats, From 8423af3e968e1e9c3e5cad0ce7a28688175ef1d9 Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:39:27 +0000 Subject: [PATCH 3/4] fix(sync): per-section review focus trap, decision tracking and summary Self-review fixes for #26: - Restore focus inside the modal after a toggle re-renders the rows container (previously focus fell to , so Escape/Tab and global shortcuts leaked through the overlay), and fall back to the primary control / modal root when the disabled Apply button refuses focus. - Track the decision INTENT (`wanted`) separately from the conflict-demoted acceptance so a both-ends-pinned section is not toggle-stuck; show it as `conflict` and let the user skip it to clear. - Count only genuinely-skipped low-confidence sections in the summary (a forced-on section no longer reads as skipped). - Disable 'Apply all high-confidence' when a conflict blocks Apply. - Preserve the rows scroll position across re-renders; add dialog ARIA. - Add a DOM-driven suite pinning the focus/summary/apply/Escape behaviour. --- screen.html | 13 +- src/section-sync-ui.js | 62 +++++++-- src/section-sync.js | 7 + tests/section_sync_ui.test.mjs | 13 ++ tests/section_sync_ui_dom.test.mjs | 197 +++++++++++++++++++++++++++++ 5 files changed, 276 insertions(+), 16 deletions(-) create mode 100644 tests/section_sync_ui_dom.test.mjs diff --git a/screen.html b/screen.html index 2de7994..3a34089 100644 --- a/screen.html +++ b/screen.html @@ -886,10 +886,11 @@ never applied unless explicitly selected; "Apply all high-confidence" is the safe default. -->