Skip to content

feat(editor): song key/scale + in-key highlight for the piano roll - #108

Merged
byrongamatos merged 3 commits into
mainfrom
feat/editor-key-highlight
Jul 7, 2026
Merged

byrongamatos merged 3 commits into
mainfrom
feat/editor-key-highlight

Conversation

@ChrisBeWithYou

@ChrisBeWithYou ChrisBeWithYou commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

A broad-stroke pass on the key-awareness thread — directly answering Christian's ask for "a toggle for notes that are in key only, account for the key of the song." When a keys arrangement is active, the toolbar shows a Key control (tonic + scale/mode) and an In-key toggle. With the highlight on:

  • out-of-key rows get a neutral desaturating wash in the piano roll (drawPianoLanes);
  • out-of-key notes are dimmed (_drawPianoNote).

Nothing is hidden or flagged as wrong — no red, and chromatic notes stay fully visible and editable (barbershop, prog modal interchange, and atonal passages are correctly chromatic, per the harmony charrette seat).

  • SCALE_INTERVALS ships 13 tonic-relative pitch-class sets: major, the seven diatonic modes, harmonic/melodic minor, major/minor pentatonic, blues, and chromatic (shades nothing) for atonal regions.
  • _pcInScalePure(pc, tonic, scale) is octave-invariant; unknown scale / non-finite inputs default to in-key so a bad state never paints the whole roll out-of-key.
  • The key is a per-song editor preference (localStorage keyed by S.filename, never the feedpak — a view aid, not chart data); the highlight is a global preference. Lazily loaded per song in the control refresh (no song-load-path edit). Registry command + toolbar button + command palette; no key binding (avoids collision).

Deliberately the broad-stroke infrastructure: a later pass reads authored key regions (keys.json), adds enharmonic key-signature row spelling, the chord-tone emphasis tier, and guitar-lane scale-degree tinting.

Verification — held to the testing habits

