Skip to content

feat: implement MIDI device API with piano visualizer example#35

Merged
niklabh merged 3 commits into
mainfrom
feat/midi-device-api
Apr 17, 2026
Merged

feat: implement MIDI device API with piano visualizer example#35
niklabh merged 3 commits into
mainfrom
feat/midi-device-api

Conversation

@niklabh
Copy link
Copy Markdown
Owner

@niklabh niklabh commented Apr 16, 2026

Adds full MIDI input/output support to Oxide via a new midi host module.

Host (oxide-browser)

  • oxide-browser/src/midi.rs — new module with platform-conditional implementation:
    • macOS: CoreMIDI backend via coremidi crate. open_input connects a CoreMIDI source; incoming packets are pushed into a per-handle Arc<Mutex<VecDeque<Vec<u8>>>> by a CoreMIDI background callback. open_output opens a CoreMIDI destination port for sending raw bytes.
    • Linux / Windows: no-op stubs returning 0 devices — graceful degradation; cross-platform backends (ALSA, WinMM) can be added later without changing the API.
  • HostState gains a midi: Arc<Mutex<Option<MidiState>>> field, lazily initialised on the first api_midi_open_* call.
  • 8 host functions registered in register_midi_functions (called from register_host_functions alongside WebRTC and WebSocket): api_midi_input_count, api_midi_output_count, api_midi_input_name, api_midi_output_name, api_midi_open_input, api_midi_open_output, api_midi_send, api_midi_recv, api_midi_close
  • coremidi = "0.7" added as a macOS-only target dependency; avoids the alsa-sys links conflict that midir v0.10 would cause with cpal.

SDK (oxide-sdk)

  • 9 FFI imports + public wrappers added after the WebSocket section: midi_input_count, midi_output_count, midi_input_name, midi_output_name, midi_open_input, midi_open_output, midi_send, midi_recv (returns Option<Vec<u8>>), midi_close.

Example (examples/midi-demo)

Piano keyboard visualizer guest app demonstrating the full MIDI API:

  • Scans input devices once in start_app; shows a button per device.
  • Click to connect / click again to disconnect.
  • Real-time message log (ring buffer, 16 entries) with colour-coded entries: Note On (green), Note Off (grey), CC (amber), Pitch Bend (blue), Program Change (purple), unknown (hex dump).
  • Piano keyboard rendering (C2–B5, 4 octaves): white keys light up blue on Note On, black keys light up on their respective notes; keys dim on Note Off. Octave labels drawn on C notes.
  • No heap allocations in the hot receive path — incoming bytes are copied to a fixed-size stack array before being dispatched to handle_midi_message.

Other

  • Fix pre-existing static-mut-refs clippy error in examples/raf-demo (read VX/VY into locals before passing to format!).
  • examples/midi-demo added to workspace members.

Summary by CodeRabbit

  • New Features

    • Added comprehensive MIDI support: host MIDI API, SDK wrappers for enumerate/open/send/receive/close, and crate-level docs.
    • Added a new MIDI demo with real-time device monitoring, interactive piano keyboard visualization, message log, and device connect controls.
    • Included the MIDI demo in the workspace.
  • Bug Fixes

    • Fixed velocity handling in the RAF demo rendering.

Adds full MIDI input/output support to Oxide via a new `midi` host module.

## Host (oxide-browser)

- `oxide-browser/src/midi.rs` — new module with platform-conditional
  implementation:
  - **macOS**: CoreMIDI backend via `coremidi` crate. `open_input` connects
    a CoreMIDI source; incoming packets are pushed into a per-handle
    `Arc<Mutex<VecDeque<Vec<u8>>>>` by a CoreMIDI background callback.
    `open_output` opens a CoreMIDI destination port for sending raw bytes.
  - **Linux / Windows**: no-op stubs returning 0 devices — graceful
    degradation; cross-platform backends (ALSA, WinMM) can be added later
    without changing the API.
- `HostState` gains a `midi: Arc<Mutex<Option<MidiState>>>` field, lazily
  initialised on the first `api_midi_open_*` call.
- 8 host functions registered in `register_midi_functions` (called from
  `register_host_functions` alongside WebRTC and WebSocket):
  `api_midi_input_count`, `api_midi_output_count`,
  `api_midi_input_name`, `api_midi_output_name`,
  `api_midi_open_input`, `api_midi_open_output`,
  `api_midi_send`, `api_midi_recv`, `api_midi_close`
