Repository navigation
tecs.audio.fm: an emulated Yamaha FM chip as a mixer voice (ymfm-oxide) - #11
Closed
albanmgicquel wants to merge 5 commits into
Closed
albanmgicquel wants to merge 5 commits into
albanmgicquel wants to merge 5 commits into
Conversation
rustysynth (MIT) renders a SoundFont in the output callback as the input of a voice started by a PLAY carrying the new SYNTH flag. Events cross from the game thread through a bounded lock-free SPSC ring (4096) into a preallocated schedule (4096); a send that finds no room is refused and counted. The render splits at rustysynth's 8-frame block boundaries and applies each event at the first boundary at or after its frame, so the output is a deterministic function of the schedule. The clock is frames rendered over the rate. FFI: tecs_audio_load_soundfont, tecs_audio_release_soundfont, tecs_audio_synth_open/send/notes_off/reset/now/close. Tests cover the grid, ordering, reset discarding, queue bounds, and with TECS_TEST_SOUNDFONT a real SoundFont: sample-exact landing, determinism, voice gain, cuts and pause.
loadSoundFont(audio, path) and new(audio, font, {group, gain, polyphony,
effects}) with send, noteOn, noteOff, controlChange, programChange,
pitchBend, allNotesOff, reset, now, setGain and destroy. Sending methods
return whether the event was queued. The synth takes its voice slot the way
play does, through an internal per-mixer lender, so the voice ceiling, group
gain, mute and pause apply unchanged. The backend contract gains SynthPort and
optional loadSoundFont, releaseSoundFont and openSynth; a backend without them
and a mixer without an output get a silent port.
tecs.audio moves to audio/init.nupp for its child. README records why
synthesis is in-process and where a hardware MIDI output would plug in.
src/fm drives a YM3812 or YMF262 from ymfm-oxide, a pure-Rust, no_std port of ymfm (BSD-3-Clause), taken as a git dependency pinned to commit 4cc9e10. Tecs keeps no emulator source: the crate carries the port and its check against ymfm. A register 0x000-0x1FF maps onto the chip's address and data ports, offsets 0/1 below 0x100 and 2/3 for the OPL3's second bank; the OPL3 plays outputs A and B as left and right, the OPL2 its one output on both. The chip renders at 49715 Hz, the rate the emulator gives a 3.579545 MHz OPL2 and a 14.31818 MHz OPL3, and the voice resamples it. The chip goes through the synth's ring, schedule and source. The block grid is now per Target: eight frames for rustysynth, one for the OPL, so a write lands on the first chip sample at or after its time, and writes due on one sample all apply before it is generated. Port gains an all-or-nothing batch push, and a reset carries a frame: events sent before it and due before it still apply, those due at or after it are discarded (the synth's reset, at the next block, behaves as before). FFI: tecs_audio_fm_open/write/writes/reset/now/close and a RegisterWrite layout. THIRD_PARTY_NOTICES records ymfm-oxide and carries its licence. Tests: the grid, ordering, the second bank and the NEW bit, a key-off and key-on on one sample, queue bounds, batch atomicity, reset, the rate against the emulator's, and the engine path.
new(audio, {chip = "opl2" | "opl3", group, gain}) with write(register, value,
at?), writes({reg, val, ...}, at?), reset(at?), now, setGain, destroy and a
read-only rate of 49715. chip defaults to "opl2". Writing methods return
whether the writes were queued; a register or value out of the chip's range
raises, writes checks every pair before queuing any, and a batch past 4096
pairs is refused. A time within a millionth of a sample of a whole sample
counts as it. The chip takes its voice slot through the same internal lender
the synth uses. The backend contract gains FmChip, FmPort and an optional
openFm; a backend without it and a mixer without an output get silentFm.
README records why the interface is registers only, the per-target grid and
the same-sample rule, what a timed reset discards, the chip rate, and why
the emulator is the ymfm-oxide crate rather than source in this tree.
fm.new(mixer, {chip, clock?, group?, gain?}) now makes any of the 24 chips
ymfm-oxide emulates: ym3526 y8950 ym3812 ymf262 ymf289b ymf278b, ym2413
ym2423 ymf281 ds1001, ym2151 ym2164, ym2203 ym2608 ymf288 ym2610 ym2610b
ym2612 ym3438 ymf276, ym2414, ym3806 ym3533 and ym2149. "opl2" and "opl3"
stay as aliases of the YM3812 and YMF262, so the OPL calls and their rate of
49715 are unchanged. clock defaults to the chip's usual one and must be
100 kHz to 100 MHz. rate is now per chip (the native port reports it);
clock, lastRegister and lastPort are new read-only fields, and chips(),
defaultClock(), rate() and memoryClasses() describe the chips.
New methods: writePort/writePorts write the chip's own ports (offset, data)
beside the register convenience, which maps a register's high byte to the
bank's address/data pair (0/1, 2/3, the OPL4 PCM pair at 4/5, the YM2149's
data port at 2; the OPQ is written directly up to 0x11F). setMemory(class,
image) hands the chip an ADPCM-A, ADPCM-B or PCM image, which replaces the
class from the next frame and survives a reset; images cross on their own
8-deep channel paired with a write-queue event, and displaced ones are freed
on the game thread. pause/resume/paused, setStereo and destroy(fadeOut?)
work on the chip's voice.
Rendering: the OPN chips with an SSG run at ymfm's lowest fidelity, and a
chip whose emulator renders past 64 kHz (YM2203, YM2608 x3, YMF288 x2) has
samples averaged into one frame of its clock. Outputs fold to stereo by a
table (OPL3 A/B, OPL4 DO2, OPLL melody+rhythm, SSG at 2/9 a channel, ymfm's
own OPNA mix) and chips with an SSG get a 5 Hz DC blocker. Timers, busy
time, IRQ and status reads are not offered (CSM and ADPCM end flags need
them). The C ABI opens a chip by name and clock and gains rate, write_port,
a ports flag on writes, and memory.
Tests: every chip sounds through its register layout, rates match ymfm's
sample_rate for every chip and clock (and the Nupp table matches the native
port), decimation matches a direct render, ADPCM-A plays from an image and
keeps it across a reset, the memory queue is bounded and returns images, and
a YM2612 plays resampled through the engine. README records why the chip is
a name and a clock, the fidelity and averaging choices, the SSG filter, and
why memory is owned by the render side without a lock.
This was referenced Oct 16, 2026
Collaborator
Author
|
Folded into #8, which now carries the synth, FM and music layers on one branch (audio-synth). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Depends on #8 (
audio-synth); review after it, the first two commits of this branch are #8's.tecs.audio.fmis an emulated Yamaha FM chip that a game drives register by register, played as one voice of the mixer on the synth's scheduler. It is for the era's music and effects, which were streams of register writes to an AdLib, a Sound Blaster, a Mega Drive or an arcade board.Chips: any of the 24 chips ymfm-oxide models, by lower-case part number (
ym3812,ymf262,ym2612,ym2151,ymf278b,ym2149…) and clock."opl2"and"opl3"remain aliases with their old meaning and 49715 Hz rate. The clock defaults to the chip's usual one; outside 100 kHz–100 MHz raises.API:
fm.new(audio, {chip?, clock?, group?, gain?}), then:write(register, value, at?),writes({reg, val, ...}, at?): a register's high byte picks the bank's address/data port pair (0/1, 2/3, then 4/5 for the OPL4's PCM bank).writePort(port, value, at?),writePorts(pairs, at?): the chip's own ports, for layouts the register form doesn't cover.setMemory("adpcmA" | "adpcmB" | "pcm", image): an ADPCM or PCM memory image, applied from the next frame; a reset keeps it.reset(at?),now(),setGain(gain),setStereo(left, right),pause(),resume(),paused(),destroy(fadeOut?).chip,clock,rate,lastRegister,lastPort.chips(),defaultClock(chip),rate(chip, clock?),memoryClasses(chip).Behaviour, for review:
chipdefaults to"opl2".resettakes a time: writes sent before it and due before it still apply; writes due at or after it are discarded.now() + n/ratelands on sample n.writeswith more than 4096 pairs returns false; it is all or nothing.Rendering:
rateis at most 64 kHz.Rust:
tecs_audio_fm_*; a chip is opened by name and clock. The backend gainsFmChip/FmPort/openFm(handle, chip, clock)/silentFm;FmPortcarriesrate,writePort,writePortsandsetMemory.Emulator: ymfm-oxide, a pure-Rust, no_std, dependency-free port of ymfm, BSD-3-Clause, verified sample for sample against upstream ymfm. It is a git dependency pinned to
4cc9e106c7dbf28c848a9a463bf3ef74709a5ed0. Tecs carries no emulator source.cargo-licenses.txtrecords it as BSD-3-Clause and THIRD_PARTY_NOTICES carries its licence. The packaging's crates.io requirement applies only to MPL-2.0.Rate: 49715 Hz for OPL2/OPL3 at their usual clocks, the emulator's truncation of 49 715.9 Hz. The README explains why the 20 ppm difference from 49716 doesn't matter.
Tests: every chip sounding; rates against ymfm for every chip and clock; decimation against a direct render; memory images across a reset; a YM2612 played through the engine; plus the grid, ordering, timed reset, queue bounds, batch atomicity, OPL3 bank and NEW bit, same-sample rule and the offline-engine clock test, and
tests/audiofmtest.nupp.Checks: fmt, clippy -D warnings,
cargo test -p tecs-audio,nupp task format-check,nupp check --strict(0 warnings),nupp test(1042 passed),nupp task docs-check. Not run here: a fullnupp task package.