xmrsplayer 0.14.4

Safe, no_std SoundTracker music player — plays MOD/XM/S3M/IT/DW with cycle-accurate SID and OPL/AdLib FM synthesis.
Documentation

XMrsPlayer — a safe, no_std soundtracker music player

XMrsPlayer plays real soundtracker modules, on desktops and on tiny embedded targets alike. Beyond the tracker formats (MOD, XM, S3M, IT) it also plays David Whittaker (DW) Amiga tunes, and renders OPL2/AdLib FM and Commodore 64 SID instruments through clean-room synthesizers. The crate is 100% safe Rust (#![forbid(unsafe_code)]) and builds on no_std; it reuses the data structures defined by the companion xmrs crate.

It does not aim for bit-exact compatibility with every tracker ever written — that target is unreachable given the historical and ever-shifting nature of the demo scene — but it aims to be close enough to amaze you. :-)

Supported formats

Format Status
Amiga Module (MOD) Works — replay semantics follow ProTracker (pt2-clone)
FastTracker XM Works — replay semantics follow FastTracker 2 (ft2-clone)
ScreamTracker S3M Works — replay semantics follow Schism Tracker
Impulse Tracker IT Works — replay semantics follow Schism Tracker, validated over a 322-file corpus
David Whittaker DW Works — reverse-engineered from the original .dw 68000 player binaries
SID (C64) Plays — Rob Hubbard's tunes through a cycle-accurate 6581/8580 synthesizer

A word on DW: David Whittaker's Amiga tunes ship as self-contained 68000 player binaries with no magic header. The xmrs importer detects them structurally, reads the replayer's init-time constants, and re-interprets the original per-voice playback (per-voice loops, period-space vibrato, arpeggio pitch lanes, sub-song selection) into the generic Module model, so they play back through the same engine as the tracker formats. Load via Module::load (tried last in auto-detect) or Module::load_dw.

A word on SID: Commodore 64 tunes are played back the way the FM instruments are — through a dedicated synthesizer rather than the sample engine. The xmrs importer reverse-engineers Rob Hubbard's 6510 replayer into InstrRobSid instruments (a SidVoice patch plus his signature per-tick effect tables: waveform cycling, pulse-width sweep, frequency arpeggio, vibrato), and the player drives them through a cycle-accurate MOS 6581/8580 synthesis core (xmrs::generators::sid, built on the sidera chip model). The mapping is one chip per instrument, with the chip's three hardware voices serving as that instrument's polyphony pool — exactly like the OPL driver's slots. Note pitch, portamento, freq slides and vibrato track each note like a sample voice. The imported Module is fully generic, so volume, panning, automation and DAW routing are all representable and editable like any other track — but at render the chip is currently summed mono into the master (only per-channel mute is honoured), so those per-track controls aren't yet applied to SID voices.

A word on FM: OPL2 / AdLib instruments embedded in S3M modules play back through a clean-room OPL2 synthesizer (xmrs::generators::opl), modeled on the documented YMF262 (OPL3) hardware behaviour with integer-only, const-generated tables — no vendored emulator code. The same xmrs data structures are ready for OPL in future formats (MPTM, native FM patches).

Using it as a library

cargo add xmrsplayer

Minimal example (desktop, std):

use xmrs::prelude::*;
use xmrs::fixed::units::Amplification;
use xmrsplayer::prelude::*;

let bytes = std::fs::read("song.xm")?;
let module = Module::load(&bytes)?;

// Default voice pool capacity (256, matching schism's MAX_VOICES) —
// see `XmrsPlayer::new_with_voice_pool_capacity` if you need more
// (NNA-heavy modules) or fewer (embedded targets).
let mut player = XmrsPlayer::new(&module, 48_000, /* song */ 0);
player.set_amplification(Amplification::UNITY); // unity by default; raise for more presence
player.set_max_loop_count(1);                   // 0 = loop forever

// The player is an `Iterator<Item = i16>` producing Q1.15 interleaved
// L/R samples. Most audio backends (cpal, rodio, hound, ...) accept i16
// natively; if your backend wants f32, divide each sample by 32768.0 at
// the boundary.
for sample in player.by_ref().take(48_000 * 2) {
    // hand `sample` to your audio backend
}