- `coremidi = "0.7"` added as a macOS-only target dependency; avoids the
  `alsa-sys` links conflict that `midir v0.10` would cause with `cpal`.

## SDK (oxide-sdk)

- 9 FFI imports + public wrappers added after the WebSocket section:
  `midi_input_count`, `midi_output_count`, `midi_input_name`,
  `midi_output_name`, `midi_open_input`, `midi_open_output`,
  `midi_send`, `midi_recv` (returns `Option<Vec<u8>>`), `midi_close`.

## Example (examples/midi-demo)

Piano keyboard visualizer guest app demonstrating the full MIDI API:
- Scans input devices once in `start_app`; shows a button per device.
- Click to connect / click again to disconnect.
- Real-time message log (ring buffer, 16 entries) with colour-coded entries:
  Note On (green), Note Off (grey), CC (amber), Pitch Bend (blue),
  Program Change (purple), unknown (hex dump).
- Piano keyboard rendering (C2–B5, 4 octaves): white keys light up blue on
  Note On, black keys light up on their respective notes; keys dim on
  Note Off. Octave labels drawn on C notes.
- No heap allocations in the hot receive path — incoming bytes are copied
  to a fixed-size stack array before being dispatched to `handle_midi_message`.

## Other

- Fix pre-existing `static-mut-refs` clippy error in `examples/raf-demo`
  (read `VX`/`VY` into locals before passing to `format!`).
- `examples/midi-demo` added to workspace members.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 16, 2026

Warning

Rate limit exceeded

@niklabh has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 47 minutes and 19 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 47 minutes and 19 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a1ebf415-b5da-45da-a849-a0b1e7e250d5

📥 Commits

Reviewing files that changed from the base of the PR and between a6d7372 and 34770bb.

📒 Files selected for processing (3)
  • oxide-browser/src/bookmarks.rs
  • oxide-browser/src/capabilities.rs
  • oxide-browser/src/midi.rs
📝 Walkthrough

Walkthrough

Adds a new MIDI subsystem: host-side MIDI support (CoreMIDI on macOS, stub elsewhere), Wasmtime host imports, SDK wrappers, and a new midi-demo example crate with a MIDI monitor and piano visualizer. Also registers the MIDI API in HostState and workspace.

Changes

Cohort / File(s) Summary
Workspace
Cargo.toml
Added examples/midi-demo to the workspace members.
New Example
examples/midi-demo/Cargo.toml, examples/midi-demo/src/lib.rs
Added a cdylib example crate implementing a MIDI monitor + piano keyboard visualizer; exports start_app and on_frame.
Host MIDI implementation
oxide-browser/Cargo.toml, oxide-browser/src/lib.rs, oxide-browser/src/capabilities.rs, oxide-browser/src/midi.rs
Added macOS-only coremidi dependency, introduced midi field in HostState, registered MIDI host functions, and implemented platform-specific MidiState with APIs for enumerate/open/send/recv/close (macOS backend + non-macOS stub).
SDK wrappers
oxide-sdk/src/lib.rs
Added public wrapper functions mapping host FFI to Rust: device counts/names, open/close, send, recv (with stack+heap buffer handling) and updated API docs.
Minor example tweak
examples/raf-demo/src/lib.rs
Small local refactor: copy global velocity statics into locals before formatting HUD text.

Sequence Diagram(s)

sequenceDiagram
    participant Guest as Guest WASM (example)
    participant SDK as oxide-sdk
    participant Host as oxide-browser host
    participant Platform as Platform MIDI (CoreMIDI / stub)

    Guest->>SDK: call midi_input_count()/midi_open_input()/midi_recv()/midi_send()
    SDK->>Host: host FFI calls (api_midi_input_count / api_midi_open_input / api_midi_recv / api_midi_send)
    Host->>Host: ensure_midi() (lazy init MidiState)
    Host->>Platform: platform-specific ops (enumerate/open/send/recv/close)
    Platform-->>Host: device names / open handles / queued messages / send ack
    Host-->>SDK: return values (counts, names, message bytes, status)
    SDK-->>Guest: Rust types (String, Vec<u8>, handles, i32/status)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐇 I tapped the keys with nimble feet,
