pygoattracker 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,92 @@
1
+ """Read, write, play, and render GoatTracker 2 (.SNG) songs."""
2
+
3
+ from pygoattracker.audio import render_samples, render_wav, write_wav
4
+ from pygoattracker.convert import gt_to_nt2
5
+ from pygoattracker.errors import (
6
+ ConversionError,
7
+ GoatTrackerError,
8
+ NinjaParseError,
9
+ NinjaValidationError,
10
+ SngParseError,
11
+ SngValidationError,
12
+ )
13
+ from pygoattracker.ninja import (
14
+ NinjaCommand,
15
+ NinjaPattern,
16
+ NinjaRow,
17
+ NinjaSong,
18
+ build_nt2,
19
+ parse_nt2,
20
+ read_nt2,
21
+ validate_nt2,
22
+ write_nt2,
23
+ )
24
+ from pygoattracker.model import (
25
+ Instrument,
26
+ Orderlist,
27
+ Pattern,
28
+ PlayPattern,
29
+ Repeat,
30
+ Row,
31
+ Song,
32
+ Subtune,
33
+ Table,
34
+ Transpose,
35
+ entry_from_byte,
36
+ )
37
+ from pygoattracker.player import Player, iter_frames
38
+ from pygoattracker.reader import parse_sng, read_sng
39
+ from pygoattracker.reglog import (
40
+ RegWrite,
41
+ iter_register_writes,
42
+ read_reglog,
43
+ write_reglog,
44
+ )
45
+ from pygoattracker.writer import build_sng, validate_song, write_sng
46
+
47
+ __version__ = "0.1.0"
48
+
49
+ __all__ = [
50
+ "ConversionError",
51
+ "GoatTrackerError",
52
+ "Instrument",
53
+ "NinjaCommand",
54
+ "NinjaParseError",
55
+ "NinjaPattern",
56
+ "NinjaRow",
57
+ "NinjaSong",
58
+ "NinjaValidationError",
59
+ "Orderlist",
60
+ "Pattern",
61
+ "PlayPattern",
62
+ "Player",
63
+ "RegWrite",
64
+ "Repeat",
65
+ "Row",
66
+ "SngParseError",
67
+ "SngValidationError",
68
+ "Song",
69
+ "Subtune",
70
+ "Table",
71
+ "Transpose",
72
+ "__version__",
73
+ "build_nt2",
74
+ "build_sng",
75
+ "entry_from_byte",
76
+ "gt_to_nt2",
77
+ "iter_frames",
78
+ "iter_register_writes",
79
+ "parse_nt2",
80
+ "parse_sng",
81
+ "read_nt2",
82
+ "read_reglog",
83
+ "read_sng",
84
+ "render_samples",
85
+ "render_wav",
86
+ "validate_nt2",
87
+ "validate_song",
88
+ "write_nt2",
89
+ "write_reglog",
90
+ "write_sng",
91
+ "write_wav",
92
+ ]
pygoattracker/audio.py ADDED
@@ -0,0 +1,106 @@
1
+ """Render songs through an emulated SID to samples or WAV.
2
+
3
+ By default the emulated SID is `pyresidfp
4
+ <https://pypi.org/project/pyresidfp/>`_ (install the ``audio`` extra).
5
+ Any object with ``write_register(reg, value)``, ``clock(timedelta) ->
6
+ samples`` and a ``sampling_frequency`` attribute can be passed as
7
+ ``device`` instead, e.g. for tests or a different emulator.
8
+
9
+ Each register write is clocked individually at the same in-frame offset
10
+ the register log uses, so renders line up with
11
+ :mod:`pygoattracker.reglog` output.
12
+ """
13
+
14
+ import wave
15
+ from array import array
16
+ from datetime import timedelta
17
+ from pathlib import Path
18
+
19
+ from pygoattracker import constants
20
+ from pygoattracker.errors import GoatTrackerError
21
+ from pygoattracker.model import Song
22
+ from pygoattracker.player import iter_frames
23
+ from pygoattracker.reglog import DEFAULT_WRITE_SPACING
24
+
25
+ CHIP_MODELS = ("6581", "8580")
26
+
27
+
28
+ def _default_device(model: str, sampling_frequency: float | None):
29
+ try:
30
+ from pyresidfp import SoundInterfaceDevice
31
+ from pyresidfp.sound_interface_device import ChipModel
32
+ except ImportError as exc:
33
+ raise GoatTrackerError(
34
+ "pyresidfp is required to render audio; "
35
+ "install with: pip install pygoattracker[audio]"
36
+ ) from exc
37
+ chip = {"6581": ChipModel.MOS6581, "8580": ChipModel.MOS8580}[model]
38
+ if sampling_frequency:
39
+ return SoundInterfaceDevice(
40
+ model=chip, sampling_frequency=float(sampling_frequency)
41
+ )
42
+ return SoundInterfaceDevice(model=chip)
43
+
44
+
45
+ def render_samples(
46
+ song: Song,
47
+ seconds: float = 60.0,
48
+ subtune: int = 0,
49
+ until_loop: bool = False,
50
+ model: str = "8580",
51
+ sampling_frequency: float | None = None,
52
+ device=None,
53
+ cycles_per_frame: int = constants.PAL_CYCLES_PER_FRAME,
54
+ clock_frequency: float = constants.PAL_CLOCK_HZ,
55
+ **player_options,
56
+ ):
57
+ """Render ``song`` on an emulated SID.
58
+
59
+ Returns ``(samples, sampling_frequency)`` where samples are signed
60
+ 16-bit mono. Rendering stops at ``seconds`` (or earlier when the
61
+ song stops, or at the song loop with ``until_loop``).
62
+ """
63
+ if model not in CHIP_MODELS:
64
+ raise GoatTrackerError(f"chip model must be one of {CHIP_MODELS}")
65
+ if device is None:
66
+ device = _default_device(model, sampling_frequency)
67
+ write_q = DEFAULT_WRITE_SPACING / clock_frequency
68
+ frame_seconds = cycles_per_frame / clock_frequency
69
+ max_frames = max(1, round(seconds / frame_seconds))
70
+ samples = array("h")
71
+ for writes in iter_frames(
72
+ song,
73
+ subtune=subtune,
74
+ max_frames=max_frames,
75
+ until_loop=until_loop,
76
+ **player_options,
77
+ ):
78
+ remainder = frame_seconds
79
+ for reg, val in writes:
80
+ device.write_register(reg, val)
81
+ samples.extend(device.clock(timedelta(seconds=write_q)))
82
+ remainder -= write_q
83
+ if remainder > 0:
84
+ samples.extend(device.clock(timedelta(seconds=remainder)))
85
+ return samples, float(device.sampling_frequency)
86
+
87
+
88
+ def write_wav(dst, samples, sampling_frequency: float) -> None:
89
+ """Write signed 16-bit mono samples as a WAV file."""
90
+ if not isinstance(samples, array):
91
+ samples = array("h", samples)
92
+ with wave.open(str(dst), "wb") as out:
93
+ out.setnchannels(1)
94
+ out.setsampwidth(2)
95
+ out.setframerate(round(sampling_frequency))
96
+ out.writeframes(samples.tobytes())
97
+
98
+
99
+ def render_wav(song: Song, dst, seconds: float = 60.0, **options) -> Path:
100
+ """Render ``song`` to a WAV file; returns the path written.
101
+
102
+ Keyword options are those of :func:`render_samples`.
103
+ """
104
+ samples, sampling_frequency = render_samples(song, seconds=seconds, **options)
105
+ write_wav(dst, samples, sampling_frequency)
106
+ return Path(dst)
pygoattracker/cli.py ADDED
@@ -0,0 +1,118 @@
1
+ """Command line interface: song info, register logs, and WAV rendering."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from pygoattracker import audio, convert, ninja, reglog
8
+ from pygoattracker.errors import GoatTrackerError
9
+ from pygoattracker.reader import read_sng
10
+
11
+
12
+ def _nt2_info(song) -> None:
13
+ print("format: NinjaTracker 2")
14
+ print(f"subtunes: {len(song.subtunes)}")
15
+ print(f"patterns: {len(song.patterns)}")
16
+ print(f"commands: {len(song.commands)}")
17
+ print(f"hardrestart: {song.hr_param:02X} / first wave {song.first_wave:02X}")
18
+ for num, command in enumerate(song.commands, start=1):
19
+ print(f" {num:02X}: {command.name}")
20
+
21
+
22
+ def _info(args) -> None:
23
+ data = Path(args.song).read_bytes()
24
+ if data[: len(ninja.NT2_MAGIC)] == ninja.NT2_MAGIC:
25
+ _nt2_info(ninja.parse_nt2(data))
26
+ return
27
+ song = read_sng(data)
28
+ print(f"name: {song.name}")
29
+ print(f"author: {song.author}")
30
+ print(f"copyright: {song.copyright}")
31
+ print(f"subtunes: {len(song.subtunes)}")
32
+ print(f"patterns: {len(song.patterns)}")
33
+ print(f"instruments: {len(song.instruments)}")
34
+ for num, instrument in enumerate(song.instruments, start=1):
35
+ print(f" {num:02X}: {instrument.name}")
36
+
37
+
38
+ def _reglog(args) -> None:
39
+ song = read_sng(args.song)
40
+ frames = round(args.seconds * 50)
41
+ writes = reglog.iter_register_writes(song, subtune=args.subtune, max_frames=frames)
42
+ reglog.write_reglog(writes, args.output)
43
+ print(f"wrote {args.output}")
44
+
45
+
46
+ def _wav(args) -> None:
47
+ song = read_sng(args.song)
48
+ audio.render_wav(
49
+ song,
50
+ args.output,
51
+ seconds=args.seconds,
52
+ subtune=args.subtune,
53
+ model=args.model,
54
+ )
55
+ print(f"wrote {args.output}")
56
+
57
+
58
+ def _nt2(args) -> None:
59
+ song = read_sng(args.song)
60
+ report: list = []
61
+ errors = "drop" if args.lenient else "strict"
62
+ converted = convert.gt_to_nt2(song, errors=errors, report=report)
63
+ ninja.write_nt2(converted, args.output)
64
+ for message in report:
65
+ print(f"dropped: {message}")
66
+ print(f"wrote {args.output}")
67
+
68
+
69
+ def _parser() -> argparse.ArgumentParser:
70
+ parser = argparse.ArgumentParser(
71
+ prog="pygoattracker", description="GoatTracker 2 song tools"
72
+ )
73
+ commands = parser.add_subparsers(dest="command", required=True)
74
+
75
+ info = commands.add_parser("info", help="print song metadata")
76
+ info.add_argument("song", help=".sng file")
77
+ info.set_defaults(func=_info)
78
+
79
+ log = commands.add_parser("reglog", help="write a SID register log")
80
+ log.add_argument("song", help=".sng file")
81
+ log.add_argument("output", help="register log file to write")
82
+ log.add_argument("--subtune", type=int, default=0)
83
+ log.add_argument("--seconds", type=float, default=60.0)
84
+ log.set_defaults(func=_reglog)
85
+
86
+ nt2 = commands.add_parser("nt2", help="convert to a NinjaTracker 2 song")
87
+ nt2.add_argument("song", help=".sng file")
88
+ nt2.add_argument("output", help="NinjaTracker 2 file to write")
89
+ nt2.add_argument(
90
+ "--lenient",
91
+ action="store_true",
92
+ help="drop and report inexpressible features instead of failing",
93
+ )
94
+ nt2.set_defaults(func=_nt2)
95
+
96
+ wav = commands.add_parser("wav", help="render through an emulated SID")
97
+ wav.add_argument("song", help=".sng file")
98
+ wav.add_argument("output", help="WAV file to write")
99
+ wav.add_argument("--subtune", type=int, default=0)
100
+ wav.add_argument("--seconds", type=float, default=60.0)
101
+ wav.add_argument("--model", choices=audio.CHIP_MODELS, default="8580")
102
+ wav.set_defaults(func=_wav)
103
+ return parser
104
+
105
+
106
+ def main(argv=None) -> int:
107
+ """CLI entry point; returns a process exit code."""
108
+ args = _parser().parse_args(argv)
109
+ try:
110
+ args.func(args)
111
+ except (GoatTrackerError, OSError) as exc:
112
+ print(f"error: {exc}", file=sys.stderr)
113
+ return 1
114
+ return 0
115
+
116
+
117
+ if __name__ == "__main__":
118
+ sys.exit(main())
@@ -0,0 +1,170 @@
1
+ """Constants shared by the GoatTracker 2 .SNG format and playroutine.
2
+
3
+ Values follow GoatTracker 2.76 (``gcommon.h`` and the format description
4
+ in its ``readme.txt`` section 6.1).
5
+ """
6
+
7
+ SNG_MAGIC = b"GTS5"
8
+ # GTS3/GTS4 share GTS5's binary layout (with small post-load value
9
+ # conversions); GTS2 (early 3-table GoatTracker 2) and GTS! (GoatTracker
10
+ # 1.x) are converted on load, exactly as GoatTracker 2 imports them.
11
+ SNG_COMPATIBLE_MAGICS = (b"GTS!", b"GTS2", b"GTS3", b"GTS4", b"GTS5")
12
+
13
+ MAX_STR = 32
14
+ MAX_INSTR = 64
15
+ MAX_CHN = 3
16
+ MAX_PATT = 208
17
+ MAX_TABLES = 4
18
+ MAX_TABLELEN = 255
19
+ MAX_INSTRNAMELEN = 16
20
+ MAX_PATTROWS = 128
21
+ MAX_SONGLEN = 254
22
+ MAX_SONGS = 32
23
+ MAX_NOTES = 96
24
+
25
+ # Orderlist entry encoding.
26
+ REPEAT = 0xD0
27
+ TRANSDOWN = 0xE0
28
+ TRANSUP = 0xF0
29
+ LOOPSONG = 0xFF
30
+
31
+ # Pattern note column encoding.
32
+ ENDPATT = 0xFF
33
+ FIRSTNOTE = 0x60
34
+ LASTNOTE = 0xBC
35
+ REST = 0xBD
36
+ KEYOFF = 0xBE
37
+ KEYON = 0xBF
38
+
39
+ # Pattern commands 0XY-FXY.
40
+ CMD_DONOTHING = 0x0
41
+ CMD_PORTAUP = 0x1
42
+ CMD_PORTADOWN = 0x2
43
+ CMD_TONEPORTA = 0x3
44
+ CMD_VIBRATO = 0x4
45
+ CMD_SETAD = 0x5
46
+ CMD_SETSR = 0x6
47
+ CMD_SETWAVE = 0x7
48
+ CMD_SETWAVEPTR = 0x8
49
+ CMD_SETPULSEPTR = 0x9
50
+ CMD_SETFILTERPTR = 0xA
51
+ CMD_SETFILTERCTRL = 0xB
52
+ CMD_SETFILTERCUTOFF = 0xC
53
+ CMD_SETMASTERVOL = 0xD
54
+ CMD_FUNKTEMPO = 0xE
55
+ CMD_SETTEMPO = 0xF
56
+
57
+ # Table indexes, in on-disk order.
58
+ WTBL = 0
59
+ PTBL = 1
60
+ FTBL = 2
61
+ STBL = 3
62
+
63
+ # Wavetable left-side ranges.
64
+ WAVEDELAY = 0x01
65
+ WAVELASTDELAY = 0x0F
66
+ WAVESILENT = 0xE0
67
+ WAVELASTSILENT = 0xEF
68
+ WAVECMD = 0xF0
69
+ WAVELASTCMD = 0xFE
70
+ TABLEJUMP = 0xFF
71
+
72
+ # SID register map (offsets within the chip's 25 registers).
73
+ SID_REGISTERS = 25
74
+ VOICES = 3
75
+ VOICE_REG_SIZE = 7
76
+ FREQ_LO_REG = 0x00
77
+ FREQ_HI_REG = 0x01
78
+ PULSE_LO_REG = 0x02
79
+ PULSE_HI_REG = 0x03
80
+ CONTROL_REG = 0x04
81
+ AD_REG = 0x05
82
+ SR_REG = 0x06
83
+ FC_LO_REG = 0x15
84
+ FC_HI_REG = 0x16
85
+ RES_FILT_REG = 0x17
86
+ MODE_VOL_REG = 0x18
87
+
88
+ # C64 timing. A PAL frame is 312 rasterlines x 63 cycles.
89
+ PAL_CLOCK_HZ = 985248
90
+ PAL_CYCLES_PER_FRAME = 19656
91
+ NTSC_CLOCK_HZ = 1022727
92
+ NTSC_CYCLES_PER_FRAME = 17095
93
+
94
+ # Default player options, as in the GoatTracker editor.
95
+ DEFAULT_ADPARAM = 0x0F00
96
+
97
+ # PAL note frequency table from the GoatTracker 2 playroutine, notes
98
+ # C-0 to B-7 (the player can reach A-7 to B-7 through transpose only).
99
+ FREQ_LO = (
100
+ # fmt: off
101
+ 0x17, 0x27, 0x39, 0x4B, 0x5F, 0x74, 0x8A, 0xA1, 0xBA, 0xD4, 0xF0, 0x0E,
102
+ 0x2D, 0x4E, 0x71, 0x96, 0xBE, 0xE8, 0x14, 0x43, 0x74, 0xA9, 0xE1, 0x1C,
103
+ 0x5A, 0x9C, 0xE2, 0x2D, 0x7C, 0xCF, 0x28, 0x85, 0xE8, 0x52, 0xC1, 0x37,
104
+ 0xB4, 0x39, 0xC5, 0x5A, 0xF7, 0x9E, 0x4F, 0x0A, 0xD1, 0xA3, 0x82, 0x6E,
105
+ 0x68, 0x71, 0x8A, 0xB3, 0xEE, 0x3C, 0x9E, 0x15, 0xA2, 0x46, 0x04, 0xDC,
106
+ 0xD0, 0xE2, 0x14, 0x67, 0xDD, 0x79, 0x3C, 0x29, 0x44, 0x8D, 0x08, 0xB8,
107
+ 0xA1, 0xC5, 0x28, 0xCD, 0xBA, 0xF1, 0x78, 0x53, 0x87, 0x1A, 0x10, 0x71,
108
+ 0x42, 0x89, 0x4F, 0x9B, 0x74, 0xE2, 0xF0, 0xA6, 0x0E, 0x33, 0x20, 0xFF,
109
+ # fmt: on
110
+ )
111
+ FREQ_HI = (
112
+ # fmt: off
113
+ 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02,
114
+ 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x03, 0x04,
115
+ 0x04, 0x04, 0x04, 0x05, 0x05, 0x05, 0x06, 0x06, 0x06, 0x07, 0x07, 0x08,
116
+ 0x08, 0x09, 0x09, 0x0A, 0x0A, 0x0B, 0x0C, 0x0D, 0x0D, 0x0E, 0x0F, 0x10,
117
+ 0x11, 0x12, 0x13, 0x14, 0x15, 0x17, 0x18, 0x1A, 0x1B, 0x1D, 0x1F, 0x20,
118
+ 0x22, 0x24, 0x27, 0x29, 0x2B, 0x2E, 0x31, 0x34, 0x37, 0x3A, 0x3E, 0x41,
119
+ 0x45, 0x49, 0x4E, 0x52, 0x57, 0x5C, 0x62, 0x68, 0x6E, 0x75, 0x7C, 0x83,
120
+ 0x8B, 0x93, 0x9C, 0xA5, 0xAF, 0xB9, 0xC4, 0xD0, 0xDD, 0xEA, 0xF8, 0xFF,
121
+ # fmt: on
122
+ )
123
+
124
+ # The playroutine indexes the frequency table with 7-bit note numbers,
125
+ # so pad to 128 entries exactly like the original player's data segment.
126
+ FREQ_TABLE = tuple((hi << 8) | lo for lo, hi in zip(FREQ_LO, FREQ_HI)) + (0,) * (
127
+ 128 - MAX_NOTES
128
+ )
129
+
130
+ _NOTE_NAMES = (
131
+ "C-",
132
+ "C#",
133
+ "D-",
134
+ "D#",
135
+ "E-",
136
+ "F-",
137
+ "F#",
138
+ "G-",
139
+ "G#",
140
+ "A-",
141
+ "A#",
142
+ "B-",
143
+ )
144
+
145
+
146
+ def note_name(note: int) -> str:
147
+ """Tracker-style display name for a pattern note byte."""
148
+ if note == REST:
149
+ return "..."
150
+ if note == KEYOFF:
151
+ return "---"
152
+ if note == KEYON:
153
+ return "+++"
154
+ if note == ENDPATT:
155
+ return "END"
156
+ if FIRSTNOTE <= note <= LASTNOTE:
157
+ offset = note - FIRSTNOTE
158
+ return f"{_NOTE_NAMES[offset % 12]}{offset // 12}"
159
+ raise ValueError(f"not a note byte: {note:#04x}")
160
+
161
+
162
+ def note_value(name: str) -> int:
163
+ """Pattern note byte for a tracker-style name such as ``A-4``."""
164
+ if len(name) == 3:
165
+ prefix, octave = name[:2], name[2]
166
+ if prefix in _NOTE_NAMES and octave.isdigit() and int(octave) < 8:
167
+ note = FIRSTNOTE + int(octave) * 12 + _NOTE_NAMES.index(prefix)
168
+ if note <= LASTNOTE:
169
+ return note
170
+ raise ValueError(f"not a note name: {name!r}")