diff --git a/routes.py b/routes.py index 82a7d3c1..9f53c8f5 100644 --- a/routes.py +++ b/routes.py @@ -49,11 +49,15 @@ # content signature (`_align_xml_files_to_arrangements`) — keeping the two # in lockstep so a newly-added technique can't silently drop out of either. _NOTE_TECH_FIELDS = ( - "bend", "slide_to", "slide_unpitch_to", "hammer_on", "pull_off", - "harmonic", "harmonic_pinch", "palm_mute", "mute", "vibrato", + "bend", "bend_intent", "slide_to", "slide_unpitch_to", "hammer_on", + "pull_off", "harmonic", "harmonic_pinch", "palm_mute", "mute", "vibrato", "tremolo", "accent", "tap", "link_next", "fret_hand_mute", "pluck", "slap", "right_hand", "pick_direction", "ignore", ) +# `bend_values` (the §6.2.1 bend curve) is deliberately NOT in the tuple above: +# it's a list, and the tuple feeds hashable content-signature tuples +# (`_obj_note_sig` / `_dict_note_sig`). It's handled explicitly in `_tech_dict` +# (load) and `_arr_dict_to_wire` (save) instead. def _split_stems_best_effort(sloppak_path) -> bool: @@ -407,6 +411,31 @@ def _safe_bool(v, default=False): return default +def _safe_bend_curve(raw): + """Sanitize an editor bend curve ([{t, v}], §6.2.1) for the wire: drop + non-dict / non-finite entries, round (t to 3, v to 1, matching `bn`), and + sort by `t`. Returns None for non-list / empty / all-invalid input so an + absent curve round-trips as *omitted*, never [].""" + if not isinstance(raw, list): + return None + out = [] + for p in raw: + if not isinstance(p, dict): + continue + t, v = p.get("t"), p.get("v") + if (not isinstance(t, (int, float)) or isinstance(t, bool) + or not math.isfinite(t)): + continue + if (not isinstance(v, (int, float)) or isinstance(v, bool) + or not math.isfinite(v)): + continue + out.append({"t": round(float(t), 3), "v": round(float(v), 1)}) + if not out: + return None + out.sort(key=lambda e: e["t"]) + return out + + def _valid_anchor_dicts(seq): """Coerce a candidate anchors list into clean {time, fret, width} dicts. @@ -561,6 +590,14 @@ def _note(n): "pkd": _safe_int(tech.get("pick_direction"), -1), "ig": _safe_bool(tech.get("ignore")), } + # Bend shape (§6.2.1) — default-omitted, matching core's note_to_wire: + # `bt` only when non-zero, `bnv` only when a curve is present. + _bt = _safe_int(tech.get("bend_intent"), 0) + if _bt: + out["bt"] = _bt + _bnv = _safe_bend_curve(tech.get("bend_values")) + if _bnv: + out["bnv"] = _bnv return out def _note_in_chord(n): @@ -4684,7 +4721,11 @@ def _tech_dict(n): # round-trips so the editor can render and re-emit them. Field # set lives in `_NOTE_TECH_FIELDS` so the content signature # stays in sync (attr name == wire key for each). - return {f: getattr(n, f) for f in _NOTE_TECH_FIELDS} + d = {f: getattr(n, f) for f in _NOTE_TECH_FIELDS} + # `bend_values` (§6.2.1 curve) is a list, kept out of the signature + # tuple — carry it explicitly so an authored/imported curve loads. + d["bend_values"] = getattr(n, "bend_values", None) + return d for arr in song.arrangements: arr_data = { diff --git a/screen.js b/screen.js index b12ed578..939e447a 100644 --- a/screen.js +++ b/screen.js @@ -555,6 +555,65 @@ function remapHandshapeChordIds(handshapes, oldToNew) { } /* @pure:chord-relink:end */ +/* @pure:bend-shape:start — pure, no browser deps; node-tested by + * tests/bend_shape.test.js. Helpers for authoring the §6.2.1 bend curve. */ + +// Bend-intent (`bt`) options, in spec order. +const BEND_INTENTS = [ + { v: 0, label: 'Bend up' }, + { v: 1, label: 'Release' }, + { v: 2, label: 'Pre-bend' }, + { v: 3, label: 'Pre-bend + release' }, + { v: 4, label: 'Round-trip' }, +]; + +// Generate a sensible bend curve ([{t, v}], t = seconds-from-onset) for a +// given intent `bt`, peak `bn` and note `sustain`. Used to seed the curve +// editor and by the preset buttons. +function bendPresetCurve(bt, bn, sustain) { + const T = sustain > 0 ? sustain : 1.0; + const peak = Math.max(0, bn) || 0; + const mid = Math.round(T * 0.5 * 1000) / 1000; + const end = Math.round(T * 1000) / 1000; + switch (Number(bt) || 0) { + case 1: // release: held bend let down to pitch + return [{ t: 0, v: peak }, { t: end, v: 0 }]; + case 2: // pre-bend: already bent, held + return [{ t: 0, v: peak }, { t: end, v: peak }]; + case 3: // pre-bend + release + return [{ t: 0, v: peak }, { t: mid, v: peak }, { t: end, v: 0 }]; + case 4: // round-trip: up then back down + return [{ t: 0, v: 0 }, { t: mid, v: peak }, { t: end, v: 0 }]; + default: // 0 up + return [{ t: 0, v: 0 }, { t: end, v: peak }]; + } +} + +// Sanitize an authored curve for persistence: drop non-finite / non-dict +// entries, round (t to 3, v to 1) and sort by t. No magnitude clamp — mirrors +// core's `_sanitize_bend_curve` / the backend `_safe_bend_curve` (a bend can +// legitimately exceed the editor's 3-semitone authoring cap), and the curve +// canvas already bounds authored values. Returns null for empty / all-invalid +// input so an absent curve serializes as omitted, never []. +function sanitizeBendCurve(raw) { + if (!Array.isArray(raw)) return null; + const out = []; + for (const p of raw) { + if (!p || typeof p !== 'object') continue; + const t = Number(p.t); + const v = Number(p.v); + if (!Number.isFinite(t) || !Number.isFinite(v)) continue; + out.push({ + t: Math.round(t * 1000) / 1000, + v: Math.round(v * 10) / 10, + }); + } + if (!out.length) return null; + out.sort((a, b) => a.t - b.t); + return out; +} +/* @pure:bend-shape:end */ + // Reconstruct chords from notes at the same time before saving function reconstructChords() { if (!S.arrangements.length) return; @@ -1235,6 +1294,75 @@ class ChangeFretCmd { rollback() { notes()[this.index].fret = this.oldFret; } } +// Set the full bend shape (peak `bend`, intent `bend_intent`, curve +// `bend_values` — §6.2.1) on one or more notes as a single undoable edit. +// Snapshots the prior bend triple per note so undo restores it exactly. +class SetBendShapeCmd { + constructor(indices, bn, bt, bnv) { + this.indices = indices.slice(); + this.bn = bn; + this.bt = bt; + // Store a defensive copy; null when the note has no curve. + this.bnv = Array.isArray(bnv) && bnv.length + ? bnv.map(p => ({ t: p.t, v: p.v })) + : null; + this.old = this.indices.map(i => { + const t = notes()[i].techniques || {}; + return { + bend: t.bend, + bend_intent: t.bend_intent, + bend_values: t.bend_values, + }; + }); + } + exec() { + for (const i of this.indices) { + const n = notes()[i]; + if (!n.techniques) n.techniques = {}; + n.techniques.bend = this.bn; + n.techniques.bend_intent = this.bt; + n.techniques.bend_values = this.bnv + ? this.bnv.map(p => ({ t: p.t, v: p.v })) + : null; + } + } + rollback() { + this.indices.forEach((i, k) => { + const n = notes()[i]; + if (!n.techniques) n.techniques = {}; + const o = this.old[k]; + n.techniques.bend = o.bend; + n.techniques.bend_intent = o.bend_intent; + n.techniques.bend_values = o.bend_values; + }); + } +} + +// Set only the bend intent (`bt`) on a set of notes — used by the inspector +// dropdown so changing intent across a multi-selection doesn't flatten each +// note's distinct peak/curve (which the full SetBendShapeCmd would). +class SetBendIntentCmd { + constructor(indices, bt) { + this.indices = indices.slice(); + this.bt = Number(bt) || 0; + this.old = this.indices.map(i => (notes()[i].techniques || {}).bend_intent); + } + exec() { + for (const i of this.indices) { + const n = notes()[i]; + if (!n.techniques) n.techniques = {}; + n.techniques.bend_intent = this.bt; + } + } + rollback() { + this.indices.forEach((i, k) => { + const n = notes()[i]; + if (!n.techniques) n.techniques = {}; + n.techniques.bend_intent = this.old[k]; + }); + } +} + // ── Move-to-string helpers ────────────────────────────────────────── // Standard open-string MIDI pitches (low → high, string index order). // Guitar E2=40 A2=45 D3=50 G3=55 B3=59 e4=64; extended low strings @@ -2445,29 +2573,202 @@ async function promptFret(idx) { _renderInspector(); } +// Bend authoring (§6.2.1): a modal with the peak amount (`bn`), an intent +// dropdown (`bt`) and an interactive drag-point curve editor (`bnv`). Applies +// to the full selection when the right-clicked note is part of it, else just +// that note. Wrapped in SetBendShapeCmd so the whole edit is one undo step. async function promptBend(idx) { hideContextMenu(); const n = notes()[idx]; + if (!n) return; + const targets = (S.sel && S.sel.size && S.sel.has(idx)) ? [...S.sel] : [idx]; const techs = n.techniques || {}; - const current = techs.bend || 0; - const val = await _editorPromptText({ - title: 'Bend', - label: 'Bend amount in semitones (0 = none, 1 = full, 0.5 = half)', - value: String(current), + const startBn = Number(techs.bend) || 0; + const startBt = Number(techs.bend_intent) || 0; + const startBnv = sanitizeBendCurve(techs.bend_values) + || bendPresetCurve(startBt, startBn || 1, n.sustain); + const result = await _editorBendModal({ + bn: startBn, bt: startBt, bnv: startBnv, sustain: n.sustain, }); - if (val === null) return; - // Strict-numeric parse — `Number('1abc')` is NaN, while - // `parseFloat('1abc')` would partial-parse to `1`. Matches the - // inspector's `_coerceInspectorNumber` for the same field so both - // entry points accept/reject the same set of inputs. - const s = String(val).trim(); - const parsed = s === '' ? NaN : Number(s); - if (!Number.isFinite(parsed)) return; - const bend = Math.max(0, Math.min(3, parsed)); - if (!n.techniques) n.techniques = {}; - n.techniques.bend = bend; + if (result === null) return; // cancelled + S.history.exec(new SetBendShapeCmd( + targets, result.bn, result.bt, sanitizeBendCurve(result.bnv))); draw(); _renderInspector(); + updateStatus(); +} + +// The bend-shape modal. Resolves to {bn, bt, bnv} on OK, or null on Cancel. +// The curve editor: left-click empty space adds a point, drag moves it, +// right-click deletes it; x = time across the note, y = semitones. +function _editorBendModal({ bn = 0, bt = 0, bnv = null, sustain = 0 } = {}) { + return new Promise((resolve) => { + document.getElementById('editor-bend-modal')?.remove(); + const Tmax = sustain > 0 ? sustain : 1.0; + let curBn = Math.max(0, Math.min(3, Number(bn) || 0)); + let curBt = Number(bt) || 0; + let pts = (sanitizeBendCurve(bnv) || []).map(p => ({ t: p.t, v: p.v })); + + const modal = document.createElement('div'); + modal.id = 'editor-bend-modal'; + modal.className = 'fixed inset-0 bg-black/70 z-50 flex items-center justify-center'; + const inner = document.createElement('div'); + inner.className = 'bg-dark-800 border border-gray-700 rounded-lg p-6 w-full max-w-md mx-4'; + inner.setAttribute('role', 'dialog'); + inner.setAttribute('aria-modal', 'true'); + inner.setAttribute('aria-label', 'Edit bend'); + + let settled = false; + const done = (val) => { + if (settled) return; + settled = true; + modal.remove(); + resolve(val); + }; + + // Vmax: keep the peak and any authored point visible (>= 3 semis). + const vmax = () => Math.max(3, curBn, ...pts.map(p => p.v), 1); + const W = 380, H = 170, pad = 26; + const toX = (t) => pad + (Tmax > 0 ? t / Tmax : 0) * (W - 2 * pad); + const toY = (v) => H - pad - (v / vmax()) * (H - 2 * pad); + const fromX = (px) => Math.max(0, Math.min(1, (px - pad) / (W - 2 * pad))) * Tmax; + const fromY = (py) => Math.max(0, Math.min(1, (H - pad - py) / (H - 2 * pad))) * vmax(); + + inner.innerHTML = ` +
Click to add a point · drag to move · right-click to remove. Preset from intent:
+