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,513 @@
1
+ """Music Assembler playroutine in Python.
2
+
3
+ A faithful, byte-exact reimplementation of the Music Assembler player's
4
+ per-frame DSP, recovered from the player's disassembly
5
+ (``decompile.c``) and validated byte-exact against the
6
+ ``preframr-sidtrace`` register oracle. The control flow -- tempo
7
+ divider, orderlist + pattern walk, the 16-bit pulse-width sweep, the
8
+ vibrato/arp frequency accumulators, the gate/retrigger idiom and the
9
+ filter-cutoff sweep -- is the player's own algorithm; the per-tune
10
+ ADDRESSES it operates on are discovered by the reader, so a relocated
11
+ tune of the same player plays unchanged.
12
+
13
+ Each :meth:`Player.play_frame` call runs one frame and returns the SID
14
+ register writes for that frame as ``(register, value)`` pairs in
15
+ ascending register order (changed registers only, after the first
16
+ frame), mirroring :class:`pymusicassembler.model.Song`'s output.
17
+ """
18
+
19
+ from dataclasses import dataclass
20
+ from typing import List, Optional
21
+
22
+ from pymusicassembler import constants
23
+ from pymusicassembler.model import Song
24
+
25
+ FREQ_LO, FREQ_HI, PW_LO, PW_HI, CTRL, AD, SR = 0, 1, 2, 3, 4, 5, 6
26
+ FC_HI, RES_FILT, MODE_VOL = (
27
+ constants.FC_HI_REG,
28
+ constants.RES_FILT_REG,
29
+ constants.MODE_VOL_REG,
30
+ )
31
+ SID_OFFSET = constants.SID_OFFSET
32
+
33
+
34
+ @dataclass
35
+ class _Voice:
36
+ """Per-voice work state (mirrors the player's ``$..,X`` work arrays)."""
37
+
38
+ # pylint: disable=too-many-instance-attributes # mirrors the player state
39
+ pat_cursor: int = 0 # $1081
40
+ ctrl: int = 0 # $1084 waveform/CTRL
41
+ order_cursor: int = 0 # $1087 orderlist cursor
42
+ dur: int = 0 # $108a note-duration counter
43
+ pattern_id: int = 0 # $108d
44
+ note_index: int = 0 # $10c9
45
+ freq_lo: int = 0 # $10cc plain FREQ_LO
46
+ freq_hi: int = 0 # $10cf plain FREQ_HI
47
+ arp_substep: int = 0 # $10dd
48
+ pw_substep: int = 0 # $10e0
49
+ pw_dir: int = 0 # $10e3
50
+ transrep: int = 0 # $10e6 transpose(hi)/repeat(lo)
51
+ repeat_ctr: int = 0 # $10e9
52
+ ctrl_mask: int = 0xFF # $1031 gate/CTRL AND-mask
53
+ note_ctl: int = 0 # $1141 note control byte
54
+ instr_cursor: int = 0 # $1144
55
+ vib_lo_step: int = 0 # $1147
56
+ vib_hi_step: int = 0 # $114a
57
+ porta_ctr: int = 0 # $114d
58
+ vib_freq_hi: int = 0 # $12b6 vibrato FREQ_HI accumulator
59
+ vib_toggle: int = 0 # $12b9
60
+ arp_phase: int = 0 # $12bd
61
+ instr_x8: int = 0 # $13d9 instrument id * 8 (record byte offset)
62
+ pw_lo: int = 0 # $13dc PW_LO accumulator
63
+ pw_hi: int = 0 # $13df PW_HI accumulator
64
+ vib_freq_lo: int = 0 # $13e2 vibrato FREQ_LO accumulator
65
+ flags: int = 0 # $00fd effect flags
66
+
67
+
68
+ @dataclass
69
+ class _Tables:
70
+ """Resolved per-tune table base addresses (absolute C64 addresses)."""
71
+
72
+ # pylint: disable=too-many-instance-attributes # one field per table base
73
+ order_lo: int
74
+ order_hi: int
75
+ pattern_lo: int
76
+ pattern_hi: int
77
+ instr: int
78
+ freq_lo: int
79
+ freq_hi: int
80
+ inner_lo: int
81
+ inner_hi: int
82
+ arp: int
83
+
84
+
85
+ class Player:
86
+ """Play a :class:`~pymusicassembler.model.Song` one frame at a time."""
87
+
88
+ # pylint: disable=too-many-instance-attributes # full player machine state
89
+ def __init__(self, song: Song):
90
+ self._song = song
91
+ self._image = song.image
92
+ self._load = song.load_addr
93
+ self._tables = _resolve_tables(song)
94
+ self.regs = [0] * constants.SID_REGISTERS
95
+ self._voices = [_Voice() for _ in range(constants.VOICES)]
96
+ self._tempo = 0 # $1090 (init 0; underflows on frame 0 -> advance)
97
+ self._filter_owner = 0 # $1262
98
+ self._fc_acc = 0 # $129e accumulator (self-modified LDA immediate)
99
+ self._fc_ctr = 0 # $1296 gate counter (self-modified LDA immediate)
100
+ self._fc_step = 0 # $12a0 sweep step (self-modified ADC immediate)
101
+ self._last_regs: Optional[List[int]] = None
102
+ self._init()
103
+
104
+ # -- raw image access ------------------------------------------------
105
+ def _byte(self, abs_addr: int) -> int:
106
+ idx = abs_addr - self._load
107
+ if 0 <= idx < len(self._image):
108
+ return self._image[idx]
109
+ return 0
110
+
111
+ def _orderlist_addr(self, voice: int) -> int:
112
+ tab = self._tables
113
+ return self._byte(tab.order_lo + voice) | (
114
+ self._byte(tab.order_hi + voice) << 8
115
+ )
116
+
117
+ def _pattern_addr(self, pattern_id: int) -> int:
118
+ tab = self._tables
119
+ lo = self._byte(tab.pattern_lo + pattern_id)
120
+ hi = self._byte(tab.pattern_hi + pattern_id)
121
+ return lo | (hi << 8)
122
+
123
+ def _inner_addr(self, flags: int) -> int:
124
+ tab = self._tables
125
+ return self._byte(tab.inner_lo + flags) | (
126
+ self._byte(tab.inner_hi + flags) << 8
127
+ )
128
+
129
+ def _instr_byte(self, idx: int, byte_off: int) -> int:
130
+ return self._byte(
131
+ self._tables.instr + idx * constants.INSTR_RECORD_SIZE + byte_off
132
+ )
133
+
134
+ def _freq_lo(self, note_index: int) -> int:
135
+ return self._byte(self._tables.freq_lo + note_index)
136
+
137
+ def _freq_hi(self, note_index: int) -> int:
138
+ return self._byte(self._tables.freq_hi + note_index)
139
+
140
+ def _res_preset(self, v: int) -> int:
141
+ return constants.RES_PRESETS[v]
142
+
143
+ # -- init ($1048) ----------------------------------------------------
144
+ def _init(self) -> None:
145
+ self.regs[MODE_VOL] = 0x1F
146
+ self.regs[RES_FILT] = 0xF0
147
+ # The init routine zeroes only $1081..$1090 (pat_cursor / ctrl /
148
+ # order_cursor / dur / pattern_id) and the filter-owner flag; every
149
+ # other work-RAM byte keeps its loaded-image value (a tune saved
150
+ # mid-playback bakes live state in). Seed those from the image so the
151
+ # player's first frames match the chip's, then apply the init reloads.
152
+ # The filter-sweep state ($1262 owner, $129e acc, $1296 gate, $12a0
153
+ # step) is self-modified player code; init does not clear it, so it
154
+ # keeps the loaded-image immediates until a filter command / voice-0
155
+ # trigger overwrites them.
156
+ self._filter_owner = self._byte(constants.WORK_FILTER_OWNER)
157
+ self._fc_acc = self._byte(0x129E)
158
+ self._fc_ctr = self._byte(0x1296)
159
+ self._fc_step = self._byte(0x12A0)
160
+ # Voice-0 trigger reset values ($1265/$126a immediates) -- per-tune
161
+ # constants baked into the player code region (the player customises
162
+ # its filter-sweep envelope per tune).
163
+ self._fc_acc_reset = self._byte(0x1266)
164
+ self._fc_ctr_reset = self._byte(0x126B)
165
+ self._tempo = 0 # $1090 cleared by the init loop
166
+ for v, voice in enumerate(self._voices):
167
+ voice.pat_cursor = 0
168
+ voice.ctrl = 0
169
+ voice.order_cursor = 0
170
+ voice.dur = 0
171
+ voice.note_index = self._byte(constants.WORK_NOTE_INDEX + v)
172
+ voice.freq_lo = self._byte(constants.WORK_FREQ_LO + v)
173
+ voice.freq_hi = self._byte(constants.WORK_FREQ_HI + v)
174
+ voice.arp_substep = self._byte(constants.WORK_ARP_SUBSTEP + v)
175
+ voice.pw_substep = self._byte(constants.WORK_PW_SUBSTEP + v)
176
+ voice.pw_dir = self._byte(constants.WORK_PW_DIR + v)
177
+ voice.note_ctl = self._byte(constants.WORK_NOTE_CTL + v)
178
+ voice.instr_cursor = self._byte(constants.WORK_INSTR_CURSOR + v)
179
+ voice.vib_lo_step = self._byte(constants.WORK_VIB_LO_STEP + v)
180
+ voice.vib_hi_step = self._byte(constants.WORK_VIB_HI_STEP + v)
181
+ voice.porta_ctr = self._byte(constants.WORK_PORTA_CTR + v)
182
+ voice.vib_freq_hi = self._byte(constants.WORK_VIB_FREQ_HI + v)
183
+ voice.vib_toggle = self._byte(constants.WORK_VIB_TOGGLE + v)
184
+ voice.arp_phase = self._byte(constants.WORK_ARP_PHASE + v)
185
+ voice.instr_x8 = self._byte(constants.WORK_INSTR_X8 + v)
186
+ voice.pw_lo = self._byte(constants.WORK_PW_LO + v)
187
+ voice.pw_hi = self._byte(constants.WORK_PW_HI + v)
188
+ voice.vib_freq_lo = self._byte(constants.WORK_VIB_FREQ_LO + v)
189
+ voice.ctrl_mask = self._byte(constants.WORK_CTRL_MASK + v)
190
+ voice.flags = self._byte(constants.WORK_FLAGS + v)
191
+ # init reload: pattern_id / transrep / repeat from the orderlist.
192
+ order = self._orderlist_addr(v)
193
+ voice.pattern_id = self._byte(order)
194
+ second = self._byte(order + 1)
195
+ voice.transrep = second
196
+ voice.repeat_ctr = second & 0x0F
197
+
198
+ # -- one play call ($1021) ------------------------------------------
199
+ def play_frame(self) -> List[tuple]:
200
+ """Run one player tick; return this frame's SID register writes.
201
+
202
+ The first frame returns all 25 registers; later frames return
203
+ only registers whose value changed.
204
+ """
205
+ self._frame()
206
+ if self._last_regs is None:
207
+ writes = list(enumerate(self.regs))
208
+ else:
209
+ writes = [
210
+ (reg, value)
211
+ for reg, (value, last) in enumerate(zip(self.regs, self._last_regs))
212
+ if value != last
213
+ ]
214
+ self._last_regs = list(self.regs)
215
+ return writes
216
+
217
+ def _frame(self) -> None:
218
+ self._tempo = (self._tempo - 1) & 0xFF
219
+ advance = self._tempo >= 0x80 # signed < 0
220
+ if advance:
221
+ self._tempo = constants.TEMPO_DIVIDER
222
+ for v in range(constants.VOICES):
223
+ voice = self._voices[v]
224
+ if not advance:
225
+ self._output(v)
226
+ continue
227
+ voice.dur = (voice.dur - 1) & 0xFF
228
+ if voice.dur >= 0x80: # signed < 0 -> new row
229
+ self._advance_row(v)
230
+ else:
231
+ self._output(v)
232
+
233
+ # -- orderlist + pattern parse ($1091) ------------------------------
234
+ def _advance_row(self, v: int) -> None:
235
+ voice = self._voices[v]
236
+ if voice.pattern_id == constants.SILENCE_PATTERN:
237
+ voice.ctrl &= 0xFE
238
+ return
239
+ pat = self._pattern_addr(voice.pattern_id)
240
+ cursor = voice.pat_cursor
241
+ val = self._byte(pat + cursor)
242
+ if val >= constants.INSTR_MIN:
243
+ if val >= constants.LONGDUR_MIN: # long-duration token
244
+ voice.dur = val & constants.CTL_DUR_MASK
245
+ self._after_row(v, pat, cursor)
246
+ return
247
+ # instrument select: id*8 kept in one byte, ASL wraps at 8 bits.
248
+ voice.instr_x8 = (val << 3) & 0xFF
249
+ cursor += 1
250
+ val = self._byte(pat + cursor)
251
+ if val >= constants.DUR_MIN:
252
+ voice.dur = val & constants.CTL_DUR_MASK
253
+ self._after_row(v, pat, cursor)
254
+ return
255
+ # else fall to note
256
+ elif val >= constants.DUR_MIN: # bare duration token ($60..$7F)
257
+ # $10b6: dur = val & 0x1f, gate-off mask, gate off, then advance
258
+ # the cursor / check the pattern end (the $10c3 JMP $1187).
259
+ voice.dur = val & constants.CTL_DUR_MASK
260
+ voice.ctrl_mask = 0xFE
261
+ voice.ctrl &= 0xFE
262
+ self._after_row(v, pat, cursor)
263
+ return
264
+ self._parse_note(v, pat, cursor, val)
265
+
266
+ def _parse_note(self, v: int, pat: int, cursor: int, note: int) -> None:
267
+ voice = self._voices[v]
268
+ idx = (voice.transrep >> 4) + note
269
+ voice.note_index = idx & 0xFF
270
+ voice.freq_lo = self._freq_lo(voice.note_index)
271
+ voice.vib_freq_lo = voice.freq_lo
272
+ voice.freq_hi = self._freq_hi(voice.note_index)
273
+ voice.vib_freq_hi = voice.freq_hi
274
+ cursor += 1
275
+ ctl = self._byte(pat + cursor)
276
+ voice.note_ctl = ctl
277
+ voice.dur = ctl & constants.CTL_DUR_MASK
278
+ if ctl >= constants.CTL_FILTER: # filter command attached ($1150)
279
+ self._filter_owner = v
280
+ cursor += 1
281
+ param = self._byte(pat + cursor)
282
+ # step = (param & 0xf) * 2 - 0x10 (self-modified ADC immediate).
283
+ self._fc_step = (((param & 0x0F) << 1) - 0x10) & 0xFF
284
+ cursor += 1
285
+ third = self._byte(pat + cursor)
286
+ if third == 0:
287
+ self.regs[RES_FILT] = 0xF0
288
+ else:
289
+ self.regs[RES_FILT] = self._res_preset(v)
290
+ elif ctl & constants.CTL_VIBARP: # vibrato/arp params attached
291
+ cursor += 1
292
+ voice.vib_lo_step = self._byte(pat + cursor)
293
+ cursor += 1
294
+ voice.vib_hi_step = self._byte(pat + cursor)
295
+ voice.ctrl_mask = 0xFF
296
+ voice.vib_toggle = 0xFF
297
+ voice.arp_substep = 0
298
+ voice.arp_phase = 0
299
+ self._after_row(v, pat, cursor)
300
+
301
+ def _after_row(self, v: int, pat: int, cursor: int) -> None:
302
+ voice = self._voices[v]
303
+ cursor += 1
304
+ nb = self._byte(pat + cursor)
305
+ if nb == constants.PATTERN_END:
306
+ voice.repeat_ctr = (voice.repeat_ctr - 1) & 0xFF
307
+ if voice.repeat_ctr < 0x80: # signed >= 0: replay pattern
308
+ voice.pat_cursor = 0
309
+ return
310
+ order = self._orderlist_addr(v)
311
+ ocur = voice.order_cursor + 2
312
+ if self._byte(order + ocur) == constants.ORDER_END:
313
+ ocur = 0
314
+ voice.order_cursor = ocur
315
+ voice.pattern_id = self._byte(order + ocur)
316
+ voice.transrep = self._byte(order + ocur + 1)
317
+ voice.repeat_ctr = voice.transrep & 0x0F
318
+ voice.pat_cursor = 0
319
+ else:
320
+ voice.pat_cursor = cursor
321
+
322
+ # -- per-frame output + generators ----------------------------------
323
+ def _output(self, v: int) -> None:
324
+ voice = self._voices[v]
325
+ ofs = SID_OFFSET[v]
326
+ triggered = voice.note_ctl & constants.CTL_TRIGGERED
327
+ if not triggered: # NOTE-TRIGGER frame
328
+ voice.instr_cursor = 0
329
+ idx = voice.instr_x8 >> 3
330
+ self.regs[ofs + SR] = self._instr_byte(idx, constants.INSTR_SR)
331
+ self.regs[ofs + AD] = self._instr_byte(idx, constants.INSTR_AD)
332
+ self.regs[ofs + CTRL] = voice.ctrl & 0xFE
333
+ voice.ctrl = self._instr_byte(idx, constants.INSTR_WAVEFORM)
334
+ pw_init = self._instr_byte(idx, constants.INSTR_PULSE_INIT)
335
+ voice.pw_lo = pw_init
336
+ voice.pw_hi = pw_init
337
+ if v == 0:
338
+ self._fc_acc = self._fc_acc_reset
339
+ self._fc_ctr = self._fc_ctr_reset
340
+ voice.pw_substep = 0
341
+ voice.pw_dir = 0
342
+ voice.porta_ctr = self._instr_byte(idx, constants.INSTR_ARP_SPEED) >> 3
343
+ voice.note_ctl |= constants.CTL_TRIGGERED
344
+ voice.flags = self._instr_byte(idx, constants.INSTR_PORTA)
345
+ self._sid_final(v)
346
+ return
347
+ # SUSTAIN frame. Filter-cutoff sweep ($1290): only the owner voice
348
+ # runs it; while the gate counter ($1296) is nonzero, decrement it,
349
+ # add the step ($12a0) to the accumulator ($129e) and write FC_HI.
350
+ if v == self._filter_owner and self._fc_ctr != 0:
351
+ self._fc_ctr = (self._fc_ctr - 1) & 0xFF
352
+ self._fc_acc = (self._fc_acc + self._fc_step) & 0xFF
353
+ self.regs[FC_HI] = self._fc_acc
354
+ flags = voice.flags & 0x0F
355
+ if flags != 0:
356
+ self._instrument_tick(v, flags)
357
+ elif (voice.note_ctl & constants.CTL_VIBARP) == 0 and (voice.flags & 0x10) != 0:
358
+ self._arp_walk(v)
359
+ self._pw_gen(v)
360
+ self._sid_final(v)
361
+
362
+ def _arp_walk(self, v: int) -> None:
363
+ voice = self._voices[v]
364
+ voice.porta_ctr = (voice.porta_ctr - 1) & 0xFF
365
+ if voice.porta_ctr < 0x80: # signed >= 0: not yet
366
+ return
367
+ voice.porta_ctr = (voice.porta_ctr + 1) & 0xFF
368
+ idx = voice.instr_x8 >> 3
369
+ step = self._instr_byte(idx, constants.INSTR_ARP_STEP)
370
+ phase = voice.arp_phase & 3
371
+ ascend = self._byte(self._tables.arp + phase)
372
+ if ascend == 0: # descending
373
+ lo = voice.freq_lo - step
374
+ voice.freq_lo = lo & 0xFF
375
+ if lo < 0:
376
+ voice.freq_hi = (voice.freq_hi - 1) & 0xFF
377
+ else: # ascending
378
+ lo = voice.freq_lo + step
379
+ voice.freq_lo = lo & 0xFF
380
+ if lo > 0xFF:
381
+ voice.freq_hi = (voice.freq_hi + 1) & 0xFF
382
+ voice.arp_substep = (voice.arp_substep + 1) & 0xFF
383
+ arpspeed = self._instr_byte(idx, constants.INSTR_ARP_SPEED) & 0x0F
384
+ if arpspeed == voice.arp_substep:
385
+ voice.arp_substep = 0
386
+ voice.arp_phase = (voice.arp_phase + 1) & 0xFF
387
+
388
+ def _instrument_tick(self, v: int, flags: int) -> None:
389
+ voice = self._voices[v]
390
+ base = self._inner_addr(flags)
391
+ cur = voice.instr_cursor
392
+ ctrl = self._byte(base + cur) & voice.ctrl_mask
393
+ voice.ctrl = ctrl
394
+ pitch = self._byte(base + cur + 1)
395
+ if pitch < 0x80:
396
+ pitch = (pitch + voice.note_index) & 0xFF
397
+ pitch &= 0x7F
398
+ cutoff = self._byte(base + cur + 2)
399
+ if cutoff != 0:
400
+ self._fc_acc = cutoff
401
+ marker = self._byte(base + cur + 3)
402
+ if marker >= 0xFE:
403
+ if marker == 0xFE: # stop the effect
404
+ voice.flags &= 0xF0
405
+ cur = cur + 3
406
+ else: # $FF: loop to start
407
+ cur = 0
408
+ else:
409
+ cur = cur + 3
410
+ voice.instr_cursor = cur
411
+ voice.freq_lo = self._freq_lo(pitch)
412
+ voice.freq_hi = self._freq_hi(pitch)
413
+
414
+ def _pw_gen(self, v: int) -> None:
415
+ voice = self._voices[v]
416
+ idx = voice.instr_x8 >> 3
417
+ speed = self._instr_byte(idx, constants.INSTR_PULSE_STEP)
418
+ flags = voice.flags
419
+ if flags & 0x40: # linear add
420
+ lo = voice.pw_lo + speed
421
+ voice.pw_lo = lo & 0xFF
422
+ voice.pw_hi = (voice.pw_hi + speed + (lo >> 8)) & 0xFF
423
+ elif flags & 0x20: # triangle up/down
424
+ if voice.pw_dir:
425
+ lo = voice.pw_lo + speed
426
+ voice.pw_lo = lo & 0xFF
427
+ if lo > 0xFF:
428
+ voice.pw_hi = (voice.pw_hi + 1) & 0xFF
429
+ else:
430
+ lo = voice.pw_lo - speed
431
+ voice.pw_lo = lo & 0xFF
432
+ if lo < 0:
433
+ voice.pw_hi = (voice.pw_hi - 1) & 0xFF
434
+ voice.pw_substep = (voice.pw_substep + 1) & 0xFF
435
+ if (speed & 0x0F) == voice.pw_substep:
436
+ voice.pw_substep = 0
437
+ voice.pw_dir ^= 1
438
+
439
+ def _sid_final(self, v: int) -> None:
440
+ voice = self._voices[v]
441
+ ofs = SID_OFFSET[v]
442
+ self.regs[ofs + CTRL] = voice.ctrl
443
+ self.regs[ofs + PW_HI] = voice.pw_hi & 0x0F
444
+ self.regs[ofs + PW_LO] = voice.pw_lo
445
+ if voice.note_ctl & constants.CTL_VIBARP: # vibrato/arp-freq mode
446
+ if voice.vib_lo_step & 1:
447
+ voice.vib_toggle ^= 0xFF
448
+ if voice.vib_toggle != 0:
449
+ self.regs[ofs + FREQ_LO] = voice.freq_lo
450
+ self.regs[ofs + FREQ_HI] = voice.freq_hi
451
+ return
452
+ lo = voice.vib_freq_lo + voice.vib_lo_step
453
+ voice.vib_freq_lo = lo & 0xFF
454
+ self.regs[ofs + FREQ_LO] = voice.vib_freq_lo
455
+ voice.vib_freq_hi = (
456
+ voice.vib_freq_hi + voice.vib_hi_step + (lo >> 8)
457
+ ) & 0xFF
458
+ self.regs[ofs + FREQ_HI] = voice.vib_freq_hi
459
+ return
460
+ self.regs[ofs + FREQ_LO] = voice.freq_lo
461
+ self.regs[ofs + FREQ_HI] = voice.freq_hi
462
+
463
+
464
+ def _resolve_tables(song: Song) -> _Tables:
465
+ """Resolve the per-tune table base addresses from the song's image.
466
+
467
+ The Music Assembler player binary is identical across tunes, so each
468
+ pointer-table base lives at a fixed player-code offset as a 16-bit
469
+ operand; reading those operands recovers the bases for any (relocated)
470
+ tune of the same player.
471
+ """
472
+ image = song.image
473
+
474
+ def op16(off: int) -> int:
475
+ if off + 1 < len(image):
476
+ return image[off] | (image[off + 1] << 8)
477
+ return 0
478
+
479
+ return _Tables(
480
+ order_lo=op16(constants.OP_ORDER_LO),
481
+ order_hi=op16(constants.OP_ORDER_HI),
482
+ pattern_lo=op16(constants.OP_PATTERN_LO),
483
+ pattern_hi=op16(constants.OP_PATTERN_HI),
484
+ instr=op16(constants.OP_INSTR),
485
+ freq_lo=op16(constants.OP_FREQ_LO),
486
+ freq_hi=op16(constants.OP_FREQ_HI),
487
+ inner_lo=op16(constants.OP_INNER_LO),
488
+ inner_hi=op16(constants.OP_INNER_HI),
489
+ arp=op16(constants.OP_ARP),
490
+ )
491
+
492
+
493
+ def iter_frames(song: Song, max_frames: Optional[int] = None):
494
+ """Yield per-frame register write lists for ``song``.
495
+
496
+ Stops after ``max_frames`` frames (required for a non-looping render,
497
+ since the Music Assembler player loops forever).
498
+ """
499
+ player = Player(song)
500
+ frame = 0
501
+ while max_frames is None or frame < max_frames:
502
+ yield player.play_frame()
503
+ frame += 1
504
+
505
+
506
+ def render_grid(song: Song, nframes: int) -> List[List[int]]:
507
+ """Render ``nframes`` of forward-filled per-frame register snapshots."""
508
+ player = Player(song)
509
+ rows: List[List[int]] = []
510
+ for _ in range(nframes):
511
+ player.play_frame()
512
+ rows.append(player.regs[:])
513
+ return rows