XMrsPlayer — a safe, no_std soundtracker music player
XMrsPlayer plays real soundtracker modules, on desktops and on tiny embedded targets alike. 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 |
| FastTracker XM | Works |
| ScreamTracker S3M | Works |
| Impulse Tracker IT | WIP — loader mostly done, playback in progress |
| SID (C64) | Experimental — reverse-engineered from Rob Hubbard's 6510 player |
A word on SID: tracks and effects can already be extracted from several of Hubbard's hits and played back through the generic module engine. One thing is still needed to go further — working per-track to reconstruct patterns (some tracks change length, which defeats a naive pattern builder), The module engine is already generic enough; the rest is a matter of time (and motivated contributors — Rust speakers welcome!).
FM instruments are supported by the underlying xmrs data structures and are ready for future formats.
Using it as a library
Minimal example (desktop, std):
use *;
use Amplification;
use *;
let bytes = read?;
let module = load?;
// 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 = new;
player.set_amplification; // unity by default; raise for more presence
player.set_max_loop_count; // 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
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 wrappers, 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 = new_with_voice_pool_capacity;
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>— one track per (song, instrument) pair, carryingVec<Cell>content. Cells carry a typedCellEvent(NoteOn { pitch, velocity },InstrReset,NoteOff { retrig }, …) plus a vector of discreteTrackEffects (Arpeggio, NoteDelay/Cut/Off/Retrig/FadeOut, Tremor, Volume/Panning/ChannelVolume set, instrument switches, MidiMacro).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 fromTrackImportUnitat import time. The player consumes the lanes exclusively for those classes — there is no cell-side fallback.module.timeline_map: TimelineMap— the linearisedpattern_orderused to compute absolute ticks. Absorbs every navigation effect (Bxx/Dxx/E6y/EEy+ restart byte) at import time so the sequencer just walksentrieslinearly.
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 wrappers: when
a Track repeats a single (note, no-effect) cell on a strict
Bjorklund pattern, auto_convert_strict_euclidean_tracks collapses
the Track to a single prototype Cell and stashes the
(events, steps, rotation) triplet in an empty Instrument slot
typed as InstrumentType::Euclidean(InstrEkn). The cells are
regenerated on-the-fly by Module::row_at, so playback is
bit-identical while the on-disk Track is tiny.
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 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 → 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_sid |
C64 SID |
Build examples:
# Tiny no_std build, XM only
# Embedded build with multiple importers
# Desktop build (default)
Installing the CLI player
The demo feature ships a standalone CLI player (xmrsplayer) built on cpal.
From crates.io:
From a local checkout:
Run it directly from the git repo:
Typical invocation:
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
xmrsare#![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::tablesfor bit-numerical comparison against reference players. - Playback decisions are quirk-driven. Per-format runtime behaviour — FT2's arpeggio LUT and
E60pattern-loop bug, IT's persistent tremor state and pan-reset policy, the per-formatSample.volumesemantics, and so on — is exposed as individual flags onPlaybackQuirksinsideModule::profile.module.profile.formatitself is metadata only (used for display, export, and editor UI). TheCompatibilityProfile::ft2(),it214(),pt(), etc. constructors preset coherent quirk bundles per format; an editor or modern authored module can compose the quirks freely without lying about the format tag. Seexmrs::module::PlaybackQuirksfor 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.