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 [`Cm32lSynth`] 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, { Cm32lSynth } from './moont_web.js';
20//!
21//! await init();
22//! const synth = new Cm32lSynth();
23//!
24//! // Note On: MIDI channel 2, note C4, velocity 100.
25//! synth.play_midi(new Uint8Array([0x91, 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 = Cm32lSynth.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::{Frame, Synth, cm32l, 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::Device,
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            }
85            self.smf_index += 1;
86        }
87    }
88}
89
90/// CM-32L synthesizer with Web Audio output.
91///
92/// Wraps a [`moont::cm32l::Device`] and connects it to a Web Audio
93/// `ScriptProcessorNode` for real-time 32 kHz stereo playback.
94///
95/// Supports both live MIDI input ([`play_midi`](Cm32lSynth::play_midi),
96/// [`play_sysex`](Cm32lSynth::play_sysex)) and SMF file playback
97/// ([`load_smf`](Cm32lSynth::load_smf)).
98#[wasm_bindgen]
99pub struct Cm32lSynth {
100    inner: Rc<RefCell<Inner>>,
101    _ctx: AudioContext,
102    _processor: ScriptProcessorNode,
103    _closure: Closure<dyn FnMut(AudioProcessingEvent)>,
104}
105
106fn setup(synth: cm32l::Device) -> Result<Cm32lSynth, JsValue> {
107    let inner = Rc::new(RefCell::new(Inner {
108        synth,
109        smf_events: Vec::new(),
110        smf_index: 0,
111        smf_start: 0,
112        smf_duration: 0,
113    }));
114
115    let opts = AudioContextOptions::new();
116    opts.set_sample_rate(SAMPLE_RATE);
117    let ctx = AudioContext::new_with_context_options(&opts)?;
118
119    let processor = ctx.create_script_processor_with_buffer_size_and_number_of_input_channels_and_number_of_output_channels(
120        BUFFER_SIZE, 0, 2,
121    )?;
122
123    let inner_ref = inner.clone();
124    let closure = Closure::wrap(Box::new(move |event: AudioProcessingEvent| {
125        let buf = event.output_buffer().unwrap();
126        let len = buf.length() as usize;
127        let mut inner = inner_ref.borrow_mut();
128        let current = inner.synth.current_time();
129        inner.feed_smf(current + len as u32);
130        let mut frames = vec![Frame(0, 0); len];
131        inner.synth.render(&mut frames);
132        drop(inner);
133
134        let mut left = vec![0.0f32; len];
135        let mut right = vec![0.0f32; len];
136        for (i, f) in frames.iter().enumerate() {
137            left[i] = f.0 as f32 / 32768.0;
138            right[i] = f.1 as f32 / 32768.0;
139        }
140        buf.copy_to_channel(&left, 0).unwrap();
141        buf.copy_to_channel(&right, 1).unwrap();
142    }) as Box<dyn FnMut(AudioProcessingEvent)>);
143
144    processor.set_onaudioprocess(Some(closure.as_ref().unchecked_ref()));
145    processor.connect_with_audio_node(&ctx.destination())?;
146
147    Ok(Cm32lSynth {
148        inner,
149        _ctx: ctx,
150        _processor: processor,
151        _closure: closure,
152    })
153}
154
155#[wasm_bindgen]
156impl Cm32lSynth {
157    /// Creates a new CM-32L synthesizer using the bundled ROM.
158    ///
159    /// Requires the **`bundle-rom`** feature and CM-32L ROM files at
160    /// `rom/` during compilation (or set `MOONT_ROM_DIR`).
161    #[cfg(feature = "bundle-rom")]
162    #[wasm_bindgen(constructor)]
163    pub fn new() -> Result<Cm32lSynth, JsValue> {
164        let synth = cm32l::Device::new(cm32l::Rom::bundled());
165        setup(synth)
166    }
167
168    /// Creates a new CM-32L synthesizer from dynamically loaded ROM data.
169    ///
170    /// Pass the full contents of the CM-32L control ROM (64 KiB) and
171    /// PCM ROM (1 MiB) as byte slices.
172    pub fn from_rom(
173        control_rom: &[u8],
174        pcm_rom: &[u8],
175    ) -> Result<Cm32lSynth, JsValue> {
176        let rom = cm32l::Rom::new(control_rom, pcm_rom)
177            .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
178        let synth = cm32l::Device::new(rom);
179        setup(synth)
180    }
181
182    /// Plays a MIDI channel message from raw bytes (1-3 bytes).
183    ///
184    /// Returns `true` if the message was successfully enqueued.
185    pub fn play_midi(&self, data: &[u8]) -> bool {
186        if data.is_empty() || data.len() > 3 {
187            return false;
188        }
189        let mut msg: u32 = 0;
190        for (i, &b) in data.iter().enumerate() {
191            msg |= (b as u32) << (i * 8);
192        }
193        self.inner.borrow_mut().synth.play_msg(msg)
194    }
195
196    /// Plays a SysEx message from raw bytes.
197    ///
198    /// Returns `true` if the message was successfully enqueued.
199    pub fn play_sysex(&self, data: &[u8]) -> bool {
200        self.inner.borrow_mut().synth.play_sysex(data)
201    }
202
203    /// Loads an SMF (Standard MIDI File) for incremental playback.
204    ///
205    /// Events are fed to the synthesizer incrementally during audio
206    /// rendering. All event times are offset by the current synth time
207    /// so playback starts immediately. Returns the total duration in
208    /// seconds, or throws on parse error.
209    pub fn load_smf(&self, data: &[u8]) -> Result<f64, JsValue> {
210        let events =
211            smf::parse(data).map_err(|e| JsValue::from_str(&format!("{e}")))?;
212        let last = events.last().map(|e| e.time()).unwrap_or(0);
213        let tail = 2 * SAMPLE_RATE as u32;
214        let mut inner = self.inner.borrow_mut();
215        let offset = inner.synth.current_time();
216        let shifted: Vec<smf::Event> = events
217            .into_iter()
218            .map(|e| match e {
219                smf::Event::Msg { time, msg } => smf::Event::Msg {
220                    time: time + offset,
221                    msg,
222                },
223                smf::Event::Sysex { time, data } => smf::Event::Sysex {
224                    time: time + offset,
225                    data,
226                },
227                _ => e,
228            })
229            .collect();
230        inner.smf_events = shifted;
231        inner.smf_index = 0;
232        inner.smf_start = offset;
233        inner.smf_duration = last + tail;
234        Ok(inner.smf_duration as f64 / SAMPLE_RATE as f64)
235    }
236
237    /// Stops SMF playback and clears loaded events.
238    pub fn stop_smf(&self) {
239        let mut inner = self.inner.borrow_mut();
240        inner.smf_events.clear();
241        inner.smf_index = 0;
242        inner.smf_duration = 0;
243    }
244
245    /// Returns elapsed playback time of the current SMF in seconds.
246    pub fn smf_elapsed(&self) -> f64 {
247        let inner = self.inner.borrow();
248        let elapsed =
249            inner.synth.current_time().saturating_sub(inner.smf_start);
250        elapsed as f64 / SAMPLE_RATE as f64
251    }
252
253    /// Returns the current playback position in seconds.
254    pub fn current_time(&self) -> f64 {
255        self.inner.borrow().synth.current_time() as f64 / SAMPLE_RATE as f64
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    #[test]
262    fn test_midi_packing() {
263        let data: &[u8] = &[0x90, 0x3C, 0x7F];
264        let mut msg: u32 = 0;
265        for (i, &b) in data.iter().enumerate() {
266            msg |= (b as u32) << (i * 8);
267        }
268        assert_eq!(msg, 0x007F3C90);
269    }
270}