Skip to content

refactor(editor): move the MIDI recorder to src/midi-record.js (R2, step 23) - #175

Merged
byrongamatos merged 1 commit into
mainfrom
refactor/r2-step23-midi-record
Jul 9, 2026
Merged

refactor(editor): move the MIDI recorder to src/midi-record.js (R2, step 23)#175
byrongamatos merged 1 commit into
mainfrom
refactor/r2-step23-midi-record

Conversation

@byrongamatos

@byrongamatos byrongamatos commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

src/main.js 10,380 → 9,779 — under ten thousand for the first time. 25 modules, graph still acyclic.

Zero new host hooks

The recorder reached eight main.js symbols. Six were already on host. The other two were not hooks waiting to happen — they were symbols in the wrong file:

symbol new home why
_transportChartTimePure src/transport.js (new) Pure time math, and the one formula the playback tick, the guide scheduler and the recorder must agree on. It goes with _composeSongDurationPure, which its own test already pairs it with. No imports — the seed of the eventual audio lift.
_uniqueKeysName src/keys.js It names a Keys arrangement. That is what keys.js is.

This is the rule host.js’s own header states, applied: a symbol only belongs there if it genuinely cannot leave main.js. A pure function always can.

_recState as a live binding

_recState is now export let. Every writer is inside the recorder; main.js reads it at 14 sites and wires host.isRecording() for the modules that only need the predicate.

An import binding is read-only for reassignment and live for reads — precisely the guarantee wanted. No container, no accessor. (This is the same live-binding rule that decided lanes.js’s LC the other way: can the writer move?)

Tests

  • compose_transport imports the two pures instead of slicing them.
  • midi_domain / midi_domain_leak still slice module internals, now from midi-record.js. Both CJS → .mjs. They strip the export keyword before eval (it is a SyntaxError inside new Function), and midi_domain_leak’s sandbox preamble gained a host stub.

Harness

verify_midi_record.py opens the record modal on a loaded session and asserts the five window.* handlers are bound, that the backend is queried, and that with no device attached Start stays disabled rather than the modal throwing.

Recording itself needs hardware, so the state machine stays with the unit suites. The harness deliberately asserts nothing it cannot observe — an earlier draft had a hardcoded True and a "canvas still exists" check; both were padding and were cut.

Comment out one window.* re-attach and the harness throws where 89 unit tests still pass.

Verification

node --test 89/89 · pytest 248/248 · npm run lint 0 errors (6 warnings) · Codex clean · all 17 headless harnesses pass.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added MIDI keyboard recording for creating “Keys” arrangements.
    • Supports device selection, channel filtering, sustain pedal input, and live note visualization.
    • New arrangements automatically receive unique names such as “Keys 2.”
  • Bug Fixes

    • Improved MIDI recording cleanup when switching devices, closing the recorder, or stopping playback.
    • Improved note timing, sustain handling, and transport duration calculations for more accurate recordings.

…tep 23)

src/main.js 10,380 -> 9,779. Under ten thousand for the first time; 25 modules,
graph still acyclic.

ZERO new host hooks. Two of the recorder's eight main.js dependencies were not
hooks waiting to happen, they were symbols in the wrong file:

  _transportChartTimePure  pure time math, and the ONE formula the playback tick,
                           the guide scheduler and the recorder must agree on.
                           It is now the whole of src/transport.js (with
                           _composeSongDurationPure, which its test already pairs
                           it with). No imports; the seed of the audio lift.
  _uniqueKeysName          names a Keys arrangement. That is what src/keys.js is.

The other six were already on `host`. This is the rule host.js's header states,
applied: a symbol only belongs there if it genuinely cannot leave main.js.

_recState is exported as a live `export let`. Every writer is inside the
recorder; main.js reads it at 14 sites and wires host.isRecording() for the
modules that only need the predicate. An import binding is read-only for
reassignment and live for reads, which is precisely the guarantee wanted — no
container, no accessor.

Tests: compose_transport imports the two pures instead of slicing them.
midi_domain and midi_domain_leak still slice module internals, now from
midi-record.js — CJS -> .mjs, they strip the `export` keyword before eval (it is
a SyntaxError inside `new Function`), and midi_domain_leak's sandbox preamble
gained a `host` stub.

verify_midi_record.py opens the record modal on a loaded session and asserts the
five window.* handlers are bound, that the backend is queried, and that with no
device attached Start stays disabled rather than the modal throwing. Recording
itself needs hardware, so the state machine stays with the unit suites; the
harness deliberately asserts nothing it cannot observe.

