From 434cbb55ccabe13174ea5b521f713422081576af Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Wed, 22 Jul 2026 18:53:22 -0500 Subject: [PATCH] feat(editor): notes carry a notated rhythm value (note-value model foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first slice of the note-value / rhythm-duration model (the roadmap's "deep foundation under everything"): a note can carry a symbolic `rhythm` — base (whole…128th), dots, nested tuplets, grace — as a THIRD quantity beside its onset and its sounding `sustain`, so a staccato quarter and a legato quarter are the same value, different ring. Lands the pure engine + the undoable command; no UI. - `src/rhythm-value.js` (pure tier): whole-note magnitude (base × dots × nested tuplet, grace = 0), the beat conversion, best-fit with an HONEST null past tolerance, greedy tie-decomposition, and the GP/MIDI/MusicXML tick projection with a lossy flag. Fully unit-tested. - `SetRhythmValueCmd`: sets/clears value on a selection as one undoable edit, snapshotting the absent-vs-present distinction with a sentinel and cloning the value per note (no aliasing). - value is a runtime field like `note.beat`: it SURVIVES chord reconstruction (the field-mapper vanish hazard) but never rides the seconds-only note wire (`_stripBeat`), so a value-free pack stays byte-identical and the content signature is untouched. Authored values persist later via the §7.6 notation side-file, not the note dict. - tests drive the REAL EditHistory / reconstructChords / save-strip, not stubs: exec→rollback→redo round-trips, chord-member survival, off-wire strip. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017xGPjDBF8NTwTK7VQvizix --- CHANGELOG.md | 12 ++ src/chords.js | 8 ++ src/commands.js | 42 ++++++ src/rhythm-value.js | 228 ++++++++++++++++++++++++++++++++ src/tempo.js | 8 +- tests/rhythm_value.test.mjs | 172 ++++++++++++++++++++++++ tests/rhythm_value_cmd.test.mjs | 137 +++++++++++++++++++ 7 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 src/rhythm-value.js create mode 100644 tests/rhythm_value.test.mjs create mode 100644 tests/rhythm_value_cmd.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index adfea4a8..2d232121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Notated rhythm values are now a first-class part of the note model (the + foundation slice).** A note can carry a symbolic `rhythm` — a base value + (whole…128th), dots, nested tuplets, and grace — as a third quantity alongside + its onset and its sounding `sustain`, so a staccato quarter and a legato quarter + are the *same value, different ring*. This first slice lands the pure engine + (`src/rhythm-value.js`: whole-note magnitude, the beat conversion, best-fit with + an honest `null` past tolerance, greedy tie-decomposition, and the GP/MIDI/ + MusicXML tick projection) and the undoable `SetRhythmValueCmd`, with no UI yet. + Value is a runtime field like `note.beat`: it survives chord reconstruction but + never rides the seconds-only note wire (authored values will persist via the + notation side-file in a later slice), so a pack with no authored values stays + byte-identical. Tests: `tests/rhythm_value.test.mjs`, `tests/rhythm_value_cmd.test.mjs`. - **Save is now project persistence; Export to Library is publishing.** Importing files creates only an unsaved editing session. A new project's first Save chooses a `.feedpak` name and location through the native picker, later saves diff --git a/src/chords.js b/src/chords.js index 2a43dbd1..6bfe18c9 100644 --- a/src/chords.js +++ b/src/chords.js @@ -418,6 +418,14 @@ export function reconstructChords() { fret: n.fret, sustain: n.sustain || 0, techniques: n.techniques || {}, + // Carry the notated rhythm VALUE onto the rebuilt chord member, + // or it silently vanishes on chord notes (the exact field-mapper + // hazard the suggested-mark WeakSet documents). Omit the key when + // absent so "no authored value" stays distinct from a value — + // reconstructChords runs at save time but mutates LIVE state, so a + // dropped rhythm would lose the chord's authored value in-session. + // It rides no further: `_stripBeat` peels it off the seconds-only wire. + ...(n.rhythm != null ? { rhythm: n.rhythm } : {}), })), }); } diff --git a/src/commands.js b/src/commands.js index 3ef8f969..03b870be 100644 --- a/src/commands.js +++ b/src/commands.js @@ -35,6 +35,7 @@ import { _clearSuggested, _isSuggested, _markSuggested, notes, rescaleBendCurveT import { _absolutePitch, _cyclePositionCandidatesPure, _cycleStepPure, _suggestPositionPure, } from './position.js'; +import { cloneRhythm } from './rhythm-value.js'; import { SNAP_VALUES, _editorEffectiveSnapValuePure, _editorSnapSubdivisionsPure } from './snap.js'; import { S } from './state.js'; import { setStatus } from './ui.js'; @@ -435,6 +436,47 @@ export class SetTechScalarCmd { } } +// Set (or clear, with value=null) the notated rhythm VALUE on a selection as one +// undoable edit — the foundation command of the note-value model +// (NOTE-VALUE-MODEL-DESIGN §12 slice 1). `rhythm` is a TOP-LEVEL runtime field, +// NOT a technique (the `_NOTE_TECH_*` machinery is scalar/bool and drives the +// content signature; value must stay off it, §5). Snapshots the prior per-note +// rhythm, preserving the ABSENT-vs-present distinction with a sentinel, so undo +// restores it exactly — including "there was no value here." The value is cloned +// per note so two notes never share one mutable rhythm object (the bend_values +// aliasing trap). +const _RHYTHM_ABSENT = Symbol('rhythm-absent'); +export class SetRhythmValueCmd { + constructor(indices, value) { + this.indices = indices.slice(); + this.value = cloneRhythm(value); // canonical clone (or null to clear) + const nn = notes(); + this.old = this.indices.map(i => { + const n = nn[i]; + return (n && 'rhythm' in n) ? n.rhythm : _RHYTHM_ABSENT; + }); + } + exec() { + const nn = notes(); + for (const i of this.indices) { + const n = nn[i]; + if (!n) continue; + if (this.value == null) delete n.rhythm; + else n.rhythm = cloneRhythm(this.value); // fresh per note — no aliasing + } + } + rollback() { + const nn = notes(); + this.indices.forEach((i, k) => { + const n = nn[i]; + if (!n) return; + const prev = this.old[k]; + if (prev === _RHYTHM_ABSENT || prev == null) delete n.rhythm; + else n.rhythm = prev; + }); + } +} + // 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. diff --git a/src/rhythm-value.js b/src/rhythm-value.js new file mode 100644 index 00000000..d9e9eb97 --- /dev/null +++ b/src/rhythm-value.js @@ -0,0 +1,228 @@ +/* Slopsmith Arrangement Editor — notated rhythm-value model (pure tier). + * + * The symbolic note VALUE (a quarter, a dotted-eighth, a triplet member) — the + * third quantity in the editor's time model, decoupled from onset + * (note.time / runtime note.beat) and sounding length (note.sustain). A staccato + * quarter and a legato quarter are the SAME value, different sustain. See + * NOTE-VALUE-MODEL-DESIGN.md for the full model. + * + * note.rhythm = { + * base: 1|2|4|8|16|32|64|128, // value denominator (power of two) + * dots: 0|1|2, // augmentation dots + * tuplet: null | [{ n, m, group }, …], // outer→inner NESTING; "n in the time of m" + * grace: null | 'acciaccatura' | 'appoggiatura', // zero notated span + * } + * + * ABSENT rhythm ("no authored value") means "derive best-fit for the notation + * lens" — the mirror of how string+fret are performance truth and pitch is + * DERIVED (position.js): the machine proposes, the human pins. `note.rhythm` is a + * runtime field like `note.beat`: it never rides the seconds-only note wire + * (stripped in src/tempo.js `_stripBeat`); authored values persist later via the + * feedpak §7.6 notation side-file, never the note dict. + * + * Pure: imports nothing, reads no `S`, touches no DOM. Every export is total and + * returns an honest `null`/`NaN` rather than guessing — a confidently-wrong value + * is worse than an honest gap. Works in the BEAT domain (not seconds) so the + * seconds cache's ms-rounding can never push a value out of tolerance. + */ + +// Value denominators we represent (a whole note down to a 128th). Any power of +// two; 128 is the engraving-grade floor (NOTE-VALUE-MODEL-DESIGN §10-A). +export const RHYTHM_BASES = [1, 2, 4, 8, 16, 32, 64, 128]; + +// Tuplets the best-fit search will PROPOSE when asked (opts.allowTuplets). An +// explicit tuplet context (opts.tuplet) is always honored regardless. +export const COMMON_TUPLETS = [ + { n: 3, m: 2 }, { n: 5, m: 4 }, { n: 6, m: 4 }, { n: 7, m: 4 }, { n: 9, m: 8 }, +]; + +const _TOL = 1e-6; // whole-note-fraction tolerance for an "exact" match + +// ── absence / identity ────────────────────────────────────────────── + +// "No authored notated value." Absence is meaningful (derive-best-fit), so it is +// preserved distinctly from an empty object everywhere (commands snapshot it, the +// chord field-mapper omits the key rather than writing rhythm:undefined). +export function rhythmIsAbsent(note) { + return !note || note.rhythm == null; +} + +// Deep clone — value is never shared between notes (the bend_values aliasing bug: +// two notes sharing one mutable structure edit each other). null passes through. +export function cloneRhythm(r) { + if (r == null) return null; + return { + base: r.base, + dots: r.dots || 0, + tuplet: Array.isArray(r.tuplet) + ? r.tuplet.map(t => ({ n: t.n, m: t.m, group: t.group })) + : null, + grace: r.grace || null, + }; +} + +export function rhythmEquals(a, b) { + if (a == null || b == null) return a == null && b == null; + if (a.base !== b.base) return false; + if ((a.dots || 0) !== (b.dots || 0)) return false; + if ((a.grace || null) !== (b.grace || null)) return false; + const ta = Array.isArray(a.tuplet) ? a.tuplet : []; + const tb = Array.isArray(b.tuplet) ? b.tuplet : []; + if (ta.length !== tb.length) return false; + for (let i = 0; i < ta.length; i++) { + if (ta[i].n !== tb[i].n || ta[i].m !== tb[i].m) return false; + } + return true; +} + +// ── magnitude ─────────────────────────────────────────────────────── + +// Product of m/n over the (possibly nested) tuplet array; 1 when straight. A +// malformed member yields NaN so callers reject it rather than mis-size a note. +export function tupletFactor(r) { + if (!r || !Array.isArray(r.tuplet) || !r.tuplet.length) return 1; + let f = 1; + for (const t of r.tuplet) { + const n = Number(t.n), m = Number(t.m); + if (!(n > 0) || !(m > 0)) return NaN; + f *= m / n; + } + return f; +} + +// Fraction of a WHOLE note this value occupies. Grace = 0 (zero notated span, the +// one case where notated value and sounding length must diverge). +export function valueWholeFraction(r) { + if (!r || !(r.base > 0)) return NaN; + if (r.grace) return 0; + const dots = r.dots || 0; + const dotMul = 2 - Math.pow(2, -dots); // 1, 1.5, 1.75 for 0 / 1 / 2 dots + return (1 / r.base) * dotMul * tupletFactor(r); +} + +// Length in RULER beats, where one beat is one meter-denominator unit (`den` — +// an eighth in x/8). This is the note's rhythmic SLOT; sustain is the free ring. +export function valueToBeats(r, den) { + const f = valueWholeFraction(r); + return Number.isFinite(f) ? f * den : NaN; +} + +// ── best-fit (derive-when-absent, quantize, import inference) ──────── + +// Simplest {base,dots} whose whole-fraction ≈ target, within tol. Prefers fewer +// dots (then the match nearest target). Returns null when nothing matches. +function _fitPlain(targetFrac, tol) { + let best = null; + for (const base of RHYTHM_BASES) { + for (const dots of [0, 1, 2]) { + const f = (1 / base) * (2 - Math.pow(2, -dots)); + if (Math.abs(f - targetFrac) <= tol && (!best || dots < best.dots)) { + best = { base, dots, tuplet: null, grace: null }; + } + } + } + return best; +} + +// Best-fit a beat-span to a notated value (or a tied sequence). Returns +// `{ rhythm, ties }` (ties = the subsequent tied values; [] when one value +// suffices) or `null` when the span can't be represented within tolerance — the +// honest-gap signal, the mirror of `_suggestPositionPure` returning null. +// +// Order of preference: an explicit tuplet CONTEXT (opts.tuplet), then a single +// straight/dotted value, then a proposed common tuplet (opts.allowTuplets), then +// a greedy largest-first tie-decomposition. Broad, position-aware tuplet/beam +// splitting (which needs the onset's bar phase) lands with the live-tab and +// import-inference slices; this is the span-only magnitude fit. +export function beatsToValue(spanBeats, den, opts = {}) { + if (!(spanBeats > 0) || !(den > 0)) return null; + const tolBeats = opts.tolBeats != null ? opts.tolBeats : _TOL * den; + const tolFrac = tolBeats / den; + const targetFrac = spanBeats / den; + + // 1) explicit tuplet context: fit base/dots inside it + if (Array.isArray(opts.tuplet) && opts.tuplet.length) { + const tf = tupletFactor({ tuplet: opts.tuplet }); + if (Number.isFinite(tf) && tf > 0) { + const plain = _fitPlain(targetFrac / tf, tolFrac / tf); + if (plain) { + return { rhythm: { ...plain, tuplet: opts.tuplet.map(t => ({ ...t })) }, ties: [] }; + } + } + } + + // 2) a single straight/dotted value + const single = _fitPlain(targetFrac, tolFrac); + if (single) return { rhythm: single, ties: [] }; + + // 3) a proposed common tuplet (only when the caller opts in) + if (opts.allowTuplets) { + for (const tp of COMMON_TUPLETS) { + const plain = _fitPlain((targetFrac * tp.n) / tp.m, tolFrac); + if (plain) { + const group = opts.group != null ? opts.group : 1; + return { rhythm: { ...plain, tuplet: [{ n: tp.n, m: tp.m, group }] }, ties: [] }; + } + } + } + + // 4) greedy tie-decomposition over the straight/dotted ladder, largest-first + const ladder = []; + for (const base of RHYTHM_BASES) { + for (const dots of [2, 1, 0]) { + ladder.push({ base, dots, frac: (1 / base) * (2 - Math.pow(2, -dots)) }); + } + } + ladder.sort((a, b) => b.frac - a.frac); + const pieces = []; + let rem = targetFrac; + let guard = 0; + while (rem > tolFrac && guard++ < 32) { + const step = ladder.find(l => l.frac <= rem + tolFrac); + if (!step) break; + pieces.push({ base: step.base, dots: step.dots, tuplet: null, grace: null }); + rem -= step.frac; + } + if (Math.abs(rem) <= tolFrac && pieces.length) { + return { rhythm: pieces[0], ties: pieces.slice(1) }; + } + return null; // unquantizable within tolerance +} + +// ── interchange projection (GP / MIDI / MusicXML) ─────────────────── + +// Absolute-tick length. `lossy` when the exact tick count isn't an integer (a +// septuplet over 960 ppq). A handoff artifact for export, NOT the stored form. +export function valueToTicks(r, ppq = 960) { + const f = valueWholeFraction(r); + if (!Number.isFinite(f)) return { ticks: NaN, lossy: true }; + const exact = f * 4 * ppq; // a whole note is 4 quarters + const ticks = Math.round(exact); + return { ticks, lossy: Math.abs(exact - ticks) > 1e-9 }; +} + +// Inverse: a single value whose length is `ticks`, or null (needs a tie / is +// unquantizable). ppq ticks = one quarter, so ticks/ppq is a quarter-count and +// the fit runs in quarter-denominated beats (den = 4). +export function ticksToValue(ticks, ppq = 960) { + if (!(ticks > 0) || !(ppq > 0)) return null; + const fit = beatsToValue(ticks / ppq, 4, { allowTuplets: true }); + return fit && !fit.ties.length ? fit.rhythm : null; +} + +// ── bar accounting ────────────────────────────────────────────────── + +// Sum the notated beat-lengths of a bar's rhythms; `complete` is whether they +// fill the bar's capacity (when given). The model twin of alphatex's "bars sum +// exactly" render guarantee — feeds the over/under-full-bar lint. +export function barValueSum(rhythms, den, capacityBeats) { + let sumBeats = 0; + for (const r of rhythms || []) { + const b = valueToBeats(r, den); + if (Number.isFinite(b)) sumBeats += b; + } + const complete = capacityBeats != null + ? Math.abs(sumBeats - capacityBeats) <= _TOL * den + : undefined; + return { sumBeats, complete }; +} diff --git a/src/tempo.js b/src/tempo.js index aec3fcbe..68f05340 100644 --- a/src/tempo.js +++ b/src/tempo.js @@ -3737,7 +3737,13 @@ export function _reprojectAll(toBeats) { // goes out — the live objects keep their beats for continued editing. export function _stripBeat(o) { if (!o || typeof o !== 'object') return o; - const { beat, beatEnd, ...rest } = o; + // `rhythm` (notated value) is a runtime NOTE field, like beat/beatEnd: it + // never rides the seconds-only note wire (feedpak §6.2). Authored values + // persist via the separate §7.6 notation side-file, never the note dict, so + // the wire strip is permanent (NOTE-VALUE-MODEL-DESIGN §5). Non-note objects + // carry no `rhythm`, so pulling it here is a no-op for them. Stripped into the + // shallow clone; the live object keeps its rhythm for continued editing. + const { beat, beatEnd, rhythm, ...rest } = o; return rest; } export function _stripBeatsList(list) { diff --git a/tests/rhythm_value.test.mjs b/tests/rhythm_value.test.mjs new file mode 100644 index 00000000..d14b27bf --- /dev/null +++ b/tests/rhythm_value.test.mjs @@ -0,0 +1,172 @@ +/* + * Notated rhythm-value model — the pure tier (src/rhythm-value.js), the + * foundation of the note-value model (NOTE-VALUE-MODEL-DESIGN §12 slice 1). + * + * Pinned: whole-note magnitude (base × dots × nested tuplet + grace = 0), the + * beat conversion, best-fit (single value, dotted, explicit tuplet context, + * proposed tuplet, greedy tie-decomposition, and the HONEST NULL past tolerance), + * the tick interchange projection incl. the lossy flag, bar summing, and the + * clone/equals/absent helpers that the command layer's absent-vs-present + * discipline rides on. + * + * Run: node tests/rhythm_value.test.mjs + */ +import assert from 'node:assert'; + +const { + RHYTHM_BASES, COMMON_TUPLETS, + rhythmIsAbsent, cloneRhythm, rhythmEquals, + tupletFactor, valueWholeFraction, valueToBeats, + beatsToValue, valueToTicks, ticksToValue, barValueSum, +} = await import('../src/rhythm-value.js'); + +let pass = 0, fail = 0; +function t(name, fn) { + try { fn(); pass++; console.log(' ok ' + name); } + catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } +} +const close = (a, b, tol = 1e-9) => assert.ok(Math.abs(a - b) <= tol, `${a} ≈ ${b}`); +const R = (base, dots = 0, tuplet = null, grace = null) => ({ base, dots, tuplet, grace }); + +// ── magnitude ─────────────────────────────────────────────────────── + +t('valueWholeFraction: plain, dotted, double-dotted', () => { + close(valueWholeFraction(R(4)), 1 / 4); + close(valueWholeFraction(R(8)), 1 / 8); + close(valueWholeFraction(R(1)), 1); + close(valueWholeFraction(R(4, 1)), 3 / 8); // dotted quarter + close(valueWholeFraction(R(4, 2)), 7 / 16); // double-dotted quarter +}); + +t('valueWholeFraction: grace = 0, malformed = NaN', () => { + close(valueWholeFraction(R(8, 0, null, 'acciaccatura')), 0); + assert.ok(Number.isNaN(valueWholeFraction(R(0)))); + assert.ok(Number.isNaN(valueWholeFraction(null))); +}); + +t('tupletFactor: straight, triplet, nested, malformed', () => { + close(tupletFactor(R(4)), 1); + close(tupletFactor({ tuplet: [{ n: 3, m: 2 }] }), 2 / 3); + close(tupletFactor({ tuplet: [{ n: 3, m: 2 }, { n: 3, m: 2 }] }), 4 / 9); + assert.ok(Number.isNaN(tupletFactor({ tuplet: [{ n: 0, m: 2 }] }))); +}); + +t('valueWholeFraction: tuplets (triplet-eighth, nested)', () => { + close(valueWholeFraction(R(8, 0, [{ n: 3, m: 2 }])), 1 / 12); + close(valueWholeFraction(R(16, 0, [{ n: 3, m: 2 }, { n: 3, m: 2 }])), 1 / 36); +}); + +t('valueToBeats: meter-relative (a beat = one denominator unit)', () => { + close(valueToBeats(R(4), 4), 1); // quarter in x/4 = 1 beat + close(valueToBeats(R(8), 4), 0.5); + close(valueToBeats(R(4), 8), 2); // quarter in x/8 = 2 eighth-beats + close(valueToBeats(R(8, 0, [{ n: 3, m: 2 }]), 4), 1 / 3); // triplet-eighth +}); + +// ── best-fit ──────────────────────────────────────────────────────── + +t('beatsToValue: single straight/dotted values, no ties', () => { + let r = beatsToValue(1, 4); + assert.deepStrictEqual(r.rhythm, R(4)); assert.deepStrictEqual(r.ties, []); + assert.deepStrictEqual(beatsToValue(0.5, 4).rhythm, R(8)); + assert.deepStrictEqual(beatsToValue(1.5, 4).rhythm, R(4, 1)); // dotted quarter + assert.deepStrictEqual(beatsToValue(1.75, 4).rhythm, R(4, 2)); // double-dotted quarter + assert.strictEqual(beatsToValue(1.5, 4).rhythm.dots, 1); // prefers the dot over a tie +}); + +t('beatsToValue: greedy tie-decomposition for an un-single span', () => { + // 1.25 beats @ x/4 = 5/16 whole = quarter TIED sixteenth. + const r = beatsToValue(1.25, 4); + assert.deepStrictEqual(r.rhythm, R(4)); + assert.deepStrictEqual(r.ties, [R(16)]); + // the pieces sum back to the span + close(valueToBeats(r.rhythm, 4) + r.ties.reduce((s, p) => s + valueToBeats(p, 4), 0), 1.25); +}); + +t('beatsToValue: explicit tuplet context fits base/dots inside it', () => { + const r = beatsToValue(1 / 3, 4, { tuplet: [{ n: 3, m: 2, group: 7 }] }); + assert.strictEqual(r.rhythm.base, 8); + assert.deepStrictEqual(r.rhythm.tuplet, [{ n: 3, m: 2, group: 7 }]); +}); + +t('beatsToValue: proposes a common tuplet only when asked', () => { + const off = beatsToValue(1 / 3, 4); // no allowTuplets + const on = beatsToValue(1 / 3, 4, { allowTuplets: true }); + assert.strictEqual(off, null); // a 1/12-whole span isn't a straight value + assert.ok(on && on.rhythm.base === 8 && on.rhythm.tuplet[0].n === 3 && on.rhythm.tuplet[0].m === 2); +}); + +t('beatsToValue: HONEST NULL past tolerance and on bad input', () => { + assert.strictEqual(beatsToValue(0.1, 4), null); // not representable within tol + assert.strictEqual(beatsToValue(0, 4), null); + assert.strictEqual(beatsToValue(-1, 4), null); + assert.strictEqual(beatsToValue(1, 0), null); +}); + +// ── interchange ───────────────────────────────────────────────────── + +t('valueToTicks: exact for power-of-two & clean tuplets, lossy for septuplets', () => { + assert.deepStrictEqual(valueToTicks(R(4)), { ticks: 960, lossy: false }); + assert.deepStrictEqual(valueToTicks(R(4, 1)), { ticks: 1440, lossy: false }); // dotted quarter + assert.deepStrictEqual(valueToTicks(R(8, 0, [{ n: 3, m: 2 }])), { ticks: 320, lossy: false }); // triplet-8th + const sept = valueToTicks(R(4, 0, [{ n: 7, m: 4 }])); // 960/7 → not integer + assert.strictEqual(sept.lossy, true); +}); + +t('ticksToValue: inverse for single values, null when it needs a tie', () => { + assert.deepStrictEqual(ticksToValue(960), R(4)); + assert.deepStrictEqual(ticksToValue(1440), R(4, 1)); + const trip = ticksToValue(320); + assert.ok(trip.base === 8 && trip.tuplet[0].n === 3); + assert.strictEqual(ticksToValue(500), null); // not a single clean value + assert.strictEqual(ticksToValue(0), null); +}); + +// ── bar accounting ────────────────────────────────────────────────── + +t('barValueSum: sums beats, flags completeness against capacity', () => { + const full = barValueSum([R(4), R(4), R(4), R(4)], 4, 4); + close(full.sumBeats, 4); assert.strictEqual(full.complete, true); + const short = barValueSum([R(4), R(4), R(4)], 4, 4); + close(short.sumBeats, 3); assert.strictEqual(short.complete, false); + assert.strictEqual(barValueSum([R(4)], 4).complete, undefined); // no capacity → no verdict +}); + +// ── helpers the command layer depends on ──────────────────────────── + +t('rhythmIsAbsent: null/undefined/no-note all count as absent', () => { + assert.strictEqual(rhythmIsAbsent(null), true); + assert.strictEqual(rhythmIsAbsent({}), true); + assert.strictEqual(rhythmIsAbsent({ rhythm: null }), true); + assert.strictEqual(rhythmIsAbsent({ rhythm: R(4) }), false); +}); + +t('cloneRhythm: deep, independent tuplet array; null passes through', () => { + assert.strictEqual(cloneRhythm(null), null); + const src = R(8, 1, [{ n: 3, m: 2, group: 5 }], null); + const cp = cloneRhythm(src); + assert.notStrictEqual(cp, src); + assert.notStrictEqual(cp.tuplet, src.tuplet); + assert.notStrictEqual(cp.tuplet[0], src.tuplet[0]); + cp.tuplet[0].n = 99; + assert.strictEqual(src.tuplet[0].n, 3); // mutation did not leak back +}); + +t('rhythmEquals: base/dots/grace/tuplet sensitivity + null pair', () => { + assert.strictEqual(rhythmEquals(null, null), true); + assert.strictEqual(rhythmEquals(null, R(4)), false); + assert.strictEqual(rhythmEquals(R(4), R(4)), true); + assert.strictEqual(rhythmEquals(R(4), R(8)), false); + assert.strictEqual(rhythmEquals(R(4), R(4, 1)), false); + assert.strictEqual(rhythmEquals(R(4, 0, null, 'acciaccatura'), R(4)), false); + assert.strictEqual(rhythmEquals(R(8, 0, [{ n: 3, m: 2 }]), R(8, 0, [{ n: 3, m: 2 }])), true); + assert.strictEqual(rhythmEquals(R(8, 0, [{ n: 3, m: 2 }]), R(8, 0, [{ n: 5, m: 4 }])), false); +}); + +t('exports present', () => { + assert.ok(Array.isArray(RHYTHM_BASES) && RHYTHM_BASES.includes(128)); + assert.ok(Array.isArray(COMMON_TUPLETS) && COMMON_TUPLETS[0].n === 3); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/tests/rhythm_value_cmd.test.mjs b/tests/rhythm_value_cmd.test.mjs new file mode 100644 index 00000000..3a169daf --- /dev/null +++ b/tests/rhythm_value_cmd.test.mjs @@ -0,0 +1,137 @@ +/* + * Note-value model — the STATEFUL wiring (NOTE-VALUE-MODEL-DESIGN §12 slice 1), + * driven through the REAL EditHistory / reconstructChords / save-strip, not stubs. + * + * Pinned (each would fail on main): + * - SetRhythmValueCmd exec → rollback → redo restores the model EXACTLY, + * including the ABSENT-vs-present distinction (the sentinel snapshot) and the + * dirty flag; and the value is CLONED per note (no aliasing between notes). + * - `note.rhythm` SURVIVES reconstructChords on chord members (the field-mapper + * vanish hazard) while a rhythm-free note gains no key. + * - `note.rhythm` NEVER rides the seconds-only note wire: `_stripBeat` peels it + * off the save body while the LIVE note keeps it (Test #6, no signature poison). + * + * Run: node tests/rhythm_value_cmd.test.mjs + */ +import assert from 'node:assert'; + +globalThis.document = globalThis.document || { + getElementById: () => null, addEventListener: () => {}, activeElement: null, +}; +globalThis.localStorage = globalThis.localStorage || { getItem: () => null, setItem: () => {} }; +globalThis.window = globalThis.window || globalThis; + +const { EditHistory } = await import('../src/history.js'); +const { S } = await import('../src/state.js'); +const { notes } = await import('../src/notes.js'); +const { SetRhythmValueCmd } = await import('../src/commands.js'); +const { reconstructChords } = await import('../src/chords.js'); +const { _stripBeat, _stripBeatsFromSaveBody } = await import('../src/tempo.js'); +const { rhythmEquals } = await import('../src/rhythm-value.js'); + +let pass = 0, fail = 0; +function t(name, fn) { + try { fn(); pass++; console.log(' ok ' + name); } + catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } +} +const R = (base, dots = 0, tuplet = null, grace = null) => ({ base, dots, tuplet, grace }); +const N = (time, string, fret, sustain = 0) => ({ time, string, fret, sustain, techniques: {} }); + +function seedArr(noteList) { + const arr = { + name: 'Lead', tuning: [0, 0, 0, 0, 0, 0], capo: 0, + notes: noteList, chords: [], chord_templates: [], handshapes: [], + }; + S.arrangements = [arr]; + S.currentArr = 0; + S.sel = new Set(); + S.drumEditMode = false; S.tempoMapMode = false; S.partsViewMode = false; + S.dirty = false; + S.history = new EditHistory(); + return arr; +} + +t('SetRhythmValueCmd: exec sets value, rollback restores ABSENCE exactly', () => { + seedArr([N(1, 0, 5), N(2, 1, 3)]); + const before0 = 'rhythm' in notes()[0]; + S.history.exec(new SetRhythmValueCmd([0], R(8, 1))); // dotted eighth on note 0 + assert.ok(rhythmEquals(notes()[0].rhythm, R(8, 1))); + assert.strictEqual('rhythm' in notes()[1], false); // untouched note stays absent + S.history.doUndo(); + assert.strictEqual('rhythm' in notes()[0], before0); // key GONE again, not left as {}/null + assert.strictEqual(notes()[0].rhythm, undefined); +}); + +t('SetRhythmValueCmd: exec → rollback → redo reproduces the post-exec state', () => { + seedArr([N(1, 0, 5)]); + S.history.exec(new SetRhythmValueCmd([0], R(4))); + assert.ok(rhythmEquals(notes()[0].rhythm, R(4))); + S.history.doUndo(); + assert.strictEqual(notes()[0].rhythm, undefined); + S.history.doRedo(); + assert.ok(rhythmEquals(notes()[0].rhythm, R(4))); +}); + +t('SetRhythmValueCmd: replacing a value restores the PRIOR value on undo', () => { + seedArr([N(1, 0, 5)]); + S.history.exec(new SetRhythmValueCmd([0], R(4))); // quarter + S.history.exec(new SetRhythmValueCmd([0], R(8, 0, [{ n: 3, m: 2, group: 1 }]))); // → triplet-8th + assert.ok(rhythmEquals(notes()[0].rhythm, R(8, 0, [{ n: 3, m: 2, group: 1 }]))); + S.history.doUndo(); + assert.ok(rhythmEquals(notes()[0].rhythm, R(4))); // back to the quarter, not absent +}); + +t('SetRhythmValueCmd: clear (value=null) removes the key, undo brings it back', () => { + seedArr([N(1, 0, 5)]); + S.history.exec(new SetRhythmValueCmd([0], R(2))); + S.history.exec(new SetRhythmValueCmd([0], null)); // clear + assert.strictEqual(notes()[0].rhythm, undefined); + S.history.doUndo(); + assert.ok(rhythmEquals(notes()[0].rhythm, R(2))); +}); + +t('SetRhythmValueCmd: value is CLONED per note — no aliasing between notes', () => { + seedArr([N(1, 0, 5), N(1, 1, 3)]); // (same time — a chord, but indices still distinct) + S.history.exec(new SetRhythmValueCmd([0, 1], R(8, 0, [{ n: 3, m: 2, group: 1 }]))); + assert.notStrictEqual(notes()[0].rhythm, notes()[1].rhythm); + assert.notStrictEqual(notes()[0].rhythm.tuplet, notes()[1].rhythm.tuplet); + notes()[0].rhythm.tuplet[0].n = 99; + assert.strictEqual(notes()[1].rhythm.tuplet[0].n, 3); // mutating one did not touch the other +}); + +t('reconstructChords: rhythm SURVIVES on chord members; absent stays absent', () => { + // two notes at the SAME time = a chord; one carries a value, one doesn't. + const withVal = { ...N(1, 0, 5), rhythm: R(8, 1) }; + const noVal = N(1, 2, 7); + seedArr([withVal, noVal, N(3, 1, 4)]); // + a lone note at t=3 + reconstructChords(); + const arr = S.arrangements[0]; + assert.strictEqual(arr.chords.length, 1); + const members = arr.chords[0].notes; + const m0 = members.find(m => m.string === 0); + const m2 = members.find(m => m.string === 2); + assert.ok(rhythmEquals(m0.rhythm, R(8, 1)), 'chord member kept its value'); + assert.strictEqual('rhythm' in m2, false, 'value-free member gained no rhythm key'); +}); + +t('_stripBeat: rhythm is peeled off the wire clone; the live note keeps it', () => { + const live = { ...N(1, 0, 5), beat: 2, beatEnd: 3, rhythm: R(4, 1) }; + const wire = _stripBeat(live); + assert.strictEqual('rhythm' in wire, false); // never on the seconds-only wire + assert.strictEqual('beat' in wire, false); + assert.ok(rhythmEquals(live.rhythm, R(4, 1)), 'live note untouched (clone semantics)'); +}); + +t('_stripBeatsFromSaveBody: strips rhythm across notes and chord members', () => { + const body = { + notes: [{ ...N(1, 0, 5), rhythm: R(4) }], + chords: [{ time: 2, notes: [{ ...N(2, 0, 5), rhythm: R(8) }, { ...N(2, 1, 3), rhythm: R(8) }] }], + }; + const out = _stripBeatsFromSaveBody(body); + assert.strictEqual('rhythm' in out.notes[0], false); + assert.strictEqual('rhythm' in out.chords[0].notes[0], false); + assert.strictEqual('rhythm' in out.chords[0].notes[1], false); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0);