moont-render 1.0.0

Render Standard MIDI Files through the moont CM-32L synthesizer
// 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.

//! Renders a Standard MIDI File (.mid) to a WAV file through the
//! [moont](https://docs.rs/moont) CM-32L synthesizer.
//!
//! # Usage
//!
//! ```text
//! moont-render [OPTIONS] --control <ROM> --pcm <ROM> <input.mid> <output.wav>
//! ```
//!
//! With the **`bundle-rom`** feature, ROM arguments are optional:
//!
//! ```text
//! moont-render <input.mid> <output.wav>
//! ```
//!
//! Parses SMF format 0 and 1 files, converts tick-based timing to 32 kHz
//! sample timestamps, feeds all events into the CM-32L synthesizer, and
//! writes a 16-bit stereo WAV with a 2-second tail.
//!
//! # Related crates
//!
//! | Crate | Description |
//! |-------|-------------|
//! | [`moont`](https://docs.rs/moont) | Core CM-32L synthesizer library |
//! | [`moont-live`](https://docs.rs/moont-live) | Real-time ALSA MIDI sink |
//! | [`moont-web`](https://docs.rs/moont-web) | WebAssembly wrapper with Web Audio API |

use moont::smf;
use moont::{Frame, Synth, cm32l};

use std::fs::File;
use std::io::prelude::*;
use std::process;

const SAMPLE_RATE: u32 = 32000;
const CHANNELS: u16 = 2;
const BITS_PER_SAMPLE: u16 = 16;
const TAIL_SECONDS: u32 = 2;
const BUFFER_FRAMES: usize = 8192;

struct Args {
    midi_path: String,
    ctrl_path: Option<String>,
    pcm_path: Option<String>,
    output_path: String,
}

fn usage(prog: &str) {
    if cfg!(feature = "bundle-rom") {
        eprintln!("Usage: {prog} [OPTIONS] <input.mid> <output.wav>");
    } else {
        eprintln!(
            "Usage: {prog} [OPTIONS] --control <ROM> --pcm <ROM> <input.mid> <output.wav>"
        );
    }
    eprintln!();
    eprintln!("Options:");
    eprintln!("  -c, --control ROM  CM-32L control ROM path");
    eprintln!("  -p, --pcm ROM      CM-32L PCM ROM path");
}

fn parse_args() -> Args {
    let raw: Vec<String> = std::env::args().collect();
    let prog = &raw[0];
    let mut ctrl_path = None;
    let mut pcm_path = None;
    let mut positional = Vec::new();

    let mut i = 1;
    while i < raw.len() {
        match raw[i].as_str() {
            "-c" | "--control" => {
                i += 1;
                if i >= raw.len() {
                    eprintln!("Error: --control requires an argument");
                    process::exit(1);
                }
                ctrl_path = Some(raw[i].clone());
            }
            "-p" | "--pcm" => {
                i += 1;
                if i >= raw.len() {
                    eprintln!("Error: --pcm requires an argument");
                    process::exit(1);
                }
                pcm_path = Some(raw[i].clone());
            }
            "-h" | "--help" => {
                usage(prog);
                process::exit(0);
            }
            s if s.starts_with('-') => {
                eprintln!("Error: unknown option: {s}");
                usage(prog);
                process::exit(1);
            }
            _ => {
                positional.push(raw[i].clone());
            }
        }
        i += 1;
    }

    if positional.len() != 2 {
        usage(prog);
        process::exit(1);
    }

    let have_ctrl = ctrl_path.is_some();
    let have_pcm = pcm_path.is_some();
    if have_ctrl != have_pcm {
        eprintln!("Error: --control and --pcm must both be provided");
        process::exit(1);
    }
    if !have_ctrl && !cfg!(feature = "bundle-rom") {
        usage(prog);
        process::exit(1);
    }

    Args {
        midi_path: positional[0].clone(),
        ctrl_path,
        pcm_path,
        output_path: positional[1].clone(),
    }
}

fn load_rom_files(ctrl_path: &str, pcm_path: &str) -> cm32l::Rom {
    let ctrl = std::fs::read(ctrl_path).unwrap_or_else(|e| {
        eprintln!("Failed to read control ROM: {e}");
        process::exit(1);
    });
    let pcm = std::fs::read(pcm_path).unwrap_or_else(|e| {
        eprintln!("Failed to read PCM ROM: {e}");
        process::exit(1);
    });
    cm32l::Rom::new(&ctrl, &pcm).unwrap_or_else(|e| {
        eprintln!("Invalid ROM: {e}");
        process::exit(1);
    })
}

