Skip to content

tecs.audio.fm: an emulated Yamaha FM chip as a mixer voice (ymfm-oxide) - #11

Closed
albanmgicquel wants to merge 5 commits into
tecs-dev:mainfrom
albanmgicquel:audio-fm-ymfm
Closed

albanmgicquel wants to merge 5 commits into
tecs-dev:mainfrom
albanmgicquel:audio-fm-ymfm

Conversation

@albanmgicquel

@albanmgicquel albanmgicquel commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Depends on #8 (audio-synth); review after it, the first two commits of this branch are #8's.

tecs.audio.fm is 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?).
  • Read-only chip, clock, rate, lastRegister, lastPort.
  • Module functions chips(), defaultClock(chip), rate(chip, clock?), memoryClasses(chip).

Behaviour, for review:

  • chip defaults to "opl2".
  • reset takes a time: writes sent before it and due before it still apply; writes due at or after it are discarded.
  • A time within 1e-6 of a whole sample counts as that sample, so now() + n/rate lands on sample n.
  • writes with more than 4096 pairs returns false; it is all or nothing.
  • Writes on one sample apply together, so a key-off and key-on there count as no change. Space writes one sample apart to re-strike a note.
  • A register, port or value out of range raises.

Rendering:

  • OPN chips with an SSG run at ymfm's lowest fidelity. Chips that still render above 64 kHz (YM2203 and YM2608 ×3, YMF288 ×2) are averaged down, so rate is at most 64 kHz.
  • Outputs fold to stereo by a fixed per-chip table; chips with an SSG get a 5 Hz DC filter.
  • Timers, IRQ and status reads are not offered, so OPN CSM and ADPCM end-of-sample flags don't work.

Rust:

  • The scheduler grid is per target: 8 frames for rustysynth, 1 for the chip, so a write lands on the first chip sample at or after its time.
  • New FFI: tecs_audio_fm_*; a chip is opened by name and clock. The backend gains FmChip/FmPort/openFm(handle, chip, clock)/silentFm; FmPort carries rate, writePort, writePorts and setMemory.

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.txt records 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 full nupp task package.

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.
@albanmgicquel albanmgicquel changed the title tecs.audio.fm: an OPL2 or OPL3 FM chip as a mixer voice (ymfm-oxide) tecs.audio.fm: an emulated Yamaha FM chip as a mixer voice (ymfm-oxide) Oct 16, 2026
@albanmgicquel

Copy link
Copy Markdown
Collaborator Author

Folded into #8, which now carries the synth, FM and music layers on one branch (audio-synth).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant