A small, hackable, MCU-friendly OPL3 (YMF262) synthesizer with a sequence-player API designed to fit comfortably on a Cortex-M / RP2040 / ESP32 / similar microcontroller. Comes with a Windows demo wrapper that exercises the exact API that the MCU port uses, so what you hear on your PC is what your MCU will play.
Why? For fun, and because the OPL3 (the FM synth chip in the Sound Blaster 16 / AdLib Gold) is a beautiful piece of late-80s hardware whose music — fast attack envelopes, gritty FM timbres, that distinctive ~50 kHz sample rate — is impossible to reproduce convincingly with any other technique. With ~30–80 KB of flash for the code (depending on which core you pick) plus whatever your songs need, and one spare CPU, you can have authentic OPL3 audio on a $3 board.
The included demo plays the title-screen music from the 1993 DOS game
Prehistorik 2, captured live from the original PRE2.EXE running
in DOSBox and converted to a packed opl_song C header.
opl/
+-- sequencer/ <-- shared OPL sequence player (PC + MCU)
| +-- seq_player.c tick + render API; one file, every core
| +-- seq_player.h public interface -- read this file first
| (inline render *and* a producer/consumer
| FIFO are both compiled in; pick one
| per call site)
|
+-- cores/ <-- chip emulators, one folder each
| +-- nuked/ portable reference build (PC + MCU)
| | +-- opl3.c opl3.h Nuked-OPL3 v1.8 (LGPL 2.1+, unmodified)
| |
| +-- nuked_optimized/ MCU-tuned variant of the Nuked port (MCU-only build)
| | +-- opl3.c opl3.h Nuked-OPL3 v1.8 + heavy Cortex-M tweaks
| |
| +-- opal/ alternate, much smaller OPL3 (PC + MCU)
| | +-- opal.c opal.h Reality's Opal OPL3, public-domain (~12 KB code)
| | +-- opl3.h header-only `OPL3_*` adapter (no .c shim)
| | +-- LICENSE.txt
| |
| +-- mame/ MAME's OPL3 (Burczynski/Satoh), full rhythm + 4-op
| | +-- ymf262.c ymf262.h Vendored from FBNeo (byte-identical to MAME's ymf262.c)
| | +-- mame_compat.h Tiny shim: typedefs + no-op SCAN_VAR/ACB_* save-state
| | +-- opl3.h header-only `OPL3_*` adapter (no .c shim)
| |
| +-- adlibemu/ DOSBox legacy OPL3 (Ken Silverman lineage, LGPL 2.1+)
| | +-- adlibemu_opl3.c Vendored from ValleyBell/libvgm (8-line OPL3 shim)
| | +-- adlibemu_opl_inc.[ch] The actual emulator (shared OPL2/OPL3 body)
| | +-- adlibemu.h Upstream public API
| | +-- adlibemu_compat.h Tiny shim replacing libvgm's stdtype/snddef/common_def
| | +-- opl3.h header-only `OPL3_*` adapter (no .c shim)
| |
| +-- dbopl/ DOSBox dbopl, C port (GPLv2 -- not for closed firmware)
| | +-- dbopl.c Hand-port of DOSBox's dbopl.cpp to plain C99
| | +-- opl3.h header-only `OPL3_*` adapter (no .c shim)
| | +-- LICENSE.txt
| |
| +-- dbopl_optimized/ MCU-tuned variant of the dbopl port (MCU-only build)
| +-- dbopl.c Same C port + HOT_INLINE / HOT_FUNC (CCM placement)
| +-- opl3.h header-only `OPL3_*` adapter (no .c shim)
| +-- LICENSE.txt
|
+-- heatshrink/ <-- portable streaming decompressor (PC + MCU)
| +-- heatshrink_decoder.[ch] Atomic Object's heatshrink (decoder unmodified)
| +-- heatshrink_encoder.[ch] (PC-only; only dro2hdr / hs_bench pull this in)
| +-- heatshrink_config.h Decoder build-time config (locally patched: HEATSHRINK_STATIC_* are #ifndef-guarded so -D overrides reach every TU)
| +-- hs_stream.[ch] Generic byte-stream pump on top of the decoder
|
+-- songs/ <-- + at least one *_song.h
| +-- opl_song_hs.h Self-describing descriptor (hs_data + metadata)
| +-- *_song.h 39 ready-to-link songs, heatshrink-packed (w13/l4)
| +-- *.dro Source DRO captures (input to dro2hdr)
| +-- *.vgz Original VGM source files for the above
|
+-- demo_win/ <-- Windows-only wrapper, NOT for the MCU
| +-- main.c Fake-ISR loop + curated SONGS[] list
| +-- audio.h Tiny audio-sink interface
| +-- audio_win.c WinMM (waveOut) implementation
| +-- dro_load.[ch] Load a .dro file at runtime (PC only)
|
+-- tools/ <-- offline PC utilities
| +-- vgz2dro.c / .exe VGM/VGZ capture -> DRO v2
| +-- dro_opt.c / .exe Strip redundant register writes from a .dro
| +-- dro_loop3.c / .exe Loop-point detector
| +-- dro2hdr.c / .exe DRO -> embeddable C header (with --hs for packed)
| +-- hs_bench.c / .exe Sweep heatshrink window/lookahead for best ratio
| +-- regen_songs.ps1 Re-pack every songs/*.dro into songs/*_song.h
|
+-- capture/ DRO captures from DOSBox
+-- games/ DOSBox configs + your copies of the games
+-- Makefile / build.bat Windows build (one binary per core)
+-- README.md This file
The sequencer in sequencer/ is core-agnostic: it
depends only on a tiny OPL3_* surface (Reset, WriteReg,
WriteRegBuffered, Generate, GenerateResampled). Each chip
emulator lives in its own folder and exposes that surface either
directly (Nuked) or via a header-only adapter (Opal). Adding a new
engine is a self-contained "new folder + adapter header" exercise; no
changes anywhere else.
Five engines are wired up today (nuked_optimized is an MCU-only
fork of nuked, and dbopl_optimized is an MCU-only fork of dbopl,
so they don't count as separate engines). All emulate the same Yamaha
YMF262 (OPL3); they differ in how faithful the emulation is and
what that costs in cycles and flash.
Footprints are gcc 13 -Os for x64 (size on the per-core .o); MCU
flash with arm-none-eabi-gcc -Os -mthumb -mcpu=cortex-m4 lands within
~5 % of these. CPU cost is 60 s of metallica (typical OPL3 game-music
workload) @ 49 716 Hz stereo on a modern x64 laptop; see Measured CPU
cost for the harness and other songs.
| core | license | runtime math | rhythm | 4-op | bit-exact | .text (-Os) |
static .bss tables |
per-chip RAM | ns/frame | realtime |
|---|---|---|---|---|---|---|---|---|---|---|
nuked |
LGPL 2.1+ | pure integer | yes | yes | yes (decap) | 7.5 KB | 0 | 20.5 KB? | 481 | 42.5× |
nuked_optimized |
LGPL 2.1+ | pure integer | yes | yes | yes for OPL2 | ~7 KB | 0 | ~12 KB? | 297? | 68.8× |
opal |
public domain | pure integer | no | yes | no | 6.4 KB | 0 | 5.7 KB | 222 | 92.4× |
adlibemu |
LGPL 2.1+ | double envelopes + libm pow/sin |
yes | yes | no | 14.1 KB | 14.6 KB (wave + LFO) | 7.5 KB | 239 | 85.3× |
mame |
BSD-style? | integer (FP only at init) | yes | yes | no | 11.3 KB | 58.0 KB (sin_tab + tl_tab) |
13.8 KB | 192 | 106.0× |
dbopl? |
GPLv2? | integer (FP only at init) | yes | yes | no | 13.1 KB | 9.2 KB (wave+ksl+tremolo) | 4.7 KB | 85 | 240× |
? nuked_optimized measured on the 9-ch / OPL2 / mono profile; the full
OPL3 build sits closer to plain nuked in both code size and ns/frame.
Per-chip RAM drops because the unused channels/operators are compiled out.
? MAME license, BSD-compatible commercial use; see
cores/mame/ymf262.c header.
? nuked keeps its log-sin/exp ROM and per-operator scratch inside
opl3_chip (rather than as file-static globals), which is why its
per-instance footprint is the largest even though .bss is zero. With
one chip — the usual case — total RAM is ~20.5 KB.
? dbopl lives at cores/dbopl/dbopl.c as a
hand-port of DOSBox's dbopl.cpp to plain C99 (templates lowered to
static inline + constant args, member function pointers replaced with
free-function pointers, namespaces stripped). The C port is ~20%
faster than the C++ original at -O2 (85 vs 105 ns/frame on x64), with
identical output. License inheritance: linking cores/dbopl/dbopl.c
into your build makes the resulting binary GPLv2. The other four cores
(LGPL/BSD/public-domain) do not have this property -- pick whichever
matches your distribution requirements. See
cores/dbopl/LICENSE.txt.
Add ~5–10 KB of libm (pow, sin, soft-float helpers if no FPU) on top
of adlibemu if your project doesn't already link it.
-
nuked— the truth.-
- Bit-identical to a real YMF262, including rhythm mode and 4-op.
-
- Pure integer; runs anywhere from M0 up.
- ? Slowest of the bunch (~3–4× plain
mame). - ? Function-pointer dispatch over 8 waveforms hurts on M-class cores.
-
-
nuked_optimized— Nuked, MCU-tuned.-
- Same audible output as
nukedfor OPL2 content; cuts work in half.
- Same audible output as
-
- Active-slot skip list, fused L+R loop,
__SSAT/__USAT, CCM placement.
- Active-slot skip list, fused L+R loop,
-
- Compile-time profiles (OPL2 only, mono, channel cap) for tight MCUs.
- ? Pulls in CMSIS (
cmsis_gcc.h); not built on PC. - ? Trades a few per-sample bits when the OPL2/mono knobs are turned on.
-
-
dbopl_optimized— dbopl, MCU-tuned (GPLv2).-
- Same audible output as
dbopl; same WAVE_TABLEMUL inner loop.
- Same audible output as
-
- Adds
HOT_INLINEon hot helpers andHOT_FUNC(CCM placement) on the per-sample dispatch targets (vol_*,synth_*,chip_generate_block2/3,OPL3_Generate*,op_set_state).
- Adds
-
- One build flag (
-DHOT_FUNC=...) propagates the same CCM placement to the sequencer and tonuked_optimized-- pick a core, set the flag once, done.
- One build flag (
- ? Inherits dbopl's GPLv2; not for closed-source firmware.
- ? MCU-only build (defaults assume the linker script defines
.ccm).
-
-
opal— small and quick.-
- ~12 KB code, simple integer inner loop.
-
- Fast on every target; trivial to drop into a 256 KB part.
- ? No rhythm/percussion mode. id Software / Prehistorik 2 / Doom drum kits silently disappear.
- ? A few envelope edge cases differ from real silicon.
-
-
adlibemu— DOSBox lineage, the architectural odd one out.-
- Plays everything (full OPL3, rhythm, 4-op).
-
- LGPL 2.1+ (link-friendly even for closed firmware).
-
- Direct ancestor of
dbopl; useful as a DOSBox-era reference.
- Direct ancestor of
- ? Uses
doublefor envelope state and calls libm'spow()on every attack/decay/release-rate-changing register write. Per-sample envelope updates are FP, not integer. - ? Pulls in libm (
pow,sin); table init alone does ~256sinand ~512powcalls. - ? Fine on x64 / M7 / M4F (consider
s/double/float/); not viable on M0/M0+ (every per-sample envelope step lowers to soft-double).
-
-
mame— flash-for-cycles.-
- Fastest pure-integer core that doesn't carry a viral license.
-
- Inner loop is two unconditional table lookups (no branches, no fn ptrs).
-
- Pure integer at runtime;
doubleonly at one-shot table init.
- Pure integer at runtime;
- ? ~58 KB of pre-flattened tables (
sin_tab32 KB +tl_tab26 KB). Tight on 128/256 KB parts; comfortable on F4/F7/H7. - ? Not bit-exact (pre-Nuked envelope/phase model). Audibly indistinguishable.
-
-
dbopl— fastest, smallest, GPL-encumbered.-
- Fastest core in the repo (~2.3× plain
mame) thanks to a pre-multiplied wave table that turns the inner loop into one load + one multiply per operator, no envelope branches in the hot path.
- Fastest core in the repo (~2.3× plain
-
- Smallest per-chip RAM (4.7 KB — less than
opal).
- Smallest per-chip RAM (4.7 KB — less than
-
- Pure integer at runtime;
doubleonly at one-shot table init. Builds clean witharm-none-eabi-gcc -Os -mthumb(no FPU needed).
- Pure integer at runtime;
-
- Plays everything: full OPL3, rhythm, 4-op, panning.
- ? GPLv2 — linking it makes your binary GPL. Don't pick this
core if you ship closed-source firmware; use
mame(BSD) oradlibemu(LGPL) instead. - ? Not bit-exact (DOSBox heuristic envelope model).
- ? ~1.6× slower on percussion-heavy songs than on pure
melodic content (the
0xBDrhythm-mode dispatcher bypasses the WAVE*TABLEMUL hot path). On Cortex-M this can be the difference between meeting and missing the audio ISR deadline -- prefermamefor percussion-heavy material on tight MCUs. See Measured CPU cost for numbers.
-
Every per-sample mix loop does, for each of the chip's 36 operators: "compute current phase, look up sin(phase), apply the envelope's log-domain attenuation, exp-back to linear, sum into the channel accumulator". An OPL3 ROM stores log-sin and exp lookup tables for 1/4 of a sine period each (a few hundred bytes), and the silicon reconstructs the rest by quadrant flips and a barrel shift. The core's whole performance story is how it handles those two table lookups per operator per sample.
cores/nuked/opl3.c is Nuked-OPL3 v1.8 by Nuke.YKT,
verified byte-for-byte against the upstream
https://github.com/nukeykt/Nuked-OPL3 master. It mirrors the real
silicon: ~1.5 KB of logsinrom + exprom plus per-operator
quadrant flips, a function-pointer dispatch over the 8 OPL
waveforms, a data-dependent shift in the exp stage, and signed XOR
masking for negative half-cycles. Output is bit-identical to a
real YMF262. Cost: roughly 25–30 cycles per operator on x64,
~3–4× that on Cortex-M4. Use it as the audible reference and
to settle "is this song playing right?" disputes.
cores/nuked_optimized/opl3.c keeps Nuked's
audio model and ROM contents but reworks the traversal: an active-
slot index list (skip operators whose envelope is OFF), cached
envelope/phase increments, fused L+R mix loop, CCM/RAM placement
attributes, and Cortex-M4 intrinsics (__SSAT/__USAT instead of
branchy clamps, __builtin_ctz for the eg trailing-zero scan).
Adds opt-in compile flags: OPL_FORCE_OPL2=1 (drops the OPL3
upper bank, halves the operator count for OPL2 material), OPL_MAX_CHANNELS<18
(silently ignores channels above the limit), OPL_MONO=1. With the
default profile (9-ch / OPL2 / mono) it does roughly half the work
of plain Nuked on the same song and still sounds bit-identical
for OPL2 content. Not built on PC because it pulls in
cmsis_gcc.h; bring it into your firmware project alongside that
dependency. The non-bit-exact compromises are listed in the banner
at the top of the file.
cores/opal/opal.c is Reality's Opal OPL3 (public
domain, pure-C port from libADLMIDI). It uses small tables (~10 KB
total) with on-the-fly waveform synthesis — same engineering tradeoff
as Nuked but a much lighter inner loop. ~12 KB code. Caveat:
the rhythm/percussion mode (register 0xBD BD/SD/TT/TC/HH strikes)
is silently dropped, and a few envelope edge cases differ subtly.
Excellent fallback when you don't need percussion (most non-id-Software
material is fine). Refuses to play Prehistorik 2's drum-heavy title.
cores/mame/ymf262.c is Jarek Burczynski / Tatsuyuki
Satoh's MAME OPL3, vendored from FBNeo (byte-identical to MAME's
upstream ymf262.c). Pure C99 once a tiny mame_compat.h shim
replaces the framework #includes. Full 18 channels including
4-op mode and rhythm/percussion, so it plays everything Nuked plays
(though not bit-identically — pre-Nuked envelope/phase model).
The reason it's the fastest core in this repo is the table
strategy: instead of mirroring the silicon's compact ROMs, MAME
pre-flattens sin_tab (32 KB, all 8 OPL waveforms expanded across
the full period) and tl_tab (26 KB, exp + total-level attenuation
already combined). The entire FM operator collapses to:
p = (env << 4) + sin_tab[wave + ((phase + pm) >> SH) & MASK];
return (p >= TL_TAB_LEN) ? 0 : tl_tab[p];— two table lookups, no branches, no function pointers, no data-dependent shifts. Roughly 8 cycles per operator on x64 vs Nuked's ~25.
Tradeoff: ~90 KB total flash (50–60 KB of which is those two
tables). Fine on F4 and up; tight on 256 KB parts. Audio path is
100% integer. double is used only at one-shot table init (and for
the timer-period scalar, only relevant if the song uses OPL timer
reads — DRO captures don't).
cores/adlibemu/adlibemu_opl_inc.c is the
original ADLIBEMU by Ken Silverman (1998-2001), extended to OPL3
by the DOSBox team (2002-2010) and vendored from
ValleyBell/libvgm. DOSBox
kept this around as the "compat" core after replacing it with dbopl;
it's the direct ancestor of dbopl and the closest in-tree analogue
to it that's available as pure C under a friendly license
(LGPL 2.1+, link-friendly even for closed-source firmware).
The architectural odd-one-out (see also the pros/cons
block above). Every other core in this repo is pure integer (or, in
mame's case, integer at runtime with double used only at one-shot
table init). adlibemu instead uses double precision for the
envelope state (amp, vol, step_amp, sustain_level, attack-curve
coefficients a0..a3, the per-op decaymul/releasemul) and calls
pow() from libm on every register write that re-derives an
attack/decay/release rate (change_attackrate etc. — every
0x60/0x80 write hits libm). The per-sample wave/phase math is
fixed-point Bit32s, but the envelope is recomputed in FP each sample.
libm gets pulled in for chip table init too (~256 sin() + ~512 pow()
calls during init_tables), adding ~5–10 KB of flash on top of the
~30 KB code if your project doesn't already link libm.
Useful as a sanity check on the others (when something sounds
weird in mame, A/B against adlibemu to see if the same
compromises were made in DOSBox-era emulation) and as the
LGPL-licensed alternative to dbopl (same family, ~3× slower but
you can ship it in closed firmware). About 25% slower than mame on
x64; would lose more ground on M4F because of the FP envelope
updates, and is unbuildable on M0.
cores/dbopl/dbopl.c is a hand-port of
DOSBox's dbopl.cpp to plain C99. The original was templated C++
with member function pointers, namespaces, and cross-channel
this+1 pointer arithmetic; the C port lowers all of that to
static inline bodies dispatched by free-function pointers, with
HOT_INLINE (__attribute__((always_inline))) on the hot helpers
so gcc -O2 specialises the synth handlers as cleanly as the C++
template instantiations did. The C port runs ~20% faster than the
C++ original at -O2 (85 vs 105 ns/frame on x64), with byte-identical
output.
Why it's so fast: dbopl uses the WAVETABLEMUL strategy — the
log-sin and exp tables are pre-multiplied at init into a single
~6 KB MulTable + 16 KB WaveTable. The per-operator inner loop
collapses to one indexed load and one multiply; no envelope branches,
no function calls per sample. That's also why the per-chip RAM is
the _smallest in the repo (4.7 KB) despite full OPL3 / rhythm /
4-op support — the big tables live as static globals shared by all
operators, not inside each Chip.
License inheritance. dbopl is GPLv2. Linking
cores/dbopl/dbopl.c into your binary makes
the combined work GPLv2. If you ship closed-source firmware, pick
mame (BSD-style, second fastest) or
adlibemu (LGPL 2.1+, same
algorithmic family) instead. See
cores/dbopl/LICENSE.txt for the full
story.
Each opl_demo_<core>.exe accepts --bench [seconds], which runs
the exact same per-sample render + 1 ms tick loop as live playback
but skips audio output and times it with QueryPerformanceCounter.
The number you get is the cost of synth_render_sample +
seq_tick, i.e. exactly what would run inside the MCU's DAC and
SysTick ISRs.
Sample run on a modern x64 laptop at 49 716 Hz stereo, 30 s per
song, 3 runs averaged (variance ? 3%). Two contrasting workloads:
metallica is heavy melodic OPL3 with no rhythm mode;
eric (Eric Prydz - Call on Me) is percussion-heavy (BD + SD
- HH on every beat through the
0xBDrhythm path).
| core | metallica ns/frame | eric ns/frame | percussion penalty | metallica realtime | eric realtime |
|---|---|---|---|---|---|
nuked |
481 | 454 | 0.94x | 42.5× | 44.4× |
nuked_optimized |
297 | - | - | 68.8× | - |
adlibemu |
239 | 186 | 0.78x | 85.3× | 108.4× |
opal |
222 | 229 | 1.03x | 92.4× | 88.0× |
mame |
192 | 188 | 0.98x | 106.0× | 107.2× |
dbopl |
73 | 116 | 1.59x | 272× | 174× |
The surprise is dbopl's 1.6x penalty on percussion while the
other integer cores stay flat (or even speed up, in adlibemu's case,
because its FP envelope path runs less often when rhythm-mode
operators bypass envelope re-derivation). dbopl's hot path is the
WAVE*TABLEMUL one-load-one-multiply trick; rhythm mode (0xBD
BD/SD/TT/TC/HH) routes through a separate dispatcher with extra
noise-LFSR updates, hi-hat XOR mixing and snare phase logic. On
x64 those branches and 64-bit shifts are essentially free; on
Cortex-M they cost real cycles. For percussion-heavy songs on M4,
budget ~1.5x dbopl's metallica figure -- which can push it from
"comfortable margin" into "misses ISR deadlines", as one MCU port
of this repo found out the hard way. mame is the safe pick for
percussion-heavy material on tight MCUs (only 2% slower on eric,
still BSD-licensed, second fastest overall).
(nuked_optimized measured separately on a 9-ch / OPL2 / mono
profile build; the OPL2 profile silently ignores OPL3 rhythm so
the eric cell would be apples-to-oranges. dbopl is a C port of
DOSBox's dbopl.cpp; the C version runs ~20% faster than the
original C++ at -O2 because gcc specialises the small HOT_INLINE
body wrappers as cleanly as it does template instantiations -- and
free-function pointers are slightly cheaper to dispatch than C++
member-function pointers.)
Don't read "mame beats nuked" as a statement about the chips —
it's a statement about which inner loop x64 happens to schedule
best. On Cortex-M the gap will narrow somewhat (mame uses a few
64-bit shifts that x64 does in 1 cycle but M4 emulates in several),
but the ranking is stable. Order of magnitude on STM32F407 @
168 MHz at 49 716 Hz: mame/opal ? 25-35% of one core,
nuked_optimized (default 9-ch / OPL2 / mono profile) ? 30-40%,
plain nuked ? 50-70%. adlibemu lands in the opal/mame band
on raw cycles but the FP envelope updates push it noticeably higher
on M4F. All four are real-time on M7 with margin (adlibemu only
if you have an FPU — see its section).
| If… | Use |
|---|---|
| You want to verify "did I capture this song right?" | nuked — the audio reference |
| The song uses rhythm-mode percussion (id Software, Prehistorik 2, …) | mame (flattest cost), adlibemu, nuked, or nuked_optimized; dbopl works but pays a ~1.6× penalty on percussion (see CPU table); not opal. |
| You're on an MCU and ship GPL-compatible code | dbopl_optimized — fastest, smallest per-chip RAM, CCM-placed hot path |
| You're on an MCU with ?512 KB flash and need a permissive license | mame — second fastest, BSD-style |
| You're on an MCU with ?256 KB flash | opal (no percussion) or nuked_optimized (default profile) |
| You're targeting strict bit-accuracy on an MCU | nuked_optimized with all defaults disabled, or plain nuked if you have the cycles |
| You want a DOSBox-era second opinion on tone (M4F or higher) | adlibemu — LGPL alternative to dbopl from the same family |
| You need to A/B sound quality | Build all five PC binaries, listen — --once makes it easy |
ymfm(Aaron Giles, BSD-3-Clause). Modern C++17 unified Yamaha FM family. Broader chip coverage than this repo needs (we only want OPL3). Comparable speed tomame/dbopl. Porting cost: high (templates,std::array, references). Worth it only if you need OPL2/OPN/OPN2 etc. side-by-side.hatariOPL and similar legacy ports. Older, slower, less faithful than any of the above. Not recommended.
If you ever feel cycle-starved on the MCU with mame, the
productive moves (in order of payoff vs effort) are: place the hot
tables in CCM/DTCM RAM if your part has it (2× on memory-bound
parts); pre-bake sin_tab/tl_tab as static const to drop the
libm dependency (~10 KB flash); precompute fn_tab for a fixed
sample rate (4 KB RAM saved); add __SSAT to the final clamps; add
an active-channel skip list. None of those touch op_calc. Together
they should put mame solidly under 20% of an STM32F407.
Requires MinGW gcc (anything ≥ 8 works).
make (or: build.bat)
opl_demo_nuked.exe --list show all built-in songs
opl_demo_nuked.exe loop the default song (Prehistorik 2 title)
opl_demo_nuked.exe eric --once play one of the curated entries once and exit
opl_demo_nuked.exe metallica loop "Master of Puppets"
opl_demo_nuked.exe path\to\file.dro play any DOSBox DRO v2 capture
opl_demo_mame.exe metallica --bench 30 render 30 s of song with no audio,
print render time / CPU% (no real-time pacing)
The build produces one binary per PC-ready core:
opl_demo_nuked.exe, opl_demo_opal.exe, opl_demo_mame.exe,
opl_demo_adlibemu.exe, and opl_demo_dbopl.exe. All five take
the same command line; the banner prints core: <name> so you
know which engine you're hearing.
The curated short-name list lives at the top of
demo_win/main.c (one #include + one row in
SONGS[] per song); add a row to expose any of the 39 packed songs
in songs/.
cores/nuked/opl3.c and cores/nuked/opl3.h are
Nuked-OPL3 v1.8 by Nuke.YKT, taken byte-for-byte from
https://github.com/nukeykt/Nuked-OPL3 (verified by SHA-256 against
the upstream master branch). It is a cycle-accurate, decap-derived
emulation of the YMF262, well-known and widely used.
Two functions matter:
OPL3_Reset(chip, sample_rate_hz)— initialise.OPL3_GenerateResampled(chip, &stereo_pair)— produce one stereo PCM frame at your sample rate, internally driving the chip at its native 49 716 Hz and linearly interpolating to your rate.
Native rate is a hardware fact: the real YMF262 derives its sample clock by dividing 14.318 MHz (the NTSC color burst, on every PC) by 288 (= 36 operators × 8 cycles). The resampler lets you run any sensible DAC frequency.
sequencer/seq_player.h — read this. The whole API is:
typedef struct {
const uint8_t *data; // codemap + (code,val) opcode stream
uint32_t data_len;
uint16_t codemap_len;
uint8_t short_code; // (val+1) ms delay opcode
uint8_t long_code; // (val+1)*256 ms delay opcode
uint32_t total_ms;
} opl_song;
void synth_init(uint32_t sample_rate_hz); // once at boot
void seq_play_song(const opl_song *song, int loop); // start a song
void seq_stop(void);
void seq_silence(void); // emergency mute
int seq_is_playing(void);
void seq_tick(uint32_t ms_elapsed); // call every ~1 ms
/* render path A -- inline; call directly in the audio ISR */
void synth_render_sample(int16_t *l, int16_t *r);
/* render path B -- producer/consumer FIFO; call A from the audio ISR
OR call B (and refill) from a low-priority task */
void synth_update_fifo(void); // producer
void synth_get_fifo_sample(int16_t *l, int16_t *r); // consumerThe opl_song payload is byte-for-byte the same as a DOSBox DRO v2
file's data section, so songs are ~2 bytes per OPL register write.
For flash-constrained targets there is a second descriptor
opl_song_hs (defined in songs/opl_song_hs.h)
whose payload is a heatshrink-compressed DRO stream — typically
30–40 % of the plain size. The runtime entry point is
seq_play_stream(const opl_song_stream*, int loop) instead of
seq_play_song, fed by the generic byte pump in
heatshrink/hs_stream.h. Same sequencer,
same ISRs — only the byte source differs. See Compressed songs
below.
That's it. Two real-time entry points (plus an optional third for the FIFO render path):
seq_tick(1)in a 1 ms timer ISR — walks the song, fires due events withOPL3_WriteRegBuffered. O(1) amortised; cheap.synth_render_sample(&l, &r)in your DAC half-buffer-empty IRQ — produces one PCM frame inline (callsOPL3_GenerateResampled). Simplest possible code path; fine when the audio IRQ has the cycles.synth_update_fifo()+synth_get_fifo_sample(&l, &r)— if you prefer to keep the audio IRQ to one FIFO pop and run the heavy synth in a lower-priority context, callsynth_update_fifo()after eachseq_tick(1)andsynth_get_fifo_sample()from the DAC IRQ. On underrun the previous frame is held to avoid clicks. Both render paths are always compiled in; pick whichever you call from your audio ISR and ignore the other.
Both paths share two short critical sections inside seq_player.c
that must not be preempted by the audio ISR (the OPL register write
in seq_tick, and the ring slot/head publish in synth_update_fifo).
They are bracketed with SEQ_ISR_DISABLE() / SEQ_ISR_ENABLE()
macro hooks that default to no-ops; on a Cortex-M target wire them
to CMSIS on the compile command line:
-DSEQ_ISR_DISABLE=__disable_irq -DSEQ_ISR_ENABLE=__enable_irq
or, to mask only the audio IRQ vector:
#define SEQ_ISR_DISABLE() NVIC_DisableIRQ(MY_AUDIO_IRQn)
#define SEQ_ISR_ENABLE() NVIC_EnableIRQ(MY_AUDIO_IRQn)A song is a static const opl_song or static const opl_song_hs (in
a *_song.h file under songs/). All 39 songs shipped
with this repo are the heatshrink-packed opl_song_hs flavour;
plain opl_song is still supported for cases where you don't want
the decoder dependency.
The whole pipeline from a fresh game recording to a playable header is one shell command per stage:
tools\vgz2dro.exe songs\My_Song.vgz songs\my_song.dro (skip if you have .dro already)
tools\dro_loop3.exe songs\my_song.dro songs\my_song.dro (loop-point trim, optional)
tools\dro_opt.exe songs\my_song.dro songs\my_song.dro (drop redundant writes, optional)
tools\dro2hdr.exe songs\my_song.dro songs\my_song_song.h my_song --hs
The --hs flag tells dro2hdr to heatshrink-pack the DRO payload
(default window=13, lookahead=4 — "w13/l4" — ~8 KB decoder window,
best-ratio choice for OPL register streams). Drop --hs to emit a
plain opl_song instead.
To re-pack every songs/*.dro in one go (e.g. after editing
encoder settings):
powershell -File tools\regen_songs.ps1
Then add one #include and one SONGS[] row in
demo_win/main.c to expose the new song under a
friendly short name.
There is exactly one delivery channel: dro2hdr --hs reads a
.dro and emits a *_song.h containing an opl_song_hs struct
(metadata) plus a heatshrink-compressed codemap+stream payload.
#include the header, point an hs_stream at song->hs_data,
call seq_play_stream. No malloc, no file I/O, no header parsing.
The payload is header-stripped at compile time: every field the
player needs at runtime (codemap_len, short_code, long_code,
total_ms) is already a field of the emitted opl_song_hs struct,
so the original 26-byte DRO v2 file header would be dead weight
inside the compressed stream. The first decompressed byte is
codemap[0].
For PC-side ad-hoc playback the demo also accepts a .dro path on
the command line (demo_win/dro_load.c).
That path is uncompressed -- if you want compression at PC runtime,
build a *_song.h and link it. Keeping a single representation
end-to-end keeps the demo, and the docs, simple.
The decode pipeline:
bytes ?? hs_stream_next ??> heatshrink_decoder ??> seq_play_stream ??> OPL3
heatshrink/hs_stream.h is the only new
piece — a 3-function pump that knows nothing about OPL or DRO. It is
portable C99, ~80 lines, and is what you ship on the MCU alongside
heatshrink_decoder.c.
Loop-point detection (snipping out exactly one repetition of a song)
is done by tools/dro_loop3.exe, which finds the section markers in
the OPL register stream — see the comments in
tools/dro_loop3.c for the heuristic.
| File | Why |
|---|---|
<core>/opl3.c, <core>/opl3.h (or opal/opal.c + opal/opl3.h) |
Synth |
sequencer/seq_player.c, sequencer/seq_player.h |
Sequencer (inline + FIFO render paths both compiled in) |
songs/opl_song_hs.h |
Descriptor type for packed songs |
songs/<your_song>_song.h |
The music |
heatshrink/heatshrink_decoder.c, .h |
Streaming decompressor (only if using --hs) |
heatshrink/heatshrink_common.h, heatshrink_config.h |
Decoder build-time config |
heatshrink/hs_stream.c, .h |
Generic byte pump on top of the decoder |
<core> is any of nuked/, nuked_optimized/, opal/,
mame/, adlibemu/, dbopl/, or dbopl_optimized/. The sequencer
is the same file in every case; you select the engine by putting one
core's folder on the include path and linking that folder's .c files.
See Cores at a glance for footprint / license
/ feature tradeoffs.
For the heatshrink decoder use the static-allocation flags so it takes no malloc and one BSS-resident instance:
-DHEATSHRINK_DYNAMIC_ALLOC=0
-DHEATSHRINK_STATIC_WINDOW_BITS=13
-DHEATSHRINK_STATIC_LOOKAHEAD_BITS=4
-DHEATSHRINK_STATIC_INPUT_BUFFER_SIZE=64
Critical: every TU that sees
heatshrink_decoder.hmust be compiled with the sameHEATSHRINK_STATIC_WINDOW_BITSvalue used when the song was packed. The struct embeds a fixed-size window buffer whose size is derived from this macro, so a mismatch silently shrinks the buffer in some translation units while the decoder code keeps writing past it. The result is a song that plays correctly for the first few hundred bytes (small backrefs stay within the truncated window) and then degrades into garbage as soon as the encoder emits a backref past the truncated window size. Put the-Dflags into your top-level Makefile's globalCFLAGS/UDEFS, not in a per-file rule.
That costs ~8.2 KB of BSS for the decoder window plus ~1.5 KB of
flash for the decoder code itself; in exchange every *_song.h in
this repo shrinks to 30–40 % of its plain size.
The _optimized cores (nuked_optimized/, dbopl_optimized/) and
the sequencer's audio-ISR-side render helpers all funnel their hot
functions through a single overridable macro:
#ifndef HOT_FUNC
#if defined(__GNUC__) || defined(__clang__)
#define HOT_FUNC __attribute__((section(".ccm")))
#else
#define HOT_FUNC
#endif
#endif
Default on GCC/Clang is .ccm (STM32F3/F4/G4 Core-Coupled Memory);
on other toolchains it silently degrades to a no-op and the function
lands in flash like normal. Override it once at the top-level
Makefile to point at whatever fast-RAM section your linker script
defines, e.g.
'-DHOT_FUNC=__attribute__((section(".ccm")))'
or disable it entirely with -DHOT_FUNC=. The same flag covers
sequencer/seq_player.c (synth_render_sample,
synth_update_fifo, synth_get_fifo_sample),
cores/nuked_optimized/opl3.c
(~26 hot functions) and
cores/dbopl_optimized/dbopl.c
(~22 hot functions) -- pick a core, set the flag once, done.
Why it matters on Cortex-M: when DMA, the audio ISR and the synth render loop all contend for the single flash bus, the synth gets stalled waiting for instruction fetches. Moving the per-sample hot path into CCM removes the contention; on STM32G4 @ 144 MHz this is the difference between "keeps up" and "drops the occasional sample in dense passages".
If your linker script has no .ccm section the link will fail at
the orphan-section warning -- either add one, change the section
name (-DHOT_FUNC='__attribute__((section(".my_fast_ram")))'), or
disable the placement (-DHOT_FUNC=).
If you don't want to ship heatshrink at all, regenerate the songs
you need without --hs (tools/dro2hdr.exe in.dro out.h sym) and
call seq_play_song() instead of seq_play_stream(). The plain
path has zero new dependencies on top of the original sequencer.
The FIFO render path is built in unconditionally and uses a local
lock-free SPSC ring of stereo frames (no external fifo.h, no CMSIS
dependency, no malloc). Tune its size with -DSEQ_FIFO_FRAMES=N
(power of two; defaults to 1024 frames ? 20 ms @ 49 716 Hz). Wire
the SEQ_ISR_DISABLE / SEQ_ISR_ENABLE hooks to whatever your
platform uses to mask the audio IRQ — see the snippet at the top of
this section.
(If you'd rather start from the unmodified reference port, swap
nuked_optimized/ for nuked/ everywhere above; you then don't need
the MCU optimizations described below but everything still builds.
For a cheaper / smaller engine without rhythm-mode percussion, swap
in opal/ instead.)
That's everything. No malloc, no stdio, no FILE *, no float math, no
threads. Pure C99.
Per-core .text / .bss / per-chip RAM / cycles-per-frame are in
Cores at a glance and the
Measured CPU cost table; pick a core there first
and bring its numbers down here. The fixed costs that are the
same across cores are:
- Flash, sequencer: ~3 KB (
seq_player.c, both render paths compiled in). - Flash, heatshrink decoder +
hs_stream: ~1.5 KB code + ~8.2 KB BSS for the 8 KB sliding window (HEATSHRINK_STATIC_*flags above). Skip both if you only use plainopl_song. - Flash, song (plain
opl_song): ~2 bytes per OPL register write — e.g. Prehistorik 2 loop ~41 KB, DOOM E1M1 ~34 KB, Wacky Wheels intro ~2.5 KB. Songs scale with length and density. - Flash, song (packed
opl_song_hs, w13/l4): typically 30–40 % of the plain size. Across the 39 songs that ship in this repo the average ratio is ~31 %. - RAM, sample FIFO:
SEQ_FIFO_FRAMES * 4bytes (default 1024 frames = 4 KB). Tune with-DSEQ_FIFO_FRAMES=N. - RAM, player state: a few hundred bytes inside
seq_player.c.
The one variable cost is sizeof(opl3_chip) and the per-frame
cycle count, both of which are core-dependent — see the tables
above. As a rough guide, the cheapest combo (dbopl + plain
opl_song) fits in ~30 KB code + ~5 KB RAM per chip and
renders at ~85 ns/frame on x64.
#include "seq_player.h"
#include "opl_song_hs.h"
#include "pre2_loop_song.h" /* defines &pre2_loop (opl_song_hs) */
#include "heatshrink_decoder.h"
#include "hs_stream.h"
#define SAMPLE_RATE_HZ 49716 /* OPL3 native rate -- run as close to this as your DAC allows */
/* One static decoder + one static stream is enough for the whole
runtime; both are reused for every song you play. */
static heatshrink_decoder g_dec;
static hs_stream g_hs;
static opl_song_stream g_stream;
static int hs_byte (void* u) { (void)u; return hs_stream_next(&g_hs); }
static void hs_rewind(void* u) { (void)u; hs_stream_rewind(&g_hs); }
static void play(const opl_song_hs* s, int loop)
{
hs_stream_init(&g_hs, s->hs_data, s->hs_len, &g_dec);
g_stream = (opl_song_stream){
.next_byte = hs_byte, .rewind = hs_rewind, .user = NULL,
.codemap_len = s->codemap_len, .short_code = s->short_code,
.long_code = s->long_code, .total_ms = s->total_ms,
};
seq_play_stream(&g_stream, loop);
}
void boot(void)
{
synth_init(SAMPLE_RATE_HZ);
play(&pre2_loop, /*loop=*/1);
timer_setup_periodic(1000 /* µs */, on_systick_1ms);
dac_setup(SAMPLE_RATE_HZ, on_dac_sample);
enable_irq();
}
void on_systick_1ms(void) // 1 kHz low-priority timer ISR
{
seq_tick(1);
synth_update_fifo(); // refill the sample FIFO
}
void on_dac_sample(void) // ~50 kHz high-priority DAC ISR
{
int16_t l, r;
synth_get_fifo_sample(&l, &r); // O(1): pop one frame from the FIFO
dac_write(l, r); // or dac_mono((l + r) >> 1);
}The sample FIFO between synth_update_fifo() and
synth_get_fifo_sample() is what makes this safe: the DAC ISR is
guaranteed to find a frame ready every time, no matter how long the
previous seq_tick() took. The two ISRs share only the opl3_chip
struct and the FIFO; on a single-core MCU the SEQ_ISR_DISABLE /
SEQ_ISR_ENABLE hooks (see Files to copy / link above) handle
the two short critical sections. On a multi-core MCU put both ISRs
on the same core.
If you'd rather skip the FIFO and call the synth straight from the
DAC IRQ, use synth_render_sample(&l, &r) in on_dac_sample and
remove the synth_update_fifo() call from on_systick_1ms. The ISR
guard hooks still apply (they protect the OPL register write inside
seq_tick from the inline render in the DAC ISR).
The recommended MCU layout keeps the audio ISR as cheap as
physically possible: it pops one stereo frame from a small ring and
returns. All synth work — the expensive part — happens in a
lower-priority context that calls synth_update_fifo() to refill the ring
whenever it has spare time.
low priority high priority
-------------------------------- -------------------------
seq_tick(1) ? OPL register writes
synth_update_fifo()? OPL3_Generate ? push pop ? synth_get_fifo_sample()
frames into FIFO ?
DAC / I?S / PWM
This is why the FIFO render path is what you want on the MCU: the
chip is clocked at its native 49 716 Hz and the audio ISR just pops
ready frames. The choice of generator (resampled vs native) is a
compile-time switch in sequencer/seq_player.c
that applies to both synth_render_sample and synth_update_fifo:
-DSEQ_RESAMPLE=1(default) ->OPL3_GenerateResampled()at thesample_rate_hzyou passed tosynth_init(). Convenient on PC.-DSEQ_RESAMPLE=0->OPL3_Generate()at the chip's native rate. Cheaper, bit-exact w.r.t. the underlying core, recommended for embedded targets that can clock the DAC near 49 716 Hz.
Keeping both render paths on the same generator means you can A/B the inline path against the FIFO path without an apples-to-oranges resampler difference.
If your DAC clock can't hit 49 716 Hz exactly:
- 48 000 Hz is essentially indistinguishable and the easiest divider on most modern audio peripherals.
- 44 100 Hz is fine — a fraction of a percent off pitch, no other audible effect.
- 24 858 Hz (= 49 716 / 2) is the FALCON build's choice; halves the per-frame CPU at a tiny aliasing cost on the highest OPL voices.
- Anything below ~22 kHz — leave
SEQ_RESAMPLE=1(the default) and letOPL3_GenerateResampleddo the work.
demo_win/main.c is the canonical real-time loop
shape, just done cooperatively (no actual ISRs). Each iteration:
- Renders one tick's worth of samples (
SAMPLE_RATE / 1000frames). - Calls
seq_tick(1). - Pushes the buffer to
audio_write, which blocks until the WaveOut ring has room — that's what keeps the loop in real time.
On the MCU you don't need that explicit buffering; the DAC IRQ self-paces the synth and the timer IRQ self-paces the sequencer.
DOSBox can sniff every write your game makes to the OPL2/OPL3 chip and log them to a DRO v2 file. That file is the raw, byte-perfect register stream — same one the chip would have seen on real hardware — and is the input to all of the converter / loop-finder tools below.
-
Install DOSBox 0.74 (e.g.
winget install DOSBox.DOSBox). -
Drop the game into a folder under
games/(folder is gitignored — you supply your own copy of the game). -
Write a small DOSBox config that points at it. See
games/doom.conffor a complete example. The parts that matter are:[sblaster] oplmode=opl3 oplemu=nuked ; (only affects DOSBox's own playback; .dro records the raw register writes regardless) oplrate=49716 [dosbox] captures=capture ; .dro files land here [autoexec] mount c c:\silixcon-devel\opl\games\doom c:
& "C:\Program Files (x86)\DOSBox-0.74-3\DOSBox.exe" -conf games\doom.conf
Inside DOSBox, start the game, get to the music you want, then:
| Key | Action |
|---|---|
Ctrl+Alt+F7 |
Start / stop OPL capture (writes .dro) |
Ctrl+F10 |
Release / grab the mouse |
Type EXIT |
Quit DOSBox |
Each capture appears in capture/ as <gamename>_NNN.dro.
Tip: silence the SFX channels first (
-nomonsters -nosoundin DOOM, mute SFX in the in-game options elsewhere) so only the music ends up in the recording.
Once you have a .dro, the offline programs in tools/ turn it into a
clean, embeddable C header. They share the same DRO v2 reader and can
be chained pipeline-style.
| Tool | What it does |
|---|---|
tools\vgz2dro.exe |
Converts a VGM/VGZ capture (e.g. from vgmrips.net) into DOSBox DRO v2 format — handy because most archived OPL music online is .vgz, not .dro. |
tools\dro_opt.exe |
Drops register writes that don't change anything observable (re-writing the same value, writes shadowed by an immediately following one). Typically shaves 5–15 % off a capture before any compression. |
tools\dro_loop3.exe |
Finds the loop point (start of the second repetition) and writes a trimmed .dro containing exactly one loop iteration. Heuristic: scans the OPL register stream for the longest self-similar suffix. |
tools\dro2hdr.exe |
Converts a .dro into an embeddable C header. Pass --hs (default w13/l4) to heatshrink-pack the payload and emit an opl_song_hs; without it you get the plain opl_song form. |
tools\hs_bench.exe |
Sweeps a .dro through every reasonable (window, lookahead) combination and reports the smallest output. Use it once per song corpus to pick the global default; w13/l4 is rarely beaten. |
Typical pipeline for a looping song captured as VGZ:
tools\vgz2dro.exe songs\My_Song.vgz songs\my_song.dro
tools\dro_loop3.exe songs\my_song.dro songs\my_song.dro
tools\dro_opt.exe songs\my_song.dro songs\my_song.dro
tools\dro2hdr.exe songs\my_song.dro songs\my_song_song.h my_song --hs
Then wire it into the demo by adding #include "my_song_song.h" and a
SONGS[] row in demo_win/main.c. To re-pack the
whole songs/ folder in one shot run
powershell -File tools\regen_songs.ps1.
For a one-shot song the dro_loop3 step is unnecessary — just slice
off the intro/outro silence with dro_slice (below) and feed the
result to dro2hdr --hs.
These don't modify the file; they help you decide where to trim.
| Tool | Purpose |
|---|---|
tools\dro_dump.exe file.dro [start_ev] [count] |
Pretty-print the decoded register-write stream with timestamps. Used to eyeball where a section starts. |
tools\dro_segments.exe file.dro |
Splits the timeline at long silent gaps; useful for separating stinger / loop / outro within a single capture. |
tools\dro_perc.exe file.dro |
Lists every percussion strike with its timestamp — handy for finding bar boundaries in songs whose drums lock to the beat. |
tools\dro_trigs.exe file.dro |
Reports register writes that look like loop / cue triggers (e.g. abrupt instrument changes). |
tools\dro_loopfind.exe file.dro loop_start_ms [N] |
Given a candidate loop start, scores how well the tail repeats it. Use to validate / nudge what dro_loop3 produced. |
These rewrite the file.
| Tool | Purpose |
|---|---|
tools\dro_slice.exe in.dro out.dro start_ms end_ms |
Cuts out a [start_ms, end_ms) window. Use to crop intros/outros once dro_dump told you where to cut. |
tools\dro_evrange.exe in.dro out.dro start_ev end_ev |
Same idea but in event-index space. |
tools\dro_loop.exe, dro_loop2.exe, dro_loop3.exe |
Three generations of the loop-finder; dro_loop3 is the current default. The older ones are kept for diffing on tricky inputs. |
Most tools are plain C99 with no dependencies; rebuild any of them with:
gcc -O2 -Wall -Wextra -std=c99 tools\<name>.c -o tools\<name>.exe
The two that touch heatshrink (dro2hdr, hs_bench) need the
encoder linked in too:
gcc -O2 -Wall -Wextra -std=c99 -Iheatshrink -DHEATSHRINK_DYNAMIC_ALLOC=1 ^
tools\dro2hdr.c heatshrink\heatshrink_encoder.c -o tools\dro2hdr.exe
- Nuked-OPL3 (
nuked/opl3.[ch]) — © Nuke.YKT, LGPL 2.1+. Unmodified upstream. See the header innuked/opl3.hfor credits to the MAME team, OPLx decap project, and others whose work made the emulation possible. - heatshrink (
heatshrink/heatshrink_*.[ch]) — © Atomic Object, ISC license. Decoder/encoder taken unmodified from https://github.com/atomicobject/heatshrink;heatshrink_config.hcarries one local patch that#ifndef-guards theHEATSHRINK_STATIC_*macros so-Doverrides actually reach every translation unit (see commitbcb7dedfor the failure mode it fixes). Thehs_stream.[ch]pump on top of it is original to this repo and is under the same permissive terms as everything else here. - Game music captures — the
.dro/ generated.hfiles in this repo are derived from games whose music remains © their respective rightsholders (Titus Interactive for Prehistorik 2, id Software for DOOM, Apogee for Wacky Wheels). They are included for personal / historical / educational use; do not redistribute. The original game binaries themselves are not in the repo — supply your own copy undergames/. - Everything else (sequencer, demo, tools) — do whatever you like.