From dc166b6a6acf73df14bb3a52b3311a85042f593f Mon Sep 17 00:00:00 2001 From: Alban Gicquel <6368237+albanmgicquel@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:17:06 -0400 Subject: [PATCH 1/7] tecs-audio: a General MIDI synthesizer as one voice of the mixer rustysynth (MIT) renders a SoundFont in the output callback as the input of a voice started by a PLAY carrying the new SYNTH flag. Events cross from the game thread through a bounded lock-free SPSC ring (4096) into a preallocated schedule (4096); a send that finds no room is refused and counted. The render splits at rustysynth's 8-frame block boundaries and applies each event at the first boundary at or after its frame, so the output is a deterministic function of the schedule. The clock is frames rendered over the rate. FFI: tecs_audio_load_soundfont, tecs_audio_release_soundfont, tecs_audio_synth_open/send/notes_off/reset/now/close. Tests cover the grid, ordering, reset discarding, queue bounds, and with TECS_TEST_SOUNDFONT a real SoundFont: sample-exact landing, determinism, voice gain, cuts and pause. --- Cargo.lock | 7 + Cargo.toml | 1 + native/rust/tecs-audio/Cargo.toml | 1 + native/rust/tecs-audio/examples/synthscale.rs | 72 ++ native/rust/tecs-audio/src/command.rs | 4 + native/rust/tecs-audio/src/engine.rs | 76 ++ native/rust/tecs-audio/src/lib.rs | 213 +++++ native/rust/tecs-audio/src/synth.rs | 730 ++++++++++++++++++ native/rust/tecs-audio/tests/synth.rs | 236 ++++++ src/tecs/{audio.nupp => audio/init.nupp} | 0 10 files changed, 1340 insertions(+) create mode 100644 native/rust/tecs-audio/examples/synthscale.rs create mode 100644 native/rust/tecs-audio/src/synth.rs create mode 100644 native/rust/tecs-audio/tests/synth.rs rename src/tecs/{audio.nupp => audio/init.nupp} (100%) diff --git a/Cargo.lock b/Cargo.lock index 72720602..fe03a809 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2661,6 +2661,12 @@ dependencies = [ "unicode-script", ] +[[package]] +name = "rustysynth" +version = "1.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2fafc9b46be0b8ad0a15bb17406e34b4602987c90d12509ca71710ab545b40f" + [[package]] name = "safe_arch" version = "1.1.0" @@ -3186,6 +3192,7 @@ name = "tecs-audio" version = "0.1.0" dependencies = [ "cpal", + "rustysynth", "symphonia", ] diff --git a/Cargo.toml b/Cargo.toml index 4ea995c5..2c7753e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ rayon = "1.12" reqwest = { version = "0.13.4", default-features = false, features = ["deflate", "gzip", "rustls-no-provider", "socks", "stream"] } resvg = { version = "0.47", default-features = false, features = ["text"] } regex = "1.12" +rustysynth = "1.3.7" rmcp = { version = "3.0.0", default-features = false, features = ["client", "server", "transport-io", "transport-streamable-http-client-reqwest", "transport-streamable-http-server"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } serde = { version = "1", features = ["derive"] } diff --git a/native/rust/tecs-audio/Cargo.toml b/native/rust/tecs-audio/Cargo.toml index 2f374f00..dffc5510 100644 --- a/native/rust/tecs-audio/Cargo.toml +++ b/native/rust/tecs-audio/Cargo.toml @@ -11,4 +11,5 @@ crate-type = ["cdylib", "rlib"] [dependencies] cpal.workspace = true +rustysynth.workspace = true symphonia.workspace = true diff --git a/native/rust/tecs-audio/examples/synthscale.rs b/native/rust/tecs-audio/examples/synthscale.rs new file mode 100644 index 00000000..59d73d1f --- /dev/null +++ b/native/rust/tecs-audio/examples/synthscale.rs @@ -0,0 +1,72 @@ +//! Plays a C major scale on the default output through the synthesizer. +//! +//! A manual check for a real machine, kept out of the test suite because it +//! opens the output device, which on macOS can block without bound when the +//! audio daemon is unreachable. It takes the SoundFont as its argument or from +//! `TECS_TEST_SOUNDFONT`, and says it skipped when given neither. +//! +//! ```text +//! cargo run -p tecs-audio --example synthscale -- path/to/FluidR3_GM.sf2 +//! ``` + +use std::time::Duration; + +use tecsaudio::command::{self, Command}; +use tecsaudio::engine::Engine; +use tecsaudio::synth::Settings; + +fn main() { + let path = std::env::args() + .nth(1) + .or_else(|| std::env::var("TECS_TEST_SOUNDFONT").ok()); + let Some(path) = path else { + eprintln!("skipped: name a SoundFont as the argument or in TECS_TEST_SOUNDFONT"); + return; + }; + + let engine = Engine::open(48_000, 2, 8); + if !engine.available() { + eprintln!("no output device: {}", engine.last_error()); + } + let font = match engine.load_sound_font(&path) { + Ok(font) => font, + Err(reason) => { + eprintln!("{reason}"); + std::process::exit(1); + } + }; + let handle = 1; + let mut port = engine + .open_synth(font, handle, &Settings::default()) + .expect("the synthesizer opens"); + engine.submit(&[Command { + kind: command::PLAY, + handle, + clip: 0, + flags: command::FLAG_SYNTH, + gain: 1.0, + pitch: 1.0, + start: 0.0, + fade: 0.0, + loop_start: 0.0, + x: 0.0, + y: 0.0, + z: 0.0, + }]); + + // Scheduled ahead in one go, a quarter of a second apart, against the + // synthesizer clock rather than against when this thread wakes up. + let start = port.now() + 0.1; + let keys = [60u8, 62, 64, 65, 67, 69, 71, 72]; + for (step, key) in keys.iter().enumerate() { + let at = start + step as f64 * 0.25; + port.send(0x90, *key, 100, at); + port.send(0x80, *key, 0, at + 0.22); + } + std::thread::sleep(Duration::from_millis(3000)); + eprintln!( + "played {} notes; the synthesizer clock reads {:.3} s", + keys.len(), + port.now() + ); +} diff --git a/native/rust/tecs-audio/src/command.rs b/native/rust/tecs-audio/src/command.rs index e96166c5..185f2877 100644 --- a/native/rust/tecs-audio/src/command.rs +++ b/native/rust/tecs-audio/src/command.rs @@ -51,6 +51,10 @@ pub const FLAG_STEREO: u32 = 4; /// Marks a `PLAY` reading its input from the file rather than held samples. pub const FLAG_STREAM: u32 = 8; +/// Marks a `PLAY` whose input is the synthesizer `tecs_audio_synth_open` +/// parked under the command's handle, rather than a clip. +pub const FLAG_SYNTH: u32 = 16; + /// Requests one operation. /// /// One struct covers every operation, because the array crossing the seam has diff --git a/native/rust/tecs-audio/src/engine.rs b/native/rust/tecs-audio/src/engine.rs index b17ac449..b15aae26 100644 --- a/native/rust/tecs-audio/src/engine.rs +++ b/native/rust/tecs-audio/src/engine.rs @@ -16,6 +16,7 @@ use crate::device::{self, Output}; use crate::mixer::Mixer; use crate::source::{ResidentSource, Source, MAX_CHANNELS}; use crate::stream::Feeder; +use crate::synth::{self, Port, Settings}; /// Measures a clip's duration against the streaming threshold. pub const MODE_AUTO: u32 = 0; @@ -52,6 +53,13 @@ pub struct Loaded { pub struct Engine { mixer: Arc>, clips: RwLock>, + /// SoundFonts by the id `load_sound_font` handed out, shared by every + /// synthesizer opened on one. + fonts: RwLock>>, + next_font: Mutex, + /// Synthesizers opened and not yet started, by the voice handle whose + /// `PLAY` will take them. + synths: Mutex>>, feeder: Feeder, output: Option, /// The reason the most recent failing call gave, kept so the binding can @@ -115,6 +123,9 @@ impl Engine { failed, mixer, clips: RwLock::new(HashMap::new()), + fonts: RwLock::new(HashMap::new()), + next_font: Mutex::new(0), + synths: Mutex::new(HashMap::new()), feeder: Feeder::start(), output, last_error: Mutex::new(reason), @@ -291,8 +302,67 @@ impl Engine { } } + /// Reads a SoundFont and returns the id synthesizers name it by. + /// + /// @return the id, from one up, or the reason nothing was loaded + pub fn load_sound_font(&self, path: &str) -> Result { + let font = synth::load_sound_font(path).map_err(|reason| self.fail(reason))?; + let id = match self.next_font.lock() { + Err(_) => return Err(self.fail("the SoundFont counter is poisoned".to_string())), + Ok(mut next) => { + *next += 1; + *next + } + }; + match self.fonts.write() { + Err(_) => Err(self.fail("the SoundFont table is poisoned".to_string())), + Ok(mut fonts) => { + fonts.insert(id, font); + Ok(id) + } + } + } + + /// Drops a SoundFont. Synthesizers already opened on it keep their own + /// reference and play on. + pub fn release_sound_font(&self, font: u32) { + if let Ok(mut fonts) = self.fonts.write() { + fonts.remove(&font); + } + } + + /// Creates a synthesizer on a loaded SoundFont and parks its source for the + /// `PLAY` carrying `handle` and `FLAG_SYNTH`. + /// + /// Everything that allocates happens here, on the caller's thread: the + /// synthesizer, its voices, its queue and its scratch buffers. The output + /// callback only ever renders it. + /// + /// @return the port events are sent through, or the reason nothing opened + pub fn open_synth(&self, font: u32, handle: u32, settings: &Settings) -> Result { + let shared = match self.fonts.read() { + Err(_) => return Err(self.fail("the SoundFont table is poisoned".to_string())), + Ok(fonts) => match fonts.get(&font) { + None => return Err(self.fail(format!("no SoundFont is loaded under {font}"))), + Some(value) => Arc::clone(value), + }, + }; + let (port, source) = + synth::open(&shared, self.sample_rate, settings).map_err(|reason| self.fail(reason))?; + match self.synths.lock() { + Err(_) => Err(self.fail("the synthesizer table is poisoned".to_string())), + Ok(mut synths) => { + synths.insert(handle, Box::new(source)); + Ok(port) + } + } + } + /// Builds the source a `PLAY` needs, outside the mixer lock. fn prepare(&self, command: &Command) -> Option> { + if command.has(command::FLAG_SYNTH) { + return self.synths.lock().ok()?.remove(&command.handle); + } let clips = self.clips.read().ok()?; match clips.get(&command.clip)? { Clip::Resident { @@ -405,6 +475,12 @@ impl Engine { if let Ok(mut clips) = self.clips.write() { clips.clear(); } + if let Ok(mut fonts) = self.fonts.write() { + fonts.clear(); + } + if let Ok(mut synths) = self.synths.lock() { + synths.clear(); + } } } diff --git a/native/rust/tecs-audio/src/lib.rs b/native/rust/tecs-audio/src/lib.rs index 4b89a5a0..62190e27 100644 --- a/native/rust/tecs-audio/src/lib.rs +++ b/native/rust/tecs-audio/src/lib.rs @@ -30,6 +30,7 @@ pub mod enumerate; pub mod mixer; pub mod source; pub mod stream; +pub mod synth; use std::ffi::{c_char, CStr, CString}; use std::sync::Mutex; @@ -38,6 +39,7 @@ use capture::Capture; use command::{Command, Event}; use engine::Engine; use enumerate::DeviceList; +use synth::{Port, Settings}; /// What a call answers when it worked. const STATUS_OK: i32 = 0; @@ -405,6 +407,190 @@ pub unsafe extern "C" fn tecs_audio_render(device: *mut Engine, out: *mut f32, f value.render(span) as u32 } +/// Reads a SoundFont 2 file into the device and writes the id it is held under. +/// +/// # Safety +/// +/// `path` must be a NUL-terminated string, `font` must be writable, and the +/// device must be live. +/// +/// @return zero when the SoundFont loaded, and one with the reason left for +/// `tecs_audio_last_error` when it did not +#[no_mangle] +pub unsafe extern "C" fn tecs_audio_load_soundfont( + device: *mut Engine, + path: *const c_char, + font: *mut u32, +) -> i32 { + let value = match unsafe { engine(device) } { + None => return STATUS_FAILED, + Some(value) => value, + }; + if path.is_null() || font.is_null() { + return STATUS_FAILED; + } + let Ok(text) = unsafe { CStr::from_ptr(path) }.to_str() else { + return STATUS_FAILED; + }; + match value.load_sound_font(text) { + Err(_) => STATUS_FAILED, + Ok(id) => { + unsafe { *font = id }; + STATUS_OK + } + } +} + +/// Drops a SoundFont. Synthesizers already opened on it play on. +/// +/// # Safety +/// +/// The pointer must name a live device. +#[no_mangle] +pub unsafe extern "C" fn tecs_audio_release_soundfont(device: *mut Engine, font: u32) { + if let Some(value) = unsafe { engine(device) } { + value.release_sound_font(font); + } +} + +/// Opens a synthesizer on a loaded SoundFont for the voice `handle` names. +/// +/// The synthesizer starts sounding when a `PLAY` carrying the same handle and +/// the synth flag reaches `tecs_audio_submit`. Until then events sent to it +/// wait in its queue and its clock stands at zero. +/// +/// # Safety +/// +/// The pointer must name a live device. The returned port is owned by the +/// caller, outlives the device safely, and is freed by `tecs_audio_synth_close`. +/// +/// @return the port, or null with the reason left for `tecs_audio_last_error` +#[no_mangle] +pub unsafe extern "C" fn tecs_audio_synth_open( + device: *mut Engine, + font: u32, + handle: u32, + polyphony: u32, + effects: i32, +) -> *mut Port { + let value = match unsafe { engine(device) } { + None => return std::ptr::null_mut(), + Some(value) => value, + }; + let settings = Settings { + polyphony: polyphony as usize, + effects: effects != 0, + }; + match value.open_synth(font, handle, &settings) { + Err(_) => std::ptr::null_mut(), + Ok(port) => Box::into_raw(Box::new(port)), + } +} + +/// Borrows the port a synthesizer pointer names. +/// +/// # Safety +/// +/// The pointer must be one `tecs_audio_synth_open` returned and +/// `tecs_audio_synth_close` has not yet been given. +unsafe fn port<'a>(synth: *mut Port) -> Option<&'a mut Port> { + if synth.is_null() { + None + } else { + Some(unsafe { &mut *synth }) + } +} + +/// Queues a short MIDI message for `at` seconds on the synthesizer clock. +/// +/// A time of zero or less, or one already rendered, means the next block. +/// +/// # Safety +/// +/// The pointer must name a live port. +/// +/// @return nonzero when the queue took the event, and zero when it was full +#[no_mangle] +pub unsafe extern "C" fn tecs_audio_synth_send( + synth: *mut Port, + status: u32, + data1: u32, + data2: u32, + at: f64, +) -> i32 { + match unsafe { port(synth) } { + None => 0, + Some(value) => i32::from(value.send(status as u8, data1 as u8, data2 as u8, at)), + } +} + +/// Queues a release of every note on `channel`, or on every channel when it is +/// negative, for the next block. +/// +/// # Safety +/// +/// The pointer must name a live port. +/// +/// @return nonzero when the queue took the event +#[no_mangle] +pub unsafe extern "C" fn tecs_audio_synth_notes_off( + synth: *mut Port, + channel: i32, + immediate: i32, +) -> i32 { + match unsafe { port(synth) } { + None => 0, + Some(value) => { + let named = u8::try_from(channel).ok(); + i32::from(value.notes_off(named, immediate != 0)) + } + } +} + +/// Queues a reset to General MIDI defaults for the next block, discarding +/// every event sent before it that has not been applied. +/// +/// # Safety +/// +/// The pointer must name a live port. +/// +/// @return nonzero when the queue took the event +#[no_mangle] +pub unsafe extern "C" fn tecs_audio_synth_reset(synth: *mut Port) -> i32 { + match unsafe { port(synth) } { + None => 0, + Some(value) => i32::from(value.reset()), + } +} + +/// Reports the synthesizer clock: the frames it has rendered over the rate. +/// +/// # Safety +/// +/// The pointer must name a live port. +/// +/// @return seconds, and zero for a null port +#[no_mangle] +pub unsafe extern "C" fn tecs_audio_synth_now(synth: *mut Port) -> f64 { + match unsafe { port(synth) } { + None => 0.0, + Some(value) => value.now(), + } +} + +/// Frees a port. The voice it fed is the mixer's to stop, through `STOP`. +/// +/// # Safety +/// +/// The pointer must come from `tecs_audio_synth_open` and must not be used +/// again. +#[no_mangle] +pub unsafe extern "C" fn tecs_audio_synth_close(synth: *mut Port) { + if !synth.is_null() { + drop(unsafe { Box::from_raw(synth) }); + } +} + /// Selects the command struct in `tecs_audio_layout`. pub const LAYOUT_COMMAND: u32 = 0; @@ -1018,6 +1204,33 @@ mod tests { } } + #[test] + fn tolerates_a_null_port_and_refuses_a_synth_on_no_soundfont() { + let null: *mut Port = std::ptr::null_mut(); + unsafe { + assert_eq!(tecs_audio_synth_send(null, 0x90, 60, 100, 0.0), 0); + assert_eq!(tecs_audio_synth_notes_off(null, -1, 1), 0); + assert_eq!(tecs_audio_synth_reset(null), 0); + assert_eq!(tecs_audio_synth_now(null), 0.0); + tecs_audio_synth_close(null); + + let device = tecs_audio_open_offline(48_000, 2, 8); + let port = tecs_audio_synth_open(device, 1, 65_537, 64, 1); + assert!(port.is_null(), "no SoundFont is loaded under id 1"); + assert!(!tecs_audio_last_error(device).is_null()); + + let path = CString::new("no/such/font.sf2").expect("the literal has no NUL"); + let mut font = 0u32; + assert_eq!( + tecs_audio_load_soundfont(device, path.as_ptr(), &mut font), + STATUS_FAILED + ); + assert_eq!(font, 0); + tecs_audio_release_soundfont(device, 1); + tecs_audio_close(device); + } + } + #[test] fn reports_the_size_of_every_struct_crossing_the_seam() { assert_eq!(tecs_audio_layout(LAYOUT_COMMAND), 48); diff --git a/native/rust/tecs-audio/src/synth.rs b/native/rust/tecs-audio/src/synth.rs new file mode 100644 index 00000000..46e2c1c3 --- /dev/null +++ b/native/rust/tecs-audio/src/synth.rs @@ -0,0 +1,730 @@ +//! A General MIDI synthesizer that plays as one voice of the mixer. +//! +//! The synthesizer is `rustysynth`, rendering a SoundFont in-process. Tecs +//! starts it the way it starts any voice, so group gain, mute and pause, the +//! master gain and the voice ceiling reach it with no code of their own. What +//! is particular to it is how MIDI events cross from the game thread to the +//! output callback that renders them. +//! +//! The crossing is a bounded single-producer, single-consumer ring. The game +//! side pushes fixed-size events and never waits; the callback side pops them +//! while it holds the mixer lock it already holds to render, and never waits +//! either. Nothing on the render path allocates: the ring, the schedule the +//! callback sorts events into and the scratch buffers are all sized when the +//! synthesizer is made. +//! +//! Each event carries the frame of the synthesizer's own clock it is due at. +//! `rustysynth` applies a message at its next internal block boundary, never +//! part way through a block, so the render splits at those boundaries and +//! applies every due event at the first boundary at or after its frame. An +//! event is therefore never early and is at most `BLOCK_FRAMES - 1` frames +//! late, and where it lands depends only on its frame, which is what makes the +//! output a deterministic function of the schedule. +//! +//! ```no_run +//! use tecsaudio::synth::{self, Settings}; +//! +//! let font = synth::load_sound_font("FluidR3_GM.sf2").expect("a SoundFont"); +//! let (mut port, mut source) = synth::open(&font, 48_000, &Settings::default()).expect("a synth"); +//! port.send(0x90, 60, 100, 0.0); +//! let mut out = vec![0.0f32; 2 * 512]; +//! use tecsaudio::source::Source; +//! source.read(&mut out); +//! ``` + +use std::cell::UnsafeCell; +use std::collections::VecDeque; +use std::fs::File; +use std::io::{BufReader, Read, Seek, SeekFrom}; +use std::mem::MaybeUninit; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::Arc; + +use rustysynth::{SoundFont, Synthesizer, SynthesizerSettings}; + +use crate::source::Source; + +/// Frames in one internal render block, and so the grid events land on. +/// +/// `rustysynth` takes a block size from 8 to 1024 and applies MIDI only between +/// blocks. It also runs its envelopes and LFOs once per block, so a smaller +/// block is a finer grid at a higher cost per voice. Eight is the finest it +/// allows, and puts every event within 0.17 milliseconds of its time at 48 kHz. +/// Measured on FluidR3 GM with 48 notes held, polyphony 64 and the effects on, +/// eight costs 3.3 percent of one core against 1.9 percent at its default of +/// 64, which is a small price for a grid nobody can hear. +pub const BLOCK_FRAMES: usize = 8; + +/// Events the ring between the game thread and the callback holds. +/// +/// The callback moves events out of the ring into its schedule at every block, +/// so the ring only has to cover what a game sends between two blocks. The +/// schedule holds as many again, for events sent ahead of their time. Past +/// both, `send` refuses the event rather than growing anything the callback +/// touches. +pub const QUEUE_EVENTS: usize = 4096; + +/// Applies a short MIDI message: `status`, `data1` and `data2`. +pub const KIND_MESSAGE: u8 = 0; + +/// Releases every note, on `status` as a channel or on all sixteen when it is +/// `ALL_CHANNELS`. A nonzero `data1` cuts the notes without their release. +pub const KIND_NOTES_OFF: u8 = 1; + +/// Returns every channel to its General MIDI defaults and silences every note. +pub const KIND_RESET: u8 = 2; + +/// Names every channel in a `KIND_NOTES_OFF` event. +pub const ALL_CHANNELS: u8 = 0xFF; + +/// One event on its way to the synthesizer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Event { + /// The frame of the synthesizer clock the event is due at. Zero, and any + /// frame already rendered, means the next block boundary. + pub frame: u64, + /// Selects the operation from this module's kind constants. + pub kind: u8, + /// Carries the status byte, or the channel for `KIND_NOTES_OFF`. + pub status: u8, + /// Carries the first data byte. + pub data1: u8, + /// Carries the second data byte. + pub data2: u8, +} + +/// Configures one synthesizer. +#[derive(Clone, Copy, Debug)] +pub struct Settings { + /// The notes `rustysynth` sounds at once, from 8 to 256. + pub polyphony: usize, + /// Whether `rustysynth` runs its reverb and chorus. + pub effects: bool, +} + +impl Default for Settings { + fn default() -> Settings { + Settings { + polyphony: 64, + effects: true, + } + } +} + +/// What a synthesizer is driven through, so the scheduler can be tested +/// without a SoundFont. +pub trait Target: Send { + /// Applies one event at the current block boundary. + fn apply(&mut self, event: &Event); + + /// Renders the next `left.len()` frames. + fn render(&mut self, left: &mut [f32], right: &mut [f32]); +} + +/// The `rustysynth` synthesizer as a target. +pub struct GeneralMidi { + synthesizer: Synthesizer, +} + +impl Target for GeneralMidi { + fn apply(&mut self, event: &Event) { + match event.kind { + KIND_MESSAGE => { + // System messages carry no channel and this synthesizer has no + // use for any of them, so they are accepted and dropped here. + // A hardware output would forward them. + if event.status < 0xF0 { + self.synthesizer.process_midi_message( + i32::from(event.status & 0x0F), + i32::from(event.status & 0xF0), + i32::from(event.data1), + i32::from(event.data2), + ); + } + } + KIND_NOTES_OFF => { + let immediate = event.data1 != 0; + if event.status == ALL_CHANNELS { + self.synthesizer.note_off_all(immediate); + } else { + self.synthesizer + .note_off_all_channel(i32::from(event.status), immediate); + } + } + KIND_RESET => self.synthesizer.reset(), + _ => {} + } + } + + fn render(&mut self, left: &mut [f32], right: &mut [f32]) { + self.synthesizer.render(left, right); + } +} + +/// A bounded lock-free ring with one producer and one consumer. +/// +/// `head` is the next slot to read and `tail` the next to write, both counted +/// without wrapping so that full and empty are told apart by their distance. +/// The producer alone moves `tail` and the consumer alone moves `head`, which +/// the `Producer` and `Consumer` halves enforce by taking `&mut self`. +struct Ring { + slots: Box<[UnsafeCell>]>, + head: AtomicUsize, + tail: AtomicUsize, +} + +// SAFETY: a slot is written only by the producer while it lies outside +// `head..tail`, and read only by the consumer while it lies inside, and the +// acquire and release pair on `tail` and `head` orders those accesses. +unsafe impl Sync for Ring {} + +impl Ring { + fn new(capacity: usize) -> Ring { + let slots = (0..capacity) + .map(|_| UnsafeCell::new(MaybeUninit::uninit())) + .collect(); + Ring { + slots, + head: AtomicUsize::new(0), + tail: AtomicUsize::new(0), + } + } +} + +/// The game-thread half of the ring. +struct Producer { + ring: Arc, +} + +impl Producer { + /// Appends an event and reports whether there was room for it. + fn push(&mut self, event: Event) -> bool { + let tail = self.ring.tail.load(Ordering::Relaxed); + let head = self.ring.head.load(Ordering::Acquire); + let capacity = self.ring.slots.len(); + if tail.wrapping_sub(head) >= capacity { + return false; + } + // SAFETY: the slot lies outside `head..tail`, so the consumer is not + // reading it, and only this producer writes. + unsafe { + (*self.ring.slots[tail % capacity].get()).write(event); + } + self.ring + .tail + .store(tail.wrapping_add(1), Ordering::Release); + true + } +} + +/// The callback half of the ring. +struct Consumer { + ring: Arc, +} + +impl Consumer { + /// Removes the oldest event, if there is one. + fn pop(&mut self) -> Option { + let head = self.ring.head.load(Ordering::Relaxed); + let tail = self.ring.tail.load(Ordering::Acquire); + if head == tail { + return None; + } + let capacity = self.ring.slots.len(); + // SAFETY: the slot lies inside `head..tail`, so the producer finished + // writing it before its release store on `tail`. + let event = unsafe { (*self.ring.slots[head % capacity].get()).assume_init() }; + self.ring + .head + .store(head.wrapping_add(1), Ordering::Release); + Some(event) + } +} + +/// What both halves of one synthesizer read. +struct Shared { + /// Frames rendered since the synthesizer started, which is its clock. + frames: AtomicU64, +} + +/// The game side of one synthesizer: where events are sent and the clock read. +pub struct Port { + producer: Producer, + shared: Arc, + sample_rate: u32, + dropped: u64, +} + +impl Port { + /// Converts a time on the synthesizer clock to the frame it is due at. + /// + /// Zero, a negative time and a time that is not a number all mean the next + /// block boundary. + fn frame_at(&self, seconds: f64) -> u64 { + if seconds.is_nan() || seconds <= 0.0 { + return 0; + } + (seconds * self.sample_rate as f64).round() as u64 + } + + fn push(&mut self, event: Event) -> bool { + if self.producer.push(event) { + return true; + } + self.dropped += 1; + false + } + + /// Queues a short MIDI message for `at` seconds on the synthesizer clock, + /// and reports whether the queue had room for it. + pub fn send(&mut self, status: u8, data1: u8, data2: u8, at: f64) -> bool { + let frame = self.frame_at(at); + self.push(Event { + frame, + kind: KIND_MESSAGE, + status, + data1: data1 & 0x7F, + data2: data2 & 0x7F, + }) + } + + /// Queues a release of every note on `channel`, or on every channel for + /// `None`, for the next block boundary. + pub fn notes_off(&mut self, channel: Option, immediate: bool) -> bool { + self.push(Event { + frame: 0, + kind: KIND_NOTES_OFF, + status: channel.map_or(ALL_CHANNELS, |value| value & 0x0F), + data1: u8::from(immediate), + data2: 0, + }) + } + + /// Queues a reset for the next block boundary. Every event sent before it + /// and not yet applied is discarded when the callback reaches it. + pub fn reset(&mut self) -> bool { + self.push(Event { + frame: 0, + kind: KIND_RESET, + status: 0, + data1: 0, + data2: 0, + }) + } + + /// Reports the synthesizer clock in seconds: the frames it has rendered + /// over the output rate. + pub fn now(&self) -> f64 { + if self.sample_rate == 0 { + return 0.0; + } + self.shared.frames.load(Ordering::Acquire) as f64 / self.sample_rate as f64 + } + + /// Reports the frames the synthesizer has rendered. + pub fn frames(&self) -> u64 { + self.shared.frames.load(Ordering::Acquire) + } + + /// Reports how many events `send`, `notes_off` and `reset` refused because + /// the queue was full. + pub fn dropped(&self) -> u64 { + self.dropped + } +} + +/// The callback side of one synthesizer, read by the mixer as a voice's input. +pub struct SynthSource { + target: T, + consumer: Consumer, + shared: Arc, + /// Events moved out of the ring, ordered by frame and then by sending. + schedule: VecDeque, + /// Frames rendered, mirrored from `shared` so the callback reads no atomic. + frames: u64, + /// Frames already rendered into the current internal block. + phase: usize, + sample_rate: u32, + left: Vec, + right: Vec, +} + +impl SynthSource { + /// Moves every event the schedule has room for out of the ring. + fn collect(&mut self) { + // Bounded by the constant rather than by `capacity`, which may be + // larger than was asked for and is not a promise to keep to. + while self.schedule.len() < QUEUE_EVENTS { + let Some(mut event) = self.consumer.pop() else { + return; + }; + if event.kind == KIND_RESET { + // A reset undoes everything sent before it, including notes + // scheduled for later. Leaving those would play them over the + // silence the reset was sent to make. + self.schedule.clear(); + } + // A time already rendered becomes the next boundary, and sending + // order is kept among events that land on the same one. + event.frame = event.frame.max(self.frames); + let at = self + .schedule + .partition_point(|queued| queued.frame <= event.frame); + self.schedule.insert(at, event); + } + } + + /// Applies every scheduled event due at or before the current frame. + fn apply_due(&mut self) { + while let Some(event) = self.schedule.front() { + if event.frame > self.frames { + return; + } + let event = *event; + self.schedule.pop_front(); + self.target.apply(&event); + } + } +} + +impl Source for SynthSource { + fn channels(&self) -> u16 { + 2 + } + + fn sample_rate(&self) -> u32 { + self.sample_rate + } + + /// Renders up to the next internal block boundary. + /// + /// One block per call rather than as much as `out` holds, so a voice reads + /// at most one block ahead of the output and an event sent now reaches the + /// next block rather than one a whole staging buffer later. + fn read(&mut self, out: &mut [f32]) -> usize { + let wanted = out.len() / 2; + if wanted == 0 { + return 0; + } + self.collect(); + if self.phase == 0 { + self.apply_due(); + } + let count = (BLOCK_FRAMES - self.phase).min(wanted); + let (left, right) = (&mut self.left[..count], &mut self.right[..count]); + self.target.render(left, right); + for (index, (l, r)) in left.iter().zip(right.iter()).enumerate() { + out[index * 2] = *l; + out[index * 2 + 1] = *r; + } + self.phase = (self.phase + count) % BLOCK_FRAMES; + self.frames += count as u64; + self.shared.frames.store(self.frames, Ordering::Release); + count + } + + /// Refuses: a synthesizer has no position to move to. + fn seek(&mut self, _seconds: f64) -> bool { + false + } + + fn position(&self) -> f64 { + if self.sample_rate == 0 { + 0.0 + } else { + self.frames as f64 / self.sample_rate as f64 + } + } + + /// Never: a synthesizer sounds until its voice is stopped. + fn exhausted(&self) -> bool { + false + } +} + +/// Builds the two halves of one synthesizer around any target. +pub fn assemble(target: T, sample_rate: u32) -> (Port, SynthSource) { + let ring = Arc::new(Ring::new(QUEUE_EVENTS)); + let shared = Arc::new(Shared { + frames: AtomicU64::new(0), + }); + let port = Port { + producer: Producer { + ring: Arc::clone(&ring), + }, + shared: Arc::clone(&shared), + sample_rate, + dropped: 0, + }; + let source = SynthSource { + target, + consumer: Consumer { ring }, + shared, + schedule: VecDeque::with_capacity(QUEUE_EVENTS), + frames: 0, + phase: 0, + sample_rate, + left: vec![0.0; BLOCK_FRAMES], + right: vec![0.0; BLOCK_FRAMES], + }; + (port, source) +} + +/// Creates a General MIDI synthesizer on a loaded SoundFont. +/// +/// @return the port the game sends through and the source the mixer reads, or +/// the reason `rustysynth` refused the settings +pub fn open( + font: &Arc, + sample_rate: u32, + settings: &Settings, +) -> Result<(Port, SynthSource), String> { + let rate = i32::try_from(sample_rate) + .map_err(|_| format!("an output rate of {sample_rate} is out of range"))?; + let mut options = SynthesizerSettings::new(rate); + options.block_size = BLOCK_FRAMES; + options.maximum_polyphony = settings.polyphony; + options.enable_reverb_and_chorus = settings.effects; + let synthesizer = Synthesizer::new(font, &options) + .map_err(|error| format!("the synthesizer refused its settings: {error}"))?; + Ok(assemble(GeneralMidi { synthesizer }, sample_rate)) +} + +/// Reads a SoundFont 2 file. +/// +/// The RIFF header is checked first, so a file that is not a SoundFont is +/// refused with a reason that says so rather than with a parse error from +/// somewhere inside it. +/// +/// @return the SoundFont, ready to share between synthesizers, or the reason +pub fn load_sound_font(path: &str) -> Result, String> { + let file = File::open(path).map_err(|error| format!("cannot open '{path}': {error}"))?; + let mut reader = BufReader::new(file); + let mut header = [0u8; 12]; + reader + .read_exact(&mut header) + .map_err(|_| format!("'{path}' is too short to be a SoundFont"))?; + if &header[0..4] != b"RIFF" || &header[8..12] != b"sfbk" { + return Err(format!("'{path}' is not a SoundFont 2 file")); + } + reader + .seek(SeekFrom::Start(0)) + .map_err(|error| format!("cannot read '{path}': {error}"))?; + let font = SoundFont::new(&mut reader) + .map_err(|error| format!("'{path}' is not a readable SoundFont: {error}"))?; + Ok(Arc::new(font)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const RATE: u32 = 48_000; + + /// Records the frame each event was applied at and renders a constant, so + /// a test can see both when an event landed and how far the clock ran. + struct Recorder { + frame: u64, + applied: Arc>>, + } + + impl Target for Recorder { + fn apply(&mut self, event: &Event) { + self.applied + .lock() + .expect("the log is not poisoned") + .push((self.frame, *event)); + } + + fn render(&mut self, left: &mut [f32], right: &mut [f32]) { + left.fill(0.25); + right.fill(0.5); + self.frame += left.len() as u64; + } + } + + type Log = Arc>>; + + fn recorder() -> (Port, SynthSource, Log) { + let applied = Arc::new(std::sync::Mutex::new(Vec::new())); + let (port, source) = assemble( + Recorder { + frame: 0, + applied: Arc::clone(&applied), + }, + RATE, + ); + (port, source, applied) + } + + /// Reads as the mixer does, a staging buffer at a time, for `frames`. + fn pull(source: &mut impl Source, frames: usize) { + let mut staging = vec![0.0f32; 256 * 2]; + let mut done = 0; + while done < frames { + let wanted = (frames - done).min(256); + done += source.read(&mut staging[..wanted * 2]); + } + } + + fn frames_of(log: &Log) -> Vec<(u64, u8, u8)> { + log.lock() + .expect("the log is not poisoned") + .iter() + .map(|(frame, event)| (*frame, event.kind, event.data1)) + .collect() + } + + #[test] + fn lands_a_scheduled_event_on_the_first_block_boundary_at_or_after_it() { + let (mut port, mut source, log) = recorder(); + let block = BLOCK_FRAMES as u64; + // A boundary exactly, a frame past one, and a frame short of one. + let frames = [8 * block, 8 * block + 1, 100 * block - 1]; + for (key, frame) in frames.iter().enumerate() { + assert!(port.send(0x90, key as u8, 100, *frame as f64 / RATE as f64)); + } + pull(&mut source, 2048); + assert_eq!( + frames_of(&log), + vec![ + (8 * block, KIND_MESSAGE, 0), + (9 * block, KIND_MESSAGE, 1), + (100 * block, KIND_MESSAGE, 2) + ] + ); + } + + #[test] + fn applies_an_unscheduled_or_past_event_at_the_next_boundary_in_sending_order() { + let (mut port, mut source, log) = recorder(); + let block = BLOCK_FRAMES as u64; + // Read part way into a block, as a staging buffer that does not + // divide by the block leaves a voice. + let mut partial = vec![0.0f32; 2 * (BLOCK_FRAMES / 2)]; + pull(&mut source, 40 * BLOCK_FRAMES); + source.read(&mut partial); + assert!(port.send(0x90, 1, 100, 0.0)); + assert!(port.send(0x90, 2, 100, 10.0 / RATE as f64)); + assert!(port.send(0x90, 3, 100, -1.0)); + pull(&mut source, 4 * BLOCK_FRAMES); + let next = 41 * block; + assert_eq!( + frames_of(&log), + vec![ + (next, KIND_MESSAGE, 1), + (next, KIND_MESSAGE, 2), + (next, KIND_MESSAGE, 3) + ] + ); + } + + #[test] + fn orders_events_by_time_rather_than_by_sending() { + let (mut port, mut source, log) = recorder(); + assert!(port.send(0x90, 2, 100, 512.0 / RATE as f64)); + assert!(port.send(0x90, 1, 100, 256.0 / RATE as f64)); + assert!(port.send(0x90, 3, 100, 512.0 / RATE as f64)); + pull(&mut source, 1024); + assert_eq!( + frames_of(&log), + vec![ + (256, KIND_MESSAGE, 1), + (512, KIND_MESSAGE, 2), + (512, KIND_MESSAGE, 3) + ] + ); + } + + #[test] + fn runs_the_clock_by_rendered_frames_only() { + let (port, mut source, _log) = recorder(); + assert_eq!(port.now(), 0.0); + pull(&mut source, 480); + assert_eq!(port.frames(), 480); + assert!((port.now() - 0.01).abs() < 1e-12); + } + + #[test] + fn interleaves_the_two_rendered_channels() { + let (_port, mut source, _log) = recorder(); + let mut out = vec![0.0f32; 6]; + assert_eq!(source.read(&mut out), 3); + assert_eq!(out, vec![0.25, 0.5, 0.25, 0.5, 0.25, 0.5]); + assert!(!source.exhausted()); + assert!(!source.seek(1.0)); + } + + #[test] + fn refuses_an_event_past_the_bound_and_counts_it() { + let (mut port, mut source, log) = recorder(); + // Nothing renders, so nothing leaves the ring. + for _ in 0..QUEUE_EVENTS { + assert!(port.send(0x90, 1, 100, 1.0)); + } + assert!(!port.send(0x90, 2, 100, 1.0)); + assert!(!port.reset()); + assert_eq!(port.dropped(), 2); + + // One block moves the ring into the schedule, which then has the + // room again, and the refused events are gone rather than delayed. + pull(&mut source, BLOCK_FRAMES); + for _ in 0..QUEUE_EVENTS { + assert!(port.send(0x90, 3, 100, 1.0)); + } + // Ring and schedule are both full now. + assert!(!port.send(0x90, 4, 100, 1.0)); + pull(&mut source, RATE as usize + 2 * BLOCK_FRAMES); + let keys: Vec = frames_of(&log).iter().map(|entry| entry.2).collect(); + assert_eq!(keys.len(), 2 * QUEUE_EVENTS); + assert!(!keys.contains(&2) && !keys.contains(&4)); + } + + #[test] + fn discards_what_was_sent_before_a_reset_and_keeps_what_follows() { + let (mut port, mut source, log) = recorder(); + assert!(port.send(0x90, 1, 100, 0.5)); + assert!(port.send(0x90, 2, 100, 0.0)); + assert!(port.reset()); + assert!(port.send(0x90, 3, 100, 0.0)); + pull(&mut source, RATE as usize); + assert_eq!( + frames_of(&log), + vec![(0, KIND_RESET, 0), (0, KIND_MESSAGE, 3)] + ); + } + + #[test] + fn keeps_a_notes_off_in_order_with_the_events_around_it() { + let (mut port, mut source, log) = recorder(); + assert!(port.send(0x90, 1, 100, 0.0)); + assert!(port.notes_off(Some(3), true)); + assert!(port.notes_off(None, false)); + assert!(port.send(0x90, 2, 100, 0.0)); + pull(&mut source, BLOCK_FRAMES); + let applied: Vec = log + .lock() + .expect("the log is not poisoned") + .iter() + .map(|entry| entry.1) + .collect(); + assert_eq!(applied.len(), 4); + assert_eq!(applied[1].kind, KIND_NOTES_OFF); + assert_eq!((applied[1].status, applied[1].data1), (3, 1)); + assert_eq!((applied[2].status, applied[2].data1), (ALL_CHANNELS, 0)); + assert_eq!(applied[3].data1, 2); + } + + #[test] + fn refuses_a_file_that_is_not_a_soundfont() { + let directory = std::env::temp_dir().join(format!("tecs-synth-{}", std::process::id())); + std::fs::create_dir_all(&directory).expect("a scratch directory"); + let path = directory.join("not.sf2"); + std::fs::write(&path, b"RIFF\0\0\0\0WAVEfmt ").expect("a scratch file"); + let reason = load_sound_font(path.to_str().expect("a UTF-8 path")) + .expect_err("a WAVE file is not a SoundFont"); + assert!(reason.contains("not a SoundFont 2"), "got {reason}"); + assert!(load_sound_font("no/such/font.sf2").is_err()); + std::fs::remove_dir_all(&directory).ok(); + } +} diff --git a/native/rust/tecs-audio/tests/synth.rs b/native/rust/tecs-audio/tests/synth.rs new file mode 100644 index 00000000..05662ec7 --- /dev/null +++ b/native/rust/tecs-audio/tests/synth.rs @@ -0,0 +1,236 @@ +//! The General MIDI synthesizer as a voice of the mixer, with no audio device. +//! +//! The tests that render real notes need a SoundFont, and none ships in the +//! tree: General MIDI sets are tens of megabytes and more. They read the one +//! `TECS_TEST_SOUNDFONT` names and skip, saying so, when it names nothing. The +//! scheduling grid itself is covered without one in `src/synth.rs`. + +use std::sync::OnceLock; + +use tecsaudio::command::{self, Command}; +use tecsaudio::engine::Engine; +use tecsaudio::synth::{Port, Settings, BLOCK_FRAMES}; + +const RATE: u32 = 48_000; + +fn command(kind: u32, handle: u32) -> Command { + Command { + kind, + handle, + clip: 0, + flags: 0, + gain: 1.0, + pitch: 1.0, + start: 0.0, + fade: 0.0, + loop_start: 0.0, + x: 0.0, + y: 0.0, + z: 0.0, + } +} + +fn play_synth(handle: u32, gain: f32) -> Command { + let mut play = command(command::PLAY, handle); + play.flags = command::FLAG_SYNTH; + play.gain = gain; + play +} + +/// Names the SoundFont the environment points at, or nil to skip. +fn sound_font() -> Option<&'static str> { + static PATH: OnceLock> = OnceLock::new(); + PATH.get_or_init(|| { + let path = std::env::var("TECS_TEST_SOUNDFONT").ok()?; + std::path::Path::new(&path).is_file().then_some(path) + }) + .as_deref() +} + +/// Opens an offline engine with one synthesizer started on voice `handle`. +fn started(path: &str, handle: u32, gain: f32) -> (Engine, Port) { + started_with(path, handle, gain, &Settings::default()) +} + +fn started_with(path: &str, handle: u32, gain: f32, settings: &Settings) -> (Engine, Port) { + let engine = Engine::open_offline(RATE, 2, 8); + let font = engine.load_sound_font(path).expect("the SoundFont loads"); + let port = engine + .open_synth(font, handle, settings) + .expect("the synthesizer opens"); + engine.submit(&[play_synth(handle, gain)]); + assert_eq!(engine.voice_count(), 1); + (engine, port) +} + +fn render(engine: &Engine, frames: usize) -> Vec { + let mut out = vec![0.0f32; frames * 2]; + // In device-sized pieces, the way an output callback asks. + for chunk in out.chunks_mut(512 * 2) { + engine.render(chunk); + } + out +} + +/// Returns the first frame holding any sound. +fn onset(out: &[f32]) -> Option { + out.iter() + .position(|sample| *sample != 0.0) + .map(|index| index / 2) +} + +#[test] +fn starts_nothing_for_a_synth_play_with_no_synthesizer_parked() { + let engine = Engine::open_offline(RATE, 2, 8); + engine.submit(&[play_synth(7, 1.0)]); + assert_eq!(engine.voice_count(), 0); + assert!(engine.open_synth(404, 7, &Settings::default()).is_err()); + assert!(!engine.last_error().is_empty()); +} + +#[test] +fn refuses_a_soundfont_path_that_names_nothing() { + let engine = Engine::open_offline(RATE, 2, 8); + assert!(engine.load_sound_font("no/such/font.sf2").is_err()); + assert!(engine.last_error().contains("cannot open")); +} + +#[test] +fn lands_a_scheduled_note_on_its_block_and_runs_the_clock_with_the_mix() { + let Some(path) = sound_font() else { + eprintln!("skipped: TECS_TEST_SOUNDFONT names no SoundFont"); + return; + }; + // Without reverb and chorus, whose LFO runs on the clock, a note is the + // same samples wherever it starts, so a late one can be compared against + // one at zero frame by frame. + let dry = Settings { + effects: false, + ..Settings::default() + }; + let (engine, mut port) = started_with(path, 1, 1.0, &dry); + assert_eq!(port.now(), 0.0); + assert!(port.send(0x90, 60, 110, 0.0)); + let reference = render(&engine, 9600); + + // A tenth of a second in is frame 4800, which is on the grid, so the note + // lands on exactly that frame. + let at = 4800; + assert_eq!(at % BLOCK_FRAMES, 0); + let (engine, mut port) = started_with(path, 1, 1.0, &dry); + assert!(port.send(0x90, 60, 110, at as f64 / RATE as f64)); + let out = render(&engine, 9600); + assert!(out[..at * 2].iter().all(|sample| *sample == 0.0)); + assert!(onset(&out).is_some(), "the note sounds"); + assert_eq!(&out[at * 2..], &reference[..(9600 - at) * 2]); + + // The clock is what the voice rendered, which runs at most one block + // ahead of the mix it fed. + let rendered = port.frames() as usize; + assert!( + (9600..=9600 + BLOCK_FRAMES).contains(&rendered), + "{rendered}" + ); +} + +#[test] +fn renders_the_same_schedule_to_the_same_samples() { + let Some(path) = sound_font() else { + eprintln!("skipped: TECS_TEST_SOUNDFONT names no SoundFont"); + return; + }; + let mut takes = Vec::new(); + for _ in 0..2 { + let (engine, mut port) = started(path, 1, 1.0); + for (step, key) in [60u8, 62, 64, 65].iter().enumerate() { + let at = step as f64 * 0.05; + assert!(port.send(0x90, *key, 100, at)); + assert!(port.send(0x80, *key, 0, at + 0.04)); + } + takes.push(render(&engine, RATE as usize / 4)); + } + assert!(takes[0].iter().any(|sample| *sample != 0.0)); + assert_eq!(takes[0], takes[1]); +} + +#[test] +fn scales_the_synthesizer_by_its_voice_gain() { + let Some(path) = sound_font() else { + eprintln!("skipped: TECS_TEST_SOUNDFONT names no SoundFont"); + return; + }; + // The policy above composes group gain and mute into the voice gain, so + // a voice gain is what a group setting reaches the synthesizer as. + let mut levels = Vec::new(); + for gain in [1.0f32, 0.5, 0.0] { + let (engine, mut port) = started(path, 1, 1.0); + let mut set = command(command::SET_GAIN, 1); + set.gain = gain; + engine.submit(&[set]); + assert!(port.send(0x90, 60, 110, 0.0)); + levels.push(render(&engine, 4800)); + } + for (full, half) in levels[0].iter().zip(levels[1].iter()) { + assert!((full * 0.5 - half).abs() < 1e-6); + } + assert!(levels[2].iter().all(|sample| *sample == 0.0)); +} + +#[test] +fn cuts_every_note_at_once_and_releases_them_otherwise() { + let Some(path) = sound_font() else { + eprintln!("skipped: TECS_TEST_SOUNDFONT names no SoundFont"); + return; + }; + // Without reverb and chorus nothing rings past a note, so a cut is exact. + let dry = Settings { + effects: false, + ..Settings::default() + }; + let (engine, mut port) = started_with(path, 1, 1.0, &dry); + assert!(port.send(0x90, 60, 110, 0.0)); + render(&engine, 4800); + assert!(port.notes_off(None, true)); + let cut = render(&engine, 4800); + let first_silent = BLOCK_FRAMES * 2; + assert!( + cut[first_silent * 2..].iter().all(|sample| *sample == 0.0), + "an immediate cut silences the next block on" + ); + + let (engine, mut port) = started_with(path, 1, 1.0, &dry); + assert!(port.send(0x90, 60, 110, 0.0)); + render(&engine, 4800); + assert!(port.notes_off(Some(0), false)); + let released = render(&engine, 4800); + assert!( + released[first_silent * 2..] + .iter() + .any(|sample| *sample != 0.0), + "a release still sounds as it fades" + ); + + let (engine, mut port) = started(path, 1, 1.0); + // A reset discards a note scheduled before it. + assert!(port.send(0x90, 60, 110, 0.05)); + assert!(port.reset()); + let after = render(&engine, 9600); + assert!(after.iter().all(|sample| *sample == 0.0)); +} + +#[test] +fn holds_the_clock_while_the_voice_is_paused() { + let Some(path) = sound_font() else { + eprintln!("skipped: TECS_TEST_SOUNDFONT names no SoundFont"); + return; + }; + let (engine, port) = started(path, 1, 1.0); + render(&engine, 1024); + let before = port.frames(); + engine.submit(&[command(command::PAUSE, 1)]); + render(&engine, 4800); + assert_eq!(port.frames(), before, "a paused voice does not advance"); + engine.submit(&[command(command::RESUME, 1)]); + render(&engine, 1024); + assert!(port.frames() > before); +} diff --git a/src/tecs/audio.nupp b/src/tecs/audio/init.nupp similarity index 100% rename from src/tecs/audio.nupp rename to src/tecs/audio/init.nupp From 609cbc5d9ea330528021cca15926b4b36a43a95f Mon Sep 17 00:00:00 2001 From: Alban Gicquel <6368237+albanmgicquel@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:17:07 -0400 Subject: [PATCH 2/7] tecs.audio.synth: General MIDI from a SoundFont, on a mixer voice loadSoundFont(audio, path) and new(audio, font, {group, gain, polyphony, effects}) with send, noteOn, noteOff, controlChange, programChange, pitchBend, allNotesOff, reset, now, setGain and destroy. Sending methods return whether the event was queued. The synth takes its voice slot the way play does, through an internal per-mixer lender, so the voice ceiling, group gain, mute and pause apply unchanged. The backend contract gains SynthPort and optional loadSoundFont, releaseSoundFont and openSynth; a backend without them and a mixer without an output get a silent port. tecs.audio moves to audio/init.nupp for its child. README records why synthesis is in-process and where a hardware MIDI output would plug in. --- AGENTS.md | 2 +- README.md | 48 ++- THIRD_PARTY_NOTICES.md | 6 + nupp.lua | 2 + src/tecs/audio/init.nupp | 50 ++- src/tecs/audio/synth.nupp | 483 ++++++++++++++++++++++++++++ src/tecs/internal/audiosynth.nupp | 28 ++ src/tecs/platform/audiobackend.nupp | 81 ++++- src/tecs/platform/audionative.nupp | 133 ++++++++ tests/audiosynthtest.nupp | 365 +++++++++++++++++++++ 10 files changed, 1184 insertions(+), 14 deletions(-) create mode 100644 src/tecs/audio/synth.nupp create mode 100644 src/tecs/internal/audiosynth.nupp create mode 100644 tests/audiosynthtest.nupp diff --git a/AGENTS.md b/AGENTS.md index 8ed255ff..a698de76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ tecs/ │ ├── platform/ # Window, events, audio and gamepad backends │ ├── physics/ # Rapier contract and binding │ ├── input.nupp # Gameplay input over the platform event stream -│ ├── audio.nupp # Clips, voices, groups, limits, the Sound component +│ ├── audio/ # Clips, voices, groups, limits, the Sound component, General MIDI │ ├── sequence.nupp # Sequencer, with the tween runtime inside it │ ├── assets.nupp # Asset orchestration │ ├── mcp.nupp # Debug server: transport, tools, sandbox diff --git a/README.md b/README.md index 7ac7e39d..c03f0cc8 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,9 @@ test suite reach the same world without starting a graphics stack. - Camera, layers with positioning modes, images, clips, sprite sheets with animation, and distance-field text. - Input across keyboard, pointer, gamepad and touch; audio playback, recording - and device enumeration; physics over Rapier; sequencing with tweening; asset - orchestration; file watching; and an MCP debug server. + and device enumeration; General MIDI from a SoundFont; physics over Rapier; + sequencing with tweening; asset orchestration; file watching; and an MCP + debug server. - [Tiled TMX maps and TSX tilesets](docs/tiled/index.md), animated tile and image layers, object factories, runtime tile edits and authored collision outlines. @@ -488,6 +489,49 @@ stream did. It holds one second by default, drops the oldest, and reports the loss through `Microphone.overruns`. An unbounded allocation driven by a realtime callback was not worth carrying across. +## General MIDI is synthesized in-process + +`tecs.audio.synth` renders General MIDI from a SoundFont with `rustysynth` +(MIT, pure Rust, no dependencies) as one voice of the mixer, rather than +sending MIDI to whatever synthesizer the operating system offers. On a desktop +the system synthesizer is software too: Apple's DLS synth on macOS, the +Microsoft GS Wavetable synth on Windows, and on Linux nothing at all unless the +player installed and started FluidSynth or TiMidity. Sending to it buys no +quality and gives up three things a game needs. The music sounds the same on +every machine, because the SoundFont ships with the game instead of being +whatever bank the platform has. It goes through the mixer, so group gain, mute, +pause and master gain apply to it like any other sound, and a system synth +offers none of them. And it is deterministic: events carry a time on the +synthesizer's own clock, the callback splits its render at those times, and the +same schedule renders the same samples on every run, which a test can pin and +a recording can reproduce. An external synth answers on its own clock and its +own latency, and the mix of the two cannot be captured offline. + +Timing is exact to `rustysynth`'s internal block, which is where it applies +MIDI and runs its envelopes. Tecs sets that block to eight frames, the smallest +the library takes: every event lands on the first eight-frame boundary at or +after its time, 0.17 milliseconds at worst at 48 kHz. Measured on FluidR3 GM +with 48 notes held and the effects on, that costs 3.3 percent of one core +against 1.9 at the library's default of 64 frames. + +Events cross to the output callback through a bounded single-producer, +single-consumer ring, 4096 events, with as many again held in a preallocated +schedule for events sent ahead of their time. The callback never waits and +never allocates; a send that finds no room returns false and the event is +dropped, because the alternatives are blocking the frame thread or growing a +buffer the callback reads. A game streaming a song with a lookahead of a frame +or two never comes near it. + +A hardware MIDI output is the extension this leaves room for and does not +build. The seam is `tecs.platform.audiobackend.SynthPort`: `send` with a time, +`notesOff`, `reset` and a clock. A second implementation over `midir` +(CoreMIDI, WinMM, ALSA) would put a sender thread in Rust that holds the +schedule and writes each message when its time comes, with `now` on the host's +monotonic clock instead of rendered frames, chosen when the synth is made. +Nothing above the port changes. It is deferred because nothing needs it yet, +and what it costs is the determinism above: a hardware synth cannot be rendered +offline. + ## Device vocabularies follow the library that reports them Key names, mouse button names and gamepad button and axis names came from SDL diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ef79a5b5..924ca0ff 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -81,6 +81,12 @@ Open Font License 1.1. The tests and retained UI example read it. Packages ship `share/tecs/assets/fonts` with `JetBrainsMono-OFL.txt` and the versioned `JetBrainsMono-NOTICE.md`. +## SoundFonts + +Tecs ships no SoundFont. `tecs.audio.synth` reads one a game supplies, and a +General MIDI set is game content under its own licence, so a game that ships +one ships that licence beside it. FluidR3 GM, for example, is MIT. + ## Scope of the checks The generated inventory and source manifest make shipped dependency names, diff --git a/nupp.lua b/nupp.lua index 6fb30e3e..9f6d48b5 100644 --- a/nupp.lua +++ b/nupp.lua @@ -234,6 +234,8 @@ return { "tecs.internal.assetnative", "tecs.assets", "tecs.audio", + "tecs.audio.synth", + "tecs.internal.audiosynth", "tecs.ecs", "tecs.files", "tecs.gfx", diff --git a/src/tecs/audio/init.nupp b/src/tecs/audio/init.nupp index 4a40da19..a98a7580 100644 --- a/src/tecs/audio/init.nupp +++ b/src/tecs/audio/init.nupp @@ -123,6 +123,7 @@ depend on notifications. ]] local audiobackend = require("tecs.platform.audiobackend") +local audiosynth = require("tecs.internal.audiosynth") local ecs = require("tecs.ecs") local log = require("nupp.log") local random = require("nupp.random") @@ -1065,14 +1066,21 @@ local function admits(borrows self: Audio, key: string): boolean return true end ---- Starts a clip on a free slot and returns its handle, or zero. -local function start(exclusive self: Audio, clip: Clip?, options: PlayOptions, owned: boolean): integer +--- Takes a free slot for one voice reading `clip`, queues its start, and +--- returns its handle, or zero when nothing started. +--- +--- `flags` carries what the input adds to the command: `STREAM` for a clip read +--- from its file, `SYNTH` for a synthesizer, which reads no clip at all. +local function claim( + exclusive self: Audio, + clip: integer, + flags: integer, + options: PlayOptions, + owned: boolean +): integer if not self.available then return 0 end - if clip == nil or clip.status ~= "ready" then - return 0 - end local key = options.key if key ~= nil and not admits(self, key) then return 0 @@ -1089,7 +1097,7 @@ local function start(exclusive self: Audio, clip: Clip?, options: PlayOptions, o if voice.generation > MAX_GENERATION then voice.generation = 1 end - voice.clip = clip.id + voice.clip = clip voice.gain = options.gain or 1 voice.applied = -1 voice.group = options.group @@ -1112,15 +1120,14 @@ local function start(exclusive self: Audio, clip: Clip?, options: PlayOptions, o local handle = handleOf(self, slot) local command = queue(self, audiobackend.PLAY, handle) - command.clip = clip.id + command.clip = clip command.gain = composedGain(self, voice) command.pitch = pitch command.start = options.start or 0 command.fade = options.fadeIn or 0 command.loopStart = options.loopStart or 0 - local flags = voice.loop and audiobackend.LOOP or 0 - if not clip.resident then - flags = flags + audiobackend.STREAM + if voice.loop then + flags = flags + audiobackend.LOOP end -- The two placements are exclusive, and a position wins when a caller asks @@ -1169,6 +1176,15 @@ local function start(exclusive self: Audio, clip: Clip?, options: PlayOptions, o return handle end +--- Starts a clip on a free slot and returns its handle, or zero. +local function start(exclusive self: Audio, clip: Clip?, options: PlayOptions, owned: boolean): integer + if clip == nil or clip.status ~= "ready" then + return 0 + end + + return claim(self, clip.id, clip.resident and 0 or audiobackend.STREAM, options, owned) +end + --- Creates the mixer a game plays sound through. --- --- Never raises for want of hardware. A machine with no sound card gets an @@ -1267,6 +1283,20 @@ export function newAudio(config: Config?): Audio LOGGER:info("no audio output; sound is silent for this run") end + -- What `tecs.audio.synth` borrows: the backend a SoundFont loads into, and + -- a voice slot taken the way `play` takes one, so the voice ceiling, the + -- group and the gain composition apply to a synthesizer unchanged. Lent + -- through an internal module because both reach fields this record keeps + -- private. + audiosynth.lent[self] = new audiosynth.Lender( + backend = function(): audiobackend.Backend + return self.backend + end, + start = function(gain: number, group: string?): integer + return claim(self, 0, audiobackend.SYNTH, {gain = gain, group = group}, false) + end + ) + return self end diff --git a/src/tecs/audio/synth.nupp b/src/tecs/audio/synth.nupp new file mode 100644 index 00000000..afdea47a --- /dev/null +++ b/src/tecs/audio/synth.nupp @@ -0,0 +1,483 @@ +module tecs.audio.synth + +--[[ +General MIDI from a SoundFont, rendered in-process as one voice of the mixer. + +A [`SoundFont`](tecs.audio.synth.SoundFont) is loaded once per mixer and +shared by every [`Synth`](tecs.audio.synth.Synth) made on it. A synth takes one +voice slot the way `Audio.play` takes one, so it counts against `maxVoices`, +joins a group, and follows group gain, group mute, group pause, master gain and +master mute like any other voice. What it adds is a channel for MIDI: sixteen +General MIDI channels driven by short messages a game sends from the frame +thread. + +```nupp +local mixer = tecs.audio.newAudio() +local font = assert(tecs.audio.synth.loadSoundFont(mixer, "assets/music/FluidR3_GM.sf2")) +local music = assert(tecs.audio.synth.new(mixer, font, {group = "music"})) + +-- A C major scale, scheduled ahead on the synthesizer clock. +local start = music:now() + 0.1 +music:programChange(0, 0) +for step, key in ipairs({60, 62, 64, 65, 67, 69, 71, 72}) do + local at = start + (step - 1) * 0.25 + music:noteOn(0, key, 100, at) + music:noteOff(0, key, at + 0.22) +end +``` + +Values are MIDI's own. Channels count from zero to fifteen, as on the wire, so +the General MIDI percussion channel is 9. Keys, velocities, controllers, +controller values and programs run from zero to 127, and a pitch bend from zero +to 16383 with 8192 at the centre. A value outside its range raises: it is a +mistake in the call, and a byte silently masked into range plays a wrong note +rather than failing. + +## The clock and `at` + +Every sending method takes an optional `at`, in seconds on the synthesizer's +own clock, which `now` reads. The clock is the frames the synthesizer has +rendered divided by the output rate, so it is the audio clock rather than the +frame clock: it does not drift against what is heard, it never runs backwards, +and it stands still while nothing renders the synth, which is the case before +the first output buffer, while its group is paused, and for good on a mixer +with no output. + +An event lands on the first point of the render grid at or after `at`. The +synthesizer applies MIDI between internal blocks of eight frames, so an event +is never early and at most seven frames late, which is 0.15 milliseconds at +48 kHz, and where it lands depends only on `at`. That last property is the one +to build on: the same schedule renders the same samples on every run and every +machine, whatever the frame rate did. + +An omitted `at`, and one already in the past, means the next block, and events +landing on the same block apply in the order they were sent. Sending each event +as it falls due therefore works, but it puts every note on whichever block the +next output buffer starts with, so its timing follows the frame rate. A +sequencer sends a little ahead instead, against `now`: a lookahead of one or +two frames' worth, say 50 milliseconds, absorbs an ordinary hitch and keeps the +timing exact. + +## The queue + +Events cross to the audio thread through a bounded queue the output callback +never waits on, and that thread holds as many again in its schedule for events +sent ahead of their time. Either is 4096 events. A send that finds no room is +refused rather than waiting or growing anything: the method returns false and +the event is gone. A game streaming a song with a short lookahead never comes +near the bound. One that sends a whole song up front can, so schedule a window +at a time rather than everything at once. + +## Without an output + +A mixer with no output device, or one made with a test backend, still makes a +synth whose every call succeeds. On a mixer with no output nothing renders, so +nothing is heard and `now` stays at zero. A test backend that supplies +`openSynth` receives every event the game sends through the +[`SynthPort`](tecs.platform.audiobackend.SynthPort) it returns, which is how a +harness logs them; one that does not gets the silent port, which accepts and +drops them. A backend without `loadSoundFont` loads nothing, and its SoundFont +is a name. + +## Hardware MIDI output + +Rendering in-process is the only output for now. A hardware output, through +CoreMIDI, WinMM or ALSA, would be a second implementation of `SynthPort` with +the same `send` and `at` contract, chosen when the synth is made. Nothing above +the port would change. +]] + +local audio = require("tecs.audio") +local audiobackend = require("tecs.platform.audiobackend") +local audiosynth = require("tecs.internal.audiosynth") + +--- Notes a synthesizer sounds at once unless asked otherwise. +local DEFAULT_POLYPHONY: integer = 64 + +--- The range the synthesizer accepts for its polyphony. +local MIN_POLYPHONY: integer = 8 + +local MAX_POLYPHONY: integer = 256 + +--- The channel `notesOff` reads as every channel. +local ALL_CHANNELS: integer = -1 + +--- A loaded SoundFont, shared by every synth made on it. +export record SoundFont + --- Read-only. Reports the path the SoundFont was loaded from, exactly as + --- given. It is the SoundFont's identity within its mixer, so loading the + --- same path again returns this record. + path: string + + @private mixer: audio.Audio + + --- The backend's id for it, and zero for a backend that holds none. + @private id: integer +end + +--- Configures `new`. +--- +--- Read once, when the synth is made. Nothing here is retained. +export type Options = { + --- Caller-writable. Selects the group the synth's voice joins, so the + --- group's gain, mute and pause apply to it. Defaults to none. + group: string?, + + --- Caller-writable. Sets the voice's linear gain before group and master + --- gain, and defaults to one. + gain: number?, + + --- Caller-writable. Sets how many notes sound at once, from 8 to 256, and + --- defaults to 64. Past it the synthesizer takes the oldest note for a new + --- one. + polyphony: integer?, + + --- Caller-writable. Turns the synthesizer's reverb and chorus on, and + --- defaults to true. Off, a released note stops at the end of its own + --- release rather than ringing on in the reverb. + effects: boolean? +} + +--- One General MIDI synthesizer, playing as one voice of a mixer. +--- +--- Every sending method returns whether the event was queued, which is false +--- only when the bounded queue is full or the synth has been destroyed. None of +--- them raises for either, because a note that cannot be queued is not a +--- reason for a frame to stop. Nothing here is thread safe: call it from the +--- thread that owns the mixer. +export record Synth + @private mixer: audio.Audio + @private port: audiobackend.SynthPort + @private voice: integer + @private destroyed: boolean + + send: function(exclusive self: Synth, status: integer, data1: integer, data2: integer, at: number?): boolean + noteOn: function(exclusive self: Synth, channel: integer, key: integer, velocity: integer, at: number?): boolean + noteOff: function(exclusive self: Synth, channel: integer, key: integer, at: number?): boolean + controlChange: function( + exclusive self: Synth, + channel: integer, + controller: integer, + value: integer, + at: number? + ): boolean + programChange: function(exclusive self: Synth, channel: integer, program: integer, at: number?): boolean + pitchBend: function(exclusive self: Synth, channel: integer, value: integer, at: number?): boolean + allNotesOff: function(exclusive self: Synth, channel: integer?, immediate: boolean?): boolean + reset: function(exclusive self: Synth): boolean + now: function(borrows self: Synth): number + setGain: function(exclusive self: Synth, gain: number): nil + destroy: function(exclusive self: Synth): nil +end + +--- The SoundFonts each mixer has loaded, by path, keyed weakly so a mixer that +--- has gone takes its fonts with it. +local loaded: {[audio.Audio]: {[string]: SoundFont}} = setmetatable({}, {__mode = "k"}) + +--- Returns what `tecs.audio` lent this module for one mixer. +local function lender(mixer: audio.Audio): audiosynth.Lender + return assert(audiosynth.lent[mixer], "tecs.audio registered no synthesizer seam for this mixer") +end + +--- Raises unless `value` is from `low` to `high`. +--- @raises string when the value is outside the range +local function check(name: string, what: string, value: integer, low: integer, high: integer): nil + if value < low or value > high then + error( + "tecs.audio.synth." .. name .. ": " .. what .. " must be from " .. tostring( + low + ) .. " to " .. tostring(high) .. ", got " .. tostring(value), + 3 + ) + end +end + +--- Loads a SoundFont 2 file into a mixer and returns it. +--- +--- Loading the same path into the same mixer again returns the same record, so +--- every synth on it shares one copy of its samples. A General MIDI set is +--- large, FluidR3 GM is 140 megabytes, and the load reads all of it before it +--- returns, so do it while loading rather than while playing. +--- +--- Only load a SoundFont the game ships. The reader checks the file is a +--- SoundFont 2, but it is not hardened against a crafted one. +--- +--- @param mixer the mixer whose synths will use it +--- @param path the file, which is also the SoundFont's identity in this mixer +--- @return the SoundFont, or nil when the file could not be read +--- @return the reason, when the first return is nil +--- @raises string when the path is empty +export function loadSoundFont(mixer: audio.Audio, path: string): (SoundFont?, string?) + if path == "" then + error("tecs.audio.synth.loadSoundFont: a SoundFont path cannot be empty", 2) + end + local fonts = loaded[mixer] + if fonts == nil then + fonts = {} + loaded[mixer] = fonts + end + local existing = fonts[path] + if existing ~= nil then + return existing + end + + local load = lender(mixer).backend().loadSoundFont + local id: integer = 0 + if load ~= nil then + local font, reason = load(path) + if font == nil then + return nil, reason or ("cannot load " .. path) + end + id = font + end + local font = new SoundFont(path = path, mixer = mixer, id = id) + fonts[path] = font + + return font +end + +--- Makes a synthesizer on a SoundFont and starts its voice. +--- +--- The voice starts sounding with the mixer's next flush, which `update` makes +--- once per frame, and events sent before then wait for it. It sounds until +--- `destroy`, and a voice `stopAll` or `stopGroup` ended does not come back: +--- make another synth instead. +--- +--- @param mixer the mixer the voice plays on, which must be the one the +--- SoundFont was loaded into +--- @param font the SoundFont to play +--- @param options the group, gain, polyphony and effects, or nil for the +--- defaults +--- @return the synth, or nil when its voice could not start. A mixer with no +--- output is not such a case: it returns a synth that renders nothing. +--- @return the reason, when the first return is nil: every voice is busy, or +--- the backend refused the synthesizer +--- @raises string when the SoundFont belongs to another mixer +--- @raises string when `polyphony` is outside 8 to 256 +export function new(mixer: audio.Audio, font: SoundFont, options: Options?): (Synth?, string?) + if font.mixer ~= mixer then + error("tecs.audio.synth.new: the SoundFont was loaded into another mixer", 2) + end + local polyphony = (options and options.polyphony) or DEFAULT_POLYPHONY + if polyphony < MIN_POLYPHONY or polyphony > MAX_POLYPHONY then + error("tecs.audio.synth.new: polyphony must be from 8 to 256, got " .. tostring(polyphony), 2) + end + local effects = not (options ~= nil and options.effects == false) + + local seam = lender(mixer) + local voice = seam.start((options and options.gain) or 1, options and options.group) + if voice == 0 then + if mixer.available then + return nil, "every voice is busy, so the synthesizer has none to play through" + end + + return new Synth(mixer = mixer, port = audiobackend.silentSynth(), voice = 0, destroyed = false) + end + + local open = seam.backend().openSynth + if open == nil or font.id == 0 then + return new Synth(mixer = mixer, port = audiobackend.silentSynth(), voice = voice, destroyed = false) + end + -- Opened before the voice's start is flushed, because the start is what + -- hands the synthesizer to the mixer. + local port, reason = open(font.id, voice, polyphony, effects) + if port == nil then + mixer:stop(voice) + + return nil, reason or "the backend opened no synthesizer" + end + + return new Synth(mixer = mixer, port = port, voice = voice, destroyed = false) +end + +--- Queues any short MIDI message. +--- +--- @param self the synth to send to +--- @param status the status byte, from 0x80 to 0xFF. A channel message +--- carries its channel in the low four bits. System messages, 0xF0 and up, +--- are accepted and have no effect on this synthesizer. +--- @param data1 the first data byte, from 0 to 127 +--- @param data2 the second data byte, from 0 to 127, and 0 for a message that +--- carries one +--- @param at seconds on the synthesizer clock, where nil or a time already +--- passed means the next block +--- @return whether the event was queued, false when the queue is full or the +--- synth is destroyed +--- @raises string when a byte is outside its range +function Synth.send(exclusive self: Synth, status: integer, data1: integer, data2: integer, at: number?): boolean + check("send", "status", status, 0x80, 0xFF) + check("send", "data1", data1, 0, 127) + check("send", "data2", data2, 0, 127) + if self.destroyed then + return false + end + + return self.port.send(status, data1, data2, at or 0) +end + +--- Queues a note on. +--- +--- @param self the synth to send to +--- @param channel from 0 to 15, where 9 is General MIDI percussion +--- @param key from 0 to 127, where 60 is middle C +--- @param velocity from 0 to 127. Zero is a note off, as MIDI defines it. +--- @param at seconds on the synthesizer clock, read as `send` reads it +--- @return whether the event was queued +--- @raises string when a value is outside its range +function Synth.noteOn(exclusive self: Synth, channel: integer, key: integer, velocity: integer, at: number?): boolean + check("noteOn", "channel", channel, 0, 15) + check("noteOn", "key", key, 0, 127) + check("noteOn", "velocity", velocity, 0, 127) + + return self:send(0x90 + channel, key, velocity, at) +end + +--- Queues a note off, which lets the note play its release. +--- +--- @param self the synth to send to +--- @param channel from 0 to 15 +--- @param key from 0 to 127 +--- @param at seconds on the synthesizer clock, read as `send` reads it +--- @return whether the event was queued +--- @raises string when a value is outside its range +function Synth.noteOff(exclusive self: Synth, channel: integer, key: integer, at: number?): boolean + check("noteOff", "channel", channel, 0, 15) + check("noteOff", "key", key, 0, 127) + + return self:send(0x80 + channel, key, 0, at) +end + +--- Queues a control change. +--- +--- @param self the synth to send to +--- @param channel from 0 to 15 +--- @param controller from 0 to 127, such as 7 for channel volume, 10 for pan, +--- 64 for the sustain pedal and 0 for bank select +--- @param value from 0 to 127 +--- @param at seconds on the synthesizer clock, read as `send` reads it +--- @return whether the event was queued +--- @raises string when a value is outside its range +function Synth.controlChange( + exclusive self: Synth, + channel: integer, + controller: integer, + value: integer, + at: number? +): boolean + check("controlChange", "channel", channel, 0, 15) + check("controlChange", "controller", controller, 0, 127) + check("controlChange", "value", value, 0, 127) + + return self:send(0xB0 + channel, controller, value, at) +end + +--- Queues a program change, selecting an instrument for later notes. +--- +--- @param self the synth to send to +--- @param channel from 0 to 15 +--- @param program from 0 to 127, the General MIDI instrument numbered from +--- zero, so 0 is the acoustic grand piano +--- @param at seconds on the synthesizer clock, read as `send` reads it +--- @return whether the event was queued +--- @raises string when a value is outside its range +function Synth.programChange(exclusive self: Synth, channel: integer, program: integer, at: number?): boolean + check("programChange", "channel", channel, 0, 15) + check("programChange", "program", program, 0, 127) + + return self:send(0xC0 + channel, program, 0, at) +end + +--- Queues a pitch bend. +--- +--- @param self the synth to send to +--- @param channel from 0 to 15 +--- @param value the 14-bit bend from 0 to 16383, where 8192 is no bend. The +--- range it spans is the channel's, two semitones each way by default. +--- @param at seconds on the synthesizer clock, read as `send` reads it +--- @return whether the event was queued +--- @raises string when a value is outside its range +function Synth.pitchBend(exclusive self: Synth, channel: integer, value: integer, at: number?): boolean + check("pitchBend", "channel", channel, 0, 15) + check("pitchBend", "value", value, 0, 16383) + + return self:send(0xE0 + channel, value % 128, value // 128, at) +end + +--- Releases every sounding note, for the next block. +--- +--- Takes no `at`: it is what a game sends to stop, now. Notes scheduled for +--- later still play when their time comes; `reset` is the call that discards +--- them too. +--- +--- @param self the synth to send to +--- @param channel from 0 to 15, or nil for every channel +--- @param immediate true cuts the notes without their release, and nil or +--- false lets each one fade as a note off would +--- @return whether the event was queued +--- @raises string when the channel is outside its range +function Synth.allNotesOff(exclusive self: Synth, channel: integer?, immediate: boolean?): boolean + if channel ~= nil then + check("allNotesOff", "channel", channel, 0, 15) + end + if self.destroyed then + return false + end + + return self.port.notesOff(channel or ALL_CHANNELS, immediate == true) +end + +--- Returns every channel to its General MIDI defaults, for the next block. +--- +--- Cuts every note, puts every channel back on program zero with its +--- controllers at rest, and discards every event sent before this call that +--- has not been applied, scheduled ones included. Events sent after it apply +--- as usual. +--- +--- @param self the synth to reset +--- @return whether the reset was queued +function Synth.reset(exclusive self: Synth): boolean + if self.destroyed then + return false + end + + return self.port.reset() +end + +--- Returns the synthesizer clock, for computing `at`. +--- +--- @param self the synth to read +--- @return seconds of audio the synthesizer has rendered: monotonic, zero +--- before its first block, still while its group is paused, and zero for +--- good on a mixer with no output. It leads what is heard by the output's +--- own latency, and leads the mix by at most one block. +function Synth.now(borrows self: Synth): number + if self.destroyed then + return 0 + end + + return self.port.now() +end + +--- Sets the synth's gain, before its group's and the master's. +--- +--- @param self the synth to scale +--- @param gain linear amplitude rather than decibels, applied from the next +--- output buffer rather than ramped +function Synth.setGain(exclusive self: Synth, gain: number): nil + self.mixer:setGain(self.voice, gain) +end + +--- Stops the synth's voice and frees its synthesizer. Safe more than once. +--- +--- The voice stops without a fade, so a note still sounding is cut. Send +--- `allNotesOff` a little before this for a softer ending. +--- +--- @param self the synth to destroy +function Synth.destroy(exclusive self: Synth): nil + if self.destroyed then + return + end + self.destroyed = true + self.mixer:stop(self.voice) + self.port.close() +end diff --git a/src/tecs/internal/audiosynth.nupp b/src/tecs/internal/audiosynth.nupp new file mode 100644 index 00000000..fccbcbd0 --- /dev/null +++ b/src/tecs/internal/audiosynth.nupp @@ -0,0 +1,28 @@ +module tecs.internal.audiosynth + +--[[ +What `tecs.audio` lends `tecs.audio.synth`. + +A synthesizer is a voice of the mixer, so it needs a slot taken the way +`Audio.play` takes one and the backend its SoundFont loads into. Both live in +fields `tecs.audio` keeps private, and a child module cannot reach them. This +module is the seam between the two: `tecs.audio` fills `lent` for each mixer it makes, and +`tecs.audio.synth` reads it. Nothing else should. +]] + +local audiobackend = require("tecs.platform.audiobackend") + +--- The two operations lent for one mixer, as closures over it, because this +--- module sits below `tecs.audio` and cannot name its record. +export record Lender + --- Read-only. Returns the backend the mixer drives. + backend: function(): audiobackend.Backend + + --- Read-only. Takes a voice slot for a synthesizer at `gain` in `group`, and + --- returns its handle, or zero when the mixer has no output or no free slot. + start: function(gain: number, group: string?): integer +end + +--- Holds the lender each mixer registers when it is made, keyed weakly by the +--- mixer so one that has gone takes its entry with it. +export const lent: {[any]: Lender} = setmetatable({}, {__mode = "k"}) diff --git a/src/tecs/platform/audiobackend.nupp b/src/tecs/platform/audiobackend.nupp index 688391d3..9533da58 100644 --- a/src/tecs/platform/audiobackend.nupp +++ b/src/tecs/platform/audiobackend.nupp @@ -48,7 +48,8 @@ export record Command --- `tecs.audio.clipId` assigned. clip: integer - --- Caller-writable. Carries the `LOOP`, `SPATIAL` and `STEREO` bits. + --- Caller-writable. Carries the `LOOP`, `SPATIAL`, `STEREO`, `STREAM` and + --- `SYNTH` bits. flags: integer --- Caller-writable. Sets linear amplitude for `PLAY`, `SET_GAIN` and @@ -91,6 +92,37 @@ export record ClipInfo resident: boolean end +--- Sends General MIDI to one synthesizer a backend renders as a voice. +--- +--- Pushed rather than called back: each function queues or reads on the frame +--- thread and returns, and the backend's audio thread takes the events from a +--- bounded queue it never waits on. Every field is a plain function, so a test +--- that wants to see what a game sent is a record of closures that logs. +export record SynthPort + --- Read-only. Queues a short MIDI message for `at` seconds on this port's + --- clock, where zero or less means as soon as possible, and reports + --- whether the queue had room. + send: function(status: integer, data1: integer, data2: integer, at: number): boolean + + --- Read-only. Queues a release of every note on `channel`, or on every + --- channel when `channel` is negative, cutting them without their release + --- when `immediate` is set. Reports whether the queue had room. + notesOff: function(channel: integer, immediate: boolean): boolean + + --- Read-only. Queues a return to General MIDI defaults that discards every + --- event sent before it and not yet applied, and reports whether the queue + --- had room. + reset: function(): boolean + + --- Read-only. Reports the port's clock in seconds: the frames the + --- synthesizer has rendered over the output rate. Zero until it renders. + now: function(): number + + --- Read-only. Frees the port. Calling it twice is harmless, and nothing + --- sent afterwards reaches anything. + close: function(): nil +end + --- Selects residency for a load. --- --- `"auto"` measures the clip's duration against the configured streaming @@ -159,6 +191,23 @@ export record Backend --- backend's own order. decoders: function(): {string} + --- Read-only. Reads a SoundFont 2 file and returns the id `openSynth` + --- names it by, or nil and a reason. Omitted by a backend with no + --- synthesizer, which `tecs.audio.synth` answers with a SoundFont that + --- holds nothing. + loadSoundFont: (function(path: string): (integer?, string?))? + + --- Read-only. Drops a SoundFont. Synthesizers already opened on it play on. + releaseSoundFont: (function(font: integer): nil)? + + --- Read-only. Opens a synthesizer on a loaded SoundFont for the voice + --- `handle` names, which starts sounding when a `PLAY` carrying that + --- handle and the `SYNTH` flag is submitted. `polyphony` is the notes it + --- sounds at once and `effects` turns its reverb and chorus on. Returns + --- the port, or nil and a reason. Omitted by a backend with no + --- synthesizer, which `tecs.audio.synth` answers with `silentSynth`. + openSynth: (function(font: integer, handle: integer, polyphony: integer, effects: boolean): (SynthPort?, string?))? + --- Read-only. Stops everything and closes the output. Calling it twice is --- harmless. close: function(): nil @@ -322,6 +371,10 @@ export const STEREO: integer = 4 --- samples. export const STREAM: integer = 8 +--- Marks a `PLAY` whose input is the synthesizer `openSynth` opened for the +--- command's handle, rather than a clip. +export const SYNTH: integer = 16 + --- Creates one zeroed command, for a caller filling a reusable buffer. --- @return a command whose every field is at rest export function newCommand(): Command @@ -385,6 +438,32 @@ export function silent(): Backend ) end +--- Creates a synthesizer port that accepts everything and renders nothing. +--- +--- What a synthesizer gets on a mixer with no output, and on a backend that +--- has no synthesizer of its own. Every event is taken and dropped, and the +--- clock stays at zero because no frame is ever rendered. +--- +--- @return a port whose sends all succeed and whose clock reads zero +export function silentSynth(): SynthPort + return new SynthPort( + send = function(_status: integer, _data1: integer, _data2: integer, _at: number): boolean + return true + end, + notesOff = function(_channel: integer, _immediate: boolean): boolean + return true + end, + reset = function(): boolean + return true + end, + now = function(): number + return 0 + end, + close = function(): nil + end + ) +end + --- Creates a provider that names no devices and opens none. --- --- What a build with no audio library gets, and what a device-free test that diff --git a/src/tecs/platform/audionative.nupp b/src/tecs/platform/audionative.nupp index 23f8f55c..b4bf0034 100644 --- a/src/tecs/platform/audionative.nupp +++ b/src/tecs/platform/audionative.nupp @@ -68,6 +68,15 @@ cdef struct tecsAudioClipInfo resident: int32 end +--- The id a SoundFont load writes back. +--- +--- A one-field struct for the reason `tecsAudioSample` is one: the library +--- writes through a `uint32_t *`, and a struct holding one `uint32` has that +--- integer's size and alignment under every ABI this tree targets. +cdef struct tecsAudioFontId + value: uint32 +end + --- One captured sample, so a block of them can be a `carray`. --- --- `carray` takes a struct type, and the captured samples are bare floats, so @@ -139,6 +148,39 @@ cdef function tecs_audio_last_error(borrows device: voidptr?): cstring? cdef function tecs_audio_layout(kind: uint32): uint32 +-- The synthesizer. A port is a handle of its own rather than a field of the +-- device: it outlives `tecs_audio_close` safely, and the binding frees it with +-- `tecs_audio_synth_close`. Nothing here crosses as a struct, so these add +-- nothing for `layoutMismatch` to compare. + +cdef function tecs_audio_load_soundfont(borrows device: voidptr?, path: cstring, borrows font: tecsAudioFontId*): int32 + +cdef function tecs_audio_release_soundfont(borrows device: voidptr?, font: uint32) + +cdef function tecs_audio_synth_open( + borrows device: voidptr?, + font: uint32, + handle: uint32, + polyphony: uint32, + effects: int32 +): voidptr? + +cdef function tecs_audio_synth_send( + borrows synth: voidptr?, + status: uint32, + data1: uint32, + data2: uint32, + at: number +): int32 + +cdef function tecs_audio_synth_notes_off(borrows synth: voidptr?, channel: int32, immediate: int32): int32 + +cdef function tecs_audio_synth_reset(borrows synth: voidptr?): int32 + +cdef function tecs_audio_synth_now(borrows synth: voidptr?): number + +cdef function tecs_audio_synth_close(borrows synth: voidptr?) + -- Device listing and recording. Neither a listing nor a capture crosses as a -- struct: a device name has no fixed width worth choosing, and a capture is a -- handle. So these add nothing for `layoutMismatch` to compare, and the @@ -388,6 +430,90 @@ local function wrap(openDevice: function(): voidptr?, absent: string): (audiobac return new audiobackend.ClipInfo(duration = info.duration as number, resident = info.resident ~= 0) end + local fontId = ffi.new() + + local function loadSoundFont(path: string): (integer?, string?) + if closed[1] then + return nil, "the audio output is closed" + end + if tecs_audio_load_soundfont(device, path, fontId) ~= 0 then + return nil, reason("tecsaudio could not read '" .. path .. "'") + end + + return fontId.value as integer + end + + --- Opens one port with the thunk and wraps it in closures that refuse once + --- it is closed, because a freed port reached again is not a diagnosable + --- failure. The thunk opens it here for the reason `wrap` takes one: the + --- closures outlive any frame a pointer could be handed in from. + local function wrapSynth(openPort: function(): voidptr?): (audiobackend.SynthPort?, string?) + local synth = openPort() + if synth == nil then + return nil, reason("tecsaudio opened no synthesizer") + end + local released = {false} + + return new audiobackend.SynthPort( + send = function(status: integer, data1: integer, data2: integer, at: number): boolean + if released[1] then + return false + end + + return tecs_audio_synth_send(synth, status as uint32, data1 as uint32, data2 as uint32, at) ~= 0 + end, + notesOff = function(channel: integer, immediate: boolean): boolean + if released[1] then + return false + end + + return tecs_audio_synth_notes_off(synth, channel as int32, (immediate and 1 or 0) as int32) ~= 0 + end, + reset = function(): boolean + if released[1] then + return false + end + + return tecs_audio_synth_reset(synth) ~= 0 + end, + now = function(): number + if released[1] then + return 0 + end + + return tecs_audio_synth_now(synth) + end, + close = function(): nil + if released[1] then + return + end + released[1] = true + tecs_audio_synth_close(synth) + end + ) + end + + local function openSynth( + font: integer, + handle: integer, + polyphony: integer, + effects: boolean + ): (audiobackend.SynthPort?, string?) + if closed[1] then + return nil, "the audio output is closed" + end + + return wrapSynth(function(): voidptr? + return tecs_audio_synth_open( + device, + font as uint32, + handle as uint32, + polyphony as uint32, + (effects and 1 or 0) as int32 + ) + end) + end + return new audiobackend.Backend( failure = function(): string? if not closed[1] and tecs_audio_failed(device) ~= 0 then @@ -428,6 +554,13 @@ local function wrap(openDevice: function(): voidptr?, absent: string): (audiobac return names end, + loadSoundFont = loadSoundFont, + releaseSoundFont = function(font: integer): nil + if not closed[1] then + tecs_audio_release_soundfont(device, font as uint32) + end + end, + openSynth = openSynth, close = function(): nil if closed[1] then return diff --git a/tests/audiosynthtest.nupp b/tests/audiosynthtest.nupp new file mode 100644 index 00000000..f09b0dd4 --- /dev/null +++ b/tests/audiosynthtest.nupp @@ -0,0 +1,365 @@ +local audio = require("tecs.audio") +local audiobackend = require("tecs.platform.audiobackend") +local synth = require("tecs.audio.synth") +local test = require("nupp.test") + +local suite = {} + +--- One event a test port received, as the port saw it. +local record Sent + kind: string + status: integer + data1: integer + data2: integer + at: number +end + +--- A backend with a synthesizer that logs every event instead of rendering. +local record Harness + backend: audiobackend.Backend + commands: {audiobackend.Command} + sent: {Sent} + fonts: {string} + opened: {integer} + closed: {integer} + full: {boolean} +end + +local function newHarness(available: boolean, withSynth: boolean): Harness + local commands: {audiobackend.Command} = {} + local sent: {Sent} = {} + local fonts: {string} = {} + local opened: {integer} = {} + local closed: {integer} = {0} + local full = {false} + + local function record(kind: string, status: integer, data1: integer, data2: integer, at: number): boolean + if full[1] then + return false + end + sent[#sent + 1] = new Sent(kind = kind, status = status, data1 = data1, data2 = data2, at = at) + + return true + end + + local backend = audiobackend.silent() + backend.available = available + backend.submit = function(pending: {audiobackend.Command}, count: integer): nil + for index = 1, count do + local source = pending[index] + local copy = audiobackend.newCommand() + copy.kind = source.kind + copy.handle = source.handle + copy.clip = source.clip + copy.flags = source.flags + copy.gain = source.gain + commands[#commands + 1] = copy + end + end + if withSynth then + backend.loadSoundFont = function(path: string): (integer?, string?) + if path == "missing.sf2" then + return nil, "cannot open 'missing.sf2'" + end + fonts[#fonts + 1] = path + + return #fonts + end + backend.openSynth = function( + _font: integer, + handle: integer, + _polyphony: integer, + _effects: boolean + ): (audiobackend.SynthPort?, string?) + opened[#opened + 1] = handle + + return new audiobackend.SynthPort( + send = function(status: integer, data1: integer, data2: integer, at: number): boolean + return record("message", status, data1, data2, at) + end, + notesOff = function(channel: integer, immediate: boolean): boolean + return record("notesOff", channel, immediate and 1 or 0, 0, 0) + end, + reset = function(): boolean + return record("reset", 0, 0, 0, 0) + end, + now = function(): number + return 1.5 + end, + close = function(): nil + closed[1] = closed[1] + 1 + end + ) + end + end + + return new Harness( + backend = backend, + commands = commands, + sent = sent, + fonts = fonts, + opened = opened, + closed = closed, + full = full + ) +end + +local function lastCommand(harness: Harness, kind: integer): audiobackend.Command? + for index = #harness.commands, 1, -1 do + if harness.commands[index].kind == kind then + return harness.commands[index] + end + end + + return nil +end + +local function made(harness: Harness, options: synth.Options?): (audio.Audio, synth.Synth) + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 2}) + local font = assert(synth.loadSoundFont(mixer, "gm.sf2")) + + return mixer, assert(synth.new(mixer, font, options)) +end + +function suite.loadsOneSoundFontPerPathAndReportsAFailure(): nil + local harness = newHarness(true, true) + local mixer = audio.newAudio({backend = harness.backend}) + local first = assert(synth.loadSoundFont(mixer, "gm.sf2")) + test.equal(first.path, "gm.sf2") + test.equal(synth.loadSoundFont(mixer, "gm.sf2"), first) + test.equal(#harness.fonts, 1) + + local missing, reason = synth.loadSoundFont(mixer, "missing.sf2") + test.equal(missing, nil) + test.matches(assert(reason), "missing") + test.raises( + function(): nil + synth.loadSoundFont(mixer, "") + end, + "cannot be empty" + ) +end + +function suite.startsTheSynthAsOneVoiceOfTheMixer(): nil + local harness = newHarness(true, true) + local mixer, music = made(harness, {group = "music", gain = 0.5}) + test.equal(mixer:sounding(), 1) + mixer:update() + local play = assert(lastCommand(harness, audiobackend.PLAY)) + test.equal(play.flags, audiobackend.SYNTH) + test.equal(play.gain, 0.5) + -- The synthesizer was opened for the voice that started, before the + -- start crossed, which is what hands it to the mixer. + test.equal(harness.opened[1], play.handle) + + mixer:setGroupGain("music", 0.5) + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.SET_GAIN)).gain, 0.25) + mixer:setGroupMuted("music", true) + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.SET_GAIN)).gain, 0) + mixer:setGroupMuted("music", false) + music:setGain(1) + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.SET_GAIN)).gain, 0.5) + mixer:pauseGroup("music") + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.PAUSE)).handle, play.handle) + + music:destroy() + music:destroy() + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.STOP)).handle, play.handle) + test.equal(harness.closed[1], 1) + test.equal(mixer:sounding(), 0) +end + +function suite.countsAgainstTheVoiceCeiling(): nil + local harness = newHarness(true, true) + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 1}) + local font = assert(synth.loadSoundFont(mixer, "gm.sf2")) + local first = assert(synth.new(mixer, font)) + local second, reason = synth.new(mixer, font) + test.equal(second, nil) + test.matches(assert(reason), "every voice is busy") + first:destroy() + test.assert(synth.new(mixer, font) ~= nil) +end + +function suite.sendsEveryHelperAsTheShortMessageItNames(): nil + local harness = newHarness(true, true) + local _mixer, music = made(harness) + test.assert(music:noteOn(2, 60, 100, 0.25)) + test.assert(music:noteOff(2, 60)) + test.assert(music:controlChange(9, 7, 90, 1)) + test.assert(music:programChange(15, 40)) + test.assert(music:pitchBend(0, 8192)) + test.assert(music:pitchBend(0, 16383)) + test.assert(music:send(0xF8, 0, 0)) + local expected: { + {number} + } = { + {0x92, 60, 100, 0.25}, + {0x82, 60, 0, 0}, + {0xB9, 7, 90, 1}, + {0xCF, 40, 0, 0}, + {0xE0, 0, 64, 0}, + {0xE0, 127, 127, 0}, + {0xF8, 0, 0, 0}, + } + test.equal(#harness.sent, #expected) + for index = 1, #expected do + local event = harness.sent[index] + test.equal(event.kind, "message") + test.equal(event.status, expected[index][1]) + test.equal(event.data1, expected[index][2]) + test.equal(event.data2, expected[index][3]) + test.equal(event.at, expected[index][4]) + end + test.equal(music:now(), 1.5) +end + +function suite.carriesNotesOffAndResetToThePort(): nil + local harness = newHarness(true, true) + local _mixer, music = made(harness) + test.assert(music:allNotesOff()) + test.assert(music:allNotesOff(3, true)) + test.assert(music:reset()) + test.equal(harness.sent[1].kind, "notesOff") + test.equal(harness.sent[1].status, -1) + test.equal(harness.sent[1].data1, 0) + test.equal(harness.sent[2].status, 3) + test.equal(harness.sent[2].data1, 1) + test.equal(harness.sent[3].kind, "reset") +end + +function suite.reportsAFullQueueAsAnUnqueuedEvent(): nil + local harness = newHarness(true, true) + local _mixer, music = made(harness) + harness.full[1] = true + test.assert(not music:noteOn(0, 60, 100)) + test.assert(not music:reset()) + music:destroy() + harness.full[1] = false + test.assert(not music:noteOn(0, 60, 100), "a destroyed synth queues nothing") + test.equal(music:now(), 0) +end + +function suite.raisesForAValueOutsideItsRange(): nil + local harness = newHarness(true, true) + local mixer, music = made(harness) + test.raises( + function(): nil + music:noteOn(16, 60, 100) + end, + "channel must be from 0 to 15" + ) + test.raises( + function(): nil + music:noteOn(0, 128, 100) + end, + "key" + ) + test.raises( + function(): nil + music:send(0x7F, 0, 0) + end, + "status" + ) + test.raises( + function(): nil + music:pitchBend(0, 16384) + end, + "value" + ) + test.raises( + function(): nil + music:allNotesOff(-1) + end, + "channel" + ) + local font = assert(synth.loadSoundFont(mixer, "gm.sf2")) + test.raises( + function(): nil + synth.new(mixer, font, {polyphony = 4}) + end, + "polyphony" + ) + local other = audio.newAudio({backend = newHarness(true, true).backend}) + test.raises( + function(): nil + synth.new(other, font) + end, + "another mixer" + ) + test.equal(#harness.sent, 0) +end + +function suite.acceptsEverythingSilentlyWithoutAnOutput(): nil + -- A mixer with no output takes no voice, and its synth still answers + -- every call, on a clock that never moves because nothing renders. + local mixer = audio.newAudio({backend = audiobackend.silent()}) + local font = assert(synth.loadSoundFont(mixer, "gm.sf2")) + local music = assert(synth.new(mixer, font)) + test.equal(mixer:sounding(), 0) + test.assert(music:noteOn(0, 60, 100, 0.5)) + test.assert(music:allNotesOff()) + test.equal(music:now(), 0) + music:destroy() +end + +function suite.takesAVoiceButRendersNothingOnABackendWithNoSynthesizer(): nil + local harness = newHarness(true, false) + local mixer, music = made(harness) + test.equal(mixer:sounding(), 1) + test.assert(music:noteOn(0, 60, 100)) + test.equal(#harness.sent, 0) + test.equal(music:now(), 0) +end + +--- Returns the native binding, or skips when `libtecsaudio` is not staged. +local function binding(): {[string]: any} + local ok, loaded = pcall(require, "tecs.platform.audionative") + if not ok then + test.skip("libtecsaudio is not on the loader path: " .. tostring(loaded)) + end + + return loaded as {[string]: any} +end + +function suite.refusesAMissingSoundFontThroughTheNativeBackend(): nil + local backend = binding().openOffline(48000, 2, 8) as audiobackend.Backend? + if backend == nil then + test.skip("libtecsaudio did not open") + end + local mixer = audio.newAudio({backend = backend}) + local font, reason = synth.loadSoundFont(mixer, "tests/audio/no-such-font.sf2") + test.equal(font, nil) + test.matches(assert(reason), "cannot open") + mixer:destroy() +end + +function suite.loadsARealSoundFontThroughTheNativeBackend(): nil + -- No SoundFont ships in the tree, and the verify gate counts a skip as a + -- failure, so a run without one passes here having proved nothing more + -- than the missing-file case above does. + local path = os.getenv("TECS_TEST_SOUNDFONT") + if path == nil or path == "" then + return + end + local backend = binding().openOffline(48000, 2, 8) as audiobackend.Backend? + if backend == nil then + test.skip("libtecsaudio did not open") + end + local mixer = audio.newAudio({backend = backend}) + local font = assert(synth.loadSoundFont(mixer, assert(path))) + -- The offline backend reports no output, so the synth plays through no + -- voice and renders nothing; the load is what crossed into Rust. + local music = assert(synth.new(mixer, font)) + test.assert(music:noteOn(0, 60, 100)) + test.equal(music:now(), 0) + music:destroy() + mixer:destroy() +end + +return suite From b3460aa933b6f8a86fe71966aa344ea17daa3466 Mon Sep 17 00:00:00 2001 From: Alban Gicquel <6368237+albanmgicquel@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:41:41 -0400 Subject: [PATCH 3/7] tecs-audio: an emulated OPL2 and OPL3 as one voice of the mixer src/fm drives a YM3812 or YMF262 from ymfm-oxide, a pure-Rust, no_std port of ymfm (BSD-3-Clause), taken as a git dependency pinned to commit 4cc9e10. Tecs keeps no emulator source: the crate carries the port and its check against ymfm. A register 0x000-0x1FF maps onto the chip's address and data ports, offsets 0/1 below 0x100 and 2/3 for the OPL3's second bank; the OPL3 plays outputs A and B as left and right, the OPL2 its one output on both. The chip renders at 49715 Hz, the rate the emulator gives a 3.579545 MHz OPL2 and a 14.31818 MHz OPL3, and the voice resamples it. The chip goes through the synth's ring, schedule and source. The block grid is now per Target: eight frames for rustysynth, one for the OPL, so a write lands on the first chip sample at or after its time, and writes due on one sample all apply before it is generated. Port gains an all-or-nothing batch push, and a reset carries a frame: events sent before it and due before it still apply, those due at or after it are discarded (the synth's reset, at the next block, behaves as before). FFI: tecs_audio_fm_open/write/writes/reset/now/close and a RegisterWrite layout. THIRD_PARTY_NOTICES records ymfm-oxide and carries its licence. Tests: the grid, ordering, the second bank and the NEW bit, a key-off and key-on on one sample, queue bounds, batch atomicity, reset, the rate against the emulator's, and the engine path. --- Cargo.lock | 6 + Cargo.toml | 1 + THIRD_PARTY_NOTICES.md | 46 ++ native/rust/tecs-audio/Cargo.toml | 1 + native/rust/tecs-audio/src/command.rs | 4 +- native/rust/tecs-audio/src/engine.rs | 65 ++- native/rust/tecs-audio/src/fm.rs | 660 ++++++++++++++++++++++++++ native/rust/tecs-audio/src/lib.rs | 192 ++++++++ native/rust/tecs-audio/src/synth.rs | 94 +++- native/rust/tecs-audio/tests/fm.rs | 168 +++++++ 10 files changed, 1219 insertions(+), 18 deletions(-) create mode 100644 native/rust/tecs-audio/src/fm.rs create mode 100644 native/rust/tecs-audio/tests/fm.rs diff --git a/Cargo.lock b/Cargo.lock index fe03a809..1aac99e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3194,6 +3194,7 @@ dependencies = [ "cpal", "rustysynth", "symphonia", + "ymfm-oxide", ] [[package]] @@ -4361,6 +4362,11 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "ymfm-oxide" +version = "0.1.0" +source = "git+https://github.com/albanmgicquel/ymfm-oxide?rev=4cc9e106c7dbf28c848a9a463bf3ef74709a5ed0#4cc9e106c7dbf28c848a9a463bf3ef74709a5ed0" + [[package]] name = "zerocopy" version = "0.8.55" diff --git a/Cargo.toml b/Cargo.toml index 2c7753e9..1b365e3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,7 @@ urlencoding = "2.1.3" walkdir = "2.5" wgpu = "30.0.1" winit = "0.30.13" +ymfm-oxide = { git = "https://github.com/albanmgicquel/ymfm-oxide", rev = "4cc9e106c7dbf28c848a9a463bf3ef74709a5ed0" } axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] } [profile.release] diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 924ca0ff..eb57f1a3 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -87,6 +87,52 @@ Tecs ships no SoundFont. `tecs.audio.synth` reads one a game supplies, and a General MIDI set is game content under its own licence, so a game that ships one ships that licence beside it. FluidR3 GM, for example, is MIT. +## Linked BSD-licensed code + +### ymfm-oxide + +`tecs.audio.fm` emulates its OPL2 and OPL3 with ymfm-oxide +(), a pure-Rust port by Alban +Gicquel of ymfm, Aaron Giles's emulator of the Yamaha FM chips +(). It is a Cargo dependency pinned to one +commit, compiled into `tecsaudio`, so every package carries it and +`cargo-licenses.txt` records it as BSD-3-Clause. The licence asks for its +notice to travel with a binary, which is this one; Tecs keeps none of its +source. + +```text +BSD 3-Clause License + +Copyright (c) 2021, Aaron Giles +Copyright (c) 2026, Alban Gicquel +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + ## Scope of the checks The generated inventory and source manifest make shipped dependency names, diff --git a/native/rust/tecs-audio/Cargo.toml b/native/rust/tecs-audio/Cargo.toml index dffc5510..4ff25989 100644 --- a/native/rust/tecs-audio/Cargo.toml +++ b/native/rust/tecs-audio/Cargo.toml @@ -13,3 +13,4 @@ crate-type = ["cdylib", "rlib"] cpal.workspace = true rustysynth.workspace = true symphonia.workspace = true +ymfm-oxide.workspace = true diff --git a/native/rust/tecs-audio/src/command.rs b/native/rust/tecs-audio/src/command.rs index 185f2877..17712306 100644 --- a/native/rust/tecs-audio/src/command.rs +++ b/native/rust/tecs-audio/src/command.rs @@ -51,8 +51,8 @@ pub const FLAG_STEREO: u32 = 4; /// Marks a `PLAY` reading its input from the file rather than held samples. pub const FLAG_STREAM: u32 = 8; -/// Marks a `PLAY` whose input is the synthesizer `tecs_audio_synth_open` -/// parked under the command's handle, rather than a clip. +/// Marks a `PLAY` whose input is the synthesizer `tecs_audio_synth_open` or +/// `tecs_audio_fm_open` parked under the command's handle, rather than a clip. pub const FLAG_SYNTH: u32 = 16; /// Requests one operation. diff --git a/native/rust/tecs-audio/src/engine.rs b/native/rust/tecs-audio/src/engine.rs index b15aae26..410b06cc 100644 --- a/native/rust/tecs-audio/src/engine.rs +++ b/native/rust/tecs-audio/src/engine.rs @@ -13,6 +13,7 @@ use std::sync::{Arc, Mutex, RwLock}; use crate::command::{self, Command, Event}; use crate::decode::{self, Streamer}; use crate::device::{self, Output}; +use crate::fm::{self, FmPort, Kind}; use crate::mixer::Mixer; use crate::source::{ResidentSource, Source, MAX_CHANNELS}; use crate::stream::Feeder; @@ -57,8 +58,8 @@ pub struct Engine { /// synthesizer opened on one. fonts: RwLock>>, next_font: Mutex, - /// Synthesizers opened and not yet started, by the voice handle whose - /// `PLAY` will take them. + /// Synthesizers opened and not yet started, General MIDI and FM alike, by + /// the voice handle whose `PLAY` will take them. synths: Mutex>>, feeder: Feeder, output: Option, @@ -358,6 +359,25 @@ impl Engine { } } + /// Creates an emulated OPL2 or OPL3 and parks its source for the `PLAY` + /// carrying `handle` and `FLAG_SYNTH`, as `open_synth` does. + /// + /// The chip renders at its own rate, `fm::RATE`, and the voice resamples + /// it to the output. + /// + /// @return the port register writes are sent through, or the reason + /// nothing opened + pub fn open_fm(&self, handle: u32, kind: Kind) -> Result { + let (port, source) = fm::open(kind); + match self.synths.lock() { + Err(_) => Err(self.fail("the synthesizer table is poisoned".to_string())), + Ok(mut synths) => { + synths.insert(handle, Box::new(source)); + Ok(port) + } + } + } + /// Builds the source a `PLAY` needs, outside the mixer lock. fn prepare(&self, command: &Command) -> Option> { if command.has(command::FLAG_SYNTH) { @@ -661,6 +681,47 @@ mod tests { assert_eq!(engine.voice_count(), 0); } + #[test] + fn an_fm_chip_started_by_its_play_renders_and_keeps_its_clock() { + let engine = Engine::open_offline(48_000, 2, 8); + let mut port = engine.open_fm(257, Kind::Opl3).expect("an OPL3 opens"); + assert!(port.reset(0.0)); + // A held note on channel 0, OPL3 mode and both outputs on. + for (register, value) in [ + (0x105, 0x01), + (0x20, 0x01), + (0x23, 0x01), + (0x40, 0x3f), + (0x43, 0x00), + (0x63, 0xf0), + (0x83, 0x0f), + (0xc0, 0x30), + (0xa0, 0x41), + (0xb0, 0x32), + ] { + assert!(port.write(register, value, 0.0)); + } + let mut start = play(257, 0); + start.flags = command::FLAG_SYNTH; + engine.submit(&[start]); + assert_eq!(engine.voice_count(), 1); + let mut out = vec![0.0f32; 2 * 512]; + let mut heard = false; + for block in 1..=200u64 { + engine.render(&mut out); + heard |= out.iter().any(|value| *value != 0.0); + // The chip's clock follows the output's, block after block. + let expected = block as f64 * 512.0 / 48_000.0; + assert!( + (port.now() - expected).abs() < 2.0 / 48_000.0, + "block {block}: {}", + port.now() + ); + } + assert!(heard, "the note is rendered"); + assert_eq!(engine.voice_count(), 1); + } + #[test] fn answers_a_position_query_for_a_handle_naming_nothing() { let engine = Engine::open_offline(48_000, 2, 8); diff --git a/native/rust/tecs-audio/src/fm.rs b/native/rust/tecs-audio/src/fm.rs new file mode 100644 index 00000000..bfd1767b --- /dev/null +++ b/native/rust/tecs-audio/src/fm.rs @@ -0,0 +1,660 @@ +//! An emulated OPL2 or OPL3 that plays as one voice of the mixer. +//! +//! The chip is the YM3812 or the YMF262 of [`ymfm_oxide`], a pure-Rust port of +//! ymfm, and what it is driven by is the scheduler the General MIDI +//! synthesizer uses: the same bounded ring, the same schedule sorted by frame +//! and then by sending, the same source the mixer reads. What differs is the +//! grid. A register write takes effect on the very sample it is due at, so the +//! target's block is one frame and an event lands on the first chip sample at +//! or after its time, never later. Writes due on one sample all apply before +//! it is generated, so a key-off and a key-on there are no change at all: a +//! note is struck again by spacing them a sample apart. +//! +//! The chip runs at its own rate, [`RATE`], and the voice that reads it +//! resamples to the output, as it would a clip recorded at that rate. The +//! clock a game schedules against counts chip samples. +//! +//! ``` +//! use tecsaudio::fm::{self, Kind}; +//! use tecsaudio::source::Source; +//! +//! let (mut port, mut source) = fm::open(Kind::Opl2); +//! // Key channel 0 on a quarter of a second in, on the chip's own clock. +//! assert!(port.write(0xb0, 0x32, 0.25)); +//! let mut out = [0.0f32; 2]; +//! source.read(&mut out); +//! ``` + +use ymfm_oxide::opl::{Ym3812, Ymf262}; +use ymfm_oxide::{NullInterface, Output}; + +use crate::synth::{self, Event, Port, SynthSource, Target, KIND_MESSAGE, KIND_RESET}; + +/// The input clock of an OPL2, the NTSC colour burst a sound card divides +/// down: 3.579545 MHz. The OPL3 takes four times it. +pub const OPL2_CLOCK: u32 = 3_579_545; + +/// The input clock of an OPL3: 14.31818 MHz. +pub const OPL3_CLOCK: u32 = 4 * OPL2_CLOCK; + +/// The rate the chip renders at and its clock counts in, in hertz: the input +/// clock over 72 on the OPL2 and over 288 on the OPL3, 49 715.9 Hz, which the +/// emulator truncates to a whole rate. +pub const RATE: u32 = 49_715; + +/// Selects which chip to emulate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + /// The YM3812: nine channels of two operators, four waveforms, mono. + Opl2, + /// The YMF262: eighteen channels that pair into 4-operator voices, eight + /// waveforms, and stereo from the pan bits of 0xC0 to 0xC8. + Opl3, +} + +impl Kind { + /// Returns the highest register address the chip decodes: 0xFF on the + /// OPL2, and 0x1FF on the OPL3, whose second bank is 0x100 up. + pub const fn last_register(self) -> u16 { + match self { + Kind::Opl2 => 0xff, + Kind::Opl3 => 0x1ff, + } + } +} + +/// One emulated chip, written by register and read as a stereo pair. +#[derive(Clone)] +enum Chip { + Opl2(Ym3812), + Opl3(Ymf262), +} + +impl Chip { + /// Creates a chip in its power-on state. + fn new(kind: Kind) -> Chip { + match kind { + Kind::Opl2 => Chip::Opl2(Ym3812::new(NullInterface)), + Kind::Opl3 => Chip::Opl3(Ymf262::new(NullInterface)), + } + } + + fn kind(&self) -> Kind { + match self { + Chip::Opl2(_) => Kind::Opl2, + Chip::Opl3(_) => Kind::Opl3, + } + } + + /// Reports the rate the emulator gives the chip's input clock, which + /// [`RATE`] has to be. + #[cfg(test)] + fn sample_rate(&self) -> u32 { + match self { + Chip::Opl2(chip) => chip.sample_rate(OPL2_CLOCK), + Chip::Opl3(chip) => chip.sample_rate(OPL3_CLOCK), + } + } + + /// Writes `value` to `register` the way a driver does, through the address + /// port and then the data port. + /// + /// An OPL3 register from 0x100 up goes through the second bank's address + /// port (offset 2, data at 3), and the chip itself masks it back to the + /// first bank until the NEW bit in 0x105 is set, as the hardware does. + fn write(&mut self, register: u16, value: u8) { + match self { + Chip::Opl2(chip) => { + chip.write(0, register as u8); + chip.write(1, value); + } + Chip::Opl3(chip) => { + let bank = u32::from(register >> 8 & 1) * 2; + chip.write(bank, register as u8); + chip.write(bank + 1, value); + } + } + } + + /// Generates the next sample as a left and right pair: the OPL2's one + /// output on both sides, the OPL3's outputs A and B, which the pan bits of + /// each channel select. + fn generate(&mut self) -> (i32, i32) { + match self { + Chip::Opl2(chip) => { + let mut out = [Output::<1>::default()]; + chip.generate(&mut out); + (out[0].data[0], out[0].data[0]) + } + Chip::Opl3(chip) => { + let mut out = [Output::<4>::default()]; + chip.generate(&mut out); + (out[0].data[0], out[0].data[1]) + } + } + } + + /// Returns the chip to its power-on state, as `new` makes it. + fn reset(&mut self) { + *self = Chip::new(self.kind()); + } +} + +/// Writes the queue holds between the game thread and the callback, which is +/// also the largest batch `writes` can ever accept. +pub const QUEUE_WRITES: usize = synth::QUEUE_EVENTS; + +/// One register write, as a batch crosses the C ABI. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RegisterWrite { + /// The register, from 0x00 to 0xFF on the OPL2 and 0x000 to 0x1FF on the + /// OPL3, whose second bank is 0x100 up. + pub register: u16, + /// The value, from 0 to 255. Sixteen bits wide so the struct has no + /// padding for two languages to disagree about. + pub value: u16, +} + +/// The chip as a scheduler target. +/// +/// A write event carries the register's low byte in `status`, its bank in +/// `data1` and the value in `data2`. +pub struct Fm { + chip: Chip, +} + +impl Target for Fm { + const BLOCK_FRAMES: usize = 1; + + fn apply(&mut self, event: &Event) { + match event.kind { + KIND_MESSAGE => { + let register = u16::from(event.status) | (u16::from(event.data1) << 8); + self.chip.write(register, event.data2); + } + KIND_RESET => self.chip.reset(), + _ => {} + } + } + + fn render(&mut self, left: &mut [f32], right: &mut [f32]) { + for (l, r) in left.iter_mut().zip(right.iter_mut()) { + let (a, b) = self.chip.generate(); + *l = a as f32 / 32768.0; + *r = b as f32 / 32768.0; + } + } +} + +/// The game side of one chip: where register writes are sent and the clock +/// read. +pub struct FmPort { + port: Port, + kind: Kind, +} + +impl FmPort { + /// Converts a time on the chip clock to the first sample at or after it. + /// + /// A time within a millionth of a sample of a whole sample is that + /// sample, so a time computed as `now() + k / RATE` lands on sample `k` + /// rather than one past it for a rounding error. Zero, a negative time and + /// a time that is not a number all mean the next sample. + fn frame_at(&self, seconds: f64) -> u64 { + if seconds.is_nan() || seconds <= 0.0 { + return 0; + } + let exact = seconds * f64::from(self.port.sample_rate()); + let nearest = exact.round(); + if (exact - nearest).abs() < 1e-6 { + nearest as u64 + } else { + exact.ceil() as u64 + } + } + + fn event(register: u16, value: u8, frame: u64) -> Event { + Event { + frame, + kind: KIND_MESSAGE, + status: register as u8, + data1: (register >> 8) as u8, + data2: value, + } + } + + /// Reports which chip the port drives. + pub fn kind(&self) -> Kind { + self.kind + } + + /// Queues one register write for `at` seconds on the chip clock, and + /// reports whether it was queued: false when the queue is full or the + /// register is past the chip's last. + pub fn write(&mut self, register: u16, value: u8, at: f64) -> bool { + if register > self.kind.last_register() { + return false; + } + let frame = self.frame_at(at); + self.port.push(FmPort::event(register, value, frame)) + } + + /// Queues every write in `writes` for `at`, in order, all or none, and + /// reports which: none when the queue cannot take them all, or when any + /// register is past the chip's last or any value past 255. + pub fn writes(&mut self, writes: &[RegisterWrite], at: f64) -> bool { + let last = self.kind.last_register(); + if writes + .iter() + .any(|write| write.register > last || write.value > 0xff) + { + return false; + } + let frame = self.frame_at(at); + self.port.push_all(writes.len(), |index| { + FmPort::event(writes[index].register, writes[index].value as u8, frame) + }) + } + + /// Queues a return to the chip's power-on state for `at` seconds on the + /// chip clock, where zero or a time already passed means the next sample. + /// + /// Writes sent before the reset and due before `at` still apply; those + /// due at or after it are discarded, so nothing sent earlier plays over + /// the silence the reset makes. Writes sent after it apply as usual. + pub fn reset(&mut self, at: f64) -> bool { + let frame = self.frame_at(at); + self.port.push(Event { + frame, + kind: KIND_RESET, + status: 0, + data1: 0, + data2: 0, + }) + } + + /// Reports the chip clock in seconds: the samples it has rendered over + /// [`RATE`]. + pub fn now(&self) -> f64 { + self.port.now() + } + + /// Reports the samples the chip has rendered. + pub fn frames(&self) -> u64 { + self.port.frames() + } + + /// Reports how many sends the queue refused: a refused batch counts once. + pub fn dropped(&self) -> u64 { + self.port.dropped() + } +} + +/// Builds the two halves of one chip: the port the game writes through and +/// the source the mixer reads. +pub fn open(kind: Kind) -> (FmPort, SynthSource) { + let (port, source) = synth::assemble( + Fm { + chip: Chip::new(kind), + }, + RATE, + ); + (FmPort { port, kind }, source) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::source::Source; + + /// The writes that put a held sine on channel 0 and key it on. Its + /// envelope jumps straight to full and holds, so the note sounds the same + /// wherever the chip's envelope counter happens to stand. + const NOTE: [(u16, u8); 8] = [ + (0x20, 0x01), + (0x23, 0x21), + (0x40, 0x3f), + (0x43, 0x00), + (0x63, 0xf0), + (0x83, 0x0f), + (0xa0, 0x41), + (0xb0, 0x32), + ]; + + /// Reads `frames` from a source in pieces of `piece`, as a voice does, + /// and returns the left channel. + fn pull(source: &mut impl Source, frames: usize, piece: usize) -> Vec { + let mut out = Vec::with_capacity(frames); + let mut staging = vec![0.0f32; piece * 2]; + while out.len() < frames { + let wanted = (frames - out.len()).min(piece); + let got = source.read(&mut staging[..wanted * 2]); + out.extend( + staging[..got * 2] + .as_chunks::<2>() + .0 + .iter() + .map(|frame| frame[0]), + ); + } + out + } + + /// Renders `frames` straight from a chip, applying each write before the + /// sample it names, which is what ymfm's renders mean by a write's time. + fn direct(kind: Kind, writes: &[(u64, u16, u8)], frames: usize) -> Vec { + let mut chip = Chip::new(kind); + let mut next = 0; + (0..frames as u64) + .map(|frame| { + while next < writes.len() && writes[next].0 <= frame { + chip.write(writes[next].1, writes[next].2); + next += 1; + } + chip.generate().0 as f32 / 32768.0 + }) + .collect() + } + + fn timed(frame: u64) -> Vec<(u64, u16, u8)> { + NOTE.iter() + .map(|(register, value)| (frame, *register, *value)) + .collect() + } + + #[test] + fn lands_a_write_on_the_first_sample_at_or_after_its_time() { + let rate = f64::from(RATE); + for (at, frame) in [ + (1000.0 / rate, 1000), + (1000.4 / rate, 1001), + (1000.9 / rate, 1001), + ] { + let (mut port, mut source) = open(Kind::Opl2); + for (register, value) in NOTE { + assert!(port.write(register, value, at)); + } + // A piece size that does not divide anything, so the reads fall + // across the write's sample at an arbitrary offset. + let out = pull(&mut source, 3000, 37); + assert_eq!(out, direct(Kind::Opl2, &timed(frame), 3000), "at {at}"); + assert!(out[..frame as usize].iter().all(|sample| *sample == 0.0)); + assert!(out[frame as usize + 4..] + .iter() + .any(|sample| *sample != 0.0)); + } + } + + #[test] + fn lands_a_time_computed_from_the_clock_on_its_sample() { + let (mut port, mut source) = open(Kind::Opl2); + pull(&mut source, 777, 64); + // `now` and a step of whole samples, each as floating point carries it. + let at = port.now() + 123.0 / f64::from(RATE); + for (register, value) in NOTE { + assert!(port.write(register, value, at)); + } + let out = pull(&mut source, 2000, 64); + let reference = direct(Kind::Opl2, &timed(123), 2000); + assert_eq!(out, reference); + } + + #[test] + fn applies_writes_due_on_one_sample_in_sending_order() { + let (mut port, mut source) = open(Kind::Opl2); + for (register, value) in NOTE { + assert!(port.write(register, value, 0.0)); + } + // Two frequencies for the same sample: the second one sent wins. + let at = 500.0 / f64::from(RATE); + assert!(port.write(0xa0, 0x10, at)); + assert!(port.write(0xa0, 0x80, at)); + let out = pull(&mut source, 1500, 256); + let mut writes = timed(0); + writes.push((500, 0xa0, 0x10)); + writes.push((500, 0xa0, 0x80)); + assert_eq!(out, direct(Kind::Opl2, &writes, 1500)); + writes.truncate(NOTE.len() + 1); + assert_ne!(out, direct(Kind::Opl2, &writes, 1500)); + } + + #[test] + fn orders_writes_by_time_rather_than_by_sending() { + let (mut port, mut source) = open(Kind::Opl2); + let rate = f64::from(RATE); + for (register, value) in NOTE { + assert!(port.write(register, value, 0.0)); + } + assert!(port.write(0xb0, 0x12, 900.0 / rate)); + assert!(port.write(0xa0, 0x99, 300.0 / rate)); + let out = pull(&mut source, 1500, 100); + let mut writes = timed(0); + writes.push((300, 0xa0, 0x99)); + writes.push((900, 0xb0, 0x12)); + assert_eq!(out, direct(Kind::Opl2, &writes, 1500)); + } + + #[test] + fn applies_a_past_or_untimed_write_on_the_next_sample() { + let (mut port, mut source) = open(Kind::Opl2); + pull(&mut source, 640, 64); + assert!(port.write(NOTE[0].0, NOTE[0].1, 0.0)); + assert!(port.write(NOTE[1].0, NOTE[1].1, -3.0)); + for (register, value) in &NOTE[2..] { + assert!(port.write(*register, *value, 1.0 / f64::from(RATE))); + } + let out = pull(&mut source, 1000, 64); + let reference = direct(Kind::Opl2, &timed(0), 1000); + assert_eq!(out, reference); + } + + #[test] + fn plays_the_opl2_on_both_sides_and_the_opl3_from_its_pan_bits() { + let (mut port, mut source) = open(Kind::Opl2); + for (register, value) in NOTE { + assert!(port.write(register, value, 0.0)); + } + let mut out = vec![0.0f32; 2 * 600]; + let mut done = 0; + while done < 600 { + done += source.read(&mut out[done * 2..]); + } + assert!(out + .as_chunks::<2>() + .0 + .iter() + .all(|frame| frame[0] == frame[1])); + assert!(out.iter().any(|sample| *sample != 0.0)); + + let (mut port, mut source) = open(Kind::Opl3); + assert!(port.write(0x105, 0x01, 0.0)); + for (register, value) in NOTE { + assert!(port.write(register, value, 0.0)); + } + // Channel 0 to the left only. + assert!(port.write(0xc0, 0x10, 0.0)); + let mut out = vec![0.0f32; 2 * 600]; + let mut done = 0; + while done < 600 { + done += source.read(&mut out[done * 2..]); + } + assert!(out.as_chunks::<2>().0.iter().any(|frame| frame[0] != 0.0)); + assert!(out.as_chunks::<2>().0.iter().all(|frame| frame[1] == 0.0)); + } + + #[test] + fn refuses_a_register_past_the_chip_and_queues_nothing() { + let (mut opl2, _source) = open(Kind::Opl2); + assert!(!opl2.write(0x100, 0x01, 0.0)); + assert!(opl2.write(0xff, 0x01, 0.0)); + let (mut opl3, _source) = open(Kind::Opl3); + assert!(opl3.write(0x1ff, 0x01, 0.0)); + assert!(!opl3.write(0x200, 0x01, 0.0)); + let bad = [ + RegisterWrite { + register: 0x20, + value: 1, + }, + RegisterWrite { + register: 0x40, + value: 0x100, + }, + ]; + assert!(!opl3.writes(&bad, 0.0)); + assert_eq!( + opl2.dropped() + opl3.dropped(), + 0, + "a refused value is not a drop" + ); + } + + #[test] + fn bounds_the_queue_and_takes_a_batch_whole_or_not_at_all() { + let (mut port, mut source) = open(Kind::Opl2); + let later = 1.0; + let batch: Vec = (0..100) + .map(|index| RegisterWrite { + register: 0xa0, + value: index, + }) + .collect(); + for _ in 0..QUEUE_WRITES - 50 { + assert!(port.write(0x20, 0x01, later)); + } + // Fifty slots are left: the batch of a hundred does not fit and none + // of it is queued, so all fifty singles still are. + assert!(!port.writes(&batch, later)); + for _ in 0..50 { + assert!(port.write(0x20, 0x01, later)); + } + assert!(!port.write(0x20, 0x01, later)); + assert_eq!(port.dropped(), 2); + + // One sample moves the ring into the schedule, which has the room. + pull(&mut source, 1, 1); + assert!(port.writes(&batch, later)); + let too_many = vec![ + RegisterWrite { + register: 0x20, + value: 1, + }; + QUEUE_WRITES + 1 + ]; + let (mut fresh, _source) = open(Kind::Opl2); + assert!( + !fresh.writes(&too_many, 0.0), + "a batch past the queue never fits" + ); + assert!(fresh.writes(&too_many[..QUEUE_WRITES], 0.0)); + } + + #[test] + fn resets_to_power_on_and_discards_what_was_pending() { + let (mut port, mut source) = open(Kind::Opl3); + for (register, value) in NOTE { + assert!(port.write(register, value, 0.0)); + } + pull(&mut source, 1000, 128); + // A note scheduled for later, then a reset sent after it. + assert!(port.write(0xb1, 0x32, 2000.0 / f64::from(RATE))); + assert!(port.reset(0.0)); + let after = pull(&mut source, 3000, 128); + assert!( + after.iter().all(|sample| *sample == 0.0), + "the chip is silent and the note gone" + ); + + // What follows a reset plays on a chip just as a new one would. + for (register, value) in NOTE { + assert!(port.write(register, value, 0.0)); + } + let replay = pull(&mut source, 2000, 128); + assert_eq!(replay, direct(Kind::Opl3, &timed(0), 2000)); + } + + #[test] + fn resets_at_its_time_keeping_what_falls_before_it() { + let rate = f64::from(RATE); + let (mut port, mut source) = open(Kind::Opl2); + for (register, value) in NOTE { + assert!(port.write(register, value, 0.0)); + } + // A frequency change before the reset, one after it, then the reset + // between them, and a write sent after the reset for later still. + assert!(port.write(0xa0, 0x80, 400.0 / rate)); + assert!(port.write(0xa0, 0x20, 900.0 / rate)); + assert!(port.reset(600.0 / rate)); + for (register, value) in NOTE { + assert!(port.write(register, value, 800.0 / rate)); + } + let out = pull(&mut source, 1200, 50); + let mut writes = timed(0); + writes.push((400, 0xa0, 0x80)); + let before = direct(Kind::Opl2, &writes, 600); + assert_eq!(&out[..600], &before[..]); + assert!( + out[600..800].iter().all(|sample| *sample == 0.0), + "the reset silences the chip at its sample" + ); + // The note sent after the reset plays, and the frequency change at 900 + // sent before it does not. + let after = direct(Kind::Opl2, &timed(0), 400); + assert_eq!(&out[800..], &after[..]); + } + + #[test] + fn renders_at_the_rate_the_emulator_gives_each_chip_clock() { + assert_eq!(Chip::new(Kind::Opl2).sample_rate(), RATE); + assert_eq!(Chip::new(Kind::Opl3).sample_rate(), RATE); + } + + #[test] + fn writes_the_second_bank_of_an_opl3_through_its_own_port() { + let (mut port, mut source) = open(Kind::Opl3); + assert!(port.write(0x105, 0x01, 0.0)); + // The note on channel 9, the first of the second bank, centred. + for (register, value) in NOTE { + assert!(port.write(0x100 | register, value, 0.0)); + } + assert!(port.write(0x1c0, 0x31, 0.0)); + let out = pull(&mut source, 600, 64); + assert!(out.iter().any(|sample| *sample != 0.0)); + // Without the NEW bit the chip masks the bank away, so the same + // writes land on channel 0 of the first bank. + let (mut port, mut source) = open(Kind::Opl3); + for (register, value) in NOTE { + assert!(port.write(0x100 | register, value, 0.0)); + } + let out = pull(&mut source, 600, 64); + assert!(out.iter().any(|sample| *sample != 0.0)); + assert_eq!(out, direct(Kind::Opl3, &timed(0), 600)); + } + + #[test] + fn takes_a_key_off_and_on_due_on_one_sample_as_no_change() { + let rate = f64::from(RATE); + let (mut port, mut source) = open(Kind::Opl2); + for (register, value) in NOTE { + assert!(port.write(register, value, 0.0)); + } + // Released and keyed again on sample 500: both apply before it, so + // the note holds as if neither had been sent. + assert!(port.write(0xb0, 0x12, 500.0 / rate)); + assert!(port.write(0xb0, 0x32, 500.0 / rate)); + let out = pull(&mut source, 1500, 64); + assert_eq!(out, direct(Kind::Opl2, &timed(0), 1500)); + } + + #[test] + fn runs_the_clock_in_chip_samples() { + let (port, mut source) = open(Kind::Opl2); + assert_eq!(port.now(), 0.0); + pull(&mut source, RATE as usize / 2, 512); + assert_eq!(port.frames(), u64::from(RATE / 2)); + assert!((port.now() - f64::from(RATE / 2) / f64::from(RATE)).abs() < 1e-12); + assert_eq!(source.sample_rate(), RATE); + assert_eq!(source.channels(), 2); + assert!(!source.exhausted()); + } +} diff --git a/native/rust/tecs-audio/src/lib.rs b/native/rust/tecs-audio/src/lib.rs index 62190e27..9eedd99d 100644 --- a/native/rust/tecs-audio/src/lib.rs +++ b/native/rust/tecs-audio/src/lib.rs @@ -27,6 +27,7 @@ pub mod decode; pub mod device; pub mod engine; pub mod enumerate; +pub mod fm; pub mod mixer; pub mod source; pub mod stream; @@ -39,6 +40,7 @@ use capture::Capture; use command::{Command, Event}; use engine::Engine; use enumerate::DeviceList; +use fm::{FmPort, Kind, RegisterWrite}; use synth::{Port, Settings}; /// What a call answers when it worked. @@ -591,6 +593,150 @@ pub unsafe extern "C" fn tecs_audio_synth_close(synth: *mut Port) { } } +/// Opens an emulated OPL2, or an OPL3 when `opl3` is nonzero, for the voice +/// `handle` names. +/// +/// The chip starts sounding when a `PLAY` carrying the same handle and the +/// synth flag reaches `tecs_audio_submit`, as a General MIDI synthesizer does. +/// Until then writes wait in its queue and its clock stands at zero. +/// +/// # Safety +/// +/// The pointer must name a live device. The returned port is owned by the +/// caller, outlives the device safely, and is freed by `tecs_audio_fm_close`. +/// +/// @return the port, or null with the reason left for `tecs_audio_last_error` +#[no_mangle] +pub unsafe extern "C" fn tecs_audio_fm_open( + device: *mut Engine, + handle: u32, + opl3: i32, +) -> *mut FmPort { + let value = match unsafe { engine(device) } { + None => return std::ptr::null_mut(), + Some(value) => value, + }; + let kind = if opl3 != 0 { Kind::Opl3 } else { Kind::Opl2 }; + match value.open_fm(handle, kind) { + Err(_) => std::ptr::null_mut(), + Ok(port) => Box::into_raw(Box::new(port)), + } +} + +/// Borrows the port a chip pointer names. +/// +/// # Safety +/// +/// The pointer must be one `tecs_audio_fm_open` returned and +/// `tecs_audio_fm_close` has not yet been given. +unsafe fn fm_port<'a>(fm: *mut FmPort) -> Option<&'a mut FmPort> { + if fm.is_null() { + None + } else { + Some(unsafe { &mut *fm }) + } +} + +/// Queues one register write for `at` seconds on the chip clock. +/// +/// A time of zero or less, or one already rendered, means the next sample. +/// +/// # Safety +/// +/// The pointer must name a live port. +/// +/// @return nonzero when the queue took the write, and zero when it was full +/// or the register or value is out of the chip's range +#[no_mangle] +pub unsafe extern "C" fn tecs_audio_fm_write( + fm: *mut FmPort, + register: u32, + value: u32, + at: f64, +) -> i32 { + match unsafe { fm_port(fm) } { + None => 0, + Some(port) => { + let (Ok(register), Ok(value)) = (u16::try_from(register), u8::try_from(value)) else { + return 0; + }; + i32::from(port.write(register, value, at)) + } + } +} + +/// Queues `count` register writes for `at`, all or none. +/// +/// # Safety +/// +/// The pointer must name a live port, and `writes` must point at `count` +/// readable structs, or may be null when `count` is zero. +/// +/// @return nonzero when the queue took every write, and zero when it took +/// none: it could not hold them all, or one was out of range +#[no_mangle] +pub unsafe extern "C" fn tecs_audio_fm_writes( + fm: *mut FmPort, + writes: *const RegisterWrite, + count: u32, + at: f64, +) -> i32 { + let Some(port) = (unsafe { fm_port(fm) }) else { + return 0; + }; + if count == 0 { + return 1; + } + if writes.is_null() { + return 0; + } + let batch = unsafe { std::slice::from_raw_parts(writes, count as usize) }; + i32::from(port.writes(batch, at)) +} + +/// Queues a return to the chip's power-on state for `at` seconds on the chip +/// clock, discarding every write sent before it that falls at or after it. +/// +/// # Safety +/// +/// The pointer must name a live port. +/// +/// @return nonzero when the queue took the reset +#[no_mangle] +pub unsafe extern "C" fn tecs_audio_fm_reset(fm: *mut FmPort, at: f64) -> i32 { + match unsafe { fm_port(fm) } { + None => 0, + Some(port) => i32::from(port.reset(at)), + } +} + +/// Reports the chip clock: the samples it has rendered over its own rate. +/// +/// # Safety +/// +/// The pointer must name a live port. +/// +/// @return seconds, and zero for a null port +#[no_mangle] +pub unsafe extern "C" fn tecs_audio_fm_now(fm: *mut FmPort) -> f64 { + match unsafe { fm_port(fm) } { + None => 0.0, + Some(port) => port.now(), + } +} + +/// Frees a port. The voice it fed is the mixer's to stop, through `STOP`. +/// +/// # Safety +/// +/// The pointer must come from `tecs_audio_fm_open` and must not be used again. +#[no_mangle] +pub unsafe extern "C" fn tecs_audio_fm_close(fm: *mut FmPort) { + if !fm.is_null() { + drop(unsafe { Box::from_raw(fm) }); + } +} + /// Selects the command struct in `tecs_audio_layout`. pub const LAYOUT_COMMAND: u32 = 0; @@ -600,6 +746,9 @@ pub const LAYOUT_EVENT: u32 = 1; /// Selects the clip-info struct in `tecs_audio_layout`. pub const LAYOUT_CLIP_INFO: u32 = 2; +/// Selects the FM register-write struct in `tecs_audio_layout`. +pub const LAYOUT_FM_WRITE: u32 = 3; + /// Reports the size in bytes of one struct crossing the seam. /// /// The binding checks these against what LuaJIT computed for its own `cdef` @@ -615,6 +764,7 @@ pub extern "C" fn tecs_audio_layout(kind: u32) -> u32 { LAYOUT_COMMAND => std::mem::size_of::(), LAYOUT_EVENT => std::mem::size_of::(), LAYOUT_CLIP_INFO => std::mem::size_of::(), + LAYOUT_FM_WRITE => std::mem::size_of::(), _ => 0, }; size as u32 @@ -1231,11 +1381,53 @@ mod tests { } } + #[test] + fn tolerates_a_null_fm_port_and_refuses_what_is_out_of_range() { + let null: *mut FmPort = std::ptr::null_mut(); + unsafe { + assert_eq!(tecs_audio_fm_write(null, 0xb0, 0x20, 0.0), 0); + assert_eq!(tecs_audio_fm_writes(null, std::ptr::null(), 0, 0.0), 0); + assert_eq!(tecs_audio_fm_reset(null, 0.0), 0); + assert_eq!(tecs_audio_fm_now(null), 0.0); + tecs_audio_fm_close(null); + + let device = tecs_audio_open_offline(48_000, 2, 8); + let opl2 = tecs_audio_fm_open(device, 65_537, 0); + assert!(!opl2.is_null()); + assert_eq!(tecs_audio_fm_write(opl2, 0xb0, 0x20, 0.0), 1); + assert_eq!(tecs_audio_fm_write(opl2, 0x100, 0x20, 0.0), 0); + assert_eq!(tecs_audio_fm_write(opl2, 0xb0, 0x100, 0.0), 0); + let batch = [ + RegisterWrite { + register: 0xa0, + value: 0x41, + }, + RegisterWrite { + register: 0xb0, + value: 0x32, + }, + ]; + assert_eq!(tecs_audio_fm_writes(opl2, batch.as_ptr(), 2, 0.0), 1); + assert_eq!(tecs_audio_fm_writes(opl2, std::ptr::null(), 0, 0.0), 1); + assert_eq!(tecs_audio_fm_writes(opl2, std::ptr::null(), 2, 0.0), 0); + assert_eq!(tecs_audio_fm_reset(opl2, 0.0), 1); + assert_eq!(tecs_audio_fm_now(opl2), 0.0); + let opl3 = tecs_audio_fm_open(device, 65_538, 1); + assert_eq!(tecs_audio_fm_write(opl3, 0x1b0, 0x20, 0.0), 1); + tecs_audio_close(device); + // A port outlives its device. + assert_eq!(tecs_audio_fm_write(opl2, 0xb0, 0x00, 0.0), 1); + tecs_audio_fm_close(opl2); + tecs_audio_fm_close(opl3); + } + } + #[test] fn reports_the_size_of_every_struct_crossing_the_seam() { assert_eq!(tecs_audio_layout(LAYOUT_COMMAND), 48); assert_eq!(tecs_audio_layout(LAYOUT_EVENT), 8); assert_eq!(tecs_audio_layout(LAYOUT_CLIP_INFO), 8); + assert_eq!(tecs_audio_layout(LAYOUT_FM_WRITE), 4); assert_eq!(tecs_audio_layout(99), 0); } diff --git a/native/rust/tecs-audio/src/synth.rs b/native/rust/tecs-audio/src/synth.rs index 46e2c1c3..81e5254d 100644 --- a/native/rust/tecs-audio/src/synth.rs +++ b/native/rust/tecs-audio/src/synth.rs @@ -21,6 +21,12 @@ //! late, and where it lands depends only on its frame, which is what makes the //! output a deterministic function of the schedule. //! +//! The ring, the schedule and the source are not particular to General MIDI. +//! Anything that implements [`Target`] plays through them, and each target +//! names its own grid: eight frames for `rustysynth`, and one for the OPL +//! emulator in [`crate::fm`], whose register writes take effect on the very +//! sample they are due at. +//! //! ```no_run //! use tecsaudio::synth::{self, Settings}; //! @@ -112,8 +118,12 @@ impl Default for Settings { } /// What a synthesizer is driven through, so the scheduler can be tested -/// without a SoundFont. +/// without a SoundFont and can drive more than one kind of synthesizer. pub trait Target: Send { + /// The frames between the points events can be applied at, which is the + /// grid an event's frame is rounded up to. + const BLOCK_FRAMES: usize; + /// Applies one event at the current block boundary. fn apply(&mut self, event: &Event); @@ -127,6 +137,8 @@ pub struct GeneralMidi { } impl Target for GeneralMidi { + const BLOCK_FRAMES: usize = BLOCK_FRAMES; + fn apply(&mut self, event: &Event) { match event.kind { KIND_MESSAGE => { @@ -215,6 +227,33 @@ impl Producer { .store(tail.wrapping_add(1), Ordering::Release); true } + + /// Appends `count` events, the one `event` makes for each index, or none + /// of them when the ring cannot hold them all. + /// + /// The tail moves once, after the last is written, so the consumer sees + /// either the whole batch or nothing of it. + fn push_all(&mut self, count: usize, mut event: impl FnMut(usize) -> Event) -> bool { + let tail = self.ring.tail.load(Ordering::Relaxed); + let head = self.ring.head.load(Ordering::Acquire); + let capacity = self.ring.slots.len(); + if count > capacity - tail.wrapping_sub(head) { + return false; + } + for index in 0..count { + let slot = tail.wrapping_add(index) % capacity; + // SAFETY: every slot from `tail` for `count` lies outside + // `head..tail`, since the check above left room for all of them, + // so the consumer is not reading any of them. + unsafe { + (*self.ring.slots[slot].get()).write(event(index)); + } + } + self.ring + .tail + .store(tail.wrapping_add(count), Ordering::Release); + true + } } /// The callback half of the ring. @@ -267,7 +306,8 @@ impl Port { (seconds * self.sample_rate as f64).round() as u64 } - fn push(&mut self, event: Event) -> bool { + /// Queues one event and reports whether the queue had room for it. + pub(crate) fn push(&mut self, event: Event) -> bool { if self.producer.push(event) { return true; } @@ -275,6 +315,21 @@ impl Port { false } + /// Queues `count` events, the one `event` makes for each index, all or + /// none, and reports which. A refused batch counts as one drop. + pub(crate) fn push_all(&mut self, count: usize, event: impl FnMut(usize) -> Event) -> bool { + if self.producer.push_all(count, event) { + return true; + } + self.dropped += 1; + false + } + + /// Reports the rate the clock counts frames at. + pub(crate) fn sample_rate(&self) -> u32 { + self.sample_rate + } + /// Queues a short MIDI message for `at` seconds on the synthesizer clock, /// and reports whether the queue had room for it. pub fn send(&mut self, status: u8, data1: u8, data2: u8, at: f64) -> bool { @@ -302,6 +357,11 @@ impl Port { /// Queues a reset for the next block boundary. Every event sent before it /// and not yet applied is discarded when the callback reaches it. + /// + /// The scheduler also takes a reset with a frame, which is how + /// [`crate::fm::FmPort::reset`] schedules one: events sent before it and + /// due before its frame still apply, and those due at or after it are + /// discarded. pub fn reset(&mut self) -> bool { self.push(Event { frame: 0, @@ -358,15 +418,18 @@ impl SynthSource { let Some(mut event) = self.consumer.pop() else { return; }; - if event.kind == KIND_RESET { - // A reset undoes everything sent before it, including notes - // scheduled for later. Leaving those would play them over the - // silence the reset was sent to make. - self.schedule.clear(); - } // A time already rendered becomes the next boundary, and sending // order is kept among events that land on the same one. event.frame = event.frame.max(self.frames); + if event.kind == KIND_RESET { + // A reset discards everything sent before it that is due at + // or after it, notes scheduled for later included: leaving + // those would play them over the silence the reset was sent + // to make. What is due before it still applies first, as it + // would have without the reset. + let at = event.frame; + self.schedule.retain(|queued| queued.frame < at); + } let at = self .schedule .partition_point(|queued| queued.frame <= event.frame); @@ -396,11 +459,12 @@ impl Source for SynthSource { self.sample_rate } - /// Renders up to the next internal block boundary. + /// Renders up to the next boundary of the target's grid. /// /// One block per call rather than as much as `out` holds, so a voice reads /// at most one block ahead of the output and an event sent now reaches the - /// next block rather than one a whole staging buffer later. + /// next block rather than one a whole staging buffer later. On a grid of + /// one frame that is a frame per call. fn read(&mut self, out: &mut [f32]) -> usize { let wanted = out.len() / 2; if wanted == 0 { @@ -410,14 +474,14 @@ impl Source for SynthSource { if self.phase == 0 { self.apply_due(); } - let count = (BLOCK_FRAMES - self.phase).min(wanted); + let count = (T::BLOCK_FRAMES - self.phase).min(wanted); let (left, right) = (&mut self.left[..count], &mut self.right[..count]); self.target.render(left, right); for (index, (l, r)) in left.iter().zip(right.iter()).enumerate() { out[index * 2] = *l; out[index * 2 + 1] = *r; } - self.phase = (self.phase + count) % BLOCK_FRAMES; + self.phase = (self.phase + count) % T::BLOCK_FRAMES; self.frames += count as u64; self.shared.frames.store(self.frames, Ordering::Release); count @@ -464,8 +528,8 @@ pub fn assemble(target: T, sample_rate: u32) -> (Port, SynthSource frames: 0, phase: 0, sample_rate, - left: vec![0.0; BLOCK_FRAMES], - right: vec![0.0; BLOCK_FRAMES], + left: vec![0.0; T::BLOCK_FRAMES], + right: vec![0.0; T::BLOCK_FRAMES], }; (port, source) } @@ -529,6 +593,8 @@ mod tests { } impl Target for Recorder { + const BLOCK_FRAMES: usize = BLOCK_FRAMES; + fn apply(&mut self, event: &Event) { self.applied .lock() diff --git a/native/rust/tecs-audio/tests/fm.rs b/native/rust/tecs-audio/tests/fm.rs new file mode 100644 index 00000000..0751e0ad --- /dev/null +++ b/native/rust/tecs-audio/tests/fm.rs @@ -0,0 +1,168 @@ +//! An emulated OPL as a voice of the mixer, with no audio device. +//! +//! The emulator is the ymfm-oxide crate, which holds itself to ymfm sample for +//! sample, and the scheduling grid is tested in `src/fm.rs`; this covers what lies between them and the output: the parked +//! source a `PLAY` takes, resampling from the chip's rate, the voice gain, and +//! a clock that stops with the voice. + +use tecsaudio::command::{self, Command}; +use tecsaudio::engine::Engine; +use tecsaudio::fm::{FmPort, Kind, RegisterWrite, RATE as CHIP_RATE}; + +const RATE: u32 = 48_000; + +/// A held sine on channel 0 at 440 Hz, keyed on. +const NOTE: [(u16, u16); 8] = [ + (0x20, 0x01), + (0x23, 0x21), + (0x40, 0x3f), + (0x43, 0x00), + (0x63, 0xf0), + (0x83, 0x0f), + (0xa0, 0x41), + (0xb0, 0x32), +]; + +fn command(kind: u32, handle: u32) -> Command { + Command { + kind, + handle, + clip: 0, + flags: 0, + gain: 1.0, + pitch: 1.0, + start: 0.0, + fade: 0.0, + loop_start: 0.0, + x: 0.0, + y: 0.0, + z: 0.0, + } +} + +fn started(kind: Kind, gain: f32) -> (Engine, FmPort) { + let engine = Engine::open_offline(RATE, 2, 8); + let port = engine.open_fm(1, kind).expect("the chip opens"); + let mut play = command(command::PLAY, 1); + play.flags = command::FLAG_SYNTH; + play.gain = gain; + engine.submit(&[play]); + assert_eq!(engine.voice_count(), 1); + (engine, port) +} + +fn note() -> Vec { + NOTE.iter() + .map(|(register, value)| RegisterWrite { + register: *register, + value: *value, + }) + .collect() +} + +fn render(engine: &Engine, frames: usize) -> Vec { + let mut out = vec![0.0f32; frames * 2]; + for chunk in out.chunks_mut(512 * 2) { + engine.render(chunk); + } + out +} + +fn onset(out: &[f32]) -> Option { + out.iter() + .position(|sample| *sample != 0.0) + .map(|index| index / 2) +} + +#[test] +fn starts_nothing_for_an_fm_play_with_no_chip_parked() { + let engine = Engine::open_offline(RATE, 2, 8); + let mut play = command(command::PLAY, 9); + play.flags = command::FLAG_SYNTH; + engine.submit(&[play]); + assert_eq!(engine.voice_count(), 0); +} + +#[test] +fn plays_a_scheduled_note_resampled_to_the_output() { + let (engine, mut port) = started(Kind::Opl2, 1.0); + assert_eq!(port.now(), 0.0); + // A tenth of a second in, on the chip clock. + assert!(port.writes(¬e(), 0.1)); + let out = render(&engine, 9600); + let start = onset(&out).expect("the note sounds"); + // The chip lands the note on sample 4972 of its own clock, which the + // voice reaches at output frame 4972 * 48000 / 49715, give or take the + // resampler's one frame of interpolation. + let expected = (4972.0 * f64::from(RATE) / f64::from(CHIP_RATE)) as usize; + assert!( + (expected..=expected + 2).contains(&start), + "the note starts at {start}" + ); + // Mono on both sides. + assert!(out + .as_chunks::<2>() + .0 + .iter() + .all(|frame| frame[0] == frame[1])); + // The clock is chip samples, running at most a frame or two ahead of the + // mix it fed. + let rendered = port.frames() as f64 / f64::from(CHIP_RATE); + assert!( + (rendered - 9600.0 / f64::from(RATE)).abs() < 3.0 / f64::from(RATE), + "{rendered}" + ); +} + +#[test] +fn renders_the_same_schedule_to_the_same_samples() { + let mut takes = Vec::new(); + for _ in 0..2 { + let (engine, mut port) = started(Kind::Opl3, 1.0); + // NEW on, and channel 0 panned to both sides, which with NEW on it + // has to be to sound at all. + assert!(port.write(0x105, 0x01, 0.0)); + assert!(port.write(0xc0, 0x30, 0.0)); + for step in 0..4 { + let at = f64::from(step) * 0.05; + assert!(port.writes(¬e(), at)); + assert!(port.write(0xa0, 0x41 + step as u8 * 0x20, at + 0.01)); + assert!(port.write(0xb0, 0x12, at + 0.04)); + } + takes.push(render(&engine, RATE as usize / 4)); + } + assert!(takes[0].iter().any(|sample| *sample != 0.0)); + assert_eq!(takes[0], takes[1]); +} + +#[test] +fn scales_the_chip_by_its_voice_gain() { + let mut levels = Vec::new(); + for gain in [1.0f32, 0.5, 0.0] { + let (engine, mut port) = started(Kind::Opl2, 1.0); + let mut set = command(command::SET_GAIN, 1); + set.gain = gain; + engine.submit(&[set]); + assert!(port.writes(¬e(), 0.0)); + levels.push(render(&engine, 4800)); + } + assert!(levels[0].iter().any(|sample| *sample != 0.0)); + for (full, half) in levels[0].iter().zip(levels[1].iter()) { + assert!((full * 0.5 - half).abs() < 1e-6); + } + assert!(levels[2].iter().all(|sample| *sample == 0.0)); +} + +#[test] +fn holds_the_clock_while_the_voice_is_paused() { + let (engine, port) = started(Kind::Opl2, 1.0); + render(&engine, 1024); + let before = port.frames(); + assert!(before > 0); + engine.submit(&[command(command::PAUSE, 1)]); + render(&engine, 4800); + assert_eq!(port.frames(), before, "a paused voice does not advance"); + engine.submit(&[command(command::RESUME, 1)]); + render(&engine, 1024); + assert!(port.frames() > before); +} From 2de3f153bfb4844dd34ca389c107dd9fbd6b6f41 Mon Sep 17 00:00:00 2001 From: Alban Gicquel <6368237+albanmgicquel@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:41:41 -0400 Subject: [PATCH 4/7] tecs.audio.fm: a register-level OPL2 or OPL3 on a mixer voice new(audio, {chip = "opl2" | "opl3", group, gain}) with write(register, value, at?), writes({reg, val, ...}, at?), reset(at?), now, setGain, destroy and a read-only rate of 49715. chip defaults to "opl2". Writing methods return whether the writes were queued; a register or value out of the chip's range raises, writes checks every pair before queuing any, and a batch past 4096 pairs is refused. A time within a millionth of a sample of a whole sample counts as it. The chip takes its voice slot through the same internal lender the synth uses. The backend contract gains FmChip, FmPort and an optional openFm; a backend without it and a mixer without an output get silentFm. README records why the interface is registers only, the per-target grid and the same-sample rule, what a timed reset discards, the chip rate, and why the emulator is the ymfm-oxide crate rather than source in this tree. --- AGENTS.md | 4 +- README.md | 87 ++++++- nupp.lua | 1 + src/tecs/audio/fm.nupp | 382 ++++++++++++++++++++++++++++ src/tecs/internal/audiosynth.nupp | 10 +- src/tecs/platform/audiobackend.nupp | 74 +++++- src/tecs/platform/audionative.nupp | 112 +++++++- tests/audiofmtest.nupp | 327 ++++++++++++++++++++++++ 8 files changed, 982 insertions(+), 15 deletions(-) create mode 100644 src/tecs/audio/fm.nupp create mode 100644 tests/audiofmtest.nupp diff --git a/AGENTS.md b/AGENTS.md index a698de76..8139ed9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,14 +108,14 @@ tecs/ │ ├── platform/ # Window, events, audio and gamepad backends │ ├── physics/ # Rapier contract and binding │ ├── input.nupp # Gameplay input over the platform event stream -│ ├── audio/ # Clips, voices, groups, limits, the Sound component, General MIDI +│ ├── audio/ # Clips, voices, groups, limits, the Sound component, General MIDI, OPL FM │ ├── sequence.nupp # Sequencer, with the tween runtime inside it │ ├── assets.nupp # Asset orchestration │ ├── mcp.nupp # Debug server: transport, tools, sandbox │ └── events.nupp, files.nupp, watch.nupp ├── native/rust/ │ ├── winit-host/ # The application loop, the wgpu renderer, the host ABI -│ ├── tecs-audio/ # cpal output and capture behind a batched contract +│ ├── tecs-audio/ # cpal output and capture behind a batched contract; General MIDI and OPL voices │ ├── tecs-gamepad/ # gilrs enumeration and observations │ ├── physics/ # Rapier storage and stepping │ └── build-support/ # Native packaging and binary inspection diff --git a/README.md b/README.md index c03f0cc8..8131a4f7 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,9 @@ test suite reach the same world without starting a graphics stack. - Camera, layers with positioning modes, images, clips, sprite sheets with animation, and distance-field text. - Input across keyboard, pointer, gamepad and touch; audio playback, recording - and device enumeration; General MIDI from a SoundFont; physics over Rapier; - sequencing with tweening; asset orchestration; file watching; and an MCP - debug server. + and device enumeration; General MIDI from a SoundFont; an emulated OPL2 and + OPL3 FM chip; physics over Rapier; sequencing with tweening; asset + orchestration; file watching; and an MCP debug server. - [Tiled TMX maps and TSX tilesets](docs/tiled/index.md), animated tile and image layers, object factories, runtime tile edits and authored collision outlines. @@ -532,6 +532,87 @@ Nothing above the port changes. It is deferred because nothing needs it yet, and what it costs is the determinism above: a hardware synth cannot be rendered offline. +## FM synthesis is a register-level chip, emulated by a Rust crate + +`tecs.audio.fm` is a Yamaha OPL2 (YM3812) or OPL3 (YMF262) a game drives by +writing its registers, played as one voice of the mixer on the same scheduler +as the General MIDI synth. It is for the music and effects of the DOS era, +which were written as streams of register writes to an AdLib or a Sound +Blaster, and a port of such a game wants to replay what its driver wrote, not +an approximation of it. + +The interface is registers only: `write`, `writes` for a tick's batch, `reset` +and a clock. A note-and-instrument layer was the alternative, and it lost +because it is the part that differs between games. Every driver had its own +instrument format, its own channel allocation and its own idea of a note, and +an engine layer would have had to pick one or grow into a superset of all of +them. The chip is the one contract they all shared. A game that wants notes +writes a driver for its own format above this, in Nupp, and gets exactly the +sound that format made. Status and timer reads are left out for the same +reason they are left out of the scheduler: a driver that paced itself on the +chip's timers paces itself on `now` instead, and a read would need an answer +from the audio thread inside the call. + +Writes land on the first chip sample at or after their time, a grid of one +rather than the synth's eight, because a register write takes effect on the +next sample the chip computes and a driver's timing is part of its sound: a +key-off and key-on a sample apart is a retrigger, and moving it eight samples +changes the attack. The scheduler names its grid per target to allow that, +eight frames for the synth and one for the chip, and the ring, the schedule +and the source are otherwise the synth's, so the queue bound and the clock +behave the same way. Writes due on one sample all apply before that sample is +generated, in sending order, so a key-off and a key-on there count as no +change: a driver that re-strikes a note spaces the two writes one sample apart, +which is what the hardware's write timing did anyway. `writes` is all or +nothing, because a tick applied in part plays a note with half its instrument. + +A reset carries a time like a write. Writes sent before it and due before it +still apply; those due at or after it are discarded, so nothing scheduled +earlier plays over the silence it makes, and writes sent after it apply as +usual, at its own sample included. The synth's reset, which lands at its next +block, keeps its old meaning; the chip's needed a time because a driver +restarting a song schedules the restart rather than taking it whenever the +next buffer begins. + +The chip renders at its own rate and the mixer voice resamples it as it would +a clip, so the clock counts chip samples and the output rate does not leak +into a driver's timing. The rate is the input clock over 72 on the OPL2, +3.579545 MHz, and over 288 on the OPL3, four times that: 49 715.9 Hz, which +the emulator truncates to 49715. Rounding it to 49716 instead, as an earlier +draft did, would be closer by under twenty parts per million, which is a +thirtieth of a cent of pitch and under a tenth of a second an hour of tempo, +below anything heard; the clock simply has to agree with the emulator, and a test +holds `rate` to what the emulator reports for both chips. + +The emulator is ymfm-oxide (), a +pure-Rust, `no_std`, dependency-free port of ymfm, Aaron Giles's BSD-3-Clause +emulator of the Yamaha FM family, used for its YM3812 and YMF262: OPL3 +4-operator pairs, all eight waveforms, the NEW bit, stereo and rhythm mode. +Linking ymfm's C++ was the alternative, and it lost to the build: every other +native service here is Cargo only, and a C++ toolchain on every platform, +Windows included, for one module was not a cost worth carrying. ymfm was the +source because it is permissively licensed and written to be read, which is +what makes a port checkable. The candidates under the LGPL or the GPL, +Nuked-OPL3 and DOSBox's among them, are excluded by the licence rule in +`THIRD_PARTY_NOTICES.md`, and so is anything developed from them. + +The port lives in its own crate rather than in this tree, and it was first +written here. Kept here, Tecs carried a few thousand lines of BSD-3-Clause +source with its own notices, its golden renders and a C++ oracle, none of it +about the engine; as a crate it is one dependency with one notice, its check +lives with its code, and the other chips ymfm models come with it for whoever +wants them. The crate holds itself to ymfm sample for sample with golden +renders of synthetic register logs, made by ymfm through an oracle program, +and a randomized differential test against it. It is a git dependency pinned +to a full commit, and `Cargo.lock` records that commit, so a build cannot pick +up a different emulator without a change here. `tecs-audio` drives it through +the chips' own ports: a register below 0x100 through the address port at +offset 0 and the data port at 1, one from 0x100 up through the OPL3's +second-bank address port at 2 and the data port at 3, which is how the chip +itself folds a second-bank write back to the first until the NEW bit is set. +The OPL3's left and right are its outputs A and B; the OPL2 is mono and plays +its one output on both sides. + ## Device vocabularies follow the library that reports them Key names, mouse button names and gamepad button and axis names came from SDL diff --git a/nupp.lua b/nupp.lua index 9f6d48b5..f700658a 100644 --- a/nupp.lua +++ b/nupp.lua @@ -234,6 +234,7 @@ return { "tecs.internal.assetnative", "tecs.assets", "tecs.audio", + "tecs.audio.fm", "tecs.audio.synth", "tecs.internal.audiosynth", "tecs.ecs", diff --git a/src/tecs/audio/fm.nupp b/src/tecs/audio/fm.nupp new file mode 100644 index 00000000..e2c815c9 --- /dev/null +++ b/src/tecs/audio/fm.nupp @@ -0,0 +1,382 @@ +module tecs.audio.fm + +--[[ +An emulated Yamaha OPL2 or OPL3 FM chip, played as one voice of the mixer. + +An [`Fm`](tecs.audio.fm.Fm) takes one voice slot the way `Audio.play` takes +one, so it counts against `maxVoices`, joins a group, and follows group gain, +group mute, group pause, master gain and master mute like any other voice. +What it adds is the chip's register file: a game drives it with the same +register writes a DOS driver sent to an AdLib or a Sound Blaster, and the +emulator renders what the chip would have played. + +```nupp +local mixer = tecs.audio.newAudio() +local chip = assert(tecs.audio.fm.new(mixer, {chip = "opl2", group = "music"})) + +-- A held sine on channel 0 at 440 Hz, keyed on a tenth of a second ahead. +local at = chip:now() + 0.1 +chip:writes({ + 0x20, 0x01, 0x23, 0x21, -- multiple 1 on both operators, carrier sustains + 0x40, 0x3F, 0x43, 0x00, -- modulator silent, carrier at full level + 0x63, 0xF0, 0x83, 0x0F, -- carrier: instant attack, no decay, fast release + 0xA0, 0x41, 0xB0, 0x32, -- frequency number 0x241, block 4, key on +}, at) +chip:write(0xB0, 0x12, at + 0.5) -- key off half a second later +``` + +The interface is registers and nothing else: no notes, no instruments, no +channels allocated on a game's behalf. That is the whole contract of the real +chip, and it is what lets a port of a game that drove one replay its music +driver's output unchanged, timing and all. A note-level layer belongs above +this, in the game or in a driver written for it. + +## The chips + +`"opl2"` is the YM3812: nine 2-operator channels, four waveforms once bit 5 of +register 0x01 enables them, and rhythm mode through 0xBD. Its registers run +from 0x00 to 0xFF and it is mono, so it plays the same on both sides. + +`"opl3"` is the YMF262: eighteen channels in two banks, the second at 0x100 to +0x1FF, eight waveforms, and 4-operator voices from pairs of channels that +register 0x104 joins. Most of that waits on the NEW bit, bit 0 of 0x105: until +a game sets it, the chip behaves as an OPL2 with a second bank it cannot reach, +since the chip itself folds a write to 0x1xx back onto bank 0. Once it is set, +each channel's output is chosen by bits 4 and 5 of its 0xC0 register, left and +right, and a channel with neither set is silent. Bits 6 and 7 select the +chip's third and fourth outputs, which a stereo card did not wire to anything, +and neither do these. + +Status and timer reads are not offered. A driver that polled the timers to pace +itself paces itself on `now` instead. + +The chip runs at its own rate, `rate` samples a second, which is 49715: the +input clock over 72 on the OPL2 and over 288 on the OPL3, 49 715.9 Hz, as the +emulator truncates it. The mixer resamples it to the output's. + +## The clock and `at` + +Every writing method takes an optional `at`, in seconds on the chip's own +clock, which `now` reads. The clock is the samples the chip has rendered over +`rate`, so it is the audio clock rather than the frame clock: it does not drift +against what is heard, it never runs backwards, and it stands still while +nothing renders the chip, which is the case before the first output buffer, +while its group is paused, and for good on a mixer with no output. + +A write lands on the first chip sample at or after `at`, and where it lands +depends only on `at`, so the same schedule renders the same samples on every +run and every machine, whatever the frame rate did. A time within a millionth +of a sample of a whole sample counts as that sample, so `now() + n / rate` +lands on sample `n` from now despite rounding. Writes landing on the same +sample apply together, in the order they were sent, before that sample is +generated. So a key-off and a key-on there count as no change: to strike a +note again, space the writes one sample apart, `1 / rate`. + +An omitted `at`, and one already in the past, means the next sample. Sending +each write as it falls due therefore works, but puts it wherever the next +output buffer happens to start, so its timing follows the frame rate. A music +driver sends a little ahead instead: run its tick against `now` with a +lookahead of a frame or two, say 50 milliseconds, and stamp each tick's writes +with the tick's time. + +## The queue + +Writes cross to the audio thread through a bounded queue the output callback +never waits on, and that thread holds as many again in its schedule for writes +sent ahead of their time. Either is 4096 writes. A write that finds no room is +refused rather than waiting or growing anything: the method returns false and +the write is gone. `writes` is all or nothing, so a tick's writes are never +applied in part, and a batch of more than 4096 can never be taken. A driver +streaming with a short lookahead never comes near the bound. + +## Without an output + +A mixer with no output device, or one made with a test backend, still makes a +chip whose every call succeeds. On a mixer with no output nothing renders, so +nothing is heard and `now` stays at zero. A test backend that supplies `openFm` +receives every write through the [`FmPort`](tecs.platform.audiobackend.FmPort) +it returns, which is how a harness logs them; one that does not gets the silent +port, which accepts and drops them. + +## The emulator + +The emulation is the YM3812 and YMF262 of ymfm-oxide, a pure-Rust port of +ymfm, Aaron Giles's BSD-3-Clause emulator of the Yamaha FM chips, which the +crate holds to ymfm sample for sample. Tecs links it as a Cargo dependency +pinned to one commit, and `THIRD_PARTY_NOTICES.md` carries its licence. +]] + +local audio = require("tecs.audio") +local audiobackend = require("tecs.platform.audiobackend") +local audiosynth = require("tecs.internal.audiosynth") + +--- The rate the chip renders at, in samples a second: `tecsaudio::fm::RATE`, +--- which a Rust test holds to what the emulator reports for both chips. +local RATE: integer = 49715 + +--- The writes the queue holds, which is also the largest batch `writes` takes. +local QUEUE_WRITES: integer = 4096 + +--- The last register each chip decodes. +local LAST_REGISTER: {[string]: integer} = {opl2 = 0xFF, opl3 = 0x1FF} + +--- Names a chip `new` can make: the YM3812 (OPL2) or the YMF262 (OPL3). +export type Chip = audiobackend.FmChip + +--- Configures `new`. +--- +--- Read once, when the chip is made. Nothing here is retained. +export type Options = { + --- Caller-writable. Selects the chip, `"opl2"` or `"opl3"`, and defaults + --- to `"opl2"`. + chip: Chip?, + + --- Caller-writable. Selects the group the chip's voice joins, so the + --- group's gain, mute and pause apply to it. Defaults to none. + group: string?, + + --- Caller-writable. Sets the voice's linear gain before group and master + --- gain, and defaults to one. + gain: number? +} + +--- One emulated FM chip, playing as one voice of a mixer. +--- +--- Every writing method returns whether its writes were queued, which is false +--- only when the bounded queue is full or the chip has been destroyed. None of +--- them raises for either, because a write that cannot be queued is not a +--- reason for a frame to stop. A register or value outside the chip's range +--- raises, because it is a mistake in the call. Nothing here is thread safe: +--- call it from the thread that owns the mixer. +export record Fm + --- Read-only. Reports the rate the chip renders at, 49715 samples a + --- second, which is the unit of its clock. One sample is `1 / rate` + --- seconds of `at`. + rate: integer + + --- Read-only. Reports which chip this is, `"opl2"` or `"opl3"`. + chip: Chip + + @private mixer: audio.Audio + @private port: audiobackend.FmPort + @private voice: integer + @private destroyed: boolean + @private lastRegister: integer + + write: function(exclusive self: Fm, register: integer, value: integer, at: number?): boolean + writes: function(exclusive self: Fm, pairs: {integer}, at: number?): boolean + reset: function(exclusive self: Fm, at: number?): boolean + now: function(borrows self: Fm): number + setGain: function(exclusive self: Fm, gain: number): nil + destroy: function(exclusive self: Fm): nil +end + +--- Returns what `tecs.audio` lent the synthesizer modules for one mixer. +local function lender(mixer: audio.Audio): audiosynth.Lender + return assert(audiosynth.lent[mixer], "tecs.audio registered no synthesizer seam for this mixer") +end + +--- Writes a register address the way the chip's documentation does. +local function hex(value: integer): string + if value < 0 then + return tostring(value) + end + + return string.format("0x%X", value) +end + +--- Raises unless a register and value are in range for the chip. +--- @raises string when the register is past the chip's last or the value is +--- outside 0 to 255 +local function check(name: string, borrows self: Fm, register: integer, value: integer): nil + local where = "tecs.audio.fm." .. name .. ": " + if register < 0 or register > self.lastRegister then + local range = "from 0x00 to " .. hex(self.lastRegister) + error(where .. "an " .. self.chip .. " register must be " .. range .. ", got " .. hex(register), 3) + end + if value < 0 or value > 0xFF then + error(where .. "a register value must be from 0 to 255, got " .. tostring(value), 3) + end +end + +--- Makes an emulated FM chip and starts its voice. +--- +--- The voice starts sounding with the mixer's next flush, which `update` makes +--- once per frame, and writes sent before then wait for it. The chip starts in +--- its power-on state, every register zero, and sounds until `destroy`. A voice +--- `stopAll` or `stopGroup` ended does not come back: make another chip +--- instead. +--- +--- @param mixer the mixer the voice plays on +--- @param options the chip, group and gain, or nil for an OPL2 in no group at +--- a gain of one +--- @return the chip, or nil when its voice could not start. A mixer with no +--- output is not such a case: it returns a chip that renders nothing. +--- @return the reason, when the first return is nil: every voice is busy, or +--- the backend refused the chip +--- @raises string when `chip` is neither `"opl2"` nor `"opl3"` +export function new(mixer: audio.Audio, options: Options?): (Fm?, string?) + local chip = (options and options.chip) or "opl2" + local lastRegister = LAST_REGISTER[chip] + if lastRegister == nil then + error("tecs.audio.fm.new: chip must be \"opl2\" or \"opl3\", got " .. tostring(chip), 2) + end + + local function made(port: audiobackend.FmPort, voice: integer): Fm + return new Fm( + rate = RATE, + chip = chip, + mixer = mixer, + port = port, + voice = voice, + destroyed = false, + lastRegister = lastRegister + ) + end + + local seam = lender(mixer) + local voice = seam.start((options and options.gain) or 1, options and options.group) + if voice == 0 then + if mixer.available then + return nil, "every voice is busy, so the FM chip has none to play through" + end + + return made(audiobackend.silentFm(), 0) + end + + local open = seam.backend().openFm + if open == nil then + return made(audiobackend.silentFm(), voice) + end + -- Opened before the voice's start is flushed, because the start is what + -- hands the chip to the mixer. + local port, reason = open(voice, chip) + if port == nil then + mixer:stop(voice) + + return nil, reason or "the backend opened no FM chip" + end + + return made(port, voice) +end + +--- Queues one register write. +--- +--- @param self the chip to write to +--- @param register from 0x00 to 0xFF on an OPL2, and to 0x1FF on an OPL3, +--- whose second bank is 0x100 up +--- @param value from 0 to 255 +--- @param at seconds on the chip clock, where nil or a time already passed +--- means the next sample +--- @return whether the write was queued, false when the queue is full or the +--- chip is destroyed +--- @raises string when the register or the value is outside its range +function Fm.write(exclusive self: Fm, register: integer, value: integer, at: number?): boolean + check("write", self, register, value) + if self.destroyed then + return false + end + + return self.port.write(register, value, at or 0) +end + +--- Queues a batch of register writes for one time, all or none. +--- +--- The batch is what one tick of a music driver writes, and taking it whole is +--- the point: a tick applied in part plays a note with half its instrument. +--- Every pair is checked before anything is queued. +--- +--- @param self the chip to write to +--- @param pairs a flat list alternating register and value, as +--- `{0xA0, 0x41, 0xB0, 0x32}`, applied in order. The list is read during +--- the call and not retained. An empty list queues nothing and returns +--- true. +--- @param at seconds on the chip clock for every write in the batch, read as +--- `write` reads it +--- @return whether the whole batch was queued: false when the queue cannot +--- hold all of it, which is always the case past 4096 pairs, or when the +--- chip is destroyed +--- @raises string when the list has an odd length +--- @raises string when a register or a value is outside its range +function Fm.writes(exclusive self: Fm, pairs: {integer}, at: number?): boolean + local length = #pairs + if length % 2 ~= 0 then + error( + "tecs.audio.fm.writes: pairs must alternate register and value, got " .. tostring(length) .. " numbers", + 2 + ) + end + for index = 1, length, 2 do + check("writes", self, pairs[index], pairs[index + 1]) + end + if self.destroyed then + return false + end + local count = length // 2 + if count == 0 then + return true + end + if count > QUEUE_WRITES then + return false + end + + return self.port.writes(pairs, count, at or 0) +end + +--- Returns the chip to its power-on state. +--- +--- Every register goes back to zero, so every note stops at once without its +--- release, and the chip's internal clocks start again. Writes sent before +--- this call and due at or after `at` are discarded, scheduled ones included; +--- those due before it still play up to it. Writes sent after it apply as +--- usual, including at `at` itself. +--- +--- @param self the chip to reset +--- @param at seconds on the chip clock, read as `write` reads it +--- @return whether the reset was queued +function Fm.reset(exclusive self: Fm, at: number?): boolean + if self.destroyed then + return false + end + + return self.port.reset(at or 0) +end + +--- Returns the chip clock, for computing `at`. +--- +--- @param self the chip to read +--- @return seconds of audio the chip has rendered: monotonic, zero before its +--- first sample, still while its group is paused, and zero for good on a +--- mixer with no output. It leads what is heard by the output's own +--- latency, and leads the mix by at most a sample or two. +function Fm.now(borrows self: Fm): number + if self.destroyed then + return 0 + end + + return self.port.now() +end + +--- Sets the chip's gain, before its group's and the master's. +--- +--- @param self the chip to scale +--- @param gain linear amplitude rather than decibels, applied from the next +--- output buffer rather than ramped +function Fm.setGain(exclusive self: Fm, gain: number): nil + self.mixer:setGain(self.voice, gain) +end + +--- Stops the chip's voice and frees the emulator. Safe more than once. +--- +--- The voice stops without a fade, so a note still sounding is cut. Key the +--- notes off a little before this for a softer ending. +--- +--- @param self the chip to destroy +function Fm.destroy(exclusive self: Fm): nil + if self.destroyed then + return + end + self.destroyed = true + self.mixer:stop(self.voice) + self.port.close() +end diff --git a/src/tecs/internal/audiosynth.nupp b/src/tecs/internal/audiosynth.nupp index fccbcbd0..ce02623f 100644 --- a/src/tecs/internal/audiosynth.nupp +++ b/src/tecs/internal/audiosynth.nupp @@ -1,13 +1,13 @@ module tecs.internal.audiosynth --[[ -What `tecs.audio` lends `tecs.audio.synth`. +What `tecs.audio` lends `tecs.audio.synth` and `tecs.audio.fm`. -A synthesizer is a voice of the mixer, so it needs a slot taken the way -`Audio.play` takes one and the backend its SoundFont loads into. Both live in +A synthesizer, General MIDI or FM, is a voice of the mixer, so it needs a slot +taken the way `Audio.play` takes one and the backend it opens on. Both live in fields `tecs.audio` keeps private, and a child module cannot reach them. This -module is the seam between the two: `tecs.audio` fills `lent` for each mixer it makes, and -`tecs.audio.synth` reads it. Nothing else should. +module is the seam between them: `tecs.audio` fills `lent` for each mixer it +makes, and the two synthesizer modules read it. Nothing else should. ]] local audiobackend = require("tecs.platform.audiobackend") diff --git a/src/tecs/platform/audiobackend.nupp b/src/tecs/platform/audiobackend.nupp index 9533da58..256951ae 100644 --- a/src/tecs/platform/audiobackend.nupp +++ b/src/tecs/platform/audiobackend.nupp @@ -123,6 +123,43 @@ export record SynthPort close: function(): nil end +--- Names the FM chip an [`FmPort`](tecs.platform.audiobackend.FmPort) drives: +--- the Yamaha YM3812 (OPL2) or the YMF262 (OPL3). +export type FmChip = "opl2" | "opl3" + +--- Sends register writes to one emulated FM chip a backend renders as a voice. +--- +--- Pushed rather than called back, as a +--- [`SynthPort`](tecs.platform.audiobackend.SynthPort) is: each function queues or +--- reads on the frame thread and returns, and the backend's audio thread takes the +--- writes from a bounded queue it never waits on. Every field is a plain function, so a +--- test that wants to see what a game sent is a record of closures that logs. Arguments +--- arrive checked: a register in the chip's range and a value from 0 to 255. +export record FmPort + --- Read-only. Queues one register write for `at` seconds on this port's + --- clock, where zero or less means the next sample, and reports whether + --- the queue had room. + write: function(register: integer, value: integer, at: number): boolean + + --- Read-only. Queues the first `count` register and value pairs of + --- `pairs`, a flat list alternating register and value, for `at`, all or + --- none, and reports whether the queue took them. `count` is at most 4096. + writes: function(pairs: {integer}, count: integer, at: number): boolean + + --- Read-only. Queues a return to the chip's power-on state for `at` that + --- discards every write sent before it and due at or after it, and + --- reports whether the queue had room. + reset: function(at: number): boolean + + --- Read-only. Reports the port's clock in seconds: the samples the chip + --- has rendered over its own rate. Zero until it renders. + now: function(): number + + --- Read-only. Frees the port. Calling it twice is harmless, and nothing + --- sent afterwards reaches anything. + close: function(): nil +end + --- Selects residency for a load. --- --- `"auto"` measures the clip's duration against the configured streaming @@ -208,6 +245,13 @@ export record Backend --- synthesizer, which `tecs.audio.synth` answers with `silentSynth`. openSynth: (function(font: integer, handle: integer, polyphony: integer, effects: boolean): (SynthPort?, string?))? + --- Read-only. Opens an emulated FM chip for the voice `handle` names, + --- which starts sounding when a `PLAY` carrying that handle and the + --- `SYNTH` flag is submitted. Returns the port, or nil and a reason. + --- Omitted by a backend with no FM emulator, which `tecs.audio.fm` + --- answers with `silentFm`. + openFm: (function(handle: integer, chip: FmChip): (FmPort?, string?))? + --- Read-only. Stops everything and closes the output. Calling it twice is --- harmless. close: function(): nil @@ -371,8 +415,8 @@ export const STEREO: integer = 4 --- samples. export const STREAM: integer = 8 ---- Marks a `PLAY` whose input is the synthesizer `openSynth` opened for the ---- command's handle, rather than a clip. +--- Marks a `PLAY` whose input is the synthesizer `openSynth` or the chip +--- `openFm` opened for the command's handle, rather than a clip. export const SYNTH: integer = 16 --- Creates one zeroed command, for a caller filling a reusable buffer. @@ -464,6 +508,32 @@ export function silentSynth(): SynthPort ) end +--- Creates an FM port that accepts everything and renders nothing. +--- +--- What a chip gets on a mixer with no output, and on a backend that has no FM +--- emulator of its own. Every write is taken and dropped, and the clock stays +--- at zero because no sample is ever rendered. +--- +--- @return a port whose writes all succeed and whose clock reads zero +export function silentFm(): FmPort + return new FmPort( + write = function(_register: integer, _value: integer, _at: number): boolean + return true + end, + writes = function(_pairs: {integer}, _count: integer, _at: number): boolean + return true + end, + reset = function(_at: number): boolean + return true + end, + now = function(): number + return 0 + end, + close = function(): nil + end + ) +end + --- Creates a provider that names no devices and opens none. --- --- What a build with no audio library gets, and what a device-free test that diff --git a/src/tecs/platform/audionative.nupp b/src/tecs/platform/audionative.nupp index b4bf0034..a8f9d1f5 100644 --- a/src/tecs/platform/audionative.nupp +++ b/src/tecs/platform/audionative.nupp @@ -77,6 +77,15 @@ cdef struct tecsAudioFontId value: uint32 end +--- One register write in a batch for `tecs_audio_fm_writes`. +--- +--- The value is sixteen bits rather than eight so the struct has no padding +--- for the two sides to disagree about; `layoutMismatch` checks its size. +cdef struct tecsAudioFmWrite + address: uint16 + value: uint16 +end + --- One captured sample, so a block of them can be a `carray`. --- --- `carray` takes a struct type, and the captured samples are bare floats, so @@ -181,6 +190,26 @@ cdef function tecs_audio_synth_now(borrows synth: voidptr?): number cdef function tecs_audio_synth_close(borrows synth: voidptr?) +-- The FM chip. A port is a handle of its own, as a synthesizer's is, and the +-- batch of writes crosses as a `carray` of `tecsAudioFmWrite`. + +cdef function tecs_audio_fm_open(borrows device: voidptr?, handle: uint32, opl3: int32): voidptr? + +cdef function tecs_audio_fm_write(borrows fm: voidptr?, register: uint32, value: uint32, at: number): int32 + +cdef function tecs_audio_fm_writes( + borrows fm: voidptr?, + borrows writes: tecsAudioFmWrite*, + count: uint32, + at: number +): int32 + +cdef function tecs_audio_fm_reset(borrows fm: voidptr?, at: number): int32 + +cdef function tecs_audio_fm_now(borrows fm: voidptr?): number + +cdef function tecs_audio_fm_close(borrows fm: voidptr?) + -- Device listing and recording. Neither a listing nor a capture crosses as a -- struct: a device name has no fixed width worth choosing, and a capture is a -- handle. So these add nothing for `layoutMismatch` to compare, and the @@ -276,6 +305,14 @@ local LAYOUT_EVENT: integer = 1 --- Selects the clip-info struct in `tecs_audio_layout`. local LAYOUT_CLIP_INFO: integer = 2 +--- Selects the FM register-write struct in `tecs_audio_layout`. +local LAYOUT_FM_WRITE: integer = 3 + +--- The writes one FM batch may carry, which is the chip's whole queue: a +--- larger batch could never be taken, so the binding refuses it before the +--- crossing and needs no larger buffer. +local FM_BATCH: integer = 4096 + --- Reports where LuaJIT and the library disagree about a struct size. --- --- Two descriptions of one memory layout can drift, and the failure a drift @@ -284,11 +321,16 @@ local LAYOUT_CLIP_INFO: integer = 2 --- --- @return the reason, or nil when every struct agrees local function layoutMismatch(): string? - local names: {string} = {"tecsAudioCommand", "tecsAudioEvent", "tecsAudioClipInfo"} - local kinds: {integer} = {LAYOUT_COMMAND, LAYOUT_EVENT, LAYOUT_CLIP_INFO} + local names: {string} = {"tecsAudioCommand", "tecsAudioEvent", "tecsAudioClipInfo", "tecsAudioFmWrite"} + local kinds: {integer} = {LAYOUT_COMMAND, LAYOUT_EVENT, LAYOUT_CLIP_INFO, LAYOUT_FM_WRITE} local sizes: { integer - } = {ffi.sizeof(), ffi.sizeof(), ffi.sizeof(),} + } = { + ffi.sizeof(), + ffi.sizeof(), + ffi.sizeof(), + ffi.sizeof(), + } for index = 1, #kinds do local reported = tecs_audio_layout(kinds[index] as uint32) as integer if reported ~= sizes[index] then @@ -514,6 +556,69 @@ local function wrap(openDevice: function(): voidptr?, absent: string): (audiobac end) end + --- The batch buffer every FM port on this device fills. One is enough: + --- the ports are only ever written from the frame thread, and each batch + --- crosses before the next is filled. + local batch = carray(tecsAudioFmWrite, FM_BATCH) + + local function openFm(handle: integer, chip: audiobackend.FmChip): (audiobackend.FmPort?, string?) + if closed[1] then + return nil, "the audio output is closed" + end + local fm = tecs_audio_fm_open(device, handle as uint32, (chip == "opl3" and 1 or 0) as int32) + if fm == nil then + return nil, reason("tecsaudio opened no FM chip") + end + local released = {false} + + return new audiobackend.FmPort( + write = function(register: integer, value: integer, at: number): boolean + if released[1] then + return false + end + + return tecs_audio_fm_write(fm, register as uint32, value as uint32, at) ~= 0 + end, + writes = function(pairs: {integer}, count: integer, at: number): boolean + if released[1] or count > FM_BATCH then + return false + end + for index = 0, count - 1 do + -- The index stays below `FM_BATCH` because of the check + -- above, which the checker cannot read off a `carray`. + @unsafe do + local target = batch[index] + target.address = pairs[2 * index + 1] as uint32 + target.value = pairs[2 * index + 2] as uint32 + end + end + + return tecs_audio_fm_writes(fm, batch, count as uint32, at) ~= 0 + end, + reset = function(at: number): boolean + if released[1] then + return false + end + + return tecs_audio_fm_reset(fm, at) ~= 0 + end, + now = function(): number + if released[1] then + return 0 + end + + return tecs_audio_fm_now(fm) + end, + close = function(): nil + if released[1] then + return + end + released[1] = true + tecs_audio_fm_close(fm) + end + ) + end + return new audiobackend.Backend( failure = function(): string? if not closed[1] and tecs_audio_failed(device) ~= 0 then @@ -561,6 +666,7 @@ local function wrap(openDevice: function(): voidptr?, absent: string): (audiobac end end, openSynth = openSynth, + openFm = openFm, close = function(): nil if closed[1] then return diff --git a/tests/audiofmtest.nupp b/tests/audiofmtest.nupp new file mode 100644 index 00000000..415791e2 --- /dev/null +++ b/tests/audiofmtest.nupp @@ -0,0 +1,327 @@ +local audio = require("tecs.audio") +local audiobackend = require("tecs.platform.audiobackend") +local fm = require("tecs.audio.fm") +local test = require("nupp.test") + +local suite = {} + +--- One call a test port received, as the port saw it. +local record Sent + kind: string + pairs: {integer} + at: number +end + +--- A backend with an FM chip that logs every write instead of rendering. +local record Harness + backend: audiobackend.Backend + commands: {audiobackend.Command} + sent: {Sent} + opened: {integer} + chips: {string} + closed: {integer} + full: {boolean} +end + +local function newHarness(available: boolean, withFm: boolean): Harness + local commands: {audiobackend.Command} = {} + local sent: {Sent} = {} + local opened: {integer} = {} + local chips: {string} = {} + local closed: {integer} = {0} + local full = {false} + + local function record(kind: string, pairs: {integer}, at: number): boolean + if full[1] then + return false + end + sent[#sent + 1] = new Sent(kind = kind, pairs = pairs, at = at) + + return true + end + + local backend = audiobackend.silent() + backend.available = available + backend.submit = function(pending: {audiobackend.Command}, count: integer): nil + for index = 1, count do + local source = pending[index] + local copy = audiobackend.newCommand() + copy.kind = source.kind + copy.handle = source.handle + copy.flags = source.flags + copy.gain = source.gain + commands[#commands + 1] = copy + end + end + if withFm then + backend.openFm = function(handle: integer, chip: audiobackend.FmChip): (audiobackend.FmPort?, string?) + opened[#opened + 1] = handle + chips[#chips + 1] = chip + + return new audiobackend.FmPort( + write = function(register: integer, value: integer, at: number): boolean + return record("write", {register, value}, at) + end, + writes = function(pairs: {integer}, count: integer, at: number): boolean + -- The list is the caller's, so the log keeps a copy. + local copy: {integer} = {} + for index = 1, 2 * count do + copy[index] = pairs[index] + end + + return record("writes", copy, at) + end, + reset = function(at: number): boolean + return record("reset", {}, at) + end, + now = function(): number + return 2.5 + end, + close = function(): nil + closed[1] = closed[1] + 1 + end + ) + end + end + + return new Harness( + backend = backend, + commands = commands, + sent = sent, + opened = opened, + chips = chips, + closed = closed, + full = full + ) +end + +local function lastCommand(harness: Harness, kind: integer): audiobackend.Command? + for index = #harness.commands, 1, -1 do + if harness.commands[index].kind == kind then + return harness.commands[index] + end + end + + return nil +end + +local function made(harness: Harness, options: fm.Options?): (audio.Audio, fm.Fm) + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 2}) + + return mixer, assert(fm.new(mixer, options)) +end + +function suite.startsTheChipAsOneVoiceOfTheMixer(): nil + local harness = newHarness(true, true) + local mixer, chip = made(harness, {chip = "opl3", group = "music", gain = 0.5}) + test.equal(chip.rate, 49715) + test.equal(chip.chip, "opl3") + test.equal(harness.chips[1], "opl3") + test.equal(mixer:sounding(), 1) + mixer:update() + local play = assert(lastCommand(harness, audiobackend.PLAY)) + test.equal(play.flags, audiobackend.SYNTH) + test.equal(play.gain, 0.5) + -- The chip was opened for the voice that started, before the start + -- crossed, which is what hands it to the mixer. + test.equal(harness.opened[1], play.handle) + + mixer:setGroupGain("music", 0.5) + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.SET_GAIN)).gain, 0.25) + mixer:setGroupMuted("music", true) + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.SET_GAIN)).gain, 0) + mixer:setGroupMuted("music", false) + chip:setGain(1) + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.SET_GAIN)).gain, 0.5) + mixer:pauseGroup("music") + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.PAUSE)).handle, play.handle) + + chip:destroy() + chip:destroy() + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.STOP)).handle, play.handle) + test.equal(harness.closed[1], 1) + test.equal(mixer:sounding(), 0) +end + +function suite.makesAnOpl2WhenNoChipIsNamed(): nil + local harness = newHarness(true, true) + local _mixer, chip = made(harness) + test.equal(chip.chip, "opl2") + test.equal(harness.chips[1], "opl2") +end + +function suite.countsAgainstTheVoiceCeiling(): nil + local harness = newHarness(true, true) + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 1}) + local first = assert(fm.new(mixer)) + local second, reason = fm.new(mixer) + test.equal(second, nil) + test.matches(assert(reason), "every voice is busy") + first:destroy() + test.assert(fm.new(mixer) ~= nil) +end + +function suite.carriesWritesBatchesAndResetsToThePortWithTheirTimes(): nil + local harness = newHarness(true, true) + local _mixer, chip = made(harness, {chip = "opl3"}) + test.assert(chip:write(0x105, 0x01)) + test.assert(chip:write(0x1B0, 0x32, 0.25)) + test.assert(chip:writes({0xA0, 0x41, 0xB0, 0x32}, 1.5)) + test.assert(chip:writes({})) + test.assert(chip:reset()) + test.assert(chip:reset(3)) + test.equal(#harness.sent, 5, "an empty batch reaches nothing") + test.equal(harness.sent[1].kind, "write") + test.equal(harness.sent[1].pairs[1], 0x105) + test.equal(harness.sent[1].at, 0) + test.equal(harness.sent[2].pairs[1], 0x1B0) + test.equal(harness.sent[2].pairs[2], 0x32) + test.equal(harness.sent[2].at, 0.25) + test.equal(harness.sent[3].kind, "writes") + test.equal(#harness.sent[3].pairs, 4) + test.equal(harness.sent[3].pairs[3], 0xB0) + test.equal(harness.sent[3].at, 1.5) + test.equal(harness.sent[4].kind, "reset") + test.equal(harness.sent[4].at, 0) + test.equal(harness.sent[5].at, 3) + test.equal(chip:now(), 2.5) +end + +function suite.reportsAFullQueueAsAnUnqueuedWrite(): nil + local harness = newHarness(true, true) + local _mixer, chip = made(harness) + harness.full[1] = true + test.assert(not chip:write(0xB0, 0x20)) + test.assert(not chip:writes({0xB0, 0x20})) + test.assert(not chip:reset()) + harness.full[1] = false + + -- A batch past the queue could never be taken, so it is refused here. + local huge: {integer} = {} + for index = 1, 4097 do + huge[2 * index - 1] = 0xA0 + huge[2 * index] = index % 256 + end + test.assert(not chip:writes(huge)) + huge[8193] = nil + huge[8194] = nil + test.assert(chip:writes(huge)) + test.equal(#harness.sent, 1) + + chip:destroy() + test.assert(not chip:write(0xB0, 0x20), "a destroyed chip queues nothing") + test.assert(not chip:writes({0xB0, 0x20})) + test.assert(not chip:reset()) + test.equal(chip:now(), 0) +end + +function suite.raisesForARegisterOrValueOutsideItsRange(): nil + local harness = newHarness(true, true) + local mixer, opl2 = made(harness) + local opl3 = assert(fm.new(mixer, {chip = "opl3"})) + test.raises( + function(): nil + opl2:write(0x100, 0) + end, + "opl2 register must be from 0x00 to 0xFF, got 0x100" + ) + test.raises( + function(): nil + opl3:write(0x200, 0) + end, + "0x1FF" + ) + test.raises( + function(): nil + opl2:write(-1, 0) + end, + "register" + ) + test.raises( + function(): nil + opl2:write(0xB0, 256) + end, + "value must be from 0 to 255" + ) + test.raises( + function(): nil + opl2:writes({0xB0}) + end, + "alternate register and value" + ) + -- One bad pair refuses the whole batch before any of it is sent. + test.raises( + function(): nil + opl3:writes({0xA0, 0x41, 0x1B0, 0x300}) + end, + "value" + ) + test.equal(#harness.sent, 0) + test.assert(opl3:write(0x1FF, 0xFF)) + test.raises( + function(): nil + fm.new(mixer, {chip = "opl4" as fm.Chip}) + end, + "chip must be" + ) +end + +function suite.acceptsEverythingSilentlyWithoutAnOutput(): nil + -- A mixer with no output takes no voice, and its chip still answers + -- every call, on a clock that never moves because nothing renders. + local mixer = audio.newAudio({backend = audiobackend.silent()}) + local chip = assert(fm.new(mixer, {chip = "opl3"})) + test.equal(mixer:sounding(), 0) + test.assert(chip:write(0xB0, 0x20, 0.5)) + test.assert(chip:writes({0xA0, 0x41})) + test.assert(chip:reset()) + test.equal(chip:now(), 0) + chip:destroy() +end + +function suite.takesAVoiceButRendersNothingOnABackendWithNoFm(): nil + local harness = newHarness(true, false) + local mixer, chip = made(harness) + test.equal(mixer:sounding(), 1) + test.assert(chip:write(0xB0, 0x20)) + test.equal(#harness.sent, 0) + test.equal(chip:now(), 0) +end + +--- Returns the native binding, or skips when `libtecsaudio` is not staged. +local function binding(): {[string]: any} + local ok, loaded = pcall(require, "tecs.platform.audionative") + if not ok then + test.skip("libtecsaudio is not on the loader path: " .. tostring(loaded)) + end + + return loaded as {[string]: any} +end + +function suite.crossesIntoTheNativeChipThroughTheBackend(): nil + local backend = binding().openOffline(48000, 2, 8) as audiobackend.Backend? + if backend == nil then + test.skip("libtecsaudio did not open") + end + -- The offline backend reports no output, so a mixer would give the chip + -- no voice; the port is opened directly to reach the library. + local openFm = assert(assert(backend).openFm) + for _, chip in ipairs({"opl2", "opl3"} as {audiobackend.FmChip}) do + local port = assert(openFm(1, chip)) + test.assert(port.write(0xB0, 0x32, 0)) + test.assert(port.writes({0xA0, 0x41, 0xB0, 0x32}, 2, 0.1)) + test.assert(port.reset(0.2)) + test.equal(port.now(), 0) + port.close() + port.close() + test.assert(not port.write(0xB0, 0x32, 0), "a closed port queues nothing") + end + assert(backend).close() +end + +return suite From c157a49443bf392619f4ef101a763d8acfe908e6 Mon Sep 17 00:00:00 2001 From: Alban Gicquel <6368237+albanmgicquel@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:49:37 -0400 Subject: [PATCH 5/7] tecs.audio.fm: every chip ymfm-oxide models, by part number and clock fm.new(mixer, {chip, clock?, group?, gain?}) now makes any of the 24 chips ymfm-oxide emulates: ym3526 y8950 ym3812 ymf262 ymf289b ymf278b, ym2413 ym2423 ymf281 ds1001, ym2151 ym2164, ym2203 ym2608 ymf288 ym2610 ym2610b ym2612 ym3438 ymf276, ym2414, ym3806 ym3533 and ym2149. "opl2" and "opl3" stay as aliases of the YM3812 and YMF262, so the OPL calls and their rate of 49715 are unchanged. clock defaults to the chip's usual one and must be 100 kHz to 100 MHz. rate is now per chip (the native port reports it); clock, lastRegister and lastPort are new read-only fields, and chips(), defaultClock(), rate() and memoryClasses() describe the chips. New methods: writePort/writePorts write the chip's own ports (offset, data) beside the register convenience, which maps a register's high byte to the bank's address/data pair (0/1, 2/3, the OPL4 PCM pair at 4/5, the YM2149's data port at 2; the OPQ is written directly up to 0x11F). setMemory(class, image) hands the chip an ADPCM-A, ADPCM-B or PCM image, which replaces the class from the next frame and survives a reset; images cross on their own 8-deep channel paired with a write-queue event, and displaced ones are freed on the game thread. pause/resume/paused, setStereo and destroy(fadeOut?) work on the chip's voice. Rendering: the OPN chips with an SSG run at ymfm's lowest fidelity, and a chip whose emulator renders past 64 kHz (YM2203, YM2608 x3, YMF288 x2) has samples averaged into one frame of its clock. Outputs fold to stereo by a table (OPL3 A/B, OPL4 DO2, OPLL melody+rhythm, SSG at 2/9 a channel, ymfm's own OPNA mix) and chips with an SSG get a 5 Hz DC blocker. Timers, busy time, IRQ and status reads are not offered (CSM and ADPCM end flags need them). The C ABI opens a chip by name and clock and gains rate, write_port, a ports flag on writes, and memory. Tests: every chip sounds through its register layout, rates match ymfm's sample_rate for every chip and clock (and the Nupp table matches the native port), decimation matches a direct render, ADPCM-A plays from an image and keeps it across a reset, the memory queue is bounded and returns images, and a YM2612 plays resampled through the engine. README records why the chip is a name and a clock, the fidelity and averaging choices, the SSG filter, and why memory is owned by the render side without a lock. --- AGENTS.md | 4 +- README.md | 65 +- THIRD_PARTY_NOTICES.md | 6 +- native/rust/tecs-audio/src/engine.rs | 18 +- native/rust/tecs-audio/src/fm.rs | 1500 +++++++++++++++++++++++--- native/rust/tecs-audio/src/lib.rs | 149 ++- native/rust/tecs-audio/src/synth.rs | 30 +- native/rust/tecs-audio/tests/fm.rs | 54 +- src/tecs/audio/fm.nupp | 602 +++++++++-- src/tecs/platform/audiobackend.nupp | 80 +- src/tecs/platform/audionative.nupp | 71 +- tests/audiofmtest.nupp | 184 +++- 12 files changed, 2425 insertions(+), 338 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8139ed9c..ad278501 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,14 +108,14 @@ tecs/ │ ├── platform/ # Window, events, audio and gamepad backends │ ├── physics/ # Rapier contract and binding │ ├── input.nupp # Gameplay input over the platform event stream -│ ├── audio/ # Clips, voices, groups, limits, the Sound component, General MIDI, OPL FM +│ ├── audio/ # Clips, voices, groups, limits, the Sound component, General MIDI, Yamaha FM │ ├── sequence.nupp # Sequencer, with the tween runtime inside it │ ├── assets.nupp # Asset orchestration │ ├── mcp.nupp # Debug server: transport, tools, sandbox │ └── events.nupp, files.nupp, watch.nupp ├── native/rust/ │ ├── winit-host/ # The application loop, the wgpu renderer, the host ABI -│ ├── tecs-audio/ # cpal output and capture behind a batched contract; General MIDI and OPL voices +│ ├── tecs-audio/ # cpal output and capture behind a batched contract; General MIDI and Yamaha FM voices │ ├── tecs-gamepad/ # gilrs enumeration and observations │ ├── physics/ # Rapier storage and stepping │ └── build-support/ # Native packaging and binary inspection diff --git a/README.md b/README.md index 8131a4f7..c4f37e17 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,10 @@ test suite reach the same world without starting a graphics stack. - Camera, layers with positioning modes, images, clips, sprite sheets with animation, and distance-field text. - Input across keyboard, pointer, gamepad and touch; audio playback, recording - and device enumeration; General MIDI from a SoundFont; an emulated OPL2 and - OPL3 FM chip; physics over Rapier; sequencing with tweening; asset - orchestration; file watching; and an MCP debug server. + and device enumeration; General MIDI from a SoundFont; emulated Yamaha FM + chips, from the OPL2 and OPL3 to the YM2612 and the OPL4; physics over + Rapier; sequencing with tweening; asset orchestration; file watching; and an + MCP debug server. - [Tiled TMX maps and TSX tilesets](docs/tiled/index.md), animated tile and image layers, object factories, runtime tile edits and authored collision outlines. @@ -613,6 +614,64 @@ itself folds a second-bank write back to the first until the NEW bit is set. The OPL3's left and right are its outputs A and B; the OPL2 is mono and plays its one output on both sides. +## Every chip the emulator models is one voice, named by its part number + +`tecs.audio.fm` began as the OPL2 and OPL3 and now makes any of the 24 chips +ymfm-oxide models, because the register logs a game ships or a player loads +are not all DOS ones: a Mega Drive log is a YM2612, an arcade one a YM2151 or +a YM2610, an MSX one a YM2413 beside a Y8950. The alternative was a module per +family, and it lost because the families differ only in data a table holds: +where the registers sit among the ports, what the input clock usually was, how +the outputs reach two speakers and what memory the chip reads. The contract a +game writes against, registers, a clock and `at`, is the same for all of them, +so the chip is a name and a clock and nothing else changes. Names are Yamaha +part numbers in lower case rather than family names, because a family name +does not say which chip: an OPN is any of eight. `"opl2"` and `"opl3"` stay as +aliases, so the first callers never notice. + +The register convenience stays the primary interface, since every log and every +driver's source names registers, and it maps a register's high byte to the +bank's address and data ports: one bank at offsets 0 and 1, the second at 2 and +3, the OPL4's PCM registers as a third at 4 and 5, and the YM2149's data port +at 2. The OPQ has no address port and is written by register. Port writes are +offered beside it for the driver that wrote the bus itself, and a test holds the +two to the same render. + +The chip renders at the emulator's own rate for its clock, which the voice +resamples. Two consequences needed decisions. The OPN chips with an SSG default +in ymfm to their highest fidelity, a megahertz for the YM2203, and run here at +their lowest, which keeps every FM sample and averages the SSG's; and where the +emulator still renders faster than 64 kHz, as the YM2203, the YM2608 and the +YMF288 do, consecutive samples are averaged into one frame of the chip's clock. +That keeps the mixer's linear resampler away from ratios where it aliases, and +it puts a ceiling on `rate`, the unit of `at`. Outputs fold to stereo by a +fixed table: a mono chip on both sides, the OPL3's A and B, the OPL4's mixed +pair, the SSG at two thirds of its channels' sum, which is ymfm's own mix. +The SSG's square waves swing between zero and a positive level, which a real +board's output capacitor took out, so a chip with one has a 5 Hz high-pass +filter on its output; without it a song would start and end with a click and +carry an offset into the mix. + +External memory is images a game hands over, whole: `setMemory` replaces the +ADPCM-A, ADPCM-B or PCM image from the next frame, and a reset keeps it. The +images belong to the audio thread's half of the chip. ymfm reaches memory +through the chip's interface, which the chip owns and not every chip lends +back, so the interface holds a pointer to images the target owns, read and +swapped only between samples on the one thread that renders; a lock around +them was the alternative and lost, being a lock on the audio thread taken on +every ADPCM fetch to protect nothing a second thread touches. An image crosses +on a bounded channel of its own, paired with an event in the write queue that +says when to take it, and the image it displaces goes back to the game thread +to be freed there. Timers, busy time and the IRQ line are not offered: a log is +already timed and a driver paces on `now`, and an answer from the emulator +inside a call would need the audio thread to reply. What that leaves out is +the OPN's CSM key-on and the ADPCM end-of-sample flags, which only a timer or a +status read can drive. + +An idle chip at its usual clock costs between a fifth of a percent and one +percent of a core in a release build, measured over a second of rendering; the +YMF288 is the dearest and the YM2149 the cheapest. + ## Device vocabularies follow the library that reports them Key names, mouse button names and gamepad button and axis names came from SDL diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index eb57f1a3..4405d83b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -91,14 +91,16 @@ one ships that licence beside it. FluidR3 GM, for example, is MIT. ### ymfm-oxide -`tecs.audio.fm` emulates its OPL2 and OPL3 with ymfm-oxide +`tecs.audio.fm` emulates every chip it offers with ymfm-oxide (), a pure-Rust port by Alban Gicquel of ymfm, Aaron Giles's emulator of the Yamaha FM chips (). It is a Cargo dependency pinned to one commit, compiled into `tecsaudio`, so every package carries it and `cargo-licenses.txt` records it as BSD-3-Clause. The licence asks for its notice to travel with a binary, which is this one; Tecs keeps none of its -source. +source. The OPLL family plays ymfm's own copyright-free stand-ins for the +instrument ROMs, made by David Viens and Hubert Lamontagne and carried in the +crate; no Yamaha ROM ships with Tecs. ```text BSD 3-Clause License diff --git a/native/rust/tecs-audio/src/engine.rs b/native/rust/tecs-audio/src/engine.rs index 410b06cc..b1c2cc42 100644 --- a/native/rust/tecs-audio/src/engine.rs +++ b/native/rust/tecs-audio/src/engine.rs @@ -181,7 +181,7 @@ impl Engine { } /// Records the reason a call is about to report. - fn fail(&self, reason: String) -> String { + pub(crate) fn fail(&self, reason: String) -> String { if let Ok(mut slot) = self.last_error.lock() { *slot = reason.clone(); } @@ -359,16 +359,16 @@ impl Engine { } } - /// Creates an emulated OPL2 or OPL3 and parks its source for the `PLAY` - /// carrying `handle` and `FLAG_SYNTH`, as `open_synth` does. + /// Creates an emulated FM chip at `clock` hertz and parks its source for + /// the `PLAY` carrying `handle` and `FLAG_SYNTH`, as `open_synth` does. /// - /// The chip renders at its own rate, `fm::RATE`, and the voice resamples - /// it to the output. + /// The chip renders at its own rate, `kind.rate(clock)`, and the voice + /// resamples it to the output. /// /// @return the port register writes are sent through, or the reason /// nothing opened - pub fn open_fm(&self, handle: u32, kind: Kind) -> Result { - let (port, source) = fm::open(kind); + pub fn open_fm(&self, handle: u32, kind: Kind, clock: u32) -> Result { + let (port, source) = fm::open(kind, clock); match self.synths.lock() { Err(_) => Err(self.fail("the synthesizer table is poisoned".to_string())), Ok(mut synths) => { @@ -684,7 +684,9 @@ mod tests { #[test] fn an_fm_chip_started_by_its_play_renders_and_keeps_its_clock() { let engine = Engine::open_offline(48_000, 2, 8); - let mut port = engine.open_fm(257, Kind::Opl3).expect("an OPL3 opens"); + let mut port = engine + .open_fm(257, Kind::Ymf262, fm::OPL3_CLOCK) + .expect("an OPL3 opens"); assert!(port.reset(0.0)); // A held note on channel 0, OPL3 mode and both outputs on. for (register, value) in [ diff --git a/native/rust/tecs-audio/src/fm.rs b/native/rust/tecs-audio/src/fm.rs index bfd1767b..5f76e6a8 100644 --- a/native/rust/tecs-audio/src/fm.rs +++ b/native/rust/tecs-audio/src/fm.rs @@ -1,34 +1,57 @@ -//! An emulated OPL2 or OPL3 that plays as one voice of the mixer. +//! An emulated Yamaha FM chip that plays as one voice of the mixer. //! -//! The chip is the YM3812 or the YMF262 of [`ymfm_oxide`], a pure-Rust port of -//! ymfm, and what it is driven by is the scheduler the General MIDI -//! synthesizer uses: the same bounded ring, the same schedule sorted by frame -//! and then by sending, the same source the mixer reads. What differs is the -//! grid. A register write takes effect on the very sample it is due at, so the -//! target's block is one frame and an event lands on the first chip sample at -//! or after its time, never later. Writes due on one sample all apply before -//! it is generated, so a key-off and a key-on there are no change at all: a -//! note is struck again by spacing them a sample apart. +//! The chip is any of the 24 that [`ymfm_oxide`], a pure-Rust port of ymfm, +//! emulates: the OPL family with the OPL4, the OPLL family, the OPM and OPP, +//! the OPN family with its SSG and ADPCM, the OPZ, the OPQ and the YM2149. What +//! drives it is the scheduler the General MIDI synthesizer uses: the same +//! bounded ring, the same schedule sorted by frame and then by sending, the +//! same source the mixer reads. What differs is the grid. A register write +//! takes effect on the very sample it is due at, so the target's block is one +//! frame and an event lands on the first clock frame at or after its time, +//! never later. Writes due on one frame all apply before it is generated, so a +//! key-off and a key-on there are no change at all: a note is struck again by +//! spacing them a frame apart. //! -//! The chip runs at its own rate, [`RATE`], and the voice that reads it -//! resamples to the output, as it would a clip recorded at that rate. The -//! clock a game schedules against counts chip samples. +//! Each chip runs at its own rate, the one ymfm gives its input clock, and the +//! voice that reads it resamples to the output, as it would a clip recorded at +//! that rate. Three chips render faster than any output needs, the YM2203 and +//! the YM2608 at three times their FM rate so the SSG can be averaged down and +//! the YMF288 at twice it, and for those the source averages consecutive +//! samples into one frame ([`Kind::decimation`]), so the clock a game +//! schedules against never runs past 64 kHz. Every chip's outputs are folded +//! into one stereo pair ([`Kind::outputs`]), and a chip with an SSG has its +//! direct current removed, because the SSG's square waves swing between zero +//! and a positive level rather than around zero, which the hardware's output +//! capacitor took care of. //! //! ``` //! use tecsaudio::fm::{self, Kind}; //! use tecsaudio::source::Source; //! -//! let (mut port, mut source) = fm::open(Kind::Opl2); +//! let (mut port, mut source) = fm::open(Kind::Ym3812, Kind::Ym3812.default_clock()); //! // Key channel 0 on a quarter of a second in, on the chip's own clock. //! assert!(port.write(0xb0, 0x32, 0.25)); //! let mut out = [0.0f32; 2]; //! source.read(&mut out); //! ``` -use ymfm_oxide::opl::{Ym3812, Ymf262}; -use ymfm_oxide::{NullInterface, Output}; +use std::sync::mpsc::{sync_channel, Receiver, SyncSender}; -use crate::synth::{self, Event, Port, SynthSource, Target, KIND_MESSAGE, KIND_RESET}; +use ymfm_oxide::misc::Ym2149; +use ymfm_oxide::opl::{ + Ds1001, Ym2413, Ym2423, Ym3526, Ym3812, Ymf262, Ymf278b, Ymf281, Ymf289b, Y8950, +}; +use ymfm_oxide::opm::{Ym2151, Ym2164}; +use ymfm_oxide::opn::{ + OpnFidelity, Ym2203, Ym2608, Ym2610, Ym2610b, Ym2612, Ym3438, Ymf276, Ymf288, +}; +use ymfm_oxide::opq::{Ym3533, Ym3806}; +use ymfm_oxide::opz::Ym2414; +use ymfm_oxide::{AccessClass, Interface, Output}; + +use crate::synth::{ + self, Event, Port, SynthSource, Target, KIND_MEMORY, KIND_MESSAGE, KIND_PORT, KIND_RESET, +}; /// The input clock of an OPL2, the NTSC colour burst a sound card divides /// down: 3.579545 MHz. The OPL3 takes four times it. @@ -37,119 +60,731 @@ pub const OPL2_CLOCK: u32 = 3_579_545; /// The input clock of an OPL3: 14.31818 MHz. pub const OPL3_CLOCK: u32 = 4 * OPL2_CLOCK; -/// The rate the chip renders at and its clock counts in, in hertz: the input -/// clock over 72 on the OPL2 and over 288 on the OPL3, 49 715.9 Hz, which the -/// emulator truncates to a whole rate. +/// The rate an OPL2 or an OPL3 renders at on its usual clock, in hertz: the +/// input clock over 72 on the OPL2 and over 288 on the OPL3, 49 715.9 Hz, +/// which the emulator truncates to a whole rate. pub const RATE: u32 = 49_715; +/// The fastest clock a chip's source runs at. A chip whose emulator renders +/// faster has consecutive samples averaged into one frame, as many as it takes +/// to come under this. +pub const MAX_RATE: u32 = 64_000; + +/// The slowest input clock a chip is made with: below it the slowest chip's +/// rate falls under two thousand frames a second. +pub const MIN_CLOCK: u32 = 100_000; + +/// The fastest input clock a chip is made with, three times the OPL4's. +pub const MAX_CLOCK: u32 = 100_000_000; + +/// The largest memory image one class takes: sixteen megabytes, which covers +/// the 24-bit address space of every ADPCM and PCM engine ymfm has. +pub const MAX_IMAGE: usize = 16 * 1024 * 1024; + +/// The memory images that can be on their way to the audio thread at once, +/// and the displaced ones on their way back. +pub const MEMORY_QUEUE: usize = 8; + /// Selects which chip to emulate. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Kind { - /// The YM3812: nine channels of two operators, four waveforms, mono. - Opl2, - /// The YMF262: eighteen channels that pair into 4-operator voices, eight - /// waveforms, and stereo from the pan bits of 0xC0 to 0xC8. - Opl3, + /// The YM3526 (OPL): nine 2-operator channels, one sine wave, mono. + Ym3526, + /// The Y8950 (MSX-Audio): an OPL with an ADPCM-B channel reading + /// external memory, mono. + Y8950, + /// The YM3812 (OPL2): nine 2-operator channels, four waveforms, mono. + Ym3812, + /// The YMF262 (OPL3): eighteen channels that pair into 4-operator + /// voices, eight waveforms, and stereo from the pan bits of 0xC0 to 0xC8. + Ymf262, + /// The YMF289B (OPL3L): an OPL3 with two outputs. + Ymf289b, + /// The YMF278B (OPL4): an OPL3 and a 24-voice PCM wavetable reading + /// external memory, the PCM registers in a third bank. + Ymf278b, + /// The YM2413 (OPLL): nine channels on fifteen built-in instruments and + /// one the registers define, with ymfm's copyright-free stand-in for the + /// instrument ROM. + Ym2413, + /// The YM2423 (OPLL-X), an OPLL with another instrument set. + Ym2423, + /// The YMF281 (OPLLP), an OPLL with another instrument set. + Ymf281, + /// The DS1001 (Konami VRC7): six OPLL channels and its own instruments. + Ds1001, + /// The YM2151 (OPM): eight 4-operator channels, stereo. + Ym2151, + /// The YM2164 (OPP), an OPM variant. + Ym2164, + /// The YM2203 (OPN): three 4-operator channels and a 3-channel SSG, + /// mono. + Ym2203, + /// The YM2608 (OPNA): six FM channels, the SSG, six ADPCM-A rhythm + /// channels on a ROM and an ADPCM-B channel, stereo. + Ym2608, + /// The YMF288 (OPN3L), an OPNA with its rhythm ROM built in. + Ymf288, + /// The YM2610 (OPNB): four FM channels, the SSG and ADPCM-A and ADPCM-B + /// from external memory, stereo. + Ym2610, + /// The YM2610B (OPNB2): the YM2610 with all six FM channels. + Ym2610b, + /// The YM2612 (OPN2): six FM channels, one of them a DAC, stereo. + Ym2612, + /// The YM3438 (OPN2C), the CMOS YM2612. + Ym3438, + /// The YMF276 (OPN2L), the low-voltage YM3438. + Ymf276, + /// The YM2414 (OPZ): eight 4-operator channels, eight waveforms, stereo. + Ym2414, + /// The YM3806 (OPQ): eight 4-operator channels, stereo, written by + /// register address directly rather than through an address port. + Ym3806, + /// The YM3533, an OPQ variant. + Ym3533, + /// The YM2149 (SSG), the AY-3-8910 family: three square-wave channels, + /// noise and an envelope. + Ym2149, +} + +/// Where a chip's registers sit among its ports. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Layout { + /// Banks of 256 registers, each written through an address port and then + /// a data port, given as `(address, data)` offsets per bank. + Banks(&'static [(u16, u16)]), + /// One register file written by address directly: the offset is the + /// register. + Direct, +} + +const ONE_BANK: &[(u16, u16)] = &[(0, 1)]; +const TWO_BANKS: &[(u16, u16)] = &[(0, 1), (2, 3)]; +const THREE_BANKS: &[(u16, u16)] = &[(0, 1), (2, 3), (4, 5)]; +const SSG_BANK: &[(u16, u16)] = &[(0, 2)]; + +/// The last register the OPQ decodes. +const OPQ_LAST_REGISTER: u16 = 0x11f; + +/// A class of external memory a chip reads. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MemoryClass { + /// ADPCM-A sample ROM: the YM2608's rhythm ROM, the YM2610's sample ROM. + AdpcmA, + /// ADPCM-B sample ROM or RAM: the Y8950's, the YM2608's and the YM2610's. + AdpcmB, + /// PCM wavetable ROM and RAM: the OPL4's. + Pcm, +} + +impl MemoryClass { + /// Every class, in the order of their wire numbers. + pub const ALL: [MemoryClass; 3] = [MemoryClass::AdpcmA, MemoryClass::AdpcmB, MemoryClass::Pcm]; + + /// Returns the class a wire number names: 0, 1 or 2. + pub fn from_index(index: u32) -> Option { + MemoryClass::ALL.get(index as usize).copied() + } + + /// Returns the wire number the class crosses the C ABI as. + pub const fn index(self) -> u8 { + match self { + MemoryClass::AdpcmA => 0, + MemoryClass::AdpcmB => 1, + MemoryClass::Pcm => 2, + } + } + + fn of(access: AccessClass) -> Option { + match access { + AccessClass::AdpcmA => Some(MemoryClass::AdpcmA), + AccessClass::AdpcmB => Some(MemoryClass::AdpcmB), + AccessClass::Pcm => Some(MemoryClass::Pcm), + AccessClass::Io => None, + } + } +} + +/// How one of a chip's outputs reaches the stereo pair. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Route { + /// The output's index in the chip's order. + pub output: usize, + /// The share of it that goes left. + pub left: f32, + /// The share of it that goes right. + pub right: f32, +} + +const fn both(output: usize, gain: f32) -> Route { + Route { + output, + left: gain, + right: gain, + } +} + +const fn left(output: usize) -> Route { + Route { + output, + left: 1.0, + right: 0.0, + } +} + +const fn right(output: usize) -> Route { + Route { + output, + left: 0.0, + right: 1.0, + } } +/// The share each SSG channel takes of a mono mix of three: two ninths, which +/// puts three of them at two thirds of their sum, the factor ymfm itself +/// applies when it mixes the OPNA's SSG to one output. +pub const SSG_SHARE: f32 = 2.0 / 9.0; + +const MONO: &[Route] = &[both(0, 1.0)]; +const STEREO: &[Route] = &[left(0), right(1)]; +const OPLL: &[Route] = &[both(0, 1.0), both(1, 1.0)]; +const OPL4: &[Route] = &[left(4), right(5)]; +const OPN: &[Route] = &[ + both(0, 1.0), + both(1, SSG_SHARE), + both(2, SSG_SHARE), + both(3, SSG_SHARE), +]; +const OPNA: &[Route] = &[left(0), right(1), both(2, 1.0)]; +const SSG: &[Route] = &[both(0, SSG_SHARE), both(1, SSG_SHARE), both(2, SSG_SHARE)]; + impl Kind { - /// Returns the highest register address the chip decodes: 0xFF on the - /// OPL2, and 0x1FF on the OPL3, whose second bank is 0x100 up. + /// Every chip, in the order the module lists them. + pub const ALL: [Kind; 24] = [ + Kind::Ym3526, + Kind::Y8950, + Kind::Ym3812, + Kind::Ymf262, + Kind::Ymf289b, + Kind::Ymf278b, + Kind::Ym2413, + Kind::Ym2423, + Kind::Ymf281, + Kind::Ds1001, + Kind::Ym2151, + Kind::Ym2164, + Kind::Ym2203, + Kind::Ym2608, + Kind::Ymf288, + Kind::Ym2610, + Kind::Ym2610b, + Kind::Ym2612, + Kind::Ym3438, + Kind::Ymf276, + Kind::Ym2414, + Kind::Ym3806, + Kind::Ym3533, + Kind::Ym2149, + ]; + + /// Returns the chip a name selects: its part number in lower case, such + /// as `"ym2612"`, or `"opl2"` and `"opl3"` for the YM3812 and the YMF262. + pub fn from_name(name: &str) -> Option { + match name { + "opl2" => return Some(Kind::Ym3812), + "opl3" => return Some(Kind::Ymf262), + _ => {} + } + Kind::ALL.into_iter().find(|kind| kind.name() == name) + } + + /// Returns the chip's part number in lower case, which `from_name` reads. + pub const fn name(self) -> &'static str { + match self { + Kind::Ym3526 => "ym3526", + Kind::Y8950 => "y8950", + Kind::Ym3812 => "ym3812", + Kind::Ymf262 => "ymf262", + Kind::Ymf289b => "ymf289b", + Kind::Ymf278b => "ymf278b", + Kind::Ym2413 => "ym2413", + Kind::Ym2423 => "ym2423", + Kind::Ymf281 => "ymf281", + Kind::Ds1001 => "ds1001", + Kind::Ym2151 => "ym2151", + Kind::Ym2164 => "ym2164", + Kind::Ym2203 => "ym2203", + Kind::Ym2608 => "ym2608", + Kind::Ymf288 => "ymf288", + Kind::Ym2610 => "ym2610", + Kind::Ym2610b => "ym2610b", + Kind::Ym2612 => "ym2612", + Kind::Ym3438 => "ym3438", + Kind::Ymf276 => "ymf276", + Kind::Ym2414 => "ym2414", + Kind::Ym3806 => "ym3806", + Kind::Ym3533 => "ym3533", + Kind::Ym2149 => "ym2149", + } + } + + /// Returns the input clock the chip usually ran at, in hertz: the one in + /// the machine it is best known from. + pub const fn default_clock(self) -> u32 { + match self { + Kind::Ymf262 | Kind::Ymf289b => OPL3_CLOCK, + Kind::Ymf278b => 33_868_800, + Kind::Ym2203 => 3_993_600, + Kind::Ym2608 => 7_987_200, + Kind::Ymf288 => 15_974_400, + Kind::Ym2610 | Kind::Ym2610b => 8_000_000, + Kind::Ym2612 | Kind::Ym3438 | Kind::Ymf276 => 7_670_453, + Kind::Ym2149 => 1_789_772, + _ => OPL2_CLOCK, + } + } + + /// Returns the input clocks per sample the emulator renders at, so that + /// its rate is the clock over this, truncated. + /// + /// The OPN chips with an SSG run at their lowest fidelity, which still + /// keeps every FM sample and averages the SSG's: the highest renders the + /// YM2203 at a megahertz for a difference nobody hears. + pub const fn divider(self) -> u32 { + match self { + Kind::Ymf262 | Kind::Ymf289b => 288, + Kind::Ymf278b => 768, + Kind::Ym2151 | Kind::Ym2164 | Kind::Ym2414 | Kind::Ym3806 | Kind::Ym3533 => 64, + Kind::Ym2203 => 24, + Kind::Ym2608 => 48, + Kind::Ymf288 | Kind::Ym2610 | Kind::Ym2610b => 144, + Kind::Ym2612 | Kind::Ym3438 | Kind::Ymf276 => 144, + Kind::Ym2149 => 64, + _ => 72, + } + } + + /// Returns how many emulator samples make one frame of the chip's clock + /// at `clock`: one, unless the emulator renders faster than [`MAX_RATE`]. + pub const fn decimation(self, clock: u32) -> u32 { + let rate = clock / self.divider(); + if rate <= MAX_RATE { + 1 + } else { + rate.div_ceil(MAX_RATE) + } + } + + /// Returns the rate of the chip's clock at `clock`, in frames a second: + /// the emulator's rate over its decimation. + pub const fn rate(self, clock: u32) -> u32 { + clock / self.divider() / self.decimation(clock) + } + + const fn layout(self) -> Layout { + match self { + Kind::Ymf262 + | Kind::Ymf289b + | Kind::Ym2608 + | Kind::Ymf288 + | Kind::Ym2610 + | Kind::Ym2610b + | Kind::Ym2612 + | Kind::Ym3438 + | Kind::Ymf276 => Layout::Banks(TWO_BANKS), + Kind::Ymf278b => Layout::Banks(THREE_BANKS), + Kind::Ym3806 | Kind::Ym3533 => Layout::Direct, + Kind::Ym2149 => Layout::Banks(SSG_BANK), + _ => Layout::Banks(ONE_BANK), + } + } + + /// Returns the highest register address the chip decodes, a bank of 256 + /// to each address port: 0xFF on a chip with one, 0x1FF on the OPL3 and + /// the two-bank OPN chips, whose second bank is 0x100 up, and 0x2FF on the + /// OPL4, whose PCM registers are the third. The OPQ, which has no address + /// port, decodes to 0x11F. pub const fn last_register(self) -> u16 { + match self.layout() { + Layout::Banks(banks) => (banks.len() * 256 - 1) as u16, + Layout::Direct => OPQ_LAST_REGISTER, + } + } + + /// Returns the highest port offset the chip has: 1 on a chip with one + /// address and one data port, 3 with two of each and on the YM2149, whose + /// data port is at 2, 5 on the OPL4, and the last register on the OPQ, + /// whose offset is the register. + pub const fn last_port(self) -> u16 { + match self { + Kind::Ym2149 => 3, + _ => match self.layout() { + Layout::Banks(banks) => (banks.len() * 2 - 1) as u16, + Layout::Direct => OPQ_LAST_REGISTER, + }, + } + } + + /// Reports whether the chip reads memory of `class`. + pub const fn reads(self, class: MemoryClass) -> bool { + matches!( + (self, class), + (Kind::Y8950, MemoryClass::AdpcmB) + | ( + Kind::Ym2608 | Kind::Ym2610 | Kind::Ym2610b, + MemoryClass::AdpcmA | MemoryClass::AdpcmB + ) + | (Kind::Ymf278b, MemoryClass::Pcm) + ) + } + + /// Returns how each of the chip's outputs reaches the stereo pair. + /// + /// A mono chip plays its output on both sides. The OPL3 plays outputs A + /// and B, which its pan bits select, and leaves C and D, which a stereo + /// card did not wire. The OPL4 plays the mix of its FM and PCM A and B + /// outputs, its third pair. The OPLL sums its melody and rhythm. The + /// YM2203 adds its SSG channels to its FM, the YM2608 and the YM2610 add + /// their mixed SSG to both sides, and the YM2149 mixes its three channels. + pub const fn outputs(self) -> &'static [Route] { match self { - Kind::Opl2 => 0xff, - Kind::Opl3 => 0x1ff, + Kind::Ymf262 | Kind::Ymf289b => STEREO, + Kind::Ymf278b => OPL4, + Kind::Ym2413 | Kind::Ym2423 | Kind::Ymf281 | Kind::Ds1001 => OPLL, + Kind::Ym2151 + | Kind::Ym2164 + | Kind::Ym2612 + | Kind::Ym3438 + | Kind::Ymf276 + | Kind::Ym2414 + | Kind::Ym3806 + | Kind::Ym3533 => STEREO, + Kind::Ym2203 => OPN, + Kind::Ym2608 | Kind::Ymf288 | Kind::Ym2610 | Kind::Ym2610b => OPNA, + Kind::Ym2149 => SSG, + _ => MONO, } } + + /// Reports whether the chip plays the same on both sides. + pub const fn mono(self) -> bool { + matches!( + self, + Kind::Ym3526 + | Kind::Y8950 + | Kind::Ym3812 + | Kind::Ym2413 + | Kind::Ym2423 + | Kind::Ymf281 + | Kind::Ds1001 + | Kind::Ym2203 + | Kind::Ym2149 + ) + } + + /// Reports whether the chip has an SSG, whose output is offset from zero + /// and is therefore filtered. + pub const fn has_ssg(self) -> bool { + matches!( + self, + Kind::Ym2203 + | Kind::Ym2608 + | Kind::Ymf288 + | Kind::Ym2610 + | Kind::Ym2610b + | Kind::Ym2149 + ) + } } -/// One emulated chip, written by register and read as a stereo pair. -#[derive(Clone)] +/// The memory images one chip reads, owned by its [`Fm`] target. +#[derive(Default)] +struct Images { + adpcm_a: Box<[u8]>, + adpcm_b: Box<[u8]>, + pcm: Box<[u8]>, +} + +impl Images { + fn slot(&mut self, class: MemoryClass) -> &mut Box<[u8]> { + match class { + MemoryClass::AdpcmA => &mut self.adpcm_a, + MemoryClass::AdpcmB => &mut self.adpcm_b, + MemoryClass::Pcm => &mut self.pcm, + } + } +} + +/// The chip's connection to its memory images: what ymfm calls its interface. +/// +/// The images belong to the target, which replaces them between samples, and +/// the chip reads and writes them during one. A chip owns its interface and +/// not every chip lends it back, so the interface reaches the images through +/// a pointer to the allocation the target owns rather than holding them. +/// Timers, busy time and the IRQ line keep ymfm's defaults: nothing here asks +/// for them, because a chip played from a log or by a driver paced on the +/// clock never waits on them. +struct Memory(*mut Images); + +// SAFETY: the pointer names an allocation the target owns and frees, and the +// target and the chip holding this move between threads together and are +// used from one thread at a time, through `&mut` on the target. +unsafe impl Send for Memory {} + +impl Interface for Memory { + fn external_read(&mut self, access: AccessClass, address: u32) -> u8 { + let Some(class) = MemoryClass::of(access) else { + return 0; + }; + // SAFETY: see `Memory`. The chip calls this only while the target is + // generating, and the target touches the images only between + // samples, so no other reference to them is live. + let images = unsafe { &mut *self.0 }; + images + .slot(class) + .get(address as usize) + .copied() + .unwrap_or(0) + } + + fn external_write(&mut self, access: AccessClass, address: u32, data: u8) { + let Some(class) = MemoryClass::of(access) else { + return; + }; + // SAFETY: as for `external_read`. + let images = unsafe { &mut *self.0 }; + if let Some(byte) = images.slot(class).get_mut(address as usize) { + *byte = data; + } + } +} + +/// One emulated chip, written by port and read as all of its outputs. +/// +/// Every chip is boxed, so the enum is a pointer wide and a reset replaces +/// one allocation rather than moving kilobytes of state through the stack. enum Chip { - Opl2(Ym3812), - Opl3(Ymf262), + Ym3526(Box>), + Y8950(Box>), + Ym3812(Box>), + Ymf262(Box>), + Ymf289b(Box>), + Ymf278b(Box>), + Ym2413(Box>), + Ym2423(Box>), + Ymf281(Box>), + Ds1001(Box>), + Ym2151(Box>), + Ym2164(Box>), + Ym2203(Box>), + Ym2608(Box>), + Ymf288(Box>), + Ym2610(Box>), + Ym2610b(Box>), + Ym2612(Box>), + Ym3438(Box>), + Ymf276(Box>), + Ym2414(Box>), + Ym3806(Box>), + Ym3533(Box>), + Ym2149(Box>), } -impl Chip { - /// Creates a chip in its power-on state. - fn new(kind: Kind) -> Chip { - match kind { - Kind::Opl2 => Chip::Opl2(Ym3812::new(NullInterface)), - Kind::Opl3 => Chip::Opl3(Ymf262::new(NullInterface)), +/// The most outputs any chip has: the OPL4's six. +const MAX_OUTPUTS: usize = 6; + +/// Generates one sample of a chip with `N` outputs into the first `N` of +/// `out`. +fn one(out: &mut [i32; MAX_OUTPUTS], generate: impl FnOnce(&mut [Output])) { + let mut sample = [Output::::default()]; + generate(&mut sample); + out[..N].copy_from_slice(&sample[0].data); +} + +/// Dispatches one expression over every variant of [`Chip`], with the chip +/// bound to `$chip`. +macro_rules! each { + ($value:expr, $chip:ident => $body:expr) => { + match $value { + Chip::Ym3526($chip) => $body, + Chip::Y8950($chip) => $body, + Chip::Ym3812($chip) => $body, + Chip::Ymf262($chip) => $body, + Chip::Ymf289b($chip) => $body, + Chip::Ymf278b($chip) => $body, + Chip::Ym2413($chip) => $body, + Chip::Ym2423($chip) => $body, + Chip::Ymf281($chip) => $body, + Chip::Ds1001($chip) => $body, + Chip::Ym2151($chip) => $body, + Chip::Ym2164($chip) => $body, + Chip::Ym2203($chip) => $body, + Chip::Ym2608($chip) => $body, + Chip::Ymf288($chip) => $body, + Chip::Ym2610($chip) => $body, + Chip::Ym2610b($chip) => $body, + Chip::Ym2612($chip) => $body, + Chip::Ym3438($chip) => $body, + Chip::Ymf276($chip) => $body, + Chip::Ym2414($chip) => $body, + Chip::Ym3806($chip) => $body, + Chip::Ym3533($chip) => $body, + Chip::Ym2149($chip) => $body, } + }; +} + +impl Chip { + /// Creates a chip in its power-on state, reading memory through `images`. + /// + /// Boxing a chip allocates, and a reset builds a new one, so a reset on + /// the audio thread allocates once: it is a rare event a game sends, not + /// something every sample does. + fn new(kind: Kind, images: *mut Images) -> Chip { + let memory = Memory(images); + let mut chip = match kind { + Kind::Ym3526 => Chip::Ym3526(Box::new(Ym3526::new(memory))), + Kind::Y8950 => Chip::Y8950(Box::new(Y8950::new(memory))), + Kind::Ym3812 => Chip::Ym3812(Box::new(Ym3812::new(memory))), + Kind::Ymf262 => Chip::Ymf262(Box::new(Ymf262::new(memory))), + Kind::Ymf289b => Chip::Ymf289b(Box::new(Ymf289b::new(memory))), + Kind::Ymf278b => Chip::Ymf278b(Box::new(Ymf278b::new(memory))), + Kind::Ym2413 => Chip::Ym2413(Box::new(Ym2413::new(memory))), + Kind::Ym2423 => Chip::Ym2423(Box::new(Ym2423::new(memory))), + Kind::Ymf281 => Chip::Ymf281(Box::new(Ymf281::new(memory))), + Kind::Ds1001 => Chip::Ds1001(Box::new(Ds1001::new(memory))), + Kind::Ym2151 => Chip::Ym2151(Box::new(Ym2151::new(memory))), + Kind::Ym2164 => Chip::Ym2164(Box::new(Ym2164::new(memory))), + Kind::Ym2203 => Chip::Ym2203(Box::new(Ym2203::new(memory))), + Kind::Ym2608 => Chip::Ym2608(Box::new(Ym2608::new(memory))), + Kind::Ymf288 => Chip::Ymf288(Box::new(Ymf288::new(memory))), + Kind::Ym2610 => Chip::Ym2610(Box::new(Ym2610::new(memory))), + Kind::Ym2610b => Chip::Ym2610b(Box::new(Ym2610b::new(memory))), + Kind::Ym2612 => Chip::Ym2612(Box::new(Ym2612::new(memory))), + Kind::Ym3438 => Chip::Ym3438(Box::new(Ym3438::new(memory))), + Kind::Ymf276 => Chip::Ymf276(Box::new(Ymf276::new(memory))), + Kind::Ym2414 => Chip::Ym2414(Box::new(Ym2414::new(memory))), + Kind::Ym3806 => Chip::Ym3806(Box::new(Ym3806::new(memory))), + Kind::Ym3533 => Chip::Ym3533(Box::new(Ym3533::new(memory))), + Kind::Ym2149 => Chip::Ym2149(Box::new(Ym2149::new(memory))), + }; + chip.fidelity(); + chip } - fn kind(&self) -> Kind { + /// Puts the OPN chips with an SSG on the fidelity `Kind::divider` names. + fn fidelity(&mut self) { match self { - Chip::Opl2(_) => Kind::Opl2, - Chip::Opl3(_) => Kind::Opl3, + Chip::Ym2203(chip) => chip.set_fidelity(OpnFidelity::Min), + Chip::Ym2608(chip) => chip.set_fidelity(OpnFidelity::Min), + Chip::Ymf288(chip) => chip.set_fidelity(OpnFidelity::Min), + Chip::Ym2610(chip) => chip.set_fidelity(OpnFidelity::Min), + Chip::Ym2610b(chip) => chip.set_fidelity(OpnFidelity::Min), + _ => {} } } - /// Reports the rate the emulator gives the chip's input clock, which - /// [`RATE`] has to be. + /// Reports the rate the emulator gives `clock`, which `Kind::divider` + /// has to agree with. #[cfg(test)] - fn sample_rate(&self) -> u32 { - match self { - Chip::Opl2(chip) => chip.sample_rate(OPL2_CLOCK), - Chip::Opl3(chip) => chip.sample_rate(OPL3_CLOCK), - } + fn sample_rate(&self, clock: u32) -> u32 { + each!(self, chip => chip.sample_rate(clock)) + } + + /// Writes `data` to the port at `offset`, as the hardware's bus does. + fn port(&mut self, offset: u16, data: u8) { + let offset = u32::from(offset); + each!(self, chip => chip.write(offset, data)) } - /// Writes `value` to `register` the way a driver does, through the address - /// port and then the data port. + /// Writes `value` to `register` the way a driver does: the register's + /// low byte to its bank's address port, then the value to the bank's + /// data port, or straight to the register on a chip that has no address + /// port. /// /// An OPL3 register from 0x100 up goes through the second bank's address /// port (offset 2, data at 3), and the chip itself masks it back to the /// first bank until the NEW bit in 0x105 is set, as the hardware does. - fn write(&mut self, register: u16, value: u8) { - match self { - Chip::Opl2(chip) => { - chip.write(0, register as u8); - chip.write(1, value); - } - Chip::Opl3(chip) => { - let bank = u32::from(register >> 8 & 1) * 2; - chip.write(bank, register as u8); - chip.write(bank + 1, value); + fn register(&mut self, kind: Kind, register: u16, value: u8) { + match kind.layout() { + Layout::Direct => self.port(register, value), + Layout::Banks(banks) => { + let Some((address, data)) = banks.get(usize::from(register >> 8)) else { + return; + }; + self.port(*address, register as u8); + self.port(*data, value); } } } - /// Generates the next sample as a left and right pair: the OPL2's one - /// output on both sides, the OPL3's outputs A and B, which the pan bits of - /// each channel select. - fn generate(&mut self) -> (i32, i32) { + /// Generates the next sample of every output into `out`, in the chip's + /// order. + fn generate(&mut self, out: &mut [i32; MAX_OUTPUTS]) { match self { - Chip::Opl2(chip) => { - let mut out = [Output::<1>::default()]; - chip.generate(&mut out); - (out[0].data[0], out[0].data[0]) - } - Chip::Opl3(chip) => { - let mut out = [Output::<4>::default()]; - chip.generate(&mut out); - (out[0].data[0], out[0].data[1]) - } + Chip::Ym3526(chip) => one::<1>(out, |s| chip.generate(s)), + Chip::Y8950(chip) => one::<1>(out, |s| chip.generate(s)), + Chip::Ym3812(chip) => one::<1>(out, |s| chip.generate(s)), + Chip::Ymf262(chip) => one::<4>(out, |s| chip.generate(s)), + Chip::Ymf289b(chip) => one::<2>(out, |s| chip.generate(s)), + Chip::Ymf278b(chip) => one::<6>(out, |s| chip.generate(s)), + Chip::Ym2413(chip) => one::<2>(out, |s| chip.generate(s)), + Chip::Ym2423(chip) => one::<2>(out, |s| chip.generate(s)), + Chip::Ymf281(chip) => one::<2>(out, |s| chip.generate(s)), + Chip::Ds1001(chip) => one::<2>(out, |s| chip.generate(s)), + Chip::Ym2151(chip) => one::<2>(out, |s| chip.generate(s)), + Chip::Ym2164(chip) => one::<2>(out, |s| chip.generate(s)), + Chip::Ym2203(chip) => one::<4>(out, |s| chip.generate(s)), + Chip::Ym2608(chip) => one::<3>(out, |s| chip.generate(s)), + Chip::Ymf288(chip) => one::<3>(out, |s| chip.generate(s)), + Chip::Ym2610(chip) => one::<3>(out, |s| chip.generate(s)), + Chip::Ym2610b(chip) => one::<3>(out, |s| chip.generate(s)), + Chip::Ym2612(chip) => one::<2>(out, |s| chip.generate(s)), + Chip::Ym3438(chip) => one::<2>(out, |s| chip.generate(s)), + Chip::Ymf276(chip) => one::<2>(out, |s| chip.generate(s)), + Chip::Ym2414(chip) => one::<2>(out, |s| chip.generate(s)), + Chip::Ym3806(chip) => one::<2>(out, |s| chip.generate(s)), + Chip::Ym3533(chip) => one::<2>(out, |s| chip.generate(s)), + Chip::Ym2149(chip) => one::<3>(out, |s| chip.generate(s)), } } +} + +/// A one-pole high-pass filter that removes direct current, at a few hertz. +#[derive(Clone, Copy, Default)] +struct DcBlocker { + input: f32, + output: f32, +} - /// Returns the chip to its power-on state, as `new` makes it. - fn reset(&mut self) { - *self = Chip::new(self.kind()); +impl DcBlocker { + fn process(&mut self, sample: f32, pole: f32) -> f32 { + let output = sample - self.input + pole * self.output; + self.input = sample; + self.output = output; + output } } +/// The corner the SSG filter cuts below, in hertz. +const DC_CORNER: f64 = 5.0; + +/// Returns the pole of the SSG filter at a clock of `rate` frames a second. +fn dc_pole(rate: u32) -> f32 { + (1.0 - 2.0 * std::f64::consts::PI * DC_CORNER / f64::from(rate)) as f32 +} + /// Writes the queue holds between the game thread and the callback, which is /// also the largest batch `writes` can ever accept. pub const QUEUE_WRITES: usize = synth::QUEUE_EVENTS; -/// One register write, as a batch crosses the C ABI. +/// One register write, or one port write, as a batch crosses the C ABI. #[repr(C)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct RegisterWrite { - /// The register, from 0x00 to 0xFF on the OPL2 and 0x000 to 0x1FF on the - /// OPL3, whose second bank is 0x100 up. + /// The register, up to the chip's last, or the port offset, up to its + /// last port. pub register: u16, /// The value, from 0 to 255. Sixteen bits wide so the struct has no /// padding for two languages to disagree about. @@ -158,31 +793,114 @@ pub struct RegisterWrite { /// The chip as a scheduler target. /// -/// A write event carries the register's low byte in `status`, its bank in -/// `data1` and the value in `data2`. +/// A register write carries the register's low byte in `status`, its high +/// byte in `data1` and the value in `data2`; a port write carries the offset +/// the same way. A memory event carries the class in `data1`, and its image +/// is the next one waiting on the memory channel. pub struct Fm { + kind: Kind, chip: Chip, + /// The images the chip reads, owned here through a raw pointer so the + /// chip's interface can point at them: from `Box::into_raw`, freed in + /// `Drop`. + images: *mut Images, + images_in: Receiver>, + images_out: SyncSender>, + decimation: u32, + /// The routes of `kind.outputs()`. + routes: &'static [Route], + /// The scale from the emulator's integers to the mixer's floats, with the + /// average over the decimated samples folded in. + scale: f32, + /// The SSG filter for each side and its pole, on a chip with an SSG. + dc: Option<(DcBlocker, DcBlocker, f32)>, +} + +// SAFETY: `images` is owned by this target alone, and the chip's interface +// that also points at it moves with the target. See `Memory`. +unsafe impl Send for Fm {} + +impl Drop for Fm { + fn drop(&mut self) { + // SAFETY: the pointer came from `Box::into_raw` in `open` and is + // freed only here. The chip still holds a copy, but its interface has + // no destructor and the chip is not used again. + drop(unsafe { Box::from_raw(self.images) }); + } +} + +impl Fm { + /// Replaces the image of `class` and hands back the one it displaced. + fn swap(&mut self, class: MemoryClass, image: Box<[u8]>) -> Box<[u8]> { + // SAFETY: the chip is not generating, since this target is borrowed + // mutably here, so this is the one live reference to the images. + let images = unsafe { &mut *self.images }; + std::mem::replace(images.slot(class), image) + } + + /// Generates the next frame: as many samples as the decimation names, + /// averaged, folded to stereo and, for an SSG, filtered. + fn frame(&mut self) -> (f32, f32) { + let mut outputs = [0i32; MAX_OUTPUTS]; + let mut sums = [0f32; MAX_OUTPUTS]; + for _ in 0..self.decimation { + self.chip.generate(&mut outputs); + for (sum, output) in sums.iter_mut().zip(outputs.iter()) { + *sum += *output as f32; + } + } + let (mut l, mut r) = (0.0f32, 0.0f32); + for route in self.routes { + l += sums[route.output] * route.left; + r += sums[route.output] * route.right; + } + l *= self.scale; + r *= self.scale; + if let Some((left, right, pole)) = &mut self.dc { + l = left.process(l, *pole); + r = right.process(r, *pole); + } + (l, r) + } } impl Target for Fm { const BLOCK_FRAMES: usize = 1; fn apply(&mut self, event: &Event) { + let address = u16::from(event.status) | (u16::from(event.data1) << 8); match event.kind { - KIND_MESSAGE => { - let register = u16::from(event.status) | (u16::from(event.data1) << 8); - self.chip.write(register, event.data2); + KIND_MESSAGE => self.chip.register(self.kind, address, event.data2), + KIND_PORT => self.chip.port(address, event.data2), + KIND_RESET => { + // A reset keeps the memory images, as the hardware's reset + // line leaves its RAM alone. + self.chip = Chip::new(self.kind, self.images); + if let Some((left, right, _)) = &mut self.dc { + *left = DcBlocker::default(); + *right = DcBlocker::default(); + } + } + KIND_MEMORY => { + let Some(class) = MemoryClass::from_index(u32::from(event.data1)) else { + return; + }; + let Ok(image) = self.images_in.try_recv() else { + return; + }; + let displaced = self.swap(class, image); + // Freed on the game thread, which collects it at its next + // memory load; dropped here only if that queue is full, which + // takes a game loading images faster than the chip renders. + let _ = self.images_out.try_send(displaced); } - KIND_RESET => self.chip.reset(), _ => {} } } fn render(&mut self, left: &mut [f32], right: &mut [f32]) { for (l, r) in left.iter_mut().zip(right.iter_mut()) { - let (a, b) = self.chip.generate(); - *l = a as f32 / 32768.0; - *r = b as f32 / 32768.0; + (*l, *r) = self.frame(); } } } @@ -192,15 +910,18 @@ impl Target for Fm { pub struct FmPort { port: Port, kind: Kind, + clock: u32, + images_out: SyncSender>, + images_back: Receiver>, } impl FmPort { - /// Converts a time on the chip clock to the first sample at or after it. + /// Converts a time on the chip clock to the first frame at or after it. /// - /// A time within a millionth of a sample of a whole sample is that - /// sample, so a time computed as `now() + k / RATE` lands on sample `k` - /// rather than one past it for a rounding error. Zero, a negative time and - /// a time that is not a number all mean the next sample. + /// A time within a millionth of a frame of a whole frame is that frame, + /// so a time computed as `now() + k / rate` lands on frame `k` rather + /// than one past it for a rounding error. Zero, a negative time and a + /// time that is not a number all mean the next frame. fn frame_at(&self, seconds: f64) -> u64 { if seconds.is_nan() || seconds <= 0.0 { return 0; @@ -214,12 +935,12 @@ impl FmPort { } } - fn event(register: u16, value: u8, frame: u64) -> Event { + fn event(kind: u8, address: u16, value: u8, frame: u64) -> Event { Event { frame, - kind: KIND_MESSAGE, - status: register as u8, - data1: (register >> 8) as u8, + kind, + status: address as u8, + data1: (address >> 8) as u8, data2: value, } } @@ -229,6 +950,16 @@ impl FmPort { self.kind } + /// Reports the input clock the chip was made with, in hertz. + pub fn clock(&self) -> u32 { + self.clock + } + + /// Reports the rate of the chip's clock, in frames a second. + pub fn rate(&self) -> u32 { + self.port.sample_rate() + } + /// Queues one register write for `at` seconds on the chip clock, and /// reports whether it was queued: false when the queue is full or the /// register is past the chip's last. @@ -237,14 +968,32 @@ impl FmPort { return false; } let frame = self.frame_at(at); - self.port.push(FmPort::event(register, value, frame)) + self.port + .push(FmPort::event(KIND_MESSAGE, register, value, frame)) + } + + /// Queues one write of `data` to the port at `offset`, for `at` seconds + /// on the chip clock, and reports whether it was queued: false when the + /// queue is full or the offset is past the chip's last port. + pub fn write_port(&mut self, offset: u16, data: u8, at: f64) -> bool { + if offset > self.kind.last_port() { + return false; + } + let frame = self.frame_at(at); + self.port + .push(FmPort::event(KIND_PORT, offset, data, frame)) } /// Queues every write in `writes` for `at`, in order, all or none, and /// reports which: none when the queue cannot take them all, or when any - /// register is past the chip's last or any value past 255. - pub fn writes(&mut self, writes: &[RegisterWrite], at: f64) -> bool { - let last = self.kind.last_register(); + /// address is past the chip's last or any value past 255. The addresses + /// are registers, or port offsets when `ports` is set. + pub fn writes(&mut self, writes: &[RegisterWrite], at: f64, ports: bool) -> bool { + let (last, kind) = if ports { + (self.kind.last_port(), KIND_PORT) + } else { + (self.kind.last_register(), KIND_MESSAGE) + }; if writes .iter() .any(|write| write.register > last || write.value > 0xff) @@ -253,16 +1002,63 @@ impl FmPort { } let frame = self.frame_at(at); self.port.push_all(writes.len(), |index| { - FmPort::event(writes[index].register, writes[index].value as u8, frame) + FmPort::event( + kind, + writes[index].register, + writes[index].value as u8, + frame, + ) }) } + /// Replaces the memory image of `class` with a copy of `image`, from the + /// chip's next frame, and reports whether it was queued: false when the + /// chip reads no memory of that class, the image is past [`MAX_IMAGE`], + /// [`MEMORY_QUEUE`] images are already on their way, or the write queue + /// is full. + /// + /// An image replaces the whole class rather than a range of it, and lands + /// before any write due on the same frame. A reset keeps it. + pub fn set_memory(&mut self, class: MemoryClass, image: &[u8]) -> bool { + self.collect(); + if !self.kind.reads(class) || image.len() > MAX_IMAGE { + return false; + } + // An image sent without its event would be taken by the next one, so + // the queue is checked first. This port is the ring's only producer + // and the callback only ever makes room, so room seen here is still + // there for the push below. + if !self.port.has_room() { + self.port.refuse(); + return false; + } + if self + .images_out + .try_send(image.to_vec().into_boxed_slice()) + .is_err() + { + return false; + } + self.port.push(FmPort::event( + KIND_MEMORY, + u16::from(class.index()) << 8, + 0, + 0, + )) + } + + /// Frees the images the chip has displaced, on this thread. + fn collect(&mut self) { + while self.images_back.try_recv().is_ok() {} + } + /// Queues a return to the chip's power-on state for `at` seconds on the - /// chip clock, where zero or a time already passed means the next sample. + /// chip clock, where zero or a time already passed means the next frame. /// /// Writes sent before the reset and due before `at` still apply; those /// due at or after it are discarded, so nothing sent earlier plays over - /// the silence the reset makes. Writes sent after it apply as usual. + /// the silence the reset makes. Writes sent after it apply as usual. The + /// memory images stay. pub fn reset(&mut self, at: f64) -> bool { let frame = self.frame_at(at); self.port.push(Event { @@ -274,13 +1070,13 @@ impl FmPort { }) } - /// Reports the chip clock in seconds: the samples it has rendered over - /// [`RATE`]. + /// Reports the chip clock in seconds: the frames it has rendered over + /// its rate. pub fn now(&self) -> f64 { self.port.now() } - /// Reports the samples the chip has rendered. + /// Reports the frames the chip has rendered. pub fn frames(&self) -> u64 { self.port.frames() } @@ -291,16 +1087,44 @@ impl FmPort { } } -/// Builds the two halves of one chip: the port the game writes through and -/// the source the mixer reads. -pub fn open(kind: Kind) -> (FmPort, SynthSource) { +/// Builds the two halves of one chip at `clock` hertz: the port the game +/// writes through and the source the mixer reads. +/// +/// A clock outside [`MIN_CLOCK`] to [`MAX_CLOCK`] is clamped into it. +pub fn open(kind: Kind, clock: u32) -> (FmPort, SynthSource) { + let clock = clock.clamp(MIN_CLOCK, MAX_CLOCK); + let rate = kind.rate(clock); + let decimation = kind.decimation(clock); + let images = Box::into_raw(Box::::default()); + let (images_out, images_in) = sync_channel(MEMORY_QUEUE); + let (images_return, images_back) = sync_channel(MEMORY_QUEUE); + let dc = kind + .has_ssg() + .then(|| (DcBlocker::default(), DcBlocker::default(), dc_pole(rate))); let (port, source) = synth::assemble( Fm { - chip: Chip::new(kind), + kind, + chip: Chip::new(kind, images), + images, + images_in, + images_out: images_return, + decimation, + routes: kind.outputs(), + scale: 1.0 / (32768.0 * decimation as f32), + dc, }, - RATE, + rate, ); - (FmPort { port, kind }, source) + ( + FmPort { + port, + kind, + clock, + images_out, + images_back, + }, + source, + ) } #[cfg(test)] @@ -308,9 +1132,9 @@ mod tests { use super::*; use crate::source::Source; - /// The writes that put a held sine on channel 0 and key it on. Its - /// envelope jumps straight to full and holds, so the note sounds the same - /// wherever the chip's envelope counter happens to stand. + /// The writes that put a held sine on channel 0 of an OPL and key it on. + /// Its envelope jumps straight to full and holds, so the note sounds the + /// same wherever the chip's envelope counter happens to stand. const NOTE: [(u16, u8); 8] = [ (0x20, 0x01), (0x23, 0x21), @@ -322,9 +1146,17 @@ mod tests { (0xb0, 0x32), ]; + fn opl2() -> (FmPort, SynthSource) { + open(Kind::Ym3812, OPL2_CLOCK) + } + + fn opl3() -> (FmPort, SynthSource) { + open(Kind::Ymf262, OPL3_CLOCK) + } + /// Reads `frames` from a source in pieces of `piece`, as a voice does, - /// and returns the left channel. - fn pull(source: &mut impl Source, frames: usize, piece: usize) -> Vec { + /// and returns both channels. + fn pull_both(source: &mut impl Source, frames: usize, piece: usize) -> Vec<(f32, f32)> { let mut out = Vec::with_capacity(frames); let mut staging = vec![0.0f32; piece * 2]; while out.len() < frames { @@ -335,24 +1167,36 @@ mod tests { .as_chunks::<2>() .0 .iter() - .map(|frame| frame[0]), + .map(|frame| (frame[0], frame[1])), ); } out } + /// Reads `frames` from a source in pieces of `piece` and returns the left + /// channel. + fn pull(source: &mut impl Source, frames: usize, piece: usize) -> Vec { + pull_both(source, frames, piece) + .into_iter() + .map(|(left, _)| left) + .collect() + } + /// Renders `frames` straight from a chip, applying each write before the /// sample it names, which is what ymfm's renders mean by a write's time. fn direct(kind: Kind, writes: &[(u64, u16, u8)], frames: usize) -> Vec { - let mut chip = Chip::new(kind); + let mut images = Images::default(); + let mut chip = Chip::new(kind, &mut images); let mut next = 0; + let mut outputs = [0i32; MAX_OUTPUTS]; (0..frames as u64) .map(|frame| { while next < writes.len() && writes[next].0 <= frame { - chip.write(writes[next].1, writes[next].2); + chip.register(kind, writes[next].1, writes[next].2); next += 1; } - chip.generate().0 as f32 / 32768.0 + chip.generate(&mut outputs); + outputs[0] as f32 / 32768.0 }) .collect() } @@ -371,14 +1215,14 @@ mod tests { (1000.4 / rate, 1001), (1000.9 / rate, 1001), ] { - let (mut port, mut source) = open(Kind::Opl2); + let (mut port, mut source) = opl2(); for (register, value) in NOTE { assert!(port.write(register, value, at)); } // A piece size that does not divide anything, so the reads fall // across the write's sample at an arbitrary offset. let out = pull(&mut source, 3000, 37); - assert_eq!(out, direct(Kind::Opl2, &timed(frame), 3000), "at {at}"); + assert_eq!(out, direct(Kind::Ym3812, &timed(frame), 3000), "at {at}"); assert!(out[..frame as usize].iter().all(|sample| *sample == 0.0)); assert!(out[frame as usize + 4..] .iter() @@ -388,7 +1232,7 @@ mod tests { #[test] fn lands_a_time_computed_from_the_clock_on_its_sample() { - let (mut port, mut source) = open(Kind::Opl2); + let (mut port, mut source) = opl2(); pull(&mut source, 777, 64); // `now` and a step of whole samples, each as floating point carries it. let at = port.now() + 123.0 / f64::from(RATE); @@ -396,13 +1240,13 @@ mod tests { assert!(port.write(register, value, at)); } let out = pull(&mut source, 2000, 64); - let reference = direct(Kind::Opl2, &timed(123), 2000); + let reference = direct(Kind::Ym3812, &timed(123), 2000); assert_eq!(out, reference); } #[test] fn applies_writes_due_on_one_sample_in_sending_order() { - let (mut port, mut source) = open(Kind::Opl2); + let (mut port, mut source) = opl2(); for (register, value) in NOTE { assert!(port.write(register, value, 0.0)); } @@ -414,14 +1258,14 @@ mod tests { let mut writes = timed(0); writes.push((500, 0xa0, 0x10)); writes.push((500, 0xa0, 0x80)); - assert_eq!(out, direct(Kind::Opl2, &writes, 1500)); + assert_eq!(out, direct(Kind::Ym3812, &writes, 1500)); writes.truncate(NOTE.len() + 1); - assert_ne!(out, direct(Kind::Opl2, &writes, 1500)); + assert_ne!(out, direct(Kind::Ym3812, &writes, 1500)); } #[test] fn orders_writes_by_time_rather_than_by_sending() { - let (mut port, mut source) = open(Kind::Opl2); + let (mut port, mut source) = opl2(); let rate = f64::from(RATE); for (register, value) in NOTE { assert!(port.write(register, value, 0.0)); @@ -432,12 +1276,12 @@ mod tests { let mut writes = timed(0); writes.push((300, 0xa0, 0x99)); writes.push((900, 0xb0, 0x12)); - assert_eq!(out, direct(Kind::Opl2, &writes, 1500)); + assert_eq!(out, direct(Kind::Ym3812, &writes, 1500)); } #[test] fn applies_a_past_or_untimed_write_on_the_next_sample() { - let (mut port, mut source) = open(Kind::Opl2); + let (mut port, mut source) = opl2(); pull(&mut source, 640, 64); assert!(port.write(NOTE[0].0, NOTE[0].1, 0.0)); assert!(port.write(NOTE[1].0, NOTE[1].1, -3.0)); @@ -445,50 +1289,40 @@ mod tests { assert!(port.write(*register, *value, 1.0 / f64::from(RATE))); } let out = pull(&mut source, 1000, 64); - let reference = direct(Kind::Opl2, &timed(0), 1000); + let reference = direct(Kind::Ym3812, &timed(0), 1000); assert_eq!(out, reference); } #[test] fn plays_the_opl2_on_both_sides_and_the_opl3_from_its_pan_bits() { - let (mut port, mut source) = open(Kind::Opl2); + let (mut port, mut source) = opl2(); for (register, value) in NOTE { assert!(port.write(register, value, 0.0)); } - let mut out = vec![0.0f32; 2 * 600]; - let mut done = 0; - while done < 600 { - done += source.read(&mut out[done * 2..]); - } - assert!(out - .as_chunks::<2>() - .0 - .iter() - .all(|frame| frame[0] == frame[1])); - assert!(out.iter().any(|sample| *sample != 0.0)); + let out = pull_both(&mut source, 600, 64); + assert!(out.iter().all(|(left, right)| left == right)); + assert!(out.iter().any(|(left, _)| *left != 0.0)); - let (mut port, mut source) = open(Kind::Opl3); + let (mut port, mut source) = opl3(); assert!(port.write(0x105, 0x01, 0.0)); for (register, value) in NOTE { assert!(port.write(register, value, 0.0)); } // Channel 0 to the left only. assert!(port.write(0xc0, 0x10, 0.0)); - let mut out = vec![0.0f32; 2 * 600]; - let mut done = 0; - while done < 600 { - done += source.read(&mut out[done * 2..]); - } - assert!(out.as_chunks::<2>().0.iter().any(|frame| frame[0] != 0.0)); - assert!(out.as_chunks::<2>().0.iter().all(|frame| frame[1] == 0.0)); + let out = pull_both(&mut source, 600, 64); + assert!(out.iter().any(|(left, _)| *left != 0.0)); + assert!(out.iter().all(|(_, right)| *right == 0.0)); } #[test] fn refuses_a_register_past_the_chip_and_queues_nothing() { - let (mut opl2, _source) = open(Kind::Opl2); + let (mut opl2, _source) = opl2(); assert!(!opl2.write(0x100, 0x01, 0.0)); assert!(opl2.write(0xff, 0x01, 0.0)); - let (mut opl3, _source) = open(Kind::Opl3); + assert!(!opl2.write_port(2, 0x01, 0.0)); + assert!(opl2.write_port(1, 0x01, 0.0)); + let (mut opl3, _source) = opl3(); assert!(opl3.write(0x1ff, 0x01, 0.0)); assert!(!opl3.write(0x200, 0x01, 0.0)); let bad = [ @@ -501,7 +1335,12 @@ mod tests { value: 0x100, }, ]; - assert!(!opl3.writes(&bad, 0.0)); + assert!(!opl3.writes(&bad, 0.0, false)); + let ports = [RegisterWrite { + register: 4, + value: 1, + }]; + assert!(!opl3.writes(&ports, 0.0, true)); assert_eq!( opl2.dropped() + opl3.dropped(), 0, @@ -511,7 +1350,7 @@ mod tests { #[test] fn bounds_the_queue_and_takes_a_batch_whole_or_not_at_all() { - let (mut port, mut source) = open(Kind::Opl2); + let (mut port, mut source) = opl2(); let later = 1.0; let batch: Vec = (0..100) .map(|index| RegisterWrite { @@ -524,7 +1363,7 @@ mod tests { } // Fifty slots are left: the batch of a hundred does not fit and none // of it is queued, so all fifty singles still are. - assert!(!port.writes(&batch, later)); + assert!(!port.writes(&batch, later, false)); for _ in 0..50 { assert!(port.write(0x20, 0x01, later)); } @@ -533,7 +1372,7 @@ mod tests { // One sample moves the ring into the schedule, which has the room. pull(&mut source, 1, 1); - assert!(port.writes(&batch, later)); + assert!(port.writes(&batch, later, false)); let too_many = vec![ RegisterWrite { register: 0x20, @@ -541,17 +1380,17 @@ mod tests { }; QUEUE_WRITES + 1 ]; - let (mut fresh, _source) = open(Kind::Opl2); + let (mut fresh, _source) = opl2(); assert!( - !fresh.writes(&too_many, 0.0), + !fresh.writes(&too_many, 0.0, false), "a batch past the queue never fits" ); - assert!(fresh.writes(&too_many[..QUEUE_WRITES], 0.0)); + assert!(fresh.writes(&too_many[..QUEUE_WRITES], 0.0, false)); } #[test] fn resets_to_power_on_and_discards_what_was_pending() { - let (mut port, mut source) = open(Kind::Opl3); + let (mut port, mut source) = opl3(); for (register, value) in NOTE { assert!(port.write(register, value, 0.0)); } @@ -570,13 +1409,13 @@ mod tests { assert!(port.write(register, value, 0.0)); } let replay = pull(&mut source, 2000, 128); - assert_eq!(replay, direct(Kind::Opl3, &timed(0), 2000)); + assert_eq!(replay, direct(Kind::Ymf262, &timed(0), 2000)); } #[test] fn resets_at_its_time_keeping_what_falls_before_it() { let rate = f64::from(RATE); - let (mut port, mut source) = open(Kind::Opl2); + let (mut port, mut source) = opl2(); for (register, value) in NOTE { assert!(port.write(register, value, 0.0)); } @@ -591,7 +1430,7 @@ mod tests { let out = pull(&mut source, 1200, 50); let mut writes = timed(0); writes.push((400, 0xa0, 0x80)); - let before = direct(Kind::Opl2, &writes, 600); + let before = direct(Kind::Ym3812, &writes, 600); assert_eq!(&out[..600], &before[..]); assert!( out[600..800].iter().all(|sample| *sample == 0.0), @@ -599,19 +1438,58 @@ mod tests { ); // The note sent after the reset plays, and the frequency change at 900 // sent before it does not. - let after = direct(Kind::Opl2, &timed(0), 400); + let after = direct(Kind::Ym3812, &timed(0), 400); assert_eq!(&out[800..], &after[..]); } #[test] fn renders_at_the_rate_the_emulator_gives_each_chip_clock() { - assert_eq!(Chip::new(Kind::Opl2).sample_rate(), RATE); - assert_eq!(Chip::new(Kind::Opl3).sample_rate(), RATE); + assert_eq!(Kind::Ym3812.rate(OPL2_CLOCK), RATE); + assert_eq!(Kind::Ymf262.rate(OPL3_CLOCK), RATE); + for kind in Kind::ALL { + for clock in [kind.default_clock(), 4_000_000, MIN_CLOCK] { + let mut images = Images::default(); + let chip = Chip::new(kind, &mut images); + assert_eq!( + clock / kind.divider(), + chip.sample_rate(clock), + "{} at {clock}", + kind.name() + ); + assert!(kind.rate(clock) <= MAX_RATE, "{}", kind.name()); + } + let (port, source) = open(kind, kind.default_clock()); + assert_eq!(port.rate(), kind.rate(kind.default_clock())); + assert_eq!(source.sample_rate(), port.rate()); + assert_eq!(port.clock(), kind.default_clock()); + } + // The three that render past the ceiling at their own clocks, and + // what their frames come to. + assert_eq!(Kind::Ym2203.decimation(3_993_600), 3); + assert_eq!(Kind::Ym2203.rate(3_993_600), 55_466); + assert_eq!(Kind::Ym2608.decimation(7_987_200), 3); + assert_eq!(Kind::Ymf288.decimation(15_974_400), 2); + assert_eq!(Kind::Ym2612.decimation(7_670_453), 1); + assert_eq!(Kind::Ym2612.rate(7_670_453), 53_267); + // A clock past either bound is clamped to it. + let (port, _source) = open(Kind::Ym3812, 1); + assert_eq!(port.clock(), MIN_CLOCK); + } + + #[test] + fn names_every_chip_once_and_reads_the_opl_aliases() { + for kind in Kind::ALL { + assert_eq!(Kind::from_name(kind.name()), Some(kind)); + } + assert_eq!(Kind::from_name("opl2"), Some(Kind::Ym3812)); + assert_eq!(Kind::from_name("opl3"), Some(Kind::Ymf262)); + assert_eq!(Kind::from_name("sn76489"), None); + assert_eq!(Kind::from_name("YM2612"), None); } #[test] fn writes_the_second_bank_of_an_opl3_through_its_own_port() { - let (mut port, mut source) = open(Kind::Opl3); + let (mut port, mut source) = opl3(); assert!(port.write(0x105, 0x01, 0.0)); // The note on channel 9, the first of the second bank, centred. for (register, value) in NOTE { @@ -622,19 +1500,42 @@ mod tests { assert!(out.iter().any(|sample| *sample != 0.0)); // Without the NEW bit the chip masks the bank away, so the same // writes land on channel 0 of the first bank. - let (mut port, mut source) = open(Kind::Opl3); + let (mut port, mut source) = opl3(); for (register, value) in NOTE { assert!(port.write(0x100 | register, value, 0.0)); } let out = pull(&mut source, 600, 64); assert!(out.iter().any(|sample| *sample != 0.0)); - assert_eq!(out, direct(Kind::Opl3, &timed(0), 600)); + assert_eq!(out, direct(Kind::Ymf262, &timed(0), 600)); + } + + #[test] + fn writes_a_register_and_its_two_port_writes_alike() { + let (mut by_register, mut first) = opl3(); + let (mut by_port, mut second) = opl3(); + for (register, value) in NOTE { + assert!(by_register.write(register | 0x100, value, 0.0)); + let batch = [ + RegisterWrite { + register: 2, + value: register & 0xff, + }, + RegisterWrite { + register: 3, + value: u16::from(value), + }, + ]; + assert!(by_port.writes(&batch, 0.0, true)); + } + assert!(by_port.write_port(0, 0x20, 0.0)); + assert!(by_register.write(0x20, 0x00, 0.0)); + assert_eq!(pull(&mut first, 800, 64), pull(&mut second, 800, 64)); } #[test] fn takes_a_key_off_and_on_due_on_one_sample_as_no_change() { let rate = f64::from(RATE); - let (mut port, mut source) = open(Kind::Opl2); + let (mut port, mut source) = opl2(); for (register, value) in NOTE { assert!(port.write(register, value, 0.0)); } @@ -643,12 +1544,12 @@ mod tests { assert!(port.write(0xb0, 0x12, 500.0 / rate)); assert!(port.write(0xb0, 0x32, 500.0 / rate)); let out = pull(&mut source, 1500, 64); - assert_eq!(out, direct(Kind::Opl2, &timed(0), 1500)); + assert_eq!(out, direct(Kind::Ym3812, &timed(0), 1500)); } #[test] fn runs_the_clock_in_chip_samples() { - let (port, mut source) = open(Kind::Opl2); + let (port, mut source) = opl2(); assert_eq!(port.now(), 0.0); pull(&mut source, RATE as usize / 2, 512); assert_eq!(port.frames(), u64::from(RATE / 2)); @@ -657,4 +1558,247 @@ mod tests { assert_eq!(source.channels(), 2); assert!(!source.exhausted()); } + + /// The OPM's held sine on channel 0: algorithm 7, operator 4 alone at + /// full level, keyed on. The OPZ shares the layout of these registers. + const OPM_TONE: [(u16, u8); 10] = [ + (0x20, 0xc7), + (0x60, 0x7f), + (0x68, 0x7f), + (0x70, 0x7f), + (0x78, 0x00), + (0x58, 0x01), + (0x98, 0x1f), + (0xf8, 0x0f), + (0x28, 0x4a), + (0x08, 0x40), + ]; + + /// The writes that sound a held tone on each chip through its register + /// convenience. + fn tone(kind: Kind) -> Vec<(u16, u8)> { + match kind { + Kind::Ym3526 | Kind::Y8950 | Kind::Ym3812 | Kind::Ymf262 | Kind::Ymf289b => { + NOTE.to_vec() + } + Kind::Ymf278b => { + let mut writes = vec![(0x105, 0x03)]; + writes.extend(NOTE); + writes.push((0xc0, 0x30)); + writes + } + // The first instrument on channel 0 at full volume, keyed on. + Kind::Ym2413 | Kind::Ym2423 | Kind::Ymf281 | Kind::Ds1001 => { + vec![(0x30, 0x10), (0x10, 0xab), (0x20, 0x1c)] + } + Kind::Ym2151 | Kind::Ym2164 | Kind::Ym2414 => OPM_TONE.to_vec(), + // Channel 0, or 1 on the YM2610, which has no channel 0: algorithm + // 7, every operator at full level with the fastest attack, both + // sides, keyed on. + Kind::Ym2203 + | Kind::Ym2608 + | Kind::Ymf288 + | Kind::Ym2610 + | Kind::Ym2610b + | Kind::Ym2612 + | Kind::Ym3438 + | Kind::Ymf276 => { + let channel: u16 = if matches!(kind, Kind::Ym2610 | Kind::Ym2610b) { + 1 + } else { + 0 + }; + let mut writes = vec![(0x29, 0x80), (0xb0 + channel, 0x07), (0xb4 + channel, 0xc0)]; + for operator in [0x00, 0x04, 0x08, 0x0c] { + writes.push((0x30 + operator + channel, 0x01)); + writes.push((0x40 + operator + channel, 0x00)); + writes.push((0x50 + operator + channel, 0x1f)); + writes.push((0x80 + operator + channel, 0x0f)); + } + writes.push((0xa4 + channel, 0x22)); + writes.push((0xa0 + channel, 0x69)); + writes.push((0x28, 0xf0 | channel as u8)); + writes + } + // Algorithm 7 on channel 0, every operator at full level and the + // fastest attack, keyed on. + Kind::Ym3806 | Kind::Ym3533 => { + let mut writes = vec![(0x10, 0xc7)]; + for operator in [0x00, 0x08, 0x10, 0x18] { + writes.push((0x40 + operator, 0x01)); + writes.push((0x60 + operator, 0x00)); + writes.push((0x80 + operator, 0x1f)); + writes.push((0xe0 + operator, 0x0f)); + } + writes.push((0x20, 0x4a)); + writes.push((0x28, 0x4a)); + writes.push((0x30, 0x4a)); + writes.push((0x38, 0x4a)); + writes.push((0x05, 0x78)); + writes + } + // Channel A on a tone at full volume. + Kind::Ym2149 => vec![(0x00, 0xfe), (0x01, 0x00), (0x07, 0x3e), (0x08, 0x0f)], + } + } + + #[test] + fn sounds_every_chip_and_folds_its_outputs_to_stereo() { + for kind in Kind::ALL { + let (mut port, mut source) = open(kind, kind.default_clock()); + for (register, value) in tone(kind) { + assert!(port.write(register, value, 0.0), "{}", kind.name()); + } + let out = pull_both(&mut source, 4000, 256); + assert!( + out.iter() + .all(|(left, right)| left.is_finite() && right.is_finite()), + "{}", + kind.name() + ); + assert!( + out.iter() + .any(|(left, right)| *left != 0.0 || *right != 0.0), + "{} makes a sound", + kind.name() + ); + if kind.mono() { + assert!( + out.iter().all(|(left, right)| left == right), + "{} is mono", + kind.name() + ); + } + } + } + + #[test] + fn removes_the_offset_of_a_held_ssg_level() { + let kind = Kind::Ym2149; + let (mut port, mut source) = open(kind, kind.default_clock()); + // Tone and noise off: the channel holds its level, which the filter + // takes back to zero. + for (register, value) in [(0x07, 0x3f), (0x08, 0x0f)] { + assert!(port.write(register, value, 0.0)); + } + let frames = kind.rate(kind.default_clock()) as usize; + let out = pull(&mut source, frames, 512); + assert!(out[1..100].iter().any(|sample| *sample > 0.0)); + assert!(out[out.len() - 100..] + .iter() + .all(|sample| sample.abs() < 1e-3)); + } + + #[test] + fn averages_a_chip_that_renders_past_the_ceiling() { + let kind = Kind::Ym2203; + let clock = kind.default_clock(); + let (mut port, mut source) = open(kind, clock); + let writes = tone(kind); + for (register, value) in &writes { + assert!(port.write(*register, *value, 0.0)); + } + let out = pull(&mut source, 1000, 100); + // The same writes straight into a chip, three samples a frame. + let mut images = Images::default(); + let mut chip = Chip::new(kind, &mut images); + for (register, value) in &writes { + chip.register(kind, *register, *value); + } + let mut outputs = [0i32; MAX_OUTPUTS]; + let mut filter = DcBlocker::default(); + let pole = dc_pole(kind.rate(clock)); + for (index, sample) in out.iter().enumerate() { + let mut sum = 0.0f32; + for _ in 0..3 { + chip.generate(&mut outputs); + sum += outputs[0] as f32; + for output in &outputs[1..4] { + sum += *output as f32 * SSG_SHARE; + } + } + let expected = filter.process(sum * (1.0 / (32768.0 * 3.0)), pole); + assert_eq!(*sample, expected, "frame {index}"); + } + assert!(out.iter().any(|sample| *sample != 0.0)); + } + + #[test] + fn takes_memory_only_of_a_class_the_chip_reads() { + let (mut ym2612, _source) = open(Kind::Ym2612, Kind::Ym2612.default_clock()); + assert!(!ym2612.set_memory(MemoryClass::AdpcmA, &[1, 2, 3])); + let (mut ym2610, _source) = open(Kind::Ym2610, Kind::Ym2610.default_clock()); + assert!(ym2610.set_memory(MemoryClass::AdpcmA, &[1, 2, 3])); + assert!(ym2610.set_memory(MemoryClass::AdpcmB, &[1, 2, 3])); + assert!(!ym2610.set_memory(MemoryClass::Pcm, &[1, 2, 3])); + assert!(!ym2610.set_memory(MemoryClass::AdpcmA, &vec![0; MAX_IMAGE + 1])); + let (mut opl4, _source) = open(Kind::Ymf278b, Kind::Ymf278b.default_clock()); + assert!(opl4.set_memory(MemoryClass::Pcm, &[0; 16])); + let (mut y8950, _source) = open(Kind::Y8950, Kind::Y8950.default_clock()); + assert!(y8950.set_memory(MemoryClass::AdpcmB, &[0; 16])); + assert!(!y8950.set_memory(MemoryClass::AdpcmA, &[0; 16])); + // The memory queue is bounded and refuses rather than growing. + let (mut full, _source) = open(Kind::Ym2608, Kind::Ym2608.default_clock()); + for _ in 0..MEMORY_QUEUE { + assert!(full.set_memory(MemoryClass::AdpcmB, &[0; 4])); + } + assert!(!full.set_memory(MemoryClass::AdpcmB, &[0; 4])); + } + + /// Plays channel 0 of a YM2610's ADPCM-A from `image`, and returns what + /// the chip rendered. + fn adpcm_a(image: Option<&[u8]>, reset: bool) -> Vec { + let kind = Kind::Ym2610; + let (mut port, mut source) = open(kind, kind.default_clock()); + if let Some(image) = image { + assert!(port.set_memory(MemoryClass::AdpcmA, image)); + } + if reset { + assert!(port.reset(0.0)); + } + // Start 0 and end 0x0f in 256-byte units, both sides at full level, + // the total level at full, then key on. + for (register, value) in [ + (0x110, 0x00), + (0x118, 0x00), + (0x120, 0x0f), + (0x128, 0x00), + (0x108, 0xdf), + (0x101, 0x3f), + (0x100, 0x01), + ] { + assert!(port.write(register, value, 0.0)); + } + pull(&mut source, 2000, 128) + } + + #[test] + fn reads_samples_from_a_memory_image_and_keeps_it_across_a_reset() { + let image: Vec = (0..4096u32).map(|index| (index * 37 % 251) as u8).collect(); + let silent = adpcm_a(None, false); + let played = adpcm_a(Some(&image), false); + assert_ne!(played, silent, "the image is what the channel plays"); + assert!(played.iter().any(|sample| *sample != 0.0)); + assert_eq!( + adpcm_a(Some(&image), true), + played, + "a reset keeps the image" + ); + } + + #[test] + fn hands_a_displaced_image_back_to_the_game_thread() { + let (mut port, mut source) = open(Kind::Ym2610, Kind::Ym2610.default_clock()); + for _ in 0..3 { + assert!(port.set_memory(MemoryClass::AdpcmB, &[0; 64])); + } + pull(&mut source, 4, 4); + // Each load displaced the one before it, the first the empty image, + // so three came back. + let mut returned = 0; + while port.images_back.try_recv().is_ok() { + returned += 1; + } + assert_eq!(returned, 3); + } } diff --git a/native/rust/tecs-audio/src/lib.rs b/native/rust/tecs-audio/src/lib.rs index 9eedd99d..ba74c485 100644 --- a/native/rust/tecs-audio/src/lib.rs +++ b/native/rust/tecs-audio/src/lib.rs @@ -593,36 +593,69 @@ pub unsafe extern "C" fn tecs_audio_synth_close(synth: *mut Port) { } } -/// Opens an emulated OPL2, or an OPL3 when `opl3` is nonzero, for the voice +/// Opens the emulated FM chip `name` selects at `clock` hertz, for the voice /// `handle` names. /// -/// The chip starts sounding when a `PLAY` carrying the same handle and the -/// synth flag reaches `tecs_audio_submit`, as a General MIDI synthesizer does. -/// Until then writes wait in its queue and its clock stands at zero. +/// `name` is a part number in lower case, such as `ym2612`, or `opl2` and +/// `opl3`. A clock of zero means the chip's own; any other is clamped to the +/// range `fm::MIN_CLOCK` to `fm::MAX_CLOCK`. The chip starts sounding when a +/// `PLAY` carrying the same handle and the synth flag reaches +/// `tecs_audio_submit`, as a General MIDI synthesizer does. Until then writes +/// wait in its queue and its clock stands at zero. /// /// # Safety /// -/// The pointer must name a live device. The returned port is owned by the -/// caller, outlives the device safely, and is freed by `tecs_audio_fm_close`. +/// The pointer must name a live device and `name` a NUL-terminated string. +/// The returned port is owned by the caller, outlives the device safely, and +/// is freed by `tecs_audio_fm_close`. /// /// @return the port, or null with the reason left for `tecs_audio_last_error` #[no_mangle] pub unsafe extern "C" fn tecs_audio_fm_open( device: *mut Engine, handle: u32, - opl3: i32, + name: *const c_char, + clock: u32, ) -> *mut FmPort { let value = match unsafe { engine(device) } { None => return std::ptr::null_mut(), Some(value) => value, }; - let kind = if opl3 != 0 { Kind::Opl3 } else { Kind::Opl2 }; - match value.open_fm(handle, kind) { + if name.is_null() { + value.fail("an FM chip needs a name".to_string()); + return std::ptr::null_mut(); + } + let name = unsafe { CStr::from_ptr(name) }.to_string_lossy(); + let Some(kind) = Kind::from_name(&name) else { + value.fail(format!("tecsaudio emulates no FM chip named {name}")); + return std::ptr::null_mut(); + }; + let clock = if clock == 0 { + kind.default_clock() + } else { + clock + }; + match value.open_fm(handle, kind, clock) { Err(_) => std::ptr::null_mut(), Ok(port) => Box::into_raw(Box::new(port)), } } +/// Reports the rate of a chip's clock, in frames a second. +/// +/// # Safety +/// +/// The pointer must name a live port, or be null. +/// +/// @return the rate, and zero for a null port +#[no_mangle] +pub unsafe extern "C" fn tecs_audio_fm_rate(fm: *mut FmPort) -> u32 { + match unsafe { fm_port(fm) } { + None => 0, + Some(port) => port.rate(), + } +} + /// Borrows the port a chip pointer names. /// /// # Safety @@ -665,7 +698,35 @@ pub unsafe extern "C" fn tecs_audio_fm_write( } } -/// Queues `count` register writes for `at`, all or none. +/// Queues one write of `data` to the port at `offset`, for `at` seconds on +/// the chip clock. +/// +/// # Safety +/// +/// The pointer must name a live port. +/// +/// @return nonzero when the queue took the write, and zero when it was full +/// or the offset or data is out of the chip's range +#[no_mangle] +pub unsafe extern "C" fn tecs_audio_fm_write_port( + fm: *mut FmPort, + offset: u32, + data: u32, + at: f64, +) -> i32 { + match unsafe { fm_port(fm) } { + None => 0, + Some(port) => { + let (Ok(offset), Ok(data)) = (u16::try_from(offset), u8::try_from(data)) else { + return 0; + }; + i32::from(port.write_port(offset, data, at)) + } + } +} + +/// Queues `count` writes for `at`, all or none: register writes, or port +/// writes when `ports` is nonzero. /// /// # Safety /// @@ -680,6 +741,7 @@ pub unsafe extern "C" fn tecs_audio_fm_writes( writes: *const RegisterWrite, count: u32, at: f64, + ports: i32, ) -> i32 { let Some(port) = (unsafe { fm_port(fm) }) else { return 0; @@ -691,7 +753,7 @@ pub unsafe extern "C" fn tecs_audio_fm_writes( return 0; } let batch = unsafe { std::slice::from_raw_parts(writes, count as usize) }; - i32::from(port.writes(batch, at)) + i32::from(port.writes(batch, at, ports != 0)) } /// Queues a return to the chip's power-on state for `at` seconds on the chip @@ -710,6 +772,40 @@ pub unsafe extern "C" fn tecs_audio_fm_reset(fm: *mut FmPort, at: f64) -> i32 { } } +/// Replaces one memory image of a chip, from its next frame, with a copy of +/// the `length` bytes at `bytes`. `class` is 0 for ADPCM-A, 1 for ADPCM-B and +/// 2 for PCM. +/// +/// # Safety +/// +/// The pointer must name a live port, and `bytes` must point at `length` +/// readable bytes, or may be null when `length` is zero. +/// +/// @return nonzero when the image was queued, and zero when the chip reads no +/// memory of that class, the image is too large, or the queue is full +#[no_mangle] +pub unsafe extern "C" fn tecs_audio_fm_memory( + fm: *mut FmPort, + class: u32, + bytes: *const u8, + length: usize, +) -> i32 { + let Some(port) = (unsafe { fm_port(fm) }) else { + return 0; + }; + let Some(class) = fm::MemoryClass::from_index(class) else { + return 0; + }; + let image: &[u8] = if length == 0 { + &[] + } else if bytes.is_null() { + return 0; + } else { + unsafe { std::slice::from_raw_parts(bytes, length) } + }; + i32::from(port.set_memory(class, image)) +} + /// Reports the chip clock: the samples it has rendered over its own rate. /// /// # Safety @@ -1386,17 +1482,24 @@ mod tests { let null: *mut FmPort = std::ptr::null_mut(); unsafe { assert_eq!(tecs_audio_fm_write(null, 0xb0, 0x20, 0.0), 0); - assert_eq!(tecs_audio_fm_writes(null, std::ptr::null(), 0, 0.0), 0); + assert_eq!(tecs_audio_fm_write_port(null, 0, 0x20, 0.0), 0); + assert_eq!(tecs_audio_fm_writes(null, std::ptr::null(), 0, 0.0, 0), 0); + assert_eq!(tecs_audio_fm_memory(null, 0, std::ptr::null(), 0), 0); assert_eq!(tecs_audio_fm_reset(null, 0.0), 0); assert_eq!(tecs_audio_fm_now(null), 0.0); + assert_eq!(tecs_audio_fm_rate(null), 0); tecs_audio_fm_close(null); let device = tecs_audio_open_offline(48_000, 2, 8); - let opl2 = tecs_audio_fm_open(device, 65_537, 0); + let name = |text: &str| CString::new(text).expect("a name"); + let opl2 = tecs_audio_fm_open(device, 65_537, name("opl2").as_ptr(), 0); assert!(!opl2.is_null()); + assert_eq!(tecs_audio_fm_rate(opl2), fm::RATE); assert_eq!(tecs_audio_fm_write(opl2, 0xb0, 0x20, 0.0), 1); assert_eq!(tecs_audio_fm_write(opl2, 0x100, 0x20, 0.0), 0); assert_eq!(tecs_audio_fm_write(opl2, 0xb0, 0x100, 0.0), 0); + assert_eq!(tecs_audio_fm_write_port(opl2, 1, 0x20, 0.0), 1); + assert_eq!(tecs_audio_fm_write_port(opl2, 2, 0x20, 0.0), 0); let batch = [ RegisterWrite { register: 0xa0, @@ -1407,18 +1510,30 @@ mod tests { value: 0x32, }, ]; - assert_eq!(tecs_audio_fm_writes(opl2, batch.as_ptr(), 2, 0.0), 1); - assert_eq!(tecs_audio_fm_writes(opl2, std::ptr::null(), 0, 0.0), 1); - assert_eq!(tecs_audio_fm_writes(opl2, std::ptr::null(), 2, 0.0), 0); + assert_eq!(tecs_audio_fm_writes(opl2, batch.as_ptr(), 2, 0.0, 0), 1); + assert_eq!(tecs_audio_fm_writes(opl2, batch.as_ptr(), 2, 0.0, 1), 0); + assert_eq!(tecs_audio_fm_writes(opl2, std::ptr::null(), 0, 0.0, 0), 1); + assert_eq!(tecs_audio_fm_writes(opl2, std::ptr::null(), 2, 0.0, 0), 0); + assert_eq!(tecs_audio_fm_memory(opl2, 0, [1u8].as_ptr(), 1), 0); assert_eq!(tecs_audio_fm_reset(opl2, 0.0), 1); assert_eq!(tecs_audio_fm_now(opl2), 0.0); - let opl3 = tecs_audio_fm_open(device, 65_538, 1); + let opl3 = tecs_audio_fm_open(device, 65_538, name("opl3").as_ptr(), 0); assert_eq!(tecs_audio_fm_write(opl3, 0x1b0, 0x20, 0.0), 1); + let ym2610 = tecs_audio_fm_open(device, 65_539, name("ym2610").as_ptr(), 8_000_000); + assert_eq!(tecs_audio_fm_rate(ym2610), 55_555); + assert_eq!(tecs_audio_fm_memory(ym2610, 1, [1u8, 2].as_ptr(), 2), 1); + assert_eq!(tecs_audio_fm_memory(ym2610, 2, [1u8, 2].as_ptr(), 2), 0); + assert_eq!(tecs_audio_fm_memory(ym2610, 3, [1u8, 2].as_ptr(), 2), 0); + assert!(tecs_audio_fm_open(device, 65_540, name("sn76489").as_ptr(), 0).is_null()); + let reason = CStr::from_ptr(tecs_audio_last_error(device)).to_string_lossy(); + assert!(reason.contains("sn76489"), "{reason}"); + assert!(tecs_audio_fm_open(device, 65_540, std::ptr::null(), 0).is_null()); tecs_audio_close(device); // A port outlives its device. assert_eq!(tecs_audio_fm_write(opl2, 0xb0, 0x00, 0.0), 1); tecs_audio_fm_close(opl2); tecs_audio_fm_close(opl3); + tecs_audio_fm_close(ym2610); } } diff --git a/native/rust/tecs-audio/src/synth.rs b/native/rust/tecs-audio/src/synth.rs index 81e5254d..884b3703 100644 --- a/native/rust/tecs-audio/src/synth.rs +++ b/native/rust/tecs-audio/src/synth.rs @@ -80,6 +80,15 @@ pub const KIND_NOTES_OFF: u8 = 1; /// Returns every channel to its General MIDI defaults and silences every note. pub const KIND_RESET: u8 = 2; +/// Replaces one memory image of an FM chip with the next one on its memory +/// channel: [`crate::fm`] only. Unlike every other event, a reset does not +/// discard it. +pub const KIND_MEMORY: u8 = 3; + +/// Writes `data2` to the port whose offset is `status` and `data1`, low byte +/// first: [`crate::fm`] only. +pub const KIND_PORT: u8 = 4; + /// Names every channel in a `KIND_NOTES_OFF` event. pub const ALL_CHANNELS: u8 = 0xFF; @@ -325,6 +334,21 @@ impl Port { false } + /// Reports whether the ring has room for one more event. + /// + /// The answer holds until this port pushes, since the callback only ever + /// takes events out. + pub(crate) fn has_room(&self) -> bool { + let tail = self.producer.ring.tail.load(Ordering::Relaxed); + let head = self.producer.ring.head.load(Ordering::Acquire); + tail.wrapping_sub(head) < self.producer.ring.slots.len() + } + + /// Counts a send refused before it reached the ring. + pub(crate) fn refuse(&mut self) { + self.dropped += 1; + } + /// Reports the rate the clock counts frames at. pub(crate) fn sample_rate(&self) -> u32 { self.sample_rate @@ -427,8 +451,12 @@ impl SynthSource { // those would play them over the silence the reset was sent // to make. What is due before it still applies first, as it // would have without the reset. + // A memory load is kept whatever its time: its image is + // already on its way, and dropping the event would leave the + // image for the next load to take. let at = event.frame; - self.schedule.retain(|queued| queued.frame < at); + self.schedule + .retain(|queued| queued.frame < at || queued.kind == KIND_MEMORY); } let at = self .schedule diff --git a/native/rust/tecs-audio/tests/fm.rs b/native/rust/tecs-audio/tests/fm.rs index 0751e0ad..999e7263 100644 --- a/native/rust/tecs-audio/tests/fm.rs +++ b/native/rust/tecs-audio/tests/fm.rs @@ -1,4 +1,4 @@ -//! An emulated OPL as a voice of the mixer, with no audio device. +//! An emulated FM chip as a voice of the mixer, with no audio device. //! //! The emulator is the ymfm-oxide crate, which holds itself to ymfm sample for //! sample, and the scheduling grid is tested in `src/fm.rs`; this covers what lies between them and the output: the parked @@ -42,7 +42,9 @@ fn command(kind: u32, handle: u32) -> Command { fn started(kind: Kind, gain: f32) -> (Engine, FmPort) { let engine = Engine::open_offline(RATE, 2, 8); - let port = engine.open_fm(1, kind).expect("the chip opens"); + let port = engine + .open_fm(1, kind, kind.default_clock()) + .expect("the chip opens"); let mut play = command(command::PLAY, 1); play.flags = command::FLAG_SYNTH; play.gain = gain; @@ -85,10 +87,10 @@ fn starts_nothing_for_an_fm_play_with_no_chip_parked() { #[test] fn plays_a_scheduled_note_resampled_to_the_output() { - let (engine, mut port) = started(Kind::Opl2, 1.0); + let (engine, mut port) = started(Kind::Ym3812, 1.0); assert_eq!(port.now(), 0.0); // A tenth of a second in, on the chip clock. - assert!(port.writes(¬e(), 0.1)); + assert!(port.writes(¬e(), 0.1, false)); let out = render(&engine, 9600); let start = onset(&out).expect("the note sounds"); // The chip lands the note on sample 4972 of its own clock, which the @@ -118,14 +120,14 @@ fn plays_a_scheduled_note_resampled_to_the_output() { fn renders_the_same_schedule_to_the_same_samples() { let mut takes = Vec::new(); for _ in 0..2 { - let (engine, mut port) = started(Kind::Opl3, 1.0); + let (engine, mut port) = started(Kind::Ymf262, 1.0); // NEW on, and channel 0 panned to both sides, which with NEW on it // has to be to sound at all. assert!(port.write(0x105, 0x01, 0.0)); assert!(port.write(0xc0, 0x30, 0.0)); for step in 0..4 { let at = f64::from(step) * 0.05; - assert!(port.writes(¬e(), at)); + assert!(port.writes(¬e(), at, false)); assert!(port.write(0xa0, 0x41 + step as u8 * 0x20, at + 0.01)); assert!(port.write(0xb0, 0x12, at + 0.04)); } @@ -139,11 +141,11 @@ fn renders_the_same_schedule_to_the_same_samples() { fn scales_the_chip_by_its_voice_gain() { let mut levels = Vec::new(); for gain in [1.0f32, 0.5, 0.0] { - let (engine, mut port) = started(Kind::Opl2, 1.0); + let (engine, mut port) = started(Kind::Ym3812, 1.0); let mut set = command(command::SET_GAIN, 1); set.gain = gain; engine.submit(&[set]); - assert!(port.writes(¬e(), 0.0)); + assert!(port.writes(¬e(), 0.0, false)); levels.push(render(&engine, 4800)); } assert!(levels[0].iter().any(|sample| *sample != 0.0)); @@ -155,7 +157,7 @@ fn scales_the_chip_by_its_voice_gain() { #[test] fn holds_the_clock_while_the_voice_is_paused() { - let (engine, port) = started(Kind::Opl2, 1.0); + let (engine, port) = started(Kind::Ym3812, 1.0); render(&engine, 1024); let before = port.frames(); assert!(before > 0); @@ -166,3 +168,37 @@ fn holds_the_clock_while_the_voice_is_paused() { render(&engine, 1024); assert!(port.frames() > before); } + +#[test] +fn plays_a_chip_at_a_rate_of_its_own_through_the_same_voice() { + // A YM2612 at the Mega Drive's clock renders at 53 267 frames a second, + // and the voice resamples it like any other rate. + let (engine, mut port) = started(Kind::Ym2612, 1.0); + assert_eq!(port.rate(), 53_267); + let mut writes = vec![(0xb0, 0x07), (0xb4, 0xc0)]; + for operator in [0x00, 0x04, 0x08, 0x0c] { + writes.push((0x30 + operator, 0x01)); + writes.push((0x40 + operator, 0x00)); + writes.push((0x50 + operator, 0x1f)); + writes.push((0x80 + operator, 0x0f)); + } + writes.extend([(0xa4, 0x22), (0xa0, 0x69), (0x28, 0xf0)]); + for (register, value) in writes { + assert!(port.write(register, value, 0.05)); + } + let out = render(&engine, 4800); + // The YM2612's DAC holds a small offset on a silent chip, as the real + // one's ladder effect does, so the onset is where the output leaves it. + let rest = out[0]; + let start = out + .iter() + .position(|sample| (sample - rest).abs() > 0.01) + .map(|index| index / 2) + .expect("the note sounds"); + // Frame 2664 of the chip's clock, the first at or after 0.05 seconds. + let expected = (2664.0 * f64::from(RATE) / 53_267.0) as usize; + assert!( + (expected..=expected + 2).contains(&start), + "the note starts at {start}" + ); +} diff --git a/src/tecs/audio/fm.nupp b/src/tecs/audio/fm.nupp index e2c815c9..6768c1b5 100644 --- a/src/tecs/audio/fm.nupp +++ b/src/tecs/audio/fm.nupp @@ -1,14 +1,14 @@ module tecs.audio.fm --[[ -An emulated Yamaha OPL2 or OPL3 FM chip, played as one voice of the mixer. +An emulated Yamaha FM chip, played as one voice of the mixer. An [`Fm`](tecs.audio.fm.Fm) takes one voice slot the way `Audio.play` takes one, so it counts against `maxVoices`, joins a group, and follows group gain, group mute, group pause, master gain and master mute like any other voice. What it adds is the chip's register file: a game drives it with the same -register writes a DOS driver sent to an AdLib or a Sound Blaster, and the -emulator renders what the chip would have played. +register writes a driver sent to an AdLib, a Sound Blaster, a Mega Drive or an +arcade board, and the emulator renders what the chip would have played. ```nupp local mixer = tecs.audio.newAudio() @@ -25,14 +25,27 @@ chip:writes({ chip:write(0xB0, 0x12, at + 0.5) -- key off half a second later ``` -The interface is registers and nothing else: no notes, no instruments, no -channels allocated on a game's behalf. That is the whole contract of the real -chip, and it is what lets a port of a game that drove one replay its music -driver's output unchanged, timing and all. A note-level layer belongs above -this, in the game or in a driver written for it. +The interface is registers and ports and nothing else: no notes, no +instruments, no channels allocated on a game's behalf. That is the whole +contract of the real chip, and it is what lets a port of a game that drove one +replay its music driver's output unchanged, timing and all. A note-level layer +belongs above this, in the game or in a driver written for it, and +`tecs.audio.music.fm` plays recorded register logs on it. ## The chips +`chip` names any of the 24 chips the emulator models, by its part number in +lower case: the OPL family `"ym3526"`, `"y8950"`, `"ym3812"`, `"ymf262"`, +`"ymf289b"` and the OPL4 `"ymf278b"`; the OPLL family `"ym2413"`, `"ym2423"`, +`"ymf281"` and the VRC7 `"ds1001"`; the OPM `"ym2151"` and OPP `"ym2164"`; the +OPN family `"ym2203"`, `"ym2608"`, `"ymf288"`, `"ym2610"`, `"ym2610b"`, +`"ym2612"`, `"ym3438"` and `"ymf276"`; the OPZ `"ym2414"`; the OPQ `"ym3806"` +and `"ym3533"`; and the SSG `"ym2149"`, which also stands for the AY-3-8910. +`"opl2"` and `"opl3"` name the YM3812 and the YMF262. `chips` lists them all. + +Each chip runs from an input clock, `clock`, which defaults to the one the +chip usually ran at (`defaultClock`) and sets both its pitch and its rate. + `"opl2"` is the YM3812: nine 2-operator channels, four waveforms once bit 5 of register 0x01 enables them, and rhythm mode through 0xBD. Its registers run from 0x00 to 0xFF and it is mono, so it plays the same on both sides. @@ -47,32 +60,78 @@ right, and a channel with neither set is silent. Bits 6 and 7 select the chip's third and fourth outputs, which a stereo card did not wire to anything, and neither do these. -Status and timer reads are not offered. A driver that polled the timers to pace -itself paces itself on `now` instead. - -The chip runs at its own rate, `rate` samples a second, which is 49715: the -input clock over 72 on the OPL2 and over 288 on the OPL3, 49 715.9 Hz, as the -emulator truncates it. The mixer resamples it to the output's. +## Registers and ports + +`write` and `writes` take a register address the way a driver's own code +wrote one, and send its low byte to the bank's address port and the value to +its data port. A chip with one address port has registers 0x00 to 0xFF; the +OPL3, the YMF289B and the two-bank OPN chips (the YM2608, the YMF288, the +YM2610s and the YM2612s) add a second bank at 0x100; the OPL4 adds its PCM +registers as a third at 0x200. The OPQ has no address port, so its registers, +0x00 to 0x11F, are written directly. `lastRegister` reports the top of the +range. + +`writePort` and `writePorts` write the chip's own ports instead, offset and +byte, as the hardware's bus did: offset 0 and 1 are the first bank's address +and data ports, 2 and 3 the second's, 4 and 5 the OPL4's PCM pair, and the +YM2149's data port is at 2. `lastPort` reports the top offset. A register +write and the two port writes it stands for render identically. + +## Outputs + +Every chip plays as one stereo voice. A mono chip, the OPLs, the OPLLs, the +YM2203 and the YM2149, plays the same on both sides; the OPL3 plays its +outputs A and B; the OPL4 the mix of its FM and wavetable; the OPN2s, the OPM, +the OPZ and the OPQ their left and right. The SSG of the YM2203, the YM2608, +the YM2610 and the YM2149 is mixed in at two thirds of its channels' sum, which +is ymfm's own mix, and filtered of the direct current its square waves carry. + +## Memory + +The Y8950, the YM2608 and the YM2610s play ADPCM, and the OPL4 a wavetable, +from memory beside the chip. `setMemory` hands the chip an image of it: +`"adpcmA"` (the YM2608's rhythm ROM, the YM2610's sample ROM), `"adpcmB"` +(their ADPCM-B ROM or RAM, and the Y8950's) or `"pcm"` (the OPL4's wavetable +ROM and RAM in one map). An image replaces the whole class from the chip's +next frame, before any write due on that frame, and a reset keeps it. A chip +writing its RAM writes into the image, within its length. Without an image a +chip reads zeroes, which is silence: the YM2608's rhythm ROM in particular is +Yamaha's and ships with nothing here. + +## Status, timers and interrupts + +Status and timer reads are not offered, and neither are timers, busy time or +the IRQ line. A driver that polled the timers to pace itself paces itself on +`now` instead, and a recorded log is already timed. What that leaves out is +what only a timer can drive: the OPN's CSM key-on and the end-of-sample flags +of the ADPCM channels. ## The clock and `at` Every writing method takes an optional `at`, in seconds on the chip's own -clock, which `now` reads. The clock is the samples the chip has rendered over +clock, which `now` reads. The clock is the frames the chip has rendered over `rate`, so it is the audio clock rather than the frame clock: it does not drift against what is heard, it never runs backwards, and it stands still while nothing renders the chip, which is the case before the first output buffer, -while its group is paused, and for good on a mixer with no output. +while it or its group is paused, and for good on a mixer with no output. + +`rate` is the emulator's own rate for the chip at its clock, 49715 frames a +second on an OPL2 or an OPL3 at their usual clocks. The YM2203 and the YM2608 +render at three times their FM rate so the SSG can be averaged, and the YMF288 +at twice it; for those, and for any chip on a clock fast enough to pass +64 000, consecutive samples are averaged into one frame, so `rate` never +exceeds 64 000. The mixer resamples the chip to the output's rate. -A write lands on the first chip sample at or after `at`, and where it lands +A write lands on the first chip frame at or after `at`, and where it lands depends only on `at`, so the same schedule renders the same samples on every run and every machine, whatever the frame rate did. A time within a millionth -of a sample of a whole sample counts as that sample, so `now() + n / rate` -lands on sample `n` from now despite rounding. Writes landing on the same -sample apply together, in the order they were sent, before that sample is -generated. So a key-off and a key-on there count as no change: to strike a -note again, space the writes one sample apart, `1 / rate`. +of a frame of a whole frame counts as that frame, so `now() + n / rate` lands +on frame `n` from now despite rounding. Writes landing on the same frame apply +together, in the order they were sent, before that frame is generated. So a +key-off and a key-on there count as no change: to strike a note again, space +the writes one frame apart, `1 / rate`. -An omitted `at`, and one already in the past, means the next sample. Sending +An omitted `at`, and one already in the past, means the next frame. Sending each write as it falls due therefore works, but puts it wherever the next output buffer happens to start, so its timing follows the frame rate. A music driver sends a little ahead instead: run its tick against `now` with a @@ -87,7 +146,8 @@ sent ahead of their time. Either is 4096 writes. A write that finds no room is refused rather than waiting or growing anything: the method returns false and the write is gone. `writes` is all or nothing, so a tick's writes are never applied in part, and a batch of more than 4096 can never be taken. A driver -streaming with a short lookahead never comes near the bound. +streaming with a short lookahead never comes near the bound. Memory images +have a queue of their own, eight deep. ## Without an output @@ -100,37 +160,167 @@ port, which accepts and drops them. ## The emulator -The emulation is the YM3812 and YMF262 of ymfm-oxide, a pure-Rust port of -ymfm, Aaron Giles's BSD-3-Clause emulator of the Yamaha FM chips, which the -crate holds to ymfm sample for sample. Tecs links it as a Cargo dependency -pinned to one commit, and `THIRD_PARTY_NOTICES.md` carries its licence. +The emulation is ymfm-oxide, a pure-Rust port of ymfm, Aaron Giles's +BSD-3-Clause emulator of the Yamaha FM chips, which the crate holds to ymfm +sample for sample. Tecs links it as a Cargo dependency pinned to one commit, +and `THIRD_PARTY_NOTICES.md` carries its licence. The OPLL family plays +ymfm's copyright-free stand-ins for the instrument ROMs. At their usual clocks +an idle chip costs from a fifth of a percent to about one percent of a core; +the YMF288 is the dearest. ]] local audio = require("tecs.audio") local audiobackend = require("tecs.platform.audiobackend") local audiosynth = require("tecs.internal.audiosynth") ---- The rate the chip renders at, in samples a second: `tecsaudio::fm::RATE`, ---- which a Rust test holds to what the emulator reports for both chips. -local RATE: integer = 49715 - --- The writes the queue holds, which is also the largest batch `writes` takes. local QUEUE_WRITES: integer = 4096 ---- The last register each chip decodes. -local LAST_REGISTER: {[string]: integer} = {opl2 = 0xFF, opl3 = 0x1FF} +--- The fastest rate a chip's clock runs at: `tecsaudio::fm::MAX_RATE`. +local MAX_RATE: integer = 64000 + +--- The clocks `new` accepts, in hertz: `tecsaudio::fm::MIN_CLOCK` and +--- `MAX_CLOCK`. +local MIN_CLOCK: integer = 100000 + +local MAX_CLOCK: integer = 100000000 + +--- The largest memory image `setMemory` takes: `tecsaudio::fm::MAX_IMAGE`. +local MAX_IMAGE: integer = 16 * 1024 * 1024 ---- Names a chip `new` can make: the YM3812 (OPL2) or the YMF262 (OPL3). +--- Names a chip `new` can make: a Yamaha part number in lower case, or +--- `"opl2"` and `"opl3"` for the YM3812 and the YMF262. export type Chip = audiobackend.FmChip +--- Names a class of external memory: `"adpcmA"`, `"adpcmB"` or `"pcm"`. +export type Memory = audiobackend.FmMemory + +--- What one chip is, as `new` needs to know it before the emulator answers. +local record Model + --- The input clock it usually ran at, in hertz. + clock: integer + + --- Input clocks per emulator sample: `tecsaudio::fm::Kind::divider`. + divider: integer + + --- The last register the convenience writes. + lastRegister: integer + + --- The last port offset. + lastPort: integer + + --- The memory classes it reads. + memory: {Memory} +end + +local function model( + clock: integer, + divider: integer, + lastRegister: integer, + lastPort: integer, + memory: {Memory}? +): Model + return new Model( + clock = clock, + divider = divider, + lastRegister = lastRegister, + lastPort = lastPort, + memory = memory or {} + ) +end + +local OPL2_CLOCK: integer = 3579545 +local OPL3_CLOCK: integer = 4 * OPL2_CLOCK + +local NO_MEMORY: {Memory} = {} +local ADPCM_B: {Memory} = {"adpcmB"} as {Memory} +local ADPCM: {Memory} = {"adpcmA", "adpcmB"} as {Memory} +local PCM: {Memory} = {"pcm"} as {Memory} + +-- A chip with one bank decodes registers to 0xFF and ports to 1, one with two +-- to 0x1FF and 3, and the OPL4 to 0x2FF and 5. The OPQ is written by register +-- address directly, and the YM2149's data port is at 2 with 3 decoded too. +local ONE: integer, ONE_PORT: integer = 0xFF, 1 +local TWO: integer, TWO_PORT: integer = 0x1FF, 3 +const OPL2_MODEL = model(OPL2_CLOCK, 72, ONE, ONE_PORT, NO_MEMORY) +const OPL3_MODEL = model(OPL3_CLOCK, 288, TWO, TWO_PORT, NO_MEMORY) + +-- Kept in step with `tecsaudio::fm::Kind` by `tests/audiofmtest.nupp`, which +-- compares every rate here with the one the native chip reports. +local MODELS: { + [string]: Model +} = { + opl2 = OPL2_MODEL, + opl3 = OPL3_MODEL, + ym3526 = model(OPL2_CLOCK, 72, ONE, ONE_PORT, NO_MEMORY), + y8950 = model(OPL2_CLOCK, 72, ONE, ONE_PORT, ADPCM_B), + ym3812 = OPL2_MODEL, + ymf262 = OPL3_MODEL, + ymf289b = model(OPL3_CLOCK, 288, TWO, TWO_PORT, NO_MEMORY), + ymf278b = model(33868800, 768, 0x2FF, 5, PCM), + ym2413 = model(OPL2_CLOCK, 72, ONE, ONE_PORT, NO_MEMORY), + ym2423 = model(OPL2_CLOCK, 72, ONE, ONE_PORT, NO_MEMORY), + ymf281 = model(OPL2_CLOCK, 72, ONE, ONE_PORT, NO_MEMORY), + ds1001 = model(OPL2_CLOCK, 72, ONE, ONE_PORT, NO_MEMORY), + ym2151 = model(OPL2_CLOCK, 64, ONE, ONE_PORT, NO_MEMORY), + ym2164 = model(OPL2_CLOCK, 64, ONE, ONE_PORT, NO_MEMORY), + ym2203 = model(3993600, 24, ONE, ONE_PORT, NO_MEMORY), + ym2608 = model(7987200, 48, TWO, TWO_PORT, ADPCM), + ymf288 = model(15974400, 144, TWO, TWO_PORT, NO_MEMORY), + ym2610 = model(8000000, 144, TWO, TWO_PORT, ADPCM), + ym2610b = model(8000000, 144, TWO, TWO_PORT, ADPCM), + ym2612 = model(7670453, 144, TWO, TWO_PORT, NO_MEMORY), + ym3438 = model(7670453, 144, TWO, TWO_PORT, NO_MEMORY), + ymf276 = model(7670453, 144, TWO, TWO_PORT, NO_MEMORY), + ym2414 = model(OPL2_CLOCK, 64, ONE, ONE_PORT, NO_MEMORY), + ym3806 = model(OPL2_CLOCK, 64, 0x11F, 0x11F, NO_MEMORY), + ym3533 = model(OPL2_CLOCK, 64, 0x11F, 0x11F, NO_MEMORY), + ym2149 = model(1789772, 64, ONE, 3, NO_MEMORY), +} + +--- The chip names in the order `chips` lists them. +local NAMES: { + Chip +} = ({ + "ym3526", + "y8950", + "ym3812", + "ymf262", + "ymf289b", + "ymf278b", + "ym2413", + "ym2423", + "ymf281", + "ds1001", + "ym2151", + "ym2164", + "ym2203", + "ym2608", + "ymf288", + "ym2610", + "ym2610b", + "ym2612", + "ym3438", + "ymf276", + "ym2414", + "ym3806", + "ym3533", + "ym2149", + "opl2", + "opl3", +}) as {Chip} + --- Configures `new`. --- --- Read once, when the chip is made. Nothing here is retained. export type Options = { - --- Caller-writable. Selects the chip, `"opl2"` or `"opl3"`, and defaults - --- to `"opl2"`. + --- Caller-writable. Selects the chip by name, and defaults to `"opl2"`. chip: Chip?, + --- Caller-writable. Sets the chip's input clock in hertz, from 100 000 + --- to 100 000 000, and defaults to the chip's own, `defaultClock`. + clock: integer?, + --- Caller-writable. Selects the group the chip's voice joins, so the --- group's gain, mute and pause apply to it. Defaults to none. group: string?, @@ -145,30 +335,45 @@ export type Options = { --- Every writing method returns whether its writes were queued, which is false --- only when the bounded queue is full or the chip has been destroyed. None of --- them raises for either, because a write that cannot be queued is not a ---- reason for a frame to stop. A register or value outside the chip's range ---- raises, because it is a mistake in the call. Nothing here is thread safe: ---- call it from the thread that owns the mixer. +--- reason for a frame to stop. A register, port or value outside the chip's +--- range raises, because it is a mistake in the call. Nothing here is thread +--- safe: call it from the thread that owns the mixer. export record Fm - --- Read-only. Reports the rate the chip renders at, 49715 samples a - --- second, which is the unit of its clock. One sample is `1 / rate` - --- seconds of `at`. + --- Read-only. Reports the rate of the chip's clock in frames a second, + --- which is the unit of `at`: one frame is `1 / rate` seconds. rate: integer - --- Read-only. Reports which chip this is, `"opl2"` or `"opl3"`. + --- Read-only. Reports which chip this is, by the name `new` was given. chip: Chip + --- Read-only. Reports the chip's input clock in hertz. + clock: integer + + --- Read-only. Reports the highest register `write` takes. + lastRegister: integer + + --- Read-only. Reports the highest port offset `writePort` takes. + lastPort: integer + @private mixer: audio.Audio @private port: audiobackend.FmPort @private voice: integer @private destroyed: boolean - @private lastRegister: integer + @private memory: {Memory} write: function(exclusive self: Fm, register: integer, value: integer, at: number?): boolean writes: function(exclusive self: Fm, pairs: {integer}, at: number?): boolean + writePort: function(exclusive self: Fm, offset: integer, data: integer, at: number?): boolean + writePorts: function(exclusive self: Fm, pairs: {integer}, at: number?): boolean + setMemory: function(exclusive self: Fm, class: Memory, image: string): boolean reset: function(exclusive self: Fm, at: number?): boolean now: function(borrows self: Fm): number setGain: function(exclusive self: Fm, gain: number): nil - destroy: function(exclusive self: Fm): nil + setStereo: function(exclusive self: Fm, left: number, right: number): nil + pause: function(exclusive self: Fm): nil + resume: function(exclusive self: Fm): nil + paused: function(borrows self: Fm): boolean + destroy: function(exclusive self: Fm, fadeOut: number?): nil end --- Returns what `tecs.audio` lent the synthesizer modules for one mixer. @@ -185,18 +390,102 @@ local function hex(value: integer): string return string.format("0x%X", value) end ---- Raises unless a register and value are in range for the chip. ---- @raises string when the register is past the chip's last or the value is +--- Returns the model a chip name selects. +--- @raises string when the name selects no chip +local function modelOf(name: string, chip: string): Model + local found = MODELS[chip] + if found == nil then + error( + "tecs.audio.fm." + .. name + .. ": chip must be a Yamaha part number such as \"ym2612\", or \"opl2\" or " + .. "\"opl3\"; tecs.audio.fm.chips() lists them, got " + .. tostring( + chip + ), + 3 + ) + end + + return found +end + +--- Returns the rate of a chip's clock at `clock` hertz, as the emulator gives +--- it: `tecsaudio::fm::Kind::rate`. +local function rateOf(found: Model, clock: integer): integer + local emulated = clock // found.divider + if emulated <= MAX_RATE then + return emulated + end + local decimation = (emulated + MAX_RATE - 1) // MAX_RATE + + return emulated // decimation +end + +--- Raises unless a register or port and a value are in range for the chip. +--- @raises string when the address is past the chip's last or the value is --- outside 0 to 255 -local function check(name: string, borrows self: Fm, register: integer, value: integer): nil +local function check(name: string, borrows self: Fm, address: integer, value: integer, ports: boolean): nil local where = "tecs.audio.fm." .. name .. ": " - if register < 0 or register > self.lastRegister then - local range = "from 0x00 to " .. hex(self.lastRegister) - error(where .. "an " .. self.chip .. " register must be " .. range .. ", got " .. hex(register), 3) + local last = ports and self.lastPort or self.lastRegister + if address < 0 or address > last then + local what = ports and " port" or " register" + error( + where .. "an " .. self.chip .. what .. " must be from 0x00 to " .. hex(last) .. ", got " .. hex(address), + 3 + ) end if value < 0 or value > 0xFF then - error(where .. "a register value must be from 0 to 255, got " .. tostring(value), 3) + error(where .. "a value must be from 0 to 255, got " .. tostring(value), 3) + end +end + +--- Lists every name `new` takes as a chip, the 24 part numbers and then the +--- two aliases. +--- +--- @return a new list, the caller's to keep +export function chips(): {Chip} + local names: {Chip} = {} + for index, name in ipairs(NAMES) do + names[index] = name + end + + return names +end + +--- Returns the input clock a chip usually ran at. +--- +--- @param chip the chip's name +--- @return the clock in hertz +--- @raises string when the name selects no chip +export function defaultClock(chip: Chip): integer + return modelOf("defaultClock", chip).clock +end + +--- Lists the memory classes a chip reads. +--- +--- @param chip the chip's name +--- @return a new list, empty for a chip that reads none +--- @raises string when the name selects no chip +export function memoryClasses(chip: Chip): {Memory} + local classes: {Memory} = {} + for index, class in ipairs(modelOf("memoryClasses", chip).memory) do + classes[index] = class end + + return classes +end + +--- Returns the rate a chip's clock would run at. +--- +--- @param chip the chip's name +--- @param clock the input clock in hertz, or nil for the chip's own +--- @return the rate in frames a second, which `Fm.rate` reports +--- @raises string when the name selects no chip +export function rate(chip: Chip, clock: integer?): integer + local found = modelOf("rate", chip) + + return rateOf(found, clock or found.clock) end --- Makes an emulated FM chip and starts its voice. @@ -208,29 +497,39 @@ end --- instead. --- --- @param mixer the mixer the voice plays on ---- @param options the chip, group and gain, or nil for an OPL2 in no group at ---- a gain of one +--- @param options the chip, clock, group and gain, or nil for an OPL2 on its +--- own clock in no group at a gain of one --- @return the chip, or nil when its voice could not start. A mixer with no --- output is not such a case: it returns a chip that renders nothing. --- @return the reason, when the first return is nil: every voice is busy, or --- the backend refused the chip ---- @raises string when `chip` is neither `"opl2"` nor `"opl3"` +--- @raises string when `chip` names no chip +--- @raises string when `clock` is outside 100 000 to 100 000 000 export function new(mixer: audio.Audio, options: Options?): (Fm?, string?) local chip = (options and options.chip) or "opl2" - local lastRegister = LAST_REGISTER[chip] - if lastRegister == nil then - error("tecs.audio.fm.new: chip must be \"opl2\" or \"opl3\", got " .. tostring(chip), 2) + local found = modelOf("new", chip) + local clock = (options and options.clock) or found.clock + if clock < MIN_CLOCK or clock > MAX_CLOCK or clock ~= math.floor(clock) then + error( + "tecs.audio.fm.new: clock must be a whole number of hertz from 100000 to 100000000, got " .. tostring( + clock + ), + 2 + ) end local function made(port: audiobackend.FmPort, voice: integer): Fm return new Fm( - rate = RATE, + rate = port.rate, chip = chip, + clock = clock, + lastRegister = found.lastRegister, + lastPort = found.lastPort, mixer = mixer, port = port, voice = voice, destroyed = false, - lastRegister = lastRegister + memory = found.memory ) end @@ -241,16 +540,16 @@ export function new(mixer: audio.Audio, options: Options?): (Fm?, string?) return nil, "every voice is busy, so the FM chip has none to play through" end - return made(audiobackend.silentFm(), 0) + return made(audiobackend.silentFm(rateOf(found, clock)), 0) end local open = seam.backend().openFm if open == nil then - return made(audiobackend.silentFm(), voice) + return made(audiobackend.silentFm(rateOf(found, clock)), voice) end -- Opened before the voice's start is flushed, because the start is what -- hands the chip to the mixer. - local port, reason = open(voice, chip) + local port, reason = open(voice, chip, clock) if port == nil then mixer:stop(voice) @@ -263,16 +562,17 @@ end --- Queues one register write. --- --- @param self the chip to write to ---- @param register from 0x00 to 0xFF on an OPL2, and to 0x1FF on an OPL3, ---- whose second bank is 0x100 up +--- @param register from 0x00 to `lastRegister`: 0xFF on a chip with one +--- bank, 0x1FF with two, whose second is 0x100 up, 0x2FF on the OPL4 and +--- 0x11F on the OPQ --- @param value from 0 to 255 --- @param at seconds on the chip clock, where nil or a time already passed ---- means the next sample +--- means the next frame --- @return whether the write was queued, false when the queue is full or the --- chip is destroyed --- @raises string when the register or the value is outside its range function Fm.write(exclusive self: Fm, register: integer, value: integer, at: number?): boolean - check("write", self, register, value) + check("write", self, register, value, false) if self.destroyed then return false end @@ -280,6 +580,27 @@ function Fm.write(exclusive self: Fm, register: integer, value: integer, at: num return self.port.write(register, value, at or 0) end +--- Checks a flat list of address and value pairs, and returns how many pairs +--- it holds. +--- @raises string when the list has an odd length, or a pair is out of range +local function checkPairs(name: string, borrows self: Fm, pairs: {integer}, ports: boolean): integer + local length = #pairs + if length % 2 ~= 0 then + local what = ports and "port" or "register" + error( + "tecs.audio.fm." .. name .. ": pairs must alternate " .. what .. " and value, got " .. tostring( + length + ) .. " numbers", + 3 + ) + end + for index = 1, length, 2 do + check(name, self, pairs[index], pairs[index + 1], ports) + end + + return length // 2 +end + --- Queues a batch of register writes for one time, all or none. --- --- The batch is what one tick of a music driver writes, and taking it whole is @@ -299,20 +620,54 @@ end --- @raises string when the list has an odd length --- @raises string when a register or a value is outside its range function Fm.writes(exclusive self: Fm, pairs: {integer}, at: number?): boolean - local length = #pairs - if length % 2 ~= 0 then - error( - "tecs.audio.fm.writes: pairs must alternate register and value, got " .. tostring(length) .. " numbers", - 2 - ) + local count = checkPairs("writes", self, pairs, false) + if self.destroyed then + return false end - for index = 1, length, 2 do - check("writes", self, pairs[index], pairs[index + 1]) + if count == 0 then + return true + end + if count > QUEUE_WRITES then + return false end + + return self.port.writes(pairs, count, at or 0) +end + +--- Queues one write to one of the chip's ports. +--- +--- @param self the chip to write to +--- @param offset from 0 to `lastPort`: the first bank's address and data +--- ports at 0 and 1, the second's at 2 and 3, the OPL4's PCM pair at 4 +--- and 5, the YM2149's data port at 2, and the register itself on the OPQ +--- @param data from 0 to 255 +--- @param at seconds on the chip clock, read as `write` reads it +--- @return whether the write was queued +--- @raises string when the offset or the data is outside its range +function Fm.writePort(exclusive self: Fm, offset: integer, data: integer, at: number?): boolean + check("writePort", self, offset, data, true) + if self.destroyed then + return false + end + + return self.port.writePort(offset, data, at or 0) +end + +--- Queues a batch of port writes for one time, all or none, as `writes` does +--- register writes. +--- +--- @param self the chip to write to +--- @param pairs a flat list alternating port offset and data, applied in +--- order and not retained +--- @param at seconds on the chip clock for every write in the batch +--- @return whether the whole batch was queued +--- @raises string when the list has an odd length +--- @raises string when an offset or a byte is outside its range +function Fm.writePorts(exclusive self: Fm, pairs: {integer}, at: number?): boolean + local count = checkPairs("writePorts", self, pairs, true) if self.destroyed then return false end - local count = length // 2 if count == 0 then return true end @@ -320,7 +675,42 @@ function Fm.writes(exclusive self: Fm, pairs: {integer}, at: number?): boolean return false end - return self.port.writes(pairs, count, at or 0) + return self.port.writePorts(pairs, count, at or 0) +end + +--- Replaces one of the chip's memory images, from its next frame. +--- +--- The image is copied during the call, so the string is the caller's to +--- keep. It replaces the whole class rather than a range of it, lands before +--- any write due on the same frame, and survives a reset. Address zero is the +--- first byte of the string; past its end the chip reads zeroes. +--- +--- @param self the chip to load +--- @param class `"adpcmA"`, `"adpcmB"` or `"pcm"`, which `memoryClasses` +--- lists for each chip +--- @param image the bytes, at most 16 MiB +--- @return whether the image was queued: false when eight are already on +--- their way, the write queue is full, or the chip is destroyed +--- @raises string when the chip reads no memory of that class +--- @raises string when the image is larger than 16 MiB +function Fm.setMemory(exclusive self: Fm, class: Memory, image: string): boolean + local reads = false + for _, candidate in ipairs(self.memory) do + if candidate == class then + reads = true + end + end + if not reads then + error("tecs.audio.fm.setMemory: the " .. self.chip .. " reads no " .. tostring(class) .. " memory", 2) + end + if #image > MAX_IMAGE then + error("tecs.audio.fm.setMemory: a memory image is at most 16 MiB, got " .. tostring(#image) .. " bytes", 2) + end + if self.destroyed then + return false + end + + return self.port.setMemory(class, image) end --- Returns the chip to its power-on state. @@ -329,7 +719,7 @@ end --- release, and the chip's internal clocks start again. Writes sent before --- this call and due at or after `at` are discarded, scheduled ones included; --- those due before it still play up to it. Writes sent after it apply as ---- usual, including at `at` itself. +--- usual, including at `at` itself. Memory images stay. --- --- @param self the chip to reset --- @param at seconds on the chip clock, read as `write` reads it @@ -346,9 +736,9 @@ end --- --- @param self the chip to read --- @return seconds of audio the chip has rendered: monotonic, zero before its ---- first sample, still while its group is paused, and zero for good on a ---- mixer with no output. It leads what is heard by the output's own ---- latency, and leads the mix by at most a sample or two. +--- first frame, still while it or its group is paused, and zero for good +--- on a mixer with no output. It leads what is heard by the output's own +--- latency, and leads the mix by at most a frame or two. function Fm.now(borrows self: Fm): number if self.destroyed then return 0 @@ -366,17 +756,57 @@ function Fm.setGain(exclusive self: Fm, gain: number): nil self.mixer:setGain(self.voice, gain) end +--- Places the chip's voice between the speakers, as `Audio.setStereo` does. +--- +--- What a pair of chips wired one to each side wants, as the two OPL2s of a +--- Sound Blaster Pro were. +--- +--- @param self the chip to place +--- @param left the linear gain of the left side +--- @param right the linear gain of the right side +function Fm.setStereo(exclusive self: Fm, left: number, right: number): nil + self.mixer:setStereo(self.voice, left, right) +end + +--- Pauses the chip's voice, which stops its clock with it. +--- +--- Nothing is lost: the chip holds every note where it stood, writes already +--- scheduled wait on the stopped clock, and `resume` carries on exactly where +--- it left off. +--- +--- @param self the chip to pause +function Fm.pause(exclusive self: Fm): nil + self.mixer:pause(self.voice) +end + +--- Resumes a paused chip. +--- +--- @param self the chip to resume +function Fm.resume(exclusive self: Fm): nil + self.mixer:resume(self.voice) +end + +--- Reports whether the chip's own voice is paused, leaving its group aside. +--- +--- @param self the chip to inspect +--- @return true between `pause` and `resume` +function Fm.paused(borrows self: Fm): boolean + return self.mixer:paused(self.voice) +end + --- Stops the chip's voice and frees the emulator. Safe more than once. --- ---- The voice stops without a fade, so a note still sounding is cut. Key the ---- notes off a little before this for a softer ending. +--- Without a fade the voice stops at once, so a note still sounding is cut. +--- With one it fades over `fadeOut` seconds, the chip rendering on and taking +--- no more writes, and the emulator goes when the fade ends. --- --- @param self the chip to destroy -function Fm.destroy(exclusive self: Fm): nil +--- @param fadeOut seconds to fade over, or nil to stop at once +function Fm.destroy(exclusive self: Fm, fadeOut: number?): nil if self.destroyed then return end self.destroyed = true - self.mixer:stop(self.voice) + self.mixer:stop(self.voice, fadeOut) self.port.close() end diff --git a/src/tecs/platform/audiobackend.nupp b/src/tecs/platform/audiobackend.nupp index 256951ae..f5719db3 100644 --- a/src/tecs/platform/audiobackend.nupp +++ b/src/tecs/platform/audiobackend.nupp @@ -124,8 +124,38 @@ export record SynthPort end --- Names the FM chip an [`FmPort`](tecs.platform.audiobackend.FmPort) drives: ---- the Yamaha YM3812 (OPL2) or the YMF262 (OPL3). -export type FmChip = "opl2" | "opl3" +--- a Yamaha part number in lower case, or `"opl2"` and `"opl3"` for the YM3812 +--- and the YMF262. +export type FmChip = "opl2" + | "opl3" + | "ym3526" + | "y8950" + | "ym3812" + | "ymf262" + | "ymf289b" + | "ymf278b" + | "ym2413" + | "ym2423" + | "ymf281" + | "ds1001" + | "ym2151" + | "ym2164" + | "ym2203" + | "ym2608" + | "ymf288" + | "ym2610" + | "ym2610b" + | "ym2612" + | "ym3438" + | "ymf276" + | "ym2414" + | "ym3806" + | "ym3533" + | "ym2149" + +--- Names a class of external memory an FM chip reads: ADPCM-A sample ROM, +--- ADPCM-B sample ROM or RAM, or the OPL4's PCM wavetable. +export type FmMemory = "adpcmA" | "adpcmB" | "pcm" --- Sends register writes to one emulated FM chip a backend renders as a voice. --- @@ -134,10 +164,15 @@ export type FmChip = "opl2" | "opl3" --- reads on the frame thread and returns, and the backend's audio thread takes the --- writes from a bounded queue it never waits on. Every field is a plain function, so a --- test that wants to see what a game sent is a record of closures that logs. Arguments ---- arrive checked: a register in the chip's range and a value from 0 to 255. +--- arrive checked: a register or port in the chip's range and a value from 0 +--- to 255. export record FmPort + --- Read-only. Reports the rate of the port's clock in frames a second, + --- which is the unit `at` is resolved to. + rate: integer + --- Read-only. Queues one register write for `at` seconds on this port's - --- clock, where zero or less means the next sample, and reports whether + --- clock, where zero or less means the next frame, and reports whether --- the queue had room. write: function(register: integer, value: integer, at: number): boolean @@ -146,6 +181,18 @@ export record FmPort --- none, and reports whether the queue took them. `count` is at most 4096. writes: function(pairs: {integer}, count: integer, at: number): boolean + --- Read-only. Queues one write of `data` to the chip's port at `offset` + --- for `at`, and reports whether the queue had room. + writePort: function(offset: integer, data: integer, at: number): boolean + + --- Read-only. Queues the first `count` offset and data pairs of `pairs` + --- as port writes for `at`, all or none, as `writes` does registers. + writePorts: function(pairs: {integer}, count: integer, at: number): boolean + + --- Read-only. Replaces the chip's memory image of `class` with a copy of + --- `image` from its next frame, and reports whether it was queued. + setMemory: function(class: FmMemory, image: string): boolean + --- Read-only. Queues a return to the chip's power-on state for `at` that --- discards every write sent before it and due at or after it, and --- reports whether the queue had room. @@ -245,12 +292,13 @@ export record Backend --- synthesizer, which `tecs.audio.synth` answers with `silentSynth`. openSynth: (function(font: integer, handle: integer, polyphony: integer, effects: boolean): (SynthPort?, string?))? - --- Read-only. Opens an emulated FM chip for the voice `handle` names, - --- which starts sounding when a `PLAY` carrying that handle and the - --- `SYNTH` flag is submitted. Returns the port, or nil and a reason. + --- Read-only. Opens an emulated FM chip at `clock` hertz for the voice + --- `handle` names, which starts sounding when a `PLAY` carrying that + --- handle and the `SYNTH` flag is submitted. Returns the port, or nil and + --- a reason. --- Omitted by a backend with no FM emulator, which `tecs.audio.fm` --- answers with `silentFm`. - openFm: (function(handle: integer, chip: FmChip): (FmPort?, string?))? + openFm: (function(handle: integer, chip: FmChip, clock: integer): (FmPort?, string?))? --- Read-only. Stops everything and closes the output. Calling it twice is --- harmless. @@ -512,17 +560,29 @@ end --- --- What a chip gets on a mixer with no output, and on a backend that has no FM --- emulator of its own. Every write is taken and dropped, and the clock stays ---- at zero because no sample is ever rendered. +--- at zero because no frame is ever rendered. --- +--- @param rate the rate the port reports, which is the one the chip would +--- have run at --- @return a port whose writes all succeed and whose clock reads zero -export function silentFm(): FmPort +export function silentFm(rate: integer): FmPort return new FmPort( + rate = rate, write = function(_register: integer, _value: integer, _at: number): boolean return true end, writes = function(_pairs: {integer}, _count: integer, _at: number): boolean return true end, + writePort = function(_offset: integer, _data: integer, _at: number): boolean + return true + end, + writePorts = function(_pairs: {integer}, _count: integer, _at: number): boolean + return true + end, + setMemory = function(_class: FmMemory, _image: string): boolean + return true + end, reset = function(_at: number): boolean return true end, diff --git a/src/tecs/platform/audionative.nupp b/src/tecs/platform/audionative.nupp index a8f9d1f5..62419cb2 100644 --- a/src/tecs/platform/audionative.nupp +++ b/src/tecs/platform/audionative.nupp @@ -193,17 +193,29 @@ cdef function tecs_audio_synth_close(borrows synth: voidptr?) -- The FM chip. A port is a handle of its own, as a synthesizer's is, and the -- batch of writes crosses as a `carray` of `tecsAudioFmWrite`. -cdef function tecs_audio_fm_open(borrows device: voidptr?, handle: uint32, opl3: int32): voidptr? +cdef function tecs_audio_fm_open( + borrows device: voidptr?, + handle: uint32, + borrows name: cstring, + clock: uint32 +): voidptr? + +cdef function tecs_audio_fm_rate(borrows fm: voidptr?): uint32 cdef function tecs_audio_fm_write(borrows fm: voidptr?, register: uint32, value: uint32, at: number): int32 +cdef function tecs_audio_fm_write_port(borrows fm: voidptr?, offset: uint32, data: uint32, at: number): int32 + cdef function tecs_audio_fm_writes( borrows fm: voidptr?, borrows writes: tecsAudioFmWrite*, count: uint32, - at: number + at: number, + ports: int32 ): int32 +cdef function tecs_audio_fm_memory(borrows fm: voidptr?, class: uint32, borrows bytes: cstring, length: uint64): int32 + cdef function tecs_audio_fm_reset(borrows fm: voidptr?, at: number): int32 cdef function tecs_audio_fm_now(borrows fm: voidptr?): number @@ -561,17 +573,36 @@ local function wrap(openDevice: function(): voidptr?, absent: string): (audiobac --- crosses before the next is filled. local batch = carray(tecsAudioFmWrite, FM_BATCH) - local function openFm(handle: integer, chip: audiobackend.FmChip): (audiobackend.FmPort?, string?) + --- Fills the shared batch with the first `count` pairs of `pairs`, a + --- count every caller has already held to `FM_BATCH`. + local function fill(pairs: {integer}, count: integer): nil + for index = 0, count - 1 do + -- The index stays below `FM_BATCH` because every caller checks + -- `count` first, which the checker cannot read off a `carray`. + @unsafe do + local target = batch[index] + target.address = pairs[2 * index + 1] as uint32 + target.value = pairs[2 * index + 2] as uint32 + end + end + end + + --- The wire number of each memory class, as `tecs_audio_fm_memory` + --- takes it. + local MEMORY_CLASSES: {[string]: integer} = {adpcmA = 0, adpcmB = 1, pcm = 2} + + local function openFm(handle: integer, chip: audiobackend.FmChip, clock: integer): (audiobackend.FmPort?, string?) if closed[1] then return nil, "the audio output is closed" end - local fm = tecs_audio_fm_open(device, handle as uint32, (chip == "opl3" and 1 or 0) as int32) + local fm = tecs_audio_fm_open(device, handle as uint32, chip, clock as uint32) if fm == nil then return nil, reason("tecsaudio opened no FM chip") end local released = {false} return new audiobackend.FmPort( + rate = tecs_audio_fm_rate(fm) as integer, write = function(register: integer, value: integer, at: number): boolean if released[1] then return false @@ -583,17 +614,31 @@ local function wrap(openDevice: function(): voidptr?, absent: string): (audiobac if released[1] or count > FM_BATCH then return false end - for index = 0, count - 1 do - -- The index stays below `FM_BATCH` because of the check - -- above, which the checker cannot read off a `carray`. - @unsafe do - local target = batch[index] - target.address = pairs[2 * index + 1] as uint32 - target.value = pairs[2 * index + 2] as uint32 - end + fill(pairs, count) + + return tecs_audio_fm_writes(fm, batch, count as uint32, at, 0) ~= 0 + end, + writePort = function(offset: integer, data: integer, at: number): boolean + if released[1] then + return false + end + + return tecs_audio_fm_write_port(fm, offset as uint32, data as uint32, at) ~= 0 + end, + writePorts = function(pairs: {integer}, count: integer, at: number): boolean + if released[1] or count > FM_BATCH then + return false + end + fill(pairs, count) + + return tecs_audio_fm_writes(fm, batch, count as uint32, at, 1) ~= 0 + end, + setMemory = function(class: audiobackend.FmMemory, image: string): boolean + if released[1] then + return false end - return tecs_audio_fm_writes(fm, batch, count as uint32, at) ~= 0 + return tecs_audio_fm_memory(fm, MEMORY_CLASSES[class] as uint32, image, #image as uint64) ~= 0 end, reset = function(at: number): boolean if released[1] then diff --git a/tests/audiofmtest.nupp b/tests/audiofmtest.nupp index 415791e2..67879661 100644 --- a/tests/audiofmtest.nupp +++ b/tests/audiofmtest.nupp @@ -19,6 +19,8 @@ local record Harness sent: {Sent} opened: {integer} chips: {string} + clocks: {integer} + images: {string} closed: {integer} full: {boolean} end @@ -28,6 +30,8 @@ local function newHarness(available: boolean, withFm: boolean): Harness local sent: {Sent} = {} local opened: {integer} = {} local chips: {string} = {} + local clocks: {integer} = {} + local images: {string} = {} local closed: {integer} = {0} local full = {false} @@ -54,22 +58,43 @@ local function newHarness(available: boolean, withFm: boolean): Harness end end if withFm then - backend.openFm = function(handle: integer, chip: audiobackend.FmChip): (audiobackend.FmPort?, string?) + backend.openFm = function( + handle: integer, + chip: audiobackend.FmChip, + clock: integer + ): (audiobackend.FmPort?, string?) opened[#opened + 1] = handle chips[#chips + 1] = chip + clocks[#clocks + 1] = clock + + --- The list is the caller's, so the log keeps a copy. + local function copied(pairs: {integer}, count: integer): {integer} + local copy: {integer} = {} + for index = 1, 2 * count do + copy[index] = pairs[index] + end + + return copy + end return new audiobackend.FmPort( + rate = 12345, write = function(register: integer, value: integer, at: number): boolean return record("write", {register, value}, at) end, writes = function(pairs: {integer}, count: integer, at: number): boolean - -- The list is the caller's, so the log keeps a copy. - local copy: {integer} = {} - for index = 1, 2 * count do - copy[index] = pairs[index] - end + return record("writes", copied(pairs, count), at) + end, + writePort = function(offset: integer, data: integer, at: number): boolean + return record("writePort", {offset, data}, at) + end, + writePorts = function(pairs: {integer}, count: integer, at: number): boolean + return record("writePorts", copied(pairs, count), at) + end, + setMemory = function(class: audiobackend.FmMemory, image: string): boolean + images[#images + 1] = class .. ":" .. image - return record("writes", copy, at) + return record("setMemory", {#image}, 0) end, reset = function(at: number): boolean return record("reset", {}, at) @@ -90,6 +115,8 @@ local function newHarness(available: boolean, withFm: boolean): Harness sent = sent, opened = opened, chips = chips, + clocks = clocks, + images = images, closed = closed, full = full ) @@ -114,9 +141,13 @@ end function suite.startsTheChipAsOneVoiceOfTheMixer(): nil local harness = newHarness(true, true) local mixer, chip = made(harness, {chip = "opl3", group = "music", gain = 0.5}) - test.equal(chip.rate, 49715) + test.equal(chip.rate, 12345, "the rate is the port's") test.equal(chip.chip, "opl3") + test.equal(chip.clock, 14318180) + test.equal(chip.lastRegister, 0x1FF) + test.equal(chip.lastPort, 3) test.equal(harness.chips[1], "opl3") + test.equal(harness.clocks[1], 14318180) test.equal(mixer:sounding(), 1) mixer:update() local play = assert(lastCommand(harness, audiobackend.PLAY)) @@ -293,6 +324,119 @@ function suite.takesAVoiceButRendersNothingOnABackendWithNoFm(): nil test.equal(chip:now(), 0) end +function suite.makesAnyChipByNameOnItsOwnClockOrAGivenOne(): nil + local harness = newHarness(true, true) + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 4}) + local genesis = assert(fm.new(mixer, {chip = "ym2612"})) + test.equal(genesis.clock, 7670453) + test.equal(genesis.lastRegister, 0x1FF) + test.equal(harness.chips[1], "ym2612") + test.equal(harness.clocks[1], 7670453) + local opl4 = assert(fm.new(mixer, {chip = "ymf278b", clock = 33868800})) + test.equal(opl4.lastRegister, 0x2FF) + test.equal(opl4.lastPort, 5) + local opq = assert(fm.new(mixer, {chip = "ym3806"})) + test.equal(opq.lastRegister, 0x11F) + test.assert(opq:write(0x11F, 1)) + local ssg = assert(fm.new(mixer, {chip = "ym2149", clock = 2000000})) + test.equal(ssg.lastPort, 3) + test.equal(harness.clocks[4], 2000000) + test.raises( + function(): nil + fm.new(mixer, {chip = "ym2151", clock = 99999}) + end, + "clock must be a whole number of hertz" + ) + test.raises( + function(): nil + fm.new(mixer, {chip = "sn76489" as fm.Chip}) + end, + "fm.chips" + ) +end + +function suite.namesEveryChipWithItsClockRateAndMemory(): nil + local names = fm.chips() + test.equal(#names, 26) + test.equal(names[1], "ym3526") + test.equal(names[26], "opl3") + test.equal(fm.rate("opl2"), 49715) + test.equal(fm.rate("opl3"), 49715) + test.equal(fm.rate("ym2612"), 53267) + -- Rendered at three times its FM rate, then averaged three to a frame. + test.equal(fm.rate("ym2203"), 55466) + test.equal(fm.rate("ymf288"), 55466) + test.equal(fm.rate("ym2151", 4000000), 62500) + test.equal(fm.defaultClock("ym2149"), 1789772) + test.equal(#fm.memoryClasses("ym2612"), 0) + test.equal(fm.memoryClasses("ym2610")[1], "adpcmA") + test.equal(fm.memoryClasses("ym2610")[2], "adpcmB") + test.equal(fm.memoryClasses("y8950")[1], "adpcmB") + test.equal(fm.memoryClasses("ymf278b")[1], "pcm") +end + +function suite.writesPortsAndChecksTheirRange(): nil + local harness = newHarness(true, true) + local _mixer, chip = made(harness, {chip = "ym2612"}) + test.assert(chip:writePort(2, 0x2A, 0.5)) + test.assert(chip:writePorts({0, 0x28, 1, 0xF0}, 1)) + test.assert(chip:writePorts({})) + test.equal(#harness.sent, 2) + test.equal(harness.sent[1].kind, "writePort") + test.equal(harness.sent[1].pairs[1], 2) + test.equal(harness.sent[1].at, 0.5) + test.equal(harness.sent[2].kind, "writePorts") + test.equal(harness.sent[2].pairs[4], 0xF0) + test.raises( + function(): nil + chip:writePort(4, 0) + end, + "ym2612 port must be from 0x00 to 0x3, got 0x4" + ) + test.raises( + function(): nil + chip:writePorts({0, 1, 2}) + end, + "alternate port and value" + ) +end + +function suite.loadsMemoryOnlyOfAClassTheChipReads(): nil + local harness = newHarness(true, true) + local _mixer, chip = made(harness, {chip = "ym2610"}) + test.assert(chip:setMemory("adpcmA", "\1\2\3")) + test.equal(harness.images[1], "adpcmA:\1\2\3") + test.raises( + function(): nil + chip:setMemory("pcm", "x") + end, + "the ym2610 reads no pcm memory" + ) + chip:destroy() + test.assert(not chip:setMemory("adpcmB", "x"), "a destroyed chip loads nothing") +end + +function suite.pausesPlacesAndFadesTheVoice(): nil + local harness = newHarness(true, true) + local mixer, chip = made(harness) + mixer:update() + local handle = assert(lastCommand(harness, audiobackend.PLAY)).handle + chip:pause() + test.assert(chip:paused()) + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.PAUSE)).handle, handle) + chip:resume() + test.assert(not chip:paused()) + chip:setStereo(1, 0) + mixer:update() + test.assert(lastCommand(harness, audiobackend.RESUME) ~= nil) + chip:destroy(0.25) + mixer:update() + local stop = assert(lastCommand(harness, audiobackend.STOP)) + test.equal(stop.handle, handle) + test.equal(harness.closed[1], 1) +end + --- Returns the native binding, or skips when `libtecsaudio` is not staged. local function binding(): {[string]: any} local ok, loaded = pcall(require, "tecs.platform.audionative") @@ -311,16 +455,38 @@ function suite.crossesIntoTheNativeChipThroughTheBackend(): nil -- The offline backend reports no output, so a mixer would give the chip -- no voice; the port is opened directly to reach the library. local openFm = assert(assert(backend).openFm) + for _, chip in ipairs(fm.chips()) do + local clock = fm.defaultClock(chip) + local port = assert(openFm(1, chip, clock)) + -- The rate this module works out without the library is the one the + -- library reports, for every chip, at its own clock and at another. + test.equal(port.rate, fm.rate(chip), chip) + port.close() + local other = assert(openFm(1, chip, 4000000)) + test.equal(other.rate, fm.rate(chip, 4000000), chip) + other.close() + end for _, chip in ipairs({"opl2", "opl3"} as {audiobackend.FmChip}) do - local port = assert(openFm(1, chip)) + local port = assert(openFm(1, chip, fm.defaultClock(chip))) + test.equal(port.rate, 49715) test.assert(port.write(0xB0, 0x32, 0)) test.assert(port.writes({0xA0, 0x41, 0xB0, 0x32}, 2, 0.1)) + test.assert(port.writePort(0, 0x20, 0)) + test.assert(port.writePorts({0, 0x20, 1, 0x01}, 2, 0.1)) + test.assert(not port.setMemory("pcm", "x"), "an OPL reads no memory") test.assert(port.reset(0.2)) test.equal(port.now(), 0) port.close() port.close() test.assert(not port.write(0xB0, 0x32, 0), "a closed port queues nothing") end + local ym2610 = assert(openFm(1, "ym2610", 8000000)) + test.assert(ym2610.setMemory("adpcmA", string.rep("\x12", 512))) + test.assert(ym2610.setMemory("adpcmB", "")) + ym2610.close() + local refused, reason = openFm(1, "sn76489" as audiobackend.FmChip, 4000000) + test.equal(refused, nil) + test.matches(assert(reason), "sn76489") assert(backend).close() end From 8cd2720dc88f847b19ee98d6354636fe47f5e010 Mon Sep 17 00:00:00 2001 From: Alban Gicquel <6368237+albanmgicquel@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:30:54 -0400 Subject: [PATCH 6/7] tecs.audio.synth: pause, resume and a fading destroy Synth:pause(), resume() and paused() pause the synth's own voice, which stops its clock with it, so scheduled events wait and the synth carries on exactly where it was. destroy(fadeOut?) fades the voice over fadeOut seconds instead of cutting it; without one it stops at once as before. tecs.audio.fm gained the same on its chip in the previous commit; the music players use both. --- src/tecs/audio/synth.nupp | 42 ++++++++++++++++++++++++++++++++++----- tests/audiosynthtest.nupp | 22 ++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/tecs/audio/synth.nupp b/src/tecs/audio/synth.nupp index afdea47a..201c610d 100644 --- a/src/tecs/audio/synth.nupp +++ b/src/tecs/audio/synth.nupp @@ -167,7 +167,10 @@ export record Synth reset: function(exclusive self: Synth): boolean now: function(borrows self: Synth): number setGain: function(exclusive self: Synth, gain: number): nil - destroy: function(exclusive self: Synth): nil + pause: function(exclusive self: Synth): nil + resume: function(exclusive self: Synth): nil + paused: function(borrows self: Synth): boolean + destroy: function(exclusive self: Synth, fadeOut: number?): nil end --- The SoundFonts each mixer has loaded, by path, keyed weakly so a mixer that @@ -467,17 +470,46 @@ function Synth.setGain(exclusive self: Synth, gain: number): nil self.mixer:setGain(self.voice, gain) end +--- Pauses the synth's voice, which stops its clock with it. +--- +--- Nothing is lost: every note holds where it stood, events already scheduled +--- wait on the stopped clock, and `resume` carries on exactly where it left +--- off. +--- +--- @param self the synth to pause +function Synth.pause(exclusive self: Synth): nil + self.mixer:pause(self.voice) +end + +--- Resumes a paused synth. +--- +--- @param self the synth to resume +function Synth.resume(exclusive self: Synth): nil + self.mixer:resume(self.voice) +end + +--- Reports whether the synth's own voice is paused, leaving its group aside. +--- +--- @param self the synth to inspect +--- @return true between `pause` and `resume` +function Synth.paused(borrows self: Synth): boolean + return self.mixer:paused(self.voice) +end + --- Stops the synth's voice and frees its synthesizer. Safe more than once. --- ---- The voice stops without a fade, so a note still sounding is cut. Send ---- `allNotesOff` a little before this for a softer ending. +--- Without a fade the voice stops at once, so a note still sounding is cut; +--- send `allNotesOff` a little before for a softer ending. With one it fades +--- over `fadeOut` seconds, the synthesizer rendering on and taking no more +--- events, and goes when the fade ends. --- --- @param self the synth to destroy -function Synth.destroy(exclusive self: Synth): nil +--- @param fadeOut seconds to fade over, or nil to stop at once +function Synth.destroy(exclusive self: Synth, fadeOut: number?): nil if self.destroyed then return end self.destroyed = true - self.mixer:stop(self.voice) + self.mixer:stop(self.voice, fadeOut) self.port.close() end diff --git a/tests/audiosynthtest.nupp b/tests/audiosynthtest.nupp index f09b0dd4..276a8c32 100644 --- a/tests/audiosynthtest.nupp +++ b/tests/audiosynthtest.nupp @@ -53,6 +53,7 @@ local function newHarness(available: boolean, withSynth: boolean): Harness copy.clip = source.clip copy.flags = source.flags copy.gain = source.gain + copy.fade = source.fade commands[#commands + 1] = copy end end @@ -174,6 +175,27 @@ function suite.startsTheSynthAsOneVoiceOfTheMixer(): nil test.equal(mixer:sounding(), 0) end +function suite.pausesTheVoiceAndFadesItOut(): nil + local harness = newHarness(true, true) + local mixer, music = made(harness) + mixer:update() + local handle = assert(lastCommand(harness, audiobackend.PLAY)).handle + music:pause() + test.assert(music:paused()) + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.PAUSE)).handle, handle) + music:resume() + test.assert(not music:paused()) + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.RESUME)).handle, handle) + music:destroy(0.75) + mixer:update() + local stop = assert(lastCommand(harness, audiobackend.STOP)) + test.equal(stop.handle, handle) + test.equal(stop.fade, 0.75) + test.equal(harness.closed[1], 1) +end + function suite.countsAgainstTheVoiceCeiling(): nil local harness = newHarness(true, true) local mixer = audio.newAudio({backend = harness.backend, maxVoices = 1}) From 7a3acecfbdab411caa991925521ec60e448179ea Mon Sep 17 00:00:00 2001 From: Alban Gicquel <6368237+albanmgicquel@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:30:54 -0400 Subject: [PATCH 7/7] tecs.audio.music: MIDI and FM register-log asset kinds, and players Two asset kinds, named "music.midi" and "music.fm" (new tecs.assets MUSIC_MIDI and MUSIC_FM constants, pinned in the compatibility test): - tecs.audio.music.midi.kind loads a Standard MIDI File as a Song: format, ppq or SMPTE rate, tracks, every event merged in time order with its seconds through the tempo map, the tempo map, duration, and loop points from loopStart/loopEnd markers or controller 111. Format 2 patterns play one after another. SysEx and meta events are kept, not sent. - tecs.audio.music.fm.kind loads VGM (to 1.72), VGZ (gzip, inflated through nupp.compression) and DRO v1/v2 as a RegisterLog: chips with clocks, memory images, packed timed writes (entry(i)), loop, GD3 tags, and a skipped list. Every Yamaha chip VGM names plays, dual chips from bit 30 and alternates from bit 31 (VRC7, YM3438, YM2164, YM2610B), the AY family as a YM2149; ROM/RAM data blocks become memory images; YM2612 DAC data (0x8n, 0xE0, DAC streams 0x90-0x95) and compressed blocks are turned into register writes when read. Other chips are skipped with a reason; a file with nothing playable is refused. DRO dual OPL2 plays hard left/right. Parsing is Nupp on the readiness pump, 65 536 commands a pass; parse() does the same synchronously for bytes a game holds. Files are capped at 16 MiB (MIDI) and 64 MiB inflated (logs); refusals name the byte offset. Players: midi.play(mixer, font, handle, options) / playSong, and fm.play(mixer, handle, options) / playLog return a tecs.audio.music.Player that owns its synth or chips and retains the handle. Audio.update advances every player on the mixer (a new seam list in tecs.internal.audiosynth). It schedules lookahead (default 0.05 s) ahead on the voices' clock; a stall makes only the first late event play at once and keeps the rest at their distance; refused sends retry next update. Loops forever when the song has a loop point (options.loop, setLoop), releases held notes at the loop end, flushes the last tick at the song end, then rings for a tail and frees its voices. stop(fadeOut?), pause/resume, seek (MIDI: synth reset plus chase of programs/controllers/bends; FM: chip reset plus each register's last value), setTempo, setGain, setMuted (MIDI channel or chip), position, duration, loops, finished, status, and a reload of the handle rebuilds the driver at the same position (new chips when the log's chips changed). A Driver record is the seam a game can implement for a format of its own (newPlayer). The host stages Nupp's native-compression feature beside native-files and native-net, in build.rs, build-support and the dev tools, so a component that reaches nupp.compression loads. Tests build every fixture byte by byte (no third-party music): SMF formats 0/1/2, running status, tempo maps, SMPTE, loops, refusals; VGM chips, flags, loops, GD3, data blocks, DAC writes and streams, compressed blocks, VGZ, pre-1.10 clocks, AY flags; DRO v1 both header widths, v2 codemaps, dual OPL2; kinds through a loader; players on a logging backend with a test clock for lookahead, stalls, finish and tail, loops, tempo, seek, mute, pause, fade, reload, full queues, chip memory and stereo. README records the choices and the alternatives that lost. --- AGENTS.md | 2 +- README.md | 62 +- native/rust/build-support/src/nupp.rs | 2 +- native/rust/winit-host/build.rs | 2 +- nupp.lua | 6 + src/tecs/assets.nupp | 8 + src/tecs/audio/init.nupp | 19 +- src/tecs/audio/music/fm.nupp | 692 ++++++++++++++ src/tecs/audio/music/init.nupp | 661 ++++++++++++++ src/tecs/audio/music/midi.nupp | 933 +++++++++++++++++++ src/tecs/internal/audiosynth.nupp | 11 +- src/tecs/internal/dro.nupp | 250 +++++ src/tecs/internal/registerlog.nupp | 182 ++++ src/tecs/internal/vgm.nupp | 906 +++++++++++++++++++ tests/audiomusictest.nupp | 1207 +++++++++++++++++++++++++ tests/tecs/compatibilitytest.nupp | 3 + tools/tecs/dev/commands.nupp | 2 +- 17 files changed, 4938 insertions(+), 10 deletions(-) create mode 100644 src/tecs/audio/music/fm.nupp create mode 100644 src/tecs/audio/music/init.nupp create mode 100644 src/tecs/audio/music/midi.nupp create mode 100644 src/tecs/internal/dro.nupp create mode 100644 src/tecs/internal/registerlog.nupp create mode 100644 src/tecs/internal/vgm.nupp create mode 100644 tests/audiomusictest.nupp diff --git a/AGENTS.md b/AGENTS.md index ad278501..f560bee3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ tecs/ │ ├── platform/ # Window, events, audio and gamepad backends │ ├── physics/ # Rapier contract and binding │ ├── input.nupp # Gameplay input over the platform event stream -│ ├── audio/ # Clips, voices, groups, limits, the Sound component, General MIDI, Yamaha FM +│ ├── audio/ # Clips, voices, groups, limits, the Sound component, General MIDI, Yamaha FM, music │ ├── sequence.nupp # Sequencer, with the tween runtime inside it │ ├── assets.nupp # Asset orchestration │ ├── mcp.nupp # Debug server: transport, tools, sandbox diff --git a/README.md b/README.md index c4f37e17..53109322 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,9 @@ test suite reach the same world without starting a graphics stack. animation, and distance-field text. - Input across keyboard, pointer, gamepad and touch; audio playback, recording and device enumeration; General MIDI from a SoundFont; emulated Yamaha FM - chips, from the OPL2 and OPL3 to the YM2612 and the OPL4; physics over - Rapier; sequencing with tweening; asset orchestration; file watching; and an - MCP debug server. + chips, from the OPL2 and OPL3 to the YM2612 and the OPL4; MIDI, VGM, VGZ and + DRO music played on them; physics over Rapier; sequencing with tweening; + asset orchestration; file watching; and an MCP debug server. - [Tiled TMX maps and TSX tilesets](docs/tiled/index.md), animated tile and image layers, object factories, runtime tile edits and authored collision outlines. @@ -672,6 +672,62 @@ An idle chip at its usual clock costs between a fifth of a percent and one percent of a core in a release build, measured over a second of rendering; the YMF288 is the dearest and the YM2149 the cheapest. +## Music is two asset kinds and a player that owns its voices + +`tecs.audio.music` plays songs on the synthesizer voices: Standard MIDI Files on +the General MIDI synth, and VGM, VGZ and DRO register logs on emulated FM chips. +Each format is a `tecs.assets.Kind`, `"music.midi"` and `"music.fm"`, +which are content kind names and so a compatibility surface, and a +`tecs.audio.music.Player` plays what one loaded. + +The files are parsed in Nupp, on the loader's readiness pump, up to 65 536 +commands a pass, so a large log loads across frames instead of stalling one. +A worker lane was the alternative and lost: it needs the `workers` host +feature and a sendable parser, and its result, tens of thousands of events, +would be copied back across the lane only to be read on the frame thread +anyway. Parsing in Rust lost for the same reason, with a structured payload to +carry over the C ABI besides. A log is kept as two integer arrays, a tick and +a packed chip, register and value a write, rather than a record per write, +because a long VGM holds hundreds of thousands of them. VGZ inflates through +`nupp.compression`, which is Nupp's and so is what Tecs uses: the host now +stages the `native-compression` feature beside `native-files` and +`native-net`, in the three places that list them. `flate2`, already in the +Cargo graph through `tecs-assets`, would have worked too, and is exactly the +wrapper around a Nupp facility the project does not keep. + +A player owns its voices, a synth for a MIDI song and one chip for each chip a +log names, and the handle it plays, which it retains and watches for a reload. +The mixer's `update` advances every player made on it, because a game already +calls that every frame and keeps calling it while its world is paused; a +`Music` component was the alternative, and it lost because a song is a handle +and a set of voices, neither of which is data a snapshot could carry or a row +could own. The player schedules ahead on the voices' own clock, `lookahead` +seconds at a time, so timing follows the audio rather than the frame rate. When +a stall makes an event late, that event plays at once and the rest keep their +distance from it: the song slips by the stall instead of rushing what it +missed, and a stall over a rest changes nothing. A loop ends before the +events at its end, which belong to the next pass, and held notes are released +there; the song's own end takes everything, so its closing note offs are sent. +A song loops forever when it has a loop point and plays once when it has none. + +Seeking resets the voices and rebuilds their state rather than replaying the +song up to the point: a MIDI song sends the programs, controllers and bends in +force there, and a log writes the last value of every register. The second is +exact for everything a register holds and approximate for what a chip holds +besides, envelopes in flight and the OPN's shared key-on register among them, +so a note held across the point comes back at its next key-on. System +exclusive messages are kept in a song but not sent, because the synth takes +short messages; the resets they carry are what a fresh synth already is. + +A VGM file is a log of every chip in a machine, and a Mega Drive's has an +SN76489 beside its YM2612. Refusing such a file because one of its chips is +not a Yamaha one would make the YM2612 useless, so each chip Tecs cannot +emulate is skipped with a reason in `skipped`, and only a file left with +nothing is refused. The YM2612's DAC writes from a data bank, the `0x8n` +commands and the DAC streams, are turned into ordinary register writes at +their times when the file is read, so the player knows nothing of them, and a +format 2 MIDI file's patterns play one after another. + ## Device vocabularies follow the library that reports them Key names, mouse button names and gamepad button and axis names came from SDL diff --git a/native/rust/build-support/src/nupp.rs b/native/rust/build-support/src/nupp.rs index a31039a8..b4271f6c 100644 --- a/native/rust/build-support/src/nupp.rs +++ b/native/rust/build-support/src/nupp.rs @@ -4,7 +4,7 @@ use anyhow::Result; use std::path::{Path, PathBuf}; use std::process::Command; pub const OUTPUT: &str = "out/nupp"; -const HOST_FEATURES: &str = "base,native-files,native-net"; +const HOST_FEATURES: &str = "base,native-files,native-net,native-compression"; pub fn compiler(root: &Path) -> Result { if let Some(root) = std::env::var_os("NUPP_COMPILER_ROOT") { diff --git a/native/rust/winit-host/build.rs b/native/rust/winit-host/build.rs index b1a66021..6a24ab04 100644 --- a/native/rust/winit-host/build.rs +++ b/native/rust/winit-host/build.rs @@ -87,7 +87,7 @@ fn target_os() -> String { // one set of files, and `native-net` arrives with the I/O module extraction // builds its packet through. A host staged without them refuses to load the // component rather than failing later. -const HOST_FEATURES: &str = "base,native-files,native-net"; +const HOST_FEATURES: &str = "base,native-files,native-net,native-compression"; /// Names the run path a packaged link records instead of the staged SDK's /// absolute one. diff --git a/nupp.lua b/nupp.lua index f700658a..c5791cd5 100644 --- a/nupp.lua +++ b/nupp.lua @@ -236,7 +236,13 @@ return { "tecs.audio", "tecs.audio.fm", "tecs.audio.synth", + "tecs.audio.music", + "tecs.audio.music.midi", + "tecs.audio.music.fm", "tecs.internal.audiosynth", + "tecs.internal.registerlog", + "tecs.internal.vgm", + "tecs.internal.dro", "tecs.ecs", "tecs.files", "tecs.gfx", diff --git a/src/tecs/assets.nupp b/src/tecs/assets.nupp index ac1dc147..24b9453d 100644 --- a/src/tecs/assets.nupp +++ b/src/tecs/assets.nupp @@ -143,6 +143,14 @@ export const SHADER = "shader" --- Read-only. Names the content kind of a decoded model scene. export const MODEL = "model" +--- Read-only. Names the content kind of a Standard MIDI File parsed as a song, +--- which `tecs.audio.music.midi.kind` loads. +export const MUSIC_MIDI = "music.midi" + +--- Read-only. Names the content kind of a VGM, VGZ or DRO file parsed as a +--- register log, which `tecs.audio.music.fm.kind` loads. +export const MUSIC_FM = "music.fm" + --- A caller's hold on one acquired asset. --- --- Handles are shared: every caller whose request joined one acquisition gets diff --git a/src/tecs/audio/init.nupp b/src/tecs/audio/init.nupp index a98a7580..eb7fabaf 100644 --- a/src/tecs/audio/init.nupp +++ b/src/tecs/audio/init.nupp @@ -2229,7 +2229,8 @@ end --- Call once per frame with the frame's step. The step advances cooldowns, and --- nothing else here needs time: a fade is the backend's to run, and a voice is --- over when the backend says so rather than when a clock here says it should ---- be. +--- be. It also advances every [`Player`](tecs.audio.music.Player) made on this +--- mixer, which schedules on its voices' own clock rather than on the step. --- --- Not a world system, and `install` deliberately does not add one: reaping has --- to continue during a world pause, so an application drives this from the @@ -2244,6 +2245,22 @@ function Audio.update(exclusive self: Audio, dt: number?): integer if self.closed then return 0 end + -- Music players first, so what they schedule this frame rides the same + -- flush as the voices they start. + local updates = audiosynth.updates[self] + if updates ~= nil then + local kept: integer = 0 + for index = 1, #updates do + local advance = updates[index] + if advance() then + kept = kept + 1 + updates[kept] = advance + end + end + for index = #updates, kept + 1, -1 do + updates[index] = nil + end + end local observe = self.backend.failure if self.failureReason == nil and observe ~= nil then local failure = observe() diff --git a/src/tecs/audio/music/fm.nupp b/src/tecs/audio/music/fm.nupp new file mode 100644 index 00000000..1cbb6067 --- /dev/null +++ b/src/tecs/audio/music/fm.nupp @@ -0,0 +1,692 @@ +module tecs.audio.music.fm + +--[[ +Register logs of FM chips, loaded as assets and played on emulated chips. + +```nupp +local log = assert(app.assets:request(tecs.audio.music.fm.kind, "assets/music/stage1.vgz")) +local player = assert(tecs.audio.music.fm.play(mixer, log, {group = "music"})) +log:release() +``` + +A register log is a song recorded as what a sound driver wrote to its chips, +with the time of every write. `kind` loads one as a +[`RegisterLog`](tecs.audio.music.fm.RegisterLog), and `parse` reads bytes a +game already holds. `play` makes one [`tecs.audio.fm`](tecs.audio.fm) chip +for each chip the log names, on the clock the log gives, loads the memory +images the log carries, and replays the writes on time. + +## VGM and VGZ + +A VGM file is read up to version 1.72, and a VGZ file is a gzip-compressed VGM +file, inflated through `nupp.compression`. The format is told by the file's +first bytes rather than its name. + +Every Yamaha chip VGM names is played: the YM2413 (and the VRC7 its clock's +top bit selects), the YM2612 (or the YM3438), the YM2151 (or the YM2164), the +YM2203, the YM2608, the YM2610 (or the YM2610B), the YM3812, the YM3526, the +Y8950, the YMF262 and the YMF278B, and the AY-3-8910 family as a YM2149, two +of each where the header's second-chip bit says so. Their ROM and RAM data +blocks become the chips' memory images. The YM2612's DAC data is played +through the `0x8n` commands and the DAC streams, both turned into register +writes at their times when the file is read; compressed data blocks are +decompressed first. + +Every other chip, the SN76489 beside most YM2612s among them, is skipped, and +`skipped` says why for each. A file that names nothing this module can play is +refused. The AY8930 is skipped too: its expanded mode is not the YM2149's. + +## DRO + +A DOSBox Raw OPL capture is read in version 1, from DOSBox 0.61, and version 2, +from 0.73 on: one OPL2, one OPL3, or two OPL2s, which a Sound Blaster Pro wired +one to each speaker and which play the same way here. + +## Errors + +A file that is neither format, a header that does not fit, a command cut short +by the end of the file and a command VGM does not define are refused with the +byte offset where reading stopped. A file is at most 64 MiB, inflated. +]] + +local assets = require("tecs.assets") +local audio = require("tecs.audio") +local chipfm = require("tecs.audio.fm") +local compression = require("nupp.compression") +local dro = require("tecs.internal.dro") +local music = require("tecs.audio.music") +local registerlog = require("tecs.internal.registerlog") +local vgm = require("tecs.internal.vgm") + +const byte = string.byte + +--- The largest file `parse` reads, in bytes, after inflating. +local MAX_BYTES: integer = 64 * 1024 * 1024 + +--- Commands one readiness-pump pass reads before it lets the frame go on. +local PARSE_BUDGET: integer = 65536 + +--- Seconds a chip keeps sounding after the last write of a finished log. +local TAIL: number = 1 + +--- The most writes one batch carries: the chip's queue bound. +local BATCH: integer = 4096 + +--- The format a log was read from. +export type Format = "vgm" | "dro" + +--- One chip a log writes to. +export record LogChip + --- Read-only. Names the chip as `tecs.audio.fm.new` takes it. + chip: chipfm.Chip + + --- Read-only. Reports the chip's input clock in hertz. + clock: integer + + --- Read-only. Reports whether this is the second chip of its kind in the + --- file. + second: boolean + + --- Read-only. Reports the chip's gain on the left side, one for a chip in + --- the middle. + left: number + + --- Read-only. Reports the chip's gain on the right side. + right: number + + --- Read-only. Holds the memory images the log's data blocks give the + --- chip, by class. + memory: {[string]: string} +end + +--- The tags a VGM file's GD3 block carries. Each is the empty string when the +--- file leaves it out. +export record Tags + --- Read-only. Reports the track's name. + title: string + + --- Read-only. Reports the track's name in Japanese. + titleJapanese: string + + --- Read-only. Reports the game's name. + game: string + + --- Read-only. Reports the game's name in Japanese. + gameJapanese: string + + --- Read-only. Reports the system's name. + system: string + + --- Read-only. Reports the system's name in Japanese. + systemJapanese: string + + --- Read-only. Reports the composer. + author: string + + --- Read-only. Reports the composer in Japanese. + authorJapanese: string + + --- Read-only. Reports the release date. + date: string + + --- Read-only. Reports who made the file. + converter: string + + --- Read-only. Holds the file's notes. + notes: string +end + +--- A song as the writes a driver made to its chips. +export record RegisterLog + --- Read-only. Reports the format it was read from. + format: Format + + --- Read-only. Reports the format's version: a VGM version as its BCD + --- number, 0x171 for 1.71, and 1 or 2 for DRO. + version: integer + + --- Read-only. Reports the ticks a second its times count in: 44100 for + --- VGM and 1000 for DRO. + rate: integer + + --- Read-only. Reports the log's length in ticks. + length: integer + + --- Read-only. Reports the log's length in seconds. + duration: number + + --- Read-only. Reports where the log loops back to, in seconds, or nil. + loopStart: number? + + --- Read-only. Lists the chips the log plays, in the order its writes + --- number them from zero. + chips: {LogChip} + + --- Read-only. Reports how many writes the log holds. + count: integer + + --- Read-only. Holds the VGM file's GD3 tags, or nil. + tags: Tags? + + --- Read-only. Lists what the file holds that is not played, one reason + --- each: a chip other than a Yamaha FM chip, or memory for one. + skipped: {string} + + @private ticks: {integer} + @private ops: {integer} + @private loopIndex: integer + + entry: function(borrows self: RegisterLog, index: integer): (integer, integer, integer, integer) +end + +--- Returns one write of the log. +--- +--- @param self the log to read +--- @param index from one to `count` +--- @return the write's tick, its chip numbered from zero, its register and its +--- value +--- @raises string when the index is outside the log +function RegisterLog.entry(borrows self: RegisterLog, index: integer): (integer, integer, integer, integer) + if index < 1 or index > self.count then + error( + "tecs.audio.music.fm.RegisterLog.entry: the index must be from 1 to " .. tostring( + self.count + ) .. ", got " .. tostring(index), + 2 + ) + end + local op = self.ops[index] + local chip = op // registerlog.CHIP_SHIFT + local rest = op % registerlog.CHIP_SHIFT + + return self.ticks[index], chip, rest // registerlog.REGISTER_SHIFT, rest % registerlog.REGISTER_SHIFT +end + +--- Turns a finished builder into a log, or refuses one with nothing to play. +local function finish(builder: registerlog.Builder, name: string): (RegisterLog?, string?) + if builder.count == 0 then + local reason = name .. ": nothing in the file is for a chip tecs.audio.fm emulates" + if #builder.skipped > 0 then + reason = reason .. " (" .. table.concat(builder.skipped, "; ") .. ")" + end + + return nil, reason + end + local chips: {LogChip} = {} + for index, chip in ipairs(builder.chips) do + local memory: {[string]: string} = {} + for class, pieces in pairs(chip.pieces) do + memory[class] = registerlog.image(pieces) + end + chips[ + index + ] = new LogChip( + chip = chip.model as chipfm.Chip, + clock = chip.clock, + second = chip.second, + left = chip.left, + right = chip.right, + memory = memory + ) + end + local tags: Tags? = nil + local fields = builder.tags + if fields ~= nil then + tags = new Tags( + title = fields[1] or "", + titleJapanese = fields[2] or "", + game = fields[3] or "", + gameJapanese = fields[4] or "", + system = fields[5] or "", + systemJapanese = fields[6] or "", + author = fields[7] or "", + authorJapanese = fields[8] or "", + date = fields[9] or "", + converter = fields[10] or "", + notes = fields[11] or "" + ) + end + local rate = builder.rate + local loopTick = builder.loopTick + local length = math.max(builder.length, builder.ticks[builder.count]) + + return new RegisterLog( + format = builder.format as Format, + version = builder.version, + rate = rate, + length = length, + duration = length / rate, + loopStart = loopTick ~= nil and loopTick / rate or nil, + chips = chips, + count = builder.count, + tags = tags, + skipped = builder.skipped, + ticks = builder.ticks, + ops = builder.ops, + loopIndex = builder.loopIndex or 1 + ) +end + +--- Inflates a VGZ file, or passes anything else through. +local function inflate(bytes: string, name: string): (string?, string?) + if byte(bytes, 1) ~= 0x1F or byte(bytes, 2) ~= 0x8B then + if #bytes > MAX_BYTES then + return nil, name .. ": a register log is at most 64 MiB, and this one is " .. tostring(#bytes) .. " bytes" + end + + return bytes + end + local ok, result = pcall(function(): string + return compression.format("gzip"):decompress(bytes, {maxOutputBytes = MAX_BYTES}) + end) + if not ok then + return nil, name .. ": the gzip stream cannot be inflated: " .. tostring(result) + end + + return result as string +end + +--- Begins reading a log, inflating it first, or returns why it is refused. +local function begin(bytes: string, name: string): (registerlog.Parser?, string?) + local plain, reason = inflate(bytes, name) + if plain == nil then + return nil, reason + end + local text = assert(plain) + if string.sub(text, 1, 4) == "Vgm " then + return vgm.parser(text, name) + elseif string.sub(text, 1, 8) == "DBRAWOPL" then + return dro.parser(text, name) + end + + return nil, name .. ": neither a VGM, a VGZ nor a DRO file" +end + +--- Parses the bytes of a whole VGM, VGZ or DRO file. +--- +--- @param bytes the file, which is not retained once the log is built +--- @param name what a refusal names the file as, and defaults to +--- `"register log"` +--- @return the log, or nil when the bytes are refused +--- @return the reason, naming the byte where reading stopped +export function parse(bytes: string, name: string?): (RegisterLog?, string?) + local label = name or "register log" + local parser, reason = begin(bytes, label) + if parser == nil then + return nil, reason + end + local running = assert(parser) + while true do + local status, builder, failure = running.step(PARSE_BUDGET) + local outcome: string = status + if outcome == "ready" then + return finish(assert(builder), label) + elseif outcome == "failed" then + return nil, failure + end + end +end + +--- Loads a VGM, VGZ or DRO file as a +--- [`RegisterLog`](tecs.audio.music.fm.RegisterLog). +--- +--- The file is read, inflated and parsed on the loader's readiness pump, up to +--- 65 536 commands a pass, so `handle:value() as +--- tecs.audio.music.fm.RegisterLog` is the cast its caller writes. The +--- collector owns the log. +export const kind: assets.Kind = new assets.Kind( + name = assets.MUSIC_FM, + open = function(path: string): (assets.Job?, string?) + local read, reason = assets.readJob(path) + if read == nil then + return nil, reason + end + local parser: registerlog.Parser? = nil + + return new assets.Job( + poll = function(): (assets.Status, any?, string?) + local current = parser + if current == nil then + local status, bytes, failure = read.poll() + local reading: string = status + if reading ~= "ready" then + return status, nil, failure + end + local begun, refused = begin(bytes as string, path) + if begun == nil then + return "failed", nil, refused + end + parser = begun + + return "pending" + end + local status, builder, failure = current.step(PARSE_BUDGET) + local parsed: string = status + if parsed ~= "ready" then + return status, nil, failure + end + local log, refused = finish(assert(builder), path) + if log == nil then + return "failed", nil, refused + end + + return "ready", log + end, + cancel = function(): nil + read.cancel() + parser = nil + end + ) + end, + release = nil +) + +--- Makes one chip for each chip a log names, or destroys what it made and +--- returns why it could not. +local function makeChips(mixer: audio.Audio, log: RegisterLog, options: music.PlayOptions?): ({chipfm.Fm}?, string?) + local made: {chipfm.Fm} = {} + for index, entry in ipairs(log.chips) do + local chip, reason = chipfm.new(mixer, { + chip = entry.chip, + clock = math.max(100000, math.min(entry.clock, 100000000)), + group = options and options.group, + gain = options and options.gain, + }) + if chip == nil then + for _, other in ipairs(made) do + other:destroy() + end + + return nil, reason + end + local fm = assert(chip) + if entry.left ~= 1 or entry.right ~= 1 then + fm:setStereo(entry.left, entry.right) + end + for class, image in pairs(entry.memory) do + if #image > 0 then + fm:setMemory(class as chipfm.Memory, image) + end + end + made[index] = fm + end + + return made +end + +--- Reports whether two logs name the same chips on the same clocks. +local function sameChips(left: RegisterLog, right: RegisterLog): boolean + if #left.chips ~= #right.chips then + return false + end + for index, chip in ipairs(left.chips) do + local other = right.chips[index] + if chip.chip ~= other.chip or chip.clock ~= other.clock then + return false + end + end + + return true +end + +--- Returns the index of the first write at or after `tick`. +local function locate(log: RegisterLog, tick: number): integer + local ticks = log.ticks + local low: integer, high: integer = 1, log.count + 1 + while low < high do + local middle = (low + high) // 2 + if ticks[middle] < tick then + low = middle + 1 + else + high = middle + end + end + + return low +end + +--- Builds the driver that plays a log on its chips. +local function driver( + mixer: audio.Audio, + first: RegisterLog, + made: {chipfm.Fm}, + options: music.PlayOptions? +): music.Driver + local log = first + local chips = made + local index: integer = 1 + local gain = (options and options.gain) or 1 + local muted: {[integer]: boolean} = {} + local scratch: {integer} = {} + local filled: integer = 0 + local result: music.Driver + + --- Sends the writes `from` to `to` of one chip, all at `at`. + local function send(chip: integer, from: integer, to: integer, at: number): boolean + local count: integer = 0 + for position = from, to do + local op = log.ops[position] % registerlog.CHIP_SHIFT + scratch[count * 2 + 1] = op // registerlog.REGISTER_SHIFT + scratch[count * 2 + 2] = op % registerlog.REGISTER_SHIFT + count = count + 1 + end + for position = count * 2 + 1, filled do + scratch[position] = nil + end + filled = count * 2 + + return chips[chip + 1]:writes(scratch, at) + end + + local function seconds(tick: integer): number + return tick / log.rate + end + + result = new music.Driver( + duration = log.duration, + loopStart = log.loopStart, + loopEnd = nil, + tail = TAIL, + now = function(): number + return chips[1]:now() + end, + schedule = function(to: number, song: number, clock: number, tempo: number): number + local ticks = log.ticks + local ops = log.ops + local count = log.count + local limit = to * log.rate + while index <= count do + local tick = ticks[index] + if tick >= limit then + return to + end + local chip = ops[index] // registerlog.CHIP_SHIFT + -- The run of writes at this tick to this chip, as one batch. + local last = index + while last < count + and last - index + 1 < BATCH + and ticks[last + 1] == tick + and ops[last + 1] // registerlog.CHIP_SHIFT == chip do + last = last + 1 + end + local at = clock + (seconds(tick) - song) / tempo + if not send(chip, index, last, at) then + return seconds(tick) + end + index = last + 1 + end + + return to + end, + pending = function(): number? + if index > log.count then + return nil + end + + return seconds(log.ticks[index]) + end, + jump = function(target: number, _at: number): nil + if log.loopStart ~= nil and target == log.loopStart then + index = log.loopIndex + else + index = locate(log, target * log.rate) + end + end, + seek = function(target: number, at: number): nil + local stop = locate(log, target * log.rate) + local shadows: {{[integer]: integer}} = {} + for chip = 1, #chips do + shadows[chip] = {} + chips[chip]:reset(at) + end + for position = 1, stop - 1 do + local op = log.ops[position] + local chip = op // registerlog.CHIP_SHIFT + local rest = op % registerlog.CHIP_SHIFT + shadows[chip + 1][rest // registerlog.REGISTER_SHIFT] = rest % registerlog.REGISTER_SHIFT + end + -- Every register's last value, ascending, as one batch a chip: + -- at most 0x300 writes, which is under the queue's bound. + for chip = 1, #chips do + local shadow = shadows[chip] + local batch: {integer} = {} + for register = 0, chips[chip].lastRegister do + local value = shadow[register] + if value ~= nil then + batch[#batch + 1] = register + batch[#batch + 1] = value + end + end + chips[chip]:writes(batch, at) + end + index = stop + end, + reload = function(payload: any): (boolean, string?) + local incoming = payload as RegisterLog + if not sameChips(log, incoming) then + local fresh, reason = makeChips(mixer, incoming, options) + if fresh == nil then + return false, reason + end + for _, chip in ipairs(chips) do + chip:destroy() + end + chips = assert(fresh) + for chip, mute in pairs(muted) do + if mute and chips[chip + 1] ~= nil then + chips[chip + 1]:setGain(0) + end + end + else + for position, entry in ipairs(incoming.chips) do + for class, image in pairs(entry.memory) do + if #image > 0 then + chips[position]:setMemory(class as chipfm.Memory, image) + end + end + end + end + log = incoming + result.duration = log.duration + result.loopStart = log.loopStart + index = 1 + + return true + end, + pause = function(): nil + for _, chip in ipairs(chips) do + chip:pause() + end + end, + resume = function(): nil + for _, chip in ipairs(chips) do + chip:resume() + end + end, + setGain = function(value: number): nil + gain = value + for position, chip in ipairs(chips) do + if not muted[position - 1] then + chip:setGain(gain) + end + end + end, + setMuted = function(chip: integer, mute: boolean): nil + local target = chips[chip + 1] + if target == nil then + error( + "tecs.audio.music.Player.setMuted: the log has chips 0 to " .. tostring( + #chips - 1 + ) .. ", got " .. tostring(chip), + 3 + ) + end + if mute then + muted[chip] = true + else + muted[chip] = nil + end + target:setGain(mute and 0 or gain) + end, + stop = function(fadeOut: number?): nil + for _, chip in ipairs(chips) do + chip:destroy(fadeOut) + end + end + ) + + return result +end + +--- Plays a parsed log on chips of its own. +--- +--- @param mixer the mixer the chips play on +--- @param log the log, which the player reads and does not change +--- @param options the group, gain, loop, tempo, lookahead and start, or nil +--- for the defaults +--- @return the player, or nil when a chip could not start +--- @return the reason, when the first return is nil +--- @raises string when an option is out of range +export function playLog(mixer: audio.Audio, log: RegisterLog, options: music.PlayOptions?): (music.Player?, string?) + music.checkOptions("fm.playLog", options) + local chips, reason = makeChips(mixer, log, options) + if chips == nil then + return nil, reason + end + + return music.newPlayer(mixer, driver(mixer, log, assert(chips), options), nil, options) +end + +--- Plays a loaded log on chips of its own: one for each chip it names, on the +--- clock it gives, with its memory images loaded. +--- +--- The player retains the handle until it stops and follows a reload of it, +--- so the caller releases its own hold once this returns. Every chip takes a +--- voice, so a log of two chips needs two free voices. +--- +--- @param mixer the mixer the chips play on +--- @param handle a handle `kind` loaded +--- @param options the group, gain, loop, tempo, lookahead and start, or nil +--- for the defaults +--- @return the player, or nil when a chip could not start +--- @return the reason, when the first return is nil +--- @raises string when the handle was not loaded as a register log or has +--- been released +--- @raises string when an option is out of range +export function play(mixer: audio.Audio, handle: assets.Handle, options: music.PlayOptions?): (music.Player?, string?) + if handle.kind ~= assets.MUSIC_FM then + error("tecs.audio.music.fm.play: the handle was loaded as " .. handle.kind .. ", not as a register log", 2) + end + local payload = handle:value() + if payload == nil then + error("tecs.audio.music.fm.play: the handle has been released", 2) + end + music.checkOptions("fm.play", options) + local log = payload as RegisterLog + local chips, reason = makeChips(mixer, log, options) + if chips == nil then + return nil, reason + end + + return music.newPlayer(mixer, driver(mixer, log, assert(chips), options), handle, options) +end diff --git a/src/tecs/audio/music/init.nupp b/src/tecs/audio/music/init.nupp new file mode 100644 index 00000000..35a799e5 --- /dev/null +++ b/src/tecs/audio/music/init.nupp @@ -0,0 +1,661 @@ +module tecs.audio.music + +--[[ +Songs played on the mixer's synthesizer voices: Standard MIDI Files on the +General MIDI synth, and register logs on emulated FM chips. + +Two asset kinds load them through [`Assets`](tecs.assets.Assets), and a +[`Player`](tecs.audio.music.Player) plays a loaded song: + +```nupp +local mixer = tecs.audio.newAudio() +local font = assert(tecs.audio.synth.loadSoundFont(mixer, "assets/music/FluidR3_GM.sf2")) +local song = assert(app.assets:request(tecs.audio.music.midi.kind, "assets/music/theme.mid")) +local player = assert(tecs.audio.music.midi.play(mixer, font, song, {group = "music"})) +song:release() -- the player holds the song for as long as it plays + +local log = assert(app.assets:request(tecs.audio.music.fm.kind, "assets/music/stage1.vgz")) +local chiptune = assert(tecs.audio.music.fm.play(mixer, log, {group = "music"})) +log:release() +``` + +[`tecs.audio.music.midi`](tecs.audio.music.midi) reads `.mid` files, format +0, 1 and 2. [`tecs.audio.music.fm`](tecs.audio.music.fm) reads VGM, its +gzip-compressed form VGZ, and the DOSBox Raw OPL captures `.dro`, versions 1 +and 2. Both parse in Nupp on the loader's readiness pump, a slice at a time, so +a large file loads across frames rather than stalling one. + +## Playing + +A player owns the voices it plays through, a synth for a MIDI song and one +chip for each chip a register log names, and frees them when it stops. It +holds the song handle it was given until then, so the caller releases its own +hold as soon as `play` returns. Every mixer `update` advances every player +made on that mixer, so a game that already drives its mixer drives its music +too, and keeps doing so while its world is paused. + +The player schedules ahead on the voices' own clock: each update sends what +falls due within `lookahead` seconds of `now`, stamped with the time it is due +at, so the music keeps its timing whatever the frame rate does. After a stall +longer than the lookahead, the song slips rather than rushing to catch up: the +first event the stall made late plays at once, and everything after it keeps +its distance from it, rather than everything missed playing together. A stall +over a rest in the music changes nothing. A send the voice's bounded queue +refuses is retried on the next update. + +## Looping, tempo and position + +A song with a loop point loops forever unless `loop` says otherwise: a MIDI +file through a `loopStart` and `loopEnd` marker or controller 111, a VGM file +through its loop offset. A song without one plays once and then reports +`finished`. `setTempo` scales the speed: two plays twice as fast. On a MIDI +song that is the tempo; on a register log it is the rate the writes arrive at, +so notes keep their pitch but envelopes run at the chip's own speed. + +`seek` moves to a time in the song. A MIDI player resets the synth and sends +the programs, controllers and pitch bends in force at that point; a register +log resets each chip and writes the last value of every register before it. A +note held across the point comes back on a MIDI song's next note on, and on a +chip with the envelope restarted. + +## Reload + +A song handle reloaded through `Assets.reload` advances its revision, and a +player watching it rebuilds itself on the new song at the position it had +reached. A register log whose chips changed makes new chips. + +## Other formats + +A player is driven through a [`Driver`](tecs.audio.music.Driver), which is all +a format module supplies. A game that has a format of its own can write one +and play it with `newPlayer`. +]] + +local assets = require("tecs.assets") +local audio = require("tecs.audio") +local audiosynth = require("tecs.internal.audiosynth") + +--- Seconds of song a player keeps scheduled ahead of the voice clock unless +--- asked otherwise: two or three frames' worth, which absorbs an ordinary +--- hitch. +local DEFAULT_LOOKAHEAD: number = 0.05 + +--- The range `lookahead` may take, in seconds. +local MIN_LOOKAHEAD: number = 0.005 + +local MAX_LOOKAHEAD: number = 2 + +--- Seconds after `now` a start, a seek or a slip lands at, so its first +--- events are scheduled rather than late. +local LEAD: number = 0.01 + +--- The shortest loop a player repeats, in seconds of song. A loop point that +--- leaves less than this is ignored, because repeating it would spin. +local MIN_LOOP: number = 0.001 + +--- The range `setTempo` takes. +local MIN_TEMPO: number = 0.01 + +local MAX_TEMPO: number = 100 + +--- Where a player stands. +export type State = "playing" | "paused" | "finished" | "stopped" + +--- What a format module supplies to a [`Player`](tecs.audio.music.Player): +--- the voices a song plays on and the song's events, behind the operations a +--- player needs. +--- +--- Song time is seconds from the start of the song at a tempo of one. Clock +--- time is seconds on the voices' own clock, which `now` reads. A player maps +--- one to the other with an anchor: song time `song` is clock time `clock`, +--- and `tempo` song seconds pass for each clock second. +export record Driver + --- Read-only. Reports the song's length in seconds. + duration: number + + --- Read-only. Reports where the song loops back to, in seconds, or nil for + --- a song without a loop. + loopStart: number? + + --- Read-only. Reports where a loop ends and the song goes back to + --- `loopStart`, in seconds, or nil for the end of the song. + loopEnd: number? + + --- Read-only. Reports the seconds a voice keeps sounding after the last + --- event, for releases and reverb, before a finished song frees it. + tail: number + + --- Read-only. Reports the voices' clock in seconds. + now: function(): number + + --- Read-only. Sends every event due at or after the driver's cursor and + --- before song time `to`, each at clock time + --- `clock + (time - song) / tempo`, and returns the song time it reached: + --- `to`, or the time of the first event a voice refused, which the next + --- call starts from. + schedule: function(to: number, song: number, clock: number, tempo: number): number + + --- Read-only. Reports the song time of the first event the cursor has not + --- sent, or nil when every event has gone. + pending: function(): number? + + --- Read-only. Moves the cursor back to song time `target` for a loop, + --- after ending what still sounds at clock time `at`. + jump: function(target: number, at: number): nil + + --- Read-only. Moves the cursor to song time `target` from anywhere, + --- putting the voices in the state the song had there, at clock time `at`. + seek: function(target: number, at: number): nil + + --- Read-only. Replaces the song with the payload of a reloaded handle, + --- and returns false and a reason when it cannot. + reload: function(payload: any): (boolean, string?) + + --- Read-only. Pauses every voice, which stops its clock. + pause: function(): nil + + --- Read-only. Resumes every voice. + resume: function(): nil + + --- Read-only. Sets the gain of every voice. + setGain: function(gain: number): nil + + --- Read-only. Mutes or unmutes one part of the song: a MIDI channel or a + --- chip, numbered from zero. + setMuted: function(index: integer, muted: boolean): nil + + --- Read-only. Stops and frees every voice, over `fadeOut` seconds when + --- that is given. + stop: function(fadeOut: number?): nil +end + +--- Configures a player. The format modules' `play` functions take it too. +export type PlayOptions = { + --- Caller-writable. Selects the group the player's voices join, so the + --- group's gain, mute and pause apply to them. Defaults to none. + group: string?, + + --- Caller-writable. Sets the linear gain of the player's voices, before + --- group and master gain, and defaults to one. + gain: number?, + + --- Caller-writable. Repeats the song from its loop point. Defaults to true + --- when the song has one, and is ignored when it has none. + loop: boolean?, + + --- Caller-writable. Scales the speed, from 0.01 to 100, and defaults to + --- one. + tempo: number?, + + --- Caller-writable. Sets the seconds the player schedules ahead, from + --- 0.005 to 2, and defaults to 0.05. + lookahead: number?, + + --- Caller-writable. Sets where in the song playback starts, in seconds, + --- and defaults to zero. + start: number? +} + +--- A song playing on voices of its own. +--- +--- Made by `tecs.audio.music.midi.play`, `tecs.audio.music.fm.play` or +--- `newPlayer`, advanced by the mixer's `update`, and finished with `stop`. +--- Nothing here raises for a player that has stopped: every call on one +--- does nothing. +export record Player + @private driver: Driver + @private handle: assets.Handle? + @private revision: integer + @private state: State + @private loop: boolean + @private tempoScale: number + @private lookahead: number + @private cursor: number + @private anchorSong: number + @private anchorClock: number + @private previousSong: number + @private previousClock: number + @private finishClock: number + @private loopCount: integer + @private completed: boolean + @private failure: string? + + stop: function(exclusive self: Player, fadeOut: number?): nil + pause: function(exclusive self: Player): nil + resume: function(exclusive self: Player): nil + status: function(borrows self: Player): State + playing: function(borrows self: Player): boolean + paused: function(borrows self: Player): boolean + finished: function(borrows self: Player): boolean + position: function(borrows self: Player): number + duration: function(borrows self: Player): number + seek: function(exclusive self: Player, seconds: number): nil + setLoop: function(exclusive self: Player, loop: boolean): nil + looping: function(borrows self: Player): boolean + loops: function(borrows self: Player): integer + setTempo: function(exclusive self: Player, scale: number): nil + tempo: function(borrows self: Player): number + setGain: function(exclusive self: Player, gain: number): nil + setMuted: function(exclusive self: Player, index: integer, muted: boolean): nil + reloadFailure: function(borrows self: Player): string? + update: function(exclusive self: Player): boolean +end + +--- Reports whether a player stands in `state`, as an expression the checker +--- does not read as a match over every state. +local function standing(borrows self: Player, state: State): boolean + return self.state == state +end + +--- Returns the song's loop as a start and an end, or nil when it has none +--- worth repeating. +local function loopOf(driver: Driver): (number?, number) + local start = driver.loopStart + local finish = driver.loopEnd or driver.duration + if start == nil or finish - start < MIN_LOOP then + return nil, driver.duration + end + + return start, finish +end + +--- Returns the clock time song time `song` is scheduled at. +local function clockOf(borrows self: Player, song: number): number + return self.anchorClock + (song - self.anchorSong) / self.tempoScale +end + +--- Moves the anchor, keeping the one it replaces for `position` while the +--- clock has not reached the new one. +local function anchor(exclusive self: Player, song: number, clock: number): nil + self.previousSong = self.anchorSong + self.previousClock = self.anchorClock + self.anchorSong = song + self.anchorClock = clock +end + +--- Checks an options value against a range. +--- @raises string when the value is outside the range +local function within(name: string, what: string, value: number, low: number, high: number): nil + if value ~= value or value < low or value > high then + error( + "tecs.audio.music." .. name .. ": " .. what .. " must be from " .. tostring( + low + ) .. " to " .. tostring(high) .. ", got " .. tostring(value), + 3 + ) + end +end + +--- Raises unless play options are in range. +--- @raises string when `tempo`, `lookahead` or `start` is out of range +export function checkOptions(name: string, options: PlayOptions?): nil + if options == nil then + return + end + if options.tempo ~= nil then + within(name, "tempo", options.tempo, MIN_TEMPO, MAX_TEMPO) + end + if options.lookahead ~= nil then + within(name, "lookahead", options.lookahead, MIN_LOOKAHEAD, MAX_LOOKAHEAD) + end + if options.start ~= nil and (options.start ~= options.start or options.start < 0) then + error("tecs.audio.music." .. name .. ": start must not be negative, got " .. tostring(options.start), 2) + end +end + +--- Starts a player on a driver and registers it with the mixer's update. +--- +--- What the format modules' `play` functions end in, and what a game with a +--- format of its own calls with a driver it wrote. +--- +--- @param mixer the mixer whose `update` advances the player +--- @param driver the song and its voices, which the player now owns +--- @param handle the song's handle, which the player retains until it stops +--- and watches for a reload, or nil for a song held some other way +--- @param options the loop, tempo, lookahead and start, or nil for the +--- defaults. `group` and `gain` are the driver's to apply. +--- @return the playing player +--- @raises string when an option is out of range +export function newPlayer(mixer: audio.Audio, driver: Driver, handle: assets.Handle?, options: PlayOptions?): Player + checkOptions("newPlayer", options) + local start = math.min((options and options.start) or 0, driver.duration) + local clock = driver.now() + LEAD + local player = new Player( + driver = driver, + handle = handle, + revision = handle ~= nil and handle:revision() or 0, + state = "playing", + loop = not (options ~= nil and options.loop == false), + tempoScale = (options and options.tempo) or 1, + lookahead = (options and options.lookahead) or DEFAULT_LOOKAHEAD, + cursor = start, + anchorSong = start, + anchorClock = clock, + previousSong = start, + previousClock = clock, + finishClock = 0, + loopCount = 0, + completed = false, + failure = nil + ) + if handle ~= nil then + handle:retain() + end + if start > 0 then + driver.seek(start, clock) + end + local updates = audiosynth.updates[mixer] + if updates == nil then + updates = {} + audiosynth.updates[mixer] = updates + end + updates[#updates + 1] = function(): boolean + return player:update() + end + player:update() + + return player +end + +--- Stops the song and frees its voices. Safe more than once. +--- +--- @param self the player to stop +--- @param fadeOut seconds to fade the voices out over, or nil to cut them +function Player.stop(exclusive self: Player, fadeOut: number?): nil + if standing(self, "stopped") then + return + end + self.state = "stopped" + self.driver.stop(fadeOut) + local handle = self.handle + if handle ~= nil then + self.handle = nil + handle:release() + end +end + +--- Pauses the song where it stands, notes held. +--- +--- The voices pause, which stops their clock, so what was scheduled waits and +--- `resume` carries on exactly where the song was. +--- +--- @param self the player to pause +function Player.pause(exclusive self: Player): nil + if not standing(self, "playing") then + return + end + self.state = "paused" + self.driver.pause() +end + +--- Resumes a paused song. +--- +--- @param self the player to resume +function Player.resume(exclusive self: Player): nil + if not standing(self, "paused") then + return + end + self.state = "playing" + self.driver.resume() +end + +--- Reports where the player stands. +--- +--- @param self the player to inspect +--- @return `"playing"`, `"paused"`, `"finished"` while a song that does not +--- loop rings out after its last event, or `"stopped"` once it has, or +--- once `stop` ran +function Player.status(borrows self: Player): State + return self.state +end + +--- Reports whether the song is playing. +--- +--- @param self the player to inspect +--- @return true while playing, false paused, finished or stopped +function Player.playing(borrows self: Player): boolean + return self.state == "playing" +end + +--- Reports whether the song is paused. +--- +--- @param self the player to inspect +--- @return true between `pause` and `resume` +function Player.paused(borrows self: Player): boolean + return self.state == "paused" +end + +--- Reports whether a song that does not loop has played to its end. +--- +--- The voices keep sounding for the song's release tail, while `status` reads +--- `"finished"` and a `seek` can still play the song again, and then the +--- player stops itself and frees them. This stays true through that. +--- +--- @param self the player to inspect +--- @return true once the last event has played, until a `seek` plays on +function Player.finished(borrows self: Player): boolean + return self.completed +end + +--- Returns how far into the song the voices are. +--- +--- @param self the player to inspect +--- @return seconds of song at a tempo of one, from zero to the duration +function Player.position(borrows self: Player): number + local now = self.driver.now() + local song: number + if now >= self.anchorClock then + song = self.anchorSong + (now - self.anchorClock) * self.tempoScale + else + song = self.previousSong + (now - self.previousClock) * self.tempoScale + end + if song < 0 then + return 0 + end + + return math.min(song, self.driver.duration) +end + +--- Returns the song's length. +--- +--- @param self the player to inspect +--- @return seconds at a tempo of one +function Player.duration(borrows self: Player): number + return self.driver.duration +end + +--- Moves playback to a time in the song. +--- +--- A finished song plays again from there. +--- +--- @param self the player to move +--- @param seconds the song time, clamped to the song +function Player.seek(exclusive self: Player, seconds: number): nil + if standing(self, "stopped") then + return + end + local target = math.max(0, math.min(seconds, self.driver.duration)) + local at = self.driver.now() + LEAD + self.driver.seek(target, at) + anchor(self, target, at) + self.previousSong = target + self.previousClock = at + self.cursor = target + if self.state == "finished" then + self.state = "playing" + end + self.completed = false +end + +--- Sets whether the song repeats from its loop point. +--- +--- @param self the player to change +--- @param loop true to repeat, false to play on to the end +function Player.setLoop(exclusive self: Player, loop: boolean): nil + self.loop = loop +end + +--- Reports whether the song will repeat. +--- +--- @param self the player to inspect +--- @return true when looping is on and the song has a loop point +function Player.looping(borrows self: Player): boolean + local start = loopOf(self.driver) + + return self.loop and start ~= nil +end + +--- Returns how many times the song has gone back to its loop point. +--- +--- @param self the player to inspect +--- @return the count of loops scheduled so far +function Player.loops(borrows self: Player): integer + return self.loopCount +end + +--- Scales the playback speed from the point already scheduled. +--- +--- @param self the player to change +--- @param scale from 0.01 to 100, where one is the song's own speed +--- @raises string when the scale is outside its range +function Player.setTempo(exclusive self: Player, scale: number): nil + within("setTempo", "tempo", scale, MIN_TEMPO, MAX_TEMPO) + anchor(self, self.cursor, clockOf(self, self.cursor)) + self.tempoScale = scale +end + +--- Returns the speed scale. +--- +--- @param self the player to inspect +--- @return the scale, one by default +function Player.tempo(borrows self: Player): number + return self.tempoScale +end + +--- Sets the gain of the player's voices, before group and master gain. +--- +--- @param self the player to change +--- @param gain linear amplitude +function Player.setGain(exclusive self: Player, gain: number): nil + if standing(self, "stopped") then + return + end + self.driver.setGain(gain) +end + +--- Mutes or unmutes one part of the song. +--- +--- @param self the player to change +--- @param index a MIDI channel from 0 to 15, or a register log's chip from 0 +--- @param muted true to silence it +function Player.setMuted(exclusive self: Player, index: integer, muted: boolean): nil + if standing(self, "stopped") then + return + end + self.driver.setMuted(index, muted) +end + +--- Returns why the last reload of the song could not be played, if one +--- could not. +--- +--- A reload the driver cannot take leaves the player on the song it had. +--- +--- @param self the player to inspect +--- @return the reason, or nil +function Player.reloadFailure(borrows self: Player): string? + return self.failure +end + +--- Advances the player: what the mixer's `update` calls. +--- +--- Ordinary game code does not call it. A test driving a player without a +--- mixer update does. +--- +--- @param self the player to advance +--- @return false once the player has stopped, which drops it from the update +function Player.update(exclusive self: Player): boolean + if standing(self, "stopped") then + return false + end + local handle = self.handle + if handle ~= nil and handle:revision() ~= self.revision then + self.revision = handle:revision() + local payload = handle:value() + if payload ~= nil then + local position = self:position() + local reloaded, reason = self.driver.reload(payload) + if reloaded then + self.failure = nil + self:seek(position) + else + self.failure = reason or "the reloaded song could not be played" + end + end + end + if standing(self, "paused") then + return true + end + local driver = self.driver + local now = driver.now() + if standing(self, "finished") then + if now >= self.finishClock + driver.tail then + self:stop() + + return false + end + + return true + end + local next = driver.pending() + if next ~= nil and clockOf(self, next) < now then + -- A stall outran the lookahead and an event is already late: play it + -- now, and what follows it at its own distance, rather than rushing + -- everything that was missed. + anchor(self, next, now + LEAD) + self.cursor = math.max(self.cursor, next) + end + local horizon = now + self.lookahead + -- Bounded, so a loop shorter than one update's window cannot spin. + for _ = 1, 64 do + local loopStart, loopEnd = loopOf(driver) + local looping = self.loop and loopStart ~= nil + local finish = looping and loopEnd or driver.duration + local target = self.anchorSong + (horizon - self.anchorClock) * self.tempoScale + if target < finish then + self.cursor = driver.schedule(target, self.anchorSong, self.anchorClock, self.tempoScale) + + return true + end + -- A loop ends before the events at its end, which belong to the next + -- pass; the song's own end takes everything left, the note offs at + -- its last tick included. + local limit = looping and finish or math.huge + local reached = driver.schedule(limit, self.anchorSong, self.anchorClock, self.tempoScale) + if reached < limit then + self.cursor = reached + + return true + end + self.cursor = finish + local at = clockOf(self, finish) + if looping and loopStart ~= nil then + driver.jump(loopStart, at) + anchor(self, loopStart, at) + self.cursor = loopStart + self.loopCount = self.loopCount + 1 + else + self.state = "finished" + self.completed = true + self.finishClock = at + if now >= at + driver.tail then + self:stop() + + return false + end + + return true + end + end + + return true +end diff --git a/src/tecs/audio/music/midi.nupp b/src/tecs/audio/music/midi.nupp new file mode 100644 index 00000000..05e9b7f8 --- /dev/null +++ b/src/tecs/audio/music/midi.nupp @@ -0,0 +1,933 @@ +module tecs.audio.music.midi + +--[[ +Standard MIDI Files, loaded as assets and played on the General MIDI synth. + +```nupp +local font = assert(tecs.audio.synth.loadSoundFont(mixer, "assets/music/FluidR3_GM.sf2")) +local song = assert(app.assets:request(tecs.audio.music.midi.kind, "assets/music/theme.mid")) +local player = assert(tecs.audio.music.midi.play(mixer, font, song, {group = "music"})) +song:release() +``` + +`kind` loads a `.mid` file as a [`Song`](tecs.audio.music.midi.Song): its +header, its tracks merged into one list of timed events, its tempo map and its +loop points. `parse` does the same for bytes a game already holds. `play` +plays a loaded song on a synthesizer of its own, made on a SoundFont the game +loaded, and `playSong` plays a parsed one. + +## What is read + +Formats 0 and 1 are read as one song, every track at once. Format 2 holds +independent patterns rather than one song, and they are played one after +another, each starting where the previous one ended. Delta times, running +status, every channel message, system exclusive messages and meta events are +read; a chunk that is not a track is skipped, as the format asks. Time comes +from the tempo map, the tempo events of every track together, starting at 120 +beats a minute, or from the SMPTE frame rate of a file that counts in frames. + +System exclusive messages are kept in the song but not sent, because the synth +takes short messages only: a General MIDI, GS or XG reset at the start of a +file does nothing a fresh synth needs. Meta events are kept too and nothing is +done with them beyond the tempo map and the loop points. + +## Loop points + +A song loops where it says it does, in the two conventions a MIDI file has for +it. A marker meta event reading `loopStart`, and one reading `loopEnd`, case +and spaces aside, bracket the loop; controller 111 on any channel, the RPG +Maker convention, marks the start when there is no marker. A song with a start +and no end loops at its last event. A song with neither plays once. + +## Errors + +A file that is not a Standard MIDI File, one cut short inside a chunk, a +running-status byte with no status before it, a data byte with its top bit set +and a delta time longer than four bytes are refused with the byte offset where +the reading stopped. A file is at most 16 MiB. +]] + +local assets = require("tecs.assets") +local audio = require("tecs.audio") +local music = require("tecs.audio.music") +local synth = require("tecs.audio.synth") + +const byte = string.byte + +--- The largest file `parse` reads, in bytes. +local MAX_BYTES: integer = 16 * 1024 * 1024 + +--- Events one readiness-pump pass parses before it lets the frame go on. +local PARSE_BUDGET: integer = 65536 + +--- Microseconds a quarter note lasts before the first tempo event: 120 beats +--- a minute, as the format defines. +local DEFAULT_TEMPO: integer = 500000 + +--- Seconds a synth keeps sounding after the last event of a finished song, +--- for releases and reverb. +local TAIL: number = 2 + +--- The controller RPG Maker marks a loop's start with. +local LOOP_CONTROLLER: integer = 111 + +--- The meta event type of a marker. +local META_MARKER: integer = 0x06 + +--- The meta event type of a track name. +local META_NAME: integer = 0x03 + +--- The meta event type of a tempo. +local META_TEMPO: integer = 0x51 + +--- The meta event type of a track's end. +local META_END: integer = 0x2F + +--- One event of a song. +export record Event + --- Read-only. Reports the event's time in ticks from the song's start. + tick: integer + + --- Read-only. Reports the event's time in seconds from the song's start, + --- through the tempo map. + time: number + + --- Read-only. Reports the track the event came from, numbered from one. + track: integer + + --- Read-only. Reports the status byte: 0x80 to 0xEF for a channel + --- message, with the channel in its low four bits, 0xF0 or 0xF7 for a + --- system exclusive message, and 0xFF for a meta event. + status: integer + + --- Read-only. Reports the first data byte of a channel message, and zero + --- for anything else. + data1: integer + + --- Read-only. Reports the second data byte of a channel message, and zero + --- for one that carries a single byte and for anything else. + data2: integer + + --- Read-only. Reports the type of a meta event, and nil for anything + --- else. + meta: integer? + + --- Read-only. Holds the bytes of a meta event or of a system exclusive + --- message, and nil for a channel message. + data: string? +end + +--- One tempo in force from a point of the song. +export record Tempo + --- Read-only. Reports where it takes effect, in ticks. + tick: integer + + --- Read-only. Reports where it takes effect, in seconds. + time: number + + --- Read-only. Reports the microseconds a quarter note lasts. + microsecondsPerQuarter: integer +end + +--- One track as the file had it. +export record Track + --- Read-only. Reports the track's name meta event, or nil. + name: string? + + --- Read-only. Reports how many events the track holds, its end aside. + events: integer + + --- Read-only. Reports where the track ends, in ticks of the song, which + --- for format 2 includes the patterns before it. + endTick: integer +end + +--- A parsed Standard MIDI File. +export record Song + --- Read-only. Reports the file's format: 0, 1 or 2. + format: integer + + --- Read-only. Reports the ticks in a quarter note, or nil for a file + --- that counts in SMPTE frames. + ppq: integer? + + --- Read-only. Reports the SMPTE frames a second, 24, 25, 29.97 or 30, + --- for a file that counts in frames, and nil otherwise. + framesPerSecond: number? + + --- Read-only. Reports the ticks in an SMPTE frame, or nil. + ticksPerFrame: integer? + + --- Read-only. Lists the tracks in file order. + tracks: {Track} + + --- Read-only. Lists every event of every track, end-of-track events + --- aside, in time order, and in track order among events at one tick. + events: {Event} + + --- Read-only. Lists the tempo map in time order, starting with the tempo + --- at tick zero. + tempos: {Tempo} + + --- Read-only. Reports the song's length in seconds: the end of its + --- longest track. + duration: number + + --- Read-only. Reports where the song loops back to, in seconds, or nil. + loopStart: number? + + --- Read-only. Reports where its loop ends, in seconds, or nil for the end + --- of the song. + loopEnd: number? + + --- Read-only. Reports the first track's name, which is where a song's + --- title is kept, or nil. + title: string? +end + +--- One track being read. +local record Reading + track: integer + position: integer + finish: integer + tick: integer + offset: integer + running: integer + name: string? + count: integer +end + +--- A file being read a slice at a time. +local record Parser + bytes: string + name: string + format: integer + tracks: integer + division: integer + position: integer + reading: Reading? + events: {Event} + order: {integer} + infos: {Track} + patternEnd: integer + done: boolean +end + +local function u16(bytes: string, at: integer): integer + const high, low = byte(bytes, at, at + 1) + + return (high as integer) * 256 + (low as integer) +end + +local function u32(bytes: string, at: integer): integer + const a, b, c, d = byte(bytes, at, at + 3) + + return (((a as integer) * 256 + (b as integer)) * 256 + (c as integer)) * 256 + (d as integer) +end + +--- Returns `text` lowered with everything but letters and digits removed, +--- which is how a loop marker is compared. +local function squeezed(text: string): string + return (string.gsub(string.lower(text), "[^%w]", "")) +end + +--- Begins reading a file, or returns the reason its header is refused. +local function begin(bytes: string, name: string): (Parser?, string?) + if #bytes > MAX_BYTES then + return nil, name .. ": a MIDI file is at most 16 MiB, and this one is " .. tostring(#bytes) .. " bytes" + end + if #bytes < 14 or string.sub(bytes, 1, 4) ~= "MThd" then + return nil, name .. ": not a Standard MIDI File: it does not begin with an MThd chunk" + end + local length = u32(bytes, 5) + if length < 6 then + return nil, name .. ": the MThd chunk is " .. tostring(length) .. " bytes, and a MIDI header needs 6" + end + local format = u16(bytes, 9) + if format > 2 then + return nil, name .. ": format " .. tostring( + format + ) .. " is not a Standard MIDI File format, which are 0, 1 and 2" + end + local tracks = u16(bytes, 11) + if tracks == 0 then + return nil, name .. ": the header declares no tracks" + end + local division = u16(bytes, 13) + if division == 0 then + return nil, name .. ": the header's division is zero ticks" + end + if division >= 0x8000 then + local frames = 256 - division // 256 + if frames ~= 24 and frames ~= 25 and frames ~= 29 and frames ~= 30 then + return nil, name .. ": an SMPTE division of " .. tostring( + frames + ) .. " frames a second is not 24, 25, 29 or 30" + end + if division % 256 == 0 then + return nil, name .. ": an SMPTE division of zero ticks a frame" + end + end + + return new Parser( + bytes = bytes, + name = name, + format = format, + tracks = tracks, + division = division, + position = 9 + length, + reading = nil, + events = {}, + order = {}, + infos = {}, + patternEnd = 0, + done = false + ) +end + +--- Reads a variable-length quantity at `at`, and returns it and the position +--- after it, or nil when it runs past `finish` or past four bytes. +local function quantity(bytes: string, at: integer, finish: integer): (integer?, integer) + local value: integer = 0 + for count = 0, 3 do + local position = at + count + if position > finish then + return nil, position + end + local next = byte(bytes, position) as integer + value = value * 128 + next % 128 + if next < 128 then + return value, position + 1 + end + end + + return nil, -1 +end + +--- Moves to the next track chunk, skipping chunks that are not tracks, and +--- returns false when there is none. +local function nextTrack(exclusive self: Parser): boolean + local bytes = self.bytes + while self.position + 7 <= #bytes do + local id = string.sub(bytes, self.position, self.position + 3) + local length = u32(bytes, self.position + 4) + local start = self.position + 8 + self.position = start + length + if id == "MTrk" then + local finish = math.min(start + length - 1, #bytes) + local track = #self.infos + 1 + local offset = self.format == 2 and self.patternEnd or 0 + self.reading = new Reading( + track = track, + position = start, + finish = finish, + tick = offset, + offset = offset, + running = 0, + name = nil, + count = 0 + ) + + return true + end + end + + return false +end + +--- Ends the track being read at `tick`. +local function endTrack(exclusive self: Parser, reading: Reading): nil + self.infos[#self.infos + 1] = new Track(name = reading.name, events = reading.count, endTick = reading.tick) + if reading.tick > self.patternEnd then + self.patternEnd = reading.tick + end + self.reading = nil +end + +local function add(exclusive self: Parser, reading: Reading, event: Event): nil + local index = #self.events + 1 + self.events[index] = event + self.order[index] = index + reading.count = reading.count + 1 +end + +--- Reads up to `budget` events, and returns the reason the file is refused +--- if it is. +local function readEvents(exclusive self: Parser, budget: integer): string? + local bytes = self.bytes + local name = self.name + for _ = 1, budget do + local reading = self.reading + if reading == nil then + if not nextTrack(self) then + self.done = true + + return nil + end + reading = assert(self.reading) + end + local at = reading.position + local finish = reading.finish + const track = reading.track + local where = function(position: integer): string + return name .. ": track " .. tostring(track) .. " at byte " .. tostring(position - 1) + end + if at > finish then + -- A track without its end-of-track event ends where its chunk does. + endTrack(self, reading) + else + local delta, after = quantity(bytes, at, finish) + if delta == nil then + if after < 0 then + return where(at) .. ": a delta time longer than four bytes" + end + + return where(at) .. ": the track is cut short inside a delta time" + end + reading.tick = reading.tick + delta + at = after + if at > finish then + return where(at) .. ": the track is cut short after a delta time" + end + local status = byte(bytes, at) as integer + if status == 0xFF then + if at + 1 > finish then + return where(at) .. ": the track is cut short inside a meta event" + end + local meta = byte(bytes, at + 1) as integer + local length, start = quantity(bytes, at + 2, finish) + if length == nil or start + length - 1 > finish then + return where(at) .. ": the track is cut short inside a meta event" + end + local data = string.sub(bytes, start, start + length - 1) + reading.position = start + length + if meta == META_END then + endTrack(self, reading) + else + if meta == META_NAME and reading.name == nil then + reading.name = data + end + add( + self, + reading, + new Event( + tick = reading.tick, + time = 0, + track = reading.track, + status = 0xFF, + data1 = 0, + data2 = 0, + meta = meta, + data = data + ) + ) + end + elseif status == 0xF0 or status == 0xF7 then + local length, start = quantity(bytes, at + 1, finish) + if length == nil or start + length - 1 > finish then + return where(at) .. ": the track is cut short inside a system exclusive message" + end + reading.position = start + length + -- A system message cancels running status. + reading.running = 0 + add( + self, + reading, + new Event( + tick = reading.tick, + time = 0, + track = reading.track, + status = status, + data1 = 0, + data2 = 0, + meta = nil, + data = string.sub(bytes, start, start + length - 1) + ) + ) + elseif status >= 0xF1 then + return where(at) .. ": status " .. string.format("0x%02X", status) .. " is not allowed in a MIDI file" + else + local first = at + if status >= 0x80 then + reading.running = status + first = at + 1 + elseif reading.running == 0 then + return where(at) .. ": a data byte with no status before it" + else + status = reading.running + end + local nibble = status // 16 + local size = (nibble == 0xC or nibble == 0xD) and 1 or 2 + if first + size - 1 > finish then + return where(at) .. ": the track is cut short inside a channel message" + end + local data1 = byte(bytes, first) as integer + local data2 = size == 2 and byte(bytes, first + 1) as integer or 0 + if data1 >= 0x80 or data2 >= 0x80 then + return where(first) .. ": a data byte with its top bit set" + end + reading.position = first + size + add( + self, + reading, + new Event( + tick = reading.tick, + time = 0, + track = reading.track, + status = status, + data1 = data1, + data2 = data2, + meta = nil, + data = nil + ) + ) + end + end + end + + return nil +end + +--- Converts ticks to seconds through a tempo map, or through the SMPTE rate. +local function seconds(song: Song, tick: integer): number + local frames = song.framesPerSecond + if frames ~= nil then + return tick / (frames * assert(song.ticksPerFrame)) + end + local ppq = assert(song.ppq) + local tempos = song.tempos + local low: integer, high: integer = 1, #tempos + while low < high do + local middle = (low + high + 1) // 2 + if tempos[middle].tick <= tick then + low = middle + else + high = middle - 1 + end + end + local tempo = tempos[low] + + return tempo.time + (tick - tempo.tick) * tempo.microsecondsPerQuarter / (1000000 * ppq) +end + +--- Merges the tracks, times every event and finds the loop. +local function finish(exclusive self: Parser): Song + local events = self.events + local order = self.order + -- Stable by construction: events at one tick keep the order they were + -- read in, which is track order and then file order within a track. + table.sort(order, function(a: integer, b: integer): boolean + local left, right = events[a], events[b] + if left.tick ~= right.tick then + return left.tick < right.tick + end + + return a < b + end) + local merged: {Event} = {} + for index = 1, #order do + merged[index] = events[order[index]] + end + local ppq: integer? = nil + local framesPerSecond: number? = nil + local ticksPerFrame: integer? = nil + if self.division >= 0x8000 then + local frames = 256 - self.division // 256 + framesPerSecond = frames == 29 and 29.97 or frames + ticksPerFrame = self.division % 256 + else + ppq = self.division + end + local tempos: {Tempo} = {new Tempo(tick = 0, time = 0, microsecondsPerQuarter = DEFAULT_TEMPO)} + local song = new Song( + format = self.format, + ppq = ppq, + framesPerSecond = framesPerSecond, + ticksPerFrame = ticksPerFrame, + tracks = self.infos, + events = merged, + tempos = tempos, + duration = 0, + loopStart = nil, + loopEnd = nil, + title = self.infos[1] ~= nil and self.infos[1].name or nil + ) + local loopStart: number? = nil + local loopEnd: number? = nil + local controllerStart: number? = nil + for index = 1, #merged do + local event = merged[index] + event.time = seconds(song, event.tick) + if event.status == 0xFF then + local data = event.data or "" + if event.meta == META_TEMPO and #data == 3 and ppq ~= nil then + local micro = ( + byte(data, 1) as integer + ) * 65536 + (byte(data, 2) as integer) * 256 + (byte(data, 3) as integer) + if micro > 0 then + local last = tempos[#tempos] + if last.tick == event.tick then + last.microsecondsPerQuarter = micro + else + tempos[ + #tempos + 1 + ] = new Tempo(tick = event.tick, time = event.time, microsecondsPerQuarter = micro) + end + end + elseif event.meta == META_MARKER then + local marker = squeezed(data) + if marker == "loopstart" and loopStart == nil then + loopStart = event.time + elseif marker == "loopend" and loopEnd == nil then + loopEnd = event.time + end + end + elseif event.status // 16 == 0xB and event.data1 == LOOP_CONTROLLER and controllerStart == nil then + controllerStart = event.time + end + end + local endTick: integer = 0 + for _, track in ipairs(self.infos) do + if track.endTick > endTick then + endTick = track.endTick + end + end + song.duration = seconds(song, endTick) + song.loopStart = loopStart or controllerStart + if song.loopStart ~= nil and loopEnd ~= nil and loopEnd > assert(song.loopStart) then + song.loopEnd = loopEnd + end + + return song +end + +--- Advances a parse by up to `budget` events. +local function step(exclusive self: Parser, budget: integer): (assets.Status, Song?, string?) + local reason = readEvents(self, budget) + if reason ~= nil then + return "failed", nil, reason + end + if not self.done then + return "pending" + end + if #self.infos == 0 then + return "failed", nil, self.name .. ": the file holds no MTrk chunk" + end + + return "ready", finish(self) +end + +--- Parses the bytes of a whole Standard MIDI File. +--- +--- @param bytes the file, which is not retained once the song is built +--- @param name what a refusal names the file as, and defaults to `"MIDI file"` +--- @return the song, or nil when the bytes are refused +--- @return the reason, naming the byte where reading stopped +export function parse(bytes: string, name: string?): (Song?, string?) + local parser, reason = begin(bytes, name or "MIDI file") + if parser == nil then + return nil, reason + end + while true do + local status, song, failure = step(parser, PARSE_BUDGET) + local outcome: string = status + if outcome == "ready" then + return song + elseif outcome == "failed" then + return nil, failure + end + end +end + +--- Loads a `.mid` file as a [`Song`](tecs.audio.music.midi.Song). +--- +--- The file is read and parsed on the loader's readiness pump, up to 65 536 +--- events a pass, so `handle:value() as tecs.audio.music.midi.Song` is the +--- cast its caller writes. The collector owns the song. +export const kind: assets.Kind = new assets.Kind( + name = assets.MUSIC_MIDI, + open = function(path: string): (assets.Job?, string?) + local read, reason = assets.readJob(path) + if read == nil then + return nil, reason + end + local parser: Parser? = nil + + return new assets.Job( + poll = function(): (assets.Status, any?, string?) + local current = parser + if current == nil then + local status, bytes, failure = read.poll() + local reading: string = status + if reading ~= "ready" then + return status, nil, failure + end + local begun, refused = begin(bytes as string, path) + if begun == nil then + return "failed", nil, refused + end + parser = begun + + return "pending" + end + + return step(current, PARSE_BUDGET) + end, + cancel = function(): nil + read.cancel() + parser = nil + end + ) + end, + release = nil +) + +--- Returns the index of the first event at or after `time`. +local function locate(events: {Event}, time: number): integer + local low: integer, high: integer = 1, #events + 1 + while low < high do + local middle = (low + high) // 2 + if events[middle].time < time then + low = middle + 1 + else + high = middle + end + end + + return low +end + +--- Builds the driver that plays a song on a synth. +local function driver(voice: synth.Synth, first: Song): music.Driver + local song = first + local index: integer = 1 + local held: {[integer]: boolean} = {} + local muted: {[integer]: boolean} = {} + local gain: number = 1 + local result: music.Driver + + local function release(at: number, channel: integer?): nil + for note in pairs(held) do + local owner = note // 128 + if channel == nil or owner == channel then + voice:noteOff(owner, note % 128, at) + held[note] = nil + end + end + end + + result = new music.Driver( + duration = song.duration, + loopStart = song.loopStart, + loopEnd = song.loopEnd, + tail = TAIL, + now = function(): number + return voice:now() + end, + schedule = function(to: number, start: number, clock: number, tempo: number): number + local events = song.events + while index <= #events do + local event = events[index] + if event.time >= to then + return to + end + local status = event.status + if status < 0xF0 then + local channel = status % 16 + local nibble = status // 16 + local on = nibble == 0x9 and event.data2 > 0 + if not (on and muted[channel]) then + local at = clock + (event.time - start) / tempo + if not voice:send(status, event.data1, event.data2, at) then + return event.time + end + local note = channel * 128 + event.data1 + if on then + held[note] = true + elseif nibble == 0x8 or nibble == 0x9 then + held[note] = nil + end + end + end + index = index + 1 + end + + return to + end, + pending = function(): number? + local events = song.events + while index <= #events do + local event = events[index] + if event.status < 0xF0 then + return event.time + end + index = index + 1 + end + + return nil + end, + jump = function(target: number, at: number): nil + release(at, nil) + index = locate(song.events, target) + end, + seek = function(target: number, at: number): nil + voice:reset() + held = {} + local programs: {[integer]: integer} = {} + local controllers: {[integer]: {[integer]: integer}} = {} + local bends: {[integer]: integer} = {} + local pressures: {[integer]: integer} = {} + local events = song.events + local stop = locate(events, target) + for position = 1, stop - 1 do + local event = events[position] + local nibble = event.status // 16 + local channel = event.status % 16 + if nibble == 0xB and event.data1 < 120 then + local values = controllers[channel] + if values == nil then + values = {} + controllers[channel] = values + end + values[event.data1] = event.data2 + elseif nibble == 0xC then + programs[channel] = event.data1 + elseif nibble == 0xD then + pressures[channel] = event.data1 + elseif nibble == 0xE then + bends[channel] = event.data1 + event.data2 * 128 + end + end + for channel = 0, 15 do + local values = controllers[channel] + if values ~= nil then + for controller = 0, 119 do + local value = values[controller] + if value ~= nil then + voice:controlChange(channel, controller, value, at) + end + end + end + local program = programs[channel] + if program ~= nil then + voice:programChange(channel, program, at) + end + local bend = bends[channel] + if bend ~= nil then + voice:pitchBend(channel, bend, at) + end + local pressure = pressures[channel] + if pressure ~= nil then + voice:send(0xD0 + channel, pressure, 0, at) + end + end + index = stop + end, + reload = function(payload: any): (boolean, string?) + song = payload as Song + result.duration = song.duration + result.loopStart = song.loopStart + result.loopEnd = song.loopEnd + index = 1 + + return true + end, + pause = function(): nil + voice:pause() + end, + resume = function(): nil + voice:resume() + end, + setGain = function(value: number): nil + gain = value + voice:setGain(gain) + end, + setMuted = function(channel: integer, mute: boolean): nil + if channel < 0 or channel > 15 then + error( + "tecs.audio.music.Player.setMuted: a MIDI channel must be from 0 to 15, got " .. tostring(channel), + 3 + ) + end + if mute then + muted[channel] = true + else + muted[channel] = nil + end + if mute then + release(0, channel) + voice:allNotesOff(channel) + end + end, + stop = function(fadeOut: number?): nil + voice:destroy(fadeOut) + end + ) + + return result +end + +--- Plays a parsed song on a synthesizer of its own. +--- +--- @param mixer the mixer the synth plays on +--- @param font the SoundFont the synth plays, loaded into that mixer +--- @param song the song, which the player reads and does not change +--- @param options the group, gain, loop, tempo, lookahead and start, or nil +--- for the defaults +--- @return the player, or nil when the synth could not start +--- @return the reason, when the first return is nil +--- @raises string when an option is out of range, or the SoundFont belongs +--- to another mixer +export function playSong( + mixer: audio.Audio, + font: synth.SoundFont, + song: Song, + options: music.PlayOptions? +): (music.Player?, string?) + music.checkOptions("midi.playSong", options) + local voice, reason = synth.new(mixer, font, {group = options and options.group, gain = options and options.gain}) + if voice == nil then + return nil, reason + end + local made = assert(voice) + + return music.newPlayer(mixer, driver(made, song), nil, options) +end + +--- Plays a loaded song on a synthesizer of its own. +--- +--- The player retains the handle until it stops and follows a reload of it, +--- so the caller releases its own hold once this returns. +--- +--- @param mixer the mixer the synth plays on +--- @param font the SoundFont the synth plays, loaded into that mixer +--- @param handle a handle `kind` loaded +--- @param options the group, gain, loop, tempo, lookahead and start, or nil +--- for the defaults +--- @return the player, or nil when the synth could not start +--- @return the reason, when the first return is nil +--- @raises string when the handle was not loaded as a MIDI song or has been +--- released +--- @raises string when an option is out of range +export function play( + mixer: audio.Audio, + font: synth.SoundFont, + handle: assets.Handle, + options: music.PlayOptions? +): (music.Player?, string?) + if handle.kind ~= assets.MUSIC_MIDI then + error("tecs.audio.music.midi.play: the handle was loaded as " .. handle.kind .. ", not as a MIDI song", 2) + end + local song = handle:value() + if song == nil then + error("tecs.audio.music.midi.play: the handle has been released", 2) + end + music.checkOptions("midi.play", options) + local voice, reason = synth.new(mixer, font, {group = options and options.group, gain = options and options.gain}) + if voice == nil then + return nil, reason + end + local made = assert(voice) + + return music.newPlayer(mixer, driver(made, song as Song), handle, options) +end diff --git a/src/tecs/internal/audiosynth.nupp b/src/tecs/internal/audiosynth.nupp index ce02623f..f02e042f 100644 --- a/src/tecs/internal/audiosynth.nupp +++ b/src/tecs/internal/audiosynth.nupp @@ -1,13 +1,15 @@ module tecs.internal.audiosynth --[[ -What `tecs.audio` lends `tecs.audio.synth` and `tecs.audio.fm`. +What `tecs.audio` lends `tecs.audio.synth`, `tecs.audio.fm` and the music +players. A synthesizer, General MIDI or FM, is a voice of the mixer, so it needs a slot taken the way `Audio.play` takes one and the backend it opens on. Both live in fields `tecs.audio` keeps private, and a child module cannot reach them. This module is the seam between them: `tecs.audio` fills `lent` for each mixer it -makes, and the two synthesizer modules read it. Nothing else should. +makes, and the two synthesizer modules read it. Nothing else should. The music +players register in `updates`, which `Audio.update` runs. ]] local audiobackend = require("tecs.platform.audiobackend") @@ -26,3 +28,8 @@ end --- Holds the lender each mixer registers when it is made, keyed weakly by the --- mixer so one that has gone takes its entry with it. export const lent: {[any]: Lender} = setmetatable({}, {__mode = "k"}) + +--- Holds what each mixer's `update` advances besides its voices: the music +--- players made on it, each a function that returns false once it is done +--- and should be dropped. Keyed weakly by the mixer, as `lent` is. +export const updates: {[any]: {function(): boolean}} = setmetatable({}, {__mode = "k"}) diff --git a/src/tecs/internal/dro.nupp b/src/tecs/internal/dro.nupp new file mode 100644 index 00000000..4e272aba --- /dev/null +++ b/src/tecs/internal/dro.nupp @@ -0,0 +1,250 @@ +module tecs.internal.dro + +--[[ +Reads a DOSBox Raw OPL capture into a register log, a slice at a time. + +Version 1, from DOSBox 0.61, and version 2, from 0.73 on, are read. Both +count in milliseconds. Version 1 escapes its delays and its bank switches into +the register stream; version 2 names its delay codes in the header and maps +the rest through a codemap, the top bit of a code selecting the second chip or +bank. The hardware is one OPL2, one OPL3, or two OPL2s, which a Sound Blaster +Pro wired one to each speaker and this log places the same way. +]] + +local registerlog = require("tecs.internal.registerlog") + +const byte = string.byte + +--- Ticks a second: DRO counts milliseconds. +local RATE: integer = 1000 + +local function u16(bytes: string, at: integer): integer + const low, high = byte(bytes, at, at + 1) + + return (low as integer) + (high as integer) * 256 +end + +local function u32(bytes: string, at: integer): integer + const a, b, c, d = byte(bytes, at, at + 3) + + return (a as integer) + (b as integer) * 256 + (c as integer) * 65536 + (d as integer) * 16777216 +end + +--- The hardware a capture was made from. +local type Hardware = "opl2" | "opl3" | "dual" + +--- The state of one parse. +local record State + bytes: string + name: string + builder: registerlog.Builder + version: integer + hardware: Hardware + position: integer + finish: integer + tick: integer + high: boolean + shortDelay: integer + longDelay: integer + codemap: {integer} + total: integer + done: boolean +end + +--- Adds the chips the hardware had. +local function chips(exclusive builder: registerlog.Builder, hardware: Hardware): nil + if hardware == "opl3" then + registerlog.addChip(builder, "ymf262", 14318180, false, 1, 1) + elseif hardware == "dual" then + registerlog.addChip(builder, "ym3812", 3579545, false, 1, 0) + registerlog.addChip(builder, "ym3812", 3579545, true, 0, 1) + else + registerlog.addChip(builder, "ym3812", 3579545, false, 1, 1) + end +end + +--- Writes a register to the low or high chip or bank. +local function put(exclusive state: State, high: boolean, register: integer, value: integer): nil + local hardware = state.hardware + if hardware == "opl3" then + registerlog.write(state.builder, state.tick, 0, (high and 0x100 or 0) + register, value) + elseif hardware == "dual" then + registerlog.write(state.builder, state.tick, high and 1 or 0, register, value) + elseif not high then + registerlog.write(state.builder, state.tick, 0, register, value) + end +end + +--- Reads up to `budget` commands, and returns the reason the file is refused +--- if it is. +local function run(exclusive state: State, budget: integer): string? + local bytes = state.bytes + + local function cut(at: integer, what: string): string + return state.name .. ": byte " .. string.format("0x%X", at - 1) .. ": " .. what + end + + for _ = 1, budget do + local at = state.position + if at > state.finish then + state.done = true + + return nil + end + local code = byte(bytes, at) as integer + if state.version == 1 then + if code == 0x00 then + if at + 1 > state.finish then + return cut(at, "a delay is cut short") + end + state.tick = state.tick + (byte(bytes, at + 1) as integer) + 1 + state.position = at + 2 + elseif code == 0x01 then + if at + 2 > state.finish then + return cut(at, "a delay is cut short") + end + state.tick = state.tick + u16(bytes, at + 1) + 1 + state.position = at + 3 + elseif code == 0x02 or code == 0x03 then + state.high = code == 0x03 + state.position = at + 1 + elseif code == 0x04 then + if at + 2 > state.finish then + return cut(at, "an escaped register write is cut short") + end + put(state, state.high, byte(bytes, at + 1) as integer, byte(bytes, at + 2) as integer) + state.position = at + 3 + else + if at + 1 > state.finish then + return cut(at, "a register write is cut short") + end + put(state, state.high, code, byte(bytes, at + 1) as integer) + state.position = at + 2 + end + else + if at + 1 > state.finish then + return cut(at, "a register write is cut short") + end + local value = byte(bytes, at + 1) as integer + if code == state.shortDelay then + state.tick = state.tick + value + 1 + elseif code == state.longDelay then + state.tick = state.tick + (value + 1) * 256 + else + local register = state.codemap[code % 128 + 1] + if register == nil then + return cut(at, "code " .. tostring(code % 128) .. " is past the codemap") + end + put(state, code >= 128, register, value) + end + state.position = at + 2 + end + end + + return nil +end + +--- Begins reading a DRO file, or returns why its header is refused. +--- +--- @param bytes the whole file +--- @param name what a refusal names the file as +--- @return the parse, or nil and a reason +export function parser(bytes: string, name: string): (registerlog.Parser?, string?) + if #bytes < 0x15 or string.sub(bytes, 1, 8) ~= "DBRAWOPL" then + return nil, name .. ": not a DOSBox Raw OPL file: it does not begin with \"DBRAWOPL\"" + end + local major = u16(bytes, 0x09) + local minor = u16(bytes, 0x0B) + local state: State + if major == 2 and minor == 0 then + if #bytes < 0x1A then + return nil, name .. ": the version 2 header is cut short" + end + local kind = byte(bytes, 0x15) as integer + local hardware: Hardware = kind == 1 and "dual" or kind == 2 and "opl3" or "opl2" + if kind > 2 then + return nil, name .. ": hardware type " .. tostring(kind) .. " is not an OPL2, a dual OPL2 or an OPL3" + end + if (byte(bytes, 0x16) as integer) ~= 0 then + return nil, name .. ": only the interleaved data format is defined, and this file uses another" + end + if (byte(bytes, 0x17) as integer) ~= 0 then + return nil, name .. ": the data is compressed, which DOSBox never wrote" + end + local size = byte(bytes, 0x1A) as integer + if size > 128 or 0x1A + size > #bytes then + return nil, name .. ": a codemap of " .. tostring(size) .. " entries does not fit" + end + local codemap: {integer} = {} + for index = 1, size do + codemap[index] = byte(bytes, 0x1A + index) as integer + end + local start = 0x1B + size + local count = u32(bytes, 0x0D) + local builder = registerlog.newBuilder("dro", 2, RATE) + chips(builder, hardware) + state = new State( + bytes = bytes, + name = name, + builder = builder, + version = 2, + hardware = hardware, + position = start, + finish = (math.min(#bytes, start + count * 2 - 1)) as integer, + tick = 0, + high = false, + shortDelay = byte(bytes, 0x18) as integer, + longDelay = byte(bytes, 0x19) as integer, + codemap = codemap, + total = u32(bytes, 0x11), + done = false + ) + elseif major == 0 and minor == 1 then + local kind = byte(bytes, 0x15) as integer + if kind > 2 then + return nil, name .. ": hardware type " .. tostring(kind) .. " is not an OPL2, an OPL3 or a dual OPL2" + end + local hardware: Hardware = kind == 1 and "opl3" or kind == 2 and "dual" or "opl2" + -- The hardware type was one byte in the first captures and four in + -- later ones, with no change of version: three zero bytes after it + -- are the wider field. + local start: integer = 0x16 + if #bytes >= 0x18 and string.sub(bytes, 0x16, 0x18) == "\0\0\0" then + start = 0x19 + end + local length = u32(bytes, 0x11) + local builder = registerlog.newBuilder("dro", 1, RATE) + chips(builder, hardware) + state = new State( + bytes = bytes, + name = name, + builder = builder, + version = 1, + hardware = hardware, + position = start, + finish = (math.min(#bytes, start + length - 1)) as integer, + tick = 0, + high = false, + shortDelay = -1, + longDelay = -1, + codemap = {}, + total = u32(bytes, 0x0D), + done = false + ) + else + return nil, name .. ": DRO version " .. tostring(major) .. "." .. tostring(minor) .. " is neither 0.1 nor 2.0" + end + + return new registerlog.Parser(step = function(budget: integer): (registerlog.Status, registerlog.Builder?, string?) + local reason = run(state, budget) + if reason ~= nil then + return "failed", nil, reason + end + if not state.done then + return "pending" + end + state.builder.length = math.max(state.tick, state.total) + + return "ready", state.builder + end) +end diff --git a/src/tecs/internal/registerlog.nupp b/src/tecs/internal/registerlog.nupp new file mode 100644 index 00000000..c4fd991b --- /dev/null +++ b/src/tecs/internal/registerlog.nupp @@ -0,0 +1,182 @@ +module tecs.internal.registerlog + +--[[ +The register log a VGM or DRO file is read into, before +`tecs.audio.music.fm` turns it into its public record. + +A parser fills a [`Builder`] one write at a time: the chips the file names, a +tick and a packed write for each register write, the memory images its data +blocks carry, and the chips it could not keep with the reason why. Writes are +packed into one integer each, the chip in the bits above 18, the register in +the ten above 8 and the value in the low eight, so a long log costs two numbers +a write rather than a record. +]] + +--- What a step of a parse answers, as `tecs.assets.Status` spells it. +export type Status = "pending" | "ready" | "failed" + +--- The multiplier that packs a chip index above the register. +export const CHIP_SHIFT: integer = 262144 + +--- The multiplier that packs a register above the value. +export const REGISTER_SHIFT: integer = 256 + +--- The highest register `tecs.audio.fm` takes for each chip a log can name. +export const LAST_REGISTER: { + [string]: integer +} = { + ym3526 = 0xFF, + y8950 = 0xFF, + ym3812 = 0xFF, + ymf262 = 0x1FF, + ymf278b = 0x2FF, + ym2413 = 0xFF, + ds1001 = 0xFF, + ym2151 = 0xFF, + ym2164 = 0xFF, + ym2203 = 0xFF, + ym2608 = 0x1FF, + ym2610 = 0x1FF, + ym2610b = 0x1FF, + ym2612 = 0x1FF, + ym3438 = 0x1FF, + ym2149 = 0xFF, +} + +--- The largest memory image a chip takes, which `tecs.audio.fm` enforces. +export const MAX_IMAGE: integer = 16 * 1024 * 1024 + +--- One piece of a memory image, as a data block gave it. +export record Piece + offset: integer + data: string +end + +--- One chip the log writes to. +export record Chip + model: string + clock: integer + second: boolean + left: number + right: number + pieces: {[string]: {Piece}} +end + +--- A log being built. +export record Builder + format: string + version: integer + rate: integer + ticks: {integer} + ops: {integer} + count: integer + chips: {Chip} + length: integer + loopTick: integer? + loopIndex: integer? + skipped: {string} + seen: {[string]: boolean} + tags: {string}? +end + +--- One parse in progress, advanced a slice at a time. +export record Parser + --- Advances the parse by up to `budget` commands, and answers as a job's + --- `poll` does: `"pending"`, `"ready"` and the builder, or `"failed"` and + --- a reason. + step: function(budget: integer): (Status, Builder?, string?) +end + +--- Creates an empty log. +export function newBuilder(format: string, version: integer, rate: integer): Builder + return new Builder( + format = format, + version = version, + rate = rate, + ticks = {}, + ops = {}, + count = 0, + chips = {}, + length = 0, + loopTick = nil, + loopIndex = nil, + skipped = {}, + seen = {}, + tags = nil + ) +end + +--- Adds a chip and returns its index, counted from zero. +export function addChip( + exclusive builder: Builder, + model: string, + clock: integer, + second: boolean, + left: number, + right: number +): integer + local index = #builder.chips + builder.chips[ + index + 1 + ] = new Chip(model = model, clock = clock, second = second, left = left, right = right, pieces = {}) + + return index +end + +--- Appends one register write at `tick`. +--- +--- A register past the chip's range is dropped rather than kept, because a +--- player would have to refuse it: it is what a damaged or a hand-made file +--- holds, not anything a chip decodes. +export function write(exclusive builder: Builder, tick: integer, chip: integer, register: integer, value: integer): nil + local last = LAST_REGISTER[builder.chips[chip + 1].model] or 0xFF + if register < 0 or register > last or value < 0 or value > 0xFF then + return + end + local count = builder.count + 1 + builder.count = count + builder.ticks[count] = tick + builder.ops[count] = chip * CHIP_SHIFT + register * REGISTER_SHIFT + value +end + +--- Records once that something in the file is not played, and why. +export function skip(exclusive builder: Builder, key: string, reason: string): nil + if builder.seen[key] then + return + end + builder.seen[key] = true + builder.skipped[#builder.skipped + 1] = reason +end + +--- Adds a piece of a chip's memory image of `class`. +export function addMemory(exclusive builder: Builder, chip: integer, class: string, offset: integer, data: string): nil + local entry = builder.chips[chip + 1] + local pieces = entry.pieces[class] + if pieces == nil then + pieces = {} + entry.pieces[class] = pieces + end + pieces[#pieces + 1] = new Piece(offset = offset, data = data) +end + +--- Assembles the pieces of one image into the bytes a chip reads: each at its +--- offset, later pieces over earlier ones, zeroes between, as long as the +--- furthest piece reaches and no longer than `MAX_IMAGE`. +export function image(pieces: {Piece}): string + local result = "" + for _, piece in ipairs(pieces) do + local offset = piece.offset + local data = piece.data + if offset < MAX_IMAGE then + if offset + #data > MAX_IMAGE then + data = string.sub(data, 1, MAX_IMAGE - offset) + end + if #result < offset then + result = result .. string.rep("\0", offset - #result) + end + result = string.sub(result, 1, offset) .. data .. string.sub(result, offset + #data + 1) + end + end + + return result +end diff --git a/src/tecs/internal/vgm.nupp b/src/tecs/internal/vgm.nupp new file mode 100644 index 00000000..89925714 --- /dev/null +++ b/src/tecs/internal/vgm.nupp @@ -0,0 +1,906 @@ +module tecs.internal.vgm + +--[[ +Reads a VGM file into a register log, a slice at a time. + +What it keeps is what `tecs.audio.fm` can play: the writes to every Yamaha FM +chip and to the YM2149 family, the memory images their data blocks carry, the +YM2612's DAC writes from the PCM data bank (the `0x8n` commands and the DAC +streams of commands `0x90` to `0x95`, both turned into ordinary register +writes at their times), the loop point and the GD3 tags. Every other chip's +writes are skipped, each with one reason, and a file with nothing left to play +is refused by the caller. + +The format is the VGM specification up to version 1.72: a header whose fields +past the data offset read as zero, a stream of commands at 44100 ticks a +second, and data blocks, compressed ones included. +]] + +local registerlog = require("tecs.internal.registerlog") + +const byte = string.byte +const floor = math.floor + +--- Ticks a second, which every VGM file counts in. +local RATE: integer = 44100 + +--- Two to the power of thirty and of thirty-one, the two flag bits of a clock. +local BIT30: integer = 1073741824 + +local BIT31: integer = 2147483648 + +--- One chip the format numbers, and where its clock sits in the header. +local record Family + id: integer + name: string + clockAt: integer + model: string? + alternate: string? + reason: string? +end + +local function family(id: integer, name: string, clockAt: integer, model: string?, alternate: string?): Family + return new Family(id = id, name = name, clockAt = clockAt, model = model, alternate = alternate, reason = nil) +end + +local function foreign(id: integer, name: string, clockAt: integer, reason: string?): Family + return new Family( + id = id, + name = name, + clockAt = clockAt, + model = nil, + alternate = nil, + reason = reason or (name .. ": not a Yamaha FM chip, which is what tecs.audio.fm emulates") + ) +end + +--- Every chip VGM numbers, by its number, with the model `tecs.audio.fm` +--- plays it as, or the reason it does not. +local FAMILIES: {[integer]: Family} = {} + +for _, entry in ipairs({ + foreign(0x00, "SN76489", 0x0C), + family(0x01, "YM2413", 0x10, "ym2413", "ds1001"), + family(0x02, "YM2612", 0x2C, "ym2612", "ym3438"), + family(0x03, "YM2151", 0x30, "ym2151", "ym2164"), + foreign(0x04, "Sega PCM", 0x38), + foreign(0x05, "RF5C68", 0x40), + family(0x06, "YM2203", 0x44, "ym2203", nil), + family(0x07, "YM2608", 0x48, "ym2608", nil), + family(0x08, "YM2610", 0x4C, "ym2610", "ym2610b"), + family(0x09, "YM3812", 0x50, "ym3812", nil), + family(0x0A, "YM3526", 0x54, "ym3526", nil), + family(0x0B, "Y8950", 0x58, "y8950", nil), + family(0x0C, "YMF262", 0x5C, "ymf262", nil), + family(0x0D, "YMF278B", 0x60, "ymf278b", nil), + foreign( + 0x0E, + "YMF271", + 0x64, + "YMF271: ymfm has no implementation of the OPX, so tecs.audio.fm does not emulate it" + ), + foreign(0x0F, "YMZ280B", 0x68, "YMZ280B: a PCM chip, not an FM one, which is what tecs.audio.fm emulates"), + foreign(0x10, "RF5C164", 0x6C), + foreign(0x11, "PWM", 0x70), + family(0x12, "AY8910", 0x74, "ym2149", nil), + foreign(0x13, "Game Boy DMG", 0x80), + foreign(0x14, "NES APU", 0x84), + foreign(0x15, "MultiPCM", 0x88), + foreign(0x16, "uPD7759", 0x8C), + foreign(0x17, "OKIM6258", 0x90), + foreign(0x18, "OKIM6295", 0x98), + foreign(0x19, "K051649", 0x9C), + foreign(0x1A, "K054539", 0xA0), + foreign(0x1B, "HuC6280", 0xA4), + foreign(0x1C, "C140", 0xA8), + foreign(0x1D, "K053260", 0xAC), + foreign(0x1E, "Pokey", 0xB0), + foreign(0x1F, "QSound", 0xB4), + foreign(0x20, "SCSP", 0xB8), + foreign(0x21, "WonderSwan", 0xC0), + foreign(0x22, "Virtual Boy VSU", 0xC4), + foreign(0x23, "SAA1099", 0xC8), + foreign(0x24, "ES5503", 0xCC), + foreign(0x25, "ES5506", 0xD0), + foreign(0x26, "X1-010", 0xD8), + foreign(0x27, "C352", 0xDC), + foreign(0x28, "GA20", 0xE0), + foreign(0x29, "Mikey", 0xE4), +}) do + FAMILIES[entry.id] = entry +end + +--- The chip a register-write command from 0x51 to 0x5F writes, by command, +--- and the bank its register goes to. 0xA1 to 0xAF are the same commands for +--- the second chip. +local WRITES: { + [integer]: {integer} +} = { + [0x51] = {0x01, 0}, + [0x52] = {0x02, 0}, + [0x53] = {0x02, 1}, + [0x54] = {0x03, 0}, + [0x55] = {0x06, 0}, + [0x56] = {0x07, 0}, + [0x57] = {0x07, 1}, + [0x58] = {0x08, 0}, + [0x59] = {0x08, 1}, + [0x5A] = {0x09, 0}, + [0x5B] = {0x0A, 0}, + [0x5C] = {0x0B, 0}, + [0x5D] = {0x0F, 0}, + [0x5E] = {0x0C, 0}, + [0x5F] = {0x0C, 1}, +} + +--- The chip and memory class each ROM or RAM data block type loads. +local IMAGES: { + [integer]: {any} +} = { + [0x81] = {0x07, "adpcmB"}, + [0x82] = {0x08, "adpcmA"}, + [0x83] = {0x08, "adpcmB"}, + [0x84] = {0x0D, "pcm"}, + [0x87] = {0x0D, "pcm"}, + [0x88] = {0x0B, "adpcmB"}, +} + +local function u16(bytes: string, at: integer): integer + const low, high = byte(bytes, at, at + 1) + + return (low as integer) + (high as integer) * 256 +end + +local function u32(bytes: string, at: integer): integer + const a, b, c, d = byte(bytes, at, at + 3) + + return (a as integer) + (b as integer) * 256 + (c as integer) * 65536 + (d as integer) * 16777216 +end + +--- One block of PCM data in a bank. +local record Block + start: integer + length: integer +end + +--- The PCM data blocks of one type, joined. +local record Bank + parts: {string} + blocks: {Block} + size: integer + joined: string? +end + +--- One DAC stream. +local record Stream + chipType: integer + port: integer + command: integer + bank: integer + stepSize: integer + stepBase: integer + frequency: number + active: boolean + position: integer + remaining: integer + loop: boolean + reverse: boolean + first: integer + length: integer + next: number +end + +--- A decompression table a compressed data block names. +local record Table + subtype: integer + bitsOut: integer + bitsIn: integer + values: {integer} +end + +--- The state of one parse. +local record State + bytes: string + name: string + builder: registerlog.Builder + position: integer + finish: integer + tick: integer + loopAt: integer + gd3At: integer + total: integer + clocks: {[integer]: integer} + flags: {[integer]: integer} + slots: {[integer]: integer} + banks: {[integer]: Bank} + streams: {[integer]: Stream} + tables: {[integer]: Table} + pcm: integer + done: boolean +end + +--- Returns the joined data of a PCM bank, or the empty string. +local function bankData(borrows state: State, kind: integer): string + local bank = state.banks[kind] + if bank == nil then + return "" + end + local joined = bank.joined + if joined == nil then + joined = table.concat(bank.parts) + bank.parts = {joined} + bank.joined = joined + end + + return joined +end + +--- Returns the log's index for chip `id`, first or second, making it on first +--- use, or nil when the file cannot play it. +local function chipFor(exclusive state: State, id: integer, second: boolean): integer? + local slot = id * 2 + (second and 1 or 0) + local existing = state.slots[slot] + if existing ~= nil then + return existing >= 0 and existing or nil + end + local entry = FAMILIES[id] + local builder = state.builder + + local function refuse(key: string, reason: string): nil + state.slots[slot] = -1 + registerlog.skip(builder, key, reason) + end + + if entry == nil then + refuse("chip " .. tostring(id), "chip " .. string.format("0x%02X", id) .. ": not a chip this VGM reader knows") + + return nil + end + local model = entry.model + if model == nil then + refuse(entry.name, entry.reason or entry.name) + + return nil + end + local raw = state.clocks[id] or 0 + local clock = raw % BIT30 + if clock == 0 then + refuse( + entry.name .. " clock", + entry.name .. ": written to, but the header gives it no clock, so it is not played" + ) + + return nil + end + if second and raw % BIT31 < BIT30 then + refuse(entry.name .. " second", entry.name .. ": a second chip is written to that the header does not declare") + + return nil + end + if raw >= BIT31 and entry.alternate ~= nil then + model = assert(entry.alternate) + end + if id == 0x12 then + local kind = state.flags[0x78] or 0 + if kind == 0x03 then + refuse("AY8930", "AY8930: its expanded mode is not the YM2149's, which is what tecs.audio.fm emulates") + + return nil + end + -- Pin 26 low on a YM2149 halves its clock. + if (state.flags[0x79] or 0) // 16 % 2 == 1 then + clock = clock // 2 + end + end + local index = registerlog.addChip(builder, model, clock, second, 1, 1) + state.slots[slot] = index + + return index +end + +--- Writes `value` to `register` of chip `id`, when the file can play it. +local function put(exclusive state: State, id: integer, second: boolean, register: integer, value: integer): nil + local chip = chipFor(state, id, second) + if chip ~= nil then + registerlog.write(state.builder, state.tick, chip, register, value) + end +end + +--- Emits every active stream's writes due before `until`, in time order. +local function runStreams(exclusive state: State, horizon: integer): nil + while true do + local earliest: Stream? = nil + for _, stream in pairs(state.streams) do + if stream.active and stream.frequency > 0 and stream.next < horizon then + if earliest == nil or stream.next < assert(earliest).next then + earliest = stream + end + end + end + if earliest == nil then + return + end + local stream = assert(earliest) + local data = bankData(state, stream.bank) + if stream.position < 0 or stream.position >= #data then + if stream.loop and stream.length ~= 0 then + stream.position = stream.first + stream.remaining = stream.length + else + stream.active = false + end + else + local tick = floor(stream.next) + local value = byte(data, stream.position + 1) as integer + local chip = chipFor(state, stream.chipType % 128, stream.chipType >= 128) + if chip ~= nil then + registerlog.write(state.builder, tick, chip, stream.port * 256 + stream.command, value) + end + local step = stream.stepSize + stream.position = stream.position + (stream.reverse and -step or step) + stream.next = stream.next + RATE / stream.frequency + if stream.remaining > 0 then + stream.remaining = stream.remaining - 1 + if stream.remaining == 0 then + if stream.loop then + stream.position = stream.first + stream.remaining = stream.length + else + stream.active = false + end + end + end + end + end +end + +--- Advances time by `ticks`, running the streams up to the new time. +local function wait(exclusive state: State, ticks: integer): nil + if ticks <= 0 then + return + end + runStreams(state, state.tick + ticks) + state.tick = state.tick + ticks +end + +local function streamOf(exclusive state: State, id: integer): Stream + local stream = state.streams[id] + if stream == nil then + stream = new Stream( + chipType = 0x02, + port = 0, + command = 0x2A, + bank = 0, + stepSize = 1, + stepBase = 0, + frequency = 0, + active = false, + position = 0, + remaining = 0, + loop = false, + reverse = false, + first = 0, + length = 0, + next = 0 + ) + state.streams[id] = stream + end + + return stream +end + +--- Starts a stream at `position` for `remaining` commands, minus for until +--- its data ends. +local function start( + exclusive state: State, + stream: Stream, + position: integer, + remaining: integer, + loop: boolean, + reverse: boolean +): nil + stream.position = position + stream.first = position + stream.remaining = remaining + stream.length = remaining + stream.loop = loop + stream.reverse = reverse + stream.active = true + stream.next = state.tick +end + +--- The powers of two up to two to the sixteenth, by exponent plus one. +local POWERS: {integer} = {} + +do + local power: integer = 1 + for index = 1, 17 do + POWERS[index] = power + power = power * 2 + end +end + +--- Returns two to the power of `exponent`, from 0 to 16. +local function power(exponent: integer): integer + return POWERS[exponent + 1] or 1 +end + +--- Reads `count` bits from `bytes` from bit `at`, counted from the top of the +--- first byte. +local function bits(bytes: string, at: integer, count: integer): integer + local value: integer = 0 + for index = 0, count - 1 do + local bit = at + index + local octet = byte(bytes, bit // 8 + 1) as integer? or 0 + value = value * 2 + (octet // power(7 - bit % 8)) % 2 + end + + return value +end + +--- Decompresses a compressed data block, or returns the reason it cannot. +local function decompress(borrows state: State, data: string): (string?, string?) + if #data < 10 then + return nil, "a compressed data block is cut short" + end + local kind = byte(data, 1) as integer + local size = u32(data, 2) + local bitsOut = byte(data, 6) as integer + local bitsIn = byte(data, 7) as integer + local subtype = byte(data, 8) as integer + local base = u16(data, 9) + if bitsIn == 0 or bitsIn > 16 or bitsOut == 0 or bitsOut > 16 then + return nil, "a compressed data block packs " .. tostring(bitsIn) .. " bits into " .. tostring(bitsOut) + end + local width = (bitsOut + 7) // 8 + local count = size // width + local payload = string.sub(data, 11) + local tableFor = state.tables[kind] + local out: {string} = {} + local value = base + for index = 0, count - 1 do + local packed = bits(payload, index * bitsIn, bitsIn) + local result: integer + if kind == 0x00 then + if subtype == 0x00 then + result = packed + base + elseif subtype == 0x01 then + result = packed * power(bitsOut - bitsIn) + base + else + if tableFor == nil then + return nil, "a compressed data block uses a table the file never gives" + end + result = (tableFor.values[packed + 1] or 0) + base + end + elseif kind == 0x01 then + if tableFor == nil then + return nil, "a DPCM data block uses a table the file never gives" + end + value = (value + (tableFor.values[packed + 1] or 0)) % power(bitsOut) + result = value + else + return nil, "compression type " .. tostring(kind) .. " is not one VGM defines" + end + result = result % 65536 + if width == 1 then + out[#out + 1] = string.char(result % 256) + else + out[#out + 1] = string.char(result % 256, result // 256) + end + end + + return table.concat(out) +end + +--- Reads one data block, whose type is `kind` and whose bytes are `data`. +local function dataBlock(exclusive state: State, kind: integer, data: string, flagged: boolean): string? + if kind >= 0x40 and kind < 0x7F then + local decoded, reason = decompress(state, data) + if decoded == nil then + return reason + end + data = assert(decoded) + kind = kind - 0x40 + end + if kind < 0x40 then + local bank = state.banks[kind] + if bank == nil then + bank = new Bank(parts = {}, blocks = {}, size = 0, joined = nil) + state.banks[kind] = bank + end + bank.parts[#bank.parts + 1] = data + bank.blocks[#bank.blocks + 1] = new Block(start = bank.size, length = #data) + bank.size = bank.size + #data + bank.joined = nil + elseif kind == 0x7F then + if #data < 6 then + return "a decompression table is cut short" + end + local compression = byte(data, 1) as integer + local bitsOut = byte(data, 3) as integer + local count = u16(data, 5) + local width = (bitsOut + 7) // 8 + local values: {integer} = {} + for index = 0, count - 1 do + local at = 7 + index * width + values[index + 1] = width == 1 and (byte(data, at) as integer? or 0) or u16(data, at) + end + state.tables[ + compression + ] = new Table( + subtype = byte(data, 2) as integer, + bitsOut = bitsOut, + bitsIn = byte(data, 4) as integer, + values = values + ) + elseif kind < 0xC0 then + local target = IMAGES[kind] + if target == nil then + registerlog.skip( + state.builder, + "block " .. tostring(kind), + "data block " .. string.format("0x%02X", kind) .. ": memory for a chip tecs.audio.fm does not emulate" + ) + + return nil + end + if #data < 8 then + return "a ROM data block is cut short" + end + local chip = chipFor(state, target[1] as integer, flagged) + if chip ~= nil then + registerlog.addMemory(state.builder, chip, target[2] as string, u32(data, 5), string.sub(data, 9)) + end + end + + return nil +end + +--- Converts UTF-16LE to UTF-8. +local function utf8(bytes: string, at: integer, finish: integer): (string, integer) + local out: {string} = {} + local position = at + while position + 1 <= finish do + local unit = u16(bytes, position) + position = position + 2 + if unit == 0 then + break + end + local code = unit + if unit >= 0xD800 and unit < 0xDC00 and position + 1 <= finish then + local low = u16(bytes, position) + if low >= 0xDC00 and low < 0xE000 then + code = 0x10000 + (unit - 0xD800) * 1024 + (low - 0xDC00) + position = position + 2 + end + end + if code < 0x80 then + out[#out + 1] = string.char(code) + elseif code < 0x800 then + out[#out + 1] = string.char(0xC0 + code // 64, 0x80 + code % 64) + elseif code < 0x10000 then + out[#out + 1] = string.char(0xE0 + code // 4096, 0x80 + code // 64 % 64, 0x80 + code % 64) + else + out[ + #out + 1 + ] = string.char(0xF0 + code // 262144, 0x80 + code // 4096 % 64, 0x80 + code // 64 % 64, 0x80 + code % 64) + end + end + + return table.concat(out), position +end + +--- Reads the GD3 tags, when the file has them. +local function tags(exclusive state: State): nil + local bytes = state.bytes + local at = state.gd3At + if at <= 0 or at + 11 > #bytes or string.sub(bytes, at, at + 3) ~= "Gd3 " then + return + end + local finish = math.min(#bytes, at + 11 + u32(bytes, at + 8)) + local position = at + 12 + local fields: {string} = {} + for index = 1, 11 do + local text, after = utf8(bytes, position, finish) + fields[index] = text + position = after + end + state.builder.tags = fields +end + +--- Reads up to `budget` commands, and returns the reason the file is refused +--- if it is. +local function run(exclusive state: State, budget: integer): string? + local bytes = state.bytes + local builder = state.builder + + local function cut(at: integer, what: string): string + return state.name .. ": byte " .. string.format("0x%X", at - 1) .. ": " .. what + end + + for _ = 1, budget do + local at = state.position + if at > state.finish then + state.done = true + + return nil + end + if at == state.loopAt then + builder.loopTick = state.tick + builder.loopIndex = builder.count + 1 + end + local command = byte(bytes, at) as integer + + local function need(count: integer): boolean + return at + count - 1 <= state.finish + end + + if command == 0x66 then + state.done = true + + return nil + elseif command == 0x61 then + if not need(3) then + return cut(at, "a wait is cut short") + end + wait(state, u16(bytes, at + 1)) + state.position = at + 3 + elseif command == 0x62 then + wait(state, 735) + state.position = at + 1 + elseif command == 0x63 then + wait(state, 882) + state.position = at + 1 + elseif command >= 0x70 and command <= 0x7F then + wait(state, command - 0x6F) + state.position = at + 1 + elseif command >= 0x80 and command <= 0x8F then + local data = bankData(state, 0) + if state.pcm < #data then + put(state, 0x02, false, 0x2A, byte(data, state.pcm + 1) as integer) + end + state.pcm = state.pcm + 1 + wait(state, command - 0x80) + state.position = at + 1 + elseif command == 0x67 then + if not need(7) then + return cut(at, "a data block is cut short") + end + local kind = byte(bytes, at + 2) as integer + local size = u32(bytes, at + 3) + local flagged = size >= BIT31 + size = size % BIT31 + if not need(7 + size) then + return cut(at, "a data block of " .. tostring(size) .. " bytes runs past the end of the file") + end + local reason = dataBlock(state, kind, string.sub(bytes, at + 7, at + 6 + size), flagged) + if reason ~= nil then + return cut(at, reason) + end + state.position = at + 7 + size + elseif command == 0x68 then + state.position = at + 12 + elseif (command >= 0x51 and command <= 0x5F) or (command >= 0xA1 and command <= 0xAF) then + if not need(3) then + return cut(at, "a register write is cut short") + end + local second = command >= 0xA1 + local write = assert(WRITES[second and command - 0x50 or command]) + put( + state, + write[1], + second, + write[2] * 256 + (byte(bytes, at + 1) as integer), + byte(bytes, at + 2) as integer + ) + state.position = at + 3 + elseif command == 0xA0 then + if not need(3) then + return cut(at, "a register write is cut short") + end + local register = byte(bytes, at + 1) as integer + put(state, 0x12, register >= 0x80, register % 128, byte(bytes, at + 2) as integer) + state.position = at + 3 + elseif command == 0xD0 then + if not need(4) then + return cut(at, "a register write is cut short") + end + local port = byte(bytes, at + 1) as integer + put( + state, + 0x0D, + port >= 0x80, + port % 128 * 256 + (byte(bytes, at + 2) as integer), + byte(bytes, at + 3) as integer + ) + state.position = at + 4 + elseif command == 0xE0 then + if not need(5) then + return cut(at, "a PCM seek is cut short") + end + state.pcm = u32(bytes, at + 1) + state.position = at + 5 + elseif command == 0x90 then + if not need(5) then + return cut(at, "a stream setup is cut short") + end + local stream = streamOf(state, byte(bytes, at + 1) as integer) + stream.chipType = byte(bytes, at + 2) as integer + stream.port = byte(bytes, at + 3) as integer + stream.command = byte(bytes, at + 4) as integer + state.position = at + 5 + elseif command == 0x91 then + if not need(5) then + return cut(at, "a stream setup is cut short") + end + local stream = streamOf(state, byte(bytes, at + 1) as integer) + stream.bank = byte(bytes, at + 2) as integer + stream.stepSize = math.max(1, byte(bytes, at + 3) as integer) + stream.stepBase = byte(bytes, at + 4) as integer + state.position = at + 5 + elseif command == 0x92 then + if not need(6) then + return cut(at, "a stream frequency is cut short") + end + local stream = streamOf(state, byte(bytes, at + 1) as integer) + stream.frequency = u32(bytes, at + 2) + state.position = at + 6 + elseif command == 0x93 then + if not need(11) then + return cut(at, "a stream start is cut short") + end + local stream = streamOf(state, byte(bytes, at + 1) as integer) + local offset = u32(bytes, at + 2) + local mode = byte(bytes, at + 6) as integer + local length = u32(bytes, at + 7) + local position = offset == 0xFFFFFFFF and stream.position or offset + stream.stepBase + local kind = mode % 16 + local loop = mode >= 0x80 + local reverse = mode // 16 % 2 == 1 + if kind == 0 then + stream.position = position + elseif kind == 1 then + start(state, stream, position, length, loop, reverse) + elseif kind == 2 then + start(state, stream, position, math.max(1, math.ceil(length * stream.frequency / 1000)), loop, reverse) + else + start(state, stream, position, -1, loop, reverse) + end + state.position = at + 11 + elseif command == 0x94 then + if not need(2) then + return cut(at, "a stream stop is cut short") + end + local id = byte(bytes, at + 1) as integer + for key, stream in pairs(state.streams) do + if id == 0xFF or key == id then + stream.active = false + end + end + state.position = at + 2 + elseif command == 0x95 then + if not need(5) then + return cut(at, "a stream start is cut short") + end + local stream = streamOf(state, byte(bytes, at + 1) as integer) + local block = u16(bytes, at + 2) + local flags = byte(bytes, at + 4) as integer + local bank = state.banks[stream.bank] + local entry = bank ~= nil and bank.blocks[block + 1] or nil + if entry ~= nil then + local reverse = flags // 16 % 2 == 1 + local first = reverse and entry.start + entry.length - 1 or entry.start + stream.stepBase + start(state, stream, first, math.max(1, entry.length // stream.stepSize), flags % 2 == 1, reverse) + end + state.position = at + 5 + elseif command == 0x4F or command == 0x50 or (command >= 0x30 and command <= 0x3F) then + registerlog.skip(builder, "SN76489", "SN76489: not a Yamaha FM chip, which is what tecs.audio.fm emulates") + state.position = at + 2 + elseif command >= 0x40 and command <= 0x4E then + state.position = at + 3 + elseif command >= 0xB0 and command <= 0xBF then + state.position = at + 3 + elseif command >= 0xC0 and command <= 0xDF then + state.position = at + 4 + elseif command >= 0xE1 then + state.position = at + 5 + else + return cut(at, "command " .. string.format("0x%02X", command) .. " is not a VGM command") + end + end + + return nil +end + +--- Begins reading a VGM file, or returns why its header is refused. +--- +--- @param bytes the whole file, decompressed +--- @param name what a refusal names the file as +--- @return the parse, or nil and a reason +export function parser(bytes: string, name: string): (registerlog.Parser?, string?) + if #bytes < 0x40 or string.sub(bytes, 1, 4) ~= "Vgm " then + return nil, name .. ": not a VGM file: it does not begin with \"Vgm \"" + end + local version = u32(bytes, 0x09) + local dataStart: integer = 0x40 + if version >= 0x150 then + local offset = u32(bytes, 0x35) + if offset ~= 0 then + dataStart = 0x34 + offset + end + end + if dataStart > #bytes then + return nil, name .. ": the data offset points past the end of the file" + end + + local function field(offset: integer, width: integer): integer + if offset + width > dataStart or offset + width > #bytes then + return 0 + end + + return width == 4 and u32(bytes, offset + 1) or (byte(bytes, offset + 1) as integer) + end + + local eof = field(0x04, 4) + local finish = eof ~= 0 and math.min(#bytes, 0x04 + eof) or #bytes + local builder = registerlog.newBuilder("vgm", version, RATE) + local clocks: {[integer]: integer} = {} + for id, entry in pairs(FAMILIES) do + local clock = field(entry.clockAt, 4) + if clock ~= 0 then + clocks[id] = clock + if entry.model == nil then + registerlog.skip(builder, entry.name, entry.reason or entry.name) + end + end + end + if version < 0x110 then + -- Before 1.10 the YM2413's clock stood for the YM2612's and the + -- YM2151's too. + local shared = clocks[0x01] + if shared ~= nil then + clocks[0x02] = clocks[0x02] or shared + clocks[0x03] = clocks[0x03] or shared + end + end + local loop = field(0x1C, 4) + local gd3 = field(0x14, 4) + local state = new State( + bytes = bytes, + name = name, + builder = builder, + position = dataStart + 1, + finish = finish, + tick = 0, + loopAt = loop ~= 0 and 0x1C + loop + 1 or -1, + gd3At = gd3 ~= 0 and 0x14 + gd3 + 1 or 0, + total = field(0x18, 4), + clocks = clocks, + flags = {[0x78] = field(0x78, 1), [0x79] = field(0x79, 1)}, + slots = {}, + banks = {}, + streams = {}, + tables = {}, + pcm = 0, + done = false + ) + + return new registerlog.Parser(step = function(budget: integer): (registerlog.Status, registerlog.Builder?, string?) + local reason = run(state, budget) + if reason ~= nil then + return "failed", nil, reason + end + if not state.done then + return "pending" + end + tags(state) + builder.length = math.max(state.tick, state.total) + if builder.loopIndex ~= nil and assert(builder.loopIndex) > builder.count then + -- A loop with nothing after it has nothing to repeat. + builder.loopTick = nil + builder.loopIndex = nil + end + + return "ready", builder + end) +end diff --git a/tests/audiomusictest.nupp b/tests/audiomusictest.nupp new file mode 100644 index 00000000..3b808284 --- /dev/null +++ b/tests/audiomusictest.nupp @@ -0,0 +1,1207 @@ +local assets = require("tecs.assets") +local audio = require("tecs.audio") +local audiobackend = require("tecs.platform.audiobackend") +local compression = require("nupp.compression") +local files = require("nupp.io.files") +local hostpump = require("nupp.host.pump") +local midi = require("tecs.audio.music.midi") +local music = require("tecs.audio.music") +local musicfm = require("tecs.audio.music.fm") +local synth = require("tecs.audio.synth") +local test = require("nupp.test") + +local suite = {} + +------------------------------------------------------------------------------- +-- Fixtures, built byte by byte: no music in this suite comes from anywhere +------------------------------------------------------------------------------- + +local function be16(value: integer): string + return string.char(value // 256 % 256, value % 256) +end + +local function be32(value: integer): string + return string.char(value // 16777216 % 256, value // 65536 % 256, value // 256 % 256, value % 256) +end + +local function le16(value: integer): string + return string.char(value % 256, value // 256 % 256) +end + +local function le32(value: integer): string + return string.char(value % 256, value // 256 % 256, value // 65536 % 256, value // 16777216 % 256) +end + +--- Encodes a MIDI variable-length quantity. +local function vlq(value: integer): string + local text = string.char(value % 128) + local rest = value // 128 + while rest > 0 do + text = string.char(rest % 128 + 128) .. text + rest = rest // 128 + end + + return text +end + +--- One event of a track: a delta time and its bytes. +local function at(delta: integer, ...: integer): string + return vlq(delta) .. string.char(...) +end + +local function meta(delta: integer, kind: integer, data: string): string + return vlq(delta) .. string.char(0xFF, kind) .. vlq(#data) .. data +end + +local function tempo(delta: integer, micro: integer): string + return meta(delta, 0x51, string.char(micro // 65536, micro // 256 % 256, micro % 256)) +end + +local function finish(delta: integer): string + return meta(delta, 0x2F, "") +end + +--- A whole Standard MIDI File. +local function smf(format: integer, division: integer, tracks: {string}): string + local chunks: {string} = {"MThd", be32(6), be16(format), be16(#tracks), be16(division)} + for _, track in ipairs(tracks) do + chunks[#chunks + 1] = "MTrk" .. be32(#track) .. track + end + + return table.concat(chunks) +end + +--- A VGM file: a 0x100-byte header with `clocks` at their offsets, the +--- commands, and GD3 tags when given. +local record Vgm + version: integer + clocks: {[integer]: integer} + bytes: {[integer]: integer} + loop: integer? + tags: {string}? +end + +--- Encodes UTF-8 text, which holds nothing past the Basic Multilingual +--- Plane here, as null-terminated UTF-16LE. +local function utf16(text: string): string + local out: {string} = {} + local index = 1 + while index <= #text do + local lead = string.byte(text, index) as integer + local code: integer = lead + local size: integer = 1 + if lead >= 0xE0 then + code = ( + lead % 16 + ) * 4096 + ( + (string.byte(text, index + 1) as integer) % 64 + ) * 64 + (string.byte(text, index + 2) as integer) % 64 + size = 3 + elseif lead >= 0xC0 then + code = (lead % 32) * 64 + (string.byte(text, index + 1) as integer) % 64 + size = 2 + end + out[#out + 1] = le16(code) + index = index + size + end + out[#out + 1] = le16(0) + + return table.concat(out) +end + +local function vgm(header: Vgm, commands: string): string + -- Before 1.50 the data starts at 0x40, and the header has no data offset. + local size: integer = header.version < 0x150 and 0x40 or 0x100 + local bytes: {string} = {} + for index = 1, size do + bytes[index] = "\0" + end + + local function put(offset: integer, text: string): nil + for index = 1, #text do + bytes[offset + index] = string.sub(text, index, index) + end + end + + put(0x00, "Vgm ") + put(0x08, le32(header.version)) + if size == 0x100 then + put(0x34, le32(0x100 - 0x34)) + end + for offset, clock in pairs(header.clocks) do + put(offset, le32(clock)) + end + for offset, value in pairs(header.bytes) do + put(offset, string.char(value)) + end + local loop = header.loop + if loop ~= nil then + put(0x1C, le32(size + loop - 0x1C)) + end + local body = commands .. "\x66" + local tags = header.tags + local gd3 = "" + if tags ~= nil then + local strings: {string} = {} + for index = 1, 11 do + strings[index] = utf16(tags[index] or "") + end + local text = table.concat(strings) + gd3 = "Gd3 " .. le32(0x100) .. le32(#text) .. text + put(0x14, le32(size + #body - 0x14)) + end + local whole = table.concat(bytes) .. body .. gd3 + -- The end-of-file offset is relative to 0x04. + + return string.sub(whole, 1, 4) .. le32(#whole - 4) .. string.sub(whole, 9) +end + +local function header(clocks: {[integer]: integer}, version: integer?): Vgm + return new Vgm(version = version or 0x171, clocks = clocks, bytes = {}, loop = nil, tags = nil) +end + +--- A VGM wait. +local function wait(ticks: integer): string + return "\x61" .. le16(ticks) +end + +--- A VGM data block. +local function block(kind: integer, data: string, flags: integer?): string + return "\x67\x66" .. string.char(kind) .. le32(#data + (flags or 0)) .. data +end + +--- A DRO version 2 file. +local function dro2(hardware: integer, codemap: {integer}, pairs: string, milliseconds: integer): string + local map: {string} = {} + for index, register in ipairs(codemap) do + map[index] = string.char(register) + end + + return "DBRAWOPL" .. le16( + 2 + ) .. le16( + 0 + ) .. le32( + #pairs // 2 + ) .. le32(milliseconds) .. string.char(hardware, 0, 0, 0xFE, 0xFF, #codemap) .. table.concat(map) .. pairs +end + +--- A DRO version 1 file, with a four-byte hardware field or a one-byte one. +local function dro1(hardware: integer, data: string, wide: boolean): string + local field = wide and le32(hardware) or string.char(hardware) + + return "DBRAWOPL" .. le16(0) .. le16(1) .. le32(100) .. le32(#data) .. field .. data +end + +--- Writes `bytes` to a temporary file, runs `body` on its path and removes +--- the file. +local function temporary(bytes: string, suffix: string, body: function(path: string): nil): nil + local file = assert(files.createTemporaryFile(new files.TemporaryOptions(suffix = suffix))) + local path = file:toString() + assert(files.write(path, bytes)) + body(path) + file:close() +end + +--- Runs `body` under a host pump until it settles. +local function underPump(body: function(): boolean): nil + local pump = hostpump.newPump() + local state = pump:start(body) + local passes: integer = 0 + while state == "parked" and passes < 256 do + passes = passes + 1 + state = pump:poll() + end + test.assert(state ~= "parked", "the load never finished") +end + +--- Loads one asset through a fresh loader. +local function load(kind: assets.Kind, path: string): (assets.Assets, assets.Handle?, string?) + local loader = assets.newAssets() + local handle: assets.Handle? = nil + local reason: string? = nil + underPump(function(): boolean + handle, reason = loader:request(kind, path) + + return true + end) + + return loader, handle, reason +end + +------------------------------------------------------------------------------- +-- Standard MIDI Files +------------------------------------------------------------------------------- + +function suite.readsAFormatZeroSongWithRunningStatusAndATempo(): nil + local track = meta( + 0, + 0x03, + "Theme" + ) .. tempo(0, 250000) .. at(0, 0xC0, 5) .. at(0, 0x90, 60, 100) .. at(96, 64, 90) -- running status: another note on + .. at(96, 0x80, 60, 0) .. at(0, 64, 0) .. finish(96) + local song = assert(midi.parse(smf(0, 96, {track}))) + test.equal(song.format, 0) + test.equal(song.ppq, 96) + test.equal(song.title, "Theme") + test.equal(#song.tracks, 1) + test.equal(song.tracks[1].endTick, 288) + -- The tempo of 250 000 microseconds a quarter is 0.25 seconds a beat. + test.equal(#song.tempos, 1) + test.equal(song.tempos[1].microsecondsPerQuarter, 250000) + test.equal(song.duration, 0.75) + local notes: {midi.Event} = {} + for _, event in ipairs(song.events) do + if event.status < 0xF0 then + notes[#notes + 1] = event + end + end + test.equal(#notes, 5) + test.equal(notes[1].status, 0xC0) + test.equal(notes[1].data2, 0, "a program change carries one data byte") + test.equal(notes[3].status, 0x90) + test.equal(notes[3].data1, 64) + test.equal(notes[3].time, 0.25) + test.equal(notes[5].status, 0x80, "running status carries the note off") + test.equal(notes[5].time, 0.5) +end + +function suite.mergesFormatOneTracksThroughOneTempoMap(): nil + -- The tempo track halves the speed at beat one; the note track's events + -- at beat two land at 0.5 + 1.0 seconds. + local conductor = tempo(0, 500000) .. tempo(480, 1000000) .. finish(0) + local notes = at(0, 0x91, 60, 100) .. at(960, 0x81, 60, 0) .. finish(0) + local song = assert(midi.parse(smf(1, 480, {conductor, notes}))) + test.equal(#song.tempos, 2) + test.equal(song.tempos[2].time, 0.5) + local off: midi.Event? = nil + for _, event in ipairs(song.events) do + if event.status == 0x81 then + off = event + end + end + test.equal(assert(off).time, 1.5) + test.equal(assert(off).track, 2) + test.equal(song.duration, 1.5) +end + +function suite.playsFormatTwoPatternsOneAfterAnother(): nil + local first = at(0, 0x90, 60, 100) .. at(480, 0x80, 60, 0) .. finish(0) + local second = at(0, 0x90, 62, 100) .. at(480, 0x80, 62, 0) .. finish(0) + local song = assert(midi.parse(smf(2, 480, {first, second}))) + test.equal(song.format, 2) + test.equal(song.tracks[2].endTick, 960) + test.equal(song.events[3].tick, 480, "the second pattern starts where the first ended") + test.equal(song.events[3].data1, 62) + test.equal(song.duration, 1) +end + +function suite.timesAnSmpteFileInFrames(): nil + -- 25 frames a second, 40 ticks a frame: a thousand ticks a second. + local division = (256 - 25) * 256 + 40 + local song = assert(midi.parse(smf(0, division, {at(0, 0x90, 60, 1) .. at(1500, 0x80, 60, 0) .. finish(500)}))) + test.equal(song.ppq, nil) + test.equal(song.framesPerSecond, 25) + test.equal(song.ticksPerFrame, 40) + test.equal(song.events[2].time, 1.5) + test.equal(song.duration, 2) +end + +function suite.findsLoopPointsInMarkersOrController111(): nil + local marked = meta(480, 0x06, "Loop Start") .. at(0, 0x90, 60, 1) .. meta(480, 0x06, "loopEnd") .. finish(480) + local song = assert(midi.parse(smf(0, 480, {marked}))) + test.equal(song.loopStart, 0.5) + test.equal(song.loopEnd, 1) + test.equal(song.duration, 1.5) + local rpg = at(0, 0x90, 60, 1) .. at(960, 0xB0, 111, 0) .. finish(960) + local looped = assert(midi.parse(smf(0, 480, {rpg}))) + test.equal(looped.loopStart, 1) + test.equal(looped.loopEnd, nil, "a loop with no end runs to the end of the song") + local plain = assert(midi.parse(smf(0, 480, {at(0, 0x90, 60, 1) .. finish(10)}))) + test.equal(plain.loopStart, nil) +end + +function suite.keepsSystemExclusiveAndSkipsForeignChunks(): nil + local reset = "\xF0" .. vlq(5) .. "\x7E\x7F\x09\x01\xF7" + local track = vlq(0) .. reset .. at(0, 0x90, 60, 1) .. finish(10) + local bytes = smf(0, 96, {track}) + -- A chunk that is not a track, between the header and the track. + bytes = string.sub(bytes, 1, 14) .. "XFIH" .. be32(3) .. "abc" .. string.sub(bytes, 15) + local song = assert(midi.parse(bytes)) + test.equal(song.events[1].status, 0xF0) + test.equal(song.events[1].data, "\x7E\x7F\x09\x01\xF7") + test.equal(#song.events, 2) +end + +function suite.refusesWhatIsNotAStandardMidiFile(): nil + local function refused(bytes: string, pattern: string): nil + local song, reason = midi.parse(bytes, "song.mid") + test.equal(song, nil) + test.matches(assert(reason), pattern) + end + + refused("RIFF....", "song.mid: not a Standard MIDI File") + refused(smf(3, 96, {finish(0)}), "format 3 is not") + refused("MThd" .. be32(6) .. be16(0) .. be16(0) .. be16(96), "declares no tracks") + refused(smf(0, 0, {finish(0)}), "division is zero") + refused(smf(0, (256 - 27) * 256 + 4, {finish(0)}), "27 frames") + refused(smf(0, 96, {at(0, 60, 100) .. finish(0)}), "a data byte with no status before it") + refused(smf(0, 96, {at(0, 0x90, 60, 0x80) .. finish(0)}), "top bit set") + refused(smf(0, 96, {"\x81\x81\x81\x81\x01\x90\x3C\x01"}), "longer than four bytes") + local cut = smf(0, 96, {at(0, 0x90, 60, 100) .. finish(0)}) + -- The track claims more bytes than the file has. + refused(string.sub(cut, 1, #cut - 5), "cut short") + refused(smf(0, 96, {at(0, 0xF4, 0)}), "0xF4 is not allowed") + refused("MThd" .. be32(6) .. be16(0) .. be16(1) .. be16(96), "holds no MTrk chunk") +end + +function suite.loadsAMidiFileThroughItsKind(): nil + temporary( + smf(0, 96, { + at(0, 0x90, 60, 100) .. at(96, 0x80, 60, 0) .. finish(0) + }), + ".mid", + function(path: string): nil + local loader, handle, reason = load(midi.kind, path) + test.assert(handle ~= nil, reason or "no handle") + local held = assert(handle) + test.equal(held.kind, "music.midi") + local song = held:value() as midi.Song + test.equal(song.duration, 0.5) + held:release() + local _, missing, failure = load(midi.kind, path .. ".none") + test.equal(missing, nil) + test.assert(failure ~= nil) + loader:shutdown() + end + ) + temporary("not midi", ".mid", function(path: string): nil + local loader, nothing, refusal = load(midi.kind, path) + test.equal(nothing, nil) + test.matches(assert(refusal), "not a Standard MIDI File") + loader:shutdown() + end) +end + +------------------------------------------------------------------------------- +-- VGM, VGZ and DRO +------------------------------------------------------------------------------- + +function suite.readsTheWritesOfAYm2612AndSkipsTheSn76489BesideIt(): nil + local commands = "\x50\x9F" .. "\x52\x28\xF0" .. wait(441) .. "\x53\x30\x71" .. "\x62" .. "\x70" + local log = assert(musicfm.parse(vgm(header({[0x0C] = 3579545, [0x2C] = 7670453}), commands))) + test.equal(log.format, "vgm") + test.equal(log.version, 0x171) + test.equal(log.rate, 44100) + test.equal(#log.chips, 1) + test.equal(log.chips[1].chip, "ym2612") + test.equal(log.chips[1].clock, 7670453) + test.equal(log.count, 2) + local tick, chip, register, value = log:entry(1) + test.equal(tick, 0) + test.equal(chip, 0) + test.equal(register, 0x28) + test.equal(value, 0xF0) + tick, chip, register, value = log:entry(2) + test.equal(tick, 441) + test.equal(register, 0x130, "port 1 is the second bank") + test.equal(log.length, 441 + 735 + 1) + test.equal(#log.skipped, 1) + test.matches(log.skipped[1], "SN76489") + test.raises( + function(): nil + log:entry(3) + end, + "from 1 to 2" + ) +end + +function suite.namesDualAndAlternateChipsFromTheClockFlags(): nil + -- Bit 30 declares a second YM3812, bit 31 makes the YM2151 a YM2164 and + -- the YM2610 a YM2610B. + local clocks = {[0x50] = 3579545 + 1073741824, [0x30] = 3579545 + 2147483648, [0x4C] = 8000000 + 2147483648} + local commands = "\x5A\x20\x01" .. "\xAA\x20\x02" .. "\x54\x08\x00" .. "\x58\x28\x01" + local log = assert(musicfm.parse(vgm(header(clocks), commands))) + test.equal(#log.chips, 4) + test.equal(log.chips[1].chip, "ym3812") + test.equal(log.chips[2].chip, "ym3812") + test.assert(log.chips[2].second) + test.equal(log.chips[3].chip, "ym2164") + test.equal(log.chips[4].chip, "ym2610b") + local _, chip = log:entry(2) + test.equal(chip, 1) +end + +function suite.findsTheLoopOffsetAndReadsGd3Tags(): nil + local first = "\x5A\x20\x01" .. wait(100) + local looped = "\x5A\x20\x02" .. wait(200) + local file = header({[0x50] = 3579545}) + file.loop = #first + file.tags = {"Stage 1", "ステージ1", "Game", "", "PC", "", "Composer", "", "1990", "Tecs", "made up"} + local log = assert(musicfm.parse(vgm(file, first .. looped))) + test.equal(log.loopStart, 100 / 44100) + test.equal(log.duration, 300 / 44100) + local tags = assert(log.tags) + test.equal(tags.title, "Stage 1") + test.equal(tags.titleJapanese, "ステージ1") + test.equal(tags.author, "Composer") + test.equal(tags.converter, "Tecs") + test.equal(tags.notes, "made up") +end + +function suite.assemblesMemoryImagesFromRomDataBlocks(): nil + local rom = block(0x82, le32(0x1000) .. le32(0x10) .. "\1\2\3\4") .. block(0x83, le32(16) .. le32(0) .. "\9") + local log = assert(musicfm.parse(vgm(header({[0x4C] = 8000000}), rom .. "\x58\x10\x00"))) + test.equal(log.chips[1].chip, "ym2610") + local image = assert(log.chips[1].memory.adpcmA) + test.equal(#image, 0x14, "the image reaches as far as its furthest piece") + test.equal(string.sub(image, 0x11, 0x14), "\1\2\3\4") + test.equal(string.byte(image, 1), 0) + test.equal(log.chips[1].memory.adpcmB, "\9") +end + +function suite.turnsDacDataIntoTimedWrites(): nil + -- Three PCM bytes, the first two through 0x8n commands, then a seek back + -- and the first again. + local commands = block(0x00, "\x10\x20\x30") .. "\x81" .. "\x82" .. "\xE0" .. le32(0) .. "\x80" + local log = assert(musicfm.parse(vgm(header({[0x2C] = 7670453}), commands))) + test.equal(log.count, 3) + local tick, _, register, value = log:entry(1) + test.equal(tick, 0) + test.equal(register, 0x2A) + test.equal(value, 0x10) + tick, _, _, value = log:entry(2) + test.equal(tick, 1, "a 0x81 waits a tick after its write") + test.equal(value, 0x20) + tick, _, _, value = log:entry(3) + test.equal(tick, 3) + test.equal(value, 0x10) +end + +function suite.playsADacStreamAtItsFrequency(): nil + -- Stream 0 writes YM2612 register 0x2A from bank 0 at 11025 Hz, four + -- ticks apart, for three commands. + local setup = "\x90\x00\x02\x00\x2A" .. "\x91\x00\x00\x01\x00" .. "\x92\x00" .. le32(11025) + local start = "\x93\x00" .. le32(0) .. "\x01" .. le32(3) + local commands = block(0x00, "\x11\x22\x33\x44") .. "\x52\x2B\x80" .. setup .. start .. wait(20) + local log = assert(musicfm.parse(vgm(header({[0x2C] = 7670453}), commands))) + test.equal(log.count, 4) + local expected: {{integer}} = {{0, 0x11}, {4, 0x22}, {8, 0x33}} + for index, pair in ipairs(expected) do + local tick, _, register, value = log:entry(index + 1) + test.equal(tick, pair[1]) + test.equal(register, 0x2A) + test.equal(value, pair[2]) + end +end + +function suite.decompressesABitPackedDataBlock(): nil + -- Four 4-bit values, 1 2 3 4, copied into 8-bit samples with 0x10 added. + local packed = string.char(0x12, 0x34) + local data = "\x00" .. le32(4) .. "\x08\x04\x00" .. le16(0x10) .. packed + local commands = block(0x40, data) .. "\x80\x80\x80\x80" + local log = assert(musicfm.parse(vgm(header({[0x2C] = 7670453}), commands))) + test.equal(log.count, 4) + for index = 1, 4 do + local _, _, _, value = log:entry(index) + test.equal(value, 0x10 + index) + end +end + +function suite.sharesTheYm2413ClockBeforeVersion110(): nil + local log = assert(musicfm.parse(vgm(header({[0x10] = 7670453}, 0x101), "\x52\x28\xF0"))) + test.equal(log.chips[1].chip, "ym2612") + test.equal(log.chips[1].clock, 7670453) +end + +function suite.readsTheAyFamilyAsAYm2149(): nil + local file = header({[0x74] = 2000000}) + file.bytes[0x79] = 0x10 + local log = assert(musicfm.parse(vgm(file, "\xA0\x08\x0F" .. "\xA0\x88\x0F"))) + test.equal(log.chips[1].chip, "ym2149") + test.equal(log.chips[1].clock, 1000000, "pin 26 low halves the clock") + test.equal(#log.chips, 1, "a second chip the header does not declare is skipped") + test.matches(log.skipped[1], "second chip") + local ay8930 = header({[0x74] = 2000000}) + ay8930.bytes[0x78] = 0x03 + local _, reason = musicfm.parse(vgm(ay8930, "\xA0\x08\x0F")) + test.matches(assert(reason), "AY8930") +end + +function suite.inflatesAVgzFile(): nil + local plain = vgm(header({[0x50] = 3579545}), "\x5A\x20\x01" .. wait(10)) + local packed = compression.format("gzip"):compress(plain) + local log = assert(musicfm.parse(packed, "song.vgz")) + test.equal(log.count, 1) + test.equal(log.length, 10) + local _, reason = musicfm.parse("\x1F\x8B\x08\x00garbage", "broken.vgz") + test.matches(assert(reason), "broken.vgz: the gzip stream cannot be inflated") +end + +function suite.refusesALogWithNothingToPlayOrACommandItCannotRead(): nil + local _, onlySn = musicfm.parse(vgm(header({[0x0C] = 3579545}), "\x50\x9F" .. wait(10)), "sms.vgm") + test.matches(assert(onlySn), "sms.vgm: nothing in the file is for a chip tecs.audio.fm emulates") + test.matches(assert(onlySn), "SN76489") + local _, unknown = musicfm.parse(vgm(header({[0x50] = 3579545}), "\x5A\x20\x01\x60"), "odd.vgm") + test.matches(assert(unknown), "command 0x60 is not a VGM command") + local truncated = vgm(header({[0x50] = 3579545}), "") + local _, cut = musicfm.parse(string.sub(truncated, 1, #truncated - 1) .. "\x5A\x20", "cut.vgm") + test.matches(assert(cut), "cut short") + local _, neither = musicfm.parse("MThd", "x") + test.matches(assert(neither), "neither a VGM, a VGZ nor a DRO file") + local _, unclocked = musicfm.parse(vgm(header({}), "\x5A\x20\x01"), "y") + test.matches(assert(unclocked), "no clock") +end + +function suite.readsADroVersionTwoOpl3WithItsCodemap(): nil + -- Codes 0 and 1 map to 0x20 and 0xB0; 0x81 is 0xB0 of the high bank. + local data = "\x00\x01" .. "\xFE\x09" .. "\x01\x32" .. "\xFF\x01" .. "\x81\x32" + local log = assert(musicfm.parse(dro2(2, {0x20, 0xB0}, data, 600))) + test.equal(log.format, "dro") + test.equal(log.version, 2) + test.equal(log.rate, 1000) + test.equal(log.chips[1].chip, "ymf262") + local tick, _, register, value = log:entry(2) + test.equal(tick, 10, "a short delay of 9 is ten milliseconds") + test.equal(register, 0xB0) + test.equal(value, 0x32) + tick, _, register = log:entry(3) + test.equal(tick, 10 + 512) + test.equal(register, 0x1B0) + test.equal(log.length, 600) +end + +function suite.placesADualOpl2CaptureOneChipToEachSide(): nil + local log = assert(musicfm.parse(dro2(1, {0x20}, "\x00\x01\x80\x02", 10))) + test.equal(#log.chips, 2) + test.equal(log.chips[1].left, 1) + test.equal(log.chips[1].right, 0) + test.equal(log.chips[2].right, 1) + local _, chip = log:entry(2) + test.equal(chip, 1) +end + +function suite.readsADroVersionOneWithEitherHardwareField(): nil + -- A write, a one-byte delay, a two-byte delay, the high chip, an + -- escaped write to register 0x02. + local data = "\x20\x01" .. "\x00\x04" .. "\x01\x00\x01" .. "\x03" .. "\x04\x02\x07" + for _, wide in ipairs({true, false}) do + local log = assert(musicfm.parse(dro1(2, data, wide))) + test.equal(log.version, 1) + test.equal(#log.chips, 2, "hardware 2 is a dual OPL2 in version 1") + local tick, chip, register, value = log:entry(2) + test.equal(tick, 5 + 257) + test.equal(chip, 1) + test.equal(register, 0x02) + test.equal(value, 7) + end +end + +function suite.refusesADroFileItCannotRead(): nil + local _, version = musicfm.parse("DBRAWOPL" .. le16(3) .. le16(0) .. string.rep("\0", 20), "a.dro") + test.matches(assert(version), "DRO version 3.0") + local _, code = musicfm.parse(dro2(0, {0x20}, "\x05\x01", 1), "b.dro") + test.matches(assert(code), "code 5 is past the codemap") + local _, hardware = musicfm.parse(dro2(7, {}, "", 1), "c.dro") + test.matches(assert(hardware), "hardware type 7") +end + +function suite.loadsAVgzFileThroughItsKind(): nil + local plain = vgm(header({[0x50] = 3579545}), "\x5A\x20\x01" .. wait(10)) + temporary(compression.format("gzip"):compress(plain), ".vgz", function(path: string): nil + local loader, handle, reason = load(musicfm.kind, path) + test.assert(handle ~= nil, reason or "no handle") + local held = assert(handle) + test.equal(held.kind, "music.fm") + test.equal((held:value() as musicfm.RegisterLog).count, 1) + held:release() + loader:shutdown() + end) +end + +------------------------------------------------------------------------------- +-- Players +------------------------------------------------------------------------------- + +--- One event a test port received. +local record Sent + voice: integer + kind: string + a: integer + b: integer + c: integer + at: number +end + +--- A backend whose synths and chips log what they are sent, on a clock the +--- test moves. +local record Harness + backend: audiobackend.Backend + commands: {audiobackend.Command} + sent: {Sent} + clock: {number} + chips: {string} + clocks: {integer} + images: {string} + closed: {integer} + full: {boolean} +end + +local function newHarness(): Harness + local commands: {audiobackend.Command} = {} + local sent: {Sent} = {} + local clock = {0.0} + local chips: {string} = {} + local clocks: {integer} = {} + local images: {string} = {} + local closed: {integer} = {} + local full = {false} + + local function record(voice: integer, kind: string, a: integer, b: integer, c: integer, when: number): boolean + if full[1] then + return false + end + sent[#sent + 1] = new Sent(voice = voice, kind = kind, a = a, b = b, c = c, at = when) + + return true + end + + local backend = audiobackend.silent() + backend.available = true + backend.submit = function(pending: {audiobackend.Command}, count: integer): nil + for index = 1, count do + local source = pending[index] + local copy = audiobackend.newCommand() + copy.kind = source.kind + copy.handle = source.handle + copy.flags = source.flags + copy.gain = source.gain + copy.fade = source.fade + commands[#commands + 1] = copy + end + end + backend.loadSoundFont = function(_path: string): (integer?, string?) + return 1 + end + backend.openSynth = function( + _font: integer, + handle: integer, + _polyphony: integer, + _effects: boolean + ): (audiobackend.SynthPort?, string?) + return new audiobackend.SynthPort( + send = function(status: integer, data1: integer, data2: integer, when: number): boolean + return record(handle, "message", status, data1, data2, when) + end, + notesOff = function(channel: integer, immediate: boolean): boolean + return record(handle, "notesOff", channel, immediate and 1 or 0, 0, 0) + end, + reset = function(): boolean + return record(handle, "reset", 0, 0, 0, 0) + end, + now = function(): number + return clock[1] + end, + close = function(): nil + closed[#closed + 1] = handle + end + ) + end + backend.openFm = function( + handle: integer, + chip: audiobackend.FmChip, + rate: integer + ): (audiobackend.FmPort?, string?) + chips[#chips + 1] = chip + clocks[#clocks + 1] = rate + + return new audiobackend.FmPort( + rate = 50000, + write = function(register: integer, value: integer, when: number): boolean + return record(handle, "write", register, value, 0, when) + end, + writes = function(pairs: {integer}, count: integer, when: number): boolean + if full[1] then + return false + end + for index = 1, count do + record(handle, "write", pairs[2 * index - 1], pairs[2 * index], 0, when) + end + + return true + end, + writePort = function(offset: integer, data: integer, when: number): boolean + return record(handle, "port", offset, data, 0, when) + end, + writePorts = function(_pairs: {integer}, _count: integer, _when: number): boolean + return true + end, + setMemory = function(class: audiobackend.FmMemory, image: string): boolean + images[#images + 1] = class .. ":" .. tostring(#image) + + return true + end, + reset = function(when: number): boolean + return record(handle, "reset", 0, 0, 0, when) + end, + now = function(): number + return clock[1] + end, + close = function(): nil + closed[#closed + 1] = handle + end + ) + end + + return new Harness( + backend = backend, + commands = commands, + sent = sent, + clock = clock, + chips = chips, + clocks = clocks, + images = images, + closed = closed, + full = full + ) +end + +--- Returns the messages sent so far with the status `status`, as notes. +local function sentOf(harness: Harness, kind: string, first: integer?): {Sent} + local found: {Sent} = {} + for _, event in ipairs(harness.sent) do + if event.kind == kind and (first == nil or event.a == first) then + found[#found + 1] = event + end + end + + return found +end + +--- Returns the events of `kind` sent after the first `since`. +local function sentSince(harness: Harness, since: integer, kind: string, first: integer?): {Sent} + local found: {Sent} = {} + for index = since + 1, #harness.sent do + local event = harness.sent[index] + if event.kind == kind and (first == nil or event.a == first) then + found[#found + 1] = event + end + end + + return found +end + +local function lastCommand(harness: Harness, kind: integer): audiobackend.Command? + for index = #harness.commands, 1, -1 do + if harness.commands[index].kind == kind then + return harness.commands[index] + end + end + + return nil +end + +local function near(actual: number, expected: number, what: string?): nil + test.assert( + math.abs(actual - expected) < 1e-9, + (what or "") .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual) + ) +end + +--- A song of four notes, one every half second, two seconds long. +local function scale(loop: boolean): midi.Song + local track = at(0, 0x90, 60, 100) .. at(480, 0x80, 60, 0) .. at(0, 0x90, 62, 100) + if loop then + track = meta(0, 0x06, "loopStart") .. track + end + track = track .. at(480, 0x80, 62, 0) .. at(0, 0x90, 64, 100) .. at(480, 0x80, 64, 0) .. at(0, 0x90, 65, 100) + if loop then + track = track .. meta(480, 0x06, "loopEnd") .. at(0, 0x80, 65, 0) .. finish(0) + else + track = track .. at(480, 0x80, 65, 0) .. finish(0) + end + + return assert(midi.parse(smf(0, 480, {track}))) +end + +local function midiPlayer(harness: Harness, song: midi.Song, options: music.PlayOptions?): (audio.Audio, music.Player) + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 8}) + local font = assert(synth.loadSoundFont(mixer, "font.sf2")) + + return mixer, assert(midi.playSong(mixer, font, song, options)) +end + +function suite.schedulesAheadOnTheVoiceClock(): nil + local harness = newHarness() + local mixer, player = midiPlayer(harness, scale(false)) + -- Started at 0 with a lead of 0.01: the first note is due then, and the + -- next one, half a second in, is past the lookahead. + local ons = sentOf(harness, "message", 0x90) + test.equal(#ons, 1) + near(ons[1].at, 0.01, "first note") + harness.clock[1] = 0.48 + mixer:update() + ons = sentOf(harness, "message", 0x90) + test.equal(#ons, 2) + near(ons[2].at, 0.51, "second note") + test.equal(sentOf(harness, "message", 0x80)[1].a, 0x80) + near(player:position(), 0.47, "position") + test.assert(player:playing()) + test.equal(player:status(), "playing") + near(player:duration(), 2) +end + +function suite.slipsAfterAStallRatherThanRushing(): nil + local harness = newHarness() + local mixer, _player = midiPlayer(harness, scale(false)) + -- The frame thread stalls for three seconds of audio clock: the note due + -- at half a second is late, and plays at once. + harness.clock[1] = 3 + mixer:update() + local ons = sentOf(harness, "message", 0x90) + test.equal(#ons, 2, "only the late note goes, not all that was missed") + near(ons[2].at, 3.01, "the late note") + harness.clock[1] = 3.48 + mixer:update() + ons = sentOf(harness, "message", 0x90) + test.equal(#ons, 3) + near(ons[3].at, 3.51, "the next keeps its distance") +end + +function suite.finishesAndFreesItsSynthAfterTheTail(): nil + local harness = newHarness() + local mixer, player = midiPlayer(harness, scale(false)) + for step = 1, 50 do + harness.clock[1] = step * 0.05 + mixer:update() + end + test.equal(#sentOf(harness, "message", 0x90), 4) + test.assert(player:finished()) + test.equal(player:status(), "finished") + test.equal(#harness.closed, 0, "the release tail still rings") + harness.clock[1] = 2.01 + 2.1 + mixer:update() + test.equal(player:status(), "stopped") + test.assert(player:finished()) + test.equal(#harness.closed, 1) + test.assert(lastCommand(harness, audiobackend.STOP) ~= nil) +end + +function suite.loopsForeverReleasingHeldNotesAtTheEnd(): nil + local harness = newHarness() + local song = scale(true) + near(assert(song.loopStart), 0) + near(assert(song.loopEnd), 2) + local mixer, player = midiPlayer(harness, song) + test.assert(player:looping()) + for step = 1, 100 do + harness.clock[1] = step * 0.05 + mixer:update() + end + test.equal(player:loops(), 2) + test.assert(not player:finished()) + local ons = sentOf(harness, "message", 0x90) + test.assert(#ons >= 9) + -- The fifth note is the first of the second pass, two seconds on. + near(ons[5].at, 2.01, "the loop's first note") + -- The note held at the loop's end was released there. + local offs = sentOf(harness, "message", 0x80) + local released = false + for _, off in ipairs(offs) do + if off.b == 65 and math.abs(off.at - 2.01) < 1e-9 then + released = true + end + end + test.assert(released, "the held note is released at the loop") + player:setLoop(false) + for step = 101, 160 do + harness.clock[1] = step * 0.05 + mixer:update() + end + test.assert(player:finished()) +end + +function suite.scalesTheTempoFromWhatIsScheduled(): nil + local harness = newHarness() + local mixer, player = midiPlayer(harness, scale(false), {tempo = 2}) + test.equal(player:tempo(), 2) + harness.clock[1] = 0.24 + mixer:update() + local ons = sentOf(harness, "message", 0x90) + test.equal(#ons, 2) + near(ons[2].at, 0.26, "twice as fast") + player:setTempo(1) + harness.clock[1] = 0.6 + mixer:update() + ons = sentOf(harness, "message", 0x90) + test.equal(#ons, 2) + test.raises( + function(): nil + player:setTempo(0) + end, + "tempo must be from 0.01 to 100" + ) +end + +function suite.seeksByResettingAndChasingTheSongState(): nil + local track = at( + 0, + 0xB1, + 7, + 90 + ) .. at( + 0, + 0xC1, + 33 + ) .. at( + 0, + 0xE1, + 0, + 80 + ) .. at(0, 0x91, 40, 100) .. at(960, 0x81, 40, 0) .. at(0, 0x91, 43, 100) .. at(480, 0x81, 43, 0) .. finish(0) + local harness = newHarness() + local mixer, player = midiPlayer(harness, assert(midi.parse(smf(0, 480, {track})))) + harness.clock[1] = 1 + local before = #harness.sent + player:seek(1.5) + test.equal(#sentOf(harness, "reset"), 1) + local controls = sentSince(harness, before, "message", 0xB1) + test.equal(#controls, 1) + test.equal(controls[1].c, 90) + near(controls[1].at, 1.01, "the chase lands at the seek") + test.equal(sentSince(harness, before, "message", 0xC1)[1].b, 33) + local bend = sentSince(harness, before, "message", 0xE1)[1] + test.equal(bend.b, 0) + test.equal(bend.c, 80) + mixer:update() + -- From 1.5 the next event is the note off at 1.5 itself. + local offs = sentOf(harness, "message", 0x81) + near(offs[#offs].at, 1.01, "the event at the seek point") + near(player:position(), 1.49) +end + +function suite.mutesAChannelAndPausesTheSynth(): nil + local track = at( + 0, + 0x90, + 60, + 100 + ) .. at(0, 0x91, 62, 100) .. at(480, 0x80, 60, 0) .. at(0, 0x81, 62, 0) .. finish(0) + local harness = newHarness() + local mixer, player = midiPlayer(harness, assert(midi.parse(smf(0, 480, {track})))) + test.equal(#sentOf(harness, "message", 0x91), 1) + player:setMuted(1, true) + test.equal(sentOf(harness, "notesOff")[1].a, 1) + player:seek(0) + mixer:update() + test.equal(#sentOf(harness, "message", 0x91), 1, "no note on reaches the muted channel") + test.equal(#sentOf(harness, "message", 0x90), 2, "the other channel plays on") + player:pause() + test.assert(player:paused()) + mixer:update() + test.assert(lastCommand(harness, audiobackend.PAUSE) ~= nil) + player:resume() + mixer:update() + test.assert(lastCommand(harness, audiobackend.RESUME) ~= nil) + test.raises( + function(): nil + player:setMuted(16, true) + end, + "from 0 to 15" + ) + player:stop(0.5) + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.STOP)).fade, 0.5) + player:stop() + test.equal(player:status(), "stopped") +end + +function suite.followsAReloadOfTheSongHandle(): nil + local first = smf(0, 480, {at(0, 0x90, 60, 100) .. at(9600, 0x80, 60, 0) .. finish(0)}) + temporary(first, ".mid", function(path: string): nil + local loader, handle = load(midi.kind, path) + local held = assert(handle) + local harness = newHarness() + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 8}) + local font = assert(synth.loadSoundFont(mixer, "font.sf2")) + local player = assert(midi.play(mixer, font, held, {loop = false})) + held:release() + test.equal(held:holds(), 1, "the player holds the song") + harness.clock[1] = 1 + mixer:update() + assert(files.write(path, smf(0, 480, {at(0, 0x90, 72, 100) .. at(9600, 0x80, 72, 0) .. finish(0)}))) + underPump(function(): boolean + assert(loader:reload(midi.kind, held)) + + return true + end) + mixer:update() + test.equal(#sentOf(harness, "reset"), 1, "the reload seeks") + harness.clock[1] = 20 + mixer:update() + local offs = sentOf(harness, "message", 0x80) + test.equal(offs[#offs].b, 72, "the new song plays from where the old one was") + player:stop() + test.equal(held:holds(), 0) + local other, otherHandle = load(musicfm.kind, path) + test.equal(otherHandle, nil, "a MIDI file is not a register log") + other:shutdown() + loader:shutdown() + end) +end + +function suite.refusesAHandleOfAnotherKind(): nil + local plain = vgm(header({[0x50] = 3579545}), "\x5A\x20\x01" .. wait(10)) + temporary(plain, ".vgm", function(path: string): nil + local loader, handle = load(musicfm.kind, path) + local held = assert(handle) + local harness = newHarness() + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 8}) + local font = assert(synth.loadSoundFont(mixer, "font.sf2")) + test.raises( + function(): nil + midi.play(mixer, font, held) + end, + "not as a MIDI song" + ) + held:release() + loader:shutdown() + end) +end + +function suite.refusesPlayOptionsOutOfRange(): nil + local harness = newHarness() + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 8}) + local font = assert(synth.loadSoundFont(mixer, "font.sf2")) + test.raises( + function(): nil + midi.playSong(mixer, font, scale(false), {lookahead = 5}) + end, + "lookahead must be from" + ) + test.raises( + function(): nil + midi.playSong(mixer, font, scale(false), {start = -1}) + end, + "start must not be negative" + ) +end + +local function fmLog(): musicfm.RegisterLog + -- A YM2612 note keyed on at 0 and off at a second, a key-on of channel 1 + -- at half a second, and the second bank written at once. + local commands = "\x52\x28\xF0" .. "\x52\xA0\x22" .. "\x53\x30\x71" .. wait( + 22050 + ) .. "\x52\x28\xF1" .. wait(22050) .. "\x52\x28\x00" .. wait(4410) + return assert( + musicfm.parse( + vgm( + header({ + [0x2C] = 7670453, + [0x4C] = 8000000 + }), + block(0x82, le32(16) .. le32(0) .. "\1\2") .. "\x58\x10\x00" .. commands + ) + ) + ) +end + +function suite.playsALogOnChipsOfItsOwn(): nil + local harness = newHarness() + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 8}) + local log = fmLog() + test.equal(#log.chips, 2) + local player = assert(musicfm.playLog(mixer, log, {group = "music", loop = false})) + test.equal(harness.chips[1], "ym2610") + test.equal(harness.chips[2], "ym2612") + test.equal(harness.clocks[2], 7670453) + test.equal(harness.images[1], "adpcmA:2") + local writes = sentOf(harness, "write") + -- The YM2610's write and the YM2612's three at tick zero. + test.equal(#writes, 4) + near(writes[2].at, 0.01) + test.equal(writes[4].a, 0x130) + harness.clock[1] = 0.5 + mixer:update() + writes = sentOf(harness, "write") + test.equal(#writes, 5) + near(writes[5].at, 0.51) + test.equal(writes[5].b, 0xF1) + player:setMuted(1, true) + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.SET_GAIN)).gain, 0) + test.raises( + function(): nil + player:setMuted(2, true) + end, + "chips 0 to 1" + ) + -- A seek resets both chips and writes back every register's last value. + player:seek(0.75) + local resets = sentOf(harness, "reset") + test.equal(#resets, 2) + writes = sentOf(harness, "write") + local last = writes[#writes] + test.equal(last.a, 0x130, "the YM2612's registers in ascending order") + near(last.at, 0.51) + harness.clock[1] = 2 + mixer:update() + harness.clock[1] = 4 + mixer:update() + test.equal(player:status(), "stopped") + test.equal(#harness.closed, 2) +end + +function suite.placesADualOpl2CaptureOnItsVoices(): nil + local harness = newHarness() + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 8}) + local log = assert(musicfm.parse(dro2(1, {0x20}, "\x00\x01\x80\x02", 10))) + local player = assert(musicfm.playLog(mixer, log)) + mixer:update() + local stereo = 0 + for _, command in ipairs(harness.commands) do + if command.kind == audiobackend.SET_STEREO then + stereo = stereo + 1 + end + end + test.equal(stereo, 2) + player:stop() +end + +function suite.loopsALogFromItsLoopWrite(): nil + local first = "\x5A\x20\x01" .. wait(4410) + local looped = "\x5A\x20\x02" .. wait(4410) + local file = header({[0x50] = 3579545}) + file.loop = #first + local harness = newHarness() + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 8}) + local player = assert(musicfm.playLog(mixer, assert(musicfm.parse(vgm(file, first .. looped))))) + for step = 1, 12 do + harness.clock[1] = step * 0.05 + mixer:update() + end + -- Twice the first pass's tenth of a second, then a loop every tenth. + test.equal(player:loops(), 5) + local writes = sentOf(harness, "write") + test.equal(writes[1].b, 1) + test.equal(writes[2].b, 2) + test.equal(writes[3].b, 2, "the loop goes back to its own write") + near(writes[3].at, 0.21) + player:stop() +end + +function suite.reportsAFullQueueByRetryingLater(): nil + local harness = newHarness() + local mixer, _player = midiPlayer(harness, scale(false)) + harness.full[1] = true + harness.clock[1] = 0.48 + mixer:update() + test.equal(#sentOf(harness, "message", 0x90), 1) + harness.full[1] = false + mixer:update() + local ons = sentOf(harness, "message", 0x90) + test.equal(#ons, 2, "the refused note goes on the next update") + near(ons[2].at, 0.51) +end + +return suite diff --git a/tests/tecs/compatibilitytest.nupp b/tests/tecs/compatibilitytest.nupp index 30de1770..e93f4224 100644 --- a/tests/tecs/compatibilitytest.nupp +++ b/tests/tecs/compatibilitytest.nupp @@ -948,6 +948,9 @@ function suite.contentKindNames(): nil -- New with the rewrite: the SDL tree noted no font or shader content. test.equal(assets.FONT, "font") test.equal(assets.SHADER, "shader") + -- New with the music kinds. + test.equal(assets.MUSIC_MIDI, "music.midi") + test.equal(assets.MUSIC_FM, "music.fm") end ------------------------------------------------------------------------------- diff --git a/tools/tecs/dev/commands.nupp b/tools/tecs/dev/commands.nupp index 25521cd3..7a9bddde 100644 --- a/tools/tecs/dev/commands.nupp +++ b/tools/tecs/dev/commands.nupp @@ -160,7 +160,7 @@ export function sdk(): {[string]: string} "CARGO_ENCODED_RUSTFLAGS", compilerRoot .. "/scripts/toolchain", "host-library", - "base,native-files,native-net" + "base,native-files,native-net,native-compression" }) const directory = assert(text:match("([^\r\n]+)%s*$"), "the SDK staging command returned no path") assert(files.isFile(path.newPath(directory, "libnupp.a")), "the staged SDK has no libnupp.a")