moont-web 1.0.0

WebAssembly wrapper for the moont CM-32L synthesizer
Documentation
// Copyright (C) 2021-2026 Geoff Hill <geoff@geoffhill.org>
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 2.1 of the License, or (at
// your option) any later version. Read COPYING.LESSER.txt for details.

//! WebAssembly wrapper for the [moont](https://docs.rs/moont) CM-32L
//! synthesizer.
//!
//! Provides a [`Cm32lSynth`] struct that hooks into the Web Audio API
//! via `ScriptProcessorNode` for real-time audio output at 32 kHz.
//!
//! # Usage from JavaScript
//!
//! With the **`bundle-rom`** feature (ROMs embedded in the WASM binary):
//!
//! ```js,ignore
//! import init, { Cm32lSynth } from './moont_web.js';
//!
//! await init();
//! const synth = new Cm32lSynth();
//!
//! // Note On: MIDI channel 2, note C4, velocity 100.
//! synth.play_midi(new Uint8Array([0x91, 60, 100]));
//! ```
//!
//! Without `bundle-rom`, load ROMs dynamically:
//!
//! ```js,ignore
//! const ctrl = new Uint8Array(await fetch('CM32L_CONTROL.ROM').then(r => r.arrayBuffer()));
//! const pcm = new Uint8Array(await fetch('CM32L_PCM.ROM').then(r => r.arrayBuffer()));
//! const synth = Cm32lSynth.from_rom(ctrl, pcm);
//! ```
//!
//! # Features
//!
//! | Feature | Description |
//! |---------|-------------|
//! | **`bundle-rom`** | Embed pre-parsed ROMs in the WASM binary (enables the `new()` constructor) |
//!
//! # Related crates
//!
//! | Crate | Description |
//! |-------|-------------|
//! | [`moont`](https://docs.rs/moont) | Core CM-32L synthesizer library |
//! | [`moont-render`](https://docs.rs/moont-render) | Render .mid files to .wav |
//! | [`moont-live`](https://docs.rs/moont-live) | Real-time ALSA MIDI sink |

use std::cell::RefCell;
use std::rc::Rc;

use moont::{Frame, Synth, cm32l, smf};
use wasm_bindgen::prelude::*;
use web_sys::{
    AudioContext, AudioContextOptions, AudioProcessingEvent,
    ScriptProcessorNode,
};

const BUFFER_SIZE: u32 = 1024;
const SAMPLE_RATE: f32 = moont::SAMPLE_RATE as f32;

struct Inner {
    synth: cm32l::Device,
    smf_events: Vec<smf::Event>,
    smf_index: usize,
    smf_start: u32,
    smf_duration: u32,
}

impl Inner {
    fn feed_smf(&mut self, deadline: u32) {
        while self.smf_index < self.smf_events.len()
            && self.smf_events[self.smf_index].time() <= deadline
        {
            match &self.smf_events[self.smf_index] {
                smf::Event::Msg { time, msg } => {
                    self.synth.play_msg_at(*msg, *time);
                }
                smf::Event::Sysex { time, data } => {
                    self.synth.play_sysex_at(data, *time);
                }
                _ => {}
            }
            self.smf_index += 1;
        }
    }
}

/// CM-32L synthesizer with Web Audio output.
///
/// Wraps a [`moont::cm32l::Device`] and connects it to a Web Audio
/// `ScriptProcessorNode` for real-time 32 kHz stereo playback.
///
/// Supports both live MIDI input ([`play_midi`](Cm32lSynth::play_midi),
/// [`play_sysex`](Cm32lSynth::play_sysex)) and SMF file playback
/// ([`load_smf`](Cm32lSynth::load_smf)).
#[wasm_bindgen]
pub struct Cm32lSynth {
    inner: Rc<RefCell<Inner>>,
    _ctx: AudioContext,
    _processor: ScriptProcessorNode,
    _closure: Closure<dyn FnMut(AudioProcessingEvent)>,
}

fn setup(synth: cm32l::Device) -> Result<Cm32lSynth, JsValue> {
    let inner = Rc::new(RefCell::new(Inner {
        synth,
        smf_events: Vec::new(),
        smf_index: 0,
        smf_start: 0,
        smf_duration: 0,
    }));

    let opts = AudioContextOptions::new();
    opts.set_sample_rate(SAMPLE_RATE);
    let ctx = AudioContext::new_with_context_options(&opts)?;

    let processor = ctx.create_script_processor_with_buffer_size_and_number_of_input_channels_and_number_of_output_channels(
        BUFFER_SIZE, 0, 2,
    )?;

    let inner_ref = inner.clone();
    let closure = Closure::wrap(Box::new(move |event: AudioProcessingEvent| {
        let buf = event.output_buffer().unwrap();
        let len = buf.length() as usize;
        let mut inner = inner_ref.borrow_mut();
        let current = inner.synth.current_time();
        inner.feed_smf(current + len as u32);
        let mut frames = vec![Frame(0, 0); len];
        inner.synth.render(&mut frames);
        drop(inner);

        let mut left = vec![0.0f32; len];
        let mut right = vec![0.0f32; len];
        for (i, f) in frames.iter().enumerate() {
            left[i] = f.0 as f32 / 32768.0;
            right[i] = f.1 as f32 / 32768.0;
        }
        buf.copy_to_channel(&left, 0).unwrap();
        buf.copy_to_channel(&right, 1).unwrap();
    }) as Box<dyn FnMut(AudioProcessingEvent)>);

    processor.set_onaudioprocess(Some(closure.as_ref().unchecked_ref()));
    processor.connect_with_audio_node(&ctx.destination())?;

    Ok(Cm32lSynth {
        inner,
        _ctx: ctx,
        _processor: processor,
        _closure: closure,
    })
}

#[wasm_bindgen]
impl Cm32lSynth {
    /// Creates a new CM-32L synthesizer using the bundled ROM.
    ///
    /// Requires the **`bundle-rom`** feature and CM-32L ROM files at
    /// `rom/` during compilation (or set `MOONT_ROM_DIR`).
    #[cfg(feature = "bundle-rom")]
    #[wasm_bindgen(constructor)]
    pub fn new() -> Result<Cm32lSynth, JsValue> {
        let synth = cm32l::Device::new(cm32l::Rom::bundled());
        setup(synth)
    }

    /// Creates a new CM-32L synthesizer from dynamically loaded ROM data.
    ///
    /// Pass the full contents of the CM-32L control ROM (64 KiB) and
    /// PCM ROM (1 MiB) as byte slices.
    pub fn from_rom(
        control_rom: &[u8],
        pcm_rom: &[u8],
    ) -> Result<Cm32lSynth, JsValue> {
        let rom = cm32l::Rom::new(control_rom, pcm_rom)
            .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
        let synth = cm32l::Device::new(rom);
        setup(synth)
    }

    /// Plays a MIDI channel message from raw bytes (1-3 bytes).
    ///
    /// Returns `true` if the message was successfully enqueued.
    pub fn play_midi(&self, data: &[u8]) -> bool {
        if data.is_empty() || data.len() > 3 {
            return false;
        }
        let mut msg: u32 = 0;
        for (i, &b) in data.iter().enumerate() {
            msg |= (b as u32) << (i * 8);
        }
        self.inner.borrow_mut().synth.play_msg(msg)
    }

    /// Plays a SysEx message from raw bytes.
    ///
    /// Returns `true` if the message was successfully enqueued.
    pub fn play_sysex(&self, data: &[u8]) -> bool {
        self.inner.borrow_mut().synth.play_sysex(data)
    }

    /// Loads an SMF (Standard MIDI File) for incremental playback.
    ///
    /// Events are fed to the synthesizer incrementally during audio
    /// rendering. All event times are offset by the current synth time
    /// so playback starts immediately. Returns the total duration in
    /// seconds, or throws on parse error.
    pub fn load_smf(&self, data: &[u8]) -> Result<f64, JsValue> {
        let events =
            smf::parse(data).map_err(|e| JsValue::from_str(&format!("{e}")))?;
        let last = events.last().map(|e| e.time()).unwrap_or(0);
        let tail = 2 * SAMPLE_RATE as u32;
        let mut inner = self.inner.borrow_mut();
        let offset = inner.synth.current_time();
        let shifted: Vec<smf::Event> = events
            .into_iter()
            .map(|e| match e {
                smf::Event::Msg { time, msg } => smf::Event::Msg {
                    time: time + offset,
                    msg,
                },
                smf::Event::Sysex { time, data } => smf::Event::Sysex {
                    time: time + offset,
                    data,
                },
                _ => e,
            })
            .collect();
        inner.smf_events = shifted;
        inner.smf_index = 0;
        inner.smf_start = offset;
        inner.smf_duration = last + tail;
        Ok(inner.smf_duration as f64 / SAMPLE_RATE as f64)
    }

    /// Stops SMF playback and clears loaded events.
    pub fn stop_smf(&self) {
        let mut inner = self.inner.borrow_mut();
        inner.smf_events.clear();
        inner.smf_index = 0;
        inner.smf_duration = 0;
    }

    /// Returns elapsed playback time of the current SMF in seconds.
    pub fn smf_elapsed(&self) -> f64 {
        let inner = self.inner.borrow();
        let elapsed =
            inner.synth.current_time().saturating_sub(inner.smf_start);
        elapsed as f64 / SAMPLE_RATE as f64
    }

    /// Returns the current playback position in seconds.
    pub fn current_time(&self) -> f64 {
        self.inner.borrow().synth.current_time() as f64 / SAMPLE_RATE as f64
    }
}

#[cfg(test)]
mod tests {
    #[test]
    fn test_midi_packing() {
        let data: &[u8] = &[0x90, 0x3C, 0x7F];
        let mut msg: u32 = 0;
        for (i, &b) in data.iter().enumerate() {
            msg |= (b as u32) << (i * 8);
        }
        assert_eq!(msg, 0x007F3C90);
    }
}