Skip to main content

moont_web/
lib.rs

1// Copyright (C) 2021-2026 Geoff Hill <geoff@geoffhill.org>
2//
3// This program is free software: you can redistribute it and/or modify it
4// under the terms of the GNU Lesser General Public License as published by
5// the Free Software Foundation, either version 2.1 of the License, or (at
6// your option) any later version. Read COPYING.LESSER.txt for details.
7
8//! WebAssembly wrapper for the [moont](https://docs.rs/moont) CM-32L
9//! synthesizer.
10//!
11//! Provides a [`CM32LWeb`] struct that hooks into the Web Audio API
12//! via `ScriptProcessorNode` for real-time audio output at 32 kHz.
13//!
14//! # Usage from JavaScript
15//!
16//! With the **`bundle-rom`** feature (ROMs embedded in the WASM binary):
17//!
18//! ```js,ignore
19//! import init, { CM32LWeb } from './moont_web.js';
20//!
21//! await init();
22//! const synth = new CM32LWeb();
23//!
24//! // Note On: channel 1, note C4, velocity 100.
25//! synth.play_midi(new Uint8Array([0x90, 60, 100]));
26//! ```
27//!
28//! Without `bundle-rom`, load ROMs dynamically:
29//!
30//! ```js,ignore
31//! const ctrl = new Uint8Array(await fetch('CM32L_CONTROL.ROM').then(r => r.arrayBuffer()));
32//! const pcm = new Uint8Array(await fetch('CM32L_PCM.ROM').then(r => r.arrayBuffer()));
33//! const synth = CM32LWeb.from_rom(ctrl, pcm);
34//! ```
35//!
36//! # Features
37//!
38//! | Feature | Description |
39//! |---------|-------------|
40//! | **`bundle-rom`** | Embed pre-parsed ROMs in the WASM binary (enables the `new()` constructor) |
41//!
42//! # Related crates
43//!
44//! | Crate | Description |
45//! |-------|-------------|
46//! | [`moont`](https://docs.rs/moont) | Core CM-32L synthesizer library |
47//! | [`moont-render`](https://docs.rs/moont-render) | Render .mid files to .wav |
48//! | [`moont-live`](https://docs.rs/moont-live) | Real-time ALSA MIDI sink |
49
50use std::cell::RefCell;
51use std::rc::Rc;
52
53use moont::{CM32L, Frame, Synth, smf};
54use wasm_bindgen::prelude::*;
55use web_sys::{
56    AudioContext, AudioContextOptions, AudioProcessingEvent,
57    ScriptProcessorNode,
58};
59
60const BUFFER_SIZE: u32 = 1024;
61const SAMPLE_RATE: f32 = moont::SAMPLE_RATE as f32;
62
63struct Inner {
64    synth: CM32L,
65    smf_events: Vec<smf::Event>,
66    smf_index: usize,
67    smf_start: u32,
68    smf_duration: u32,
69}
70
71impl Inner {
72    fn feed_smf(&mut self, deadline: u32) {
73        while self.smf_index < self.smf_events.len()
74            && self.smf_events[self.smf_index].time() <= deadline
75        {
76            match &self.smf_events[self.smf_index] {
77                smf::Event::Msg { time, msg } => {
78                    self.synth.play_msg_at(*msg, *time);
79                }
80                smf::Event::Sysex { time, data } => {
81                    self.synth.play_sysex_at(data, *time);
82                }
83            }
84            self.smf_index += 1;
85        }
86    }
87}
88
89/// CM-32L synthesizer with Web Audio output.
90///
91/// Wraps a [`moont::CM32L`] and connects it to a Web Audio
92/// `ScriptProcessorNode` for real-time 32 kHz stereo playback.
93///
94/// Supports both live MIDI input ([`play_midi`](CM32LWeb::play_midi),
95/// [`play_sysex`](CM32LWeb::play_sysex)) and SMF file playback
96/// ([`load_smf`](CM32LWeb::load_smf)).
97#[wasm_bindgen]
98pub struct CM32LWeb {
99    inner: Rc<RefCell<Inner>>,
100    _ctx: AudioContext,
101    _processor: ScriptProcessorNode,
102    _closure: Closure<dyn FnMut(AudioProcessingEvent)>,
103}
104
105fn setup(synth: CM32L) -> Result<CM32LWeb, JsValue> {
106    let inner = Rc::new(RefCell::new(Inner {
107        synth,
108        smf_events: Vec::new(),
109        smf_index: 0,
110        smf_start: 0,
111        smf_duration: 0,
112    }));
113
114    let opts = AudioContextOptions::new();
115    opts.set_sample_rate(SAMPLE_RATE);
116    let ctx = AudioContext::new_with_context_options(&opts)?;
117
118    let processor = ctx.create_script_processor_with_buffer_size_and_number_of_input_channels_and_number_of_output_channels(
119        BUFFER_SIZE, 0, 2,
120    )?;
121
122    let inner_ref = inner.clone();
123    let closure = Closure::wrap(Box::new(move |event: AudioProcessingEvent| {
124        let buf = event.output_buffer().unwrap();
125        let len = buf.length() as usize;
126        let mut inner = inner_ref.borrow_mut();
127        let current = inner.synth.current_time();
128        inner.feed_smf(current + len as u32);
129        let mut frames = vec![Frame(0, 0); len];
130        inner.synth.render(&mut frames);
131        drop(inner);
132
133        let mut left = vec![0.0f32; len];
134        let mut right = vec![0.0f32; len];
135        for (i, f) in frames.iter().enumerate() {
136            left[i] = f.0 as f32 / 32768.0;
137            right[i] = f.1 as f32 / 32768.0;
138        }
139        buf.copy_to_channel(&left, 0).unwrap();
140        buf.copy_to_channel(&right, 1).unwrap();
141    }) as Box<dyn FnMut(AudioProcessingEvent)>);
142
143    processor.set_onaudioprocess(Some(closure.as_ref().unchecked_ref()));
144    processor.connect_with_audio_node(&ctx.destination())?;
145
146    Ok(CM32LWeb {
147        inner,
148        _ctx: ctx,
149        _processor: processor,
150        _closure: closure,
151    })
152}
153
154#[wasm_bindgen]
155impl CM32LWeb {
156    /// Creates a new CM-32L synthesizer using the bundled ROM.
157    ///
158    /// Requires the **`bundle-rom`** feature and CM-32L ROM files at
159    /// `rom/` during compilation (or set `MOONT_ROM_DIR`).
160    #[cfg(feature = "bundle-rom")]
161    #[wasm_bindgen(constructor)]
162    pub fn new() -> Result<CM32LWeb, JsValue> {
163        let synth = CM32L::new(moont::Rom::bundled());
164        setup(synth)
165    }
166
167    /// Creates a new CM-32L synthesizer from dynamically loaded ROM data.
168    ///
169    /// Pass the full contents of the CM-32L control ROM (64 KiB) and
170    /// PCM ROM (1 MiB) as byte slices.
171    pub fn from_rom(
172        control_rom: &[u8],
173        pcm_rom: &[u8],
174    ) -> Result<CM32LWeb, JsValue> {
175        let rom = moont::Rom::new(control_rom, pcm_rom)
176            .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
177        let synth = CM32L::new(rom);
178        setup(synth)
179    }
180
181    /// Plays a MIDI channel message from raw bytes (1-3 bytes).
182    ///
183    /// Returns `true` if the message was successfully enqueued.
184    pub fn play_midi(&self, data: &[u8]) -> bool {
185        if data.is_empty() || data.len() > 3 {
186            return false;
187        }
188        let mut msg: u32 = 0;
189        for (i, &b) in data.iter().enumerate() {
190            msg |= (b as u32) << (i * 8);
191        }
192        self.inner.borrow_mut().synth.play_msg(msg)
193    }
194
195    /// Plays a SysEx message from raw bytes.
196    ///
197    /// Returns `true` if the message was successfully enqueued.
198    pub fn play_sysex(&self, data: &[u8]) -> bool {
199        self.inner.borrow_mut().synth.play_sysex(data)
200    }
201
202    /// Loads an SMF (Standard MIDI File) for incremental playback.
203    ///
204    /// Events are fed to the synthesizer incrementally during audio
205    /// rendering. All event times are offset by the current synth time
206    /// so playback starts immediately. Returns the total duration in
207    /// seconds, or throws on parse error.
208    pub fn load_smf(&self, data: &[u8]) -> Result<f64, JsValue> {
209        let events =
210            smf::parse(data).map_err(|e| JsValue::from_str(&format!("{e}")))?;
211        let last = events.last().map(|e| e.time()).unwrap_or(0);
212        let tail = 2 * SAMPLE_RATE as u32;
213        let mut inner = self.inner.borrow_mut();
214        let offset = inner.synth.current_time();
215        let shifted: Vec<smf::Event> = events
216            .into_iter()
217            .map(|e| match e {
218                smf::Event::Msg { time, msg } => smf::Event::Msg {
219                    time: time + offset,
220                    msg,
221                },
222                smf::Event::Sysex { time, data } => smf::Event::Sysex {
223                    time: time + offset,
224                    data,
225                },
226            })
227            .collect();
228        inner.smf_events = shifted;
229        inner.smf_index = 0;
230        inner.smf_start = offset;
231        inner.smf_duration = last + tail;
232        Ok(inner.smf_duration as f64 / SAMPLE_RATE as f64)
233    }
234
235    /// Stops SMF playback and clears loaded events.
236    pub fn stop_smf(&self) {
237        let mut inner = self.inner.borrow_mut();
238        inner.smf_events.clear();
239        inner.smf_index = 0;
240        inner.smf_duration = 0;
241    }
242
243    /// Returns elapsed playback time of the current SMF in seconds.
244    pub fn smf_elapsed(&self) -> f64 {
245        let inner = self.inner.borrow();
246        let elapsed =
247            inner.synth.current_time().saturating_sub(inner.smf_start);
248        elapsed as f64 / SAMPLE_RATE as f64
249    }
250
251    /// Returns the current playback position in seconds.
252    pub fn current_time(&self) -> f64 {
253        self.inner.borrow().synth.current_time() as f64 / SAMPLE_RATE as f64
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    #[test]
260    fn test_midi_packing() {
261        let data: &[u8] = &[0x90, 0x3C, 0x7F];
262        let mut msg: u32 = 0;
263        for (i, &b) in data.iter().enumerate() {
264            msg |= (b as u32) << (i * 8);
265        }
266        assert_eq!(msg, 0x007F3C90);
267    }
268}