diff --git a/CHANGELOG.md b/CHANGELOG.md index 18d447e9..4851da66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **ES-module migration, step 9b — the painters (R2).** `src/draw.js` (`main.js` + 19,963 → 19,340): the lane and grid backgrounds, beat bar, section-coverage + strip, the fretted and piano-roll note painters, cursor and marquee, plus the + song-key highlight state they consult and `canvasH`. `drawNow()` — the per-frame + orchestrator — stays in `main.js` and calls in, so the edge is one-way and no + injected seam was needed. `drawWaveform` stays too: it paints the onset-strip + and bookmark overlays, which live in `main.js`. + The suggested-position mark `WeakSet` moves to `notes.js` (it is note metadata), + and the note-body constants `MIN_NOTE_W`/`NOTE_PAD` join `geometry.js`. + **`_coverageEditGen` is now `editGen` in `state.js`.** It was never a drawing + concern: three memos key on it — the coverage strip, the chord-at-cursor readout + and the drum-limb lint — because an in-place note-time move keeps the notes + array's identity *and* length, so a cheap cache key cannot see the change. + `EditHistory._afterEdit()` calls the exported `bumpEditGen()`, since a counter + cannot be written across a module boundary. + Five more suites leave the slicer path (`section_coverage`, + `suggest_position_persist`, and the three suggest-mark sandboxes now inject the + real `WeakSet`); `key_highlight_hoist` keeps its source-shape assertions and + reads `src/draw.js`. + - **ES-module migration, step 9a — the render surface (R2).** `src/canvas.js`: the `` element, its 2D context, and `DPR`. `canvas` and `ctx` are live `export let` bindings whose sole writer, `setCanvas`, moved with them — so the diff --git a/src/draw.js b/src/draw.js new file mode 100644 index 00000000..2dc982dd --- /dev/null +++ b/src/draw.js @@ -0,0 +1,684 @@ +/* Slopsmith Arrangement Editor — the painters. + * + * Everything that puts pixels on the chart canvas: lane and grid backgrounds, the + * beat bar, the section-coverage strip, the notes (fretted and piano-roll), the + * cursor and the marquee — plus the song-key highlight state the note painters + * consult, and `canvasH`, which depends on the roll's lane count. + * + * Reads `ctx` (src/canvas.js), the geometry, the lane and keys models, and `S`. + * It does NOT know the per-frame orchestrator: `drawNow()` stays in main.js and + * calls in here, so the edge is one-way and no seam is needed. `drawWaveform` + * stays there too — it paints the onset strip and bookmark overlays, which live + * in main.js. + */ + +import { ctx } from './canvas.js'; +import { + BEAT_H, + LABEL_W, + LANE_H, + MIN_NOTE_W, + NOTE_PAD, + WAVEFORM_H, + laneToY, + strToY, + timeToX, +} from './geometry.js'; +import { + PIANO_LANE_H, + PIANO_OCTAVE_COLORS, + _rollMidiForNote, + _rollPitchCtx, + isBlackKey, + isKeysMode, + midiToNote, + midiToY, + noteToMidi, + pianoLaneCount, + pianoRange, +} from './keys.js'; +import { + _openMidiForArr, + _soundingPitchPure, + _stringCountFor, + colorForLane, + laneLabels, + laneToStr, + lanes, + strToLane, +} from './lanes.js'; +import { _isSuggested, notes } from './notes.js'; +import { + SCALE_INTERVALS, + _SCALE_DEGREE_LABELS, + _pcInScalePure, + _scaleDegreeColorPure, + _scaleDegreeSemisPure, +} from './theory.js'; +import { S, editGen } from './state.js'; + +export function drawLanes(w) { + if (isKeysMode()) return drawPianoLanes(w); + const L = lanes(); + for (let l = 0; l < L; l++) { + const y = laneToY(l); + ctx.fillStyle = l % 2 === 0 ? '#0c0c1c' : '#0f0f24'; + ctx.fillRect(LABEL_W, y, w - LABEL_W, LANE_H); + // Separator + ctx.strokeStyle = '#1a1a35'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(LABEL_W, y + LANE_H); + ctx.lineTo(w, y + LANE_H); + ctx.stroke(); + } +} + +// ── Song key / scale + in-key highlight (piano roll) ───────────────── +// S.editorKey = { tonic: 0..11, scale: } | null. The +// key is a per-song editor preference (localStorage keyed by S.filename, +// never the feedpak — this is a view aid, not chart data). The highlight +// on/off is a global editor preference. Neither is authoritative harmony +// data; a later PR authors keys.json regions and this reads from them. +let _editorKeyLoadedFor = null; + +export function editorKeyHighlightEnabled() { + try { return localStorage.getItem('editorKeyHighlight') === '1'; } + catch (_) { return false; } +} + +// Lazily load the saved key for the current song (called from the control +// refresh, which runs every draw) so no song-load-path edit is needed. +export function _loadEditorKeyIfNeeded() { + if (_editorKeyLoadedFor === S.filename) return; + _editorKeyLoadedFor = S.filename; + S.editorKey = null; + // No filename yet (unsaved song): don't read a bare `editorKey:` slot — + // otherwise every unsaved song would share the same stored key. + if (!S.filename) return; + try { + const raw = localStorage.getItem('editorKey:' + (S.filename || '')); + if (raw) { + const k = JSON.parse(raw); + if (Number.isInteger(k.tonic) && SCALE_INTERVALS[k.scale]) { + S.editorKey = { tonic: k.tonic, scale: k.scale }; + } + } + } catch (_) { /* ignore */ } +} + +export function _persistEditorKey() { + // No filename yet (unsaved song): don't write a bare `editorKey:` slot that + // every unsaved song would collide on; the in-memory key still applies. + if (!S.filename) return; + try { + const key = 'editorKey:' + (S.filename || ''); + if (S.editorKey) localStorage.setItem(key, JSON.stringify(S.editorKey)); + else localStorage.removeItem(key); + } catch (_) { /* ignore */ } +} + +// The active highlight settings, or null when off / unset / invalid — the +// single guard every render path consults, so a bad state can't paint. +function _activeKeyHighlight() { + if (!editorKeyHighlightEnabled()) return null; + const k = S.editorKey; + if (!k || !Number.isInteger(k.tonic) || !SCALE_INTERVALS[k.scale]) return null; + return k; +} + +function drawPianoLanes(w) { + const hl = _activeKeyHighlight(); + for (let midi = pianoRange.lo; midi <= pianoRange.hi; midi++) { + const y = midiToY(midi); + const black = isBlackKey(midi); + ctx.fillStyle = black ? '#0a0a1a' : '#0e0e22'; + ctx.fillRect(LABEL_W, y, w - LABEL_W, PIANO_LANE_H); + + // Out-of-key rows get a neutral desaturating wash (never red — + // chromaticism isn't an error). In-key rows are left as drawn. + if (hl && !_pcInScalePure(midi % 12, hl.tonic, hl.scale)) { + ctx.fillStyle = 'rgba(20,20,34,0.55)'; + ctx.fillRect(LABEL_W, y, w - LABEL_W, PIANO_LANE_H); + } + + // Octave boundary (C notes) + if (midi % 12 === 0) { + ctx.strokeStyle = '#2a2a55'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(LABEL_W, y + PIANO_LANE_H); + ctx.lineTo(w, y + PIANO_LANE_H); + ctx.stroke(); + } + } +} + +export function drawGrid(w) { + const st = S.scrollX - 1; + const et = S.scrollX + (w - LABEL_W) / S.zoom + 1; + const laneBottom = isKeysMode() + ? WAVEFORM_H + pianoLaneCount() * PIANO_LANE_H + : WAVEFORM_H + lanes() * LANE_H; + for (const b of S.beats) { + if (b.time < st || b.time > et) continue; + const x = timeToX(b.time); + if (x < LABEL_W || x > w) continue; + const meas = b.measure > 0; + ctx.strokeStyle = meas ? '#2a2a50' : '#16162c'; + ctx.lineWidth = meas ? 1.5 : 0.5; + ctx.beginPath(); + ctx.moveTo(x, WAVEFORM_H); + ctx.lineTo(x, laneBottom); + ctx.stroke(); + } +} + +// Per-section spans with a hasContent flag: does any charted note-time fall +// in the section's [start, nextStart) window? The last section runs to the +// song end (open-ended when duration is unknown), and is INCLUSIVE of its +// upper edge — extended to the final note time when notes sit at or past a +// stale/short duration, so trailing content is never invisible. A note on an +// INTERIOR boundary belongs to the LATER section (half-open). Sections are +// sorted defensively and non-finite start_times dropped. Returns [] when +// there are no sections. Ambient progress — never a score. +export function _sectionCoveragePure(sections, noteTimes, duration) { + if (!Array.isArray(sections) || !sections.length) return []; + const secs = sections + .filter(s => s && Number.isFinite(Number(s.start_time))) + .map(s => Number(s.start_time)) + .sort((a, b) => a - b); + if (!secs.length) return []; + const dur = (Number.isFinite(duration) && duration > 0) ? duration : Infinity; + const times = Array.isArray(noteTimes) + ? noteTimes.map(Number).filter(Number.isFinite) + : []; + // The final span has no later section to bound it, so it owns every + // trailing note: extend its end past `dur` to the last note time when a + // note sits at/after the (possibly stale/short) duration, and treat its + // upper edge as INCLUSIVE. Interior spans stay half-open [start, next) so + // a note on an interior boundary still belongs to the LATER section — the + // inclusive edge is only the outermost one, so there's no double-count. + let maxT = -Infinity; + for (const t of times) if (t > maxT) maxT = t; + const lastEnd = maxT > dur ? maxT : dur; // may be Infinity + const out = []; + for (let i = 0; i < secs.length; i++) { + const start = secs[i]; + const isLast = (i + 1 >= secs.length); + const end = isLast ? lastEnd : secs[i + 1]; // may be Infinity (last) + let hasContent = false; + for (const t of times) { + if (t >= start && (isLast ? t <= end : t < end)) { hasContent = true; break; } + } + out.push({ start, end, hasContent }); + } + return out; +} + +// Note times of the ACTIVE arrangement (flattened — chord notes already live +// in notes() for the current arrangement). +function _currentNoteTimes() { + return notes().map(n => n.time); +} + +// Cross-frame memo for the section-coverage strip. drawSections runs on every +// requestAnimationFrame during playback, but coverage only changes on +// note/section/duration edits — never while the cursor moves. Recomputing the +// pure helper (an O(N) note-time pass plus an O(sections×notes) scan) every +// frame is the same per-frame O(N) trap the lanes()/laneLabels() caches in +// draw() deliberately avoid, so memoize behind a cheap key. In-place note-time +// moves keep the notes-array identity AND length, so they're caught by +// `editGen` (src/state.js), bumped by EditHistory._afterEdit() — the edit-contract +// hook every mutation flows through (constitution IV). The section fingerprint +// is O(sections) (a handful, negligible beside the O(notes) scan skipped) and +// catches add/remove/retime/reorder without wiring every section mutation site. +let _covCache = { key: null, notesRef: null, value: [] }; +function _sectionCoverage() { + const secs = S.sections || []; + const ns = notes(); + // A live note-move drag mutates note.time in place every mousemove and + // only commits to EditHistory on mouseUp, so `editGen` has not + // bumped yet — bypass the memo for the drag's duration to keep the strip + // live (matching the pre-memo per-frame recompute). This is the ONE + // interactive path that changes note times without an edit-gen bump; the + // perf target (playback) has no active drag, so it still hits the cache. + if (S.drag && S.drag.type === 'move') { + return _sectionCoveragePure(secs, _currentNoteTimes(), S.duration || 0); + } + let secSig = secs.length + ':'; + for (const s of secs) secSig += (s ? s.start_time : 'x') + ','; + const key = editGen + '|' + S.currentArr + '|' + ns.length + + '|' + (S.duration || 0) + '|' + secSig; + if (key !== _covCache.key || _covCache.notesRef !== ns) { + _covCache = { + key, notesRef: ns, + value: _sectionCoveragePure(secs, _currentNoteTimes(), S.duration || 0), + }; + } + return _covCache.value; +} + +export function drawSections(w) { + const st = S.scrollX - 1; + const et = S.scrollX + (w - LABEL_W) / S.zoom + 1; + const laneBottom = isKeysMode() + ? WAVEFORM_H + pianoLaneCount() * PIANO_LANE_H + : WAVEFORM_H + lanes() * LANE_H; + // Completeness strip: a thin band at the top of the lane area tinting + // each section by whether the active arrangement has notes in it — an + // at-a-glance "where is this chart still empty", drawn under the section + // labels/lines below. Neutral, no percentage, no red. + const cov = _sectionCoverage(); + for (const c of cov) { + const x0 = Math.max(LABEL_W, timeToX(c.start)); + const x1 = Math.min(w, timeToX(c.end)); + if (x1 <= x0) continue; + ctx.fillStyle = c.hasContent ? 'rgba(120,170,255,0.20)' : 'rgba(255,255,255,0.035)'; + ctx.fillRect(x0, WAVEFORM_H, x1 - x0, 3); + } + ctx.font = '9px monospace'; + ctx.textBaseline = 'top'; + for (const s of S.sections) { + if (s.start_time < st || s.start_time > et) continue; + const x = timeToX(s.start_time); + if (x < LABEL_W || x > w) continue; + // Dashed vertical line + ctx.strokeStyle = '#e8c04060'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(x, WAVEFORM_H); + ctx.lineTo(x, laneBottom); + ctx.stroke(); + ctx.setLineDash([]); + // Label at top of lanes + ctx.fillStyle = '#e8c040'; + ctx.textAlign = 'left'; + ctx.fillText(s.name, x + 3, WAVEFORM_H + 2); + } +} + +// Y coordinate of the beat bar's top edge. Branches on keys mode +// because keys lanes use a different per-lane height. `canvasH`, +// `_anchorLaneTopY`, `drawBeatBar` all call through here so they +// can't drift as new strips are added. +export function _beatBarTopY() { + return isKeysMode() + ? WAVEFORM_H + pianoLaneCount() * PIANO_LANE_H + : WAVEFORM_H + lanes() * LANE_H; +} + +// Highlight the bar range selected for "Loop in 3D" — a translucent blue +// band with bright edges spanning the full chart height, drawn under the +// notes so they stay legible. +export function drawBarSel(w) { + if (!S.barSel) return; + const x1 = timeToX(S.barSel.startTime); + const x2 = timeToX(S.barSel.endTime); + if (x2 < LABEL_W || x1 > w) return; + const cx1 = Math.max(LABEL_W, x1); + const cx2 = Math.min(w, x2); + const bot = canvasH(); + ctx.save(); + ctx.fillStyle = 'rgba(80,160,255,0.10)'; + ctx.fillRect(cx1, 0, Math.max(0, cx2 - cx1), bot); + ctx.strokeStyle = 'rgba(80,160,255,0.7)'; + ctx.lineWidth = 1.5; + if (x1 >= LABEL_W && x1 <= w) { ctx.beginPath(); ctx.moveTo(x1, 0); ctx.lineTo(x1, bot); ctx.stroke(); } + if (x2 >= LABEL_W && x2 <= w) { ctx.beginPath(); ctx.moveTo(x2, 0); ctx.lineTo(x2, bot); ctx.stroke(); } + ctx.restore(); +} + +export function drawBeatBar(w) { + const y = _beatBarTopY(); + ctx.fillStyle = '#08081a'; + ctx.fillRect(0, y, w, BEAT_H); + ctx.fillStyle = '#08081a'; + ctx.fillRect(0, y, LABEL_W, BEAT_H); + + // Left gutter label — identifies the strip and hints that it's + // drag-to-select for "Loop in 3D". + ctx.fillStyle = S.barSel ? '#6aa0ff' : '#667'; + ctx.font = '8px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('⇆ bars', LABEL_W / 2, y + BEAT_H / 2); + + ctx.fillStyle = '#555'; + ctx.font = '9px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const st = S.scrollX - 1; + const et = S.scrollX + (w - LABEL_W) / S.zoom + 1; + for (const b of S.beats) { + if (b.measure <= 0 || b.time < st || b.time > et) continue; + const x = timeToX(b.time); + if (x < LABEL_W || x > w) continue; + ctx.fillText(String(b.measure), x, y + BEAT_H / 2); + } +} + +export function drawLabels(w) { + // Waveform label + ctx.fillStyle = '#0a0a1a'; + ctx.fillRect(0, 0, LABEL_W, WAVEFORM_H); + ctx.fillStyle = '#555'; + ctx.font = '9px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('Audio', LABEL_W / 2, WAVEFORM_H / 2); + + if (isKeysMode()) return drawPianoLabels(w); + + // String labels. `labels` is in RS string-index order (low → high); lanes + // are drawn high-to-low (lane 0 = top = highest string). Colours come + // from `colorForLane()` which looks up the string's pitch label in + // `STRING_LABEL_COLORS` — so a 4-string bass G/D/A/E reads orange/blue/ + // yellow/red just like the same pitches on a 6-string guitar. + const L = lanes(); + const labels = laneLabels(); + for (let l = 0; l < L; l++) { + const y = laneToY(l); + ctx.fillStyle = '#0a0a1a'; + ctx.fillRect(0, y, LABEL_W, LANE_H); + const s = laneToStr(l); + ctx.fillStyle = colorForLane(l); + ctx.font = 'bold 12px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(labels[s] || String(s), LABEL_W / 2, y + LANE_H / 2); + } +} + +// The left axis of the piano roll is drawn as an actual keyboard gutter: one +// key per MIDI row, white/black shaded like a real keyboard laid on its side, +// C rows labelled with their octave. It's clickable (see onMouseDown) to +// audition the pitch. Black keys are inset from the front (right) edge so the +// white keys' tails read between them, exactly as on a side-on keyboard. +const _GUTTER_BLACK_INSET = 0.42; // fraction of LABEL_W the black key leaves as white tail on the right +function drawPianoLabels() { + ctx.font = '8px monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + const blackW = LABEL_W * (1 - _GUTTER_BLACK_INSET); + for (let midi = pianoRange.lo; midi <= pianoRange.hi; midi++) { + const y = midiToY(midi); + const black = isBlackKey(midi); + // White base for every row (the black key's tail shows on the right). + ctx.fillStyle = '#c9c9d6'; + ctx.fillRect(0, y, LABEL_W, PIANO_LANE_H); + if (black) { + // Black key: a darker bar from the back (left) edge, leaving the + // white tail on the right — the side-on keyboard read. + ctx.fillStyle = '#1b1b2a'; + ctx.fillRect(0, y, blackW, PIANO_LANE_H); + } + // Row separators only between two adjacent WHITE keys (E–F, B–C) — the + // spots a real keyboard has no black key between, so the boundary needs + // a drawn line to read as two distinct keys. + if (!black && !isBlackKey(midi + 1) && midi < pianoRange.hi) { + ctx.strokeStyle = '#9a9aac'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(0, y + 0.5); + ctx.lineTo(LABEL_W, y + 0.5); + ctx.stroke(); + } + // Label C rows with their octave (e.g. C4), on the white tail so it + // stays legible whether or not the row is a black key. + if (midi % 12 === 0 && PIANO_LANE_H >= 7) { + ctx.fillStyle = '#3a3a4a'; + ctx.fillText(midiToNote(midi), LABEL_W - blackW + 2, y + PIANO_LANE_H / 2); + } + } + // Front-edge divider so the keyboard reads as a panel distinct from the grid. + ctx.strokeStyle = '#2a2a55'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(LABEL_W - 0.5, WAVEFORM_H); + ctx.lineTo(LABEL_W - 0.5, midiToY(pianoRange.lo) + PIANO_LANE_H); + ctx.stroke(); +} + +export function drawNotes(w) { + const nn = notes(); + const st = S.scrollX - 2; + const et = S.scrollX + (w - LABEL_W) / S.zoom + 2; + const keysMode = isKeysMode(); + // Hoist the active-highlight lookup out of the per-note loop: it reads + // localStorage, so resolving it once per draw (not once per visible note) + // keeps drawNotes off the synchronous-storage path during playback/scroll. + const hl = _activeKeyHighlight(); + // Fretted lanes resolve notes to SOUNDING pitch (tuning + capo + fret) — + // the whole context is hoisted here so _drawNote does zero per-note + // arrangement work. `ghl` is null whenever the highlight can't apply. + let ghl = null; + if (hl && !keysMode) { + const arr = S.arrangements[S.currentArr]; + if (arr) { + const laneCount = _stringCountFor(arr); + const tuning = (Array.isArray(arr.tuning) ? arr.tuning : []).slice(0, laneCount); + while (tuning.length < laneCount) tuning.push(0); + ghl = { + hl, + openMidi: _openMidiForArr(arr, laneCount), + tuning, + capo: Number(arr.capo) || 0, + }; + } + } + // Fretted-in-roll draws at SOUNDING pitch — one hoisted context, and + // the same mapping hit-testing/marquee use (they must never disagree). + const rctx = keysMode ? _rollPitchCtx() : null; + for (let i = 0; i < nn.length; i++) { + const n = nn[i]; + if (n.time + (n.sustain || 0) < st || n.time > et) continue; + if (keysMode) { + const midi = _rollMidiForNote(n, rctx); + if (midi !== null) _drawPianoNote(n, S.sel.has(i), hl, midi, !!rctx); + } else { + _drawNote(n, S.sel.has(i), ghl); + } + } +} + +function _drawNote(n, selected, ghl) { + const x = timeToX(n.time); + const y = strToY(n.string) + NOTE_PAD; + const sw = Math.max(MIN_NOTE_W, (n.sustain || 0) * S.zoom); + const h = LANE_H - NOTE_PAD * 2; + const color = colorForLane(strToLane(n.string)); + // Suggested (machine-picked, unconfirmed) position: render provisional — + // dimmer body + dashed border — so an unresolved fingering reads at a glance. + const suggested = _isSuggested(n); + + // In-key highlight (mirrors the piano roll's treatment): out-of-key + // notes dim, never redden — chromaticism is not an error. Membership + // uses the SOUNDING pitch (tuning + capo + fret); an unresolvable + // pitch stays fully lit rather than falsely flagged. + let outOfKey = false; + let degMidi = null; // sounding pitch, hoisted for the scale-degree overlay + if (ghl) { + degMidi = _soundingPitchPure( + ghl.openMidi, ghl.tuning, ghl.capo, n.string, n.fret); + outOfKey = degMidi !== null + && !_pcInScalePure(((degMidi % 12) + 12) % 12, ghl.hl.tonic, ghl.hl.scale); + } + + // Body + ctx.fillStyle = color + (suggested || outOfKey ? '55' : 'cc'); + ctx.beginPath(); + ctx.roundRect(x, y, sw, h, 3); + ctx.fill(); + + // Border + if (selected) { + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 2; + } else { + ctx.strokeStyle = color; + ctx.lineWidth = suggested ? 1 : 0.5; + } + if (suggested) ctx.setLineDash([3, 2]); + ctx.beginPath(); + ctx.roundRect(x, y, sw, h, 3); + ctx.stroke(); + if (suggested) ctx.setLineDash([]); + + // Fret number + ctx.fillStyle = outOfKey ? 'rgba(255,255,255,0.6)' : '#fff'; + ctx.font = 'bold 13px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(String(n.fret), x + Math.min(sw, MIN_NOTE_W) / 2, y + h / 2); + + // Scale-degree overlay (only when the key highlight is active): a small + // degree label in the note's top-right, coloured by role so the 1/3/5/7 + // skeleton pops. Out-of-key notes still show their chromatic degree, + // dimmed — so a fretted line reads as scale degrees at a glance. Skipped + // when the sounding pitch is unresolvable (degMidi null). + if (ghl && degMidi !== null) { + const semis = _scaleDegreeSemisPure(((degMidi % 12) + 12) % 12, ghl.hl.tonic); + if (semis >= 0) { + ctx.fillStyle = _scaleDegreeColorPure(semis) + (outOfKey ? '99' : 'ff'); + ctx.font = '7px monospace'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'top'; + ctx.fillText(_SCALE_DEGREE_LABELS[semis], x + Math.min(sw, MIN_NOTE_W) - 2, y + 1); + } + } + + // Technique badges + const techs = n.techniques || {}; + const badges = []; + if (techs.hammer_on) badges.push('H'); + if (techs.pull_off) badges.push('P'); + if (techs.slide_to >= 0) badges.push('/' + techs.slide_to); + if (techs.slide_unpitch_to >= 0) badges.push('↓' + techs.slide_unpitch_to); + if (techs.bend > 0) badges.push('b'); + if (techs.harmonic) badges.push('*'); + if (techs.harmonic_pinch) badges.push('*P'); + if (techs.palm_mute) badges.push('PM'); + if (techs.fret_hand_mute) badges.push('FM'); + if (techs.tap) badges.push('T'); + if (techs.slap) badges.push('S'); + if (techs.pluck) badges.push('P!'); + if (techs.tremolo) badges.push('~'); + if (techs.vibrato) badges.push('V'); + if (techs.mute) badges.push('x'); + if (techs.link_next) badges.push('→'); + if (techs.ignore) badges.push('I'); + if (badges.length) { + ctx.fillStyle = '#ffffffbb'; + ctx.font = '7px monospace'; + ctx.textAlign = 'left'; + ctx.fillText(badges.join(' '), x + 2, y + 9); + } + + // Sustain tail + if (sw > MIN_NOTE_W) { + ctx.fillStyle = color + '40'; + ctx.fillRect(x + MIN_NOTE_W, y + h / 2 - 2, sw - MIN_NOTE_W, 4); + } +} + +function _drawPianoNote(n, selected, hl, midi, fretted) { + // `midi` is resolved by the caller through _rollMidiForNote — keys + // packing or fretted sounding pitch — so this renderer never guesses. + if (midi === undefined) midi = noteToMidi(n.string, n.fret); + if (midi < pianoRange.lo || midi > pianoRange.hi) return; + + const x = timeToX(n.time); + const y = midiToY(midi) + 1; + const sw = Math.max(MIN_NOTE_W, (n.sustain || 0) * S.zoom); + const h = PIANO_LANE_H - 2; + const octave = Math.floor(midi / 12); + // Fretted-in-roll notes wear their STRING's lane color, not the octave + // color: the Y axis already says the pitch, so the color's job is to + // say WHERE the pitch is played — which is exactly what the Shift+↑/↓ + // position cycle changes, making a cycle step visible as a color flip + // at a fixed Y (VA.5). + const color = fretted + ? colorForLane(strToLane(n.string)) + : PIANO_OCTAVE_COLORS[Math.min(octave, PIANO_OCTAVE_COLORS.length - 1)]; + // Suggested (machine-picked, unconfirmed) position — render provisional + // (dimmer + dashed). Only fretted-in-roll adds are ever marked. + const suggested = _isSuggested(n); + + // In-key highlight: dim out-of-key notes (lower body alpha) so chromatic + // notes read as chromatic without being hidden or flagged as wrong. + // `hl` is resolved once per draw in drawNotes (see hoist there) and passed + // in, so this path never reads localStorage per note. + const outOfKey = !!hl && !_pcInScalePure(midi % 12, hl.tonic, hl.scale); + + // Body + ctx.fillStyle = color + (suggested || outOfKey ? '55' : 'cc'); + ctx.beginPath(); + ctx.roundRect(x, y, sw, h, 2); + ctx.fill(); + + // Border + if (selected) { + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 2; + } else { + ctx.strokeStyle = color; + ctx.lineWidth = suggested ? 1 : 0.5; + } + if (suggested) ctx.setLineDash([3, 2]); + ctx.beginPath(); + ctx.roundRect(x, y, sw, h, 2); + ctx.stroke(); + if (suggested) ctx.setLineDash([]); + + // Note name — or, for fretted-in-roll, the s·f position chip (the + // note name is redundant with the Y axis there; string·fret is the + // one fact the roll would otherwise hide). Raw string index, matching + // the Strings modal's "String N" labels. + if (sw >= 20 && h >= 8) { + ctx.fillStyle = '#000'; + ctx.font = `bold ${Math.min(9, h - 1)}px monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const label = fretted ? (n.string + '·' + n.fret) : midiToNote(midi); + ctx.fillText(label, x + Math.min(sw, 24) / 2, y + h / 2); + } +} + +export function drawCursor(w, h) { + const x = timeToX(S.cursorTime); + if (x < LABEL_W || x > w) return; + ctx.strokeStyle = '#ff4444'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(x, 0); + // Extend the playhead through every time-axis-aligned strip + // (waveform, tone lane, lanes, beat bar, anchor lane). `canvasH()` + // stops at the beat-bar bottom, which would clip the cursor above + // the anchor lane. + ctx.lineTo(x, h); + ctx.stroke(); +} + +export function drawSelectionRect() { + if (!S.drag || (S.drag.type !== 'select' && S.drag.type !== 'drum-select')) return; + // The drum marquee only materialises once the pointer has actually + // moved — a stationary press is a click-to-add-hit, not a select. + if (S.drag.type === 'drum-select' && !S.drag.moved) return; + const x1 = Math.min(S.drag.startX, S.drag.curX); + const y1 = Math.min(S.drag.startY, S.drag.curY); + const x2 = Math.max(S.drag.startX, S.drag.curX); + const y2 = Math.max(S.drag.startY, S.drag.curY); + ctx.strokeStyle = '#4080e0'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); + ctx.setLineDash([]); + ctx.fillStyle = '#4080e018'; + ctx.fillRect(x1, y1, x2 - x1, y2 - y1); +} + +function canvasH() { + return _beatBarTopY() + BEAT_H; +} diff --git a/src/geometry.js b/src/geometry.js index e5966068..e6fe5e55 100644 --- a/src/geometry.js +++ b/src/geometry.js @@ -17,6 +17,10 @@ import { laneToStr, lanes, strToLane } from './lanes.js'; export const LABEL_W = 52; +// Note-body geometry, shared by the painters and by hit-testing. +export const MIN_NOTE_W = 18; +export const NOTE_PAD = 3; + // ─── Anchor-lane constants (PR3d) ────────────────────────────────── // Anchor lane lives below the beat bar so its time axis stays // aligned with notes and tones. 18px gives enough room for a fret diff --git a/src/main.js b/src/main.js index e25cd67f..1d3dc9c3 100644 --- a/src/main.js +++ b/src/main.js @@ -9,21 +9,31 @@ import { PIANO_NOTE_NAMES, SCALE_INTERVALS, SCALE_LABELS, - _SCALE_DEGREE_LABELS, _detectKeyPure, - _pcInScalePure, - _scaleDegreeColorPure, - _scaleDegreeSemisPure, } from './theory.js'; import { DPR, canvas, ctx, setCanvas } from './canvas.js'; -import { S } from './state.js'; +import { + _beatBarTopY, + _loadEditorKeyIfNeeded, + _persistEditorKey, + drawBarSel, + drawBeatBar, + drawCursor, + drawGrid, + drawLabels, + drawLanes, + drawNotes, + drawSections, + drawSelectionRect, + editorKeyHighlightEnabled, +} from './draw.js'; +import { S, bumpEditGen, editGen } from './state.js'; import { LC, _openMidiForArr, _seedExtendedStringsFromTuning, _soundingPitchPure, _stringCountFor, - colorForLane, laneLabels, laneToStr, lanes, @@ -36,10 +46,11 @@ import { HS_LANE_H, LABEL_W, LANE_H, + MIN_NOTE_W, + NOTE_PAD, WAVEFORM_H, _editorClampScrollXPure, _editorViewportDurationPure, - laneToY, setLaneMetrics, strToY, timeToX, @@ -49,7 +60,6 @@ import { import { KEYS_PATTERN, PIANO_LANE_H, - PIANO_OCTAVE_COLORS, _inKeyboardGutterPure, _partViewKeyPure, _rollMidiForNote, @@ -57,7 +67,6 @@ import { _rollReadOnly, _viewPrefs, _viewPrefsSave, - isBlackKey, isKeysArr, isKeysMode, midiToFreq, @@ -67,7 +76,6 @@ import { midiToY, noteToMidi, pianoLaneCount, - pianoRange, updatePianoRange, viewFor, yToMidi, @@ -75,8 +83,12 @@ import { import { BEND_INTENTS, FRET_FINGER_OPTIONS, + _clearSuggested, + _isSuggested, + _markSuggested, _resizeSustainsForDeltaPure, _resizeTargetIndicesPure, + _suggestedNotes, bendPresetCurve, chords, nextUnusedStrumGroup, @@ -110,8 +122,6 @@ import { // Constants // ════════════════════════════════════════════════════════════════════ -const MIN_NOTE_W = 18; -const NOTE_PAD = 3; // ════════════════════════════════════════════════════════════════════ // State @@ -282,9 +292,6 @@ function _downbeatTimes() { function _barSpanForTimes(t0, t1) { return _barSpanForTimesPure(_downbeatTimes(), S.duration || Math.max(t0, t1), t0, t1); } -function canvasH() { - return _beatBarTopY() + BEAT_H; -} // ── Piano roll mode helpers ───────────────────────────────────────── @@ -943,76 +950,6 @@ function _drawBookmarks(w) { if (drew) ctx.restore(); } -function drawLanes(w) { - if (isKeysMode()) return drawPianoLanes(w); - const L = lanes(); - for (let l = 0; l < L; l++) { - const y = laneToY(l); - ctx.fillStyle = l % 2 === 0 ? '#0c0c1c' : '#0f0f24'; - ctx.fillRect(LABEL_W, y, w - LABEL_W, LANE_H); - // Separator - ctx.strokeStyle = '#1a1a35'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(LABEL_W, y + LANE_H); - ctx.lineTo(w, y + LANE_H); - ctx.stroke(); - } -} - -// ── Song key / scale + in-key highlight (piano roll) ───────────────── -// S.editorKey = { tonic: 0..11, scale: } | null. The -// key is a per-song editor preference (localStorage keyed by S.filename, -// never the feedpak — this is a view aid, not chart data). The highlight -// on/off is a global editor preference. Neither is authoritative harmony -// data; a later PR authors keys.json regions and this reads from them. -let _editorKeyLoadedFor = null; - -function editorKeyHighlightEnabled() { - try { return localStorage.getItem('editorKeyHighlight') === '1'; } - catch (_) { return false; } -} - -// Lazily load the saved key for the current song (called from the control -// refresh, which runs every draw) so no song-load-path edit is needed. -function _loadEditorKeyIfNeeded() { - if (_editorKeyLoadedFor === S.filename) return; - _editorKeyLoadedFor = S.filename; - S.editorKey = null; - // No filename yet (unsaved song): don't read a bare `editorKey:` slot — - // otherwise every unsaved song would share the same stored key. - if (!S.filename) return; - try { - const raw = localStorage.getItem('editorKey:' + (S.filename || '')); - if (raw) { - const k = JSON.parse(raw); - if (Number.isInteger(k.tonic) && SCALE_INTERVALS[k.scale]) { - S.editorKey = { tonic: k.tonic, scale: k.scale }; - } - } - } catch (_) { /* ignore */ } -} - -function _persistEditorKey() { - // No filename yet (unsaved song): don't write a bare `editorKey:` slot that - // every unsaved song would collide on; the in-memory key still applies. - if (!S.filename) return; - try { - const key = 'editorKey:' + (S.filename || ''); - if (S.editorKey) localStorage.setItem(key, JSON.stringify(S.editorKey)); - else localStorage.removeItem(key); - } catch (_) { /* ignore */ } -} - -// The active highlight settings, or null when off / unset / invalid — the -// single guard every render path consults, so a bad state can't paint. -function _activeKeyHighlight() { - if (!editorKeyHighlightEnabled()) return null; - const k = S.editorKey; - if (!k || !Number.isInteger(k.tonic) || !SCALE_INTERVALS[k.scale]) return null; - return k; -} - window.editorSetKeyTonic = (v) => { const tonic = parseInt(v, 10); if (!(tonic >= 0 && tonic <= 11)) return; @@ -1188,562 +1125,6 @@ function _refreshKeyControls() { btn.setAttribute('aria-pressed', on ? 'true' : 'false'); } } - -function drawPianoLanes(w) { - const hl = _activeKeyHighlight(); - for (let midi = pianoRange.lo; midi <= pianoRange.hi; midi++) { - const y = midiToY(midi); - const black = isBlackKey(midi); - ctx.fillStyle = black ? '#0a0a1a' : '#0e0e22'; - ctx.fillRect(LABEL_W, y, w - LABEL_W, PIANO_LANE_H); - - // Out-of-key rows get a neutral desaturating wash (never red — - // chromaticism isn't an error). In-key rows are left as drawn. - if (hl && !_pcInScalePure(midi % 12, hl.tonic, hl.scale)) { - ctx.fillStyle = 'rgba(20,20,34,0.55)'; - ctx.fillRect(LABEL_W, y, w - LABEL_W, PIANO_LANE_H); - } - - // Octave boundary (C notes) - if (midi % 12 === 0) { - ctx.strokeStyle = '#2a2a55'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(LABEL_W, y + PIANO_LANE_H); - ctx.lineTo(w, y + PIANO_LANE_H); - ctx.stroke(); - } - } -} - -function drawGrid(w) { - const st = S.scrollX - 1; - const et = S.scrollX + (w - LABEL_W) / S.zoom + 1; - const laneBottom = isKeysMode() - ? WAVEFORM_H + pianoLaneCount() * PIANO_LANE_H - : WAVEFORM_H + lanes() * LANE_H; - for (const b of S.beats) { - if (b.time < st || b.time > et) continue; - const x = timeToX(b.time); - if (x < LABEL_W || x > w) continue; - const meas = b.measure > 0; - ctx.strokeStyle = meas ? '#2a2a50' : '#16162c'; - ctx.lineWidth = meas ? 1.5 : 0.5; - ctx.beginPath(); - ctx.moveTo(x, WAVEFORM_H); - ctx.lineTo(x, laneBottom); - ctx.stroke(); - } -} - -/* @pure:section-coverage:start */ -// Per-section spans with a hasContent flag: does any charted note-time fall -// in the section's [start, nextStart) window? The last section runs to the -// song end (open-ended when duration is unknown), and is INCLUSIVE of its -// upper edge — extended to the final note time when notes sit at or past a -// stale/short duration, so trailing content is never invisible. A note on an -// INTERIOR boundary belongs to the LATER section (half-open). Sections are -// sorted defensively and non-finite start_times dropped. Returns [] when -// there are no sections. Ambient progress — never a score. -function _sectionCoveragePure(sections, noteTimes, duration) { - if (!Array.isArray(sections) || !sections.length) return []; - const secs = sections - .filter(s => s && Number.isFinite(Number(s.start_time))) - .map(s => Number(s.start_time)) - .sort((a, b) => a - b); - if (!secs.length) return []; - const dur = (Number.isFinite(duration) && duration > 0) ? duration : Infinity; - const times = Array.isArray(noteTimes) - ? noteTimes.map(Number).filter(Number.isFinite) - : []; - // The final span has no later section to bound it, so it owns every - // trailing note: extend its end past `dur` to the last note time when a - // note sits at/after the (possibly stale/short) duration, and treat its - // upper edge as INCLUSIVE. Interior spans stay half-open [start, next) so - // a note on an interior boundary still belongs to the LATER section — the - // inclusive edge is only the outermost one, so there's no double-count. - let maxT = -Infinity; - for (const t of times) if (t > maxT) maxT = t; - const lastEnd = maxT > dur ? maxT : dur; // may be Infinity - const out = []; - for (let i = 0; i < secs.length; i++) { - const start = secs[i]; - const isLast = (i + 1 >= secs.length); - const end = isLast ? lastEnd : secs[i + 1]; // may be Infinity (last) - let hasContent = false; - for (const t of times) { - if (t >= start && (isLast ? t <= end : t < end)) { hasContent = true; break; } - } - out.push({ start, end, hasContent }); - } - return out; -} -/* @pure:section-coverage:end */ - -// Note times of the ACTIVE arrangement (flattened — chord notes already live -// in notes() for the current arrangement). -function _currentNoteTimes() { - return notes().map(n => n.time); -} - -// Cross-frame memo for the section-coverage strip. drawSections runs on every -// requestAnimationFrame during playback, but coverage only changes on -// note/section/duration edits — never while the cursor moves. Recomputing the -// pure helper (an O(N) note-time pass plus an O(sections×notes) scan) every -// frame is the same per-frame O(N) trap the lanes()/laneLabels() caches in -// draw() deliberately avoid, so memoize behind a cheap key. In-place note-time -// moves keep the notes-array identity AND length, so they're caught by -// `_coverageEditGen`, bumped by EditHistory._afterEdit() — the edit-contract -// hook every mutation flows through (constitution IV). The section fingerprint -// is O(sections) (a handful, negligible beside the O(notes) scan skipped) and -// catches add/remove/retime/reorder without wiring every section mutation site. -let _coverageEditGen = 0; -let _covCache = { key: null, notesRef: null, value: [] }; -function _sectionCoverage() { - const secs = S.sections || []; - const ns = notes(); - // A live note-move drag mutates note.time in place every mousemove and - // only commits to EditHistory on mouseUp, so `_coverageEditGen` hasn't - // bumped yet — bypass the memo for the drag's duration to keep the strip - // live (matching the pre-memo per-frame recompute). This is the ONE - // interactive path that changes note times without an edit-gen bump; the - // perf target (playback) has no active drag, so it still hits the cache. - if (S.drag && S.drag.type === 'move') { - return _sectionCoveragePure(secs, _currentNoteTimes(), S.duration || 0); - } - let secSig = secs.length + ':'; - for (const s of secs) secSig += (s ? s.start_time : 'x') + ','; - const key = _coverageEditGen + '|' + S.currentArr + '|' + ns.length - + '|' + (S.duration || 0) + '|' + secSig; - if (key !== _covCache.key || _covCache.notesRef !== ns) { - _covCache = { - key, notesRef: ns, - value: _sectionCoveragePure(secs, _currentNoteTimes(), S.duration || 0), - }; - } - return _covCache.value; -} - -function drawSections(w) { - const st = S.scrollX - 1; - const et = S.scrollX + (w - LABEL_W) / S.zoom + 1; - const laneBottom = isKeysMode() - ? WAVEFORM_H + pianoLaneCount() * PIANO_LANE_H - : WAVEFORM_H + lanes() * LANE_H; - // Completeness strip: a thin band at the top of the lane area tinting - // each section by whether the active arrangement has notes in it — an - // at-a-glance "where is this chart still empty", drawn under the section - // labels/lines below. Neutral, no percentage, no red. - const cov = _sectionCoverage(); - for (const c of cov) { - const x0 = Math.max(LABEL_W, timeToX(c.start)); - const x1 = Math.min(w, timeToX(c.end)); - if (x1 <= x0) continue; - ctx.fillStyle = c.hasContent ? 'rgba(120,170,255,0.20)' : 'rgba(255,255,255,0.035)'; - ctx.fillRect(x0, WAVEFORM_H, x1 - x0, 3); - } - ctx.font = '9px monospace'; - ctx.textBaseline = 'top'; - for (const s of S.sections) { - if (s.start_time < st || s.start_time > et) continue; - const x = timeToX(s.start_time); - if (x < LABEL_W || x > w) continue; - // Dashed vertical line - ctx.strokeStyle = '#e8c04060'; - ctx.lineWidth = 1; - ctx.setLineDash([4, 4]); - ctx.beginPath(); - ctx.moveTo(x, WAVEFORM_H); - ctx.lineTo(x, laneBottom); - ctx.stroke(); - ctx.setLineDash([]); - // Label at top of lanes - ctx.fillStyle = '#e8c040'; - ctx.textAlign = 'left'; - ctx.fillText(s.name, x + 3, WAVEFORM_H + 2); - } -} - -// Y coordinate of the beat bar's top edge. Branches on keys mode -// because keys lanes use a different per-lane height. `canvasH`, -// `_anchorLaneTopY`, `drawBeatBar` all call through here so they -// can't drift as new strips are added. -function _beatBarTopY() { - return isKeysMode() - ? WAVEFORM_H + pianoLaneCount() * PIANO_LANE_H - : WAVEFORM_H + lanes() * LANE_H; -} - -// Highlight the bar range selected for "Loop in 3D" — a translucent blue -// band with bright edges spanning the full chart height, drawn under the -// notes so they stay legible. -function drawBarSel(w) { - if (!S.barSel) return; - const x1 = timeToX(S.barSel.startTime); - const x2 = timeToX(S.barSel.endTime); - if (x2 < LABEL_W || x1 > w) return; - const cx1 = Math.max(LABEL_W, x1); - const cx2 = Math.min(w, x2); - const bot = canvasH(); - ctx.save(); - ctx.fillStyle = 'rgba(80,160,255,0.10)'; - ctx.fillRect(cx1, 0, Math.max(0, cx2 - cx1), bot); - ctx.strokeStyle = 'rgba(80,160,255,0.7)'; - ctx.lineWidth = 1.5; - if (x1 >= LABEL_W && x1 <= w) { ctx.beginPath(); ctx.moveTo(x1, 0); ctx.lineTo(x1, bot); ctx.stroke(); } - if (x2 >= LABEL_W && x2 <= w) { ctx.beginPath(); ctx.moveTo(x2, 0); ctx.lineTo(x2, bot); ctx.stroke(); } - ctx.restore(); -} - -function drawBeatBar(w) { - const y = _beatBarTopY(); - ctx.fillStyle = '#08081a'; - ctx.fillRect(0, y, w, BEAT_H); - ctx.fillStyle = '#08081a'; - ctx.fillRect(0, y, LABEL_W, BEAT_H); - - // Left gutter label — identifies the strip and hints that it's - // drag-to-select for "Loop in 3D". - ctx.fillStyle = S.barSel ? '#6aa0ff' : '#667'; - ctx.font = '8px monospace'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText('⇆ bars', LABEL_W / 2, y + BEAT_H / 2); - - ctx.fillStyle = '#555'; - ctx.font = '9px monospace'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - const st = S.scrollX - 1; - const et = S.scrollX + (w - LABEL_W) / S.zoom + 1; - for (const b of S.beats) { - if (b.measure <= 0 || b.time < st || b.time > et) continue; - const x = timeToX(b.time); - if (x < LABEL_W || x > w) continue; - ctx.fillText(String(b.measure), x, y + BEAT_H / 2); - } -} - -function drawLabels(w) { - // Waveform label - ctx.fillStyle = '#0a0a1a'; - ctx.fillRect(0, 0, LABEL_W, WAVEFORM_H); - ctx.fillStyle = '#555'; - ctx.font = '9px monospace'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText('Audio', LABEL_W / 2, WAVEFORM_H / 2); - - if (isKeysMode()) return drawPianoLabels(w); - - // String labels. `labels` is in RS string-index order (low → high); lanes - // are drawn high-to-low (lane 0 = top = highest string). Colours come - // from `colorForLane()` which looks up the string's pitch label in - // `STRING_LABEL_COLORS` — so a 4-string bass G/D/A/E reads orange/blue/ - // yellow/red just like the same pitches on a 6-string guitar. - const L = lanes(); - const labels = laneLabels(); - for (let l = 0; l < L; l++) { - const y = laneToY(l); - ctx.fillStyle = '#0a0a1a'; - ctx.fillRect(0, y, LABEL_W, LANE_H); - const s = laneToStr(l); - ctx.fillStyle = colorForLane(l); - ctx.font = 'bold 12px monospace'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(labels[s] || String(s), LABEL_W / 2, y + LANE_H / 2); - } -} - -// The left axis of the piano roll is drawn as an actual keyboard gutter: one -// key per MIDI row, white/black shaded like a real keyboard laid on its side, -// C rows labelled with their octave. It's clickable (see onMouseDown) to -// audition the pitch. Black keys are inset from the front (right) edge so the -// white keys' tails read between them, exactly as on a side-on keyboard. -const _GUTTER_BLACK_INSET = 0.42; // fraction of LABEL_W the black key leaves as white tail on the right -function drawPianoLabels() { - ctx.font = '8px monospace'; - ctx.textAlign = 'left'; - ctx.textBaseline = 'middle'; - const blackW = LABEL_W * (1 - _GUTTER_BLACK_INSET); - for (let midi = pianoRange.lo; midi <= pianoRange.hi; midi++) { - const y = midiToY(midi); - const black = isBlackKey(midi); - // White base for every row (the black key's tail shows on the right). - ctx.fillStyle = '#c9c9d6'; - ctx.fillRect(0, y, LABEL_W, PIANO_LANE_H); - if (black) { - // Black key: a darker bar from the back (left) edge, leaving the - // white tail on the right — the side-on keyboard read. - ctx.fillStyle = '#1b1b2a'; - ctx.fillRect(0, y, blackW, PIANO_LANE_H); - } - // Row separators only between two adjacent WHITE keys (E–F, B–C) — the - // spots a real keyboard has no black key between, so the boundary needs - // a drawn line to read as two distinct keys. - if (!black && !isBlackKey(midi + 1) && midi < pianoRange.hi) { - ctx.strokeStyle = '#9a9aac'; - ctx.lineWidth = 0.5; - ctx.beginPath(); - ctx.moveTo(0, y + 0.5); - ctx.lineTo(LABEL_W, y + 0.5); - ctx.stroke(); - } - // Label C rows with their octave (e.g. C4), on the white tail so it - // stays legible whether or not the row is a black key. - if (midi % 12 === 0 && PIANO_LANE_H >= 7) { - ctx.fillStyle = '#3a3a4a'; - ctx.fillText(midiToNote(midi), LABEL_W - blackW + 2, y + PIANO_LANE_H / 2); - } - } - // Front-edge divider so the keyboard reads as a panel distinct from the grid. - ctx.strokeStyle = '#2a2a55'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(LABEL_W - 0.5, WAVEFORM_H); - ctx.lineTo(LABEL_W - 0.5, midiToY(pianoRange.lo) + PIANO_LANE_H); - ctx.stroke(); -} - -function drawNotes(w) { - const nn = notes(); - const st = S.scrollX - 2; - const et = S.scrollX + (w - LABEL_W) / S.zoom + 2; - const keysMode = isKeysMode(); - // Hoist the active-highlight lookup out of the per-note loop: it reads - // localStorage, so resolving it once per draw (not once per visible note) - // keeps drawNotes off the synchronous-storage path during playback/scroll. - const hl = _activeKeyHighlight(); - // Fretted lanes resolve notes to SOUNDING pitch (tuning + capo + fret) — - // the whole context is hoisted here so _drawNote does zero per-note - // arrangement work. `ghl` is null whenever the highlight can't apply. - let ghl = null; - if (hl && !keysMode) { - const arr = S.arrangements[S.currentArr]; - if (arr) { - const laneCount = _stringCountFor(arr); - const tuning = (Array.isArray(arr.tuning) ? arr.tuning : []).slice(0, laneCount); - while (tuning.length < laneCount) tuning.push(0); - ghl = { - hl, - openMidi: _openMidiForArr(arr, laneCount), - tuning, - capo: Number(arr.capo) || 0, - }; - } - } - // Fretted-in-roll draws at SOUNDING pitch — one hoisted context, and - // the same mapping hit-testing/marquee use (they must never disagree). - const rctx = keysMode ? _rollPitchCtx() : null; - for (let i = 0; i < nn.length; i++) { - const n = nn[i]; - if (n.time + (n.sustain || 0) < st || n.time > et) continue; - if (keysMode) { - const midi = _rollMidiForNote(n, rctx); - if (midi !== null) _drawPianoNote(n, S.sel.has(i), hl, midi, !!rctx); - } else { - _drawNote(n, S.sel.has(i), ghl); - } - } -} - -function _drawNote(n, selected, ghl) { - const x = timeToX(n.time); - const y = strToY(n.string) + NOTE_PAD; - const sw = Math.max(MIN_NOTE_W, (n.sustain || 0) * S.zoom); - const h = LANE_H - NOTE_PAD * 2; - const color = colorForLane(strToLane(n.string)); - // Suggested (machine-picked, unconfirmed) position: render provisional — - // dimmer body + dashed border — so an unresolved fingering reads at a glance. - const suggested = _isSuggested(n); - - // In-key highlight (mirrors the piano roll's treatment): out-of-key - // notes dim, never redden — chromaticism is not an error. Membership - // uses the SOUNDING pitch (tuning + capo + fret); an unresolvable - // pitch stays fully lit rather than falsely flagged. - let outOfKey = false; - let degMidi = null; // sounding pitch, hoisted for the scale-degree overlay - if (ghl) { - degMidi = _soundingPitchPure( - ghl.openMidi, ghl.tuning, ghl.capo, n.string, n.fret); - outOfKey = degMidi !== null - && !_pcInScalePure(((degMidi % 12) + 12) % 12, ghl.hl.tonic, ghl.hl.scale); - } - - // Body - ctx.fillStyle = color + (suggested || outOfKey ? '55' : 'cc'); - ctx.beginPath(); - ctx.roundRect(x, y, sw, h, 3); - ctx.fill(); - - // Border - if (selected) { - ctx.strokeStyle = '#fff'; - ctx.lineWidth = 2; - } else { - ctx.strokeStyle = color; - ctx.lineWidth = suggested ? 1 : 0.5; - } - if (suggested) ctx.setLineDash([3, 2]); - ctx.beginPath(); - ctx.roundRect(x, y, sw, h, 3); - ctx.stroke(); - if (suggested) ctx.setLineDash([]); - - // Fret number - ctx.fillStyle = outOfKey ? 'rgba(255,255,255,0.6)' : '#fff'; - ctx.font = 'bold 13px monospace'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(String(n.fret), x + Math.min(sw, MIN_NOTE_W) / 2, y + h / 2); - - // Scale-degree overlay (only when the key highlight is active): a small - // degree label in the note's top-right, coloured by role so the 1/3/5/7 - // skeleton pops. Out-of-key notes still show their chromatic degree, - // dimmed — so a fretted line reads as scale degrees at a glance. Skipped - // when the sounding pitch is unresolvable (degMidi null). - if (ghl && degMidi !== null) { - const semis = _scaleDegreeSemisPure(((degMidi % 12) + 12) % 12, ghl.hl.tonic); - if (semis >= 0) { - ctx.fillStyle = _scaleDegreeColorPure(semis) + (outOfKey ? '99' : 'ff'); - ctx.font = '7px monospace'; - ctx.textAlign = 'right'; - ctx.textBaseline = 'top'; - ctx.fillText(_SCALE_DEGREE_LABELS[semis], x + Math.min(sw, MIN_NOTE_W) - 2, y + 1); - } - } - - // Technique badges - const techs = n.techniques || {}; - const badges = []; - if (techs.hammer_on) badges.push('H'); - if (techs.pull_off) badges.push('P'); - if (techs.slide_to >= 0) badges.push('/' + techs.slide_to); - if (techs.slide_unpitch_to >= 0) badges.push('↓' + techs.slide_unpitch_to); - if (techs.bend > 0) badges.push('b'); - if (techs.harmonic) badges.push('*'); - if (techs.harmonic_pinch) badges.push('*P'); - if (techs.palm_mute) badges.push('PM'); - if (techs.fret_hand_mute) badges.push('FM'); - if (techs.tap) badges.push('T'); - if (techs.slap) badges.push('S'); - if (techs.pluck) badges.push('P!'); - if (techs.tremolo) badges.push('~'); - if (techs.vibrato) badges.push('V'); - if (techs.mute) badges.push('x'); - if (techs.link_next) badges.push('→'); - if (techs.ignore) badges.push('I'); - if (badges.length) { - ctx.fillStyle = '#ffffffbb'; - ctx.font = '7px monospace'; - ctx.textAlign = 'left'; - ctx.fillText(badges.join(' '), x + 2, y + 9); - } - - // Sustain tail - if (sw > MIN_NOTE_W) { - ctx.fillStyle = color + '40'; - ctx.fillRect(x + MIN_NOTE_W, y + h / 2 - 2, sw - MIN_NOTE_W, 4); - } -} - -function _drawPianoNote(n, selected, hl, midi, fretted) { - // `midi` is resolved by the caller through _rollMidiForNote — keys - // packing or fretted sounding pitch — so this renderer never guesses. - if (midi === undefined) midi = noteToMidi(n.string, n.fret); - if (midi < pianoRange.lo || midi > pianoRange.hi) return; - - const x = timeToX(n.time); - const y = midiToY(midi) + 1; - const sw = Math.max(MIN_NOTE_W, (n.sustain || 0) * S.zoom); - const h = PIANO_LANE_H - 2; - const octave = Math.floor(midi / 12); - // Fretted-in-roll notes wear their STRING's lane color, not the octave - // color: the Y axis already says the pitch, so the color's job is to - // say WHERE the pitch is played — which is exactly what the Shift+↑/↓ - // position cycle changes, making a cycle step visible as a color flip - // at a fixed Y (VA.5). - const color = fretted - ? colorForLane(strToLane(n.string)) - : PIANO_OCTAVE_COLORS[Math.min(octave, PIANO_OCTAVE_COLORS.length - 1)]; - // Suggested (machine-picked, unconfirmed) position — render provisional - // (dimmer + dashed). Only fretted-in-roll adds are ever marked. - const suggested = _isSuggested(n); - - // In-key highlight: dim out-of-key notes (lower body alpha) so chromatic - // notes read as chromatic without being hidden or flagged as wrong. - // `hl` is resolved once per draw in drawNotes (see hoist there) and passed - // in, so this path never reads localStorage per note. - const outOfKey = !!hl && !_pcInScalePure(midi % 12, hl.tonic, hl.scale); - - // Body - ctx.fillStyle = color + (suggested || outOfKey ? '55' : 'cc'); - ctx.beginPath(); - ctx.roundRect(x, y, sw, h, 2); - ctx.fill(); - - // Border - if (selected) { - ctx.strokeStyle = '#fff'; - ctx.lineWidth = 2; - } else { - ctx.strokeStyle = color; - ctx.lineWidth = suggested ? 1 : 0.5; - } - if (suggested) ctx.setLineDash([3, 2]); - ctx.beginPath(); - ctx.roundRect(x, y, sw, h, 2); - ctx.stroke(); - if (suggested) ctx.setLineDash([]); - - // Note name — or, for fretted-in-roll, the s·f position chip (the - // note name is redundant with the Y axis there; string·fret is the - // one fact the roll would otherwise hide). Raw string index, matching - // the Strings modal's "String N" labels. - if (sw >= 20 && h >= 8) { - ctx.fillStyle = '#000'; - ctx.font = `bold ${Math.min(9, h - 1)}px monospace`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - const label = fretted ? (n.string + '·' + n.fret) : midiToNote(midi); - ctx.fillText(label, x + Math.min(sw, 24) / 2, y + h / 2); - } -} - -function drawCursor(w, h) { - const x = timeToX(S.cursorTime); - if (x < LABEL_W || x > w) return; - ctx.strokeStyle = '#ff4444'; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.moveTo(x, 0); - // Extend the playhead through every time-axis-aligned strip - // (waveform, tone lane, lanes, beat bar, anchor lane). `canvasH()` - // stops at the beat-bar bottom, which would clip the cursor above - // the anchor lane. - ctx.lineTo(x, h); - ctx.stroke(); -} - -function drawSelectionRect() { - if (!S.drag || (S.drag.type !== 'select' && S.drag.type !== 'drum-select')) return; - // The drum marquee only materialises once the pointer has actually - // moved — a stationary press is a click-to-add-hit, not a select. - if (S.drag.type === 'drum-select' && !S.drag.moved) return; - const x1 = Math.min(S.drag.startX, S.drag.curX); - const y1 = Math.min(S.drag.startY, S.drag.curY); - const x2 = Math.max(S.drag.startX, S.drag.curX); - const y2 = Math.max(S.drag.startY, S.drag.curY); - ctx.strokeStyle = '#4080e0'; - ctx.lineWidth = 1; - ctx.setLineDash([4, 4]); - ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); - ctx.setLineDash([]); - ctx.fillStyle = '#4080e018'; - ctx.fillRect(x1, y1, x2 - x1, y2 - y1); -} - // ════════════════════════════════════════════════════════════════════ // Hit testing // ════════════════════════════════════════════════════════════════════ @@ -1896,7 +1277,10 @@ class EditHistory { // keeps the notes array's identity + length, so the cheap cache key // can't see it — this generation bump is what forces a recompute. // typeof-guarded (like isKeysMode below): declared outside this @pure block. - if (typeof _coverageEditGen === 'number') _coverageEditGen++; + // Bump the shared edit generation: the section-coverage, chord-display and + // drum-lint memos all key on it. typeof-guarded (like isKeysMode below): + // declared outside this @pure block, so a sliced sandbox may not have it. + if (typeof bumpEditGen === 'function') bumpEditGen(); // Keep the keys viewport in sync with the current note range so // multi-octave authoring works without manual range control. // expandOnly=true so adding a note outside the current viewport @@ -2524,13 +1908,6 @@ function _suggestPositionPure(pitch, time, prevNote, anchorList, occupiedStrings // notes by reference (an underscore field would leak to the wire) and rebuild // chord members through an explicit {time,string,fret,sustain,techniques} mapper // (an extra field would vanish). A WeakSet is invisible to serialization by -// construction, and lets a rebuilt/replaced note object drop its mark for free. -/* @pure:suggest-marks:start */ -const _suggestedNotes = new WeakSet(); -function _markSuggested(note) { if (note) _suggestedNotes.add(note); } -function _clearSuggested(note) { if (note) _suggestedNotes.delete(note); } -function _isSuggested(note) { return !!note && _suggestedNotes.has(note); } -/* @pure:suggest-marks:end */ // Count of still-suggested (unconfirmed) notes in the current arrangement — // drives the "positions unresolved: N" status nudge. @@ -7400,7 +6777,7 @@ function updateMeasureDisplay() { // so cache the readout plus the [lo,hi) interval it holds over (from // _soundingIntervalPure) and skip the scan while the cursor stays inside it and // nothing relevant changed (see _chordCacheHitPure). Edits bump -// `_coverageEditGen` (the shared edit-generation, via EditHistory._afterEdit); +// the shared edit generation `editGen` (via EditHistory._afterEdit); // a song load/replace installs a fresh notes array WITHOUT a gen bump (caught by // the notes-array identity check); and a live note drag (move OR sustain // resize) mutates notes in place without a gen bump, so any active drag rescans. @@ -7411,7 +6788,7 @@ function updateChordDisplay() { const eligible = !!(S.arrangements && S.arrangements.length && !S.drumEditMode && !S.tempoMapMode); const t = S.cursorTime || 0; - const gen = typeof _coverageEditGen === 'number' ? _coverageEditGen : 0; + const gen = typeof editGen === 'number' ? editGen : 0; const ns = eligible ? notes() : null; // Any in-place note-mutating drag (move retimes, resize re-sustains) skips // the cache — neither bumps the edit generation until mouseUp commits. @@ -14753,7 +14130,7 @@ let _drumLintCache = { key: null, hitsRef: null, value: [] }; function _drumLimbConflicts(hits) { // Live drum-move drag → hits unsorted in place; advisory lint pauses. if (typeof S !== 'undefined' && S.drag && S.drag.type === 'drum-move') return []; - const gen = (typeof _coverageEditGen === 'number') ? _coverageEditGen : 0; + const gen = (typeof editGen === 'number') ? editGen : 0; const key = gen + '|' + (Array.isArray(hits) ? hits.length : -1); if (key !== _drumLintCache.key || _drumLintCache.hitsRef !== hits) { _drumLintCache = { diff --git a/src/notes.js b/src/notes.js index ee7846a4..8049e475 100644 --- a/src/notes.js +++ b/src/notes.js @@ -174,3 +174,14 @@ export function nextUnusedStrumGroup(noteList) { } return max + 1; } + +// ── Suggested-position marks ──────────────────────────────────────── +// A WeakSet, not a note field: reconstructChords serializes solo notes BY +// REFERENCE (an underscore field would leak to the wire) and rebuilds chord +// members through an explicit field mapper (an extra field would vanish). A +// WeakSet is invisible to serialization by construction, and lets a +// rebuilt/replaced note object drop its mark for free. +export const _suggestedNotes = new WeakSet(); +export function _markSuggested(note) { if (note) _suggestedNotes.add(note); } +export function _clearSuggested(note) { if (note) _suggestedNotes.delete(note); } +export function _isSuggested(note) { return !!note && _suggestedNotes.has(note); } diff --git a/src/state.js b/src/state.js index f6a2bd15..5f389ab8 100644 --- a/src/state.js +++ b/src/state.js @@ -96,3 +96,18 @@ export const S = { // Clipboard clipboard: null, // { notes: [...], baseTime } }; + +// ── Shared edit generation ────────────────────────────────────────── +// Bumped once per committed edit by `EditHistory._afterEdit()`. Three memos key +// on it — the section-coverage strip (draw.js), the chord-at-cursor readout and +// the drum-limb lint (both main.js) — because an in-place note-time move keeps +// the notes array's identity AND length, so a cheap cache key cannot see it. +// +// A counter cannot be written across a module boundary (import bindings are +// read-only), so readers import the live `editGen` binding and the one writer +// calls `bumpEditGen()`. +export let editGen = 0; + +export function bumpEditGen() { + editGen++; +} diff --git a/tests/drum_limb_lint.test.js b/tests/drum_limb_lint.test.js index e952ab4d..ea2f1b7f 100644 --- a/tests/drum_limb_lint.test.js +++ b/tests/drum_limb_lint.test.js @@ -156,7 +156,8 @@ t('_drumConflictIndexSetPure flattens every conflicted index, skips clean hits', // The pure clusterer is well-covered above; the real bugs live in the // draw-loop integration (per-frame recompute + the sorted-input assumption // vs. a live drum-move drag that mutates times in place before re-sorting). -// The wrapper reads browser globals (S, _coverageEditGen), so we extract it +// The wrapper reads globals (S, editGen — the shared edit generation, now in +// src/state.js), so we extract it // by brace-matching and eval it over injected fakes. These FAIL on pre-fix // code: the wrapper didn't exist and the draw called the pure fn directly. @@ -174,7 +175,7 @@ function braceEnd(fnStart) { } // Extract `let _drumLintCache … function _drumLimbConflicts(hits){…}` and run -// it with the pure block + a mutable fake S / _coverageEditGen in scope. +// it with the pure block + a mutable fake S / editGen in scope. function buildWrapper() { const cacheAt = src.indexOf('let _drumLintCache'); const fnAt = src.indexOf('function _drumLimbConflicts(hits) {', cacheAt); @@ -186,13 +187,13 @@ function buildWrapper() { return new Function( '"use strict";' + 'let S = { drag: null };' - + 'let _coverageEditGen = 0;' + + 'let editGen = 0;' + extractBlock('drum-limb-lint') + wrapperSrc + '\nreturn {' + ' fn: _drumLimbConflicts,' + ' setDrag: d => { S.drag = d; },' - + ' setGen: g => { _coverageEditGen = g; },' + + ' setGen: g => { editGen = g; },' + '};' )(); } @@ -260,13 +261,13 @@ t('the drum draw loop calls the memoized wrapper, not the O(n) pure fn', () => { }); t('the lint memo key is built from the shared edit-generation counter', () => { - // Assert _coverageEditGen participates in the CACHE KEY inside the wrapper + // Assert editGen participates in the CACHE KEY inside the wrapper // itself — not merely that the two identifiers sit near each other in src. const fnStart = src.indexOf('function _drumLimbConflicts(hits) {'); assert.ok(fnStart >= 0, '_drumLimbConflicts wrapper must exist'); const body = src.slice(fnStart, braceEnd(fnStart)); - assert.ok(/_coverageEditGen/.test(body), - 'the wrapper must read _coverageEditGen'); + assert.ok(/editGen/.test(body), + 'the wrapper must read editGen'); assert.ok(/const key\s*=[\s\S]*?gen/.test(body), 'the memo key must incorporate the edit generation'); }); diff --git a/tests/key_highlight_hoist.test.js b/tests/key_highlight_hoist.test.js index f0974853..fe5fe92c 100644 --- a/tests/key_highlight_hoist.test.js +++ b/tests/key_highlight_hoist.test.js @@ -19,14 +19,16 @@ const fs = require('fs'); const path = require('path'); const assert = require('assert'); -const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'main.js'), 'utf8'); +// The note painters moved to src/draw.js (R2 step 9b); the shape assertions +// below are unchanged. +const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'draw.js'), 'utf8'); function body(name) { // Grab the function body from `function name(` to its matching close brace, // then strip line comments so prose (which may mention localStorage etc.) // never trips the code-shape assertions below. const start = src.indexOf('function ' + name + '('); - assert.notStrictEqual(start, -1, name + ' should exist in src/main.js'); + assert.notStrictEqual(start, -1, name + ' should exist in src/draw.js'); const open = src.indexOf('{', start); let depth = 0; for (let i = open; i < src.length; i++) { diff --git a/tests/section_coverage.test.js b/tests/section_coverage.test.mjs similarity index 78% rename from tests/section_coverage.test.js rename to tests/section_coverage.test.mjs index 5d9a3ab4..e2ed79f8 100644 --- a/tests/section_coverage.test.js +++ b/tests/section_coverage.test.mjs @@ -1,4 +1,3 @@ -'use strict'; /* * Tests for the section-completeness strip's pure core (@pure:section-coverage): * _sectionCoveragePure marks each section span with whether the active @@ -10,21 +9,18 @@ * arguments (notes AND duration change the result). Every case drives the * real function. * - * Run: node tests/section_coverage.test.js + * Run: node tests/section_coverage.test.mjs */ -const fs = require('fs'); -const path = require('path'); -const assert = require('assert'); - -const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'main.js'), 'utf8'); -const m = src.match(/\/\* @pure:section-coverage:start \*\/[\s\S]*?\/\* @pure:section-coverage:end \*\//); -if (!m) { - console.error('FAIL: @pure:section-coverage block not found in src/main.js'); - process.exit(1); -} -const { _sectionCoveragePure } = new Function( - '"use strict";' + m[0] + '\nreturn { _sectionCoveragePure };' -)(); +import assert from 'node:assert'; +import fs from 'node:fs'; +import { _sectionCoveragePure } from '../src/draw.js'; + +// The pure helper is a real import now. Two cases still assert on code SHAPE — +// that drawSections goes through the memo, and that EditHistory._afterEdit() +// invalidates it — so they read the two files those live in. +const drawSrc = fs.readFileSync(new URL('../src/draw.js', import.meta.url), 'utf8'); +const mainSrc = fs.readFileSync(new URL('../src/main.js', import.meta.url), 'utf8'); +const stateSrc = fs.readFileSync(new URL('../src/state.js', import.meta.url), 'utf8'); const sec = (t, name) => ({ name: name || 's', start_time: t }); @@ -127,11 +123,11 @@ t('duplicate start_times make a harmless zero-width span (behavior lock)', () => // which is what proves coverage updates after an edit without recomputing // every frame. t('drawSections uses the memo, not a per-frame recompute', () => { - assert.ok(/const cov = _sectionCoverage\(\);/.test(src), + assert.ok(/const cov = _sectionCoverage\(\);/.test(drawSrc), 'drawSections should call the memoized _sectionCoverage()'); - const fnStart = src.indexOf('function drawSections'); - const nextFnStart = src.indexOf('\nfunction ', fnStart + 1); - const fnBody = src.slice(fnStart, nextFnStart === -1 ? undefined : nextFnStart); + const fnStart = drawSrc.indexOf('function drawSections'); + const nextFnStart = drawSrc.indexOf('\nfunction ', fnStart + 1); + const fnBody = drawSrc.slice(fnStart, nextFnStart === -1 ? undefined : nextFnStart); assert.ok(!/_sectionCoveragePure\(/.test(fnBody), 'drawSections must not call the O(N) pure helper directly every frame'); }); @@ -142,20 +138,25 @@ t('the coverage memo is invalidated on edit via _afterEdit()', () => { // CRLF (Windows) checkout, and comment growth inside the method had // already pushed the bump statement past the old 400-char cutoff — // green on CI's LF checkout, red on every Windows clone. - const start = src.indexOf('_afterEdit() {'); + const start = mainSrc.indexOf('_afterEdit() {'); assert.ok(start >= 0, '_afterEdit() must exist'); - const open = src.indexOf('{', start); + const open = mainSrc.indexOf('{', start); let depth = 0, end = -1; - for (let i = open; i < src.length; i++) { - if (src[i] === '{') depth++; - else if (src[i] === '}' && --depth === 0) { end = i + 1; break; } + for (let i = open; i < mainSrc.length; i++) { + if (mainSrc[i] === '{') depth++; + else if (mainSrc[i] === '}' && --depth === 0) { end = i + 1; break; } } assert.ok(end > 0, '_afterEdit() must have a balanced body'); - const body = src.slice(start, end); - assert.ok(/_coverageEditGen\+\+/.test(body), - '_afterEdit() must bump _coverageEditGen so in-place note moves invalidate the memo'); - assert.ok(/_coverageEditGen[\s\S]*?_covCache/.test(src), - 'the memo must key on the edit generation counter'); + const body = mainSrc.slice(start, end); + // The shared edit generation lives in src/state.js now. A counter cannot be + // written across a module boundary (import bindings are read-only), so + // _afterEdit calls the exported bumper instead of incrementing it directly. + assert.ok(/bumpEditGen\(\)/.test(body), + '_afterEdit() must bump the shared edit generation so in-place moves recompute'); + assert.ok(/editGen\+\+/.test(stateSrc), + 'bumpEditGen() must increment the shared edit generation'); + assert.ok(/editGen[\s\S]*?_covCache/.test(drawSrc), + 'the coverage memo must key on the edit generation counter'); }); console.log(`\n${pass} passed, ${fail} failed`); diff --git a/tests/suggest_position_move.test.mjs b/tests/suggest_position_move.test.mjs index bf685dc1..89431233 100644 --- a/tests/suggest_position_move.test.mjs +++ b/tests/suggest_position_move.test.mjs @@ -18,6 +18,7 @@ import assert from 'node:assert'; import fs from 'node:fs'; import { _soundingPitchPure } from '../src/lanes.js'; +import { _clearSuggested, _isSuggested, _markSuggested } from '../src/notes.js'; const src = fs.readFileSync(new URL('../src/main.js', import.meta.url), 'utf8'); function extractBlock(name) { @@ -56,7 +57,6 @@ function makeMoveEnv(seed) { notes: seed.map(n => ({ ...n, techniques: { ...(n.techniques || {}) } })) }], }; const body = '"use strict";' - + extractBlock('suggest-marks') + extractBlock('suggest-position') + '\n' + extractFn('_rollAnchorList') + '\n' + extractFn('_occupiedStringsAt') @@ -65,14 +65,18 @@ function makeMoveEnv(seed) { + '\n' + extractClass('MoveNoteCmd') + '\nreturn { _rollDragPitchMove, _positionLocked, MoveNoteCmd,' + ' _isSuggested, _markSuggested, _clearSuggested };'; + // The suggested-mark WeakSet moved to src/notes.js — inject the real fns. + // Each env builds fresh note objects, so a module-shared WeakSet keyed by + // object identity cannot leak marks between cases. const env = new Function('S', 'notes', '_rollPitchCtx', 'snapTime', 'PIANO_LANE_H', - '_soundingPitchPure', body)( + '_soundingPitchPure', '_markSuggested', '_clearSuggested', '_isSuggested', body)( S, () => S.arrangements[S.currentArr].notes, () => ({ openMidi: OPEN, tuning: [0, 0, 0, 0, 0, 0], capo: 0 }), tm => tm, // snapTime: identity PIANO_LANE_H, _soundingPitchPure, // the REAL one, from src/lanes.js + _markSuggested, _clearSuggested, _isSuggested, ); return { S, env, notes: () => S.arrangements[0].notes }; } diff --git a/tests/suggest_position_persist.test.js b/tests/suggest_position_persist.test.mjs similarity index 93% rename from tests/suggest_position_persist.test.js rename to tests/suggest_position_persist.test.mjs index 04710049..1ebb1610 100644 --- a/tests/suggest_position_persist.test.js +++ b/tests/suggest_position_persist.test.mjs @@ -1,4 +1,3 @@ -'use strict'; /* * Suggest-position MARK PERSISTENCE — P6 review follow-up (design V4, D15). * @@ -18,13 +17,15 @@ * * References review-fix code absent on main / pre-fix, so the suite fails there. * - * Run: node tests/suggest_position_persist.test.js + * Run: node tests/suggest_position_persist.test.mjs */ -const fs = require('fs'); -const path = require('path'); -const assert = require('assert'); +import assert from 'node:assert'; +import fs from 'node:fs'; +import { + _clearSuggested, _isSuggested, _markSuggested, _suggestedNotes, +} from '../src/notes.js'; -const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'main.js'), 'utf8'); +const src = fs.readFileSync(new URL('../src/main.js', import.meta.url), 'utf8'); function extractBlock(name) { const re = new RegExp('/\\* @pure:' + name + ':start[\\s\\S]*?@pure:' + name + ':end \\*/'); const m = src.match(re); @@ -68,7 +69,6 @@ function makeEnv(seed, filename) { }; const localStorage = fakeStore(); const body = '"use strict";' - + extractBlock('suggest-marks') + extractBlock('suggest-marks-persist') + '\n' + extractFn('_suggestedCount') + '\n' + extractFn('_saveSuggestedMarks') @@ -76,7 +76,11 @@ function makeEnv(seed, filename) { + '\nreturn { _suggestedCount, _saveSuggestedMarks, _restoreSuggestedMarks,' + ' _markSuggested, _clearSuggested, _isSuggested,' + ' _suggestedParsePure, _suggestedStorageKeyPure, _applySuggestedMarksPure };'; - const env = new Function('S', 'localStorage', body)(S, localStorage); + // The mark WeakSet moved to src/notes.js; inject the real fns. Each env makes + // fresh note objects, so identity-keyed marks cannot leak between cases. + const env = new Function('S', 'localStorage', + '_markSuggested', '_clearSuggested', '_isSuggested', '_suggestedNotes', body)( + S, localStorage, _markSuggested, _clearSuggested, _isSuggested, _suggestedNotes); return { S, env, localStorage, notes: () => S.arrangements[0].notes }; } diff --git a/tests/suggest_position_wiring.test.mjs b/tests/suggest_position_wiring.test.mjs index 4ed7c742..561d80a2 100644 --- a/tests/suggest_position_wiring.test.mjs +++ b/tests/suggest_position_wiring.test.mjs @@ -25,6 +25,9 @@ import assert from 'node:assert'; import fs from 'node:fs'; import { reconstructChords } from '../src/chords.js'; +import { + _clearSuggested, _isSuggested, _markSuggested, _suggestedNotes, +} from '../src/notes.js'; import { LC, lanes } from '../src/lanes.js'; import { S as realS } from '../src/state.js'; @@ -87,7 +90,6 @@ function makeEnv({ notes: seed = [], arrName = 'Lead' } = {}) { const statuses = []; const refusals = []; // records _rollConfirmPosition handoffs const fullSrc = '"use strict";' - + extractBlock('suggest-marks') + extractBlock('suggest-position') + extractBlock('edit-history') + '\n' + extractFn('_withStableSelection') @@ -110,6 +112,7 @@ function makeEnv({ notes: seed = [], arrName = 'Lead' } = {}) { 'S', 'document', 'notes', 'setStatus', 'draw', 'updateStatus', '_renderInspector', '_editBlipAt', '_rollReadOnly', '_rollLockNotice', '_editorCurrentNoteIndices', '_rollPitchCtx', '_rollConfirmPosition', + '_markSuggested', '_clearSuggested', '_isSuggested', '_suggestedNotes', fullSrc )( S, @@ -122,6 +125,7 @@ function makeEnv({ notes: seed = [], arrName = 'Lead' } = {}) { () => (S.sel && S.sel.size ? [...S.sel] : []), () => ({ openMidi: OPEN, tuning: TUN.slice(), capo: 0 }), (res, pitch, time) => refusals.push({ reason: res.reason, pitch, time, candidates: res.candidates }), + _markSuggested, _clearSuggested, _isSuggested, _suggestedNotes, // src/notes.js ); return { S, env, statuses, refusals, notes: () => S.arrangements[0].notes }; }