diff --git a/AGENTS.md b/AGENTS.md index 8ed255ff..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.nupp # Clips, voices, groups, limits, the Sound component +│ ├── 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 +│ ├── 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/Cargo.lock b/Cargo.lock index 72720602..1aac99e2 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,7 +3192,9 @@ name = "tecs-audio" version = "0.1.0" dependencies = [ "cpal", + "rustysynth", "symphonia", + "ymfm-oxide", ] [[package]] @@ -4354,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 4ea995c5..1b365e3e 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"] } @@ -70,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/README.md b/README.md index 7ac7e39d..c4f37e17 100644 --- a/README.md +++ b/README.md @@ -50,8 +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; 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. @@ -488,6 +490,188 @@ 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. + +## 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. + +## 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 ef79a5b5..4405d83b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -81,6 +81,60 @@ 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. + +## Linked BSD-licensed code + +### 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. 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 + +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 2f374f00..4ff25989 100644 --- a/native/rust/tecs-audio/Cargo.toml +++ b/native/rust/tecs-audio/Cargo.toml @@ -11,4 +11,6 @@ crate-type = ["cdylib", "rlib"] [dependencies] cpal.workspace = true +rustysynth.workspace = true symphonia.workspace = true +ymfm-oxide.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..17712306 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` or +/// `tecs_audio_fm_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..b1c2cc42 100644 --- a/native/rust/tecs-audio/src/engine.rs +++ b/native/rust/tecs-audio/src/engine.rs @@ -13,9 +13,11 @@ 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; +use crate::synth::{self, Port, Settings}; /// Measures a clip's duration against the streaming threshold. pub const MODE_AUTO: u32 = 0; @@ -52,6 +54,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, General MIDI and FM alike, 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 +124,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), @@ -169,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(); } @@ -291,8 +303,86 @@ 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) + } + } + } + + /// 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, `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, 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) => { + 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 +495,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(); + } } } @@ -585,6 +681,49 @@ 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::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 [ + (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..5f76e6a8 --- /dev/null +++ b/native/rust/tecs-audio/src/fm.rs @@ -0,0 +1,1804 @@ +//! An emulated Yamaha FM chip that plays as one voice of the mixer. +//! +//! 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. +//! +//! 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::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 std::sync::mpsc::{sync_channel, Receiver, SyncSender}; + +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. +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 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, Hash)] +pub enum Kind { + /// 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 { + /// 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::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 + ) + } +} + +/// 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 { + 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>), +} + +/// 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 + } + + /// Puts the OPN chips with an SSG on the fidelity `Kind::divider` names. + fn fidelity(&mut self) { + match self { + 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 `clock`, which `Kind::divider` + /// has to agree with. + #[cfg(test)] + 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: 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 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 of every output into `out`, in the chip's + /// order. + fn generate(&mut self, out: &mut [i32; MAX_OUTPUTS]) { + match self { + 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, +} + +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, or one port write, as a batch crosses the C ABI. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RegisterWrite { + /// 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. + pub value: u16, +} + +/// The chip as a scheduler target. +/// +/// 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 => 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); + } + _ => {} + } + } + + fn render(&mut self, left: &mut [f32], right: &mut [f32]) { + for (l, r) in left.iter_mut().zip(right.iter_mut()) { + (*l, *r) = self.frame(); + } + } +} + +/// The game side of one chip: where register writes are sent and the clock +/// read. +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 frame at or after it. + /// + /// 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; + } + 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(kind: u8, address: u16, value: u8, frame: u64) -> Event { + Event { + frame, + kind, + status: address as u8, + data1: (address >> 8) as u8, + data2: value, + } + } + + /// Reports which chip the port drives. + pub fn kind(&self) -> Kind { + 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. + 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(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 + /// 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) + { + return false; + } + let frame = self.frame_at(at); + self.port.push_all(writes.len(), |index| { + 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 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 + /// memory images stay. + 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 frames it has rendered over + /// its rate. + pub fn now(&self) -> f64 { + self.port.now() + } + + /// Reports the frames 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 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 { + 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, + ); + ( + FmPort { + port, + kind, + clock, + images_out, + images_back, + }, + source, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::source::Source; + + /// 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), + (0x40, 0x3f), + (0x43, 0x00), + (0x63, 0xf0), + (0x83, 0x0f), + (0xa0, 0x41), + (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 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 { + 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], 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 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.register(kind, writes[next].1, writes[next].2); + next += 1; + } + chip.generate(&mut outputs); + outputs[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) = 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::Ym3812, &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) = 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::Ym3812, &timed(123), 2000); + assert_eq!(out, reference); + } + + #[test] + fn applies_writes_due_on_one_sample_in_sending_order() { + let (mut port, mut source) = 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::Ym3812, &writes, 1500)); + writes.truncate(NOTE.len() + 1); + assert_ne!(out, direct(Kind::Ym3812, &writes, 1500)); + } + + #[test] + fn orders_writes_by_time_rather_than_by_sending() { + let (mut port, mut source) = 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::Ym3812, &writes, 1500)); + } + + #[test] + fn applies_a_past_or_untimed_write_on_the_next_sample() { + 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)); + 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::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) = opl2(); + for (register, value) in NOTE { + assert!(port.write(register, value, 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) = 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 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) = opl2(); + assert!(!opl2.write(0x100, 0x01, 0.0)); + assert!(opl2.write(0xff, 0x01, 0.0)); + 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 = [ + RegisterWrite { + register: 0x20, + value: 1, + }, + RegisterWrite { + register: 0x40, + value: 0x100, + }, + ]; + 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, + "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) = 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, false)); + 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, false)); + let too_many = vec![ + RegisterWrite { + register: 0x20, + value: 1, + }; + QUEUE_WRITES + 1 + ]; + let (mut fresh, _source) = opl2(); + assert!( + !fresh.writes(&too_many, 0.0, false), + "a batch past the queue never fits" + ); + 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) = 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::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) = 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::Ym3812, &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::Ym3812, &timed(0), 400); + assert_eq!(&out[800..], &after[..]); + } + + #[test] + fn renders_at_the_rate_the_emulator_gives_each_chip_clock() { + 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) = 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) = 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::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) = 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::Ym3812, &timed(0), 1500)); + } + + #[test] + fn runs_the_clock_in_chip_samples() { + 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)); + 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()); + } + + /// 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 4b89a5a0..ba74c485 100644 --- a/native/rust/tecs-audio/src/lib.rs +++ b/native/rust/tecs-audio/src/lib.rs @@ -27,9 +27,11 @@ pub mod decode; pub mod device; pub mod engine; pub mod enumerate; +pub mod fm; 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 +40,8 @@ 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. const STATUS_OK: i32 = 0; @@ -405,6 +409,430 @@ 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) }); + } +} + +/// Opens the emulated FM chip `name` selects at `clock` hertz, for the voice +/// `handle` names. +/// +/// `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 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, + name: *const c_char, + clock: u32, +) -> *mut FmPort { + let value = match unsafe { engine(device) } { + None => return std::ptr::null_mut(), + Some(value) => value, + }; + 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 +/// +/// 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 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 +/// +/// 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, + ports: i32, +) -> 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, ports != 0)) +} + +/// 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)), + } +} + +/// 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 +/// +/// 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; @@ -414,6 +842,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` @@ -429,6 +860,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 @@ -1018,11 +1450,99 @@ 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 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_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 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, + value: 0x41, + }, + RegisterWrite { + register: 0xb0, + value: 0x32, + }, + ]; + 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, 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); + } + } + #[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 new file mode 100644 index 00000000..884b3703 --- /dev/null +++ b/native/rust/tecs-audio/src/synth.rs @@ -0,0 +1,824 @@ +//! 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. +//! +//! 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}; +//! +//! 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; + +/// 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; + +/// 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 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); + + /// 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 { + const BLOCK_FRAMES: usize = BLOCK_FRAMES; + + 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 + } + + /// 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. +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 + } + + /// 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; + } + self.dropped += 1; + 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 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 + } + + /// 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. + /// + /// 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, + 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; + }; + // 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. + // 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 || queued.kind == KIND_MEMORY); + } + 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 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. 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 { + return 0; + } + self.collect(); + if self.phase == 0 { + self.apply_due(); + } + 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) % T::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; T::BLOCK_FRAMES], + right: vec![0.0; T::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 { + const BLOCK_FRAMES: usize = BLOCK_FRAMES; + + 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/fm.rs b/native/rust/tecs-audio/tests/fm.rs new file mode 100644 index 00000000..999e7263 --- /dev/null +++ b/native/rust/tecs-audio/tests/fm.rs @@ -0,0 +1,204 @@ +//! 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 +//! 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, kind.default_clock()) + .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::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, 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 + // 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::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, false)); + 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::Ym3812, 1.0); + let mut set = command(command::SET_GAIN, 1); + set.gain = gain; + engine.submit(&[set]); + assert!(port.writes(¬e(), 0.0, false)); + 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::Ym3812, 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); +} + +#[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/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/nupp.lua b/nupp.lua index 6fb30e3e..f700658a 100644 --- a/nupp.lua +++ b/nupp.lua @@ -234,6 +234,9 @@ return { "tecs.internal.assetnative", "tecs.assets", "tecs.audio", + "tecs.audio.fm", + "tecs.audio.synth", + "tecs.internal.audiosynth", "tecs.ecs", "tecs.files", "tecs.gfx", diff --git a/src/tecs/audio/fm.nupp b/src/tecs/audio/fm.nupp new file mode 100644 index 00000000..6768c1b5 --- /dev/null +++ b/src/tecs/audio/fm.nupp @@ -0,0 +1,812 @@ +module tecs.audio.fm + +--[[ +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 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() +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 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. + +`"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. + +## 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 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 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 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 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 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 +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. Memory images +have a queue of their own, eight deep. + +## 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 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 writes the queue holds, which is also the largest batch `writes` takes. +local QUEUE_WRITES: integer = 4096 + +--- 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: 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 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?, + + --- 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, 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 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, 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 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 + 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. +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 + +--- 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, address: integer, value: integer, ports: boolean): nil + local where = "tecs.audio.fm." .. name .. ": " + 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 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. +--- +--- 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, 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` 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 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 = port.rate, + chip = chip, + clock = clock, + lastRegister = found.lastRegister, + lastPort = found.lastPort, + mixer = mixer, + port = port, + voice = voice, + destroyed = false, + memory = found.memory + ) + 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(rateOf(found, clock)), 0) + end + + local open = seam.backend().openFm + if open == nil then + 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, clock) + 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 `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 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, false) + if self.destroyed then + return false + end + + 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 +--- 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 count = checkPairs("writes", self, pairs, false) + if self.destroyed then + return false + end + 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 + if count == 0 then + return true + end + if count > QUEUE_WRITES then + return false + end + + 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. +--- +--- 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. Memory images stay. +--- +--- @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 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 + 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 + +--- 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. +--- +--- 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 +--- @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, fadeOut) + self.port.close() +end diff --git a/src/tecs/audio.nupp b/src/tecs/audio/init.nupp similarity index 98% rename from src/tecs/audio.nupp rename to src/tecs/audio/init.nupp index 4a40da19..a98a7580 100644 --- a/src/tecs/audio.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..ce02623f --- /dev/null +++ b/src/tecs/internal/audiosynth.nupp @@ -0,0 +1,28 @@ +module tecs.internal.audiosynth + +--[[ +What `tecs.audio` lends `tecs.audio.synth` and `tecs.audio.fm`. + +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. +]] + +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..f5719db3 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,121 @@ 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 + +--- Names the FM chip an [`FmPort`](tecs.platform.audiobackend.FmPort) drives: +--- 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. +--- +--- 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 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 frame, 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 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. + 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 @@ -159,6 +275,31 @@ 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. 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, clock: integer): (FmPort?, string?))? + --- Read-only. Stops everything and closes the output. Calling it twice is --- harmless. close: function(): nil @@ -322,6 +463,10 @@ export const STEREO: integer = 4 --- samples. export const STREAM: integer = 8 +--- 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. --- @return a command whose every field is at rest export function newCommand(): Command @@ -385,6 +530,70 @@ 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 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 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(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, + 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..62419cb2 100644 --- a/src/tecs/platform/audionative.nupp +++ b/src/tecs/platform/audionative.nupp @@ -68,6 +68,24 @@ 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 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 @@ -139,6 +157,71 @@ 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?) + +-- 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, + 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, + 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 + +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 @@ -234,6 +317,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 @@ -242,11 +333,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 @@ -388,6 +484,186 @@ 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 + + --- 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) + + --- 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, 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 + 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 + 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_memory(fm, MEMORY_CLASSES[class] as uint32, image, #image as uint64) ~= 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 @@ -428,6 +704,14 @@ 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, + 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..67879661 --- /dev/null +++ b/tests/audiofmtest.nupp @@ -0,0 +1,493 @@ +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} + clocks: {integer} + images: {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 clocks: {integer} = {} + local images: {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, + 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 + 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("setMemory", {#image}, 0) + 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, + clocks = clocks, + images = images, + 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, 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)) + 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 + +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") + 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(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, 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 + +return suite 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