Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions screen.html
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,34 @@
</div>
</div>

<!-- Per-section sync review (#26): one row per section with a confidence badge
and an apply/skip toggle. Low-confidence sections default to skip and are
never applied unless explicitly selected; "Apply all high-confidence" is the
safe default. -->
<div id="editor-section-sync-modal" class="hidden absolute inset-0 z-50 flex items-center justify-center" style="background:rgba(0,0,0,0.7)">
<div role="dialog" aria-modal="true" aria-labelledby="editor-section-sync-title"
class="bg-dark-700 rounded-xl border border-gray-700 w-full max-w-lg flex flex-col max-h-[80vh]">
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-700">
<span id="editor-section-sync-title" class="text-sm font-medium">Per-section sync</span>
<button type="button" onclick="editorHideSectionSync()" class="text-gray-500 hover:text-white">&times;</button>
</div>
<div class="px-4 pt-3 space-y-2 text-xs text-gray-400">
Review each section's alignment. Low-confidence sections are skipped by
default — select one to apply it anyway.
<div id="editor-section-sync-summary" class="text-[11px] text-gray-500"></div>
</div>
<div id="editor-section-sync-rows" class="px-4 py-2 overflow-y-auto"></div>
<div class="flex gap-2 px-4 py-3 border-t border-gray-700">
<button id="editor-section-sync-apply-high" type="button" onclick="editorApplySectionSync(true)"
class="flex-1 px-2 py-1.5 bg-dark-600 hover:bg-dark-500 rounded text-xs">Apply all high-confidence</button>
<button id="editor-section-sync-apply" type="button" onclick="editorApplySectionSync(false)"
class="flex-1 px-2 py-1.5 bg-accent hover:bg-accent-light rounded text-xs disabled:opacity-50">Apply selected</button>
<button type="button" onclick="editorHideSectionSync()"
class="px-2 py-1.5 bg-dark-600 hover:bg-dark-500 rounded text-xs">Cancel</button>
</div>
</div>
</div>

<!-- New Track dialog — the single front door for adding tracks (DAW
new-tracks idiom: type tiles up top, details below, Create bottom-right).
Routes to the per-kind import modals for from-file sources. -->
Expand Down
10 changes: 10 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@
} 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';
Expand Down Expand Up @@ -743,6 +747,12 @@
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;
Expand Down Expand Up @@ -2146,7 +2156,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 2159 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
1 change: 1 addition & 0 deletions src/menu-bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
229 changes: 229 additions & 0 deletions src/section-sync-ui.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
// 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, _sectionSyncHighConfidenceDisabledPure, _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);
}

// Focus a live control in the modal. A disabled button refuses focus, so fall
// back through the primary action, any enabled control, then the modal root
// (which is `tabIndex = -1` and inside range of the keydown trap).
function _focusModal() {
if (typeof document === 'undefined') return;
const modal = _modal();
if (!modal) return;
const apply = document.getElementById('editor-section-sync-apply');
if (apply && !apply.disabled && apply.focus) { apply.focus(); return; }
if (typeof modal.querySelector === 'function') {
const first = modal.querySelector('button:not([disabled])');
if (first && first.focus) { first.focus(); return; }
}
if (modal.focus) modal.focus();
}

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() || [];
if (!onsets.length) {
setStatus('No audio onsets detected yet — cannot per-section sync.');
return;
}
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);
}
_focusModal();
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;
}
// Flip relative to the DECISION intent, not the conflict-demoted acceptance:
// an explicitly-skipped section that a conflict would otherwise hide must
// still toggle back on, and a demoted run must not become toggle-stuck.
_decisions.set(index, !row.wanted);
_renderSectionSync();
// The re-render replaced the rows container, so the clicked button is gone
// and focus fell to <body> — which would let global shortcuts through the
// overlay. Put focus back on this row's toggle (or a safe fallback).
_focusSectionToggle(index);
}

// Restore focus to a row's toggle after a re-render, falling back inside the
// modal. `document.querySelector` is absent in some DOM stubs; the fallback
// covers that.
function _focusSectionToggle(index) {
if (typeof document === 'undefined') return;
const btn = typeof document.querySelector === 'function'
? document.querySelector(`[data-section-ss="${index}"]`) : null;
if (btn && btn.focus) { btn.focus(); return; }
_focusModal();
}

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 section conflicts with the fit. Skip it and try again.');
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 `<span class="px-1.5 py-0.5 rounded text-[10px] font-medium ${cls}">`
+ `${row.confidencePct}%</span>`;
}

function _rowDetail(row) {
if (row.conflict) return 'conflict';
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
? `<button type="button" data-section-ss="${row.index}"`
+ ` onclick="editorToggleSectionSync(${row.index})"`
+ ` aria-pressed="${row.wanted}" class="w-16 px-2 py-1 rounded text-[11px] `
+ `${row.wanted ? 'bg-accent hover:bg-accent-light' : 'bg-dark-600 hover:bg-dark-500'}">`
+ `${row.wanted ? 'Apply' : 'Skip'}</button>`
: '<span class="w-16 px-2 py-1 rounded text-[11px] text-center text-gray-600">—</span>';
return '<div class="flex items-center gap-2 py-1 border-b border-dark-600/60">'
+ '<span class="flex-1 truncate text-xs text-gray-300">'
+ `${_editorEscHtml(row.label)}</span>`
+ `${_tierBadge(row)}`
+ `<span class="w-28 text-right text-[10px] font-mono text-gray-500">${_rowDetail(row)}</span>`
+ `${toggle}</div>`;
}

function _summaryText(plan) {
const s = plan.summary;
if (!s.sections) return 'No sections to sync.';
const parts = [`${s.applied}/${s.sections} selected`];
// Count only low-confidence sections that are NOT being applied — a section
// the user explicitly selected must not read as "skipped".
const lowSkipped = plan.rows.filter((r) => r.tier === 'low' && !r.accepted).length;
if (lowSkipped) parts.push(`${lowSkipped} low-confidence skipped`);
if (s.failed) parts.push(`${s.failed} unmatched`);
if (plan.conflicts.length) parts.push(`${plan.conflicts.length} conflict`);
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) {
const scroll = rowsEl.scrollTop || 0;
rowsEl.innerHTML = plan.rows.map(_sectionRowHtml).join('');
rowsEl.scrollTop = scroll;
}
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 = _sectionSyncHighConfidenceDisabledPure(_analysis);
}
Loading
Loading