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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
the editor never draws a connection the music doesn't have.
### Added

- **Tempo ramps — a ritardando is ONE thing now.** Select a run of barlines
in the Tempo Map, right-click ▸ **Ramp the range (accel/rit)…**, give it
"start → end" BPM, and the whole gesture becomes one authored object: the
bars re-space smoothly along a curve (a rit eases out, the natural
release), notes ride, locked barlines hold their exact times, and one
undo restores everything. The marker lane shows a single `rit. 140→120`
chip instead of a spray of per-bar tempo chips. **Fit ramp to the
recording** goes one better: it reads the onset drift across your
selection and proposes the ramp that flattens it — the drifting-red bar
in Map Health resolves to authored-green instead of nagging forever.
- **Tempo List (Tempo/Grid menu)** — every authored mark as text: one row
per ramp / meter grouping / hold / feel with its bar, value, and source
(human-confirmed vs detected vs imported). Click a row to jump to its
bar. The chips are paint; this is the ledger.

- **Half-time / double-time is a FEEL now, not a fake tempo change.** A
half-time chorus or double-time bridge never meant the band changed tempo
— the *pulse tier* changed. Right-click a barline: **Half-time feel /
Expand Down
27 changes: 25 additions & 2 deletions routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ def _coerce_audio_shift(value, invalid=0.0):
return shift if math.isfinite(shift) else invalid


_TEMPO_MARK_KINDS = {"meter", "hold", "feel"}
_TEMPO_MARK_KINDS = {"meter", "hold", "feel", "ramp"}
_TEMPO_RAMP_CURVES = {"linear", "ease-in", "ease-out"}
_TEMPO_FEEL_RATIOS = (0.5, 1.0, 2.0)
_TEMPO_MARK_PROVENANCE = {"confirmed", "detected", "suggested", "imported", "carried"}

