feat: implement MIDI device API with piano visualizer example#35
Conversation
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>
|
Warning Rate limit exceeded
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 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughAdds 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
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)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
Stringwithalloc::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-unknownwith no std allocator by default unlessallocis 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
📒 Files selected for processing (9)
Cargo.tomlexamples/midi-demo/Cargo.tomlexamples/midi-demo/src/lib.rsexamples/raf-demo/src/lib.rsoxide-browser/Cargo.tomloxide-browser/src/capabilities.rsoxide-browser/src/lib.rsoxide-browser/src/midi.rsoxide-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 }; |
There was a problem hiding this comment.
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.
| 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.
| /// 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()) | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
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
Adds full MIDI input/output support to Oxide via a new
midihost module.Host (oxide-browser)
oxide-browser/src/midi.rs— new module with platform-conditional implementation:coremidicrate.open_inputconnects a CoreMIDI source; incoming packets are pushed into a per-handleArc<Mutex<VecDeque<Vec<u8>>>>by a CoreMIDI background callback.open_outputopens a CoreMIDI destination port for sending raw bytes.HostStategains amidi: Arc<Mutex<Option<MidiState>>>field, lazily initialised on the firstapi_midi_open_*call.register_midi_functions(called fromregister_host_functionsalongside 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_closecoremidi = "0.7"added as a macOS-only target dependency; avoids thealsa-syslinks conflict thatmidir v0.10would cause withcpal.SDK (oxide-sdk)
midi_input_count,midi_output_count,midi_input_name,midi_output_name,midi_open_input,midi_open_output,midi_send,midi_recv(returnsOption<Vec<u8>>),midi_close.Example (examples/midi-demo)
Piano keyboard visualizer guest app demonstrating the full MIDI API:
start_app; shows a button per device.handle_midi_message.Other
static-mut-refsclippy error inexamples/raf-demo(readVX/VYinto locals before passing toformat!).examples/midi-demoadded to workspace members.Summary by CodeRabbit
New Features
Bug Fixes