Display feature, so the habits that bite are adversarial inputs and proving the helper uses all its arguments.

  • tests/scale_membership.test.js (9 cases) drive the real _pcInScalePure: C-major naturals vs accidentals; membership proven to use both the tonic (F# out of C major, in of G major) and the scale (Bb out of C major, in of C mixolydian/minor); octave-invariance; wrap-around tonic (A minor); pentatonic/blues subsets; chromatic = all-in; adversarial unknown-scale/NaN → in-key; and every declared scale is a valid, deduped, 0..11, tonic-rooted set.
  • node --check clean; all 26 JS test files pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu

Summary by CodeRabbit

  • New Features
    • Added piano-roll Key / scale controls (tonic + scale/mode) and an In-key toggle.
    • Highlighting is a view preference (tonic/scale selection) and includes multiple major/minor modes, pentatonics, blues, and chromatic.
  • Bug Fixes
    • Out-of-key notes are no longer hidden; they remain editable and are dimmed via a neutral desaturation wash (chromatic notes stay fully visible).
  • Tests
    • Added scale membership tests, including safety for unknown/invalid inputs.
    • Added a regression test to ensure highlighting is computed efficiently during rendering.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e21ab21f-7ce5-44af-97ac-a2006c48fb95

📥 Commits

Reviewing files that changed from the base of the PR and between 6a11cda and cafd771.

📒 Files selected for processing (2)
  • CHANGELOG.md
  • screen.js

📝 Walkthrough

Walkthrough

Adds piano-roll key/scale controls, persisted key preferences, rendering changes that dim out-of-key notes, shortcut wiring, scale-membership tests, and an unreleased changelog entry.

Changes

Piano-roll in-key highlighting feature

Layer / File(s) Summary
Scale data and membership helper
screen.js
Adds scale interval sets, scale labels, and a pure pitch-class membership helper that treats invalid or unknown scale inputs as in-key.
Key controls and highlight state
screen.html, screen.js
Adds hidden tonic/scale controls, per-song editor key state in localStorage, global setters and toggle wiring, UI refresh, shortcut dispatch, redraw integration, and a small coverage guard.
Rendering and validation
screen.js, tests/scale_membership.test.js, tests/key_highlight_hoist.test.js
Dims out-of-key rows and notes when highlighting is enabled, and adds Node tests covering scale membership behavior, interval-set structure, and highlight hoisting.
Changelog entry
CHANGELOG.md
Documents the new key/scale controls, In-key highlighting, supported scales, and preference behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main editor change: song key/scale support and in-key highlighting for the piano roll.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/editor-key-highlight

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ast-grep (0.44.1)
screen.js

ast-grep timed out on this file


Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
screen.js (1)

1863-1869: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Hoist _activeKeyHighlight() out of the per-note render path.

drawPianoLanes computes _activeKeyHighlight() once per call (Line 1584), but _drawPianoNote recomputes it for every note (Line 1865), which re-runs localStorage.getItem (via editorKeyHighlightEnabled()) and re-validates S.editorKey on every note draw. If _drawPianoNote is invoked in a per-note loop during frequent redraws, this adds avoidable synchronous localStorage reads to a hot rendering path.

Consider computing the active highlight once per frame (e.g., in the caller that iterates notes, or cache it alongside the lane pass) and passing it into _drawPianoNote, mirroring the pattern already used in drawPianoLanes.

♻️ Suggested direction
-function _drawPianoNote(n, selected) {
+function _drawPianoNote(n, selected, hl) {
     const midi = noteToMidi(n.string, n.fret);
     if (midi < pianoRange.lo || midi > pianoRange.hi) return;
     ...
-    const hl = _activeKeyHighlight();
     const outOfKey = !!hl && !_pcInScalePure(midi % 12, hl.tonic, hl.scale);

Then compute hl once in the note-iteration loop and pass it through.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@screen.js` around lines 1863 - 1869, Hoist the active key highlight lookup
out of the per-note render path so _drawPianoNote no longer calls
_activeKeyHighlight() for every note. Compute the highlight once per frame or
per note-iteration in the caller that draws notes, then pass it into
_drawPianoNote, following the same pattern already used by drawPianoLanes. Keep
the outOfKey alpha logic in _drawPianoNote, but use the precomputed hl value
instead of recalculating it.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@screen.js`:
- Around line 1863-1869: Hoist the active key highlight lookup out of the
per-note render path so _drawPianoNote no longer calls _activeKeyHighlight() for
every note. Compute the highlight once per frame or per note-iteration in the
caller that draws notes, then pass it into _drawPianoNote, following the same
pattern already used by drawPianoLanes. Keep the outOfKey alpha logic in
_drawPianoNote, but use the precomputed hl value instead of recalculating it.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e427a733-3c51-44d7-a26b-5c92c8fc05a7

📥 Commits

Reviewing files that changed from the base of the PR and between 57d154a and c416942.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • screen.html
  • screen.js
  • tests/scale_membership.test.js

Directly answers the "note roll toggle for in-key notes, account for the
key of the song" ask. A Key control (tonic + scale/mode) and an In-key
toggle appear in the toolbar when a keys arrangement is active. With the
highlight on, drawPianoLanes gives out-of-key rows a neutral
desaturating wash and _drawPianoNote dims out-of-key notes — chromatic
notes read as chromatic without being hidden or flagged wrong (no red).

- SCALE_INTERVALS (@pure:scale): 13 tonic-relative pitch-class sets —
  major, the seven diatonic modes, harmonic/melodic minor, major/minor
  pentatonic, blues, chromatic (which shades nothing → atonal regions
  are unshaded). Covers the genre span pop→classical→prog→blues.
- _pcInScalePure(pc, tonic, scale): octave-invariant membership; unknown
  scale / non-finite inputs default to IN-KEY so a bad state never paints
  the whole roll out-of-key.
- Key is a per-song editor pref (localStorage keyed by S.filename, never
  the feedpak — a view aid, not chart data); highlight on/off is a global
  pref. Lazily loaded per song in the control refresh (no load-path edit).
  The control group is gated visible to keys mode. Registry command +
  toolbar button + palette; no key binding (avoids collision).

This is deliberately the broad-stroke infrastructure; a later pass reads
authored key regions (keys.json) and adds enharmonic row spelling + the
chord-tone tier + guitar-lane scale-degree tinting.

Tests: tests/scale_membership.test.js (9 cases) drive the real
_pcInScalePure: C-major naturals/accidentals, membership proven to use
BOTH the tonic and the scale argument (F# out of C major, in of G major;
Bb out of C major, in of C mixolydian/minor), octave-invariance,
wrap-around tonic (A minor), pentatonic/blues subsets, chromatic =
all-in, adversarial unknown-scale/NaN → in-key, and every declared scale
is a valid deduped 0..11 tonic-rooted set. node --check clean; all 26 JS
test files pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu
@ChrisBeWithYou
ChrisBeWithYou force-pushed the feat/editor-key-highlight branch from c416942 to 3d6736f Compare July 6, 2026 23:18
_drawPianoNote ran once per visible note and called _activeKeyHighlight()
each time, which chains to localStorage.getItem('editorKeyHighlight').
drawNotes loops every visible note on every draw() (playback + scroll), so
the highlight state was re-read from synchronous storage O(visibleNotes)
times per frame. Resolve it once per draw in drawNotes (matching the sibling
drawPianoLanes) and pass it into _drawPianoNote as a parameter. Behavior is
unchanged: all notes in a single draw already saw a consistent snapshot.

Also guard _persistEditorKey/_loadEditorKeyIfNeeded to no-op when S.filename
is empty, so unsaved songs no longer read/write the shared bare `editorKey:`
slot and collide with each other.

Add a by-construction regression test pinning that _drawPianoNote takes the
highlight as a param and never resolves it (or touches storage) itself, and
that drawNotes hoists exactly one lookup and threads it through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/key_highlight_hoist.test.js (1)

24-43: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Comment-stripping in body() misses block comments.

Only // line comments are stripped (line 38) before the localStorage/_activeKeyHighlight( checks run. A future block comment (/* ... */) inside either function mentioning those tokens would cause a false test failure even though the code is compliant.

♻️ Strip block comments too
             if (depth === 0) {
-                return src.slice(start, i + 1).replace(/\/\/[^\n]*/g, '');
+                return src.slice(start, i + 1)
+                    .replace(/\/\*[\s\S]*?\*\//g, '')
+                    .replace(/\/\/[^\n]*/g, '');
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/key_highlight_hoist.test.js` around lines 24 - 43, The body() helper in
the key highlight hoist test only removes line comments, so block comments can
still trigger false positives in the code-shape assertions. Update the
comment-stripping logic in body() to remove both // and /* ... */ comments
before checking for localStorage and _activeKeyHighlight so the function scan
stays robust.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/key_highlight_hoist.test.js`:
- Around line 24-43: The body() helper in the key highlight hoist test only
removes line comments, so block comments can still trigger false positives in
the code-shape assertions. Update the comment-stripping logic in body() to
remove both // and /* ... */ comments before checking for localStorage and
_activeKeyHighlight so the function scan stays robust.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 09160ce5-1b1c-40e4-9abf-a931b46b8286

📥 Commits

Reviewing files that changed from the base of the PR and between 3d6736f and 6a11cda.

📒 Files selected for processing (2)
  • screen.js
  • tests/key_highlight_hoist.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • screen.js

Resolve CHANGELOG.md [Unreleased] conflict (take-both: keep the key/scale
entry alongside the section-undo/duplicate/inspector-time/coverage entries).

Fix cross-PR integration break: #104-#107 landing together left EditHistory
._afterEdit() (in the @pure:edit-history block) bumping _coverageEditGen,
which is declared outside that block — so the undo-test sandboxes that extract
edit-history in isolation threw "_coverageEditGen is not defined". Guard the
bump with typeof, matching the isKeysMode guard two lines below. Full JS suite
46/46 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@byrongamatos
byrongamatos merged commit 2222c59 into main Jul 7, 2026
1 check was pending
@byrongamatos
byrongamatos deleted the feat/editor-key-highlight branch July 7, 2026 08:37
byrongamatos added a commit that referenced this pull request Jul 7, 2026
…ding pitch (#115)

* feat(editor): in-key highlight on the fretted lanes — capo-aware sounding pitch

Roadmap 4.16a remainder (guitar-lane scale-degree tint). Extends the merged
song key/scale highlight (#108) from the piano roll to guitar/bass lanes.

- New _soundingPitchPure: openMidi + tuning offset + CAPO + fret, capo
  added exactly ONCE. Chart frets are capo-relative — verified against
  core lib/song.py pitch_from_base, the single source of the formula the
  tuner and highway scale-degree derivation share.
- The flagged double-count trap is now pinned in code and tests:
  _absolutePitch (string-moves) still deliberately omits capo (it cancels
  when comparing two pitches on one arrangement) and both helpers document
  the division of labor.
- Out-of-key fretted notes dim (body alpha cc->55, softened fret number —
  the piano-roll treatment; never red), unresolvable pitches stay fully
  lit. Highlight context is hoisted once per draw, zero per-note
  arrangement work. Key controls now show for any pitched arrangement.

Tests: tests/fret_key_highlight.test.js (8 cases) — the formula against
known pitches, Drop-D + capo composition, the capo-flips-membership case
an uncapoed resolver gets wrong, and the omits-capo pin on _absolutePitch.
Full JS suite green except tests/section_coverage.test.js, which fails on
current MAIN itself (pre-existing _afterEdit/#107 merge interaction).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu

* test(editor): document extractFn brace-count assumption for #115 (CodeRabbit nitpick)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
byrongamatos added a commit that referenced this pull request Jul 7, 2026
…l (read-first) (#119)

* feat(editor): in-key highlight on the fretted lanes — capo-aware sounding pitch

Roadmap 4.16a remainder (guitar-lane scale-degree tint). Extends the merged
song key/scale highlight (#108) from the piano roll to guitar/bass lanes.

- New _soundingPitchPure: openMidi + tuning offset + CAPO + fret, capo
  added exactly ONCE. Chart frets are capo-relative — verified against
  core lib/song.py pitch_from_base, the single source of the formula the
  tuner and highway scale-degree derivation share.
- The flagged double-count trap is now pinned in code and tests:
  _absolutePitch (string-moves) still deliberately omits capo (it cancels
  when comparing two pitches on one arrangement) and both helpers document
  the division of labor.
- Out-of-key fretted notes dim (body alpha cc->55, softened fret number —
  the piano-roll treatment; never red), unresolvable pitches stay fully
  lit. Highlight context is hoisted once per draw, zero per-note
  arrangement work. Key controls now show for any pitched arrangement.

Tests: tests/fret_key_highlight.test.js (8 cases) — the formula against
known pitches, Drop-D + capo composition, the capo-flips-membership case
an uncapoed resolver gets wrong, and the omits-capo pin on _absolutePitch.
Full JS suite green except tests/section_coverage.test.js, which fails on
current MAIN itself (pre-existing _afterEdit/#107 merge interaction).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu

* feat(editor): per-part view switcher — any fretted part opens in the piano roll (read-first)

EDITOR-VIEW-MODALITY-DESIGN P1 (VA.1+VA.2, decisions V1-V4/V9). The editing
view was derived from the arrangement NAME; it is now a per-part choice.

- viewFor(part): per-part pref in editor localStorage keyed song + stable
  part id (never index/display-name; keys parts piano-locked). Kind
  inference stays the default.
- isKeysMode() split: piano-SURFACE predicate (draw geometry, hit-testing,
  viewport) vs new isKeysArr() keys-DATA predicate (string moves,
  chord-sibling grouping, anchors, resize chord-expansion) — a fretted
  part in the roll still groups chords and keeps string-move machinery
  (P5 position cycling depends on exactly this).
- Read-first roll for fretted parts: one sounding-pitch mapping
  (_rollMidiForNote via _soundingPitchPure — capo once) hoisted per pass
  and shared by draw, hitNote, marquee, and updatePianoRange; null
  pitches skip, never render wrong.
- Edit-lock (V4): central gate in EditHistory.exec (typeof-guarded for
  extracted-test envs) + the live-mutating drag starts (move/resize) +
  dblclick add + EOF right-click edit; selection still works; a visible
  pill + status explain why. Lock lifts live on switching back.
- Toolbar String/Piano-roll segmented switcher + registry cycleViewMode;
  selection/drag/note-UI cleared on switch (V3).

STACKED ON #115 (feat/editor-key-highlight-guitar) — needs its
_soundingPitchPure; merge #115 first.

Tests: tests/view_switcher.test.js (11) — pure view resolution, pref
persistence/rename stability over stub localStorage, sounding-pitch roll
mapping + viewport fit (asserts NOT the wire packing), and the exec gate
(inert+notice / regression / live-unlock). Full suite green except the
pre-existing CRLF section_coverage failure (#116).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu

* feat(editor): review fixes for #119 (view switcher)

Read-only roll (fretted part in the piano roll) was enforced only at the
EditHistory exec chokepoint and the mouse/right-click-add handlers. Two
gaps:

- exec blocked ALL commands, including songScope (drum tab, tempo grid)
  edits, so switching an unrelated part into the roll froze tempo/drum
  editing. songScope commands now pass through the lock (exec + undo/redo).
- Several note-edit paths bypass EditHistory entirely and so escaped the
  lock: note-scope undo/redo, promptSlide/promptSlideUnpitch, the inspector
  setters (editorInspectorSetTech/SetFlag), and the context-menu
  editorToggleTech. The context menu opens in the roll under the default
  right-click behavior and the inspector renders for any selection, so all
  were reachable. Each is now guarded with _rollReadOnly()/_rollLockNotice.

Regression tests (tests/view_switcher.test.js) fail on pre-fix code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants