A Python library for reading and writing Dirtywave M8 tracker files.
pym8 lets you create, read, and edit M8 projects (.m8s) and single-instrument exports (.m8i) programmatically. The M8 is a portable music tracker/synthesizer by Dirtywave. pym8 is a Python port of m8-file-parser; target firmware is 6.2+.
What it covers:
- All seven instrument types (Wavsynth, Macrosynth, Sampler, MIDIOut, FMSynth, HyperSynth, External)
- All six modulator types (AHD, ADSR, Drum, LFO, Trig, Tracking) as distinct classes
- Phrases (notes + FX), chains, song matrix, instrument slots
- Tables (256 × 16-step modulation grids — transpose, velocity, 3 FX per step)
- 3-band parametric EQ (132 slots, 7 EQ types × 5 stereo modes) — global, effect-section, and per-instrument
- Mixer settings (master volume, 8 track volumes, send levels, DJ filter, limiter, OTT), MIDI settings (sync, transport, per-track input routing), effects settings (chorus / delay / reverb knobs, OTT config), MIDI mappings (128 controller routings), grooves (32 timing curves), and scales (16 microtonal tuning maps)
- FX command enums for Sequence, Sampler, Mixer (firmware 6.2), and Modulator FX groups
- Audio helpers: sample-chain WAV builder, slice-point WAV writer
What it doesn't cover: theme/UI colors (separate .m8c file format, not part of .m8s projects). Everything else from the M8 project file is editable from Python.
pip install -e .Dependencies (pydub for sample tooling, pyyaml for the demo preset loader) are declared in pyproject.toml.
from m8.api.project import M8Project
from m8.api.instruments.sampler import M8Sampler
from m8.api.phrase import M8Phrase, M8PhraseStep, M8Note
from m8.api.chain import M8Chain, M8ChainStep
project = M8Project.initialise()
project.metadata.name = "MY-SONG"
project.metadata.tempo = 140
# Instrument — parameters are typed descriptor attributes
kick = M8Sampler(name="KICK", sample_path="samples/kick.wav")
kick.delay_send = 0x80
project.instruments[0] = kick
# Phrase — four-on-the-floor
phrase = M8Phrase()
for step in (0, 4, 8, 12):
phrase[step] = M8PhraseStep(note=M8Note.C_4, velocity=0x6F, instrument=0x00)
project.phrases[0] = phrase
# Chain references the phrase; song row 0 track 0 plays the chain
project.chains[0] = M8Chain()
project.chains[0][0] = M8ChainStep(phrase=0x00, transpose=0x00)
project.song[0][0] = 0x00
project.write_to_file("MY-SONG.m8s")Reading an existing file:
project = M8Project.read_from_file("existing-song.m8s")
print(project.metadata.name, project.metadata.tempo)
for i, inst in enumerate(project.instruments):
if hasattr(inst, "name"):
print(f" {i:02X}: {type(inst).__name__} {inst.name!r}")Parameters are exposed as typed descriptor attributes. Setting an out-of-range value raises ValueError; enum-typed fields accept either an enum member or a raw int.
| Class | Type ID | Purpose |
|---|---|---|
M8Wavsynth |
0 | Wavetable synthesizer (70 shapes) |
M8Macrosynth |
1 | Mutable Instruments Braids algorithms (45 shapes) |
M8Sampler |
2 | Sample playback with slicing/looping/pitch modes |
M8MIDIOut |
3 | Pure MIDI output to external gear (10 CC slots) |
M8FMSynth |
4 | 4-operator FM synthesizer |
M8HyperSynth |
5 | 6-oscillator detuned-stack synth with 16-slot chord matrix |
M8External |
6 | Audio-input routing with 4 CC slots |
MIDIOut vs External.
M8MIDIOut(type 3) is what you want for sequencing external hardware/software synths over MIDI — 10 CC slots, port enum includingINTERNAL.M8External(type 6) routes external audio into the M8's effect chain and has a small MIDI CC capability alongside. The MIDI demos in this repo all useM8MIDIOut.
# Wavsynth
from m8.api.instruments.wavsynth import M8Wavsynth, M8WavShape
w = M8Wavsynth(name="LEAD")
w.shape = M8WavShape.SAW
w.size = 0x80
w.cutoff = 0xA0
# Macrosynth (Braids-based)
from m8.api.instruments.macrosynth import M8Macrosynth, M8MacroShape
m = M8Macrosynth(name="BASS")
m.shape = M8MacroShape.CSAW
m.timbre = 0x60
m.colour = 0x40
# Sampler
from m8.api.instruments.sampler import M8Sampler, M8PlayMode
from m8.api.instrument import M8FilterType
s = M8Sampler(name="SNARE", sample_path="samples/snare.wav")
s.play_mode = M8PlayMode.FWDLOOP
s.filter_type = M8FilterType.LOWPASS
s.cutoff = 0xC0
s.chorus_send = 0x40
# FM synth
from m8.api.instruments.fmsynth import M8FMSynth, M8FMAlgo, M8FMWave
fm = M8FMSynth(name="BELL")
fm.algo = M8FMAlgo.A_B_C_D
fm.op_a_shape = M8FMWave.SIN
fm.op_b_ratio = 0x02
# MIDI out
from m8.api.instruments.midiout import M8MIDIOut, M8MIDIPort
mout = M8MIDIOut(name="TB-03")
mout.port = M8MIDIPort.MIDI
mout.channel = 0
mout.cca_num = 71 # CC for resonance
mout.cca_val = 0x40
# HyperSynth (6-oscillator stack + chord matrix)
from m8.api.instruments.hypersynth import M8HyperSynth, M8Chord
hs = M8HyperSynth(name="STACK")
hs.swarm = 0x80 # spread amount
hs.width = 0x40 # stereo width
hs.default_chord = [0, 4, 7, 0, 0, 0, 0] # plays a major triad by default
# Slot 0 = root + 5th + octave on oscillators 0, 1, 2
hs.chords[0] = M8Chord(mask=0b00000111, offsets=[0, 7, 12, 0, 0, 0])
# External (audio in)
from m8.api.instruments.external import M8External, M8ExternalPort, M8ExternalInput
ext = M8External(name="GUITAR")
ext.port = M8ExternalPort.MIDI
ext.input = M8ExternalInput.LINE_IN_LR
ext.cutoff = 0xC0Each instrument has 4 modulator slots. Modulators are typed subclasses — to change a slot's type, replace the slot.
from m8.api.modulator import M8AHDModulator, M8LFOModulator, M8LFOShape
from m8.api.instruments.macrosynth import M8MacrosynthModDest
inst = M8Macrosynth(name="LEAD")
inst.modulators[0] = M8AHDModulator(
destination=M8MacrosynthModDest.VOLUME,
amount=0xFF,
attack=0x00,
decay=0x80,
)
inst.modulators[2] = M8LFOModulator(
destination=M8MacrosynthModDest.CUTOFF,
amount=0x60,
shape=M8LFOShape.SIN,
freq=0x20,
)The six modulator classes:
M8AHDModulator— Attack / Hold / Decay envelopeM8ADSRModulator— Attack / Decay / Sustain / Release envelopeM8DrumModulator— Peak / Body / DecayM8LFOModulator— shape / trigger_mode / freq / retriggerM8TrigModulator— Attack / Hold / Decay / SourceM8TrackingModulator— Source / Low / High values
Every project ships 132 EQ slots (1 global + 3 effect-section + 128 per-instrument). Each EQ has three bands (low / mid / high), each band has a filter type, stereo processing mode, frequency, gain, and Q:
from m8.api.project import M8Project
from m8.api.eq import M8EqType, M8EqMode
from m8.api.instruments.wavsynth import M8Wavsynth
project = M8Project.initialise()
# Tweak the global EQ
project.eqs[0].low.eq_type = M8EqType.LOWCUT
project.eqs[0].mid.q = 0x80
project.eqs[0].high.eq_mode = M8EqMode.SIDE
# Bind an instrument to EQ slot 4 (first per-instrument slot)
w = M8Wavsynth(name="EQUALIZED")
w.associated_eq = 4
project.instruments[0] = w
# Frequency and gain are 16-bit packed across two bytes; helpers decode:
print(project.eqs[0].mid.frequency()) # Hz
print(project.eqs[0].mid.gain_db()) # signed dBSlot convention (matching m8-file-parser):
eqs[0]— global / master EQeqs[1..3]— effect-section EQs (chorus, delay, reverb)eqs[4..131]— per-instrument EQs (referenced byinstrument.associated_eq)associated_eq = 0xFF(default) means "no EQ bound to this instrument"
project = M8Project.initialise()
# Mixer — master, tracks, sends, DJ filter, limiter, OTT
project.mixer.master_volume = 0xC0
project.mixer.track_volumes[0] = 0xE0 # 8 tracks, list-style indexing
project.mixer.chorus_volume = 0x40
project.mixer.dj_filter = 0xA0
project.mixer.dj_filter_type = 1
project.mixer.limiter_attack = 0x20 # firmware 6.0+
project.mixer.ott_level = 0x80 # firmware 6.2+
# Analog input is stereo by default (analog_mode = 0xFF). Set anything else
# to switch to dual-mono and address each side independently:
if project.mixer.is_analog_stereo:
project.mixer.analog_l_volume = 0xC0
else:
project.mixer.analog_l_volume = 0xC0
project.mixer.analog_r_volume = 0xC0 # really analog_mode used as right-volume byte
# Effects — chorus / delay / reverb knobs + v6.2 shimmer & OTT shaping
project.effects.chorus_mod_depth = 0x40
project.effects.delay_feedback = 0xA0
project.effects.reverb_size = 0xC0
project.effects.reverb_shimmer = 0x30 # firmware 6.2+
# MIDI — sync, transport, per-track input routing
project.midi.receive_sync = 1
project.midi.send_transport = 2
project.midi.track_input_channels[0] = 5 # track 0 records from MIDI ch 5
project.midi.track_input_instruments[0] = 8 # ...fires instrument slot 8
# Musical key (used by SCG / scale-global FX) — top-level on M8Project
# because its byte sits at file offset 187, separate from the contiguous
# metadata block.
project.key = 7 # G majorCopy chains, instruments, tables, or EQs from one project into another without ID collisions. The remapper walks the reference graph (chain → phrase → instrument → table → EQ), allocates collision-free destination slots, and copies the bytes with every reference rewritten.
from m8.api.project import M8Project
from m8.api.remapper import Remapper
src = M8Project.read_from_file("source.m8s")
dst = M8Project.read_from_file("destination.m8s")
# Move chain 3 (and all its transitive dependencies) into dst
r = Remapper(src, dst, chains={3})
print(r.mappings.total(), "slots will move")
print(r.remap.chains) # e.g. {3: 5} — chain 3 lands at chain 5 in dst
r.apply()
# Place the moved chain in dst's song matrix
dst.song[0][0] = r.remap.out_chain(3)
dst.write_to_file("merged.m8s")What gets rewritten automatically:
chain.step.phrase→ new phrase slotphrase.step.instrument→ new instrument slotphrase/table.step.fx[j].valuewhen key is INS/NXT/TBL/TBX/EQM/EQIinstrument.associated_eq→ new EQ slot
References to slots not in the move set are preserved literally — the caller is responsible for ensuring those slots exist (and mean what they should) in the destination.
For the simple "import this chain" case there's a one-line helper:
from m8.api.remapper import move_chains
remap = move_chains(src, dst, {3})
dst.song[0][0] = remap.out_chain(3)See demos/remap_merge.py for a runnable example merging a drum kit and
a bass line into a single project.
Phrases carry per-step FX tuples. Enum classes provide readable names:
from m8.api.fx import M8FXTuple, M8SequenceFX, M8SamplerFX
step = M8PhraseStep(note=M8Note.C_4, velocity=0x6F, instrument=0x00)
step.fx[0] = M8FXTuple(key=M8SequenceFX.RET, value=0x40) # Retrigger
step.fx[1] = M8FXTuple(key=M8SamplerFX.LEN, value=0xC0) # Sample lengthAvailable FX groups:
M8SequenceFX— global timing/control (ARP, RET, HOP, KIL, TPO, …)M8SamplerFX— sampler-only (VOL, PIT, PLY, CUT, SLI, …)M8ModulatorFX— per-slot mod params (EA1–EA4, AT1–AT4, …)M8MixerFX— firmware 6.2 mixer/voice (VMV, XMM, XRH, OTT, …)
M8Note is generated for C-1 through G-11:
M8Note.C_4 # 36 (0x24)
M8Note.CS_4 # 37 (C♯4)
M8Note.G_11 # 127M8's byte-0 origin is C1, so middle C = 36.
# Chain multiple samples into one sliced WAV
from m8.tools.chain_builder import ChainBuilder
builder = ChainBuilder(sample_duration_ms=500, fade_ms=5, target_frame_rate=44100)
wav_data, slice_mapping = builder.build_chain(["kick.wav", "snare.wav", "hat.wav"])
# Add slice points to an existing WAV
from m8.tools.wav_slicer import WAVSlicer
slicer = WAVSlicer()
sliced = slicer.add_slice_points(wav_data, slice_points=[0, 22050, 44100, 66150])Runnable scripts under demos/ produce .m8s files in tmp/demos/. Each demo demonstrates one library feature:
| Demo | Demonstrates |
|---|---|
demos/acid_303_wavsynth.py |
Wavsynth + iterating over instrument slots (16 shape variations) |
demos/acid_303_midi.py |
MIDIOut to monophonic external synth with OFF-note insertion |
demos/acid_909_sampler.py |
Sampler basics — multi-instrument drum kit |
demos/acid_909_chain.py |
Sample chain slicing |
demos/acid_909_midi.py |
MIDIOut drum kit (multi-channel) |
demos/euclid_sampler.py |
Bjorklund Euclidean rhythms |
demos/chords_synth.py |
Macrosynth + modulators + 3-voice polyphony |
demos/remap_merge.py |
Cross-project remapper — merge two source projects into one |
Run a demo:
# Sample-based demos need samples first
PYTHONPATH=. python demos/utils/download_erica_pico_samples.py
PYTHONPATH=. python demos/acid_909_sampler.py
PYTHONPATH=. python demos/chords_synth.pytools/sync.py covers the whole loop. Run with no args for status:
python tools/sync.py # status: local vs remote
python tools/sync.py push # copy all to /Volumes/M8/...
python tools/sync.py push acid-303 # filter by substring
python tools/sync.py push --test # use tmp/virtual-m8/ for dry runs
python tools/sync.py clean local # remove tmp/demos/
python tools/sync.py clean remote # remove /Volumes/M8/Songs/pym8-demos/-f skips per-item prompts. Non-interactive stdin auto-confirms.
m8/
├── api/
│ ├── project.py # M8Project — top-level container
│ ├── instrument.py # M8Instrument base + M8Instruments collection
│ ├── fields.py # ByteField / BytesField / StringField descriptors
│ ├── modulator.py # 6 modulator subclasses + M8Modulators
│ ├── eq.py # M8EqBand / M8Eq / M8Eqs (3-band parametric)
│ ├── settings.py # M8MixerSettings / M8EffectsSettings
│ ├── midi_settings.py # M8MidiSettings (sync, transport, track input)
│ ├── table.py # M8TableStep / M8Table / M8Tables (256 × 16-step)
│ ├── midi_mapping.py # M8MidiMapping / M8MidiMappings (128 CC routings)
│ ├── groove.py # M8Groove / M8Grooves (32 timing curves)
│ ├── scale.py # M8Scale / M8Scales (16 microtonal tuning maps)
│ ├── remapper.py # Cross-project reference walker, allocator, applier
│ ├── phrase.py # M8Phrase / M8PhraseStep / M8Note
│ ├── chain.py # M8Chain / M8ChainStep
│ ├── song.py # M8SongMatrix (255 rows × 8 tracks)
│ ├── fx.py # FX tuples + Sequence/Sampler/Mixer/Modulator FX enums
│ ├── metadata.py # M8Metadata
│ ├── version.py # M8Version + ordered comparison
│ └── instruments/
│ ├── wavsynth.py # M8Wavsynth (type 0)
│ ├── macrosynth.py # M8Macrosynth (type 1)
│ ├── sampler.py # M8Sampler (type 2)
│ ├── midiout.py # M8MIDIOut (type 3) — 10 MIDI CC slots
│ ├── fmsynth.py # M8FMSynth (type 4)
│ ├── hypersynth.py # M8HyperSynth (type 5) — chord matrix
│ └── external.py # M8External (type 6) — audio in + 4 CC slots
├── tools/
│ ├── chain_builder.py # Sliced sample chain WAV builder
│ └── wav_slicer.py # WAV slice-point writer
└── templates/ # TEMPLATE-6-2-1.m8s — bundled firmware-6.2 starting point
demos/ # Runnable scripts by category
tests/ # Mirrors m8/ layout — unittest
tools/sync.py # Unified push / clean / status for demos
Run all tests with the stdlib runner:
python -m unittest discover -s tests -p '*.py'Tests use unittest (not pytest). Each test file mirrors the module it covers; cross-cutting per-type tests live in tests/api/instruments.py and tests/api/modulator.py.
- m8-file-parser (Rust) — authoritative spec. Offsets, FX command codes, instrument layouts, version-conditional reads. pym8 is a port of this.
- m8-js — JavaScript implementation. Useful for enum lookups, but it targets firmware 4.0 and is not maintained — don't take binary offsets from it.
- m8-files — older Rust parser; predecessor of m8-file-parser.
- Dirtywave M8 — official hardware.
MIT.
Created by jhw (justin.worrall@gmail.com). Heavily indebted to Twinside for m8-file-parser, whitlockjc for m8-js, AlexCharlton for m8-files, and Dirtywave for the M8 itself.