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.
- pymusicassembler/__init__.py +59 -0
- pymusicassembler/audio.py +97 -0
- pymusicassembler/cli.py +88 -0
- pymusicassembler/constants.py +121 -0
- pymusicassembler/errors.py +13 -0
- pymusicassembler/model.py +231 -0
- pymusicassembler/player.py +513 -0
- pymusicassembler/reader.py +258 -0
- pymusicassembler/reglog.py +91 -0
- pymusicassembler/writer.py +151 -0
- pymusicassembler-0.1.0.dist-info/METADATA +364 -0
- pymusicassembler-0.1.0.dist-info/RECORD +16 -0
- pymusicassembler-0.1.0.dist-info/WHEEL +5 -0
- pymusicassembler-0.1.0.dist-info/entry_points.txt +2 -0
- pymusicassembler-0.1.0.dist-info/licenses/LICENSE +201 -0
- pymusicassembler-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|
pymusicassembler/cli.py
ADDED
|
@@ -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""
|