game-toolkit-dosaudio
The sound of a 1990s PC, for your Rust game. Beep the PC speaker, play
QBasic PLAY strings, write AdLib registers, push samples through Sound Blaster
DMA or play MIDI on FM. All of it runs on one emulated hardware clock, so the
results match real hardware timing.
Use it as a hardware back end for an emulator. Or use it as a retro sound engine in a modern game that never touches a port.
- ๐ PC speaker: tones, QBasic
PLAYmelodies and PWM sample playback - ๐น AdLib / OPL2 / dual OPL2 / OPL3: raw registers, patch helpers, MIDI driver, instrument banks
- ๐พ Sound Blaster 1, 2, Pro, Pro 2 and 16: DSP, 8237 DMA, IRQs, mixer, multi-voice SFX
- ๐ผ MIDI: MPU-401 UART, General MIDI through SoundFonts, MT-32 with your own ROMs
- ๐ป Tandy / PCjr PSG: three tones plus LFSR noise
- ๐ผ File formats: IMF, DRO, VGM and Standard MIDI files, plus DRO export of what you played
- ๐๏ธ Game-friendly control: looping, fades, live track replacement and per-device soundtracks
- ๐งต Real-time safe: rendering never allocates or takes a lock
Quick start
[]
= "0.1"
No audio device is needed. OfflineMachine renders straight to WAV:
use *;
use Duration;
let mut machine = new?;
machine.sound?; // like Turbo Pascal's Sound(440)
machine.render_for?;
machine.nosound?;
write?;
# Ok::
Tour
QBasic PLAY on the PC speaker
use ;
let mut machine = new?;
let tune = parse?;
let _handle = machine.play_speaker?;
let wav = machine.render_to_end?;
# Ok::
The parser covers notes, accidentals, octaves, tempo, lengths, pauses, N
numbers, dots and MN/ML/MS articulation. speaker_pwm(&samples, rate)
plays 8-bit PCM through the speaker, the way old games produced speech.
Talk to the AdLib like it's 1990
use *;
let = split?;
// Classic AdLib detection: start OPL timer 1, wait, check the status flags.
ports.outb; ports.outb;
ports.outb; ports.outb;
ports.wait_us;
assert_eq!;
// Or skip the register maths and use the musical helpers.
let organ = OplPatch ;
let mut opl = ports.opl?;
opl.set_patch?;
opl.note_on?;
let mut frames = ;
engine.render; // runs on your audio thread
# Ok::
DosAudio is the game side: ports, timers and register latches. AudioEngine
is the audio side: synthesis. A lock-free queue of timestamped writes connects
them.
Play classic music files
use *;
let song = load?; // or Imf::load(bytes, 560)
let mut machine = new?;
let _music = machine.play?;
write?;
# Ok::
MIDI on FM, with real instrument banks
use *;
use Arc;
let midi = load?;
let bank = from_op2?; // or OplBank::starter()
let driver = new;
let mut machine = new?;
let _music = machine.play?;
write?;
# Ok::
The driver loads SBI, OP2 (DMX), WOPL and AdLib BNK banks. It handles layered voices, voice stealing, pitch bend, sustain, pan and percussion on channel 10.
Sound Blaster sound effects
use *;
let mut machine = new?;
let zap: = .map.collect;
let sfx = machine.play_pcm?; // unsigned 8-bit mono
let wav = machine.render_to_end?;
assert!;
# Ok::
play_pcm uses the same DSP and DMA path a DOS game would program by hand. For
overlapping effects, SfxMixer mixes several voices into an auto-init DMA ring.
See examples/dma_refill.rs.
Live playback in a game
With the default toolkit feature, AudioEngine plugs into
game-toolkit-audio as a stereo
stream:
use *;
use Duration;
let song = load?;
let = split?;
let music = engine.play?;
let rate = engine.sample_rate;
let mut audio = new.map_err?;
let _stream = audio.play_stereo_stream?;
// Later, from the game loop:
music.fade_volume?;
# Ok::
Music handles can pause, resume, stop, fade and report position. Call
engine.music_controller(capacity) before streaming to swap tracks while audio
runs.
One soundtrack, every sound card
A Soundtrack holds one arrangement per device, all with the same length. The
player picks the arrangement for the hardware it has. It can also switch device
during playback, without losing its place in the song:
#
#
You can also build a soundtrack by hand with Soundtrack::insert from prepared
single-device music: DRO, IMF, VGM, MIDI or PLAY strings. fallback sets which
device to use when an arrangement or its hardware is missing, for example from
MT-32 to OPL2. See the backend reference for
restoration limits and synthie mappings.
Feature flags
| Feature | Default | Adds |
|---|---|---|
opl |
โ | MIT-licensed oplon FM core |
sb |
โ | Sound Blaster family, DMA, conventional memory, SFX mixer |
formats |
โ | IMF, DRO, VGM and SMF readers |
toolkit |
โ | game-toolkit-audio stream integration |
psg |
Tandy SN76489 tone and noise | |
mpu401 |
MPU-401 UART with custom MidiSynth back ends |
|
gm |
General MIDI from a caller-supplied SF2 via RustySynth | |
mt32 |
CM-32L/MT-32 synthesis from caller-supplied ROMs | |
synthie |
Compile synthie songs into native, FM and speaker arrangements | |
nuked |
Nuked OPL3 core (LGPL-2.1-or-later) |
The PC speaker, timeline, capture and WAV output need no features. The crate
uses std. Rust 1.95 or later is required.
Examples and demos
Bring your own files:
The render:* variants, such as render:file and render:opl-midi, write the
WAV to target/ without playing it. All demo music is original. WAV tasks play their output with ffplay, afplay,
pw-play, paplay or aplay, whichever it finds first. No ROMs, SoundFonts or
third-party music are included.
How it works
- One clock. Time is counted in ticks of the PC crystal (exactly 315/22 MHz). Sample positions use exact rational arithmetic, so audio does not drift.
- Two halves.
DosAudio::splitreturns a game side and an audio side. Port reads such as timer status come back from the game side immediately. Writes travel to the audio side through a bounded SPSC queue. - Fixed latency. Live writes are delayed by a fixed 10 ms by default, which absorbs scheduling jitter. Offline rendering uses no delay.
- Plugs into a bus.
DosAudioimplementsgame_toolkit_dosbus::IoDevice. An emulator can mount it next to its other devices. With the speaker installed,DosAudioowns the 8253 PIT on ports 40hโ43h. - Bring your own core. Implement
OplCoreand select it withOplBackend::Customto replace the FM synthesizer.
Hardware notes
Each section below gives the exact coverage and limits for one device.
Output intervals split at commands and at the OPL's 288-tick native boundaries. The speaker integrates pulse area within each output sample, then applies a coupling/cone filter. FM output uses held native frames with area resampling; this renderer does not promise a band-limited resampler or bit-identical output between chip cores.
try_outb_at reports a full queue without changing time or hardware state; the
infallible helpers panic on overflow. Late writes apply immediately and
increment engine.stats().late_commands. The host must pace its machine clock
against audio output; latency absorbs jitter but does not synchronize two
independent clocks.
The timer is the dosbus Pit: modes 0, 2 and 3 on all three channels, low/high
and paired counter writes, count latching, and IRQ 0 timing. Channel 2 drives
the speaker through the 61h gate/data bits and output readback; channel 1 drives
the DRAM-refresh bit. BCD, modes 1, 4 and 5 (run as mode 3) and the 8254
read-back command are not implemented. OPL timers are polling/status sources;
they do not drive an ISA IRQ.
ports.start_capture() and ports.finish_capture() record game-thread port
writes; capture.to_dro() exports FM accesses as v1 DRO with millisecond
timing. To include writes made by the audio-thread sequencer, call
engine.start_capture(capacity) before streaming, poll try_finish() on the
control thread, then stop() and keep polling until the renderer acknowledges
the next frame boundary. Overflow marks the capture incomplete. DRO carries OPL
writes only.
Sequencing and looping run inside the renderer, independent of game frame rate. Music and manual port I/O share the same FM hardware; music volume/pause affects that hardware's whole FM output, and stopping music releases its notes. Dropping the last handle clone stops playback.
controller.play(prepared, options) replaces the active track; play_at and
send_event_at take absolute output timestamps. Requests keep FIFO order,
including equal timestamps. Full queues return QueueFull. Call
controller.reclaim() periodically to free retired players on the control
thread. fade_volume ramps across output frames, freezes while paused and
resumes from its current gain when interrupted.
PreparedMusic owns validated OPL, MIDI/SysEx, PSG, speaker or native PCM
events. with_loop_start(at) plays an intro once, then repeats [at, duration).
Soundtrack arrangements must share duration and loop point. Switching restores prepared register/controller/key checkpoints at an output-frame boundary; sounding notes retrigger their envelopes. Limits: 4,096 checkpoints per arrangement, 1,024 restore messages per checkpoint, one million overall; MIDI restoration keeps up to 128 active notes and 16 SysEx messages (65,536 bytes) since reset. MIDI state messages replay in source order; overlapping same-key notes are rejected during preparation. A machine has one MIDI backend, so GM and MT-32 arrangements require separate machines. Switching can select that backend alongside installed FM, PSG, speaker and native output.
| Model | DSP | FM | DMA playback |
|---|---|---|---|
| SB1 | 1.05 | OPL2 | Unsigned 8-bit mono single-cycle |
| SB2 | 2.01 | OPL2 | Unsigned 8-bit mono, single/auto-init |
| Pro | 3.00 | Dual OPL2 | 8-bit mono/stereo, high-speed |
| Pro2 | 3.02 | OPL3 | 8-bit mono/stereo, high-speed |
| SB16 | 4.05 | OPL3 | 8/16-bit, signed/unsigned, mono/stereo |
Constructors are sb1(), sb2(), sb_pro(), sb_pro2() and sb16();
SoundConfig::from_blaster("A220 I5 D1 H5 T6") parses a BLASTER string.
sb2() defaults to 220h, IRQ 5, DMA 1; use
SoundConfig::none().sound_blaster(SoundBlasterConfig { base: 0x240, irq: 7, dma: 3 })
for other resources. The 2x8h/2x9h FM ports share latches and timers with
388h/389h.
Buffered PCM. play_pcm accepts 1โ65,536 bytes at 4,000โ23,000 Hz;
sound.sample_rate() reports the quantized hardware rate. An Arc<[u8]>
shares the samples. One buffer plays at a time; a new buffer replaces the
previous transfer, and a raw write to the reset or command port hands the DSP
back to manual I/O. Stop takes effect at the current machine time on the next
advance and cannot retract queued samples. For live playback, call
ports.try_advance_to(machine_time) as the game advances; it returns
QueueFull without side effects if the advance does not fit.
DSP. Reset (hold 2x6h high โฅ 3 ยตs, read AAh from 2xAh), version and complement queries, direct DAC, speaker enable/status and time-constant 8-bit DMA. 14h single-cycle, 1Ch auto-init with 48h block size, D0h/D4h halt and continue, DAh exit auto-init, D1h/D3h DAC gate, F2h software IRQ. Pro mixer 0Eh enables alternating-byte stereo. SB16 41h sets the rate; Bxh/Cxh count words or bytes with separate 2xEh/2xFh acknowledgement. CT1345/CT1745 master, PCM and FM gains affect output. ADPCM, recording and DSP MIDI are unsupported.
DMA. Either service requests yourself (next_dma_tick, try_dma_write_at,
IoDevice::dma_request) or call ports.attach_dma(Arc<ConventionalMemory>) for
an emulated 8237 with 1 MiB of memory, programmed through normal ports. Then
poll_dma_completion() reports block/IRQ events for ring refills. Polling never
acknowledges an IRQ; reading 2xEh does.
use *;
let = split?;
// Enable output, select 8 kHz, and start a two-byte single-cycle block.
for byte in
for sample in
assert!;
ports.inb; // Acknowledge the DSP interrupt.
engine.render;
# Ok::
SB2 uses an ideal DAC. Other models apply documented rate limits and approximate analog response; these are not component-level simulations.
OplMidiDriver drives OPL2's nine voices or dual OPL2/OPL3's eighteen, with
layered instruments and deterministic stealing of the oldest note. It handles
bank/program changes, volume, expression, sustain, pitch bend (RPN 0), pan and
all-notes/all-sound/reset-controller messages. Gain rounds to OPL's 0.75 dB
steps. Aftertouch, modulation and vendor SysEx are ignored; GM System On resets
the driver. driver.message(message, emit) is an allocation-free register
writer; driver.write_message(&mut ports, message) sends a live message through
the port queue (use at least 128 queue slots).
| Loader | Coverage and limits |
|---|---|
OplBank::from_sbi |
One SBI instrument as bank 0/program 0 |
OplBank::from_op2 |
175 DMX records; fixed pitch, transposition, layered voices and detune; delayed vibrato rejected |
OplBank::from_wopl |
v1โ3, bank-select metadata, fixed pitch and pseudo-four-operator layers; true four-operator/rhythm timbres rejected |
OplBank::from_bnk |
AdLib v1.0 melodic timbres, mapped to programs by name-table order; HMI/BNK2 and rhythm timbres rejected |
OplBank::starter() has six original melodic and five percussion patches, not
a full GM set. Missing programs fall back to bank 0, then program 0 (drum 36).
No historical bank is included; provenance is in
tests/fixtures/opl_banks.md.
PSG (psg): SoundConfig::none().psg(true) installs the Tandy/PCjr port
C0h; ports.psg() has tone, attenuation and noise helpers. Clock is the PC
crystal รท 4, a 15-bit LFSR with taps 0/4 and 2 dB steps. Sega/Game Gear variants
are unsupported.
MPU-401 (mpu401): UART at 330h/331h. 3Fh enters UART mode, FFh resets.
Running status, interleaved real-time bytes and SysEx up to 65,536 bytes are
supported; intelligent mode is not. MidiBackend::Custom accepts any
allocation-free MidiSynth.
General MIDI (gm): load your SF2 with GmSynth::load_soundfont(&mut reader)
and pass it to SoundConfig::gm(font). Rendering uses RustySynth.
MT-32 (mt32): Mt32Roms::from_rom_dir(path) validates your CM-32L control
and PCM ROMs; SoundConfig::mt32(roms) installs them. Supported images are
CM-32L/LAPC-I control v1.00 or v1.02 plus the full 1 MiB CM-32L PCM ROM.
Original MT-32, CM-32LN and split ROM images are rejected. The private core is
adapted from moont 1.0.0 (LGPL-2.1-or-later). Native queues are preallocated;
engine.midi_dropped_messages() reports overflow. No ROMs are distributed.
Firmware hashes, queue limits and test coverage are in the
backend reference.
- IMF:
Imf::load(bytes, tick_hz). The game picks the tick rate, as the file does not store it.load_type0/load_type1resolve ambiguous headers. - DRO: v1 and uncompressed interleaved v2, OPL2/dual OPL2/OPL3.
- SMF: type 0/1, PPQN and SMPTE divisions, tempo changes with exact phase, split SysEx. Type 2 and RIFF are errors.
- VGM: uncompressed OPL/PSG logs (YM3812, YM3526, YMF262, Tandy PSG) with intro/loop offsets. VGZ, other chips and data blocks are errors.
All parsing happens outside rendering.
Development checks
task test:property runs generated-input and playback-control properties;
task fuzz:smoke runs the four sanitizer fuzz targets. Longer campaigns, seed
reproduction and caller-ROM checks are described in the
testing reference. Fuzzing uses a separate nightly
workspace and adds no production dependencies.
License
Dosaudio's own code is EUPL-1.2 OR MIT OR Apache-2.0, at your option. See
LICENSE-EUPL, LICENSE-MIT and LICENSE-APACHE.
Dependencies have their own terms: game-toolkit-dosbus and the optional toolkit
host are EUPL-1.2, oplon is MIT, rtrb is MIT OR Apache-2.0, and optional
nuked-opl3 is LGPL-2.1-or-later. The optional synthie dependency is EUPL-1.2.
The included private CM-32L core is LGPL-2.1-or-later and retains its own
notices and license texts. The package SPDX
expression includes that source license, even when mt32 is disabled. The triple
license applies to this crate's own code; it does not relicense third-party code. See
THIRD-PARTY.md.