diff --git a/AGENTS.md b/AGENTS.md index 8ed255ff..f560bee3 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, music │ ├── 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..53109322 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; MIDI, VGM, VGZ and + DRO music played on them; physics over Rapier; sequencing with tweening; + asset orchestration; file watching; and an MCP debug server. - [Tiled TMX maps and TSX tilesets](docs/tiled/index.md), animated tile and image layers, object factories, runtime tile edits and authored collision outlines. @@ -488,6 +490,244 @@ 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. + +## Music is two asset kinds and a player that owns its voices + +`tecs.audio.music` plays songs on the synthesizer voices: Standard MIDI Files on +the General MIDI synth, and VGM, VGZ and DRO register logs on emulated FM chips. +Each format is a `tecs.assets.Kind`, `"music.midi"` and `"music.fm"`, +which are content kind names and so a compatibility surface, and a +`tecs.audio.music.Player` plays what one loaded. + +The files are parsed in Nupp, on the loader's readiness pump, up to 65 536 +commands a pass, so a large log loads across frames instead of stalling one. +A worker lane was the alternative and lost: it needs the `workers` host +feature and a sendable parser, and its result, tens of thousands of events, +would be copied back across the lane only to be read on the frame thread +anyway. Parsing in Rust lost for the same reason, with a structured payload to +carry over the C ABI besides. A log is kept as two integer arrays, a tick and +a packed chip, register and value a write, rather than a record per write, +because a long VGM holds hundreds of thousands of them. VGZ inflates through +`nupp.compression`, which is Nupp's and so is what Tecs uses: the host now +stages the `native-compression` feature beside `native-files` and +`native-net`, in the three places that list them. `flate2`, already in the +Cargo graph through `tecs-assets`, would have worked too, and is exactly the +wrapper around a Nupp facility the project does not keep. + +A player owns its voices, a synth for a MIDI song and one chip for each chip a +log names, and the handle it plays, which it retains and watches for a reload. +The mixer's `update` advances every player made on it, because a game already +calls that every frame and keeps calling it while its world is paused; a +`Music` component was the alternative, and it lost because a song is a handle +and a set of voices, neither of which is data a snapshot could carry or a row +could own. The player schedules ahead on the voices' own clock, `lookahead` +seconds at a time, so timing follows the audio rather than the frame rate. When +a stall makes an event late, that event plays at once and the rest keep their +distance from it: the song slips by the stall instead of rushing what it +missed, and a stall over a rest changes nothing. A loop ends before the +events at its end, which belong to the next pass, and held notes are released +there; the song's own end takes everything, so its closing note offs are sent. +A song loops forever when it has a loop point and plays once when it has none. + +Seeking resets the voices and rebuilds their state rather than replaying the +song up to the point: a MIDI song sends the programs, controllers and bends in +force there, and a log writes the last value of every register. The second is +exact for everything a register holds and approximate for what a chip holds +besides, envelopes in flight and the OPN's shared key-on register among them, +so a note held across the point comes back at its next key-on. System +exclusive messages are kept in a song but not sent, because the synth takes +short messages; the resets they carry are what a fresh synth already is. + +A VGM file is a log of every chip in a machine, and a Mega Drive's has an +SN76489 beside its YM2612. Refusing such a file because one of its chips is +not a Yamaha one would make the YM2612 useless, so each chip Tecs cannot +emulate is skipped with a reason in `skipped`, and only a file left with +nothing is refused. The YM2612's DAC writes from a data bank, the `0x8n` +commands and the DAC streams, are turned into ordinary register writes at +their times when the file is read, so the player knows nothing of them, and a +format 2 MIDI file's patterns play one after another. + ## Device vocabularies follow the library that reports them Key names, mouse button names and gamepad button and axis names came from SDL diff --git a/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/build-support/src/nupp.rs b/native/rust/build-support/src/nupp.rs index a31039a8..b4271f6c 100644 --- a/native/rust/build-support/src/nupp.rs +++ b/native/rust/build-support/src/nupp.rs @@ -4,7 +4,7 @@ use anyhow::Result; use std::path::{Path, PathBuf}; use std::process::Command; pub const OUTPUT: &str = "out/nupp"; -const HOST_FEATURES: &str = "base,native-files,native-net"; +const HOST_FEATURES: &str = "base,native-files,native-net,native-compression"; pub fn compiler(root: &Path) -> Result { if let Some(root) = std::env::var_os("NUPP_COMPILER_ROOT") { diff --git a/native/rust/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/native/rust/winit-host/build.rs b/native/rust/winit-host/build.rs index b1a66021..6a24ab04 100644 --- a/native/rust/winit-host/build.rs +++ b/native/rust/winit-host/build.rs @@ -87,7 +87,7 @@ fn target_os() -> String { // one set of files, and `native-net` arrives with the I/O module extraction // builds its packet through. A host staged without them refuses to load the // component rather than failing later. -const HOST_FEATURES: &str = "base,native-files,native-net"; +const HOST_FEATURES: &str = "base,native-files,native-net,native-compression"; /// Names the run path a packaged link records instead of the staged SDK's /// absolute one. diff --git a/nupp.lua b/nupp.lua index 6fb30e3e..c5791cd5 100644 --- a/nupp.lua +++ b/nupp.lua @@ -234,6 +234,15 @@ return { "tecs.internal.assetnative", "tecs.assets", "tecs.audio", + "tecs.audio.fm", + "tecs.audio.synth", + "tecs.audio.music", + "tecs.audio.music.midi", + "tecs.audio.music.fm", + "tecs.internal.audiosynth", + "tecs.internal.registerlog", + "tecs.internal.vgm", + "tecs.internal.dro", "tecs.ecs", "tecs.files", "tecs.gfx", diff --git a/src/tecs/assets.nupp b/src/tecs/assets.nupp index ac1dc147..24b9453d 100644 --- a/src/tecs/assets.nupp +++ b/src/tecs/assets.nupp @@ -143,6 +143,14 @@ export const SHADER = "shader" --- Read-only. Names the content kind of a decoded model scene. export const MODEL = "model" +--- Read-only. Names the content kind of a Standard MIDI File parsed as a song, +--- which `tecs.audio.music.midi.kind` loads. +export const MUSIC_MIDI = "music.midi" + +--- Read-only. Names the content kind of a VGM, VGZ or DRO file parsed as a +--- register log, which `tecs.audio.music.fm.kind` loads. +export const MUSIC_FM = "music.fm" + --- A caller's hold on one acquired asset. --- --- Handles are shared: every caller whose request joined one acquisition gets diff --git a/src/tecs/audio/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..eb7fabaf 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 @@ -2199,7 +2229,8 @@ end --- Call once per frame with the frame's step. The step advances cooldowns, and --- nothing else here needs time: a fade is the backend's to run, and a voice is --- over when the backend says so rather than when a clock here says it should ---- be. +--- be. It also advances every [`Player`](tecs.audio.music.Player) made on this +--- mixer, which schedules on its voices' own clock rather than on the step. --- --- Not a world system, and `install` deliberately does not add one: reaping has --- to continue during a world pause, so an application drives this from the @@ -2214,6 +2245,22 @@ function Audio.update(exclusive self: Audio, dt: number?): integer if self.closed then return 0 end + -- Music players first, so what they schedule this frame rides the same + -- flush as the voices they start. + local updates = audiosynth.updates[self] + if updates ~= nil then + local kept: integer = 0 + for index = 1, #updates do + local advance = updates[index] + if advance() then + kept = kept + 1 + updates[kept] = advance + end + end + for index = #updates, kept + 1, -1 do + updates[index] = nil + end + end local observe = self.backend.failure if self.failureReason == nil and observe ~= nil then local failure = observe() diff --git a/src/tecs/audio/music/fm.nupp b/src/tecs/audio/music/fm.nupp new file mode 100644 index 00000000..1cbb6067 --- /dev/null +++ b/src/tecs/audio/music/fm.nupp @@ -0,0 +1,692 @@ +module tecs.audio.music.fm + +--[[ +Register logs of FM chips, loaded as assets and played on emulated chips. + +```nupp +local log = assert(app.assets:request(tecs.audio.music.fm.kind, "assets/music/stage1.vgz")) +local player = assert(tecs.audio.music.fm.play(mixer, log, {group = "music"})) +log:release() +``` + +A register log is a song recorded as what a sound driver wrote to its chips, +with the time of every write. `kind` loads one as a +[`RegisterLog`](tecs.audio.music.fm.RegisterLog), and `parse` reads bytes a +game already holds. `play` makes one [`tecs.audio.fm`](tecs.audio.fm) chip +for each chip the log names, on the clock the log gives, loads the memory +images the log carries, and replays the writes on time. + +## VGM and VGZ + +A VGM file is read up to version 1.72, and a VGZ file is a gzip-compressed VGM +file, inflated through `nupp.compression`. The format is told by the file's +first bytes rather than its name. + +Every Yamaha chip VGM names is played: the YM2413 (and the VRC7 its clock's +top bit selects), the YM2612 (or the YM3438), the YM2151 (or the YM2164), the +YM2203, the YM2608, the YM2610 (or the YM2610B), the YM3812, the YM3526, the +Y8950, the YMF262 and the YMF278B, and the AY-3-8910 family as a YM2149, two +of each where the header's second-chip bit says so. Their ROM and RAM data +blocks become the chips' memory images. The YM2612's DAC data is played +through the `0x8n` commands and the DAC streams, both turned into register +writes at their times when the file is read; compressed data blocks are +decompressed first. + +Every other chip, the SN76489 beside most YM2612s among them, is skipped, and +`skipped` says why for each. A file that names nothing this module can play is +refused. The AY8930 is skipped too: its expanded mode is not the YM2149's. + +## DRO + +A DOSBox Raw OPL capture is read in version 1, from DOSBox 0.61, and version 2, +from 0.73 on: one OPL2, one OPL3, or two OPL2s, which a Sound Blaster Pro wired +one to each speaker and which play the same way here. + +## Errors + +A file that is neither format, a header that does not fit, a command cut short +by the end of the file and a command VGM does not define are refused with the +byte offset where reading stopped. A file is at most 64 MiB, inflated. +]] + +local assets = require("tecs.assets") +local audio = require("tecs.audio") +local chipfm = require("tecs.audio.fm") +local compression = require("nupp.compression") +local dro = require("tecs.internal.dro") +local music = require("tecs.audio.music") +local registerlog = require("tecs.internal.registerlog") +local vgm = require("tecs.internal.vgm") + +const byte = string.byte + +--- The largest file `parse` reads, in bytes, after inflating. +local MAX_BYTES: integer = 64 * 1024 * 1024 + +--- Commands one readiness-pump pass reads before it lets the frame go on. +local PARSE_BUDGET: integer = 65536 + +--- Seconds a chip keeps sounding after the last write of a finished log. +local TAIL: number = 1 + +--- The most writes one batch carries: the chip's queue bound. +local BATCH: integer = 4096 + +--- The format a log was read from. +export type Format = "vgm" | "dro" + +--- One chip a log writes to. +export record LogChip + --- Read-only. Names the chip as `tecs.audio.fm.new` takes it. + chip: chipfm.Chip + + --- Read-only. Reports the chip's input clock in hertz. + clock: integer + + --- Read-only. Reports whether this is the second chip of its kind in the + --- file. + second: boolean + + --- Read-only. Reports the chip's gain on the left side, one for a chip in + --- the middle. + left: number + + --- Read-only. Reports the chip's gain on the right side. + right: number + + --- Read-only. Holds the memory images the log's data blocks give the + --- chip, by class. + memory: {[string]: string} +end + +--- The tags a VGM file's GD3 block carries. Each is the empty string when the +--- file leaves it out. +export record Tags + --- Read-only. Reports the track's name. + title: string + + --- Read-only. Reports the track's name in Japanese. + titleJapanese: string + + --- Read-only. Reports the game's name. + game: string + + --- Read-only. Reports the game's name in Japanese. + gameJapanese: string + + --- Read-only. Reports the system's name. + system: string + + --- Read-only. Reports the system's name in Japanese. + systemJapanese: string + + --- Read-only. Reports the composer. + author: string + + --- Read-only. Reports the composer in Japanese. + authorJapanese: string + + --- Read-only. Reports the release date. + date: string + + --- Read-only. Reports who made the file. + converter: string + + --- Read-only. Holds the file's notes. + notes: string +end + +--- A song as the writes a driver made to its chips. +export record RegisterLog + --- Read-only. Reports the format it was read from. + format: Format + + --- Read-only. Reports the format's version: a VGM version as its BCD + --- number, 0x171 for 1.71, and 1 or 2 for DRO. + version: integer + + --- Read-only. Reports the ticks a second its times count in: 44100 for + --- VGM and 1000 for DRO. + rate: integer + + --- Read-only. Reports the log's length in ticks. + length: integer + + --- Read-only. Reports the log's length in seconds. + duration: number + + --- Read-only. Reports where the log loops back to, in seconds, or nil. + loopStart: number? + + --- Read-only. Lists the chips the log plays, in the order its writes + --- number them from zero. + chips: {LogChip} + + --- Read-only. Reports how many writes the log holds. + count: integer + + --- Read-only. Holds the VGM file's GD3 tags, or nil. + tags: Tags? + + --- Read-only. Lists what the file holds that is not played, one reason + --- each: a chip other than a Yamaha FM chip, or memory for one. + skipped: {string} + + @private ticks: {integer} + @private ops: {integer} + @private loopIndex: integer + + entry: function(borrows self: RegisterLog, index: integer): (integer, integer, integer, integer) +end + +--- Returns one write of the log. +--- +--- @param self the log to read +--- @param index from one to `count` +--- @return the write's tick, its chip numbered from zero, its register and its +--- value +--- @raises string when the index is outside the log +function RegisterLog.entry(borrows self: RegisterLog, index: integer): (integer, integer, integer, integer) + if index < 1 or index > self.count then + error( + "tecs.audio.music.fm.RegisterLog.entry: the index must be from 1 to " .. tostring( + self.count + ) .. ", got " .. tostring(index), + 2 + ) + end + local op = self.ops[index] + local chip = op // registerlog.CHIP_SHIFT + local rest = op % registerlog.CHIP_SHIFT + + return self.ticks[index], chip, rest // registerlog.REGISTER_SHIFT, rest % registerlog.REGISTER_SHIFT +end + +--- Turns a finished builder into a log, or refuses one with nothing to play. +local function finish(builder: registerlog.Builder, name: string): (RegisterLog?, string?) + if builder.count == 0 then + local reason = name .. ": nothing in the file is for a chip tecs.audio.fm emulates" + if #builder.skipped > 0 then + reason = reason .. " (" .. table.concat(builder.skipped, "; ") .. ")" + end + + return nil, reason + end + local chips: {LogChip} = {} + for index, chip in ipairs(builder.chips) do + local memory: {[string]: string} = {} + for class, pieces in pairs(chip.pieces) do + memory[class] = registerlog.image(pieces) + end + chips[ + index + ] = new LogChip( + chip = chip.model as chipfm.Chip, + clock = chip.clock, + second = chip.second, + left = chip.left, + right = chip.right, + memory = memory + ) + end + local tags: Tags? = nil + local fields = builder.tags + if fields ~= nil then + tags = new Tags( + title = fields[1] or "", + titleJapanese = fields[2] or "", + game = fields[3] or "", + gameJapanese = fields[4] or "", + system = fields[5] or "", + systemJapanese = fields[6] or "", + author = fields[7] or "", + authorJapanese = fields[8] or "", + date = fields[9] or "", + converter = fields[10] or "", + notes = fields[11] or "" + ) + end + local rate = builder.rate + local loopTick = builder.loopTick + local length = math.max(builder.length, builder.ticks[builder.count]) + + return new RegisterLog( + format = builder.format as Format, + version = builder.version, + rate = rate, + length = length, + duration = length / rate, + loopStart = loopTick ~= nil and loopTick / rate or nil, + chips = chips, + count = builder.count, + tags = tags, + skipped = builder.skipped, + ticks = builder.ticks, + ops = builder.ops, + loopIndex = builder.loopIndex or 1 + ) +end + +--- Inflates a VGZ file, or passes anything else through. +local function inflate(bytes: string, name: string): (string?, string?) + if byte(bytes, 1) ~= 0x1F or byte(bytes, 2) ~= 0x8B then + if #bytes > MAX_BYTES then + return nil, name .. ": a register log is at most 64 MiB, and this one is " .. tostring(#bytes) .. " bytes" + end + + return bytes + end + local ok, result = pcall(function(): string + return compression.format("gzip"):decompress(bytes, {maxOutputBytes = MAX_BYTES}) + end) + if not ok then + return nil, name .. ": the gzip stream cannot be inflated: " .. tostring(result) + end + + return result as string +end + +--- Begins reading a log, inflating it first, or returns why it is refused. +local function begin(bytes: string, name: string): (registerlog.Parser?, string?) + local plain, reason = inflate(bytes, name) + if plain == nil then + return nil, reason + end + local text = assert(plain) + if string.sub(text, 1, 4) == "Vgm " then + return vgm.parser(text, name) + elseif string.sub(text, 1, 8) == "DBRAWOPL" then + return dro.parser(text, name) + end + + return nil, name .. ": neither a VGM, a VGZ nor a DRO file" +end + +--- Parses the bytes of a whole VGM, VGZ or DRO file. +--- +--- @param bytes the file, which is not retained once the log is built +--- @param name what a refusal names the file as, and defaults to +--- `"register log"` +--- @return the log, or nil when the bytes are refused +--- @return the reason, naming the byte where reading stopped +export function parse(bytes: string, name: string?): (RegisterLog?, string?) + local label = name or "register log" + local parser, reason = begin(bytes, label) + if parser == nil then + return nil, reason + end + local running = assert(parser) + while true do + local status, builder, failure = running.step(PARSE_BUDGET) + local outcome: string = status + if outcome == "ready" then + return finish(assert(builder), label) + elseif outcome == "failed" then + return nil, failure + end + end +end + +--- Loads a VGM, VGZ or DRO file as a +--- [`RegisterLog`](tecs.audio.music.fm.RegisterLog). +--- +--- The file is read, inflated and parsed on the loader's readiness pump, up to +--- 65 536 commands a pass, so `handle:value() as +--- tecs.audio.music.fm.RegisterLog` is the cast its caller writes. The +--- collector owns the log. +export const kind: assets.Kind = new assets.Kind( + name = assets.MUSIC_FM, + open = function(path: string): (assets.Job?, string?) + local read, reason = assets.readJob(path) + if read == nil then + return nil, reason + end + local parser: registerlog.Parser? = nil + + return new assets.Job( + poll = function(): (assets.Status, any?, string?) + local current = parser + if current == nil then + local status, bytes, failure = read.poll() + local reading: string = status + if reading ~= "ready" then + return status, nil, failure + end + local begun, refused = begin(bytes as string, path) + if begun == nil then + return "failed", nil, refused + end + parser = begun + + return "pending" + end + local status, builder, failure = current.step(PARSE_BUDGET) + local parsed: string = status + if parsed ~= "ready" then + return status, nil, failure + end + local log, refused = finish(assert(builder), path) + if log == nil then + return "failed", nil, refused + end + + return "ready", log + end, + cancel = function(): nil + read.cancel() + parser = nil + end + ) + end, + release = nil +) + +--- Makes one chip for each chip a log names, or destroys what it made and +--- returns why it could not. +local function makeChips(mixer: audio.Audio, log: RegisterLog, options: music.PlayOptions?): ({chipfm.Fm}?, string?) + local made: {chipfm.Fm} = {} + for index, entry in ipairs(log.chips) do + local chip, reason = chipfm.new(mixer, { + chip = entry.chip, + clock = math.max(100000, math.min(entry.clock, 100000000)), + group = options and options.group, + gain = options and options.gain, + }) + if chip == nil then + for _, other in ipairs(made) do + other:destroy() + end + + return nil, reason + end + local fm = assert(chip) + if entry.left ~= 1 or entry.right ~= 1 then + fm:setStereo(entry.left, entry.right) + end + for class, image in pairs(entry.memory) do + if #image > 0 then + fm:setMemory(class as chipfm.Memory, image) + end + end + made[index] = fm + end + + return made +end + +--- Reports whether two logs name the same chips on the same clocks. +local function sameChips(left: RegisterLog, right: RegisterLog): boolean + if #left.chips ~= #right.chips then + return false + end + for index, chip in ipairs(left.chips) do + local other = right.chips[index] + if chip.chip ~= other.chip or chip.clock ~= other.clock then + return false + end + end + + return true +end + +--- Returns the index of the first write at or after `tick`. +local function locate(log: RegisterLog, tick: number): integer + local ticks = log.ticks + local low: integer, high: integer = 1, log.count + 1 + while low < high do + local middle = (low + high) // 2 + if ticks[middle] < tick then + low = middle + 1 + else + high = middle + end + end + + return low +end + +--- Builds the driver that plays a log on its chips. +local function driver( + mixer: audio.Audio, + first: RegisterLog, + made: {chipfm.Fm}, + options: music.PlayOptions? +): music.Driver + local log = first + local chips = made + local index: integer = 1 + local gain = (options and options.gain) or 1 + local muted: {[integer]: boolean} = {} + local scratch: {integer} = {} + local filled: integer = 0 + local result: music.Driver + + --- Sends the writes `from` to `to` of one chip, all at `at`. + local function send(chip: integer, from: integer, to: integer, at: number): boolean + local count: integer = 0 + for position = from, to do + local op = log.ops[position] % registerlog.CHIP_SHIFT + scratch[count * 2 + 1] = op // registerlog.REGISTER_SHIFT + scratch[count * 2 + 2] = op % registerlog.REGISTER_SHIFT + count = count + 1 + end + for position = count * 2 + 1, filled do + scratch[position] = nil + end + filled = count * 2 + + return chips[chip + 1]:writes(scratch, at) + end + + local function seconds(tick: integer): number + return tick / log.rate + end + + result = new music.Driver( + duration = log.duration, + loopStart = log.loopStart, + loopEnd = nil, + tail = TAIL, + now = function(): number + return chips[1]:now() + end, + schedule = function(to: number, song: number, clock: number, tempo: number): number + local ticks = log.ticks + local ops = log.ops + local count = log.count + local limit = to * log.rate + while index <= count do + local tick = ticks[index] + if tick >= limit then + return to + end + local chip = ops[index] // registerlog.CHIP_SHIFT + -- The run of writes at this tick to this chip, as one batch. + local last = index + while last < count + and last - index + 1 < BATCH + and ticks[last + 1] == tick + and ops[last + 1] // registerlog.CHIP_SHIFT == chip do + last = last + 1 + end + local at = clock + (seconds(tick) - song) / tempo + if not send(chip, index, last, at) then + return seconds(tick) + end + index = last + 1 + end + + return to + end, + pending = function(): number? + if index > log.count then + return nil + end + + return seconds(log.ticks[index]) + end, + jump = function(target: number, _at: number): nil + if log.loopStart ~= nil and target == log.loopStart then + index = log.loopIndex + else + index = locate(log, target * log.rate) + end + end, + seek = function(target: number, at: number): nil + local stop = locate(log, target * log.rate) + local shadows: {{[integer]: integer}} = {} + for chip = 1, #chips do + shadows[chip] = {} + chips[chip]:reset(at) + end + for position = 1, stop - 1 do + local op = log.ops[position] + local chip = op // registerlog.CHIP_SHIFT + local rest = op % registerlog.CHIP_SHIFT + shadows[chip + 1][rest // registerlog.REGISTER_SHIFT] = rest % registerlog.REGISTER_SHIFT + end + -- Every register's last value, ascending, as one batch a chip: + -- at most 0x300 writes, which is under the queue's bound. + for chip = 1, #chips do + local shadow = shadows[chip] + local batch: {integer} = {} + for register = 0, chips[chip].lastRegister do + local value = shadow[register] + if value ~= nil then + batch[#batch + 1] = register + batch[#batch + 1] = value + end + end + chips[chip]:writes(batch, at) + end + index = stop + end, + reload = function(payload: any): (boolean, string?) + local incoming = payload as RegisterLog + if not sameChips(log, incoming) then + local fresh, reason = makeChips(mixer, incoming, options) + if fresh == nil then + return false, reason + end + for _, chip in ipairs(chips) do + chip:destroy() + end + chips = assert(fresh) + for chip, mute in pairs(muted) do + if mute and chips[chip + 1] ~= nil then + chips[chip + 1]:setGain(0) + end + end + else + for position, entry in ipairs(incoming.chips) do + for class, image in pairs(entry.memory) do + if #image > 0 then + chips[position]:setMemory(class as chipfm.Memory, image) + end + end + end + end + log = incoming + result.duration = log.duration + result.loopStart = log.loopStart + index = 1 + + return true + end, + pause = function(): nil + for _, chip in ipairs(chips) do + chip:pause() + end + end, + resume = function(): nil + for _, chip in ipairs(chips) do + chip:resume() + end + end, + setGain = function(value: number): nil + gain = value + for position, chip in ipairs(chips) do + if not muted[position - 1] then + chip:setGain(gain) + end + end + end, + setMuted = function(chip: integer, mute: boolean): nil + local target = chips[chip + 1] + if target == nil then + error( + "tecs.audio.music.Player.setMuted: the log has chips 0 to " .. tostring( + #chips - 1 + ) .. ", got " .. tostring(chip), + 3 + ) + end + if mute then + muted[chip] = true + else + muted[chip] = nil + end + target:setGain(mute and 0 or gain) + end, + stop = function(fadeOut: number?): nil + for _, chip in ipairs(chips) do + chip:destroy(fadeOut) + end + end + ) + + return result +end + +--- Plays a parsed log on chips of its own. +--- +--- @param mixer the mixer the chips play on +--- @param log the log, which the player reads and does not change +--- @param options the group, gain, loop, tempo, lookahead and start, or nil +--- for the defaults +--- @return the player, or nil when a chip could not start +--- @return the reason, when the first return is nil +--- @raises string when an option is out of range +export function playLog(mixer: audio.Audio, log: RegisterLog, options: music.PlayOptions?): (music.Player?, string?) + music.checkOptions("fm.playLog", options) + local chips, reason = makeChips(mixer, log, options) + if chips == nil then + return nil, reason + end + + return music.newPlayer(mixer, driver(mixer, log, assert(chips), options), nil, options) +end + +--- Plays a loaded log on chips of its own: one for each chip it names, on the +--- clock it gives, with its memory images loaded. +--- +--- The player retains the handle until it stops and follows a reload of it, +--- so the caller releases its own hold once this returns. Every chip takes a +--- voice, so a log of two chips needs two free voices. +--- +--- @param mixer the mixer the chips play on +--- @param handle a handle `kind` loaded +--- @param options the group, gain, loop, tempo, lookahead and start, or nil +--- for the defaults +--- @return the player, or nil when a chip could not start +--- @return the reason, when the first return is nil +--- @raises string when the handle was not loaded as a register log or has +--- been released +--- @raises string when an option is out of range +export function play(mixer: audio.Audio, handle: assets.Handle, options: music.PlayOptions?): (music.Player?, string?) + if handle.kind ~= assets.MUSIC_FM then + error("tecs.audio.music.fm.play: the handle was loaded as " .. handle.kind .. ", not as a register log", 2) + end + local payload = handle:value() + if payload == nil then + error("tecs.audio.music.fm.play: the handle has been released", 2) + end + music.checkOptions("fm.play", options) + local log = payload as RegisterLog + local chips, reason = makeChips(mixer, log, options) + if chips == nil then + return nil, reason + end + + return music.newPlayer(mixer, driver(mixer, log, assert(chips), options), handle, options) +end diff --git a/src/tecs/audio/music/init.nupp b/src/tecs/audio/music/init.nupp new file mode 100644 index 00000000..35a799e5 --- /dev/null +++ b/src/tecs/audio/music/init.nupp @@ -0,0 +1,661 @@ +module tecs.audio.music + +--[[ +Songs played on the mixer's synthesizer voices: Standard MIDI Files on the +General MIDI synth, and register logs on emulated FM chips. + +Two asset kinds load them through [`Assets`](tecs.assets.Assets), and a +[`Player`](tecs.audio.music.Player) plays a loaded song: + +```nupp +local mixer = tecs.audio.newAudio() +local font = assert(tecs.audio.synth.loadSoundFont(mixer, "assets/music/FluidR3_GM.sf2")) +local song = assert(app.assets:request(tecs.audio.music.midi.kind, "assets/music/theme.mid")) +local player = assert(tecs.audio.music.midi.play(mixer, font, song, {group = "music"})) +song:release() -- the player holds the song for as long as it plays + +local log = assert(app.assets:request(tecs.audio.music.fm.kind, "assets/music/stage1.vgz")) +local chiptune = assert(tecs.audio.music.fm.play(mixer, log, {group = "music"})) +log:release() +``` + +[`tecs.audio.music.midi`](tecs.audio.music.midi) reads `.mid` files, format +0, 1 and 2. [`tecs.audio.music.fm`](tecs.audio.music.fm) reads VGM, its +gzip-compressed form VGZ, and the DOSBox Raw OPL captures `.dro`, versions 1 +and 2. Both parse in Nupp on the loader's readiness pump, a slice at a time, so +a large file loads across frames rather than stalling one. + +## Playing + +A player owns the voices it plays through, a synth for a MIDI song and one +chip for each chip a register log names, and frees them when it stops. It +holds the song handle it was given until then, so the caller releases its own +hold as soon as `play` returns. Every mixer `update` advances every player +made on that mixer, so a game that already drives its mixer drives its music +too, and keeps doing so while its world is paused. + +The player schedules ahead on the voices' own clock: each update sends what +falls due within `lookahead` seconds of `now`, stamped with the time it is due +at, so the music keeps its timing whatever the frame rate does. After a stall +longer than the lookahead, the song slips rather than rushing to catch up: the +first event the stall made late plays at once, and everything after it keeps +its distance from it, rather than everything missed playing together. A stall +over a rest in the music changes nothing. A send the voice's bounded queue +refuses is retried on the next update. + +## Looping, tempo and position + +A song with a loop point loops forever unless `loop` says otherwise: a MIDI +file through a `loopStart` and `loopEnd` marker or controller 111, a VGM file +through its loop offset. A song without one plays once and then reports +`finished`. `setTempo` scales the speed: two plays twice as fast. On a MIDI +song that is the tempo; on a register log it is the rate the writes arrive at, +so notes keep their pitch but envelopes run at the chip's own speed. + +`seek` moves to a time in the song. A MIDI player resets the synth and sends +the programs, controllers and pitch bends in force at that point; a register +log resets each chip and writes the last value of every register before it. A +note held across the point comes back on a MIDI song's next note on, and on a +chip with the envelope restarted. + +## Reload + +A song handle reloaded through `Assets.reload` advances its revision, and a +player watching it rebuilds itself on the new song at the position it had +reached. A register log whose chips changed makes new chips. + +## Other formats + +A player is driven through a [`Driver`](tecs.audio.music.Driver), which is all +a format module supplies. A game that has a format of its own can write one +and play it with `newPlayer`. +]] + +local assets = require("tecs.assets") +local audio = require("tecs.audio") +local audiosynth = require("tecs.internal.audiosynth") + +--- Seconds of song a player keeps scheduled ahead of the voice clock unless +--- asked otherwise: two or three frames' worth, which absorbs an ordinary +--- hitch. +local DEFAULT_LOOKAHEAD: number = 0.05 + +--- The range `lookahead` may take, in seconds. +local MIN_LOOKAHEAD: number = 0.005 + +local MAX_LOOKAHEAD: number = 2 + +--- Seconds after `now` a start, a seek or a slip lands at, so its first +--- events are scheduled rather than late. +local LEAD: number = 0.01 + +--- The shortest loop a player repeats, in seconds of song. A loop point that +--- leaves less than this is ignored, because repeating it would spin. +local MIN_LOOP: number = 0.001 + +--- The range `setTempo` takes. +local MIN_TEMPO: number = 0.01 + +local MAX_TEMPO: number = 100 + +--- Where a player stands. +export type State = "playing" | "paused" | "finished" | "stopped" + +--- What a format module supplies to a [`Player`](tecs.audio.music.Player): +--- the voices a song plays on and the song's events, behind the operations a +--- player needs. +--- +--- Song time is seconds from the start of the song at a tempo of one. Clock +--- time is seconds on the voices' own clock, which `now` reads. A player maps +--- one to the other with an anchor: song time `song` is clock time `clock`, +--- and `tempo` song seconds pass for each clock second. +export record Driver + --- Read-only. Reports the song's length in seconds. + duration: number + + --- Read-only. Reports where the song loops back to, in seconds, or nil for + --- a song without a loop. + loopStart: number? + + --- Read-only. Reports where a loop ends and the song goes back to + --- `loopStart`, in seconds, or nil for the end of the song. + loopEnd: number? + + --- Read-only. Reports the seconds a voice keeps sounding after the last + --- event, for releases and reverb, before a finished song frees it. + tail: number + + --- Read-only. Reports the voices' clock in seconds. + now: function(): number + + --- Read-only. Sends every event due at or after the driver's cursor and + --- before song time `to`, each at clock time + --- `clock + (time - song) / tempo`, and returns the song time it reached: + --- `to`, or the time of the first event a voice refused, which the next + --- call starts from. + schedule: function(to: number, song: number, clock: number, tempo: number): number + + --- Read-only. Reports the song time of the first event the cursor has not + --- sent, or nil when every event has gone. + pending: function(): number? + + --- Read-only. Moves the cursor back to song time `target` for a loop, + --- after ending what still sounds at clock time `at`. + jump: function(target: number, at: number): nil + + --- Read-only. Moves the cursor to song time `target` from anywhere, + --- putting the voices in the state the song had there, at clock time `at`. + seek: function(target: number, at: number): nil + + --- Read-only. Replaces the song with the payload of a reloaded handle, + --- and returns false and a reason when it cannot. + reload: function(payload: any): (boolean, string?) + + --- Read-only. Pauses every voice, which stops its clock. + pause: function(): nil + + --- Read-only. Resumes every voice. + resume: function(): nil + + --- Read-only. Sets the gain of every voice. + setGain: function(gain: number): nil + + --- Read-only. Mutes or unmutes one part of the song: a MIDI channel or a + --- chip, numbered from zero. + setMuted: function(index: integer, muted: boolean): nil + + --- Read-only. Stops and frees every voice, over `fadeOut` seconds when + --- that is given. + stop: function(fadeOut: number?): nil +end + +--- Configures a player. The format modules' `play` functions take it too. +export type PlayOptions = { + --- Caller-writable. Selects the group the player's voices join, so the + --- group's gain, mute and pause apply to them. Defaults to none. + group: string?, + + --- Caller-writable. Sets the linear gain of the player's voices, before + --- group and master gain, and defaults to one. + gain: number?, + + --- Caller-writable. Repeats the song from its loop point. Defaults to true + --- when the song has one, and is ignored when it has none. + loop: boolean?, + + --- Caller-writable. Scales the speed, from 0.01 to 100, and defaults to + --- one. + tempo: number?, + + --- Caller-writable. Sets the seconds the player schedules ahead, from + --- 0.005 to 2, and defaults to 0.05. + lookahead: number?, + + --- Caller-writable. Sets where in the song playback starts, in seconds, + --- and defaults to zero. + start: number? +} + +--- A song playing on voices of its own. +--- +--- Made by `tecs.audio.music.midi.play`, `tecs.audio.music.fm.play` or +--- `newPlayer`, advanced by the mixer's `update`, and finished with `stop`. +--- Nothing here raises for a player that has stopped: every call on one +--- does nothing. +export record Player + @private driver: Driver + @private handle: assets.Handle? + @private revision: integer + @private state: State + @private loop: boolean + @private tempoScale: number + @private lookahead: number + @private cursor: number + @private anchorSong: number + @private anchorClock: number + @private previousSong: number + @private previousClock: number + @private finishClock: number + @private loopCount: integer + @private completed: boolean + @private failure: string? + + stop: function(exclusive self: Player, fadeOut: number?): nil + pause: function(exclusive self: Player): nil + resume: function(exclusive self: Player): nil + status: function(borrows self: Player): State + playing: function(borrows self: Player): boolean + paused: function(borrows self: Player): boolean + finished: function(borrows self: Player): boolean + position: function(borrows self: Player): number + duration: function(borrows self: Player): number + seek: function(exclusive self: Player, seconds: number): nil + setLoop: function(exclusive self: Player, loop: boolean): nil + looping: function(borrows self: Player): boolean + loops: function(borrows self: Player): integer + setTempo: function(exclusive self: Player, scale: number): nil + tempo: function(borrows self: Player): number + setGain: function(exclusive self: Player, gain: number): nil + setMuted: function(exclusive self: Player, index: integer, muted: boolean): nil + reloadFailure: function(borrows self: Player): string? + update: function(exclusive self: Player): boolean +end + +--- Reports whether a player stands in `state`, as an expression the checker +--- does not read as a match over every state. +local function standing(borrows self: Player, state: State): boolean + return self.state == state +end + +--- Returns the song's loop as a start and an end, or nil when it has none +--- worth repeating. +local function loopOf(driver: Driver): (number?, number) + local start = driver.loopStart + local finish = driver.loopEnd or driver.duration + if start == nil or finish - start < MIN_LOOP then + return nil, driver.duration + end + + return start, finish +end + +--- Returns the clock time song time `song` is scheduled at. +local function clockOf(borrows self: Player, song: number): number + return self.anchorClock + (song - self.anchorSong) / self.tempoScale +end + +--- Moves the anchor, keeping the one it replaces for `position` while the +--- clock has not reached the new one. +local function anchor(exclusive self: Player, song: number, clock: number): nil + self.previousSong = self.anchorSong + self.previousClock = self.anchorClock + self.anchorSong = song + self.anchorClock = clock +end + +--- Checks an options value against a range. +--- @raises string when the value is outside the range +local function within(name: string, what: string, value: number, low: number, high: number): nil + if value ~= value or value < low or value > high then + error( + "tecs.audio.music." .. name .. ": " .. what .. " must be from " .. tostring( + low + ) .. " to " .. tostring(high) .. ", got " .. tostring(value), + 3 + ) + end +end + +--- Raises unless play options are in range. +--- @raises string when `tempo`, `lookahead` or `start` is out of range +export function checkOptions(name: string, options: PlayOptions?): nil + if options == nil then + return + end + if options.tempo ~= nil then + within(name, "tempo", options.tempo, MIN_TEMPO, MAX_TEMPO) + end + if options.lookahead ~= nil then + within(name, "lookahead", options.lookahead, MIN_LOOKAHEAD, MAX_LOOKAHEAD) + end + if options.start ~= nil and (options.start ~= options.start or options.start < 0) then + error("tecs.audio.music." .. name .. ": start must not be negative, got " .. tostring(options.start), 2) + end +end + +--- Starts a player on a driver and registers it with the mixer's update. +--- +--- What the format modules' `play` functions end in, and what a game with a +--- format of its own calls with a driver it wrote. +--- +--- @param mixer the mixer whose `update` advances the player +--- @param driver the song and its voices, which the player now owns +--- @param handle the song's handle, which the player retains until it stops +--- and watches for a reload, or nil for a song held some other way +--- @param options the loop, tempo, lookahead and start, or nil for the +--- defaults. `group` and `gain` are the driver's to apply. +--- @return the playing player +--- @raises string when an option is out of range +export function newPlayer(mixer: audio.Audio, driver: Driver, handle: assets.Handle?, options: PlayOptions?): Player + checkOptions("newPlayer", options) + local start = math.min((options and options.start) or 0, driver.duration) + local clock = driver.now() + LEAD + local player = new Player( + driver = driver, + handle = handle, + revision = handle ~= nil and handle:revision() or 0, + state = "playing", + loop = not (options ~= nil and options.loop == false), + tempoScale = (options and options.tempo) or 1, + lookahead = (options and options.lookahead) or DEFAULT_LOOKAHEAD, + cursor = start, + anchorSong = start, + anchorClock = clock, + previousSong = start, + previousClock = clock, + finishClock = 0, + loopCount = 0, + completed = false, + failure = nil + ) + if handle ~= nil then + handle:retain() + end + if start > 0 then + driver.seek(start, clock) + end + local updates = audiosynth.updates[mixer] + if updates == nil then + updates = {} + audiosynth.updates[mixer] = updates + end + updates[#updates + 1] = function(): boolean + return player:update() + end + player:update() + + return player +end + +--- Stops the song and frees its voices. Safe more than once. +--- +--- @param self the player to stop +--- @param fadeOut seconds to fade the voices out over, or nil to cut them +function Player.stop(exclusive self: Player, fadeOut: number?): nil + if standing(self, "stopped") then + return + end + self.state = "stopped" + self.driver.stop(fadeOut) + local handle = self.handle + if handle ~= nil then + self.handle = nil + handle:release() + end +end + +--- Pauses the song where it stands, notes held. +--- +--- The voices pause, which stops their clock, so what was scheduled waits and +--- `resume` carries on exactly where the song was. +--- +--- @param self the player to pause +function Player.pause(exclusive self: Player): nil + if not standing(self, "playing") then + return + end + self.state = "paused" + self.driver.pause() +end + +--- Resumes a paused song. +--- +--- @param self the player to resume +function Player.resume(exclusive self: Player): nil + if not standing(self, "paused") then + return + end + self.state = "playing" + self.driver.resume() +end + +--- Reports where the player stands. +--- +--- @param self the player to inspect +--- @return `"playing"`, `"paused"`, `"finished"` while a song that does not +--- loop rings out after its last event, or `"stopped"` once it has, or +--- once `stop` ran +function Player.status(borrows self: Player): State + return self.state +end + +--- Reports whether the song is playing. +--- +--- @param self the player to inspect +--- @return true while playing, false paused, finished or stopped +function Player.playing(borrows self: Player): boolean + return self.state == "playing" +end + +--- Reports whether the song is paused. +--- +--- @param self the player to inspect +--- @return true between `pause` and `resume` +function Player.paused(borrows self: Player): boolean + return self.state == "paused" +end + +--- Reports whether a song that does not loop has played to its end. +--- +--- The voices keep sounding for the song's release tail, while `status` reads +--- `"finished"` and a `seek` can still play the song again, and then the +--- player stops itself and frees them. This stays true through that. +--- +--- @param self the player to inspect +--- @return true once the last event has played, until a `seek` plays on +function Player.finished(borrows self: Player): boolean + return self.completed +end + +--- Returns how far into the song the voices are. +--- +--- @param self the player to inspect +--- @return seconds of song at a tempo of one, from zero to the duration +function Player.position(borrows self: Player): number + local now = self.driver.now() + local song: number + if now >= self.anchorClock then + song = self.anchorSong + (now - self.anchorClock) * self.tempoScale + else + song = self.previousSong + (now - self.previousClock) * self.tempoScale + end + if song < 0 then + return 0 + end + + return math.min(song, self.driver.duration) +end + +--- Returns the song's length. +--- +--- @param self the player to inspect +--- @return seconds at a tempo of one +function Player.duration(borrows self: Player): number + return self.driver.duration +end + +--- Moves playback to a time in the song. +--- +--- A finished song plays again from there. +--- +--- @param self the player to move +--- @param seconds the song time, clamped to the song +function Player.seek(exclusive self: Player, seconds: number): nil + if standing(self, "stopped") then + return + end + local target = math.max(0, math.min(seconds, self.driver.duration)) + local at = self.driver.now() + LEAD + self.driver.seek(target, at) + anchor(self, target, at) + self.previousSong = target + self.previousClock = at + self.cursor = target + if self.state == "finished" then + self.state = "playing" + end + self.completed = false +end + +--- Sets whether the song repeats from its loop point. +--- +--- @param self the player to change +--- @param loop true to repeat, false to play on to the end +function Player.setLoop(exclusive self: Player, loop: boolean): nil + self.loop = loop +end + +--- Reports whether the song will repeat. +--- +--- @param self the player to inspect +--- @return true when looping is on and the song has a loop point +function Player.looping(borrows self: Player): boolean + local start = loopOf(self.driver) + + return self.loop and start ~= nil +end + +--- Returns how many times the song has gone back to its loop point. +--- +--- @param self the player to inspect +--- @return the count of loops scheduled so far +function Player.loops(borrows self: Player): integer + return self.loopCount +end + +--- Scales the playback speed from the point already scheduled. +--- +--- @param self the player to change +--- @param scale from 0.01 to 100, where one is the song's own speed +--- @raises string when the scale is outside its range +function Player.setTempo(exclusive self: Player, scale: number): nil + within("setTempo", "tempo", scale, MIN_TEMPO, MAX_TEMPO) + anchor(self, self.cursor, clockOf(self, self.cursor)) + self.tempoScale = scale +end + +--- Returns the speed scale. +--- +--- @param self the player to inspect +--- @return the scale, one by default +function Player.tempo(borrows self: Player): number + return self.tempoScale +end + +--- Sets the gain of the player's voices, before group and master gain. +--- +--- @param self the player to change +--- @param gain linear amplitude +function Player.setGain(exclusive self: Player, gain: number): nil + if standing(self, "stopped") then + return + end + self.driver.setGain(gain) +end + +--- Mutes or unmutes one part of the song. +--- +--- @param self the player to change +--- @param index a MIDI channel from 0 to 15, or a register log's chip from 0 +--- @param muted true to silence it +function Player.setMuted(exclusive self: Player, index: integer, muted: boolean): nil + if standing(self, "stopped") then + return + end + self.driver.setMuted(index, muted) +end + +--- Returns why the last reload of the song could not be played, if one +--- could not. +--- +--- A reload the driver cannot take leaves the player on the song it had. +--- +--- @param self the player to inspect +--- @return the reason, or nil +function Player.reloadFailure(borrows self: Player): string? + return self.failure +end + +--- Advances the player: what the mixer's `update` calls. +--- +--- Ordinary game code does not call it. A test driving a player without a +--- mixer update does. +--- +--- @param self the player to advance +--- @return false once the player has stopped, which drops it from the update +function Player.update(exclusive self: Player): boolean + if standing(self, "stopped") then + return false + end + local handle = self.handle + if handle ~= nil and handle:revision() ~= self.revision then + self.revision = handle:revision() + local payload = handle:value() + if payload ~= nil then + local position = self:position() + local reloaded, reason = self.driver.reload(payload) + if reloaded then + self.failure = nil + self:seek(position) + else + self.failure = reason or "the reloaded song could not be played" + end + end + end + if standing(self, "paused") then + return true + end + local driver = self.driver + local now = driver.now() + if standing(self, "finished") then + if now >= self.finishClock + driver.tail then + self:stop() + + return false + end + + return true + end + local next = driver.pending() + if next ~= nil and clockOf(self, next) < now then + -- A stall outran the lookahead and an event is already late: play it + -- now, and what follows it at its own distance, rather than rushing + -- everything that was missed. + anchor(self, next, now + LEAD) + self.cursor = math.max(self.cursor, next) + end + local horizon = now + self.lookahead + -- Bounded, so a loop shorter than one update's window cannot spin. + for _ = 1, 64 do + local loopStart, loopEnd = loopOf(driver) + local looping = self.loop and loopStart ~= nil + local finish = looping and loopEnd or driver.duration + local target = self.anchorSong + (horizon - self.anchorClock) * self.tempoScale + if target < finish then + self.cursor = driver.schedule(target, self.anchorSong, self.anchorClock, self.tempoScale) + + return true + end + -- A loop ends before the events at its end, which belong to the next + -- pass; the song's own end takes everything left, the note offs at + -- its last tick included. + local limit = looping and finish or math.huge + local reached = driver.schedule(limit, self.anchorSong, self.anchorClock, self.tempoScale) + if reached < limit then + self.cursor = reached + + return true + end + self.cursor = finish + local at = clockOf(self, finish) + if looping and loopStart ~= nil then + driver.jump(loopStart, at) + anchor(self, loopStart, at) + self.cursor = loopStart + self.loopCount = self.loopCount + 1 + else + self.state = "finished" + self.completed = true + self.finishClock = at + if now >= at + driver.tail then + self:stop() + + return false + end + + return true + end + end + + return true +end diff --git a/src/tecs/audio/music/midi.nupp b/src/tecs/audio/music/midi.nupp new file mode 100644 index 00000000..05e9b7f8 --- /dev/null +++ b/src/tecs/audio/music/midi.nupp @@ -0,0 +1,933 @@ +module tecs.audio.music.midi + +--[[ +Standard MIDI Files, loaded as assets and played on the General MIDI synth. + +```nupp +local font = assert(tecs.audio.synth.loadSoundFont(mixer, "assets/music/FluidR3_GM.sf2")) +local song = assert(app.assets:request(tecs.audio.music.midi.kind, "assets/music/theme.mid")) +local player = assert(tecs.audio.music.midi.play(mixer, font, song, {group = "music"})) +song:release() +``` + +`kind` loads a `.mid` file as a [`Song`](tecs.audio.music.midi.Song): its +header, its tracks merged into one list of timed events, its tempo map and its +loop points. `parse` does the same for bytes a game already holds. `play` +plays a loaded song on a synthesizer of its own, made on a SoundFont the game +loaded, and `playSong` plays a parsed one. + +## What is read + +Formats 0 and 1 are read as one song, every track at once. Format 2 holds +independent patterns rather than one song, and they are played one after +another, each starting where the previous one ended. Delta times, running +status, every channel message, system exclusive messages and meta events are +read; a chunk that is not a track is skipped, as the format asks. Time comes +from the tempo map, the tempo events of every track together, starting at 120 +beats a minute, or from the SMPTE frame rate of a file that counts in frames. + +System exclusive messages are kept in the song but not sent, because the synth +takes short messages only: a General MIDI, GS or XG reset at the start of a +file does nothing a fresh synth needs. Meta events are kept too and nothing is +done with them beyond the tempo map and the loop points. + +## Loop points + +A song loops where it says it does, in the two conventions a MIDI file has for +it. A marker meta event reading `loopStart`, and one reading `loopEnd`, case +and spaces aside, bracket the loop; controller 111 on any channel, the RPG +Maker convention, marks the start when there is no marker. A song with a start +and no end loops at its last event. A song with neither plays once. + +## Errors + +A file that is not a Standard MIDI File, one cut short inside a chunk, a +running-status byte with no status before it, a data byte with its top bit set +and a delta time longer than four bytes are refused with the byte offset where +the reading stopped. A file is at most 16 MiB. +]] + +local assets = require("tecs.assets") +local audio = require("tecs.audio") +local music = require("tecs.audio.music") +local synth = require("tecs.audio.synth") + +const byte = string.byte + +--- The largest file `parse` reads, in bytes. +local MAX_BYTES: integer = 16 * 1024 * 1024 + +--- Events one readiness-pump pass parses before it lets the frame go on. +local PARSE_BUDGET: integer = 65536 + +--- Microseconds a quarter note lasts before the first tempo event: 120 beats +--- a minute, as the format defines. +local DEFAULT_TEMPO: integer = 500000 + +--- Seconds a synth keeps sounding after the last event of a finished song, +--- for releases and reverb. +local TAIL: number = 2 + +--- The controller RPG Maker marks a loop's start with. +local LOOP_CONTROLLER: integer = 111 + +--- The meta event type of a marker. +local META_MARKER: integer = 0x06 + +--- The meta event type of a track name. +local META_NAME: integer = 0x03 + +--- The meta event type of a tempo. +local META_TEMPO: integer = 0x51 + +--- The meta event type of a track's end. +local META_END: integer = 0x2F + +--- One event of a song. +export record Event + --- Read-only. Reports the event's time in ticks from the song's start. + tick: integer + + --- Read-only. Reports the event's time in seconds from the song's start, + --- through the tempo map. + time: number + + --- Read-only. Reports the track the event came from, numbered from one. + track: integer + + --- Read-only. Reports the status byte: 0x80 to 0xEF for a channel + --- message, with the channel in its low four bits, 0xF0 or 0xF7 for a + --- system exclusive message, and 0xFF for a meta event. + status: integer + + --- Read-only. Reports the first data byte of a channel message, and zero + --- for anything else. + data1: integer + + --- Read-only. Reports the second data byte of a channel message, and zero + --- for one that carries a single byte and for anything else. + data2: integer + + --- Read-only. Reports the type of a meta event, and nil for anything + --- else. + meta: integer? + + --- Read-only. Holds the bytes of a meta event or of a system exclusive + --- message, and nil for a channel message. + data: string? +end + +--- One tempo in force from a point of the song. +export record Tempo + --- Read-only. Reports where it takes effect, in ticks. + tick: integer + + --- Read-only. Reports where it takes effect, in seconds. + time: number + + --- Read-only. Reports the microseconds a quarter note lasts. + microsecondsPerQuarter: integer +end + +--- One track as the file had it. +export record Track + --- Read-only. Reports the track's name meta event, or nil. + name: string? + + --- Read-only. Reports how many events the track holds, its end aside. + events: integer + + --- Read-only. Reports where the track ends, in ticks of the song, which + --- for format 2 includes the patterns before it. + endTick: integer +end + +--- A parsed Standard MIDI File. +export record Song + --- Read-only. Reports the file's format: 0, 1 or 2. + format: integer + + --- Read-only. Reports the ticks in a quarter note, or nil for a file + --- that counts in SMPTE frames. + ppq: integer? + + --- Read-only. Reports the SMPTE frames a second, 24, 25, 29.97 or 30, + --- for a file that counts in frames, and nil otherwise. + framesPerSecond: number? + + --- Read-only. Reports the ticks in an SMPTE frame, or nil. + ticksPerFrame: integer? + + --- Read-only. Lists the tracks in file order. + tracks: {Track} + + --- Read-only. Lists every event of every track, end-of-track events + --- aside, in time order, and in track order among events at one tick. + events: {Event} + + --- Read-only. Lists the tempo map in time order, starting with the tempo + --- at tick zero. + tempos: {Tempo} + + --- Read-only. Reports the song's length in seconds: the end of its + --- longest track. + duration: number + + --- Read-only. Reports where the song loops back to, in seconds, or nil. + loopStart: number? + + --- Read-only. Reports where its loop ends, in seconds, or nil for the end + --- of the song. + loopEnd: number? + + --- Read-only. Reports the first track's name, which is where a song's + --- title is kept, or nil. + title: string? +end + +--- One track being read. +local record Reading + track: integer + position: integer + finish: integer + tick: integer + offset: integer + running: integer + name: string? + count: integer +end + +--- A file being read a slice at a time. +local record Parser + bytes: string + name: string + format: integer + tracks: integer + division: integer + position: integer + reading: Reading? + events: {Event} + order: {integer} + infos: {Track} + patternEnd: integer + done: boolean +end + +local function u16(bytes: string, at: integer): integer + const high, low = byte(bytes, at, at + 1) + + return (high as integer) * 256 + (low as integer) +end + +local function u32(bytes: string, at: integer): integer + const a, b, c, d = byte(bytes, at, at + 3) + + return (((a as integer) * 256 + (b as integer)) * 256 + (c as integer)) * 256 + (d as integer) +end + +--- Returns `text` lowered with everything but letters and digits removed, +--- which is how a loop marker is compared. +local function squeezed(text: string): string + return (string.gsub(string.lower(text), "[^%w]", "")) +end + +--- Begins reading a file, or returns the reason its header is refused. +local function begin(bytes: string, name: string): (Parser?, string?) + if #bytes > MAX_BYTES then + return nil, name .. ": a MIDI file is at most 16 MiB, and this one is " .. tostring(#bytes) .. " bytes" + end + if #bytes < 14 or string.sub(bytes, 1, 4) ~= "MThd" then + return nil, name .. ": not a Standard MIDI File: it does not begin with an MThd chunk" + end + local length = u32(bytes, 5) + if length < 6 then + return nil, name .. ": the MThd chunk is " .. tostring(length) .. " bytes, and a MIDI header needs 6" + end + local format = u16(bytes, 9) + if format > 2 then + return nil, name .. ": format " .. tostring( + format + ) .. " is not a Standard MIDI File format, which are 0, 1 and 2" + end + local tracks = u16(bytes, 11) + if tracks == 0 then + return nil, name .. ": the header declares no tracks" + end + local division = u16(bytes, 13) + if division == 0 then + return nil, name .. ": the header's division is zero ticks" + end + if division >= 0x8000 then + local frames = 256 - division // 256 + if frames ~= 24 and frames ~= 25 and frames ~= 29 and frames ~= 30 then + return nil, name .. ": an SMPTE division of " .. tostring( + frames + ) .. " frames a second is not 24, 25, 29 or 30" + end + if division % 256 == 0 then + return nil, name .. ": an SMPTE division of zero ticks a frame" + end + end + + return new Parser( + bytes = bytes, + name = name, + format = format, + tracks = tracks, + division = division, + position = 9 + length, + reading = nil, + events = {}, + order = {}, + infos = {}, + patternEnd = 0, + done = false + ) +end + +--- Reads a variable-length quantity at `at`, and returns it and the position +--- after it, or nil when it runs past `finish` or past four bytes. +local function quantity(bytes: string, at: integer, finish: integer): (integer?, integer) + local value: integer = 0 + for count = 0, 3 do + local position = at + count + if position > finish then + return nil, position + end + local next = byte(bytes, position) as integer + value = value * 128 + next % 128 + if next < 128 then + return value, position + 1 + end + end + + return nil, -1 +end + +--- Moves to the next track chunk, skipping chunks that are not tracks, and +--- returns false when there is none. +local function nextTrack(exclusive self: Parser): boolean + local bytes = self.bytes + while self.position + 7 <= #bytes do + local id = string.sub(bytes, self.position, self.position + 3) + local length = u32(bytes, self.position + 4) + local start = self.position + 8 + self.position = start + length + if id == "MTrk" then + local finish = math.min(start + length - 1, #bytes) + local track = #self.infos + 1 + local offset = self.format == 2 and self.patternEnd or 0 + self.reading = new Reading( + track = track, + position = start, + finish = finish, + tick = offset, + offset = offset, + running = 0, + name = nil, + count = 0 + ) + + return true + end + end + + return false +end + +--- Ends the track being read at `tick`. +local function endTrack(exclusive self: Parser, reading: Reading): nil + self.infos[#self.infos + 1] = new Track(name = reading.name, events = reading.count, endTick = reading.tick) + if reading.tick > self.patternEnd then + self.patternEnd = reading.tick + end + self.reading = nil +end + +local function add(exclusive self: Parser, reading: Reading, event: Event): nil + local index = #self.events + 1 + self.events[index] = event + self.order[index] = index + reading.count = reading.count + 1 +end + +--- Reads up to `budget` events, and returns the reason the file is refused +--- if it is. +local function readEvents(exclusive self: Parser, budget: integer): string? + local bytes = self.bytes + local name = self.name + for _ = 1, budget do + local reading = self.reading + if reading == nil then + if not nextTrack(self) then + self.done = true + + return nil + end + reading = assert(self.reading) + end + local at = reading.position + local finish = reading.finish + const track = reading.track + local where = function(position: integer): string + return name .. ": track " .. tostring(track) .. " at byte " .. tostring(position - 1) + end + if at > finish then + -- A track without its end-of-track event ends where its chunk does. + endTrack(self, reading) + else + local delta, after = quantity(bytes, at, finish) + if delta == nil then + if after < 0 then + return where(at) .. ": a delta time longer than four bytes" + end + + return where(at) .. ": the track is cut short inside a delta time" + end + reading.tick = reading.tick + delta + at = after + if at > finish then + return where(at) .. ": the track is cut short after a delta time" + end + local status = byte(bytes, at) as integer + if status == 0xFF then + if at + 1 > finish then + return where(at) .. ": the track is cut short inside a meta event" + end + local meta = byte(bytes, at + 1) as integer + local length, start = quantity(bytes, at + 2, finish) + if length == nil or start + length - 1 > finish then + return where(at) .. ": the track is cut short inside a meta event" + end + local data = string.sub(bytes, start, start + length - 1) + reading.position = start + length + if meta == META_END then + endTrack(self, reading) + else + if meta == META_NAME and reading.name == nil then + reading.name = data + end + add( + self, + reading, + new Event( + tick = reading.tick, + time = 0, + track = reading.track, + status = 0xFF, + data1 = 0, + data2 = 0, + meta = meta, + data = data + ) + ) + end + elseif status == 0xF0 or status == 0xF7 then + local length, start = quantity(bytes, at + 1, finish) + if length == nil or start + length - 1 > finish then + return where(at) .. ": the track is cut short inside a system exclusive message" + end + reading.position = start + length + -- A system message cancels running status. + reading.running = 0 + add( + self, + reading, + new Event( + tick = reading.tick, + time = 0, + track = reading.track, + status = status, + data1 = 0, + data2 = 0, + meta = nil, + data = string.sub(bytes, start, start + length - 1) + ) + ) + elseif status >= 0xF1 then + return where(at) .. ": status " .. string.format("0x%02X", status) .. " is not allowed in a MIDI file" + else + local first = at + if status >= 0x80 then + reading.running = status + first = at + 1 + elseif reading.running == 0 then + return where(at) .. ": a data byte with no status before it" + else + status = reading.running + end + local nibble = status // 16 + local size = (nibble == 0xC or nibble == 0xD) and 1 or 2 + if first + size - 1 > finish then + return where(at) .. ": the track is cut short inside a channel message" + end + local data1 = byte(bytes, first) as integer + local data2 = size == 2 and byte(bytes, first + 1) as integer or 0 + if data1 >= 0x80 or data2 >= 0x80 then + return where(first) .. ": a data byte with its top bit set" + end + reading.position = first + size + add( + self, + reading, + new Event( + tick = reading.tick, + time = 0, + track = reading.track, + status = status, + data1 = data1, + data2 = data2, + meta = nil, + data = nil + ) + ) + end + end + end + + return nil +end + +--- Converts ticks to seconds through a tempo map, or through the SMPTE rate. +local function seconds(song: Song, tick: integer): number + local frames = song.framesPerSecond + if frames ~= nil then + return tick / (frames * assert(song.ticksPerFrame)) + end + local ppq = assert(song.ppq) + local tempos = song.tempos + local low: integer, high: integer = 1, #tempos + while low < high do + local middle = (low + high + 1) // 2 + if tempos[middle].tick <= tick then + low = middle + else + high = middle - 1 + end + end + local tempo = tempos[low] + + return tempo.time + (tick - tempo.tick) * tempo.microsecondsPerQuarter / (1000000 * ppq) +end + +--- Merges the tracks, times every event and finds the loop. +local function finish(exclusive self: Parser): Song + local events = self.events + local order = self.order + -- Stable by construction: events at one tick keep the order they were + -- read in, which is track order and then file order within a track. + table.sort(order, function(a: integer, b: integer): boolean + local left, right = events[a], events[b] + if left.tick ~= right.tick then + return left.tick < right.tick + end + + return a < b + end) + local merged: {Event} = {} + for index = 1, #order do + merged[index] = events[order[index]] + end + local ppq: integer? = nil + local framesPerSecond: number? = nil + local ticksPerFrame: integer? = nil + if self.division >= 0x8000 then + local frames = 256 - self.division // 256 + framesPerSecond = frames == 29 and 29.97 or frames + ticksPerFrame = self.division % 256 + else + ppq = self.division + end + local tempos: {Tempo} = {new Tempo(tick = 0, time = 0, microsecondsPerQuarter = DEFAULT_TEMPO)} + local song = new Song( + format = self.format, + ppq = ppq, + framesPerSecond = framesPerSecond, + ticksPerFrame = ticksPerFrame, + tracks = self.infos, + events = merged, + tempos = tempos, + duration = 0, + loopStart = nil, + loopEnd = nil, + title = self.infos[1] ~= nil and self.infos[1].name or nil + ) + local loopStart: number? = nil + local loopEnd: number? = nil + local controllerStart: number? = nil + for index = 1, #merged do + local event = merged[index] + event.time = seconds(song, event.tick) + if event.status == 0xFF then + local data = event.data or "" + if event.meta == META_TEMPO and #data == 3 and ppq ~= nil then + local micro = ( + byte(data, 1) as integer + ) * 65536 + (byte(data, 2) as integer) * 256 + (byte(data, 3) as integer) + if micro > 0 then + local last = tempos[#tempos] + if last.tick == event.tick then + last.microsecondsPerQuarter = micro + else + tempos[ + #tempos + 1 + ] = new Tempo(tick = event.tick, time = event.time, microsecondsPerQuarter = micro) + end + end + elseif event.meta == META_MARKER then + local marker = squeezed(data) + if marker == "loopstart" and loopStart == nil then + loopStart = event.time + elseif marker == "loopend" and loopEnd == nil then + loopEnd = event.time + end + end + elseif event.status // 16 == 0xB and event.data1 == LOOP_CONTROLLER and controllerStart == nil then + controllerStart = event.time + end + end + local endTick: integer = 0 + for _, track in ipairs(self.infos) do + if track.endTick > endTick then + endTick = track.endTick + end + end + song.duration = seconds(song, endTick) + song.loopStart = loopStart or controllerStart + if song.loopStart ~= nil and loopEnd ~= nil and loopEnd > assert(song.loopStart) then + song.loopEnd = loopEnd + end + + return song +end + +--- Advances a parse by up to `budget` events. +local function step(exclusive self: Parser, budget: integer): (assets.Status, Song?, string?) + local reason = readEvents(self, budget) + if reason ~= nil then + return "failed", nil, reason + end + if not self.done then + return "pending" + end + if #self.infos == 0 then + return "failed", nil, self.name .. ": the file holds no MTrk chunk" + end + + return "ready", finish(self) +end + +--- Parses the bytes of a whole Standard MIDI File. +--- +--- @param bytes the file, which is not retained once the song is built +--- @param name what a refusal names the file as, and defaults to `"MIDI file"` +--- @return the song, or nil when the bytes are refused +--- @return the reason, naming the byte where reading stopped +export function parse(bytes: string, name: string?): (Song?, string?) + local parser, reason = begin(bytes, name or "MIDI file") + if parser == nil then + return nil, reason + end + while true do + local status, song, failure = step(parser, PARSE_BUDGET) + local outcome: string = status + if outcome == "ready" then + return song + elseif outcome == "failed" then + return nil, failure + end + end +end + +--- Loads a `.mid` file as a [`Song`](tecs.audio.music.midi.Song). +--- +--- The file is read and parsed on the loader's readiness pump, up to 65 536 +--- events a pass, so `handle:value() as tecs.audio.music.midi.Song` is the +--- cast its caller writes. The collector owns the song. +export const kind: assets.Kind = new assets.Kind( + name = assets.MUSIC_MIDI, + open = function(path: string): (assets.Job?, string?) + local read, reason = assets.readJob(path) + if read == nil then + return nil, reason + end + local parser: Parser? = nil + + return new assets.Job( + poll = function(): (assets.Status, any?, string?) + local current = parser + if current == nil then + local status, bytes, failure = read.poll() + local reading: string = status + if reading ~= "ready" then + return status, nil, failure + end + local begun, refused = begin(bytes as string, path) + if begun == nil then + return "failed", nil, refused + end + parser = begun + + return "pending" + end + + return step(current, PARSE_BUDGET) + end, + cancel = function(): nil + read.cancel() + parser = nil + end + ) + end, + release = nil +) + +--- Returns the index of the first event at or after `time`. +local function locate(events: {Event}, time: number): integer + local low: integer, high: integer = 1, #events + 1 + while low < high do + local middle = (low + high) // 2 + if events[middle].time < time then + low = middle + 1 + else + high = middle + end + end + + return low +end + +--- Builds the driver that plays a song on a synth. +local function driver(voice: synth.Synth, first: Song): music.Driver + local song = first + local index: integer = 1 + local held: {[integer]: boolean} = {} + local muted: {[integer]: boolean} = {} + local gain: number = 1 + local result: music.Driver + + local function release(at: number, channel: integer?): nil + for note in pairs(held) do + local owner = note // 128 + if channel == nil or owner == channel then + voice:noteOff(owner, note % 128, at) + held[note] = nil + end + end + end + + result = new music.Driver( + duration = song.duration, + loopStart = song.loopStart, + loopEnd = song.loopEnd, + tail = TAIL, + now = function(): number + return voice:now() + end, + schedule = function(to: number, start: number, clock: number, tempo: number): number + local events = song.events + while index <= #events do + local event = events[index] + if event.time >= to then + return to + end + local status = event.status + if status < 0xF0 then + local channel = status % 16 + local nibble = status // 16 + local on = nibble == 0x9 and event.data2 > 0 + if not (on and muted[channel]) then + local at = clock + (event.time - start) / tempo + if not voice:send(status, event.data1, event.data2, at) then + return event.time + end + local note = channel * 128 + event.data1 + if on then + held[note] = true + elseif nibble == 0x8 or nibble == 0x9 then + held[note] = nil + end + end + end + index = index + 1 + end + + return to + end, + pending = function(): number? + local events = song.events + while index <= #events do + local event = events[index] + if event.status < 0xF0 then + return event.time + end + index = index + 1 + end + + return nil + end, + jump = function(target: number, at: number): nil + release(at, nil) + index = locate(song.events, target) + end, + seek = function(target: number, at: number): nil + voice:reset() + held = {} + local programs: {[integer]: integer} = {} + local controllers: {[integer]: {[integer]: integer}} = {} + local bends: {[integer]: integer} = {} + local pressures: {[integer]: integer} = {} + local events = song.events + local stop = locate(events, target) + for position = 1, stop - 1 do + local event = events[position] + local nibble = event.status // 16 + local channel = event.status % 16 + if nibble == 0xB and event.data1 < 120 then + local values = controllers[channel] + if values == nil then + values = {} + controllers[channel] = values + end + values[event.data1] = event.data2 + elseif nibble == 0xC then + programs[channel] = event.data1 + elseif nibble == 0xD then + pressures[channel] = event.data1 + elseif nibble == 0xE then + bends[channel] = event.data1 + event.data2 * 128 + end + end + for channel = 0, 15 do + local values = controllers[channel] + if values ~= nil then + for controller = 0, 119 do + local value = values[controller] + if value ~= nil then + voice:controlChange(channel, controller, value, at) + end + end + end + local program = programs[channel] + if program ~= nil then + voice:programChange(channel, program, at) + end + local bend = bends[channel] + if bend ~= nil then + voice:pitchBend(channel, bend, at) + end + local pressure = pressures[channel] + if pressure ~= nil then + voice:send(0xD0 + channel, pressure, 0, at) + end + end + index = stop + end, + reload = function(payload: any): (boolean, string?) + song = payload as Song + result.duration = song.duration + result.loopStart = song.loopStart + result.loopEnd = song.loopEnd + index = 1 + + return true + end, + pause = function(): nil + voice:pause() + end, + resume = function(): nil + voice:resume() + end, + setGain = function(value: number): nil + gain = value + voice:setGain(gain) + end, + setMuted = function(channel: integer, mute: boolean): nil + if channel < 0 or channel > 15 then + error( + "tecs.audio.music.Player.setMuted: a MIDI channel must be from 0 to 15, got " .. tostring(channel), + 3 + ) + end + if mute then + muted[channel] = true + else + muted[channel] = nil + end + if mute then + release(0, channel) + voice:allNotesOff(channel) + end + end, + stop = function(fadeOut: number?): nil + voice:destroy(fadeOut) + end + ) + + return result +end + +--- Plays a parsed song on a synthesizer of its own. +--- +--- @param mixer the mixer the synth plays on +--- @param font the SoundFont the synth plays, loaded into that mixer +--- @param song the song, which the player reads and does not change +--- @param options the group, gain, loop, tempo, lookahead and start, or nil +--- for the defaults +--- @return the player, or nil when the synth could not start +--- @return the reason, when the first return is nil +--- @raises string when an option is out of range, or the SoundFont belongs +--- to another mixer +export function playSong( + mixer: audio.Audio, + font: synth.SoundFont, + song: Song, + options: music.PlayOptions? +): (music.Player?, string?) + music.checkOptions("midi.playSong", options) + local voice, reason = synth.new(mixer, font, {group = options and options.group, gain = options and options.gain}) + if voice == nil then + return nil, reason + end + local made = assert(voice) + + return music.newPlayer(mixer, driver(made, song), nil, options) +end + +--- Plays a loaded song on a synthesizer of its own. +--- +--- The player retains the handle until it stops and follows a reload of it, +--- so the caller releases its own hold once this returns. +--- +--- @param mixer the mixer the synth plays on +--- @param font the SoundFont the synth plays, loaded into that mixer +--- @param handle a handle `kind` loaded +--- @param options the group, gain, loop, tempo, lookahead and start, or nil +--- for the defaults +--- @return the player, or nil when the synth could not start +--- @return the reason, when the first return is nil +--- @raises string when the handle was not loaded as a MIDI song or has been +--- released +--- @raises string when an option is out of range +export function play( + mixer: audio.Audio, + font: synth.SoundFont, + handle: assets.Handle, + options: music.PlayOptions? +): (music.Player?, string?) + if handle.kind ~= assets.MUSIC_MIDI then + error("tecs.audio.music.midi.play: the handle was loaded as " .. handle.kind .. ", not as a MIDI song", 2) + end + local song = handle:value() + if song == nil then + error("tecs.audio.music.midi.play: the handle has been released", 2) + end + music.checkOptions("midi.play", options) + local voice, reason = synth.new(mixer, font, {group = options and options.group, gain = options and options.gain}) + if voice == nil then + return nil, reason + end + local made = assert(voice) + + return music.newPlayer(mixer, driver(made, song as Song), handle, options) +end diff --git a/src/tecs/audio/synth.nupp b/src/tecs/audio/synth.nupp new file mode 100644 index 00000000..201c610d --- /dev/null +++ b/src/tecs/audio/synth.nupp @@ -0,0 +1,515 @@ +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 + pause: function(exclusive self: Synth): nil + resume: function(exclusive self: Synth): nil + paused: function(borrows self: Synth): boolean + destroy: function(exclusive self: Synth, fadeOut: number?): nil +end + +--- The SoundFonts each mixer has loaded, by path, keyed weakly so a mixer that +--- 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 + +--- Pauses the synth's voice, which stops its clock with it. +--- +--- Nothing is lost: every note holds where it stood, events already scheduled +--- wait on the stopped clock, and `resume` carries on exactly where it left +--- off. +--- +--- @param self the synth to pause +function Synth.pause(exclusive self: Synth): nil + self.mixer:pause(self.voice) +end + +--- Resumes a paused synth. +--- +--- @param self the synth to resume +function Synth.resume(exclusive self: Synth): nil + self.mixer:resume(self.voice) +end + +--- Reports whether the synth's own voice is paused, leaving its group aside. +--- +--- @param self the synth to inspect +--- @return true between `pause` and `resume` +function Synth.paused(borrows self: Synth): boolean + return self.mixer:paused(self.voice) +end + +--- Stops the synth's voice and frees its synthesizer. Safe more than once. +--- +--- Without a fade the voice stops at once, so a note still sounding is cut; +--- send `allNotesOff` a little before for a softer ending. With one it fades +--- over `fadeOut` seconds, the synthesizer rendering on and taking no more +--- events, and goes when the fade ends. +--- +--- @param self the synth to destroy +--- @param fadeOut seconds to fade over, or nil to stop at once +function Synth.destroy(exclusive self: Synth, fadeOut: number?): nil + if self.destroyed then + return + end + self.destroyed = true + self.mixer:stop(self.voice, fadeOut) + self.port.close() +end diff --git a/src/tecs/internal/audiosynth.nupp b/src/tecs/internal/audiosynth.nupp new file mode 100644 index 00000000..f02e042f --- /dev/null +++ b/src/tecs/internal/audiosynth.nupp @@ -0,0 +1,35 @@ +module tecs.internal.audiosynth + +--[[ +What `tecs.audio` lends `tecs.audio.synth`, `tecs.audio.fm` and the music +players. + +A synthesizer, General MIDI or FM, is a voice of the mixer, so it needs a slot +taken the way `Audio.play` takes one and the backend it opens on. Both live in +fields `tecs.audio` keeps private, and a child module cannot reach them. This +module is the seam between them: `tecs.audio` fills `lent` for each mixer it +makes, and the two synthesizer modules read it. Nothing else should. The music +players register in `updates`, which `Audio.update` runs. +]] + +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"}) + +--- Holds what each mixer's `update` advances besides its voices: the music +--- players made on it, each a function that returns false once it is done +--- and should be dropped. Keyed weakly by the mixer, as `lent` is. +export const updates: {[any]: {function(): boolean}} = setmetatable({}, {__mode = "k"}) diff --git a/src/tecs/internal/dro.nupp b/src/tecs/internal/dro.nupp new file mode 100644 index 00000000..4e272aba --- /dev/null +++ b/src/tecs/internal/dro.nupp @@ -0,0 +1,250 @@ +module tecs.internal.dro + +--[[ +Reads a DOSBox Raw OPL capture into a register log, a slice at a time. + +Version 1, from DOSBox 0.61, and version 2, from 0.73 on, are read. Both +count in milliseconds. Version 1 escapes its delays and its bank switches into +the register stream; version 2 names its delay codes in the header and maps +the rest through a codemap, the top bit of a code selecting the second chip or +bank. The hardware is one OPL2, one OPL3, or two OPL2s, which a Sound Blaster +Pro wired one to each speaker and this log places the same way. +]] + +local registerlog = require("tecs.internal.registerlog") + +const byte = string.byte + +--- Ticks a second: DRO counts milliseconds. +local RATE: integer = 1000 + +local function u16(bytes: string, at: integer): integer + const low, high = byte(bytes, at, at + 1) + + return (low as integer) + (high as integer) * 256 +end + +local function u32(bytes: string, at: integer): integer + const a, b, c, d = byte(bytes, at, at + 3) + + return (a as integer) + (b as integer) * 256 + (c as integer) * 65536 + (d as integer) * 16777216 +end + +--- The hardware a capture was made from. +local type Hardware = "opl2" | "opl3" | "dual" + +--- The state of one parse. +local record State + bytes: string + name: string + builder: registerlog.Builder + version: integer + hardware: Hardware + position: integer + finish: integer + tick: integer + high: boolean + shortDelay: integer + longDelay: integer + codemap: {integer} + total: integer + done: boolean +end + +--- Adds the chips the hardware had. +local function chips(exclusive builder: registerlog.Builder, hardware: Hardware): nil + if hardware == "opl3" then + registerlog.addChip(builder, "ymf262", 14318180, false, 1, 1) + elseif hardware == "dual" then + registerlog.addChip(builder, "ym3812", 3579545, false, 1, 0) + registerlog.addChip(builder, "ym3812", 3579545, true, 0, 1) + else + registerlog.addChip(builder, "ym3812", 3579545, false, 1, 1) + end +end + +--- Writes a register to the low or high chip or bank. +local function put(exclusive state: State, high: boolean, register: integer, value: integer): nil + local hardware = state.hardware + if hardware == "opl3" then + registerlog.write(state.builder, state.tick, 0, (high and 0x100 or 0) + register, value) + elseif hardware == "dual" then + registerlog.write(state.builder, state.tick, high and 1 or 0, register, value) + elseif not high then + registerlog.write(state.builder, state.tick, 0, register, value) + end +end + +--- Reads up to `budget` commands, and returns the reason the file is refused +--- if it is. +local function run(exclusive state: State, budget: integer): string? + local bytes = state.bytes + + local function cut(at: integer, what: string): string + return state.name .. ": byte " .. string.format("0x%X", at - 1) .. ": " .. what + end + + for _ = 1, budget do + local at = state.position + if at > state.finish then + state.done = true + + return nil + end + local code = byte(bytes, at) as integer + if state.version == 1 then + if code == 0x00 then + if at + 1 > state.finish then + return cut(at, "a delay is cut short") + end + state.tick = state.tick + (byte(bytes, at + 1) as integer) + 1 + state.position = at + 2 + elseif code == 0x01 then + if at + 2 > state.finish then + return cut(at, "a delay is cut short") + end + state.tick = state.tick + u16(bytes, at + 1) + 1 + state.position = at + 3 + elseif code == 0x02 or code == 0x03 then + state.high = code == 0x03 + state.position = at + 1 + elseif code == 0x04 then + if at + 2 > state.finish then + return cut(at, "an escaped register write is cut short") + end + put(state, state.high, byte(bytes, at + 1) as integer, byte(bytes, at + 2) as integer) + state.position = at + 3 + else + if at + 1 > state.finish then + return cut(at, "a register write is cut short") + end + put(state, state.high, code, byte(bytes, at + 1) as integer) + state.position = at + 2 + end + else + if at + 1 > state.finish then + return cut(at, "a register write is cut short") + end + local value = byte(bytes, at + 1) as integer + if code == state.shortDelay then + state.tick = state.tick + value + 1 + elseif code == state.longDelay then + state.tick = state.tick + (value + 1) * 256 + else + local register = state.codemap[code % 128 + 1] + if register == nil then + return cut(at, "code " .. tostring(code % 128) .. " is past the codemap") + end + put(state, code >= 128, register, value) + end + state.position = at + 2 + end + end + + return nil +end + +--- Begins reading a DRO file, or returns why its header is refused. +--- +--- @param bytes the whole file +--- @param name what a refusal names the file as +--- @return the parse, or nil and a reason +export function parser(bytes: string, name: string): (registerlog.Parser?, string?) + if #bytes < 0x15 or string.sub(bytes, 1, 8) ~= "DBRAWOPL" then + return nil, name .. ": not a DOSBox Raw OPL file: it does not begin with \"DBRAWOPL\"" + end + local major = u16(bytes, 0x09) + local minor = u16(bytes, 0x0B) + local state: State + if major == 2 and minor == 0 then + if #bytes < 0x1A then + return nil, name .. ": the version 2 header is cut short" + end + local kind = byte(bytes, 0x15) as integer + local hardware: Hardware = kind == 1 and "dual" or kind == 2 and "opl3" or "opl2" + if kind > 2 then + return nil, name .. ": hardware type " .. tostring(kind) .. " is not an OPL2, a dual OPL2 or an OPL3" + end + if (byte(bytes, 0x16) as integer) ~= 0 then + return nil, name .. ": only the interleaved data format is defined, and this file uses another" + end + if (byte(bytes, 0x17) as integer) ~= 0 then + return nil, name .. ": the data is compressed, which DOSBox never wrote" + end + local size = byte(bytes, 0x1A) as integer + if size > 128 or 0x1A + size > #bytes then + return nil, name .. ": a codemap of " .. tostring(size) .. " entries does not fit" + end + local codemap: {integer} = {} + for index = 1, size do + codemap[index] = byte(bytes, 0x1A + index) as integer + end + local start = 0x1B + size + local count = u32(bytes, 0x0D) + local builder = registerlog.newBuilder("dro", 2, RATE) + chips(builder, hardware) + state = new State( + bytes = bytes, + name = name, + builder = builder, + version = 2, + hardware = hardware, + position = start, + finish = (math.min(#bytes, start + count * 2 - 1)) as integer, + tick = 0, + high = false, + shortDelay = byte(bytes, 0x18) as integer, + longDelay = byte(bytes, 0x19) as integer, + codemap = codemap, + total = u32(bytes, 0x11), + done = false + ) + elseif major == 0 and minor == 1 then + local kind = byte(bytes, 0x15) as integer + if kind > 2 then + return nil, name .. ": hardware type " .. tostring(kind) .. " is not an OPL2, an OPL3 or a dual OPL2" + end + local hardware: Hardware = kind == 1 and "opl3" or kind == 2 and "dual" or "opl2" + -- The hardware type was one byte in the first captures and four in + -- later ones, with no change of version: three zero bytes after it + -- are the wider field. + local start: integer = 0x16 + if #bytes >= 0x18 and string.sub(bytes, 0x16, 0x18) == "\0\0\0" then + start = 0x19 + end + local length = u32(bytes, 0x11) + local builder = registerlog.newBuilder("dro", 1, RATE) + chips(builder, hardware) + state = new State( + bytes = bytes, + name = name, + builder = builder, + version = 1, + hardware = hardware, + position = start, + finish = (math.min(#bytes, start + length - 1)) as integer, + tick = 0, + high = false, + shortDelay = -1, + longDelay = -1, + codemap = {}, + total = u32(bytes, 0x0D), + done = false + ) + else + return nil, name .. ": DRO version " .. tostring(major) .. "." .. tostring(minor) .. " is neither 0.1 nor 2.0" + end + + return new registerlog.Parser(step = function(budget: integer): (registerlog.Status, registerlog.Builder?, string?) + local reason = run(state, budget) + if reason ~= nil then + return "failed", nil, reason + end + if not state.done then + return "pending" + end + state.builder.length = math.max(state.tick, state.total) + + return "ready", state.builder + end) +end diff --git a/src/tecs/internal/registerlog.nupp b/src/tecs/internal/registerlog.nupp new file mode 100644 index 00000000..c4fd991b --- /dev/null +++ b/src/tecs/internal/registerlog.nupp @@ -0,0 +1,182 @@ +module tecs.internal.registerlog + +--[[ +The register log a VGM or DRO file is read into, before +`tecs.audio.music.fm` turns it into its public record. + +A parser fills a [`Builder`] one write at a time: the chips the file names, a +tick and a packed write for each register write, the memory images its data +blocks carry, and the chips it could not keep with the reason why. Writes are +packed into one integer each, the chip in the bits above 18, the register in +the ten above 8 and the value in the low eight, so a long log costs two numbers +a write rather than a record. +]] + +--- What a step of a parse answers, as `tecs.assets.Status` spells it. +export type Status = "pending" | "ready" | "failed" + +--- The multiplier that packs a chip index above the register. +export const CHIP_SHIFT: integer = 262144 + +--- The multiplier that packs a register above the value. +export const REGISTER_SHIFT: integer = 256 + +--- The highest register `tecs.audio.fm` takes for each chip a log can name. +export const LAST_REGISTER: { + [string]: integer +} = { + ym3526 = 0xFF, + y8950 = 0xFF, + ym3812 = 0xFF, + ymf262 = 0x1FF, + ymf278b = 0x2FF, + ym2413 = 0xFF, + ds1001 = 0xFF, + ym2151 = 0xFF, + ym2164 = 0xFF, + ym2203 = 0xFF, + ym2608 = 0x1FF, + ym2610 = 0x1FF, + ym2610b = 0x1FF, + ym2612 = 0x1FF, + ym3438 = 0x1FF, + ym2149 = 0xFF, +} + +--- The largest memory image a chip takes, which `tecs.audio.fm` enforces. +export const MAX_IMAGE: integer = 16 * 1024 * 1024 + +--- One piece of a memory image, as a data block gave it. +export record Piece + offset: integer + data: string +end + +--- One chip the log writes to. +export record Chip + model: string + clock: integer + second: boolean + left: number + right: number + pieces: {[string]: {Piece}} +end + +--- A log being built. +export record Builder + format: string + version: integer + rate: integer + ticks: {integer} + ops: {integer} + count: integer + chips: {Chip} + length: integer + loopTick: integer? + loopIndex: integer? + skipped: {string} + seen: {[string]: boolean} + tags: {string}? +end + +--- One parse in progress, advanced a slice at a time. +export record Parser + --- Advances the parse by up to `budget` commands, and answers as a job's + --- `poll` does: `"pending"`, `"ready"` and the builder, or `"failed"` and + --- a reason. + step: function(budget: integer): (Status, Builder?, string?) +end + +--- Creates an empty log. +export function newBuilder(format: string, version: integer, rate: integer): Builder + return new Builder( + format = format, + version = version, + rate = rate, + ticks = {}, + ops = {}, + count = 0, + chips = {}, + length = 0, + loopTick = nil, + loopIndex = nil, + skipped = {}, + seen = {}, + tags = nil + ) +end + +--- Adds a chip and returns its index, counted from zero. +export function addChip( + exclusive builder: Builder, + model: string, + clock: integer, + second: boolean, + left: number, + right: number +): integer + local index = #builder.chips + builder.chips[ + index + 1 + ] = new Chip(model = model, clock = clock, second = second, left = left, right = right, pieces = {}) + + return index +end + +--- Appends one register write at `tick`. +--- +--- A register past the chip's range is dropped rather than kept, because a +--- player would have to refuse it: it is what a damaged or a hand-made file +--- holds, not anything a chip decodes. +export function write(exclusive builder: Builder, tick: integer, chip: integer, register: integer, value: integer): nil + local last = LAST_REGISTER[builder.chips[chip + 1].model] or 0xFF + if register < 0 or register > last or value < 0 or value > 0xFF then + return + end + local count = builder.count + 1 + builder.count = count + builder.ticks[count] = tick + builder.ops[count] = chip * CHIP_SHIFT + register * REGISTER_SHIFT + value +end + +--- Records once that something in the file is not played, and why. +export function skip(exclusive builder: Builder, key: string, reason: string): nil + if builder.seen[key] then + return + end + builder.seen[key] = true + builder.skipped[#builder.skipped + 1] = reason +end + +--- Adds a piece of a chip's memory image of `class`. +export function addMemory(exclusive builder: Builder, chip: integer, class: string, offset: integer, data: string): nil + local entry = builder.chips[chip + 1] + local pieces = entry.pieces[class] + if pieces == nil then + pieces = {} + entry.pieces[class] = pieces + end + pieces[#pieces + 1] = new Piece(offset = offset, data = data) +end + +--- Assembles the pieces of one image into the bytes a chip reads: each at its +--- offset, later pieces over earlier ones, zeroes between, as long as the +--- furthest piece reaches and no longer than `MAX_IMAGE`. +export function image(pieces: {Piece}): string + local result = "" + for _, piece in ipairs(pieces) do + local offset = piece.offset + local data = piece.data + if offset < MAX_IMAGE then + if offset + #data > MAX_IMAGE then + data = string.sub(data, 1, MAX_IMAGE - offset) + end + if #result < offset then + result = result .. string.rep("\0", offset - #result) + end + result = string.sub(result, 1, offset) .. data .. string.sub(result, offset + #data + 1) + end + end + + return result +end diff --git a/src/tecs/internal/vgm.nupp b/src/tecs/internal/vgm.nupp new file mode 100644 index 00000000..89925714 --- /dev/null +++ b/src/tecs/internal/vgm.nupp @@ -0,0 +1,906 @@ +module tecs.internal.vgm + +--[[ +Reads a VGM file into a register log, a slice at a time. + +What it keeps is what `tecs.audio.fm` can play: the writes to every Yamaha FM +chip and to the YM2149 family, the memory images their data blocks carry, the +YM2612's DAC writes from the PCM data bank (the `0x8n` commands and the DAC +streams of commands `0x90` to `0x95`, both turned into ordinary register +writes at their times), the loop point and the GD3 tags. Every other chip's +writes are skipped, each with one reason, and a file with nothing left to play +is refused by the caller. + +The format is the VGM specification up to version 1.72: a header whose fields +past the data offset read as zero, a stream of commands at 44100 ticks a +second, and data blocks, compressed ones included. +]] + +local registerlog = require("tecs.internal.registerlog") + +const byte = string.byte +const floor = math.floor + +--- Ticks a second, which every VGM file counts in. +local RATE: integer = 44100 + +--- Two to the power of thirty and of thirty-one, the two flag bits of a clock. +local BIT30: integer = 1073741824 + +local BIT31: integer = 2147483648 + +--- One chip the format numbers, and where its clock sits in the header. +local record Family + id: integer + name: string + clockAt: integer + model: string? + alternate: string? + reason: string? +end + +local function family(id: integer, name: string, clockAt: integer, model: string?, alternate: string?): Family + return new Family(id = id, name = name, clockAt = clockAt, model = model, alternate = alternate, reason = nil) +end + +local function foreign(id: integer, name: string, clockAt: integer, reason: string?): Family + return new Family( + id = id, + name = name, + clockAt = clockAt, + model = nil, + alternate = nil, + reason = reason or (name .. ": not a Yamaha FM chip, which is what tecs.audio.fm emulates") + ) +end + +--- Every chip VGM numbers, by its number, with the model `tecs.audio.fm` +--- plays it as, or the reason it does not. +local FAMILIES: {[integer]: Family} = {} + +for _, entry in ipairs({ + foreign(0x00, "SN76489", 0x0C), + family(0x01, "YM2413", 0x10, "ym2413", "ds1001"), + family(0x02, "YM2612", 0x2C, "ym2612", "ym3438"), + family(0x03, "YM2151", 0x30, "ym2151", "ym2164"), + foreign(0x04, "Sega PCM", 0x38), + foreign(0x05, "RF5C68", 0x40), + family(0x06, "YM2203", 0x44, "ym2203", nil), + family(0x07, "YM2608", 0x48, "ym2608", nil), + family(0x08, "YM2610", 0x4C, "ym2610", "ym2610b"), + family(0x09, "YM3812", 0x50, "ym3812", nil), + family(0x0A, "YM3526", 0x54, "ym3526", nil), + family(0x0B, "Y8950", 0x58, "y8950", nil), + family(0x0C, "YMF262", 0x5C, "ymf262", nil), + family(0x0D, "YMF278B", 0x60, "ymf278b", nil), + foreign( + 0x0E, + "YMF271", + 0x64, + "YMF271: ymfm has no implementation of the OPX, so tecs.audio.fm does not emulate it" + ), + foreign(0x0F, "YMZ280B", 0x68, "YMZ280B: a PCM chip, not an FM one, which is what tecs.audio.fm emulates"), + foreign(0x10, "RF5C164", 0x6C), + foreign(0x11, "PWM", 0x70), + family(0x12, "AY8910", 0x74, "ym2149", nil), + foreign(0x13, "Game Boy DMG", 0x80), + foreign(0x14, "NES APU", 0x84), + foreign(0x15, "MultiPCM", 0x88), + foreign(0x16, "uPD7759", 0x8C), + foreign(0x17, "OKIM6258", 0x90), + foreign(0x18, "OKIM6295", 0x98), + foreign(0x19, "K051649", 0x9C), + foreign(0x1A, "K054539", 0xA0), + foreign(0x1B, "HuC6280", 0xA4), + foreign(0x1C, "C140", 0xA8), + foreign(0x1D, "K053260", 0xAC), + foreign(0x1E, "Pokey", 0xB0), + foreign(0x1F, "QSound", 0xB4), + foreign(0x20, "SCSP", 0xB8), + foreign(0x21, "WonderSwan", 0xC0), + foreign(0x22, "Virtual Boy VSU", 0xC4), + foreign(0x23, "SAA1099", 0xC8), + foreign(0x24, "ES5503", 0xCC), + foreign(0x25, "ES5506", 0xD0), + foreign(0x26, "X1-010", 0xD8), + foreign(0x27, "C352", 0xDC), + foreign(0x28, "GA20", 0xE0), + foreign(0x29, "Mikey", 0xE4), +}) do + FAMILIES[entry.id] = entry +end + +--- The chip a register-write command from 0x51 to 0x5F writes, by command, +--- and the bank its register goes to. 0xA1 to 0xAF are the same commands for +--- the second chip. +local WRITES: { + [integer]: {integer} +} = { + [0x51] = {0x01, 0}, + [0x52] = {0x02, 0}, + [0x53] = {0x02, 1}, + [0x54] = {0x03, 0}, + [0x55] = {0x06, 0}, + [0x56] = {0x07, 0}, + [0x57] = {0x07, 1}, + [0x58] = {0x08, 0}, + [0x59] = {0x08, 1}, + [0x5A] = {0x09, 0}, + [0x5B] = {0x0A, 0}, + [0x5C] = {0x0B, 0}, + [0x5D] = {0x0F, 0}, + [0x5E] = {0x0C, 0}, + [0x5F] = {0x0C, 1}, +} + +--- The chip and memory class each ROM or RAM data block type loads. +local IMAGES: { + [integer]: {any} +} = { + [0x81] = {0x07, "adpcmB"}, + [0x82] = {0x08, "adpcmA"}, + [0x83] = {0x08, "adpcmB"}, + [0x84] = {0x0D, "pcm"}, + [0x87] = {0x0D, "pcm"}, + [0x88] = {0x0B, "adpcmB"}, +} + +local function u16(bytes: string, at: integer): integer + const low, high = byte(bytes, at, at + 1) + + return (low as integer) + (high as integer) * 256 +end + +local function u32(bytes: string, at: integer): integer + const a, b, c, d = byte(bytes, at, at + 3) + + return (a as integer) + (b as integer) * 256 + (c as integer) * 65536 + (d as integer) * 16777216 +end + +--- One block of PCM data in a bank. +local record Block + start: integer + length: integer +end + +--- The PCM data blocks of one type, joined. +local record Bank + parts: {string} + blocks: {Block} + size: integer + joined: string? +end + +--- One DAC stream. +local record Stream + chipType: integer + port: integer + command: integer + bank: integer + stepSize: integer + stepBase: integer + frequency: number + active: boolean + position: integer + remaining: integer + loop: boolean + reverse: boolean + first: integer + length: integer + next: number +end + +--- A decompression table a compressed data block names. +local record Table + subtype: integer + bitsOut: integer + bitsIn: integer + values: {integer} +end + +--- The state of one parse. +local record State + bytes: string + name: string + builder: registerlog.Builder + position: integer + finish: integer + tick: integer + loopAt: integer + gd3At: integer + total: integer + clocks: {[integer]: integer} + flags: {[integer]: integer} + slots: {[integer]: integer} + banks: {[integer]: Bank} + streams: {[integer]: Stream} + tables: {[integer]: Table} + pcm: integer + done: boolean +end + +--- Returns the joined data of a PCM bank, or the empty string. +local function bankData(borrows state: State, kind: integer): string + local bank = state.banks[kind] + if bank == nil then + return "" + end + local joined = bank.joined + if joined == nil then + joined = table.concat(bank.parts) + bank.parts = {joined} + bank.joined = joined + end + + return joined +end + +--- Returns the log's index for chip `id`, first or second, making it on first +--- use, or nil when the file cannot play it. +local function chipFor(exclusive state: State, id: integer, second: boolean): integer? + local slot = id * 2 + (second and 1 or 0) + local existing = state.slots[slot] + if existing ~= nil then + return existing >= 0 and existing or nil + end + local entry = FAMILIES[id] + local builder = state.builder + + local function refuse(key: string, reason: string): nil + state.slots[slot] = -1 + registerlog.skip(builder, key, reason) + end + + if entry == nil then + refuse("chip " .. tostring(id), "chip " .. string.format("0x%02X", id) .. ": not a chip this VGM reader knows") + + return nil + end + local model = entry.model + if model == nil then + refuse(entry.name, entry.reason or entry.name) + + return nil + end + local raw = state.clocks[id] or 0 + local clock = raw % BIT30 + if clock == 0 then + refuse( + entry.name .. " clock", + entry.name .. ": written to, but the header gives it no clock, so it is not played" + ) + + return nil + end + if second and raw % BIT31 < BIT30 then + refuse(entry.name .. " second", entry.name .. ": a second chip is written to that the header does not declare") + + return nil + end + if raw >= BIT31 and entry.alternate ~= nil then + model = assert(entry.alternate) + end + if id == 0x12 then + local kind = state.flags[0x78] or 0 + if kind == 0x03 then + refuse("AY8930", "AY8930: its expanded mode is not the YM2149's, which is what tecs.audio.fm emulates") + + return nil + end + -- Pin 26 low on a YM2149 halves its clock. + if (state.flags[0x79] or 0) // 16 % 2 == 1 then + clock = clock // 2 + end + end + local index = registerlog.addChip(builder, model, clock, second, 1, 1) + state.slots[slot] = index + + return index +end + +--- Writes `value` to `register` of chip `id`, when the file can play it. +local function put(exclusive state: State, id: integer, second: boolean, register: integer, value: integer): nil + local chip = chipFor(state, id, second) + if chip ~= nil then + registerlog.write(state.builder, state.tick, chip, register, value) + end +end + +--- Emits every active stream's writes due before `until`, in time order. +local function runStreams(exclusive state: State, horizon: integer): nil + while true do + local earliest: Stream? = nil + for _, stream in pairs(state.streams) do + if stream.active and stream.frequency > 0 and stream.next < horizon then + if earliest == nil or stream.next < assert(earliest).next then + earliest = stream + end + end + end + if earliest == nil then + return + end + local stream = assert(earliest) + local data = bankData(state, stream.bank) + if stream.position < 0 or stream.position >= #data then + if stream.loop and stream.length ~= 0 then + stream.position = stream.first + stream.remaining = stream.length + else + stream.active = false + end + else + local tick = floor(stream.next) + local value = byte(data, stream.position + 1) as integer + local chip = chipFor(state, stream.chipType % 128, stream.chipType >= 128) + if chip ~= nil then + registerlog.write(state.builder, tick, chip, stream.port * 256 + stream.command, value) + end + local step = stream.stepSize + stream.position = stream.position + (stream.reverse and -step or step) + stream.next = stream.next + RATE / stream.frequency + if stream.remaining > 0 then + stream.remaining = stream.remaining - 1 + if stream.remaining == 0 then + if stream.loop then + stream.position = stream.first + stream.remaining = stream.length + else + stream.active = false + end + end + end + end + end +end + +--- Advances time by `ticks`, running the streams up to the new time. +local function wait(exclusive state: State, ticks: integer): nil + if ticks <= 0 then + return + end + runStreams(state, state.tick + ticks) + state.tick = state.tick + ticks +end + +local function streamOf(exclusive state: State, id: integer): Stream + local stream = state.streams[id] + if stream == nil then + stream = new Stream( + chipType = 0x02, + port = 0, + command = 0x2A, + bank = 0, + stepSize = 1, + stepBase = 0, + frequency = 0, + active = false, + position = 0, + remaining = 0, + loop = false, + reverse = false, + first = 0, + length = 0, + next = 0 + ) + state.streams[id] = stream + end + + return stream +end + +--- Starts a stream at `position` for `remaining` commands, minus for until +--- its data ends. +local function start( + exclusive state: State, + stream: Stream, + position: integer, + remaining: integer, + loop: boolean, + reverse: boolean +): nil + stream.position = position + stream.first = position + stream.remaining = remaining + stream.length = remaining + stream.loop = loop + stream.reverse = reverse + stream.active = true + stream.next = state.tick +end + +--- The powers of two up to two to the sixteenth, by exponent plus one. +local POWERS: {integer} = {} + +do + local power: integer = 1 + for index = 1, 17 do + POWERS[index] = power + power = power * 2 + end +end + +--- Returns two to the power of `exponent`, from 0 to 16. +local function power(exponent: integer): integer + return POWERS[exponent + 1] or 1 +end + +--- Reads `count` bits from `bytes` from bit `at`, counted from the top of the +--- first byte. +local function bits(bytes: string, at: integer, count: integer): integer + local value: integer = 0 + for index = 0, count - 1 do + local bit = at + index + local octet = byte(bytes, bit // 8 + 1) as integer? or 0 + value = value * 2 + (octet // power(7 - bit % 8)) % 2 + end + + return value +end + +--- Decompresses a compressed data block, or returns the reason it cannot. +local function decompress(borrows state: State, data: string): (string?, string?) + if #data < 10 then + return nil, "a compressed data block is cut short" + end + local kind = byte(data, 1) as integer + local size = u32(data, 2) + local bitsOut = byte(data, 6) as integer + local bitsIn = byte(data, 7) as integer + local subtype = byte(data, 8) as integer + local base = u16(data, 9) + if bitsIn == 0 or bitsIn > 16 or bitsOut == 0 or bitsOut > 16 then + return nil, "a compressed data block packs " .. tostring(bitsIn) .. " bits into " .. tostring(bitsOut) + end + local width = (bitsOut + 7) // 8 + local count = size // width + local payload = string.sub(data, 11) + local tableFor = state.tables[kind] + local out: {string} = {} + local value = base + for index = 0, count - 1 do + local packed = bits(payload, index * bitsIn, bitsIn) + local result: integer + if kind == 0x00 then + if subtype == 0x00 then + result = packed + base + elseif subtype == 0x01 then + result = packed * power(bitsOut - bitsIn) + base + else + if tableFor == nil then + return nil, "a compressed data block uses a table the file never gives" + end + result = (tableFor.values[packed + 1] or 0) + base + end + elseif kind == 0x01 then + if tableFor == nil then + return nil, "a DPCM data block uses a table the file never gives" + end + value = (value + (tableFor.values[packed + 1] or 0)) % power(bitsOut) + result = value + else + return nil, "compression type " .. tostring(kind) .. " is not one VGM defines" + end + result = result % 65536 + if width == 1 then + out[#out + 1] = string.char(result % 256) + else + out[#out + 1] = string.char(result % 256, result // 256) + end + end + + return table.concat(out) +end + +--- Reads one data block, whose type is `kind` and whose bytes are `data`. +local function dataBlock(exclusive state: State, kind: integer, data: string, flagged: boolean): string? + if kind >= 0x40 and kind < 0x7F then + local decoded, reason = decompress(state, data) + if decoded == nil then + return reason + end + data = assert(decoded) + kind = kind - 0x40 + end + if kind < 0x40 then + local bank = state.banks[kind] + if bank == nil then + bank = new Bank(parts = {}, blocks = {}, size = 0, joined = nil) + state.banks[kind] = bank + end + bank.parts[#bank.parts + 1] = data + bank.blocks[#bank.blocks + 1] = new Block(start = bank.size, length = #data) + bank.size = bank.size + #data + bank.joined = nil + elseif kind == 0x7F then + if #data < 6 then + return "a decompression table is cut short" + end + local compression = byte(data, 1) as integer + local bitsOut = byte(data, 3) as integer + local count = u16(data, 5) + local width = (bitsOut + 7) // 8 + local values: {integer} = {} + for index = 0, count - 1 do + local at = 7 + index * width + values[index + 1] = width == 1 and (byte(data, at) as integer? or 0) or u16(data, at) + end + state.tables[ + compression + ] = new Table( + subtype = byte(data, 2) as integer, + bitsOut = bitsOut, + bitsIn = byte(data, 4) as integer, + values = values + ) + elseif kind < 0xC0 then + local target = IMAGES[kind] + if target == nil then + registerlog.skip( + state.builder, + "block " .. tostring(kind), + "data block " .. string.format("0x%02X", kind) .. ": memory for a chip tecs.audio.fm does not emulate" + ) + + return nil + end + if #data < 8 then + return "a ROM data block is cut short" + end + local chip = chipFor(state, target[1] as integer, flagged) + if chip ~= nil then + registerlog.addMemory(state.builder, chip, target[2] as string, u32(data, 5), string.sub(data, 9)) + end + end + + return nil +end + +--- Converts UTF-16LE to UTF-8. +local function utf8(bytes: string, at: integer, finish: integer): (string, integer) + local out: {string} = {} + local position = at + while position + 1 <= finish do + local unit = u16(bytes, position) + position = position + 2 + if unit == 0 then + break + end + local code = unit + if unit >= 0xD800 and unit < 0xDC00 and position + 1 <= finish then + local low = u16(bytes, position) + if low >= 0xDC00 and low < 0xE000 then + code = 0x10000 + (unit - 0xD800) * 1024 + (low - 0xDC00) + position = position + 2 + end + end + if code < 0x80 then + out[#out + 1] = string.char(code) + elseif code < 0x800 then + out[#out + 1] = string.char(0xC0 + code // 64, 0x80 + code % 64) + elseif code < 0x10000 then + out[#out + 1] = string.char(0xE0 + code // 4096, 0x80 + code // 64 % 64, 0x80 + code % 64) + else + out[ + #out + 1 + ] = string.char(0xF0 + code // 262144, 0x80 + code // 4096 % 64, 0x80 + code // 64 % 64, 0x80 + code % 64) + end + end + + return table.concat(out), position +end + +--- Reads the GD3 tags, when the file has them. +local function tags(exclusive state: State): nil + local bytes = state.bytes + local at = state.gd3At + if at <= 0 or at + 11 > #bytes or string.sub(bytes, at, at + 3) ~= "Gd3 " then + return + end + local finish = math.min(#bytes, at + 11 + u32(bytes, at + 8)) + local position = at + 12 + local fields: {string} = {} + for index = 1, 11 do + local text, after = utf8(bytes, position, finish) + fields[index] = text + position = after + end + state.builder.tags = fields +end + +--- Reads up to `budget` commands, and returns the reason the file is refused +--- if it is. +local function run(exclusive state: State, budget: integer): string? + local bytes = state.bytes + local builder = state.builder + + local function cut(at: integer, what: string): string + return state.name .. ": byte " .. string.format("0x%X", at - 1) .. ": " .. what + end + + for _ = 1, budget do + local at = state.position + if at > state.finish then + state.done = true + + return nil + end + if at == state.loopAt then + builder.loopTick = state.tick + builder.loopIndex = builder.count + 1 + end + local command = byte(bytes, at) as integer + + local function need(count: integer): boolean + return at + count - 1 <= state.finish + end + + if command == 0x66 then + state.done = true + + return nil + elseif command == 0x61 then + if not need(3) then + return cut(at, "a wait is cut short") + end + wait(state, u16(bytes, at + 1)) + state.position = at + 3 + elseif command == 0x62 then + wait(state, 735) + state.position = at + 1 + elseif command == 0x63 then + wait(state, 882) + state.position = at + 1 + elseif command >= 0x70 and command <= 0x7F then + wait(state, command - 0x6F) + state.position = at + 1 + elseif command >= 0x80 and command <= 0x8F then + local data = bankData(state, 0) + if state.pcm < #data then + put(state, 0x02, false, 0x2A, byte(data, state.pcm + 1) as integer) + end + state.pcm = state.pcm + 1 + wait(state, command - 0x80) + state.position = at + 1 + elseif command == 0x67 then + if not need(7) then + return cut(at, "a data block is cut short") + end + local kind = byte(bytes, at + 2) as integer + local size = u32(bytes, at + 3) + local flagged = size >= BIT31 + size = size % BIT31 + if not need(7 + size) then + return cut(at, "a data block of " .. tostring(size) .. " bytes runs past the end of the file") + end + local reason = dataBlock(state, kind, string.sub(bytes, at + 7, at + 6 + size), flagged) + if reason ~= nil then + return cut(at, reason) + end + state.position = at + 7 + size + elseif command == 0x68 then + state.position = at + 12 + elseif (command >= 0x51 and command <= 0x5F) or (command >= 0xA1 and command <= 0xAF) then + if not need(3) then + return cut(at, "a register write is cut short") + end + local second = command >= 0xA1 + local write = assert(WRITES[second and command - 0x50 or command]) + put( + state, + write[1], + second, + write[2] * 256 + (byte(bytes, at + 1) as integer), + byte(bytes, at + 2) as integer + ) + state.position = at + 3 + elseif command == 0xA0 then + if not need(3) then + return cut(at, "a register write is cut short") + end + local register = byte(bytes, at + 1) as integer + put(state, 0x12, register >= 0x80, register % 128, byte(bytes, at + 2) as integer) + state.position = at + 3 + elseif command == 0xD0 then + if not need(4) then + return cut(at, "a register write is cut short") + end + local port = byte(bytes, at + 1) as integer + put( + state, + 0x0D, + port >= 0x80, + port % 128 * 256 + (byte(bytes, at + 2) as integer), + byte(bytes, at + 3) as integer + ) + state.position = at + 4 + elseif command == 0xE0 then + if not need(5) then + return cut(at, "a PCM seek is cut short") + end + state.pcm = u32(bytes, at + 1) + state.position = at + 5 + elseif command == 0x90 then + if not need(5) then + return cut(at, "a stream setup is cut short") + end + local stream = streamOf(state, byte(bytes, at + 1) as integer) + stream.chipType = byte(bytes, at + 2) as integer + stream.port = byte(bytes, at + 3) as integer + stream.command = byte(bytes, at + 4) as integer + state.position = at + 5 + elseif command == 0x91 then + if not need(5) then + return cut(at, "a stream setup is cut short") + end + local stream = streamOf(state, byte(bytes, at + 1) as integer) + stream.bank = byte(bytes, at + 2) as integer + stream.stepSize = math.max(1, byte(bytes, at + 3) as integer) + stream.stepBase = byte(bytes, at + 4) as integer + state.position = at + 5 + elseif command == 0x92 then + if not need(6) then + return cut(at, "a stream frequency is cut short") + end + local stream = streamOf(state, byte(bytes, at + 1) as integer) + stream.frequency = u32(bytes, at + 2) + state.position = at + 6 + elseif command == 0x93 then + if not need(11) then + return cut(at, "a stream start is cut short") + end + local stream = streamOf(state, byte(bytes, at + 1) as integer) + local offset = u32(bytes, at + 2) + local mode = byte(bytes, at + 6) as integer + local length = u32(bytes, at + 7) + local position = offset == 0xFFFFFFFF and stream.position or offset + stream.stepBase + local kind = mode % 16 + local loop = mode >= 0x80 + local reverse = mode // 16 % 2 == 1 + if kind == 0 then + stream.position = position + elseif kind == 1 then + start(state, stream, position, length, loop, reverse) + elseif kind == 2 then + start(state, stream, position, math.max(1, math.ceil(length * stream.frequency / 1000)), loop, reverse) + else + start(state, stream, position, -1, loop, reverse) + end + state.position = at + 11 + elseif command == 0x94 then + if not need(2) then + return cut(at, "a stream stop is cut short") + end + local id = byte(bytes, at + 1) as integer + for key, stream in pairs(state.streams) do + if id == 0xFF or key == id then + stream.active = false + end + end + state.position = at + 2 + elseif command == 0x95 then + if not need(5) then + return cut(at, "a stream start is cut short") + end + local stream = streamOf(state, byte(bytes, at + 1) as integer) + local block = u16(bytes, at + 2) + local flags = byte(bytes, at + 4) as integer + local bank = state.banks[stream.bank] + local entry = bank ~= nil and bank.blocks[block + 1] or nil + if entry ~= nil then + local reverse = flags // 16 % 2 == 1 + local first = reverse and entry.start + entry.length - 1 or entry.start + stream.stepBase + start(state, stream, first, math.max(1, entry.length // stream.stepSize), flags % 2 == 1, reverse) + end + state.position = at + 5 + elseif command == 0x4F or command == 0x50 or (command >= 0x30 and command <= 0x3F) then + registerlog.skip(builder, "SN76489", "SN76489: not a Yamaha FM chip, which is what tecs.audio.fm emulates") + state.position = at + 2 + elseif command >= 0x40 and command <= 0x4E then + state.position = at + 3 + elseif command >= 0xB0 and command <= 0xBF then + state.position = at + 3 + elseif command >= 0xC0 and command <= 0xDF then + state.position = at + 4 + elseif command >= 0xE1 then + state.position = at + 5 + else + return cut(at, "command " .. string.format("0x%02X", command) .. " is not a VGM command") + end + end + + return nil +end + +--- Begins reading a VGM file, or returns why its header is refused. +--- +--- @param bytes the whole file, decompressed +--- @param name what a refusal names the file as +--- @return the parse, or nil and a reason +export function parser(bytes: string, name: string): (registerlog.Parser?, string?) + if #bytes < 0x40 or string.sub(bytes, 1, 4) ~= "Vgm " then + return nil, name .. ": not a VGM file: it does not begin with \"Vgm \"" + end + local version = u32(bytes, 0x09) + local dataStart: integer = 0x40 + if version >= 0x150 then + local offset = u32(bytes, 0x35) + if offset ~= 0 then + dataStart = 0x34 + offset + end + end + if dataStart > #bytes then + return nil, name .. ": the data offset points past the end of the file" + end + + local function field(offset: integer, width: integer): integer + if offset + width > dataStart or offset + width > #bytes then + return 0 + end + + return width == 4 and u32(bytes, offset + 1) or (byte(bytes, offset + 1) as integer) + end + + local eof = field(0x04, 4) + local finish = eof ~= 0 and math.min(#bytes, 0x04 + eof) or #bytes + local builder = registerlog.newBuilder("vgm", version, RATE) + local clocks: {[integer]: integer} = {} + for id, entry in pairs(FAMILIES) do + local clock = field(entry.clockAt, 4) + if clock ~= 0 then + clocks[id] = clock + if entry.model == nil then + registerlog.skip(builder, entry.name, entry.reason or entry.name) + end + end + end + if version < 0x110 then + -- Before 1.10 the YM2413's clock stood for the YM2612's and the + -- YM2151's too. + local shared = clocks[0x01] + if shared ~= nil then + clocks[0x02] = clocks[0x02] or shared + clocks[0x03] = clocks[0x03] or shared + end + end + local loop = field(0x1C, 4) + local gd3 = field(0x14, 4) + local state = new State( + bytes = bytes, + name = name, + builder = builder, + position = dataStart + 1, + finish = finish, + tick = 0, + loopAt = loop ~= 0 and 0x1C + loop + 1 or -1, + gd3At = gd3 ~= 0 and 0x14 + gd3 + 1 or 0, + total = field(0x18, 4), + clocks = clocks, + flags = {[0x78] = field(0x78, 1), [0x79] = field(0x79, 1)}, + slots = {}, + banks = {}, + streams = {}, + tables = {}, + pcm = 0, + done = false + ) + + return new registerlog.Parser(step = function(budget: integer): (registerlog.Status, registerlog.Builder?, string?) + local reason = run(state, budget) + if reason ~= nil then + return "failed", nil, reason + end + if not state.done then + return "pending" + end + tags(state) + builder.length = math.max(state.tick, state.total) + if builder.loopIndex ~= nil and assert(builder.loopIndex) > builder.count then + -- A loop with nothing after it has nothing to repeat. + builder.loopTick = nil + builder.loopIndex = nil + end + + return "ready", builder + end) +end diff --git a/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/audiomusictest.nupp b/tests/audiomusictest.nupp new file mode 100644 index 00000000..3b808284 --- /dev/null +++ b/tests/audiomusictest.nupp @@ -0,0 +1,1207 @@ +local assets = require("tecs.assets") +local audio = require("tecs.audio") +local audiobackend = require("tecs.platform.audiobackend") +local compression = require("nupp.compression") +local files = require("nupp.io.files") +local hostpump = require("nupp.host.pump") +local midi = require("tecs.audio.music.midi") +local music = require("tecs.audio.music") +local musicfm = require("tecs.audio.music.fm") +local synth = require("tecs.audio.synth") +local test = require("nupp.test") + +local suite = {} + +------------------------------------------------------------------------------- +-- Fixtures, built byte by byte: no music in this suite comes from anywhere +------------------------------------------------------------------------------- + +local function be16(value: integer): string + return string.char(value // 256 % 256, value % 256) +end + +local function be32(value: integer): string + return string.char(value // 16777216 % 256, value // 65536 % 256, value // 256 % 256, value % 256) +end + +local function le16(value: integer): string + return string.char(value % 256, value // 256 % 256) +end + +local function le32(value: integer): string + return string.char(value % 256, value // 256 % 256, value // 65536 % 256, value // 16777216 % 256) +end + +--- Encodes a MIDI variable-length quantity. +local function vlq(value: integer): string + local text = string.char(value % 128) + local rest = value // 128 + while rest > 0 do + text = string.char(rest % 128 + 128) .. text + rest = rest // 128 + end + + return text +end + +--- One event of a track: a delta time and its bytes. +local function at(delta: integer, ...: integer): string + return vlq(delta) .. string.char(...) +end + +local function meta(delta: integer, kind: integer, data: string): string + return vlq(delta) .. string.char(0xFF, kind) .. vlq(#data) .. data +end + +local function tempo(delta: integer, micro: integer): string + return meta(delta, 0x51, string.char(micro // 65536, micro // 256 % 256, micro % 256)) +end + +local function finish(delta: integer): string + return meta(delta, 0x2F, "") +end + +--- A whole Standard MIDI File. +local function smf(format: integer, division: integer, tracks: {string}): string + local chunks: {string} = {"MThd", be32(6), be16(format), be16(#tracks), be16(division)} + for _, track in ipairs(tracks) do + chunks[#chunks + 1] = "MTrk" .. be32(#track) .. track + end + + return table.concat(chunks) +end + +--- A VGM file: a 0x100-byte header with `clocks` at their offsets, the +--- commands, and GD3 tags when given. +local record Vgm + version: integer + clocks: {[integer]: integer} + bytes: {[integer]: integer} + loop: integer? + tags: {string}? +end + +--- Encodes UTF-8 text, which holds nothing past the Basic Multilingual +--- Plane here, as null-terminated UTF-16LE. +local function utf16(text: string): string + local out: {string} = {} + local index = 1 + while index <= #text do + local lead = string.byte(text, index) as integer + local code: integer = lead + local size: integer = 1 + if lead >= 0xE0 then + code = ( + lead % 16 + ) * 4096 + ( + (string.byte(text, index + 1) as integer) % 64 + ) * 64 + (string.byte(text, index + 2) as integer) % 64 + size = 3 + elseif lead >= 0xC0 then + code = (lead % 32) * 64 + (string.byte(text, index + 1) as integer) % 64 + size = 2 + end + out[#out + 1] = le16(code) + index = index + size + end + out[#out + 1] = le16(0) + + return table.concat(out) +end + +local function vgm(header: Vgm, commands: string): string + -- Before 1.50 the data starts at 0x40, and the header has no data offset. + local size: integer = header.version < 0x150 and 0x40 or 0x100 + local bytes: {string} = {} + for index = 1, size do + bytes[index] = "\0" + end + + local function put(offset: integer, text: string): nil + for index = 1, #text do + bytes[offset + index] = string.sub(text, index, index) + end + end + + put(0x00, "Vgm ") + put(0x08, le32(header.version)) + if size == 0x100 then + put(0x34, le32(0x100 - 0x34)) + end + for offset, clock in pairs(header.clocks) do + put(offset, le32(clock)) + end + for offset, value in pairs(header.bytes) do + put(offset, string.char(value)) + end + local loop = header.loop + if loop ~= nil then + put(0x1C, le32(size + loop - 0x1C)) + end + local body = commands .. "\x66" + local tags = header.tags + local gd3 = "" + if tags ~= nil then + local strings: {string} = {} + for index = 1, 11 do + strings[index] = utf16(tags[index] or "") + end + local text = table.concat(strings) + gd3 = "Gd3 " .. le32(0x100) .. le32(#text) .. text + put(0x14, le32(size + #body - 0x14)) + end + local whole = table.concat(bytes) .. body .. gd3 + -- The end-of-file offset is relative to 0x04. + + return string.sub(whole, 1, 4) .. le32(#whole - 4) .. string.sub(whole, 9) +end + +local function header(clocks: {[integer]: integer}, version: integer?): Vgm + return new Vgm(version = version or 0x171, clocks = clocks, bytes = {}, loop = nil, tags = nil) +end + +--- A VGM wait. +local function wait(ticks: integer): string + return "\x61" .. le16(ticks) +end + +--- A VGM data block. +local function block(kind: integer, data: string, flags: integer?): string + return "\x67\x66" .. string.char(kind) .. le32(#data + (flags or 0)) .. data +end + +--- A DRO version 2 file. +local function dro2(hardware: integer, codemap: {integer}, pairs: string, milliseconds: integer): string + local map: {string} = {} + for index, register in ipairs(codemap) do + map[index] = string.char(register) + end + + return "DBRAWOPL" .. le16( + 2 + ) .. le16( + 0 + ) .. le32( + #pairs // 2 + ) .. le32(milliseconds) .. string.char(hardware, 0, 0, 0xFE, 0xFF, #codemap) .. table.concat(map) .. pairs +end + +--- A DRO version 1 file, with a four-byte hardware field or a one-byte one. +local function dro1(hardware: integer, data: string, wide: boolean): string + local field = wide and le32(hardware) or string.char(hardware) + + return "DBRAWOPL" .. le16(0) .. le16(1) .. le32(100) .. le32(#data) .. field .. data +end + +--- Writes `bytes` to a temporary file, runs `body` on its path and removes +--- the file. +local function temporary(bytes: string, suffix: string, body: function(path: string): nil): nil + local file = assert(files.createTemporaryFile(new files.TemporaryOptions(suffix = suffix))) + local path = file:toString() + assert(files.write(path, bytes)) + body(path) + file:close() +end + +--- Runs `body` under a host pump until it settles. +local function underPump(body: function(): boolean): nil + local pump = hostpump.newPump() + local state = pump:start(body) + local passes: integer = 0 + while state == "parked" and passes < 256 do + passes = passes + 1 + state = pump:poll() + end + test.assert(state ~= "parked", "the load never finished") +end + +--- Loads one asset through a fresh loader. +local function load(kind: assets.Kind, path: string): (assets.Assets, assets.Handle?, string?) + local loader = assets.newAssets() + local handle: assets.Handle? = nil + local reason: string? = nil + underPump(function(): boolean + handle, reason = loader:request(kind, path) + + return true + end) + + return loader, handle, reason +end + +------------------------------------------------------------------------------- +-- Standard MIDI Files +------------------------------------------------------------------------------- + +function suite.readsAFormatZeroSongWithRunningStatusAndATempo(): nil + local track = meta( + 0, + 0x03, + "Theme" + ) .. tempo(0, 250000) .. at(0, 0xC0, 5) .. at(0, 0x90, 60, 100) .. at(96, 64, 90) -- running status: another note on + .. at(96, 0x80, 60, 0) .. at(0, 64, 0) .. finish(96) + local song = assert(midi.parse(smf(0, 96, {track}))) + test.equal(song.format, 0) + test.equal(song.ppq, 96) + test.equal(song.title, "Theme") + test.equal(#song.tracks, 1) + test.equal(song.tracks[1].endTick, 288) + -- The tempo of 250 000 microseconds a quarter is 0.25 seconds a beat. + test.equal(#song.tempos, 1) + test.equal(song.tempos[1].microsecondsPerQuarter, 250000) + test.equal(song.duration, 0.75) + local notes: {midi.Event} = {} + for _, event in ipairs(song.events) do + if event.status < 0xF0 then + notes[#notes + 1] = event + end + end + test.equal(#notes, 5) + test.equal(notes[1].status, 0xC0) + test.equal(notes[1].data2, 0, "a program change carries one data byte") + test.equal(notes[3].status, 0x90) + test.equal(notes[3].data1, 64) + test.equal(notes[3].time, 0.25) + test.equal(notes[5].status, 0x80, "running status carries the note off") + test.equal(notes[5].time, 0.5) +end + +function suite.mergesFormatOneTracksThroughOneTempoMap(): nil + -- The tempo track halves the speed at beat one; the note track's events + -- at beat two land at 0.5 + 1.0 seconds. + local conductor = tempo(0, 500000) .. tempo(480, 1000000) .. finish(0) + local notes = at(0, 0x91, 60, 100) .. at(960, 0x81, 60, 0) .. finish(0) + local song = assert(midi.parse(smf(1, 480, {conductor, notes}))) + test.equal(#song.tempos, 2) + test.equal(song.tempos[2].time, 0.5) + local off: midi.Event? = nil + for _, event in ipairs(song.events) do + if event.status == 0x81 then + off = event + end + end + test.equal(assert(off).time, 1.5) + test.equal(assert(off).track, 2) + test.equal(song.duration, 1.5) +end + +function suite.playsFormatTwoPatternsOneAfterAnother(): nil + local first = at(0, 0x90, 60, 100) .. at(480, 0x80, 60, 0) .. finish(0) + local second = at(0, 0x90, 62, 100) .. at(480, 0x80, 62, 0) .. finish(0) + local song = assert(midi.parse(smf(2, 480, {first, second}))) + test.equal(song.format, 2) + test.equal(song.tracks[2].endTick, 960) + test.equal(song.events[3].tick, 480, "the second pattern starts where the first ended") + test.equal(song.events[3].data1, 62) + test.equal(song.duration, 1) +end + +function suite.timesAnSmpteFileInFrames(): nil + -- 25 frames a second, 40 ticks a frame: a thousand ticks a second. + local division = (256 - 25) * 256 + 40 + local song = assert(midi.parse(smf(0, division, {at(0, 0x90, 60, 1) .. at(1500, 0x80, 60, 0) .. finish(500)}))) + test.equal(song.ppq, nil) + test.equal(song.framesPerSecond, 25) + test.equal(song.ticksPerFrame, 40) + test.equal(song.events[2].time, 1.5) + test.equal(song.duration, 2) +end + +function suite.findsLoopPointsInMarkersOrController111(): nil + local marked = meta(480, 0x06, "Loop Start") .. at(0, 0x90, 60, 1) .. meta(480, 0x06, "loopEnd") .. finish(480) + local song = assert(midi.parse(smf(0, 480, {marked}))) + test.equal(song.loopStart, 0.5) + test.equal(song.loopEnd, 1) + test.equal(song.duration, 1.5) + local rpg = at(0, 0x90, 60, 1) .. at(960, 0xB0, 111, 0) .. finish(960) + local looped = assert(midi.parse(smf(0, 480, {rpg}))) + test.equal(looped.loopStart, 1) + test.equal(looped.loopEnd, nil, "a loop with no end runs to the end of the song") + local plain = assert(midi.parse(smf(0, 480, {at(0, 0x90, 60, 1) .. finish(10)}))) + test.equal(plain.loopStart, nil) +end + +function suite.keepsSystemExclusiveAndSkipsForeignChunks(): nil + local reset = "\xF0" .. vlq(5) .. "\x7E\x7F\x09\x01\xF7" + local track = vlq(0) .. reset .. at(0, 0x90, 60, 1) .. finish(10) + local bytes = smf(0, 96, {track}) + -- A chunk that is not a track, between the header and the track. + bytes = string.sub(bytes, 1, 14) .. "XFIH" .. be32(3) .. "abc" .. string.sub(bytes, 15) + local song = assert(midi.parse(bytes)) + test.equal(song.events[1].status, 0xF0) + test.equal(song.events[1].data, "\x7E\x7F\x09\x01\xF7") + test.equal(#song.events, 2) +end + +function suite.refusesWhatIsNotAStandardMidiFile(): nil + local function refused(bytes: string, pattern: string): nil + local song, reason = midi.parse(bytes, "song.mid") + test.equal(song, nil) + test.matches(assert(reason), pattern) + end + + refused("RIFF....", "song.mid: not a Standard MIDI File") + refused(smf(3, 96, {finish(0)}), "format 3 is not") + refused("MThd" .. be32(6) .. be16(0) .. be16(0) .. be16(96), "declares no tracks") + refused(smf(0, 0, {finish(0)}), "division is zero") + refused(smf(0, (256 - 27) * 256 + 4, {finish(0)}), "27 frames") + refused(smf(0, 96, {at(0, 60, 100) .. finish(0)}), "a data byte with no status before it") + refused(smf(0, 96, {at(0, 0x90, 60, 0x80) .. finish(0)}), "top bit set") + refused(smf(0, 96, {"\x81\x81\x81\x81\x01\x90\x3C\x01"}), "longer than four bytes") + local cut = smf(0, 96, {at(0, 0x90, 60, 100) .. finish(0)}) + -- The track claims more bytes than the file has. + refused(string.sub(cut, 1, #cut - 5), "cut short") + refused(smf(0, 96, {at(0, 0xF4, 0)}), "0xF4 is not allowed") + refused("MThd" .. be32(6) .. be16(0) .. be16(1) .. be16(96), "holds no MTrk chunk") +end + +function suite.loadsAMidiFileThroughItsKind(): nil + temporary( + smf(0, 96, { + at(0, 0x90, 60, 100) .. at(96, 0x80, 60, 0) .. finish(0) + }), + ".mid", + function(path: string): nil + local loader, handle, reason = load(midi.kind, path) + test.assert(handle ~= nil, reason or "no handle") + local held = assert(handle) + test.equal(held.kind, "music.midi") + local song = held:value() as midi.Song + test.equal(song.duration, 0.5) + held:release() + local _, missing, failure = load(midi.kind, path .. ".none") + test.equal(missing, nil) + test.assert(failure ~= nil) + loader:shutdown() + end + ) + temporary("not midi", ".mid", function(path: string): nil + local loader, nothing, refusal = load(midi.kind, path) + test.equal(nothing, nil) + test.matches(assert(refusal), "not a Standard MIDI File") + loader:shutdown() + end) +end + +------------------------------------------------------------------------------- +-- VGM, VGZ and DRO +------------------------------------------------------------------------------- + +function suite.readsTheWritesOfAYm2612AndSkipsTheSn76489BesideIt(): nil + local commands = "\x50\x9F" .. "\x52\x28\xF0" .. wait(441) .. "\x53\x30\x71" .. "\x62" .. "\x70" + local log = assert(musicfm.parse(vgm(header({[0x0C] = 3579545, [0x2C] = 7670453}), commands))) + test.equal(log.format, "vgm") + test.equal(log.version, 0x171) + test.equal(log.rate, 44100) + test.equal(#log.chips, 1) + test.equal(log.chips[1].chip, "ym2612") + test.equal(log.chips[1].clock, 7670453) + test.equal(log.count, 2) + local tick, chip, register, value = log:entry(1) + test.equal(tick, 0) + test.equal(chip, 0) + test.equal(register, 0x28) + test.equal(value, 0xF0) + tick, chip, register, value = log:entry(2) + test.equal(tick, 441) + test.equal(register, 0x130, "port 1 is the second bank") + test.equal(log.length, 441 + 735 + 1) + test.equal(#log.skipped, 1) + test.matches(log.skipped[1], "SN76489") + test.raises( + function(): nil + log:entry(3) + end, + "from 1 to 2" + ) +end + +function suite.namesDualAndAlternateChipsFromTheClockFlags(): nil + -- Bit 30 declares a second YM3812, bit 31 makes the YM2151 a YM2164 and + -- the YM2610 a YM2610B. + local clocks = {[0x50] = 3579545 + 1073741824, [0x30] = 3579545 + 2147483648, [0x4C] = 8000000 + 2147483648} + local commands = "\x5A\x20\x01" .. "\xAA\x20\x02" .. "\x54\x08\x00" .. "\x58\x28\x01" + local log = assert(musicfm.parse(vgm(header(clocks), commands))) + test.equal(#log.chips, 4) + test.equal(log.chips[1].chip, "ym3812") + test.equal(log.chips[2].chip, "ym3812") + test.assert(log.chips[2].second) + test.equal(log.chips[3].chip, "ym2164") + test.equal(log.chips[4].chip, "ym2610b") + local _, chip = log:entry(2) + test.equal(chip, 1) +end + +function suite.findsTheLoopOffsetAndReadsGd3Tags(): nil + local first = "\x5A\x20\x01" .. wait(100) + local looped = "\x5A\x20\x02" .. wait(200) + local file = header({[0x50] = 3579545}) + file.loop = #first + file.tags = {"Stage 1", "ステージ1", "Game", "", "PC", "", "Composer", "", "1990", "Tecs", "made up"} + local log = assert(musicfm.parse(vgm(file, first .. looped))) + test.equal(log.loopStart, 100 / 44100) + test.equal(log.duration, 300 / 44100) + local tags = assert(log.tags) + test.equal(tags.title, "Stage 1") + test.equal(tags.titleJapanese, "ステージ1") + test.equal(tags.author, "Composer") + test.equal(tags.converter, "Tecs") + test.equal(tags.notes, "made up") +end + +function suite.assemblesMemoryImagesFromRomDataBlocks(): nil + local rom = block(0x82, le32(0x1000) .. le32(0x10) .. "\1\2\3\4") .. block(0x83, le32(16) .. le32(0) .. "\9") + local log = assert(musicfm.parse(vgm(header({[0x4C] = 8000000}), rom .. "\x58\x10\x00"))) + test.equal(log.chips[1].chip, "ym2610") + local image = assert(log.chips[1].memory.adpcmA) + test.equal(#image, 0x14, "the image reaches as far as its furthest piece") + test.equal(string.sub(image, 0x11, 0x14), "\1\2\3\4") + test.equal(string.byte(image, 1), 0) + test.equal(log.chips[1].memory.adpcmB, "\9") +end + +function suite.turnsDacDataIntoTimedWrites(): nil + -- Three PCM bytes, the first two through 0x8n commands, then a seek back + -- and the first again. + local commands = block(0x00, "\x10\x20\x30") .. "\x81" .. "\x82" .. "\xE0" .. le32(0) .. "\x80" + local log = assert(musicfm.parse(vgm(header({[0x2C] = 7670453}), commands))) + test.equal(log.count, 3) + local tick, _, register, value = log:entry(1) + test.equal(tick, 0) + test.equal(register, 0x2A) + test.equal(value, 0x10) + tick, _, _, value = log:entry(2) + test.equal(tick, 1, "a 0x81 waits a tick after its write") + test.equal(value, 0x20) + tick, _, _, value = log:entry(3) + test.equal(tick, 3) + test.equal(value, 0x10) +end + +function suite.playsADacStreamAtItsFrequency(): nil + -- Stream 0 writes YM2612 register 0x2A from bank 0 at 11025 Hz, four + -- ticks apart, for three commands. + local setup = "\x90\x00\x02\x00\x2A" .. "\x91\x00\x00\x01\x00" .. "\x92\x00" .. le32(11025) + local start = "\x93\x00" .. le32(0) .. "\x01" .. le32(3) + local commands = block(0x00, "\x11\x22\x33\x44") .. "\x52\x2B\x80" .. setup .. start .. wait(20) + local log = assert(musicfm.parse(vgm(header({[0x2C] = 7670453}), commands))) + test.equal(log.count, 4) + local expected: {{integer}} = {{0, 0x11}, {4, 0x22}, {8, 0x33}} + for index, pair in ipairs(expected) do + local tick, _, register, value = log:entry(index + 1) + test.equal(tick, pair[1]) + test.equal(register, 0x2A) + test.equal(value, pair[2]) + end +end + +function suite.decompressesABitPackedDataBlock(): nil + -- Four 4-bit values, 1 2 3 4, copied into 8-bit samples with 0x10 added. + local packed = string.char(0x12, 0x34) + local data = "\x00" .. le32(4) .. "\x08\x04\x00" .. le16(0x10) .. packed + local commands = block(0x40, data) .. "\x80\x80\x80\x80" + local log = assert(musicfm.parse(vgm(header({[0x2C] = 7670453}), commands))) + test.equal(log.count, 4) + for index = 1, 4 do + local _, _, _, value = log:entry(index) + test.equal(value, 0x10 + index) + end +end + +function suite.sharesTheYm2413ClockBeforeVersion110(): nil + local log = assert(musicfm.parse(vgm(header({[0x10] = 7670453}, 0x101), "\x52\x28\xF0"))) + test.equal(log.chips[1].chip, "ym2612") + test.equal(log.chips[1].clock, 7670453) +end + +function suite.readsTheAyFamilyAsAYm2149(): nil + local file = header({[0x74] = 2000000}) + file.bytes[0x79] = 0x10 + local log = assert(musicfm.parse(vgm(file, "\xA0\x08\x0F" .. "\xA0\x88\x0F"))) + test.equal(log.chips[1].chip, "ym2149") + test.equal(log.chips[1].clock, 1000000, "pin 26 low halves the clock") + test.equal(#log.chips, 1, "a second chip the header does not declare is skipped") + test.matches(log.skipped[1], "second chip") + local ay8930 = header({[0x74] = 2000000}) + ay8930.bytes[0x78] = 0x03 + local _, reason = musicfm.parse(vgm(ay8930, "\xA0\x08\x0F")) + test.matches(assert(reason), "AY8930") +end + +function suite.inflatesAVgzFile(): nil + local plain = vgm(header({[0x50] = 3579545}), "\x5A\x20\x01" .. wait(10)) + local packed = compression.format("gzip"):compress(plain) + local log = assert(musicfm.parse(packed, "song.vgz")) + test.equal(log.count, 1) + test.equal(log.length, 10) + local _, reason = musicfm.parse("\x1F\x8B\x08\x00garbage", "broken.vgz") + test.matches(assert(reason), "broken.vgz: the gzip stream cannot be inflated") +end + +function suite.refusesALogWithNothingToPlayOrACommandItCannotRead(): nil + local _, onlySn = musicfm.parse(vgm(header({[0x0C] = 3579545}), "\x50\x9F" .. wait(10)), "sms.vgm") + test.matches(assert(onlySn), "sms.vgm: nothing in the file is for a chip tecs.audio.fm emulates") + test.matches(assert(onlySn), "SN76489") + local _, unknown = musicfm.parse(vgm(header({[0x50] = 3579545}), "\x5A\x20\x01\x60"), "odd.vgm") + test.matches(assert(unknown), "command 0x60 is not a VGM command") + local truncated = vgm(header({[0x50] = 3579545}), "") + local _, cut = musicfm.parse(string.sub(truncated, 1, #truncated - 1) .. "\x5A\x20", "cut.vgm") + test.matches(assert(cut), "cut short") + local _, neither = musicfm.parse("MThd", "x") + test.matches(assert(neither), "neither a VGM, a VGZ nor a DRO file") + local _, unclocked = musicfm.parse(vgm(header({}), "\x5A\x20\x01"), "y") + test.matches(assert(unclocked), "no clock") +end + +function suite.readsADroVersionTwoOpl3WithItsCodemap(): nil + -- Codes 0 and 1 map to 0x20 and 0xB0; 0x81 is 0xB0 of the high bank. + local data = "\x00\x01" .. "\xFE\x09" .. "\x01\x32" .. "\xFF\x01" .. "\x81\x32" + local log = assert(musicfm.parse(dro2(2, {0x20, 0xB0}, data, 600))) + test.equal(log.format, "dro") + test.equal(log.version, 2) + test.equal(log.rate, 1000) + test.equal(log.chips[1].chip, "ymf262") + local tick, _, register, value = log:entry(2) + test.equal(tick, 10, "a short delay of 9 is ten milliseconds") + test.equal(register, 0xB0) + test.equal(value, 0x32) + tick, _, register = log:entry(3) + test.equal(tick, 10 + 512) + test.equal(register, 0x1B0) + test.equal(log.length, 600) +end + +function suite.placesADualOpl2CaptureOneChipToEachSide(): nil + local log = assert(musicfm.parse(dro2(1, {0x20}, "\x00\x01\x80\x02", 10))) + test.equal(#log.chips, 2) + test.equal(log.chips[1].left, 1) + test.equal(log.chips[1].right, 0) + test.equal(log.chips[2].right, 1) + local _, chip = log:entry(2) + test.equal(chip, 1) +end + +function suite.readsADroVersionOneWithEitherHardwareField(): nil + -- A write, a one-byte delay, a two-byte delay, the high chip, an + -- escaped write to register 0x02. + local data = "\x20\x01" .. "\x00\x04" .. "\x01\x00\x01" .. "\x03" .. "\x04\x02\x07" + for _, wide in ipairs({true, false}) do + local log = assert(musicfm.parse(dro1(2, data, wide))) + test.equal(log.version, 1) + test.equal(#log.chips, 2, "hardware 2 is a dual OPL2 in version 1") + local tick, chip, register, value = log:entry(2) + test.equal(tick, 5 + 257) + test.equal(chip, 1) + test.equal(register, 0x02) + test.equal(value, 7) + end +end + +function suite.refusesADroFileItCannotRead(): nil + local _, version = musicfm.parse("DBRAWOPL" .. le16(3) .. le16(0) .. string.rep("\0", 20), "a.dro") + test.matches(assert(version), "DRO version 3.0") + local _, code = musicfm.parse(dro2(0, {0x20}, "\x05\x01", 1), "b.dro") + test.matches(assert(code), "code 5 is past the codemap") + local _, hardware = musicfm.parse(dro2(7, {}, "", 1), "c.dro") + test.matches(assert(hardware), "hardware type 7") +end + +function suite.loadsAVgzFileThroughItsKind(): nil + local plain = vgm(header({[0x50] = 3579545}), "\x5A\x20\x01" .. wait(10)) + temporary(compression.format("gzip"):compress(plain), ".vgz", function(path: string): nil + local loader, handle, reason = load(musicfm.kind, path) + test.assert(handle ~= nil, reason or "no handle") + local held = assert(handle) + test.equal(held.kind, "music.fm") + test.equal((held:value() as musicfm.RegisterLog).count, 1) + held:release() + loader:shutdown() + end) +end + +------------------------------------------------------------------------------- +-- Players +------------------------------------------------------------------------------- + +--- One event a test port received. +local record Sent + voice: integer + kind: string + a: integer + b: integer + c: integer + at: number +end + +--- A backend whose synths and chips log what they are sent, on a clock the +--- test moves. +local record Harness + backend: audiobackend.Backend + commands: {audiobackend.Command} + sent: {Sent} + clock: {number} + chips: {string} + clocks: {integer} + images: {string} + closed: {integer} + full: {boolean} +end + +local function newHarness(): Harness + local commands: {audiobackend.Command} = {} + local sent: {Sent} = {} + local clock = {0.0} + local chips: {string} = {} + local clocks: {integer} = {} + local images: {string} = {} + local closed: {integer} = {} + local full = {false} + + local function record(voice: integer, kind: string, a: integer, b: integer, c: integer, when: number): boolean + if full[1] then + return false + end + sent[#sent + 1] = new Sent(voice = voice, kind = kind, a = a, b = b, c = c, at = when) + + return true + end + + local backend = audiobackend.silent() + backend.available = true + backend.submit = function(pending: {audiobackend.Command}, count: integer): nil + for index = 1, count do + local source = pending[index] + local copy = audiobackend.newCommand() + copy.kind = source.kind + copy.handle = source.handle + copy.flags = source.flags + copy.gain = source.gain + copy.fade = source.fade + commands[#commands + 1] = copy + end + end + backend.loadSoundFont = function(_path: string): (integer?, string?) + return 1 + end + backend.openSynth = function( + _font: integer, + handle: integer, + _polyphony: integer, + _effects: boolean + ): (audiobackend.SynthPort?, string?) + return new audiobackend.SynthPort( + send = function(status: integer, data1: integer, data2: integer, when: number): boolean + return record(handle, "message", status, data1, data2, when) + end, + notesOff = function(channel: integer, immediate: boolean): boolean + return record(handle, "notesOff", channel, immediate and 1 or 0, 0, 0) + end, + reset = function(): boolean + return record(handle, "reset", 0, 0, 0, 0) + end, + now = function(): number + return clock[1] + end, + close = function(): nil + closed[#closed + 1] = handle + end + ) + end + backend.openFm = function( + handle: integer, + chip: audiobackend.FmChip, + rate: integer + ): (audiobackend.FmPort?, string?) + chips[#chips + 1] = chip + clocks[#clocks + 1] = rate + + return new audiobackend.FmPort( + rate = 50000, + write = function(register: integer, value: integer, when: number): boolean + return record(handle, "write", register, value, 0, when) + end, + writes = function(pairs: {integer}, count: integer, when: number): boolean + if full[1] then + return false + end + for index = 1, count do + record(handle, "write", pairs[2 * index - 1], pairs[2 * index], 0, when) + end + + return true + end, + writePort = function(offset: integer, data: integer, when: number): boolean + return record(handle, "port", offset, data, 0, when) + end, + writePorts = function(_pairs: {integer}, _count: integer, _when: number): boolean + return true + end, + setMemory = function(class: audiobackend.FmMemory, image: string): boolean + images[#images + 1] = class .. ":" .. tostring(#image) + + return true + end, + reset = function(when: number): boolean + return record(handle, "reset", 0, 0, 0, when) + end, + now = function(): number + return clock[1] + end, + close = function(): nil + closed[#closed + 1] = handle + end + ) + end + + return new Harness( + backend = backend, + commands = commands, + sent = sent, + clock = clock, + chips = chips, + clocks = clocks, + images = images, + closed = closed, + full = full + ) +end + +--- Returns the messages sent so far with the status `status`, as notes. +local function sentOf(harness: Harness, kind: string, first: integer?): {Sent} + local found: {Sent} = {} + for _, event in ipairs(harness.sent) do + if event.kind == kind and (first == nil or event.a == first) then + found[#found + 1] = event + end + end + + return found +end + +--- Returns the events of `kind` sent after the first `since`. +local function sentSince(harness: Harness, since: integer, kind: string, first: integer?): {Sent} + local found: {Sent} = {} + for index = since + 1, #harness.sent do + local event = harness.sent[index] + if event.kind == kind and (first == nil or event.a == first) then + found[#found + 1] = event + end + end + + return found +end + +local function lastCommand(harness: Harness, kind: integer): audiobackend.Command? + for index = #harness.commands, 1, -1 do + if harness.commands[index].kind == kind then + return harness.commands[index] + end + end + + return nil +end + +local function near(actual: number, expected: number, what: string?): nil + test.assert( + math.abs(actual - expected) < 1e-9, + (what or "") .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual) + ) +end + +--- A song of four notes, one every half second, two seconds long. +local function scale(loop: boolean): midi.Song + local track = at(0, 0x90, 60, 100) .. at(480, 0x80, 60, 0) .. at(0, 0x90, 62, 100) + if loop then + track = meta(0, 0x06, "loopStart") .. track + end + track = track .. at(480, 0x80, 62, 0) .. at(0, 0x90, 64, 100) .. at(480, 0x80, 64, 0) .. at(0, 0x90, 65, 100) + if loop then + track = track .. meta(480, 0x06, "loopEnd") .. at(0, 0x80, 65, 0) .. finish(0) + else + track = track .. at(480, 0x80, 65, 0) .. finish(0) + end + + return assert(midi.parse(smf(0, 480, {track}))) +end + +local function midiPlayer(harness: Harness, song: midi.Song, options: music.PlayOptions?): (audio.Audio, music.Player) + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 8}) + local font = assert(synth.loadSoundFont(mixer, "font.sf2")) + + return mixer, assert(midi.playSong(mixer, font, song, options)) +end + +function suite.schedulesAheadOnTheVoiceClock(): nil + local harness = newHarness() + local mixer, player = midiPlayer(harness, scale(false)) + -- Started at 0 with a lead of 0.01: the first note is due then, and the + -- next one, half a second in, is past the lookahead. + local ons = sentOf(harness, "message", 0x90) + test.equal(#ons, 1) + near(ons[1].at, 0.01, "first note") + harness.clock[1] = 0.48 + mixer:update() + ons = sentOf(harness, "message", 0x90) + test.equal(#ons, 2) + near(ons[2].at, 0.51, "second note") + test.equal(sentOf(harness, "message", 0x80)[1].a, 0x80) + near(player:position(), 0.47, "position") + test.assert(player:playing()) + test.equal(player:status(), "playing") + near(player:duration(), 2) +end + +function suite.slipsAfterAStallRatherThanRushing(): nil + local harness = newHarness() + local mixer, _player = midiPlayer(harness, scale(false)) + -- The frame thread stalls for three seconds of audio clock: the note due + -- at half a second is late, and plays at once. + harness.clock[1] = 3 + mixer:update() + local ons = sentOf(harness, "message", 0x90) + test.equal(#ons, 2, "only the late note goes, not all that was missed") + near(ons[2].at, 3.01, "the late note") + harness.clock[1] = 3.48 + mixer:update() + ons = sentOf(harness, "message", 0x90) + test.equal(#ons, 3) + near(ons[3].at, 3.51, "the next keeps its distance") +end + +function suite.finishesAndFreesItsSynthAfterTheTail(): nil + local harness = newHarness() + local mixer, player = midiPlayer(harness, scale(false)) + for step = 1, 50 do + harness.clock[1] = step * 0.05 + mixer:update() + end + test.equal(#sentOf(harness, "message", 0x90), 4) + test.assert(player:finished()) + test.equal(player:status(), "finished") + test.equal(#harness.closed, 0, "the release tail still rings") + harness.clock[1] = 2.01 + 2.1 + mixer:update() + test.equal(player:status(), "stopped") + test.assert(player:finished()) + test.equal(#harness.closed, 1) + test.assert(lastCommand(harness, audiobackend.STOP) ~= nil) +end + +function suite.loopsForeverReleasingHeldNotesAtTheEnd(): nil + local harness = newHarness() + local song = scale(true) + near(assert(song.loopStart), 0) + near(assert(song.loopEnd), 2) + local mixer, player = midiPlayer(harness, song) + test.assert(player:looping()) + for step = 1, 100 do + harness.clock[1] = step * 0.05 + mixer:update() + end + test.equal(player:loops(), 2) + test.assert(not player:finished()) + local ons = sentOf(harness, "message", 0x90) + test.assert(#ons >= 9) + -- The fifth note is the first of the second pass, two seconds on. + near(ons[5].at, 2.01, "the loop's first note") + -- The note held at the loop's end was released there. + local offs = sentOf(harness, "message", 0x80) + local released = false + for _, off in ipairs(offs) do + if off.b == 65 and math.abs(off.at - 2.01) < 1e-9 then + released = true + end + end + test.assert(released, "the held note is released at the loop") + player:setLoop(false) + for step = 101, 160 do + harness.clock[1] = step * 0.05 + mixer:update() + end + test.assert(player:finished()) +end + +function suite.scalesTheTempoFromWhatIsScheduled(): nil + local harness = newHarness() + local mixer, player = midiPlayer(harness, scale(false), {tempo = 2}) + test.equal(player:tempo(), 2) + harness.clock[1] = 0.24 + mixer:update() + local ons = sentOf(harness, "message", 0x90) + test.equal(#ons, 2) + near(ons[2].at, 0.26, "twice as fast") + player:setTempo(1) + harness.clock[1] = 0.6 + mixer:update() + ons = sentOf(harness, "message", 0x90) + test.equal(#ons, 2) + test.raises( + function(): nil + player:setTempo(0) + end, + "tempo must be from 0.01 to 100" + ) +end + +function suite.seeksByResettingAndChasingTheSongState(): nil + local track = at( + 0, + 0xB1, + 7, + 90 + ) .. at( + 0, + 0xC1, + 33 + ) .. at( + 0, + 0xE1, + 0, + 80 + ) .. at(0, 0x91, 40, 100) .. at(960, 0x81, 40, 0) .. at(0, 0x91, 43, 100) .. at(480, 0x81, 43, 0) .. finish(0) + local harness = newHarness() + local mixer, player = midiPlayer(harness, assert(midi.parse(smf(0, 480, {track})))) + harness.clock[1] = 1 + local before = #harness.sent + player:seek(1.5) + test.equal(#sentOf(harness, "reset"), 1) + local controls = sentSince(harness, before, "message", 0xB1) + test.equal(#controls, 1) + test.equal(controls[1].c, 90) + near(controls[1].at, 1.01, "the chase lands at the seek") + test.equal(sentSince(harness, before, "message", 0xC1)[1].b, 33) + local bend = sentSince(harness, before, "message", 0xE1)[1] + test.equal(bend.b, 0) + test.equal(bend.c, 80) + mixer:update() + -- From 1.5 the next event is the note off at 1.5 itself. + local offs = sentOf(harness, "message", 0x81) + near(offs[#offs].at, 1.01, "the event at the seek point") + near(player:position(), 1.49) +end + +function suite.mutesAChannelAndPausesTheSynth(): nil + local track = at( + 0, + 0x90, + 60, + 100 + ) .. at(0, 0x91, 62, 100) .. at(480, 0x80, 60, 0) .. at(0, 0x81, 62, 0) .. finish(0) + local harness = newHarness() + local mixer, player = midiPlayer(harness, assert(midi.parse(smf(0, 480, {track})))) + test.equal(#sentOf(harness, "message", 0x91), 1) + player:setMuted(1, true) + test.equal(sentOf(harness, "notesOff")[1].a, 1) + player:seek(0) + mixer:update() + test.equal(#sentOf(harness, "message", 0x91), 1, "no note on reaches the muted channel") + test.equal(#sentOf(harness, "message", 0x90), 2, "the other channel plays on") + player:pause() + test.assert(player:paused()) + mixer:update() + test.assert(lastCommand(harness, audiobackend.PAUSE) ~= nil) + player:resume() + mixer:update() + test.assert(lastCommand(harness, audiobackend.RESUME) ~= nil) + test.raises( + function(): nil + player:setMuted(16, true) + end, + "from 0 to 15" + ) + player:stop(0.5) + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.STOP)).fade, 0.5) + player:stop() + test.equal(player:status(), "stopped") +end + +function suite.followsAReloadOfTheSongHandle(): nil + local first = smf(0, 480, {at(0, 0x90, 60, 100) .. at(9600, 0x80, 60, 0) .. finish(0)}) + temporary(first, ".mid", function(path: string): nil + local loader, handle = load(midi.kind, path) + local held = assert(handle) + local harness = newHarness() + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 8}) + local font = assert(synth.loadSoundFont(mixer, "font.sf2")) + local player = assert(midi.play(mixer, font, held, {loop = false})) + held:release() + test.equal(held:holds(), 1, "the player holds the song") + harness.clock[1] = 1 + mixer:update() + assert(files.write(path, smf(0, 480, {at(0, 0x90, 72, 100) .. at(9600, 0x80, 72, 0) .. finish(0)}))) + underPump(function(): boolean + assert(loader:reload(midi.kind, held)) + + return true + end) + mixer:update() + test.equal(#sentOf(harness, "reset"), 1, "the reload seeks") + harness.clock[1] = 20 + mixer:update() + local offs = sentOf(harness, "message", 0x80) + test.equal(offs[#offs].b, 72, "the new song plays from where the old one was") + player:stop() + test.equal(held:holds(), 0) + local other, otherHandle = load(musicfm.kind, path) + test.equal(otherHandle, nil, "a MIDI file is not a register log") + other:shutdown() + loader:shutdown() + end) +end + +function suite.refusesAHandleOfAnotherKind(): nil + local plain = vgm(header({[0x50] = 3579545}), "\x5A\x20\x01" .. wait(10)) + temporary(plain, ".vgm", function(path: string): nil + local loader, handle = load(musicfm.kind, path) + local held = assert(handle) + local harness = newHarness() + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 8}) + local font = assert(synth.loadSoundFont(mixer, "font.sf2")) + test.raises( + function(): nil + midi.play(mixer, font, held) + end, + "not as a MIDI song" + ) + held:release() + loader:shutdown() + end) +end + +function suite.refusesPlayOptionsOutOfRange(): nil + local harness = newHarness() + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 8}) + local font = assert(synth.loadSoundFont(mixer, "font.sf2")) + test.raises( + function(): nil + midi.playSong(mixer, font, scale(false), {lookahead = 5}) + end, + "lookahead must be from" + ) + test.raises( + function(): nil + midi.playSong(mixer, font, scale(false), {start = -1}) + end, + "start must not be negative" + ) +end + +local function fmLog(): musicfm.RegisterLog + -- A YM2612 note keyed on at 0 and off at a second, a key-on of channel 1 + -- at half a second, and the second bank written at once. + local commands = "\x52\x28\xF0" .. "\x52\xA0\x22" .. "\x53\x30\x71" .. wait( + 22050 + ) .. "\x52\x28\xF1" .. wait(22050) .. "\x52\x28\x00" .. wait(4410) + return assert( + musicfm.parse( + vgm( + header({ + [0x2C] = 7670453, + [0x4C] = 8000000 + }), + block(0x82, le32(16) .. le32(0) .. "\1\2") .. "\x58\x10\x00" .. commands + ) + ) + ) +end + +function suite.playsALogOnChipsOfItsOwn(): nil + local harness = newHarness() + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 8}) + local log = fmLog() + test.equal(#log.chips, 2) + local player = assert(musicfm.playLog(mixer, log, {group = "music", loop = false})) + test.equal(harness.chips[1], "ym2610") + test.equal(harness.chips[2], "ym2612") + test.equal(harness.clocks[2], 7670453) + test.equal(harness.images[1], "adpcmA:2") + local writes = sentOf(harness, "write") + -- The YM2610's write and the YM2612's three at tick zero. + test.equal(#writes, 4) + near(writes[2].at, 0.01) + test.equal(writes[4].a, 0x130) + harness.clock[1] = 0.5 + mixer:update() + writes = sentOf(harness, "write") + test.equal(#writes, 5) + near(writes[5].at, 0.51) + test.equal(writes[5].b, 0xF1) + player:setMuted(1, true) + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.SET_GAIN)).gain, 0) + test.raises( + function(): nil + player:setMuted(2, true) + end, + "chips 0 to 1" + ) + -- A seek resets both chips and writes back every register's last value. + player:seek(0.75) + local resets = sentOf(harness, "reset") + test.equal(#resets, 2) + writes = sentOf(harness, "write") + local last = writes[#writes] + test.equal(last.a, 0x130, "the YM2612's registers in ascending order") + near(last.at, 0.51) + harness.clock[1] = 2 + mixer:update() + harness.clock[1] = 4 + mixer:update() + test.equal(player:status(), "stopped") + test.equal(#harness.closed, 2) +end + +function suite.placesADualOpl2CaptureOnItsVoices(): nil + local harness = newHarness() + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 8}) + local log = assert(musicfm.parse(dro2(1, {0x20}, "\x00\x01\x80\x02", 10))) + local player = assert(musicfm.playLog(mixer, log)) + mixer:update() + local stereo = 0 + for _, command in ipairs(harness.commands) do + if command.kind == audiobackend.SET_STEREO then + stereo = stereo + 1 + end + end + test.equal(stereo, 2) + player:stop() +end + +function suite.loopsALogFromItsLoopWrite(): nil + local first = "\x5A\x20\x01" .. wait(4410) + local looped = "\x5A\x20\x02" .. wait(4410) + local file = header({[0x50] = 3579545}) + file.loop = #first + local harness = newHarness() + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 8}) + local player = assert(musicfm.playLog(mixer, assert(musicfm.parse(vgm(file, first .. looped))))) + for step = 1, 12 do + harness.clock[1] = step * 0.05 + mixer:update() + end + -- Twice the first pass's tenth of a second, then a loop every tenth. + test.equal(player:loops(), 5) + local writes = sentOf(harness, "write") + test.equal(writes[1].b, 1) + test.equal(writes[2].b, 2) + test.equal(writes[3].b, 2, "the loop goes back to its own write") + near(writes[3].at, 0.21) + player:stop() +end + +function suite.reportsAFullQueueByRetryingLater(): nil + local harness = newHarness() + local mixer, _player = midiPlayer(harness, scale(false)) + harness.full[1] = true + harness.clock[1] = 0.48 + mixer:update() + test.equal(#sentOf(harness, "message", 0x90), 1) + harness.full[1] = false + mixer:update() + local ons = sentOf(harness, "message", 0x90) + test.equal(#ons, 2, "the refused note goes on the next update") + near(ons[2].at, 0.51) +end + +return suite diff --git a/tests/audiosynthtest.nupp b/tests/audiosynthtest.nupp new file mode 100644 index 00000000..276a8c32 --- /dev/null +++ b/tests/audiosynthtest.nupp @@ -0,0 +1,387 @@ +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 + copy.fade = source.fade + 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.pausesTheVoiceAndFadesItOut(): nil + local harness = newHarness(true, true) + local mixer, music = made(harness) + mixer:update() + local handle = assert(lastCommand(harness, audiobackend.PLAY)).handle + music:pause() + test.assert(music:paused()) + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.PAUSE)).handle, handle) + music:resume() + test.assert(not music:paused()) + mixer:update() + test.equal(assert(lastCommand(harness, audiobackend.RESUME)).handle, handle) + music:destroy(0.75) + mixer:update() + local stop = assert(lastCommand(harness, audiobackend.STOP)) + test.equal(stop.handle, handle) + test.equal(stop.fade, 0.75) + test.equal(harness.closed[1], 1) +end + +function suite.countsAgainstTheVoiceCeiling(): nil + local harness = newHarness(true, true) + local mixer = audio.newAudio({backend = harness.backend, maxVoices = 1}) + 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 diff --git a/tests/tecs/compatibilitytest.nupp b/tests/tecs/compatibilitytest.nupp index 30de1770..e93f4224 100644 --- a/tests/tecs/compatibilitytest.nupp +++ b/tests/tecs/compatibilitytest.nupp @@ -948,6 +948,9 @@ function suite.contentKindNames(): nil -- New with the rewrite: the SDL tree noted no font or shader content. test.equal(assets.FONT, "font") test.equal(assets.SHADER, "shader") + -- New with the music kinds. + test.equal(assets.MUSIC_MIDI, "music.midi") + test.equal(assets.MUSIC_FM, "music.fm") end ------------------------------------------------------------------------------- diff --git a/tools/tecs/dev/commands.nupp b/tools/tecs/dev/commands.nupp index 25521cd3..7a9bddde 100644 --- a/tools/tecs/dev/commands.nupp +++ b/tools/tecs/dev/commands.nupp @@ -160,7 +160,7 @@ export function sdk(): {[string]: string} "CARGO_ENCODED_RUSTFLAGS", compilerRoot .. "/scripts/toolchain", "host-library", - "base,native-files,native-net" + "base,native-files,native-net,native-compression" }) const directory = assert(text:match("([^\r\n]+)%s*$"), "the SDK staging command returned no path") assert(files.isFile(path.newPath(directory, "libnupp.a")), "the staged SDK has no libnupp.a")