Notes leapt forth, a tiny treat,
Cables hummed and LEDs glowed bright,
CoreMIDI whispered through the night,
Oxide sings — a rabbit’s delight! 🎶

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 74.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: implementing a MIDI device API and providing a piano visualizer example, both central to this PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/midi-device-api

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
examples/midi-demo/src/lib.rs (1)

489-523: Remove heap formatting from the MIDI receive path.

These helpers build a String with alloc::format! and then copy it into fixed buffers, so every incoming message still allocates. For a real-time wasm example, write directly into the provided stack buffer or store structured log fields and format only at render time.

As per coding guidelines, guest app code must remain allocation-minimal since examples run on wasm32-unknown-unknown with no std allocator by default unless alloc is explicitly linked.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/midi-demo/src/lib.rs` around lines 489 - 523, The helper functions
(format_note_on, format_note_off, format_cc, format_prog, format_pitchbend)
currently allocate via alloc::format! then copy into fixed buffers; change them
to avoid heap allocation by writing directly into the caller-provided stack
buffer (e.g., take &mut [u8] or &mut [u8; N] and return &str) and use
core::fmt::Write with a small buffer-writer or use write! into a custom writer
that appends bytes to the buffer, rather than alloc::format!. Specifically:
replace alloc::format! usage in format_note_on and format_note_off by changing
their signatures to accept the mutable buffer like format_cc/format_prog, format
into that buffer with core::fmt::Write (or manual byte writes) and return
core::str::from_utf8(&buf[..n]).unwrap_or("") so no heap allocation occurs;
apply the same change for format_pitchbend and ensure buffer length checks and
safe UTF-8 conversion are preserved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@examples/midi-demo/src/lib.rs`:
- Line 229: The code slices the UTF-8 string `name` with `&name[..22]`, which
can panic on multibyte characters; change the truncation to operate on char
boundaries (e.g., build a truncated String from `name.chars().take(22)` or use
`char_indices` to find a safe byte index) and assign that to `display`
(adjusting its type from &str to String if necessary) so device names are
truncated safely without slicing in the middle of a code point.

In `@oxide-browser/src/midi.rs`:
- Around line 95-105: The incoming MIDI queue (the Arc<Mutex<VecDeque<Vec<u8>>>>
named queue / q used in the input_port callback) is unbounded; modify the
callback to enforce a fixed max capacity (e.g., MIDI_QUEUE_MAX or a constant)
and when inserting a new pkt.data().to_vec() check the deque length and drop
items to keep it within the limit (for example pop_front() to remove oldest
messages before push_back(), or pop_back() to drop newest depending on desired
policy). Ensure the capacity constant is declared near the MIDI setup and that
all locking still uses the existing Mutex guard in the input_port closure so the
push/pop operations are atomic.

In `@oxide-sdk/src/lib.rs`:
- Around line 2112-2124: midi_recv currently allocates a fixed 256-byte buffer
and can silently truncate SysEx messages; change the API to either accept a
caller-supplied buffer or provide a two-step length/query API so callers can
detect overflow and avoid per-poll allocations: add a new function (e.g.,
midi_recv_len(handle: u32) -> Result<usize, i32>) that calls _api_midi_recv with
a null/0 pointer to query required length, and/or change midi_recv to a
recv_into(handle: u32, out: &mut [u8]) -> Result<usize, i32> that calls
_api_midi_recv with out.as_mut_ptr() and out.len(), returns the actual length
and a way to indicate if the buffer was too small (e.g., returned length >
out.len() or a specific negative code), and update callers to allocate/resize
based on midi_recv_len or reuse a provided buffer to avoid per-poll Vec
allocations; reference midi_recv and _api_midi_recv when implementing these
changes.

---

Nitpick comments:
In `@examples/midi-demo/src/lib.rs`:
- Around line 489-523: The helper functions (format_note_on, format_note_off,
format_cc, format_prog, format_pitchbend) currently allocate via alloc::format!
then copy into fixed buffers; change them to avoid heap allocation by writing
directly into the caller-provided stack buffer (e.g., take &mut [u8] or &mut
[u8; N] and return &str) and use core::fmt::Write with a small buffer-writer or
use write! into a custom writer that appends bytes to the buffer, rather than
alloc::format!. Specifically: replace alloc::format! usage in format_note_on and
format_note_off by changing their signatures to accept the mutable buffer like
format_cc/format_prog, format into that buffer with core::fmt::Write (or manual
byte writes) and return core::str::from_utf8(&buf[..n]).unwrap_or("") so no heap
allocation occurs; apply the same change for format_pitchbend and ensure buffer
length checks and safe UTF-8 conversion are preserved.
🪄 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