node --test 89/89, pytest 248/248, npm run lint 0 errors (6 warnings), Codex
clean, all 17 headless harnesses pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 9, 2026 23:47
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The MIDI recorder and transport helpers were moved from src/main.js into dedicated modules. Editor globals, arrangement naming, tests, and changelog entries were updated to match the new module boundaries.

Changes

Recorder and transport modularization

Layer / File(s) Summary
Transport helper extraction
src/transport.js, src/main.js, tests/compose_transport.test.mjs
Transport chart-time and compose-duration helpers are exported from src/transport.js, imported by the editor, and tested through direct ESM imports.
MIDI backend and session lifecycle
src/midi-record.js, tests/midi_domain*.test.mjs
MIDI backend selection, permission handling, domain session cleanup, device updates, and listener attachment are implemented in the recorder module and covered by updated extraction harnesses.
MIDI recording and finalization
src/midi-record.js, tests/midi_domain*.test.mjs
MIDI events, sustain behavior, recording lifecycle, note finalization, arrangement commits, and ghost-note rendering are implemented in src/midi-record.js.
Editor wiring and arrangement naming
src/main.js, src/keys.js, CHANGELOG.md
Editor globals delegate to the extracted recorder functions, _uniqueKeysName() is exported from src/keys.js, and the module relocations are documented.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Editor
  participant MidiRecord
  participant MIDIInput
  participant Playback
  participant Arrangement
  Editor->>MidiRecord: Start recording
  MidiRecord->>MIDIInput: Attach MIDI listener
  MidiRecord->>Playback: Start transport playback
  MIDIInput->>MidiRecord: Deliver MIDI events
  Editor->>MidiRecord: Stop recording
  MidiRecord->>Arrangement: Store finalized notes
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.16% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: moving the MIDI recorder into src/midi-record.js.
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 refactor/r2-step23-midi-record

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

@byrongamatos
byrongamatos merged commit d2ee023 into main Jul 9, 2026
5 checks passed

Copilot AI 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.

Pull request overview

Refactors the editor’s MIDI recording feature by extracting the recorder logic out of src/main.js into a dedicated module and centralizing transport time math into a new src/transport.js, while updating tests/harness references to match the new module boundaries.

Changes:

  • Extract MIDI recorder implementation into src/midi-record.js and re-attach its window.editor* handlers from main.js.
  • Move transport pure functions into new src/transport.js and update the compose transport tests to import them directly.
  • Relocate _uniqueKeysName into src/keys.js and update unit tests that slice recorder internals to handle ESM exports.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/midi_domain.test.mjs Updated to slice recorder internals from src/midi-record.js and strip export keywords for new Function evaluation.
tests/midi_domain_leak.test.mjs Updated leak regression harness to slice from src/midi-record.js, adjust handler extraction, and stub host.
tests/compose_transport.test.mjs Imports transport pures from src/transport.js instead of slicing from main.js.
src/transport.js New module for _transportChartTimePure and _composeSongDurationPure.
src/midi-record.js New module containing MIDI record modal, backend selection, and recording state machine.
src/main.js Wires new module exports, re-attaches window.editor* functions, and imports transport pures.
src/keys.js Adds _uniqueKeysName export for shared use.
CHANGELOG.md Documents the refactor and the new module locations.
Comments suppressed due to low confidence (1)

tests/midi_domain.test.mjs:35

  • The failure message in extractBlock still says it’s searching src/main.js, but this test now slices from src/midi-record.js, which will mislead debugging when the regex fails.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/midi-record.js
}
const rows = _recMidiDeviceRowsPure(backend, raw);