A complete CLI player built on cpal is shipped as the xmrsplayer binary (see Installing the CLI player below). The examples/ directory contains two focused demos: arp_bug.rs (a regression check for FT2 arpeggio) and inspect_mod.rs (a diagnostic dumper that prints the parsed module's instruments, pattern-order, DAW tracks/clips/timeline map, detected Euclidean tracks, and the result of verify_layers_consistent).

For modules with very heavy NNA polyphony (the Another Life-class files that stack hundreds of NNA = Continue voices), you can raise the pool capacity at construction:

let mut player = XmrsPlayer::new_with_voice_pool_capacity(
    &module, 48_000, /* song */ 0, /* capacity */ 1024
);

The default is sized to match how the original trackers behaved — go above it only when you have evidence the song needs it.

The player also exposes a subscription API — hook into row/tick changes with PlayerObserver, into the produced audio stream with MixObserver / ChannelsObserver (VU-meters, WAV recorders, visualisers), or into MIDI events emitted by IT Zxx macros with MidiObserver (bridge to external synths or hardware). See OBSERVERS.md.

The DAW timeline layer

Every imported Module stores songs natively in a DAW-style layout — the legacy Pattern → Row → TrackUnit matrix is gone:

  • module.tracks: Vec<Track> — an enum with three variants. Track::Notes carries the classic per-row Vec<Cell> (one cell per row); Track::Euclidean is a Bjorklund generator that synthesises one cell per row at playback time (see Euclidean rhythm tracks below); Track::Audio is a recorded-audio clip (see The DAW signal graph below). The note variants bind to a fixed instrument index. Cells carry a typed CellEvent (NoteOn { pitch, velocity }, InstrReset, NoteOff { retrig }, …), a vector of discrete TrackEffects (Arpeggio, NoteDelay/Cut/Off/Retrig/FadeOut, Tremor, Volume/Panning/ChannelVolume set, instrument switches, MidiMacro), and a per-note NoteExpression (pitch-bend / volume / pan — neutral for every tracker import).
  • module.clips: SortedClips — placements of those tracks on the timeline (song, target_channel, position_tick, end_tick).
  • module.automation: Vec<AutomationLane> — every continuous modulation (Vibrato/Tremolo/Panbrello/Portamento/TonePortamento/ VolumeSlide/ChannelVolumeSlide/PanningSlide per-Track + Bpm/Speed/ GlobalVolume + their slides song-level) extracted upstream from TrackImportUnit at import time. The player consumes the lanes exclusively for those classes — there is no cell-side fallback.
  • module.timeline_map: TimelineMap — the linearised pattern_order used to compute absolute ticks. Absorbs every navigation effect (Bxx / Dxx / E6y / EEy + restart byte) at import time so the sequencer just walks entries linearly.

The player calls Module::row_at(song, pattern, row), which routes through the DAW layer (the only path — Module.pattern[…] no longer exists). For an authored module you can build the DAW layer with xmrs::daw::build_timeline::build_timeline_layer; importers do it automatically.

The same indirection also enables Euclidean rhythm tracks: when a Track::Notes repeats a single (note, no-effect) cell on a strict Bjorklund pattern, auto_convert_strict_euclidean_tracks rewrites it in place as a Track::Euclidean variant carrying the (events, steps, rotation) parameters and the single prototype Cell directly on the Track. The cells are regenerated on-the-fly by Module::row_at, so playback is bit-identical while the on-disk Track is tiny.

The DAW signal graph

The player also renders the xmrs DAW generalization — a native signal graph (Device insert chains per channel, aux/return Buses fed by Sends, a master chain), recorded-audio Track::Audio clips (linear-resampled, routed through the same channel strip as notes and drawn from the shared Module::assets pool), DeviceParam automation, per-voice ModScope::Voice modulation, and per-note NoteExpression. The contract stays strict for the modulation and routing model: a loaded MOD / XM / S3M / DW module leaves the whole graph empty / neutral, so its render is bit-identical to a build without it. The one deliberate exception is IT mix-plugins — the loader projects a module's DMO / VST mix_plugins onto native device inserts, so a plugin-bearing .it renders its effects (a plugin-free .it stays bit-identical). Everything else exists for editors and authored modules built on the xmrs data model.

Talking to the player in f32

The player's API is fixed-point: Amplification is Q4.12, the iterator yields Q1.15 i16. Most desktop integrations want to expose a slider in linear f32 units, so xmrs provides one-line bridges from / to f32 for every Q-format newtype, gated behind the float-helpers cargo feature.

The default xmrsplayer build (the std feature is on) already forwards xmrs/float-helpers, so the conversions are available out of the box on desktop:

use xmrs::fixed::units::Amplification;

// f32 → Amplification: clamps to Q4.12's representable range
// `[-8.0, +8.0)`. Use this at the UI boundary, exactly once per
// numeric edit; store the resulting `Amplification`, don't keep
// the f32 around as the source of truth (every f32 ↔ Q round
// trip drifts by up to one LSB).
let user_slider: f32 = 1.5;
player.set_amplification(Amplification::from_f32(user_slider));

// Amplification → f32: read back for display.
let current: f32 = player.amplification().to_f32();

Volume, Panning, Amp, EnvValue, Frequency, Period, etc. all expose the same from_f32 / to_f32 pair under the same feature.

If you're targeting no_std (default-features = false), or you want the smallest possible build with no soft-float pull-in, leave the feature off and stay in fixed-point: Amplification::UNITY, Amplification::from_int(2) (= 2× gain), or Amplification::from_raw_q4_12(0x1800) (= 1.5× gain) cover the common cases without any f32.

Cargo features

xmrsplayer has opt-in features so you only pay for what you use.

Default: ["std", "import"] — the common desktop/server case.

Runtime

Feature Effect
std Enable the standard library and forward std + float-helpers to xmrs. The latter exposes f32 ↔ Q conversions on every fixed-point type, which desktop editors / GUIs use for level-meters and numeric inputs. Embedded users skip both with default-features = false.
demo Everything needed to build the example CLI player: pulls in std, import, clap, console, cpal, hound.

The whole gain / pitch / LFO / filter / sample chain is integer Q-format, end-to-end. No libm, no micromath, no num-traits. The crate compiles no_std out of the box without picking a math backend — there's nothing to pick.

Format importers (propagated to xmrs)

Feature Format
import Umbrella — all of the below
import_mod Amiga MOD
import_xm FastTracker XM
import_s3m ScreamTracker S3M
import_it Impulse Tracker IT
import_dw David Whittaker .dw
import_sid C64 SID

Build examples:

# Tiny no_std build, XM only
cargo build --no-default-features --features "import_xm" --release

# Embedded build with multiple importers
cargo build --no-default-features --features "import_mod import_xm" --release

# Desktop build (default)
cargo build --release

Installing the CLI player

The demo feature ships a standalone CLI player (xmrsplayer) built on cpal.

From crates.io:

cargo install xmrsplayer --features demo

From a local checkout:

cargo install --path . --features demo

Run it directly from the git repo:

cargo run --features demo --release -- --help

Typical invocation:

xmrsplayer -f song.xm                 # play
xmrsplayer -f song.xm -o song.wav     # render to WAV
xmrsplayer -f song.xm -a 0.25 -l 1    # one loop, amplification 0.25

Interactive keys while playing: Space to pause, / to move, Enter/i for info, Esc or q to quit.

Design notes

  • Safety first. Both this crate and its dependency xmrs are #![forbid(unsafe_code)].
  • Dependencies are lean, and gated behind features — this matters not only for embedded.
  • Concert pitch by default. Notes resolve to MIDI-aligned frequencies (A-4 = 440 Hz exactly), so tracker output mixes cleanly with any 440-Hz-aligned source. The legacy FT2 / ProTracker reference values (≈ −2 cents in linear mode, ≈ +17 cents in Amiga mode) are still available in xmrs::fixed::tables for bit-numerical comparison against reference players.
  • Playback decisions are quirk-driven. Per-format runtime behaviour — FT2's arpeggio LUT and E60 pattern-loop bug, IT's persistent tremor state and pan-reset policy, the per-format Sample.volume semantics, and so on — is exposed as individual flags on module.quirks (a PlaybackQuirks). The sibling module.origin tag (an Option<ModuleFormat>) is metadata only (used for display, export, and editor UI). The xmrs::tracker::profiles::ft2(), it214(), pt(), etc. free functions preset coherent quirk bundles per format, and PlaybackQuirks::default() is the quirk-free editor default; an editor or modern authored module can compose the quirks freely without lying about the format tag. See xmrs::core::compatibility::PlaybackQuirks for the canonical list.
  • Embedded-friendly. The code should be progressively degradable to older processors through successive optimizations, and it would be a lot of fun to see what LLVM can do with it on float-less architectures like the Z80, the MOS 6502 family, or on the 68k target.

Contributing

The project was written with extension and sharing in mind — feel free to use it for your own projects. If you do, an issue on the codeberg repository mentioning it would be lovely (but not mandatory).

License

MIT — see LICENSE.