moont-live 1.0.0

Real-time CM-32L MIDI sink using ALSA
// 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.

//! Real-time CM-32L MIDI sink using ALSA, powered by the
//! [moont](https://docs.rs/moont) synthesizer.
//!
//! Appears as an ALSA sequencer port that accepts MIDI input and renders
//! audio to the system output.
//!
//! # Usage
//!
//! ```text
//! moont-live [OPTIONS] --control <ROM> --pcm <ROM>
//! ```
//!
//! With the **`bundle-rom`** feature, ROM arguments are optional:
//!
//! ```text
//! moont-live [OPTIONS]
//! ```
//!
//! # Options
//!
//! | Flag | Description |
//! |------|-------------|
//! | `-c`, `--control ROM` | CM-32L control ROM path |
//! | `-p`, `--pcm ROM` | CM-32L PCM ROM path |
//! | `-g`, `--gm` | Enable General MIDI translation |
//! | `-d`, `--device DEV` | ALSA PCM device (default: `default`) |
//! | `-b`, `--buffer N` | Period size in frames (default: 512) |
//! | `-v`, `--verbose` | Log MIDI events to stderr |
//! | `-l`, `--list` | List MIDI ports and exit |
//!
//! # 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-web`](https://docs.rs/moont-web) | WebAssembly wrapper with Web Audio API |

mod audio_out;
mod midi_in;
#[cfg(feature = "repl")]
mod repl;

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

use std::process;
use std::sync::mpsc;

#[cfg(feature = "repl")]
fn drain_commands(
    rx: &mpsc::Receiver<repl::ReplCommand>,
    synth: &mut dyn Synth,
) -> bool {
    while let Ok(cmd) = rx.try_recv() {
        match cmd {
            repl::ReplCommand::Controls(controls) => {
                for control in controls {
                    if let Err(e) = synth.apply_command(control) {
                        eprintln!("Command failed: {e}");
                        break;
                    }
                }
            }
            repl::ReplCommand::Quit => return true,
        }
    }
    false
}

fn drain_midi(
    rx: &mpsc::Receiver<midi_in::MidiEvent>,
    synth: &mut dyn Synth,
    verbose: bool,
) {
    while let Ok(ev) = rx.try_recv() {
        match ev {
            midi_in::MidiEvent::Msg(msg) => {
                if verbose {
                    eprintln!(
                        "MIDI: {:02X} {:02X} {:02X}",
                        msg & 0xFF,
                        (msg >> 8) & 0xFF,
                        (msg >> 16) & 0xFF,
                    );
                }
                synth.play_msg(msg);
            }
            midi_in::MidiEvent::Sysex(data) => {
                if verbose {
                    eprintln!("SysEx: {} bytes", data.len());
                }
                synth.play_sysex(&data);
            }
        }
    }
}

const DEFAULT_PERIOD: u64 = 512;

struct Args {
    ctrl_path: Option<String>,
    pcm_path: Option<String>,
    gm: bool,
    device: String,
    period: u64,
    verbose: bool,
    list: bool,
}

fn usage(prog: &str) {
    if cfg!(feature = "bundle-rom") {
        eprintln!("Usage: {prog} [OPTIONS]");
    } else {
        eprintln!("Usage: {prog} [OPTIONS] --control <ROM> --pcm <ROM>");
    }
    eprintln!();
    eprintln!("Options:");
    eprintln!("  -c, --control ROM  CM-32L control ROM path");
    eprintln!("  -p, --pcm ROM      CM-32L PCM ROM path");
    eprintln!("  -g, --gm           Enable General MIDI translation");
    eprintln!("  -d, --device DEV   ALSA PCM device (default: \"default\")");
    eprintln!(
        "  -b, --buffer N     Period size in frames (default: {DEFAULT_PERIOD})"
    );
    eprintln!("  -v, --verbose      Log MIDI events to stderr");
    eprintln!("  -l, --list         List MIDI ports and exit");
}

