Skip to main content

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

pygoattracker-0.1.2.tar.gz (71.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pygoattracker-0.1.2-py3-none-any.whl (53.2 kB view details)

Uploaded Python 3

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

Hashes for pygoattracker-0.1.2.tar.gz
Algorithm Hash digest
SHA256 689f6da501b113c6336917cb1213922678acc9dadd4a15a74d9614bfc1020034
MD5 a667dc62f69f99915053bb9b8cf894b9
BLAKE2b-256 6b845dbc2d31f29b77d29ac071c0ed63671688af32726b3b86c50967eb09cccc

See more details on using hashes here.

Provenance

The following attestation bundles were made for pygoattracker-0.1.2.tar.gz:

Publisher: publish.yml on anarkiwi/pygoattracker

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for pygoattracker-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 09e3e67b653d287ca9d87c093d0fd532b89b90b57682e2c1c5f5982ef00e2e4a
MD5 85ff960ab1128b71b94a8384c09a26d2
BLAKE2b-256 e513ec5571560c81299c9e946e3ac15a5c25f643ea07abd2824acbbb21e0eeec

See more details on using hashes here.

Provenance

The following attestation bundles were made for pygoattracker-0.1.2-py3-none-any.whl:

Publisher: publish.yml on anarkiwi/pygoattracker

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page