Expand Down Expand Up @@ -178,14 +179,36 @@ def _coerce_tempo_marks(value):
if not (math.isfinite(factor) and 1 < factor <= 16):
factor = 2.0
entry["factor"] = factor
else: # feel (P2-8): the closed pulse-tier vocabulary
elif kind == "feel": # P2-8: the closed pulse-tier vocabulary
try:
ratio = float(m.get("ratio"))
except (TypeError, ValueError):
continue
if ratio not in _TEMPO_FEEL_RATIOS:
continue
entry["ratio"] = ratio
else: # ramp (P2-7): one authored accel/rit over [measure, measureEnd]
# measureEnd rides the same exact-integer rule as `measure`
# (review #279 item 7): a bare int() truncated 8.9 to 8 (silently
# moving the ramp's end), accepted bool/strings, and crashed on
# ±inf (OverflowError is not a ValueError).
measure_end = _exact_int(m.get("measureEnd"))
try:
bpm_start = float(m.get("bpmStart"))
bpm_end = float(m.get("bpmEnd"))
except (TypeError, ValueError):
continue
if measure_end is None or measure_end <= measure:
continue
if not (math.isfinite(bpm_start) and 0 < bpm_start <= 1000):
continue
if not (math.isfinite(bpm_end) and 0 < bpm_end <= 1000):
continue
entry["measureEnd"] = measure_end
entry["bpmStart"] = round(bpm_start, 3)
entry["bpmEnd"] = round(bpm_end, 3)
curve = m.get("curve")
entry["curve"] = curve if curve in _TEMPO_RAMP_CURVES else "linear"
if m.get("provenance") in _TEMPO_MARK_PROVENANCE:
entry["provenance"] = m["provenance"]
seen.add((measure, kind))
Expand Down
15 changes: 15 additions & 0 deletions screen.html
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,21 @@
<!-- Command palette (Ctrl+K) — a searchable front door over every
registry command (with its live keybinding) and menu action.
Type-to-filter; ↑/↓ select, Enter runs, Esc / backdrop closes. -->
<!-- Numeric Tempo List (P2-7, UX G3): one row per AUTHORED mark;
toggled from Tempo/Grid menu; click a row jumps to its bar. -->
<div id="editor-tempo-list" class="hidden absolute right-2 top-12 z-30 w-80 max-h-80 overflow-y-auto rounded-md border border-gray-600 bg-dark-800/95 backdrop-blur-sm text-xs">
<div class="flex items-center justify-between px-2 py-1 border-b border-gray-700">
<span class="text-teal-300 font-medium">Tempo List</span>
<button id="editor-tempo-list-close" class="px-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-600" title="Close">&#10005;</button>
</div>
<table class="w-full text-left">
<thead><tr class="text-gray-500">
<th class="px-2 py-0.5 text-right">Bar</th><th class="px-2 py-0.5">Type</th>
<th class="px-2 py-0.5">Value</th><th class="px-2 py-0.5">Source</th>
</tr></thead>
<tbody id="editor-tempo-list-body"></tbody>
</table>
</div>
<div id="editor-command-palette" class="hidden absolute inset-0 z-40 bg-black/40 backdrop-blur-[2px]">
<div class="mx-auto mt-16 w-[36rem] max-w-[calc(100%-2rem)] rounded-lg border border-gray-600 bg-dark-800/95 shadow-2xl text-sm" role="dialog" aria-label="Command palette">
<input id="editor-palette-input" type="text" placeholder="Type a command… (Esc to close)" autocomplete="off" spellcheck="false"
Expand Down
4 changes: 4 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@
editorConfirmTempoZones, editorZonesSingleTempo, editorHealGrid, editorZonesOctaveFix
} from './tempo.js';
import { initTempoZones } from './tempo-zones.js';
import { _tempoListRender, editorToggleTempoList, initTempoList } from './tempo-list.js';
import {
drawAnchorLane,
drawHandshapeLane, drawToneLane, editorApplyTonesModal, editorHideTonesModal,
Expand Down Expand Up @@ -233,6 +234,7 @@
// owns the timeline, and the readouts must not go stale behind it.
updateBPMDisplay();
updateTempoSigDisplay();
_tempoListRender(); // identity-keyed on S.tempoMarks — no-op unless marks changed
// Live Tab view: the engraved score OWNS the timeline area — ping the
// module (it shows the mount + re-renders on real changes) and skip the
// canvas chain. The else-branch hides the mount the moment any mode
Expand Down Expand Up @@ -1736,7 +1738,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 1741 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 Expand Up @@ -2001,6 +2003,8 @@
// handed over as hooks so tempo-zones.js never imports tempo.js (cycle).
initTempoZones({ confirm: editorConfirmTempoZones, single: editorZonesSingleTempo,
octave: editorZonesOctaveFix, feel: editorZonesFeelFix });
initTempoList();
window.editorToggleTempoList = editorToggleTempoList;
// Registry commands run through `editorRunShortcutCommand` — the SAME
// by-id dispatcher the shortcut panel's buttons use, which is what the
// palette is (a click on a command, not a keypress). Going straight to
Expand Down
1 change: 1 addition & 0 deletions src/menu-bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export const EDITOR_MENUS = Object.freeze([
{ title: 'Tempo/Grid', items: [
{ cmd: 'toggleTempoMap' },
{ cmd: 'setTimeSignature' },
{ label: 'Tempo List (authored marks)', fn: 'editorToggleTempoList' },
{ sep: true },
{ hdr: 'Barlines (Tempo Map)' },
{ cmd: 'tempoSuggestFit', needs: 'tempoMap' },
Expand Down
2 changes: 1 addition & 1 deletion src/ruler.js
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ export function drawRuler(w) {
if (x < LABEL_W || x > w) continue;
const row = (Math.abs(x - mkLastX) < 44) ? Math.min(mkRow + 1, 2) : 0;
mkLastX = x; mkRow = row;
const isTempo = mk.kind === 'tempo';
const isTempo = mk.kind === 'tempo' || mk.kind === 'ramp';
const isHold = mk.kind === 'hold';
const isFeel = mk.kind === 'feel';
const cy = top + 1 + row * 8.5;
Expand Down
98 changes: 98 additions & 0 deletions src/tempo-list.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/* Slopsmith Arrangement Editor — the numeric Tempo List (P2-7, UX G3).
*
* A small scrollable table of every AUTHORED tempo/meter mark — one row per
* mark: Bar · Type · Value · Source — the accessibility + provenance home
* (the chips are paint; this is text). Click a row to jump to its bar in
* Tempo Map mode. Derived (machine-read) tempo changes are deliberately NOT
* listed: this is the ledger of what a human (or an accepted fit) DECLARED.
*
* Rendered on open + after every marks change (cheap: identity-keyed).
*/

import { S } from './state.js';
import { host } from './host.js';
import { setStatus } from './ui.js';

const $panel = () => document.getElementById('editor-tempo-list');
const $body = () => document.getElementById('editor-tempo-list-body');

function _esc(t) {
return String(t).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

// One row of display strings per authored mark — pure, pinned by test.
export function _tempoListRowsPure(marks) {
return (marks || []).map(m => ({
measure: m.measure,
type: m.kind === 'ramp' ? (m.bpmEnd < m.bpmStart ? 'rit.' : 'accel.')
: m.kind === 'meter' ? 'meter'
: m.kind === 'feel' ? 'feel' : 'hold',
value: m.kind === 'ramp' ? `${m.bpmStart}→${m.bpmEnd} (bars ${m.measure}–${m.measureEnd}, ${m.curve})`
: m.kind === 'meter' ? `${m.num}/${m.den}${m.grouping ? ` (${m.grouping.join('+')})` : ''}`
: m.kind === 'feel' ? (m.ratio === 0.5 ? '½-time' : m.ratio === 2 ? '2×-time' : 'straight')
: `×${m.factor}`,
source: m.provenance || '—',
}));
}

let _renderedRef = null;

export function _tempoListRender() {
const body = $body();
const panel = $panel();
if (!body || !panel || panel.classList.contains('hidden')) return;
if (_renderedRef === S.tempoMarks) return;
_renderedRef = S.tempoMarks;
const rows = _tempoListRowsPure(S.tempoMarks);
body.innerHTML = rows.length
? rows.map((r, i) =>
`<tr data-i="${i}" class="cursor-pointer hover:bg-dark-600">`
+ `<td class="px-2 py-0.5 text-right font-mono">${r.measure}</td>`
+ `<td class="px-2 py-0.5">${_esc(r.type)}</td>`
+ `<td class="px-2 py-0.5 font-mono">${_esc(r.value)}</td>`
+ `<td class="px-2 py-0.5 text-gray-500">${_esc(r.source)}</td></tr>`).join('')
: '<tr><td colspan="4" class="px-2 py-2 text-gray-500">No authored marks yet — right-click a barline in Tempo Map.</td></tr>';
}

function _gotoMark(idx) {
const mark = (S.tempoMarks || [])[idx];
if (!mark) return;
if (!S.tempoMapMode && typeof window.editorRunShortcutCommand === 'function') {
window.editorRunShortcutCommand('toggleTempoMap');
}
let beatIdx = -1, t = 0;
for (let i = 0; i < (S.beats || []).length; i++) {
if (S.beats[i] && S.beats[i].measure === mark.measure) { beatIdx = i; t = S.beats[i].time; break; }
}
if (beatIdx < 0) { setStatus(`Bar ${mark.measure} is not on the current grid.`); return; }
S.tempoSel = beatIdx;
if (S.tempoSelMulti) S.tempoSelMulti.clear();
S.scrollX = Math.max(0, t - 0.5);
host.draw();
host.updateStatus();
}

export function editorToggleTempoList() {
const panel = $panel();
if (!panel) return false;
const opening = panel.classList.contains('hidden');
panel.classList.toggle('hidden');
if (opening) {
_renderedRef = null; // force a fresh render on open
_tempoListRender();
setStatus('Tempo List — every authored mark, one row each; click a row to jump to its bar.');
}
return true;
}

export function initTempoList() {
const panel = $panel();
if (!panel) return;
panel.addEventListener('click', (e) => {
const tr = e.target instanceof Element ? e.target.closest('tr[data-i]') : null;
if (tr) _gotoMark(Number(tr.dataset.i));
if (e.target instanceof Element && e.target.id === 'editor-tempo-list-close') {
panel.classList.add('hidden');
}
});
}
36 changes: 32 additions & 4 deletions src/tempo-marks.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ import { host } from './host.js';
// imported — carried from the source file (GP/MIDI), unchecked vs audio
// carried — interpolated across a silent/sustained zone
const TEMPO_MARK_PROVENANCE = ['confirmed', 'detected', 'suggested', 'imported', 'carried'];
const TEMPO_MARK_KINDS = ['meter', 'hold', 'feel'];
const TEMPO_MARK_KINDS = ['meter', 'hold', 'feel', 'ramp'];
// Ramp curve presets (P2-7) — the Logic curve-node analogue, but PRESETS so
// the author never hand-tunes béziers: a rit defaults to ease-out (it
// releases), an accel to linear.
const TEMPO_RAMP_CURVES = ['linear', 'ease-in', 'ease-out'];
// The feel vocabulary (P2-8): a half-/double-time section is a FEEL change
// over a constant tempo, never a 2x tempo change. Ratio applies FROM its
// measure until the next feel mark; 1 = back to straight time.
Expand Down Expand Up @@ -59,10 +63,21 @@ function _markNormPure(mark) {
const factor = Number(mark.factor);
// How much longer than metric the bar is held; 2 = "about twice".
out.factor = (Number.isFinite(factor) && factor > 1 && factor <= 16) ? factor : 2;
} else { // feel
} else if (kind === 'feel') {
const ratio = Number(mark.ratio);
if (!TEMPO_FEEL_RATIOS.includes(ratio)) return null;
out.ratio = ratio;
} else { // ramp (P2-7): ONE authored accel/rit over [measure, measureEnd]
const measureEnd = Number(mark.measureEnd);
const bpmStart = Number(mark.bpmStart);
const bpmEnd = Number(mark.bpmEnd);
if (!Number.isInteger(measureEnd) || measureEnd <= measure) return null;
if (!(Number.isFinite(bpmStart) && bpmStart > 0 && bpmStart <= 1000)) return null;
if (!(Number.isFinite(bpmEnd) && bpmEnd > 0 && bpmEnd <= 1000)) return null;
out.measureEnd = measureEnd;
out.bpmStart = Math.round(bpmStart * 1000) / 1000;
out.bpmEnd = Math.round(bpmEnd * 1000) / 1000;
out.curve = TEMPO_RAMP_CURVES.includes(mark.curve) ? mark.curve : 'linear';
}
if (TEMPO_MARK_PROVENANCE.includes(mark.provenance)) out.provenance = mark.provenance;
return out;
Expand Down Expand Up @@ -109,11 +124,24 @@ function _holdMeasuresPure(marks) {
// SURVIVING downbeats; a mark whose bar was deleted is dropped (its bar no
// longer exists — an honest drop, never a stale key pointing at the wrong
// bar). Same policy as S.tempoSelMulti, but remapped instead of cleared.
// A ramp is a RANGE — both endpoints follow the renumber ATOMICALLY
// (review #279 item 4): remapping only `measure` left `measureEnd` on the
// pre-edit numbering, silently stretching or shearing the span. A ramp
// whose end bar was deleted, or whose remapped span collapses
// (measureEnd <= measure), drops whole — a half-valid range is never
// emitted.
function _marksRemapPure(marks, oldToNew) {
const out = [];
for (const m of (marks || [])) {
const nm = oldToNew instanceof Map ? oldToNew.get(m.measure) : undefined;
if (Number.isInteger(nm) && nm >= 1) out.push({ ...m, measure: nm });
if (!Number.isInteger(nm) || nm < 1) continue;
if (m.kind === 'ramp') {
const ne = oldToNew.get(m.measureEnd);
if (!Number.isInteger(ne) || ne <= nm) continue;
out.push({ ...m, measure: nm, measureEnd: ne });
} else {
out.push({ ...m, measure: nm });
}
}
return out.sort((a, b) => (a.measure - b.measure) || (a.kind < b.kind ? -1 : 1));
}
Expand Down Expand Up @@ -267,7 +295,7 @@ function _groupingAccentsByMeasurePure(marks) {
/* @pure:tempo-marks:end */

export {
TEMPO_FEEL_RATIOS, TEMPO_MARK_PROVENANCE, _feelAtPure, _feelRangesPure,
TEMPO_FEEL_RATIOS, TEMPO_MARK_PROVENANCE, TEMPO_RAMP_CURVES, _feelAtPure, _feelRangesPure,
_groupingAccentMapPure, _groupingAccentsByMeasurePure,
_groupingLabelPure, _groupingParsePure, _holdMeasuresPure,
_markNormPure, _marksAtPure, _marksMeterReconcilePure, _marksRemapByTimePure,
Expand Down
Loading
Loading