fn parse_args() -> Args {
    let raw: Vec<String> = std::env::args().collect();
    let prog = &raw[0];
    let mut args = Args {
        ctrl_path: None,
        pcm_path: None,
        gm: false,
        device: "default".into(),
        period: DEFAULT_PERIOD,
        verbose: false,
        list: false,
    };

    let mut i = 1;
    while i < raw.len() {
        match raw[i].as_str() {
            "-g" | "--gm" => args.gm = true,
            "-v" | "--verbose" => args.verbose = true,
            "-l" | "--list" => args.list = true,
            "-c" | "--control" => {
                i += 1;
                if i >= raw.len() {
                    eprintln!("Error: --control requires an argument");
                    process::exit(1);
                }
                args.ctrl_path = Some(raw[i].clone());
            }
            "-p" | "--pcm" => {
                i += 1;
                if i >= raw.len() {
                    eprintln!("Error: --pcm requires an argument");
                    process::exit(1);
                }
                args.pcm_path = Some(raw[i].clone());
            }
            "-d" | "--device" => {
                i += 1;
                if i >= raw.len() {
                    eprintln!("Error: --device requires an argument");
                    process::exit(1);
                }
                args.device = raw[i].clone();
            }
            "-b" | "--buffer" => {
                i += 1;
                if i >= raw.len() {
                    eprintln!("Error: --buffer requires an argument");
                    process::exit(1);
                }
                args.period = raw[i].parse().unwrap_or_else(|_| {
                    eprintln!("Error: invalid buffer size: {}", raw[i]);
                    process::exit(1);
                });
            }
            "-h" | "--help" => {
                usage(prog);
                process::exit(0);
            }
            s if s.starts_with('-') => {
                eprintln!("Error: unknown option: {s}");
                usage(prog);
                process::exit(1);
            }
            _ => {
                eprintln!("Error: unexpected argument: {}", raw[i]);
                usage(prog);
                process::exit(1);
            }
        }
        i += 1;
    }

    if args.list {
        return args;
    }

    let have_ctrl = args.ctrl_path.is_some();
    let have_pcm = args.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
}

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 main() {
    let args = parse_args();

    if args.list {
        midi_in::list_ports();
        return;
    }

    let rom = load_rom(&args);
    let mut synth: Box<dyn Synth> = if args.gm {
        eprintln!("GM translation enabled");
        Box::new(cm32l::GmDevice::new(rom))
    } else {
        Box::new(cm32l::Device::new(rom))
    };

    let audio = audio_out::AudioOut::open(&args.device, args.period);
    let (midi_tx, midi_rx) = mpsc::sync_channel::<midi_in::MidiEvent>(1024);
    let _midi_thread = midi_in::open_port(midi_tx);
    #[cfg(feature = "repl")]
    let (cmd_tx, cmd_rx) = mpsc::sync_channel::<repl::ReplCommand>(128);
    #[cfg(feature = "repl")]
    let repl_thread = repl::spawn_repl(cmd_tx);

    eprintln!("Running (Ctrl-C to stop)...");

    let period = args.period as usize;
    let mut frames = vec![Frame(0, 0); period];
    let mut pcm_buf = vec![0i16; period * 2];

    loop {
        #[cfg(feature = "repl")]
        if drain_commands(&cmd_rx, synth.as_mut()) {
            break;
        }

        drain_midi(&midi_rx, synth.as_mut(), args.verbose);

        synth.render(&mut frames);
        for (i, Frame(l, r)) in frames.iter().enumerate() {
            pcm_buf[i * 2] = *l;
            pcm_buf[i * 2 + 1] = *r;
        }
        audio.writei(&pcm_buf);
    }

    #[cfg(feature = "repl")]
    {
        drop(midi_rx);
        drop(cmd_rx);
        if let Err(e) = repl_thread.join() {
            eprintln!("REPL thread join error: {:?}", e);
        }
    }
}