Run ID: 1fea2c44-ea1e-463e-8f6b-13c1109c894b

📥 Commits

Reviewing files that changed from the base of the PR and between 14676f8 and 508ddd0.

📒 Files selected for processing (9)
  • Cargo.toml
  • examples/midi-demo/Cargo.toml
  • examples/midi-demo/src/lib.rs
  • examples/raf-demo/src/lib.rs
  • oxide-browser/Cargo.toml
  • oxide-browser/src/capabilities.rs
  • oxide-browser/src/lib.rs
  • oxide-browser/src/midi.rs
  • oxide-sdk/src/lib.rs

(40u8, 50u8, 80u8)
};
canvas_rounded_rect(btn_x, btn_y, 178.0, 32.0, 6.0, br, bg, bb, 255);
let display = if name.len() > 22 { &name[..22] } else { name };
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid byte-slicing UTF-8 device names.

&name[..22] can panic for non-ASCII MIDI device names when byte 22 lands in the middle of a code point. Truncate on a char boundary instead.

💡 Safe truncation
-            let display = if name.len() > 22 { &name[..22] } else { name };
+            let display = if name.len() > 22 {
+                let mut end = 22;
+                while !name.is_char_boundary(end) {
+                    end -= 1;
+                }
+                &name[..end]
+            } else {
+                name
+            };
📝 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
let display = if name.len() > 22 { &name[..22] } else { name };
let display = if name.len() > 22 {
let mut end = 22;
while !name.is_char_boundary(end) {
end -= 1;
}
&name[..end]
} else {
name
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/midi-demo/src/lib.rs` at line 229, The code slices the UTF-8 string
`name` with `&name[..22]`, which can panic on multibyte characters; change the
truncation to operate on char boundaries (e.g., build a truncated String from
`name.chars().take(22)` or use `char_indices` to find a safe byte index) and
assign that to `display` (adjusting its type from &str to String if necessary)
so device names are truncated safely without slicing in the middle of a code
point.

Comment thread oxide-browser/src/midi.rs
Comment thread oxide-sdk/src/lib.rs
Comment on lines +2112 to +2124
/// Poll for the next queued MIDI message on an input `handle`.
///
/// Returns `Some(bytes)` if a message is available, or `None` if the queue
/// is empty. MIDI messages are typically 1–3 bytes; SysEx can be longer.
pub fn midi_recv(handle: u32) -> Option<Vec<u8>> {
let mut buf = [0u8; 256];
let n = unsafe { _api_midi_recv(handle, buf.as_mut_ptr() as u32, buf.len() as u32) };
if n < 0 {
None
} else {
Some(buf[..n as usize].to_vec())
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Preserve full MIDI messages instead of truncating them at 256 bytes.

midi_recv() currently hardcodes a 256-byte buffer and returns whatever fits, but the host side writes min(msg.len(), out_cap) and does not signal truncation. That means longer SysEx packets are delivered as corrupted partial messages, and the wrapper also allocates on every poll. Please switch this API to a caller-supplied buffer or a two-step length/query API so guests can size the buffer and detect overflow.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@oxide-sdk/src/lib.rs` around lines 2112 - 2124, midi_recv currently allocates
a fixed 256-byte buffer and can silently truncate SysEx messages; change the API
to either accept a caller-supplied buffer or provide a two-step length/query API
so callers can detect overflow and avoid per-poll allocations: add a new
function (e.g., midi_recv_len(handle: u32) -> Result<usize, i32>) that calls
_api_midi_recv with a null/0 pointer to query required length, and/or change
midi_recv to a recv_into(handle: u32, out: &mut [u8]) -> Result<usize, i32> that
calls _api_midi_recv with out.as_mut_ptr() and out.len(), returns the actual
length and a way to indicate if the buffer was too small (e.g., returned length
> out.len() or a specific negative code), and update callers to allocate/resize
based on midi_recv_len or reuse a provided buffer to avoid per-poll Vec
allocations; reference midi_recv and _api_midi_recv when implementing these
changes.

niklabh added 2 commits April 17, 2026 18:55
Hardens the MIDI host capability introduced in 508ddd0. Three issues:
the input queue grew without bound, CoreMIDI packets that contain more
than one MIDI message were returned as a single opaque blob (so guests
would only ever parse the first message), and an undersized guest buffer
silently truncated and dropped the tail of a SysEx dump.

## Host (oxide-browser/src/midi.rs)

- Cap the per-port input queue at `MAX_QUEUED_MESSAGES = 4096`. When the
  guest isn't draining fast enough the oldest message is dropped on
  push — same semantics as the demo's on-screen ring buffer. At ~12 KB
  for typical 3-byte note events this absorbs ~1 min of backlog at the
  30 kmsg/s MIDI ceiling without risk of an unbounded memory leak from a
  stuck or crashed guest.
- New `enqueue_messages()` helper splits each raw CoreMIDI packet into
  individual MIDI messages before enqueuing. Handles channel voice
  (0x80–0xEF), System Common (0xF1–0xF6), System Real-Time (0xF8–0xFF),
  and variable-length SysEx (0xF0 … 0xF7). Bytes without a preceding
  status byte are skipped defensively. `api_midi_recv` now always
  returns exactly one MIDI message.
- New `peek_len()` method on `MidiState` (and the non-macOS stub) so the
  host function can check the guest's buffer capacity before popping.
- `api_midi_recv` is now peek-then-pop. Return codes:
  - `>= 0` — bytes written.
  - `-1`   — queue empty (unchanged).
  - `-2`   — new: guest buffer too small; message stays queued so the
             guest can retry with a larger buffer. Replaces the previous
             silent-truncation behaviour that lost bytes permanently.

## SDK (oxide-sdk/src/lib.rs)

- `midi_recv()` tries its 256-byte stack buffer first, then on `-2`
  transparently retries with a 64 KB heap buffer. Guests don't need to
  know about the `-2` protocol — the wrapper handles SysEx up to 64 KB
  out of the box.
- Doc comment on `midi_recv` tightened to state the one-message-per-call
  contract and describe the retry behaviour.
- Added a MIDI row to the API Categories table in the crate-level docs
  so the API is discoverable from the module overview.

## Host crate docs (oxide-browser/src/lib.rs)

- Added `midi` and `websocket` rows to the modules overview table
  (websocket was missing from the previous commit too).

## Verification

- `cargo build -p oxide-browser`
- `cargo build -p oxide-sdk --target wasm32-unknown-unknown`
- `cargo build --target wasm32-unknown-unknown --release -p midi-demo`
- `cargo clippy --workspace --all-targets -- -D warnings`
- `cargo fmt --all`
- `cargo test --workspace`

All pass. The `examples/midi-demo` guest needed no changes: because the
host now splits packets, its existing "inspect msg[0]" dispatch is
correct for every queued entry.

Made-with: Cursor
Rust 1.95 (released 2026-04) promotes several lints to stricter defaults
that fire under the workspace's `-D warnings` clippy policy. Local 1.91
builds were green; CI on 1.95 failed seven times. All issues are
syntactic — no behavioural change.

## Fixes

**oxide-browser/src/bookmarks.rs**
- `list_all`: replace `sort_by(|a, b| b.x.cmp(&a.x))` with
  `sort_by_key(|b| Reverse(b.x))` (`clippy::unnecessary_sort_by`).

**oxide-browser/src/capabilities.rs**
- Five GPU host functions (`api_gpu_write_buffer`, `api_gpu_draw`,
  `api_gpu_dispatch_compute`, `api_gpu_destroy_buffer`,
  `api_gpu_destroy_texture`) used the pattern
  `match opt.as_ref() { Some(g) => if g.method() { 1 } else { 0 }, None => 0 }`
  which trips `clippy::collapsible_match`. Rewritten as
  `u32::from(opt.as_ref().is_some_and(|g| g.method()))` — same semantics,
  one line, no lint.

**oxide-browser/src/midi.rs**
- `api_midi_send` had the same nested `match`/`if` shape; rewritten as
  `if g.as_mut().is_some_and(|s| s.send(handle, &data)) { 0 } else { -1 }`.

## Verification

Ran on freshly-updated `rustc 1.95.0 (59807616e 2026-04-14)`:

- `cargo clippy -p oxide-browser -- -D warnings`
- `cargo clippy --workspace --all-targets -- -D warnings`
- `cargo fmt --all --check`
- `cargo test --workspace`

All pass.

Made-with: Cursor
@niklabh niklabh merged commit 3cba2d9 into main Apr 17, 2026
5 checks passed
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.

1 participant