pysidwizard
Pure-Python reader, writer, and bit-exact player for SID-Wizard SWM modules. No native dependencies.
SID-Wizard is a Commodore 64 music tracker by Hermit (Mihaly Horvath). pysidwizard implements its SWM file format and player IRQ from first principles.
The player is verified against a live capture of SID-Wizard running
inside asid-vice: every frame × every SID register, on every PR.
See Player correctness below.
Install
pip install pysidwizard
Read an SWM
from pysidwizard import read_swm
swm = read_swm("tune.swm")
print(swm.author_str(), swm.frame_speed, swm.highlight)
# Pattern rows are typed; no byte poking required.
for row in swm.patterns[0].rows[:4]:
print(row)
# Sequences (one per SID channel) are lists of typed commands.
for cmd in swm.sequences[0][:6]:
print(cmd)
# Instruments expose fixed fields by name and tables as bytes.
ins = swm.instruments[0]
print(ins.name_str(), ins.attack, ins.decay, ins.sustain, ins.release)
print(ins.wf_table.hex(), ins.pw_table.hex(), ins.filter_table.hex())
read_swm auto-detects both bare SWM payloads and PRG-wrapped files
(2-byte little-endian load address, then the SWM1 magic at offset
2). The writer emits the PRG wrapper by default; set
swm.load_address = None for a bare payload.
Build an SWM from scratch
from pysidwizard import (
End,
Instrument,
Pattern,
PlayPattern,
Row,
SWMFile,
Waveform,
build_swm,
straight_tempo,
write_swm,
)
from pysidwizard.constants import GATE_OFF_FX, SWM_C5_NOTE
instrument = Instrument(
name=b"LEAD ",
control=0x1A, # hard-restart timer on, tied PW/CTF off
hr_attack=0, hr_decay=0xF,
hr_sustain=0xF, hr_release=0,
attack=0, decay=0,
sustain=0xF, release=0,
default_chord=1,
first_waveform=Waveform.PULSE,
)
pattern = Pattern(
rows=[
Row(note=SWM_C5_NOTE, instrument=1),
Row(), Row(), Row(), Row(), Row(), Row(),
Row(note=GATE_OFF_FX),
],
length=8,
)
swm = SWMFile(
author=b"PYSIDWIZARD DEMO",
default_pattern_length=8,
sequences=[[PlayPattern(1), End()]] * 3,
patterns=[pattern],
instruments=[instrument],
subtune_tempos=[(straight_tempo(6), straight_tempo(3))],
)
write_swm(swm, "demo.swm") # ready to load in SID-Wizard 1.x
assert build_swm(read_swm("demo.swm")) == build_swm(swm) # byte-exact roundtrip
The writer rejects modules that reference patterns or instruments
that don't exist, or sequences missing an End() / Loop()
terminator. NOP packing, table terminators, and on-disk pointer
arithmetic are handled automatically — you describe content, not
bytes.
Sequence command types
| Command | Meaning |
|---|---|
PlayPattern(n) |
Play pattern n (1-based; 0 is the reserved empty slot). |
Transpose(semitones) |
Per-channel transpose, -16..+15 semitones. |
TempoOverride(value) |
Switch to row-delay value until the next override. |
End() |
Terminate the sequence without looping. |
Loop(position) |
Terminate and jump back to position within the sequence. |
RawSequenceByte(b) |
Opaque single byte preserved for byte-exact round-trip. |
Row fields
Every column of a Row is None by default and contributes nothing.
-
note: a pitch (0..0x5F) or a note-column effect:Code Constant Meaning 0x78PORTAMENTO_FXPre-arm tone portamento for the next row 0x79SYNC_ON_FXEnable hard sync 0x7ASYNC_OFF_FXDisable hard sync 0x7BRING_ON_FXEnable ring modulation 0x7CRING_OFF_FXDisable ring modulation 0x7DGATE_ON_FXForce gate on 0x7EGATE_OFF_FXForce gate off -
instrument: a1..0x3Einstrument index, or an instrument-column effect — value< 0x40selects an instrument,>= 0x40runs a small effect. The same small-effect dispatch is available in thefxcolumn (any value>= 0x20):Range Effect 0x20..0x2FSet attack nibble of post-HR ADSR 0x30..0x3FSet decay nibble of post-HR ADSR; 0x3Fis legato0x40..0x4FSet waveform nibble (not yet modelled) 0x50..0x5FSet sustain ("note volume") 0x60..0x6FSet release 0x70..0x7FSet chord ( 0x7n→ chordn, looked up inchord_table) -
fx: a1..0xFFeffect code. Values< 0x20are big-FX and require anfx_value:Code Big-FX 0x01Pitch slide up (value = step per frame) 0x02Pitch slide down 0x03Tone portamento to the row's note (value = slide speed) 0x08Set vibrato amp + freq 0x0BJump to filter-table row (value = row index) 0x10Override row delay (= live tempo change) Values
>= 0x20are small-FX, same as the table above.
Play an SWM
from pysidwizard import SWMPlayer, read_swm
swm = read_swm("tune.swm")
player = SWMPlayer(swm)
# Each play_frame() returns the SID register writes the real
# SID-Wizard player would emit on the C64 this frame, in the same
# order (channel 3 -> 2 -> 1, then global filter + volume).
for _ in range(100):
for reg, value in player.play_frame():
print(f"${reg:04X} = ${value & 0xFF:02X}")
Render to WAV
from pysidwizard import render_wav
render_wav("tune.swm", "tune.wav", seconds=60.0, model_name="MOS8580")
Or from the command line:
python -m pysidwizard.player tune.swm tune.wav --seconds 60 --model MOS8580
render_wav drives pyresidfp
under the hood (install with pip install pyresidfp).
Iterate raw writes
For SID-state inspection or feeding a different SID emulator:
from pysidwizard import iter_writes
for frame, reg, value in iter_writes(swm, n_frames=1500):
...
iter_writes deduplicates consecutive identical writes to the same
register by default — matching the schema sidwizard-driver produces
for ground-truth captures.
SWM at a glance
An SWM module is a tracker module split into typed pieces. You don't need to know the byte layout to use this library, but a high-level picture of the moving parts is useful when reading or writing patterns:
Orderlist (sequences). Three independent sequences, one per
SID channel. Each sequence is a list of typed commands
(PlayPattern, Transpose, TempoOverride, End, Loop).
Channels advance independently; if v0 is playing a long pattern and
v1 finishes early, v1 just stays silent until its sequence loops.
Patterns (patterns). A list of rows; each row may set
note / instrument / fx / fx_value columns. The player runs
one row every tempo frames (or alternating funktempo left/right
values), or whatever the current TempoOverride says.
Instruments (instruments). Each instrument is a fixed
ADSR / vibrato / hard-restart header plus three per-instrument
"tables" that the player walks once per frame while the instrument
is active:
- Waveform / arp table (
wf_table). Three-byte rows(waveform, arp_pitch, detune). Drives the SIDCTRLregister and the per-note pitch offset. Arp byte semantics:$00..$7E: relative pitch up — add this many semitones to the note.$7F: take the next pitch from the active chord table.$80: NOP — keep the previous pitch.$81..$DF: absolute pitch — the low 7 bits become the discrete pitch.$E0..$FF: relative pitch down.$FE(in the waveform column): jump to the row whose offset is in the next byte.$FF: end of table; the walker freezes here.
- Pulse-width table (
pw_table). Three-byte rows. Either set mode (high byte$80..$FD, plus a low byte -> SID pulse- width register), or sweep mode (low cycle-count byte, signed delta, key-track byte). - Filter table (
filter_table). Three-byte rows controlling the global SID filter while this instrument owns it (only one voice at a time controls the filter). Set mode encodes the band switches (LP/BP/HP) + resonance + cutoff hi; sweep mode drifts the cutoff with a signed delta over a cycle count.
Chord table (chord_table). A flat byte array indexed by
chord number. When the waveform table's arp byte is $7F, the
next chord pitch is looked up here. Chords loop until the
instrument's waveform table moves past the chord-trigger row.
Each table terminates on $FF. Jumps inside a table use $FE
followed by an offset. pysidwizard handles all of this for you
when serialising — pass the table contents only.
Player correctness
The four reference tunes in tests/fixtures/ (flashitback,
bronkosaurus, euphoria, rain8580) collectively exercise the full
1-SID feature surface: pitched notes, gate-off note-FX, chord
tables, multispeed (frame_speed=2), funktempo, BIGFX portamento,
vibrato, filter walking, instrument inheritance across F1, the
WRPITCH detune-with-carry chain.
For each tune, every frame's full SID-register state agrees
byte-for-byte with the reference captured from real SID-Wizard
running inside asid-vice
via sidwizard-driver.
tests/test_player_reference.py runs that comparison on every
push.
The integration suite in tests/integration/ goes one step
further: every PR re-derives a fresh reference CSV from the real
SID-Wizard binary and asserts pysidwizard still matches. If the
binary changes, the integration suite fails before merge.
Out of scope: multi-SID, SFX, slowdown, and non-440 Hz tuning tables.
Tests
Unit tests (fast, no Docker):
pip install -e ".[dev]"
python -m pytest
Integration tests (slow, requires Docker; pulls
anarkiwi/headlessvice):
pip install -e ".[integration]"
python -m pytest -m integration tests/integration/
The four SWM test tunes are not tracked in this repo — they're
SID-Wizard binary artifacts. tests/_swm_cache.py fetches them on
demand from the SID-Wizard 1.94 source tarball via
sidwizard-driver's cache and SHA-256 verifies each one.
Continuous integration
| Workflow | What it runs |
|---|---|
.github/workflows/test.yml |
ruff + black + pytest (matrix: Python 3.10-3.13 on Linux + 3.12 on macOS/Windows) |
.github/workflows/integration.yml |
Fresh capture from real SID-Wizard inside asid-vice; asserts pysidwizard matches |
.github/workflows/publish.yml |
Builds + uploads to PyPI via trusted publishing on release |
The integration workflow runs on every push, every PR, and weekly
on cron — so SID-Wizard binary / sidwizard-driver / headlessvice
drift is caught even when no PR has landed.
License
Apache 2.0 — see LICENSE.
Metadata
Release files for pysidwizard 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pysidwizard-0.2.1.tar.gz | 92.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pysidwizard-0.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 158.6 kB
Release files / pysidwizard-0.2.1.tar.gz
| Download URL | pysidwizard-0.2.1.tar.gz |
|---|---|
| Size | 92.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b57c969a1cd36b41923748b5940ccb49cecd6334d424f2826e4f7172328a37af
|
|
BLAKE2b-256 checksum How to use checksums |
94da16f532c20b89f4c37f1c67da49c555e43a0dd1372550dd36fcdb99a2f09d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on May 24, 2026.
Transparency logRelease files / pysidwizard-0.2.1-py3-none-any.whl
| Download URL | pysidwizard-0.2.1-py3-none-any.whl |
|---|---|
| Size | 66.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
35d547a50f94c7351646eea258bf75a07aa753ab4e54abb4e4fded72379408d3
|
|
BLAKE2b-256 checksum How to use checksums |
df1c00bd17b44c5b6cfdaff46a7c2f7f5b6d8dc954091e8f80e2fdbc28559687
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on May 24, 2026.
Transparency log