fn load_rom(args: &Args) -> cm32l::Rom {
    match (&args.ctrl_path, &args.pcm_path) {
        (Some(ctrl), Some(pcm)) => load_rom_files(ctrl, pcm),
        #[cfg(feature = "bundle-rom")]
        _ => cm32l::Rom::bundled(),
        #[cfg(not(feature = "bundle-rom"))]
        _ => unreachable!(),
    }
}

fn load_midi(path: &str) -> Vec<smf::Event> {
    let data = std::fs::read(path).unwrap_or_else(|e| {
        eprintln!("Failed to read MIDI file: {e}");
        process::exit(1);
    });
    smf::parse(&data).unwrap_or_else(|e| {
        eprintln!("Failed to parse MIDI file: {e}");
        process::exit(1);
    })
}

fn total_frames(events: &[smf::Event]) -> u32 {
    let last = events.last().map(|e| e.time()).unwrap_or(0);
    last + TAIL_SECONDS * SAMPLE_RATE
}

fn feed_events(
    synth: &mut cm32l::Device,
    events: &[smf::Event],
    ei: &mut usize,
    deadline: u32,
) {
    while *ei < events.len() && events[*ei].time() <= deadline {
        match &events[*ei] {
            smf::Event::Msg { time, msg } => {
                synth.play_msg_at(*msg, *time);
            }
            smf::Event::Sysex { time, data } => {
                synth.play_sysex_at(data, *time);
            }
            _ => {}
        }
        *ei += 1;
    }
}

fn write_wav(
    path: &str,
    synth: &mut cm32l::Device,
    events: &[smf::Event],
    frames: u32,
) {
    let mut f = File::create(path).unwrap_or_else(|e| {
        eprintln!("Failed to create output file: {e}");
        process::exit(1);
    });

    let block_align = CHANNELS * BITS_PER_SAMPLE / 8;
    let byte_rate = SAMPLE_RATE * block_align as u32;
    let data_size = frames * block_align as u32;

    // RIFF header.
    f.write_all(b"RIFF").unwrap();
    f.write_all(&(data_size + 36).to_le_bytes()).unwrap();
    f.write_all(b"WAVE").unwrap();

    // fmt chunk.
    f.write_all(b"fmt ").unwrap();
    f.write_all(&16u32.to_le_bytes()).unwrap();
    f.write_all(&1u16.to_le_bytes()).unwrap(); // PCM.
    f.write_all(&CHANNELS.to_le_bytes()).unwrap();
    f.write_all(&SAMPLE_RATE.to_le_bytes()).unwrap();
    f.write_all(&byte_rate.to_le_bytes()).unwrap();
    f.write_all(&block_align.to_le_bytes()).unwrap();
    f.write_all(&BITS_PER_SAMPLE.to_le_bytes()).unwrap();

    // data chunk.
    f.write_all(b"data").unwrap();
    f.write_all(&data_size.to_le_bytes()).unwrap();

    let mut buf = vec![Frame(0, 0); BUFFER_FRAMES];
    let mut pcm = vec![0u8; BUFFER_FRAMES * 4];
    let mut ei = 0;
    let mut rem = frames as usize;

    while rem > 0 {
        let n = rem.min(BUFFER_FRAMES);
        let current = synth.current_time();
        feed_events(synth, events, &mut ei, current + n as u32);

        let out = &mut buf[..n];
        synth.render(out);

        let bytes = &mut pcm[..n * 4];
        for (chunk, Frame(left, right)) in
            bytes.chunks_exact_mut(4).zip(out.iter())
        {
            let [a, b] = left.to_le_bytes();
            let [c, d] = right.to_le_bytes();
            chunk[0] = a;
            chunk[1] = b;
            chunk[2] = c;
            chunk[3] = d;
        }
        f.write_all(&bytes[..n * 4]).unwrap();
        rem -= n;
    }
}

fn main() {
    let args = parse_args();

    let events = load_midi(&args.midi_path);
    eprintln!("Loaded {} MIDI events", events.len());

    let rom = load_rom(&args);
    let mut synth = cm32l::Device::new(rom);

    let frames = total_frames(&events);
    let seconds = frames / SAMPLE_RATE;
    eprintln!("Rendering {seconds}s ({frames} frames)...");

    write_wav(&args.output_path, &mut synth, &events, frames);
    eprintln!("Wrote {}", args.output_path);
}