pymusicassembler 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,59 @@
1
+ """Read, write, play, and render Music Assembler SID songs."""
2
+
3
+ from pymusicassembler.audio import render_samples, render_wav, write_wav
4
+ from pymusicassembler.errors import (
5
+ MusicAssemblerError,
6
+ SidParseError,
7
+ SongValidationError,
8
+ )
9
+ from pymusicassembler.model import (
10
+ DurationEvent,
11
+ Instrument,
12
+ InstrumentEvent,
13
+ NoteEvent,
14
+ OrderEntry,
15
+ Orderlist,
16
+ Pattern,
17
+ Song,
18
+ )
19
+ from pymusicassembler.player import Player, iter_frames, render_grid
20
+ from pymusicassembler.reader import parse, read
21
+ from pymusicassembler.reglog import (
22
+ RegWrite,
23
+ iter_register_writes,
24
+ read_reglog,
25
+ write_reglog,
26
+ )
27
+ from pymusicassembler.writer import build, validate, write
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ "DurationEvent",
33
+ "Instrument",
34
+ "InstrumentEvent",
35
+ "MusicAssemblerError",
36
+ "NoteEvent",
37
+ "OrderEntry",
38
+ "Orderlist",
39
+ "Pattern",
40
+ "Player",
41
+ "RegWrite",
42
+ "SidParseError",
43
+ "Song",
44
+ "SongValidationError",
45
+ "__version__",
46
+ "build",
47
+ "iter_frames",
48
+ "iter_register_writes",
49
+ "parse",
50
+ "read",
51
+ "read_reglog",
52
+ "render_grid",
53
+ "render_samples",
54
+ "render_wav",
55
+ "validate",
56
+ "write",
57
+ "write_reglog",
58
+ "write_wav",
59
+ ]
@@ -0,0 +1,97 @@
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:`pymusicassembler.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 pymusicassembler import constants
20
+ from pymusicassembler.errors import MusicAssemblerError
21
+ from pymusicassembler.model import Song
22
+ from pymusicassembler.player import iter_frames
23
+ from pymusicassembler.reglog import DEFAULT_WRITE_SPACING
24
+
25
+ CHIP_MODELS = ("6581", "8580")
26
+
27
+
28
+ def _default_device(model: str, sampling_frequency):
29
+ try:
30
+ from pyresidfp import SoundInterfaceDevice
31
+ from pyresidfp.sound_interface_device import ChipModel
32
+ except ImportError as exc:
33
+ raise MusicAssemblerError(
34
+ "pyresidfp is required to render audio; "
35
+ "install with: pip install pymusicassembler[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
+ model: str = "8580",
49
+ sampling_frequency=None,
50
+ device=None,
51
+ cycles_per_frame: int = constants.PAL_CYCLES_PER_FRAME,
52
+ clock_frequency: float = constants.PAL_CLOCK_HZ,
53
+ ):
54
+ """Render ``song`` on an emulated SID.
55
+
56
+ Returns ``(samples, sampling_frequency)`` where samples are signed
57
+ 16-bit mono. Rendering stops at ``seconds`` (the player loops, so a
58
+ duration is required).
59
+ """
60
+ if model not in CHIP_MODELS:
61
+ raise MusicAssemblerError(f"chip model must be one of {CHIP_MODELS}")
62
+ if device is None:
63
+ device = _default_device(model, sampling_frequency)
64
+ write_q = DEFAULT_WRITE_SPACING / clock_frequency
65
+ frame_seconds = cycles_per_frame / clock_frequency
66
+ max_frames = max(1, round(seconds / frame_seconds))
67
+ samples = array("h")
68
+ for writes in iter_frames(song, max_frames=max_frames):
69
+ remainder = frame_seconds
70
+ for reg, val in writes:
71
+ device.write_register(reg, val)
72
+ samples.extend(device.clock(timedelta(seconds=write_q)))
73
+ remainder -= write_q
74
+ if remainder > 0:
75
+ samples.extend(device.clock(timedelta(seconds=remainder)))
76
+ return samples, float(device.sampling_frequency)
77
+
78
+
79
+ def write_wav(dst, samples, sampling_frequency: float) -> None:
80
+ """Write signed 16-bit mono samples as a WAV file."""
81
+ if not isinstance(samples, array):
82
+ samples = array("h", samples)
83
+ with wave.open(str(dst), "wb") as out:
84
+ out.setnchannels(1)
85
+ out.setsampwidth(2)
86
+ out.setframerate(round(sampling_frequency))
87
+ out.writeframes(samples.tobytes())
88
+
89
+
90
+ def render_wav(song: Song, dst, seconds: float = 60.0, **options) -> Path:
91
+ """Render ``song`` to a WAV file; returns the path written.
92
+
93
+ Keyword options are those of :func:`render_samples`.
94
+ """
95
+ samples, sampling_frequency = render_samples(song, seconds=seconds, **options)
96
+ write_wav(dst, samples, sampling_frequency)
97
+ return Path(dst)
@@ -0,0 +1,88 @@
1
+ """Command line interface: song info, register logs, and WAV rendering."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from pymusicassembler import audio, reglog, writer
7
+ from pymusicassembler.errors import MusicAssemblerError
8
+ from pymusicassembler.reader import read
9
+
10
+
11
+ def _info(args) -> None:
12
+ song = read(args.song)
13
+ print(f"name: {song.name}")
14
+ print(f"author: {song.author}")
15
+ print(f"released: {song.released}")
16
+ print(f"load: ${song.load_addr:04X}")
17
+ print(f"init/play: ${song.init_addr:04X} / ${song.play_addr:04X}")
18
+ print(f"patterns: {len(song.patterns)}")
19
+ print(f"instruments: {len(song.instruments)}")
20
+ for voice, orderlist in enumerate(song.orderlists):
21
+ ids = " ".join(f"{e.pattern_id:02X}" for e in orderlist.entries)
22
+ print(f" voice {voice}: {len(orderlist.entries)} entries [{ids}]")
23
+
24
+
25
+ def _reglog(args) -> None:
26
+ song = read(args.song)
27
+ frames = round(args.seconds * 50)
28
+ writes = reglog.iter_register_writes(song, max_frames=frames)
29
+ reglog.write_reglog(writes, args.output)
30
+ print(f"wrote {args.output}")
31
+
32
+
33
+ def _wav(args) -> None:
34
+ song = read(args.song)
35
+ audio.render_wav(song, args.output, seconds=args.seconds, model=args.model)
36
+ print(f"wrote {args.output}")
37
+
38
+
39
+ def _resave(args) -> None:
40
+ song = read(args.song)
41
+ writer.write(song, args.output, container=args.container)
42
+ print(f"wrote {args.output}")
43
+
44
+
45
+ def _parser() -> argparse.ArgumentParser:
46
+ parser = argparse.ArgumentParser(
47
+ prog="pymusicassembler", description="Music Assembler song tools"
48
+ )
49
+ commands = parser.add_subparsers(dest="command", required=True)
50
+
51
+ info = commands.add_parser("info", help="print song metadata")
52
+ info.add_argument("song", help="Music Assembler .sid/.prg file")
53
+ info.set_defaults(func=_info)
54
+
55
+ log = commands.add_parser("reglog", help="write a SID register log")
56
+ log.add_argument("song", help="Music Assembler .sid/.prg file")
57
+ log.add_argument("output", help="register log file to write")
58
+ log.add_argument("--seconds", type=float, default=60.0)
59
+ log.set_defaults(func=_reglog)
60
+
61
+ wav = commands.add_parser("wav", help="render through an emulated SID")
62
+ wav.add_argument("song", help="Music Assembler .sid/.prg file")
63
+ wav.add_argument("output", help="WAV file to write")
64
+ wav.add_argument("--seconds", type=float, default=60.0)
65
+ wav.add_argument("--model", choices=audio.CHIP_MODELS, default="8580")
66
+ wav.set_defaults(func=_wav)
67
+
68
+ resave = commands.add_parser("resave", help="re-emit the tune image")
69
+ resave.add_argument("song", help="Music Assembler .sid/.prg file")
70
+ resave.add_argument("output", help="output file")
71
+ resave.add_argument("--container", choices=("auto", "psid", "prg"), default="auto")
72
+ resave.set_defaults(func=_resave)
73
+ return parser
74
+
75
+
76
+ def main(argv=None) -> int:
77
+ """CLI entry point; returns a process exit code."""
78
+ args = _parser().parse_args(argv)
79
+ try:
80
+ args.func(args)
81
+ except (MusicAssemblerError, OSError) as exc:
82
+ print(f"error: {exc}", file=sys.stderr)
83
+ return 1
84
+ return 0
85
+
86
+
87
+ if __name__ == "__main__":
88
+ sys.exit(main())
@@ -0,0 +1,121 @@
1
+ """Constants for the Music Assembler player and song format.
2
+
3
+ Values follow the decompiled Music Assembler player (Richard Bayliss /
4
+ Eric Campbell era), cross-checked byte-exact against the
5
+ ``preframr-sidtrace`` register oracle. The player code is identical
6
+ across Music Assembler tunes; only its DATA (orderlists, patterns,
7
+ instruments and the addresses they live at) differs per tune.
8
+ """
9
+
10
+ # SID register map (offsets within the chip's 25 registers).
11
+ SID_REGISTERS = 25
12
+ VOICES = 3
13
+ VOICE_REG_SIZE = 7
14
+ FREQ_LO_REG = 0x00
15
+ FREQ_HI_REG = 0x01
16
+ PW_LO_REG = 0x02
17
+ PW_HI_REG = 0x03
18
+ CTRL_REG = 0x04
19
+ AD_REG = 0x05
20
+ SR_REG = 0x06
21
+ FC_HI_REG = 0x16
22
+ RES_FILT_REG = 0x17
23
+ MODE_VOL_REG = 0x18
24
+
25
+ # Per-voice SID base offsets ($D400 + offset).
26
+ SID_OFFSET = (0, 7, 14)
27
+
28
+ # Pulse-width high registers carry only their low nibble (12-bit pulse).
29
+ PW_HI_REGS = (PW_HI_REG, PW_HI_REG + 7, PW_HI_REG + 14)
30
+
31
+ # Pattern byte grammar boundaries.
32
+ NOTE_MAX = 0x5F # < 0x60: a note (+ trailing control byte)
33
+ DUR_MIN = 0x60 # 0x60..0x7F: bare duration token
34
+ INSTR_MIN = 0x80 # 0x80..0x9F: instrument select (id = value & 0x1F)
35
+ INSTR_MAX = 0x9F
36
+ LONGDUR_MIN = 0xA0 # 0xA0..0xFE: long-duration token (dur = value & 0x1F)
37
+ PATTERN_END = 0xFF
38
+ ORDER_END = 0xFF # orderlist terminator -> loop to start
39
+ SILENCE_PATTERN = 0xFE # orderlist pattern id $FE: gate off, no row
40
+
41
+ # Note-control byte ($1141) bit meanings (the byte after a note).
42
+ CTL_FILTER = 0x80 # inline filter command attached
43
+ CTL_VIBARP = 0x20 # inline vibrato/arp params attached
44
+ CTL_TRIGGERED = 0x40 # note-trigger latch (set after the trigger frame)
45
+ CTL_DUR_MASK = 0x1F
46
+
47
+ # Instrument record byte offsets (8-byte record).
48
+ INSTR_RECORD_SIZE = 8
49
+ INSTR_AD = 0
50
+ INSTR_SR = 1
51
+ INSTR_WAVEFORM = 2
52
+ INSTR_PULSE_INIT = 3
53
+ INSTR_PULSE_STEP = 4
54
+ INSTR_ARP_SPEED = 5
55
+ INSTR_ARP_STEP = 6
56
+ INSTR_PORTA = 7
57
+
58
+ # Player constants recovered from the disassembly.
59
+ TEMPO_DIVIDER = 2 # play-call divider ($1090 reset value)
60
+ FILTER_STEP = 0xF0 # cutoff sweep increment per owner frame ($129e += step)
61
+ FILTER_CTR = 0x29 # cutoff sweep gate-counter reset ($1296)
62
+ RES_PRESETS = (0xF1, 0xF7, 0xF7) # per-voice RES_FILT preset ($12b3)
63
+
64
+ # Player-code operand offsets-from-load. The player binary is identical
65
+ # across Music Assembler tunes, so each pointer-table base address lives
66
+ # at a fixed offset in the code; the reader reads the 16-bit operand
67
+ # there to discover the per-tune table base (relocation-safe).
68
+ OP_PATTERN_LO = 0x081 # pattern pointer table, low bytes
69
+ OP_PATTERN_HI = 0x086 # pattern pointer table, high bytes
70
+ OP_ORDER_LO = 0x173 # orderlist pointer table, low bytes
71
+ OP_ORDER_HI = 0x178 # orderlist pointer table, high bytes
72
+ OP_INSTR = 0x215 # instrument record table base
73
+ OP_FREQ_LO = 0x0EC # frequency table, low bytes
74
+ OP_FREQ_HI = 0x0F5 # frequency table, high bytes
75
+ OP_INNER_LO = 0x3C6 # instrument inner (effect program) pointer table, low
76
+ OP_INNER_HI = 0x3CB # instrument inner pointer table, high
77
+ OP_ARP = 0x2C2 # 4-entry arp direction table
78
+
79
+ # Fixed table sizes the reader extracts.
80
+ FREQ_TABLE_LEN = 96 # note frequency table entries
81
+
82
+ # C64 timing. A PAL frame is 312 rasterlines x 63 cycles.
83
+ PAL_CLOCK_HZ = 985248
84
+ PAL_CYCLES_PER_FRAME = 19656
85
+ NTSC_CLOCK_HZ = 1022727
86
+ NTSC_CYCLES_PER_FRAME = 17095
87
+
88
+ # Standard Music Assembler entry points (PSID header normally matches).
89
+ DEFAULT_LOAD = 0x1021
90
+ DEFAULT_INIT = 0x1048
91
+ DEFAULT_PLAY = 0x1021
92
+
93
+ # Per-voice work-RAM absolute addresses (voice index added). The init
94
+ # routine ($1048) zeroes only $1081..$1090 (pat_cursor / ctrl /
95
+ # order_cursor / dur / pattern_id) and then reloads pattern_id / transrep
96
+ # / repeat from each voice's orderlist; EVERY other work-RAM byte keeps
97
+ # its loaded-image value. A tune saved mid-playback bakes live values
98
+ # into these addresses, so the player seeds its voice state from the
99
+ # image here -- otherwise its first frames diverge from the oracle.
100
+ WORK_MASTER_TEMPO = 0x1090 # global play-call tempo counter
101
+ WORK_FILTER_OWNER = 0x1262 # filter-sweep owner voice
102
+ WORK_NOTE_INDEX = 0x10C9
103
+ WORK_FREQ_LO = 0x10CC
104
+ WORK_FREQ_HI = 0x10CF
105
+ WORK_ARP_SUBSTEP = 0x10DD
106
+ WORK_PW_SUBSTEP = 0x10E0
107
+ WORK_PW_DIR = 0x10E3
108
+ WORK_NOTE_CTL = 0x1141
109
+ WORK_INSTR_CURSOR = 0x1144
110
+ WORK_VIB_LO_STEP = 0x1147
111
+ WORK_VIB_HI_STEP = 0x114A
112
+ WORK_PORTA_CTR = 0x114D
113
+ WORK_VIB_FREQ_HI = 0x12B6
114
+ WORK_VIB_TOGGLE = 0x12B9
115
+ WORK_ARP_PHASE = 0x12BD
116
+ WORK_INSTR_X8 = 0x13D9
117
+ WORK_PW_LO = 0x13DC
118
+ WORK_PW_HI = 0x13DF
119
+ WORK_VIB_FREQ_LO = 0x13E2
120
+ WORK_CTRL_MASK = 0x1031
121
+ WORK_FLAGS = 0x00FD # zero-page; outside the image -> defaults to 0
@@ -0,0 +1,13 @@
1
+ """Exceptions raised by pymusicassembler."""
2
+
3
+
4
+ class MusicAssemblerError(Exception):
5
+ """Base class for all pymusicassembler errors."""
6
+
7
+
8
+ class SidParseError(MusicAssemblerError):
9
+ """A PSID/PRG image (or byte string) could not be parsed."""
10
+
11
+
12
+ class SongValidationError(MusicAssemblerError):
13
+ """A Song does not satisfy the Music Assembler format limits."""
@@ -0,0 +1,231 @@
1
+ """Typed model of a Music Assembler song.
2
+
3
+ A :class:`Song` holds everything needed to play and re-emit a Music
4
+ Assembler tune: three per-voice orderlists, the shared pattern pool,
5
+ 8-byte instrument records, the per-tune note-frequency tables and the
6
+ instrument inner (effect) programs.
7
+
8
+ Patterns are kept as RAW byte sequences (the player's own pattern
9
+ bytecode, minus the ``$FF`` terminator). The grammar -- ``<0x60`` note +
10
+ control byte, ``0x60-0x7F`` bare duration, ``0x80-0x9F`` instrument
11
+ select, ``0xA0-0xFE`` long duration -- is documented on
12
+ :meth:`Pattern.events`, which decodes the bytes into typed events for
13
+ inspection, while the bytes themselves drive both the player and the
14
+ writer so a tune round-trips byte-for-byte.
15
+ """
16
+
17
+ from dataclasses import dataclass, field
18
+ from typing import List
19
+
20
+ from pymusicassembler import constants
21
+ from pymusicassembler.errors import MusicAssemblerError
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class OrderEntry:
26
+ """One orderlist entry: play ``pattern_id`` with a transpose/repeat.
27
+
28
+ The on-disk pair is ``(pattern_id, transpose << 4 | repeat)``.
29
+ ``transpose`` is the orderlist transpose added to note indices (0-15);
30
+ ``repeat`` is the raw 0-15 repeat field (the pattern plays
31
+ ``repeat + 1`` times in total, matching the player's repeat counter).
32
+ Pattern id ``0xFE`` is the silence marker (gate off, no row).
33
+ """
34
+
35
+ pattern_id: int
36
+ transpose: int = 0
37
+ repeat: int = 0
38
+
39
+ def control_byte(self) -> int:
40
+ """The packed ``transpose << 4 | repeat`` control byte."""
41
+ if not 0 <= self.transpose <= 0x0F:
42
+ raise MusicAssemblerError(f"transpose out of range: {self.transpose}")
43
+ if not 0 <= self.repeat <= 0x0F:
44
+ raise MusicAssemblerError(f"repeat out of range: {self.repeat}")
45
+ return (self.transpose << 4) | self.repeat
46
+
47
+ @classmethod
48
+ def from_bytes(cls, pattern_id: int, control: int) -> "OrderEntry":
49
+ """Decode an on-disk ``(pattern_id, control)`` pair."""
50
+ return cls(
51
+ pattern_id=pattern_id,
52
+ transpose=(control >> 4) & 0x0F,
53
+ repeat=control & 0x0F,
54
+ )
55
+
56
+
57
+ @dataclass
58
+ class Orderlist:
59
+ """One voice's orderlist: a sequence of entries, looping at the end."""
60
+
61
+ entries: List[OrderEntry] = field(default_factory=list)
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class NoteEvent:
66
+ """A note row: ``note`` (+ orderlist transpose) indexes the freq table."""
67
+
68
+ note: int
69
+ control: int # the trailing note-control byte ($1141)
70
+ args: tuple = () # inline filter/vibrato argument bytes
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class InstrumentEvent:
75
+ """An instrument-select event (``id`` 0-31)."""
76
+
77
+ instrument: int
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class DurationEvent:
82
+ """A bare/long duration token (sets the row's frame duration)."""
83
+
84
+ duration: int
85
+ long: bool = False # True for the $A0-$FE long-duration form
86
+
87
+
88
+ PatternEvent = NoteEvent | InstrumentEvent | DurationEvent
89
+
90
+
91
+ @dataclass
92
+ class Pattern:
93
+ """A pattern: raw player bytecode (no trailing ``$FF`` terminator)."""
94
+
95
+ data: List[int] = field(default_factory=list)
96
+
97
+ def to_bytes(self) -> bytes:
98
+ """On-disk pattern bytes including the ``$FF`` terminator."""
99
+ return bytes(list(self.data) + [constants.PATTERN_END])
100
+
101
+ def events(self) -> List[PatternEvent]:
102
+ """Decode the raw bytes into typed events (for inspection).
103
+
104
+ Mirrors the player's pattern walk: ``$80-$9F`` selects an
105
+ instrument and the following byte may continue into a note;
106
+ ``$A0-$FE`` and ``$60-$7F`` are duration tokens; ``<0x60`` is a
107
+ note whose control byte (and any inline filter/vibrato args)
108
+ follows. Decoding is best-effort and never consumes past the
109
+ end of ``data``.
110
+ """
111
+ events: List[PatternEvent] = []
112
+ data = self.data
113
+ i = 0
114
+ n = len(data)
115
+ while i < n:
116
+ val = data[i]
117
+ if val >= constants.INSTR_MIN:
118
+ if val >= constants.LONGDUR_MIN: # long-duration token
119
+ events.append(
120
+ DurationEvent(val & constants.CTL_DUR_MASK, long=True)
121
+ )
122
+ i += 1
123
+ continue
124
+ events.append(InstrumentEvent(val & 0x1F)) # instrument select
125
+ i += 1
126
+ if i < n and data[i] >= constants.DUR_MIN:
127
+ events.append(DurationEvent(data[i] & constants.CTL_DUR_MASK))
128
+ i += 1
129
+ continue
130
+ if i >= n:
131
+ break
132
+ val = data[i]
133
+ elif val >= constants.DUR_MIN: # bare duration token
134
+ events.append(DurationEvent(val & constants.CTL_DUR_MASK))
135
+ i += 1
136
+ continue
137
+ # a note (val < 0x60)
138
+ note = val
139
+ i += 1
140
+ if i >= n:
141
+ events.append(NoteEvent(note, 0))
142
+ break
143
+ ctl = data[i]
144
+ i += 1
145
+ args: List[int] = []
146
+ if ctl >= constants.CTL_FILTER:
147
+ args = data[i : i + 2]
148
+ i += 2
149
+ elif ctl & constants.CTL_VIBARP:
150
+ args = data[i : i + 2]
151
+ i += 2
152
+ events.append(NoteEvent(note, ctl, tuple(args)))
153
+ return events
154
+
155
+
156
+ @dataclass
157
+ class Instrument:
158
+ """An 8-byte instrument record.
159
+
160
+ Fields map to the record bytes the player reads: attack/decay,
161
+ sustain/release, waveform, pulse-width init + sweep step, arp
162
+ speed + step, and the porta/effect flags byte.
163
+ """
164
+
165
+ attack_decay: int = 0
166
+ sustain_release: int = 0
167
+ waveform: int = 0
168
+ pulse_init: int = 0
169
+ pulse_step: int = 0
170
+ arp_speed: int = 0
171
+ arp_step: int = 0
172
+ porta: int = 0
173
+
174
+ def to_bytes(self) -> bytes:
175
+ """The 8 on-disk record bytes."""
176
+ return bytes(
177
+ (
178
+ self.attack_decay & 0xFF,
179
+ self.sustain_release & 0xFF,
180
+ self.waveform & 0xFF,
181
+ self.pulse_init & 0xFF,
182
+ self.pulse_step & 0xFF,
183
+ self.arp_speed & 0xFF,
184
+ self.arp_step & 0xFF,
185
+ self.porta & 0xFF,
186
+ )
187
+ )
188
+
189
+ @classmethod
190
+ def from_bytes(cls, raw) -> "Instrument":
191
+ """Decode an 8-byte record."""
192
+ b = list(raw) + [0] * (8 - len(raw))
193
+ return cls(b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7])
194
+
195
+
196
+ @dataclass
197
+ class Song:
198
+ """A complete Music Assembler song.
199
+
200
+ ``load_addr`` is the C64 load address; ``orderlists`` are the three
201
+ per-voice orderlists; ``patterns`` the shared pattern pool indexed by
202
+ pattern id; ``instruments`` the 8-byte records indexed by instrument
203
+ id; ``freq_lo`` / ``freq_hi`` the per-tune split note-frequency
204
+ tables; ``inner_programs`` the per-instrument effect programs the
205
+ instrument inner-table indexes (kept as raw bytes for round-trip).
206
+
207
+ The remaining fields capture the per-tune table base addresses and
208
+ any image bytes the player code/data occupy, so the writer can
209
+ re-emit a byte-stable image.
210
+ """
211
+
212
+ # pylint: disable=too-many-instance-attributes # a full tune is wide
213
+ load_addr: int = constants.DEFAULT_LOAD
214
+ init_addr: int = constants.DEFAULT_INIT
215
+ play_addr: int = constants.DEFAULT_PLAY
216
+ name: str = ""
217
+ author: str = ""
218
+ released: str = ""
219
+ orderlists: List[Orderlist] = field(
220
+ default_factory=lambda: [Orderlist() for _ in range(constants.VOICES)]
221
+ )
222
+ patterns: List[Pattern] = field(default_factory=list)
223
+ instruments: List[Instrument] = field(default_factory=list)
224
+ freq_lo: List[int] = field(default_factory=list)
225
+ freq_hi: List[int] = field(default_factory=list)
226
+ # Raw whole-image bytes (player + data) for byte-stable re-emission.
227
+ image: bytes = b""
228
+ # The original container header (PSID/RSID bytes before the image), kept
229
+ # so a tune read from a ``.sid`` re-emits its exact container; empty for a
230
+ # bare ``.prg`` or a programmatically built song.
231
+ container_header: bytes = b""