const saved = localStorage.getItem('editor.recordMidiDeviceId') || '';
Comment thread src/midi-record.js
// handle calls listeners with e.data, so the private path
// unwraps the event here to keep one routing function.
_recMidiInput.onmidimessage = (e) => _recMidiOnData(e.data);
localStorage.setItem('editor.recordMidiDeviceId', id);
Comment thread src/midi-record.js
// before audioSource.stop() runs.
if (S.audioSource) {
S.audioSource.onended = () => {
if (_recState === 'recording') window.editorStopRecordMidi();
Comment thread src/transport.js
Comment on lines +29 to +31
export const COMPOSE_CONTENT_TAIL = 0.25; // seconds past the last authored onset, so its
// guide clap (a ~60 ms voice) rings out before
// playbackTick hits duration and cancels voices.

@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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/midi-record.js (1)

472-476: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Self-reference via window.editorStopRecordMidi() instead of the local function.

editorStopRecordMidi is defined in this same module and exported; calling it directly avoids depending on main.js having already attached it to window before this onended callback fires. This contradicts the module's own stated self-containment intent ("Every writer is in this file... main.js wires it... for the modules that only need the predicate") and leaves this path untestable without a full window stub (the leak test only exercises editorStopRecordMidi() via direct extraction, not through this callback).

♻️ Proposed fix
     if (S.audioSource) {
         S.audioSource.onended = () => {
-            if (_recState === 'recording') window.editorStopRecordMidi();
+            if (_recState === 'recording') editorStopRecordMidi();
         };
     }
🤖 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 `@src/midi-record.js` around lines 472 - 476, Update the audio source onended
callback in the module’s recording setup to invoke the local
editorStopRecordMidi function directly instead of window.editorStopRecordMidi().
Preserve the existing recording-state guard and ensure the callback remains
self-contained and independently testable.
🤖 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.

Inline comments:
In `@src/midi-record.js`:
- Around line 227-237: Guard the localStorage write in the private-backend
branch of _recMidiConnect by wrapping
localStorage.setItem('editor.recordMidiDeviceId', id) in the same try/catch
pattern used elsewhere in the file, allowing storage failures to be ignored
while preserving MIDI connection startup.

---

Nitpick comments:
In `@src/midi-record.js`:
- Around line 472-476: Update the audio source onended callback in the module’s
recording setup to invoke the local editorStopRecordMidi function directly
instead of window.editorStopRecordMidi(). Preserve the existing recording-state
guard and ensure the callback remains self-contained and independently testable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 062629d2-6149-47d9-9bd0-7757c98ab5f0

📥 Commits

Reviewing files that changed from the base of the PR and between 429789c and 0a3f419.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • src/keys.js
  • src/main.js
  • src/midi-record.js
  • src/transport.js
  • tests/compose_transport.test.mjs
  • tests/midi_domain.test.mjs
  • tests/midi_domain_leak.test.mjs

Comment thread src/midi-record.js
Comment on lines +227 to +237
_recMidiAccess.inputs.forEach(inp => {
if (inp.id === id) {
_recMidiInput = inp;
// Both paths deliver RAW BYTES to _recMidiOnData — the domain
// handle calls listeners with e.data, so the private path
// unwraps the event here to keep one routing function.
_recMidiInput.onmidimessage = (e) => _recMidiOnData(e.data);
localStorage.setItem('editor.recordMidiDeviceId', id);
}
});
return _recMidiInput ? 'ok' : 'fail';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unguarded localStorage.setItem in the private-backend connect path.

Line 234 calls localStorage.setItem directly, while the same call is wrapped in try/catch everywhere else in this file (lines 142, 220). If storage access throws (e.g. private browsing / quota exceeded), the exception propagates out of this forEach callback, up through _recMidiConnect, and out of the unguarded editorStartRecordMidi() call site — breaking the entire recording start flow instead of just skipping the persisted device-id.

🛡️ Proposed fix
     _recMidiAccess.inputs.forEach(inp => {
         if (inp.id === id) {
             _recMidiInput = inp;
             // Both paths deliver RAW BYTES to _recMidiOnData — the domain
             // handle calls listeners with e.data, so the private path
             // unwraps the event here to keep one routing function.
             _recMidiInput.onmidimessage = (e) => _recMidiOnData(e.data);
-            localStorage.setItem('editor.recordMidiDeviceId', id);
+            try { localStorage.setItem('editor.recordMidiDeviceId', id); } catch (_) {}
         }
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_recMidiAccess.inputs.forEach(inp => {
if (inp.id === id) {
_recMidiInput = inp;
// Both paths deliver RAW BYTES to _recMidiOnData — the domain
// handle calls listeners with e.data, so the private path
// unwraps the event here to keep one routing function.
_recMidiInput.onmidimessage = (e) => _recMidiOnData(e.data);
localStorage.setItem('editor.recordMidiDeviceId', id);
}
});
return _recMidiInput ? 'ok' : 'fail';
_recMidiAccess.inputs.forEach(inp => {
if (inp.id === id) {
_recMidiInput = inp;
// Both paths deliver RAW BYTES to _recMidiOnData — the domain
// handle calls listeners with e.data, so the private path
// unwraps the event here to keep one routing function.
_recMidiInput.onmidimessage = (e) => _recMidiOnData(e.data);
try { localStorage.setItem('editor.recordMidiDeviceId', id); } catch (_) {}
}
});
return _recMidiInput ? 'ok' : 'fail';
🤖 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 `@src/midi-record.js` around lines 227 - 237, Guard the localStorage write in
the private-backend branch of _recMidiConnect by wrapping
localStorage.setItem('editor.recordMidiDeviceId', id) in the same try/catch
pattern used elsewhere in the file, allowing storage failures to be ignored
while preserving MIDI connection startup.

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