Pure-Python reader, writer, and player for GoatTracker 2 (.SNG) songs
Project description
pygoattracker
Pure-Python reader, writer, and player for
GoatTracker 2 .SNG
songs, with SID register log output and audio rendering through an
emulated SID.
GoatTracker is a family of tracker applications for composing C64/SID
music by Lasse Öörni (Cadaver). pygoattracker implements the GTS5 song
format and the playroutine from first principles, following the
GoatTracker 2.76 format documentation and gplay.c.
Install
pip install pygoattracker # read/write/play/register logs
pip install pygoattracker[audio] # + WAV rendering via pyresidfp
No required dependencies: everything except audio rendering is stdlib only.
Read a song
from pygoattracker import read_sng
song = read_sng("tune.sng")
print(song.name, song.author, song.copyright)
for instrument in song.instruments:
print(instrument.name, hex(instrument.attack_decay))
# Patterns are typed rows; orderlists are typed entries.
for row in song.patterns[0].rows[:4]:
print(row) # e.g. "C-4 01000"
print(song.subtunes[0].channels[0].entries)
read_sng accepts a path, bytes, or a binary file object, and reads
every GoatTracker song generation: GTS5/GTS4/GTS3, GTS2 (early
3-table GoatTracker 2.xx) and GTS! (GoatTracker 1.x). Old formats are
converted on load exactly as GoatTracker 2.76 imports them --
including GT1's inline instrument wavetables, synthesized pulse
programs, filtertable conversion and 0XY-arpeggio extraction -- so
they play and write back as GTS5. The writer always emits GTS5;
GTS5 read -> write round trips are byte-identical.
Build a song from scratch
from pygoattracker import (
Instrument, Pattern, Row, Song, write_sng,
)
from pygoattracker.constants import note_value
song = Song(name="DEMO", author="ME", copyright="2026")
# A pulse waveform program: one wavetable row + stop.
wave_ptr = song.wavetable.add(0x41, 0x00)
song.wavetable.add(0xFF, 0x00)
song.instruments.append(
Instrument(
attack_decay=0x09,
sustain_release=0x00,
wave_ptr=wave_ptr,
gateoff_timer=2,
first_wave=0x09, # test+gate on the init frame
name="LEAD",
)
)
pattern = Pattern.empty(16)
pattern.rows[0] = Row(note=note_value("C-4"), instrument=1)
pattern.rows[8] = Row(note=note_value("G-4"), instrument=1)
song.patterns = [pattern]
write_sng(song, "demo.sng") # loads in GoatTracker 2
The writer validates format limits and references (patterns, instruments, table pointers) before emitting anything; you describe content, not bytes.
Play a song: SID register writes
The player ports the GoatTracker 2 playroutine tick for tick: sequencer (transpose/repeat/restart), funktempo, wave/pulse/filter table execution including wavetable command execution, speedtable vibrato/portamento (including note-independent speeds), gateoff timer, and hard restart.
from pygoattracker import Player, read_sng
player = Player(read_sng("tune.sng"), subtune=0)
for _ in range(50 * 60): # one minute at 50 Hz
for reg, value in player.play_frame():
print(f"${0xD400 + reg:04X} = ${value:02X}")
play_frame() returns one PAL frame's register writes in ascending
register order (the first frame initializes all 25 registers).
Not implemented: multispeed playback and the editor's jamming / mid-song start modes.
Write a SID register log
from pygoattracker import iter_register_writes, write_reglog, read_sng
song = read_sng("tune.sng")
writes = iter_register_writes(song, until_loop=True)
write_reglog(writes, "tune.reglog")
Logs are one clock reg val triple per line (absolute clock in PAL
CPU cycles, decimal, # comments). They load straight into pandas:
import pandas as pd
df = pd.read_csv(
"tune.reglog", sep=" ", comment="#", names=["clock", "reg", "val"]
)
read_reglog reads the format back as a list of RegWrite tuples.
Render through an emulated SID
from pygoattracker import read_sng, render_wav
render_wav(read_sng("tune.sng"), "tune.wav", seconds=60, model="8580")
Rendering drives pyresidfp
(reSIDfp emulation), clocking each register write individually at the
same in-frame offsets the register log uses. render_samples returns
raw 16-bit samples instead; pass device= to use any other emulator
object with write_register/clock/sampling_frequency.
NinjaTracker 2
NinjaTracker 2 songs (the C64 editor's
N2 work files) read and write through their own typed model:
from pygoattracker import read_nt2, write_nt2
song = read_nt2("tune") # files saved by the C64 editor
print(song.hr_param, song.first_wave)
for command in song.commands: # NT2 commands double as instruments
print(command.name, hex(command.attack_decay))
for row in song.patterns[0].rows[:4]:
print(row) # e.g. "C-2 01 08"
write_nt2(song, "tune.out")
Tracks reuse the typed PlayPattern/Transpose entries (NinjaTracker
transposes are -64..+63 halftones; byte $FF is zero -- the format
doc's "$C0 = zero" does not match the player or the example tunes).
The writer emits canonical output: stale editor bytes after pattern
terminators are not preserved, so real files round-trip semantically
(byte-identically when they carry no stale bytes). There is no
NinjaTracker playroutine port; parsing and writing only.
Convert GoatTracker songs to NinjaTracker 2
from pygoattracker import gt_to_nt2, read_sng, write_nt2
song = read_sng("tune.sng")
report = []
converted = gt_to_nt2(song, errors="drop", report=report)
write_nt2(converted, "tune.nt2")
print(report) # one line per feature NinjaTracker cannot express
NinjaTracker has no tempo, so conversion replays the playroutine's
sequencer/tempo logic (funktempo and FXY commands included) to bake
every row's frame count into NinjaTracker durations; rests merge into
the previous row and long holds split into continuation rows. The
song is simulated for two full loops and must play both identically.
Instruments become commands (vibrato folds into the wavetable),
toneportamento becomes NinjaTracker's slide-to-target, 4XY vibrato
and 8/9/AXY pointers become synthesized legato commands, and 5/6XY
become ADSR commands. errors="strict" (default) raises
ConversionError on anything inexpressible (free 1/2XY portamento,
7XY/BXY/CXY/DXY, wavetable command execution, notes below C-1,
non-uniform hardrestart setups); errors="drop" drops and reports
them instead. Some mappings are inherently approximate: pulse widths
quantize to NinjaTracker's mirrored 8-bit register, filter resonance
couples to the passband, and vibrato parameters map by analogy.
Command line
pygoattracker info tune.sng # also detects NinjaTracker 2 files
pygoattracker reglog tune.sng tune.reglog --seconds 30
pygoattracker wav tune.sng tune.wav --seconds 30 --model 6581
pygoattracker nt2 tune.sng tune.nt2 --lenient
Tests
pip install -e ".[dev]"
./run_tests.sh # black + pylint + pytest with coverage gate
CI (.github/workflows/ci.yml) runs black, pylint, and the test suite
with a --cov-fail-under=85 coverage gate on Python 3.10-3.13, and
builds + smoke tests the wheel. Publishing a GitHub release uploads
sdist + wheel to PyPI via trusted publishing
(.github/workflows/publish.yml); Dependabot keeps dependencies and
actions current. The suite also enforces the gates from
within: tests/test_lint.py runs black/pylint and
tests/test_coverage.py re-runs the suite under coverage and fails
below the floor, so a plain pytest cannot pass with lint errors or
insufficient coverage.
The integration tests download three GoatTracker 2 example songs and the NinjaTracker 2 distribution disk image (SHA-256 pinned; a minimal 1541 reader extracts the six example tunes from the .d64). They verify byte-identical .SNG round trips plus 500 frames of playback each, and NinjaTracker round trips against all six tunes; they skip offline.
License
Apache 2.0 - see LICENSE.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pygoattracker-0.1.2.tar.gz.
File metadata
- Download URL: pygoattracker-0.1.2.tar.gz
- Upload date:
- Size: 71.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
689f6da501b113c6336917cb1213922678acc9dadd4a15a74d9614bfc1020034
|
|
| MD5 |
a667dc62f69f99915053bb9b8cf894b9
|
|
| BLAKE2b-256 |
6b845dbc2d31f29b77d29ac071c0ed63671688af32726b3b86c50967eb09cccc
|
Provenance
The following attestation bundles were made for pygoattracker-0.1.2.tar.gz:
Publisher:
publish.yml on anarkiwi/pygoattracker
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pygoattracker-0.1.2.tar.gz -
Subject digest:
689f6da501b113c6336917cb1213922678acc9dadd4a15a74d9614bfc1020034 - Sigstore transparency entry: 1887156704
- Sigstore integration time:
-
Permalink:
anarkiwi/pygoattracker@bfb11ca8c7129ab74c3d69845d029e69fa56f01e -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/anarkiwi
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@bfb11ca8c7129ab74c3d69845d029e69fa56f01e -
Trigger Event:
release
-
Statement type:
File details
Details for the file pygoattracker-0.1.2-py3-none-any.whl.
File metadata
- Download URL: pygoattracker-0.1.2-py3-none-any.whl
- Upload date:
- Size: 53.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
09e3e67b653d287ca9d87c093d0fd532b89b90b57682e2c1c5f5982ef00e2e4a
|
|
| MD5 |
85ff960ab1128b71b94a8384c09a26d2
|
|
| BLAKE2b-256 |
e513ec5571560c81299c9e946e3ac15a5c25f643ea07abd2824acbbb21e0eeec
|
Provenance
The following attestation bundles were made for pygoattracker-0.1.2-py3-none-any.whl:
Publisher:
publish.yml on anarkiwi/pygoattracker
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pygoattracker-0.1.2-py3-none-any.whl -
Subject digest:
09e3e67b653d287ca9d87c093d0fd532b89b90b57682e2c1c5f5982ef00e2e4a - Sigstore transparency entry: 1887156825
- Sigstore integration time:
-
Permalink:
anarkiwi/pygoattracker@bfb11ca8c7129ab74c3d69845d029e69fa56f01e -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/anarkiwi
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@bfb11ca8c7129ab74c3d69845d029e69fa56f01e -
Trigger Event:
release
-
Statement type: