unitracker-player 0.2.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,673 @@
1
+ """The tick machine: a canonical document played to the 25 SID registers' state per tick."""
2
+
3
+ import ast
4
+ import itertools
5
+ from collections import defaultdict
6
+ from dataclasses import dataclass
7
+
8
+ import numpy as np
9
+ from tqdm import tqdm
10
+ from unitracker_grammar import PAL_HZ
11
+
12
+ from unitracker_player.document import Document, Expr, Program, note_number, read
13
+ from unitracker_player.expr import compile_expr, constant, note_table, offset
14
+ from unitracker_player.modulation import SHAPES
15
+
16
+ VERSION = "1.20"
17
+ MAX_VOICES = 3
18
+ REGISTERS = 25
19
+ CONTROL, PULSE, FILTER = range(3)
20
+ FREQ_MASK, PW_MASK, CUT_MASK = 0xFFFF, 0xFFF, 0x7FF
21
+
22
+
23
+ class PlayError(ValueError):
24
+ """A document the player refuses, or a timeline that contradicts its phrases."""
25
+
26
+
27
+ @dataclass(eq=False)
28
+ class CProgram:
29
+ """A compiled program: rows of ``(wait, hold, ops)`` and the loop row, or None for end;
30
+ filled after it is memoised, so a program may run itself."""
31
+
32
+ rows: tuple = ()
33
+ loop: int | None = None
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class CInstrument:
38
+ """A compiled instrument; ``fn`` is its modulator's function ``f(bag, v, t, note)``, ``p0``
39
+ the bag it starts from, and ``restart`` is its rows with the ticks each precedes the note."""
40
+
41
+ ad: int | None
42
+ sr: int | None
43
+ ctrl: CProgram | None
44
+ pw: CProgram | None
45
+ filter: CProgram | None
46
+ freq: object
47
+ fn: object | None
48
+ p0: dict | None
49
+ restart: tuple | None
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class CEvent:
54
+ """A compiled event: note (a format note, ``off``, ``on`` or None), instrument, command ops,
55
+ keep flags, steps, legato and tempo changes."""
56
+
57
+ note: int | str | None
58
+ ins: CInstrument | None
59
+ ops: tuple
60
+ keep_pw: bool
61
+ keep_filter: bool
62
+ steps: int
63
+ legato: bool
64
+ tempo: tuple | None
65
+ vtempo: tuple | None
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Song:
70
+ """A document compiled for playing."""
71
+
72
+ voices: int
73
+ tempo: tuple
74
+ clock_hz: int
75
+ init: tuple
76
+ timeline: tuple
77
+ loop: int | None
78
+ lookahead: int
79
+ table: object
80
+
81
+
82
+ def _tempo(value, where: str) -> tuple:
83
+ pair = value if isinstance(value, tuple) else (value, value)
84
+ if min(pair) < 1:
85
+ raise PlayError(f"{where}: a tempo of 0 ticks per step")
86
+ return pair
87
+
88
+
89
+ def _slot(name: str) -> int:
90
+ return PULSE if name.startswith("pw") else CONTROL if name.startswith("c") else FILTER
91
+
92
+
93
+ class _Compiler:
94
+ """Resolves a Document's names and compiles its programs, instruments and expressions, each
95
+ once; each method compiles the construct of its name."""
96
+
97
+ # pylint: disable=missing-function-docstring
98
+
99
+ def __init__(self, doc: Document, table):
100
+ self.doc, self.table = doc, table
101
+ self.memo: dict = {}
102
+
103
+ def fn(self, expr: Expr, freq: bool = False):
104
+ node = expr.node
105
+ if freq and constant(node) is not None:
106
+ node = offset(constant(node))
107
+ key = ("fn", ast.dump(node))
108
+ if key not in self.memo:
109
+ try:
110
+ self.memo[key] = compile_expr(node, self.table, self.doc.tables)
111
+ except ValueError as exc:
112
+ raise PlayError(str(exc)) from exc
113
+ return self.memo[key]
114
+
115
+ def named(self, kind: str, names: dict, name: str):
116
+ if name not in names:
117
+ raise PlayError(f"unknown {kind} {name}")
118
+ return names[name]
119
+
120
+ def program(self, ref) -> CProgram | None:
121
+ if ref is None:
122
+ return None
123
+ key = ("program", ref if isinstance(ref, str) else id(ref))
124
+ if key not in self.memo:
125
+ source = self.named("program", self.doc.programs, ref) if isinstance(ref, str) else ref
126
+ out = self.memo[key] = CProgram()
127
+ self._fill(out, source, ref)
128
+ return self.memo[key]
129
+
130
+ def _fill(self, out: CProgram, source: Program, ref):
131
+ if source.loop is not None and source.loop >= len(source.rows):
132
+ raise PlayError(f"program {ref}: loop row {source.loop} out of range")
133
+ out.rows = tuple((r.wait, r.hold, self.ops(r.keys)) for r in source.rows)
134
+ out.loop = source.loop
135
+
136
+ def ops(self, keys) -> tuple:
137
+ out = []
138
+ for key, value in keys:
139
+ if key in ("pw", "cut", "freq") and value is not None:
140
+ value = self.fn(value, freq=key == "freq")
141
+ elif key == "run":
142
+ value = (_slot(value), self.program(value))
143
+ out.append((key, value))
144
+ return tuple(out)
145
+
146
+ def restart(self, ref) -> tuple | None:
147
+ prog = self.program(ref)
148
+ if prog is None or not prog.rows:
149
+ return None
150
+ ticks, ahead = [0], 0
151
+ for (_, hold, _), (wait, _, _) in zip(prog.rows[-2::-1], prog.rows[:0:-1]):
152
+ ahead += hold + wait
153
+ ticks.append(ahead)
154
+ return tuple(zip(prog.rows, reversed(ticks)))
155
+
156
+ def shape(self, fn):
157
+ if fn is None:
158
+ return None
159
+ if callable(fn):
160
+ return fn
161
+ if fn not in SHAPES:
162
+ raise PlayError(f"unknown modulator {fn}")
163
+ return SHAPES[fn]
164
+
165
+ def instrument(self, name: str | None) -> CInstrument | None:
166
+ if name is None:
167
+ return None
168
+ key = ("ins", name)
169
+ if key not in self.memo:
170
+ ins = self.named("instrument", self.doc.instruments, name)
171
+ self.memo[key] = CInstrument(
172
+ ins.ad,
173
+ ins.sr,
174
+ self.program(ins.ctrl),
175
+ self.program(ins.pw),
176
+ self.program(ins.filter),
177
+ None if ins.freq is None else self.fn(ins.freq, freq=True),
178
+ self.shape(ins.fn),
179
+ dict(ins.p0) if ins.p0 else None,
180
+ self.restart(ins.restart),
181
+ )
182
+ return self.memo[key]
183
+
184
+ def expression(self, value, freq: bool = False):
185
+ if value is None:
186
+ return None
187
+ if isinstance(value, int):
188
+ return self.fn(Expr(ast.Constant(value)), freq)
189
+ return self.fn(self.named("expression", self.doc.expressions, value), freq)
190
+
191
+ def event(self, event) -> CEvent:
192
+ cmds = dict(event.commands)
193
+ ops = []
194
+ for key, value in event.commands:
195
+ if key == "run":
196
+ value = None if value is None else (_slot(value), self.program(value))
197
+ elif key in ("pw", "cut", "freq"):
198
+ value = self.expression(value, key == "freq")
199
+ elif key in ("tempo", "vtempo", "legato"):
200
+ continue
201
+ ops.append((key, value))
202
+ note = event.note
203
+ if note == "-":
204
+ note = None
205
+ elif note not in ("off", "on"):
206
+ note = note_number(note)
207
+ return CEvent(
208
+ note,
209
+ self.instrument(event.ins),
210
+ tuple(ops),
211
+ event.keep_pw,
212
+ event.keep_filter,
213
+ event.steps,
214
+ "legato" in cmds,
215
+ _tempo(cmds["tempo"], "!tempo") if "tempo" in cmds else None,
216
+ _tempo(cmds["vtempo"], "!vtempo") if "vtempo" in cmds else None,
217
+ )
218
+
219
+ def phrase(self, name: str) -> tuple:
220
+ key = ("phrase", name)
221
+ if key not in self.memo:
222
+ self.memo[key] = tuple(
223
+ self.event(e) for e in self.named("phrase", self.doc.phrases, name)
224
+ )
225
+ return self.memo[key]
226
+
227
+ def entry(self, entry) -> tuple:
228
+ if entry.kind == "phrase":
229
+ return ("phrase", self.phrase(entry.phrase), entry.transpose)
230
+ return (entry.kind,)
231
+
232
+
233
+ def compile_document(doc: Document) -> Song:
234
+ """The Song of a Document: every name resolved and every expression compiled once."""
235
+ head = doc.header
236
+ if head.version != VERSION:
237
+ raise PlayError(f"version {head.version}: the player reads {VERSION}")
238
+ if not 1 <= head.voices <= MAX_VOICES:
239
+ raise PlayError(f"voices: {head.voices}: the player plays 1 to {MAX_VOICES} voices")
240
+ clock_hz = head.clock_hz or PAL_HZ
241
+ table = note_table(clock_hz)
242
+ comp = _Compiler(doc, table)
243
+ for name in doc.programs:
244
+ comp.program(name)
245
+ for name in doc.instruments:
246
+ comp.instrument(name)
247
+ init = tuple(comp.event(e) for e in doc.init or ())
248
+ if doc.init is not None and len(init) != head.voices:
249
+ raise PlayError(f"init: {len(init)} events for {head.voices} voices")
250
+ timeline = []
251
+ for i, plays in enumerate(doc.timeline):
252
+ if len(plays) != head.voices:
253
+ raise PlayError(f"row {i}: {len(plays)} play entries for {head.voices} voices")
254
+ timeline.append(tuple(comp.entry(p) for p in plays))
255
+ if doc.loop is not None and doc.loop >= len(timeline):
256
+ raise PlayError(f"song: loop row {doc.loop} out of range")
257
+ instruments = [v for k, v in comp.memo.items() if k[0] == "ins"]
258
+ lookahead = max((r[0][1] for i in instruments if (r := i.restart)), default=0)
259
+ return Song(
260
+ head.voices,
261
+ _tempo(head.tempo, "tempo"),
262
+ clock_hz,
263
+ init,
264
+ tuple(timeline),
265
+ doc.loop,
266
+ lookahead,
267
+ table,
268
+ )
269
+
270
+
271
+ def load(text: str) -> Song:
272
+ """The Song of canonical document ``text``."""
273
+ return compile_document(read(text))
274
+
275
+
276
+ class _Sequencer:
277
+ """The timeline walked to the tick each event's step begins, independent of the
278
+ registers."""
279
+
280
+ def __init__(self, song: Song):
281
+ n = self.voices = song.voices
282
+ self.song = song
283
+ self.tempo, self.phase = [song.tempo] * n, [0] * n
284
+ self.phrase, self.pos, self.transpose = [()] * n, [0] * n, [0] * n
285
+ self.rest, self.next, self.stopped = [0] * n, [0] * n, [False] * n
286
+ self.row, self.end = 0, None
287
+ for v, event in enumerate(song.init):
288
+ self._retime(v, event)
289
+
290
+ def _retime(self, v: int, event: CEvent):
291
+ if event.tempo:
292
+ self.tempo, self.phase = [event.tempo] * self.voices, [0] * self.voices
293
+ if event.vtempo:
294
+ self.tempo[v], self.phase[v] = event.vtempo, 0
295
+
296
+ def _start_row(self, t: int, ending: set):
297
+ timeline = self.song.timeline
298
+ if self.row == len(timeline):
299
+ if self.song.loop is None:
300
+ self.end = t
301
+ return
302
+ self.row = self.song.loop
303
+ index, self.row = self.row, self.row + 1
304
+ for v, entry in enumerate(timeline[index]):
305
+ if (entry[0] == "cont") == (v in ending):
306
+ state = "ends its phrase" if v in ending else "is not at a phrase end"
307
+ raise PlayError(f"row {index}: voice {v + 1} {state} at tick {t}")
308
+ if entry[0] == "end":
309
+ self.stopped[v] = True
310
+ elif entry[0] == "phrase":
311
+ self.phrase[v], self.pos[v], self.transpose[v] = entry[1], 0, entry[2]
312
+ if all(self.stopped):
313
+ self.end = t
314
+
315
+ def advance(self, limit: int) -> list:
316
+ """The events whose steps begin by tick ``limit``, as ``(tick, voice, event,
317
+ transpose)``."""
318
+ out = []
319
+ while self.end is None:
320
+ live = [v for v in range(self.voices) if not self.stopped[v]]
321
+ t = min(self.next[v] for v in live)
322
+ if t > limit:
323
+ break
324
+ due = [v for v in live if self.next[v] == t]
325
+ ending = {v for v in due if not self.rest[v] and self.pos[v] == len(self.phrase[v])}
326
+ if ending:
327
+ self._start_row(t, ending)
328
+ if self.end is not None:
329
+ break
330
+ due = [v for v in due if not self.stopped[v]]
331
+ for v in due:
332
+ if self.rest[v]:
333
+ self.rest[v] -= 1
334
+ continue
335
+ event = self.phrase[v][self.pos[v]]
336
+ self.pos[v] += 1
337
+ self.rest[v] = event.steps - 1
338
+ out.append((t, v, event, self.transpose[v]))
339
+ self._retime(v, event)
340
+ for v in due:
341
+ self.next[v] = t + self.tempo[v][self.phase[v]]
342
+ self.phase[v] ^= 1
343
+ return out
344
+
345
+
346
+ class _Voice:
347
+ """A voice's cells, note state, modulators and program slots."""
348
+
349
+ def __init__(self):
350
+ self.freq = self.pw = self.ctrl = self.mask = self.gate = self.ad = self.sr = 0
351
+ self.note = self.absolute = None
352
+ self.relative = 0
353
+ self.freq_prog = self.freq_cmd = self.pw_prog = self.pw_cmd = None
354
+ self.osc = None
355
+ self.slots = [[None, 0, 0] for _ in range(3)]
356
+
357
+ @property
358
+ def sounding(self) -> int | None:
359
+ """N: the row's absolute note, else the event note plus the row's relative note."""
360
+ if self.absolute is not None:
361
+ return self.absolute
362
+ return None if self.note is None else self.note + self.relative
363
+
364
+
365
+ class Player:
366
+ """Plays a Song one tick at a time; ``step()`` returns the registers at the end of the
367
+ tick, or None once the song has ended."""
368
+
369
+ def __init__(self, song: Song):
370
+ self.song = song
371
+ self.tick = 0
372
+ self.voices = [_Voice() for _ in range(song.voices)]
373
+ self.cut = self.res = self.route = self.mode = self.vol = 0
374
+ self.cut_prog = self.cut_cmd = None
375
+ self._seq = _Sequencer(song)
376
+ self._ins = [None] * song.voices
377
+ self._events = defaultdict(list)
378
+ self._lookahead = defaultdict(list)
379
+ self._init = [i for v, e in enumerate(song.init) for _, i in self._schedule(0, v, e, 0)]
380
+
381
+ @property
382
+ def finished(self) -> bool:
383
+ """Whether the song has ended at the current tick."""
384
+ return self._seq.end is not None and self.tick >= self._seq.end
385
+
386
+ def _schedule(self, t: int, v: int, event: CEvent, transpose: int) -> list:
387
+ """The ``(tick, event item)`` pairs of an event: a note-less one split at its
388
+ restart's lookahead, its gate (part ``gate``) on the first row's tick and its commands
389
+ (part ``ops``) on the last's."""
390
+ if event.ins is not None:
391
+ self._ins[v] = event.ins
392
+ ins = self._ins[v]
393
+ if ins is None and any(k == "run" and val is not None for k, val in event.ops):
394
+ raise PlayError(f"voice {v + 1}: !run at tick {t} on a voice with no instrument")
395
+ if not ins or not ins.restart or event.legato:
396
+ return [(t, (v, event, transpose, ins, None, None))]
397
+ *ahead, (last, _) = ins.restart
398
+ if isinstance(event.note, int):
399
+ for row, before in ahead:
400
+ if t - before >= 0:
401
+ self._lookahead[t - before].append((v, row))
402
+ return [(t, (v, event, transpose, ins, last, None))]
403
+ if not ahead:
404
+ return [(t, (v, event, transpose, ins, None, None))]
405
+ parts = [(ahead[0][1], "gate", event.note), (ahead[-1][1], "ops", event.ops)]
406
+ return [
407
+ (max(t - before, 0), (v, event, transpose, ins, None, part))
408
+ for before, part, has in parts
409
+ if has
410
+ ]
411
+
412
+ def step(self) -> list | None:
413
+ """Plays one tick."""
414
+ t = self.tick
415
+ for tick, v, event, transpose in self._seq.advance(t + self.song.lookahead):
416
+ for at, item in self._schedule(tick, v, event, transpose):
417
+ self._events[at].append(item)
418
+ if self.finished:
419
+ return None
420
+ if t == 0:
421
+ for item in self._init:
422
+ self._event(t, *item)
423
+ rows, events = self._lookahead.pop(t, ()), self._events.pop(t, ())
424
+ for v in range(len(self.voices)):
425
+ for rv, row in rows:
426
+ if rv == v:
427
+ self._row(v, row, t)
428
+ for item in events:
429
+ if item[0] == v:
430
+ self._event(t, *item)
431
+ for kind in (CONTROL, PULSE, FILTER):
432
+ for v in range(len(self.voices)):
433
+ self._run_slot(v, kind, t)
434
+ self._fire()
435
+ self.tick += 1
436
+ return self.registers()
437
+
438
+ def registers(self) -> list:
439
+ """The 25 register values."""
440
+ regs = [0] * REGISTERS
441
+ for v, voice in enumerate(self.voices):
442
+ ctrl = ((voice.ctrl ^ voice.mask) & 0xFE) | (voice.ctrl & voice.gate & 1)
443
+ regs[7 * v : 7 * v + 7] = [
444
+ voice.freq & 0xFF,
445
+ voice.freq >> 8,
446
+ voice.pw & 0xFF,
447
+ voice.pw >> 8,
448
+ ctrl,
449
+ voice.ad,
450
+ voice.sr,
451
+ ]
452
+ regs[21:25] = [
453
+ self.cut & 7,
454
+ self.cut >> 3,
455
+ self.res << 4 | self.route,
456
+ self.mode | self.vol,
457
+ ]
458
+ return regs
459
+
460
+ def _start(self, v: int, kind: int, prog: CProgram | None, t: int):
461
+ running = prog is not None and bool(prog.rows)
462
+ self.voices[v].slots[kind] = [prog, 0, t + prog.rows[0][0]] if running else [None, 0, 0]
463
+
464
+ def _stop_pulse(self, v: int):
465
+ voice = self.voices[v]
466
+ voice.slots[PULSE][0] = voice.pw_prog = None
467
+
468
+ def _stop_filter(self, v: int):
469
+ self.voices[v].slots[FILTER][0] = None
470
+ if self.cut_prog and self.cut_prog[2] == v:
471
+ self.cut_prog = None
472
+
473
+ def _run_slot(self, v: int, kind: int, t: int):
474
+ slots, seen = self.voices[v].slots, set()
475
+ while (slot := slots[kind])[0] is not None and slot[2] <= t:
476
+ prog, index = slot[0], slot[1]
477
+ if (id(prog), index) in seen:
478
+ raise PlayError(f"voice {v + 1}: a program loops in no time at tick {t}")
479
+ seen.add((id(prog), index))
480
+ _, hold, ops = prog.rows[index]
481
+ self._apply(v, ops, t, False)
482
+ if slots[kind] is not slot or slot[0] is None:
483
+ continue
484
+ index += 1
485
+ if index == len(prog.rows):
486
+ if prog.loop is None:
487
+ slot[0] = None
488
+ break
489
+ index = prog.loop
490
+ slot[1], slot[2] = index, t + hold + prog.rows[index][0]
491
+
492
+ def _row(self, v: int, row: tuple, t: int, first: bool = False):
493
+ self._apply(v, row[2], t, first)
494
+
495
+ def _apply(self, v: int, ops: tuple, t: int, first: bool): # pylint: disable=too-many-branches
496
+ """A row's keys, in vocabulary order; ``first`` marks a restart's first-tick row."""
497
+ voice = self.voices[v]
498
+ for key, value in ops:
499
+ if key == "ctrl":
500
+ ctrl, mask = value
501
+ if ctrl is not None:
502
+ voice.ctrl = ctrl & 0xFF
503
+ if mask is not None:
504
+ voice.mask = mask & 0xFE
505
+ if first:
506
+ voice.gate = 1
507
+ elif key == "gate":
508
+ voice.gate = 1 if value else 0
509
+ elif key == "note":
510
+ if value[0] == "rel":
511
+ voice.relative, voice.absolute = value[1], None
512
+ else:
513
+ voice.absolute = value[1]
514
+ elif key in ("ad", "sr"):
515
+ setattr(voice, key, value & 0xFF)
516
+ elif key == "pw":
517
+ if value is None:
518
+ self._stop_pulse(v)
519
+ else:
520
+ voice.pw_prog = [value, 0]
521
+ elif key == "cut":
522
+ self.cut_prog = [value, 0, v]
523
+ elif key == "freq":
524
+ voice.freq_prog = [value, 0]
525
+ elif key == "run":
526
+ self._start(v, value[0], value[1], t)
527
+ elif key == "filter":
528
+ self._stop_filter(v)
529
+ else:
530
+ self._global(v, key, value)
531
+
532
+ def _global(self, v: int, key: str, value):
533
+ if key == "res":
534
+ self.res = value & 15
535
+ elif key == "route":
536
+ form, mask = value
537
+ bits = (1 << v | mask) & 15 if form != "mask" else mask & 15
538
+ self.route = (
539
+ bits
540
+ if form == "mask"
541
+ else self.route | bits if form == "add" else self.route & ~bits
542
+ )
543
+ elif key == "mode":
544
+ self.mode = value & 0xF0
545
+ elif key == "vol":
546
+ self.vol = value & 15
547
+
548
+ def _event(
549
+ self, t: int, v: int, event: CEvent, transpose: int, ins, last, part
550
+ ): # pylint: disable=too-many-arguments,too-many-positional-arguments
551
+ """Event ``event``, or its ``part``: ``gate`` its gate alone, ``ops`` its commands."""
552
+ voice = self.voices[v]
553
+ if last is not None:
554
+ self._row(v, last, t, True)
555
+ if isinstance(event.note, int):
556
+ voice.note = event.note + transpose
557
+ if not event.legato:
558
+ voice.relative, voice.absolute, voice.gate = 0, None, 1
559
+ if ins is not None:
560
+ self._instrument(v, ins, event, t)
561
+ elif event.note is not None and part != "ops":
562
+ voice.gate = 1 if event.note == "on" else 0
563
+ for key, value in event.ops if part != "gate" else ():
564
+ self._command(v, key, value, t)
565
+
566
+ def _instrument(self, v: int, ins: CInstrument, event: CEvent, t: int):
567
+ voice = self.voices[v]
568
+ if ins.ad is not None:
569
+ voice.ad = ins.ad & 0xFF
570
+ if ins.sr is not None:
571
+ voice.sr = ins.sr & 0xFF
572
+ if ins.ctrl is not None:
573
+ self._start(v, CONTROL, ins.ctrl, t)
574
+ if ins.pw is not None and not event.keep_pw:
575
+ self._start(v, PULSE, ins.pw, t)
576
+ if ins.filter is not None and not event.keep_filter:
577
+ self._start(v, FILTER, ins.filter, t)
578
+ voice.freq_prog = None if ins.freq is None else [ins.freq, 0]
579
+ self._arm_osc(voice, ins.fn, ins.p0)
580
+ voice.freq_cmd = voice.pw_cmd = None
581
+ if self.cut_cmd and self.cut_cmd[2] == v:
582
+ self.cut_cmd = None
583
+
584
+ def _command(self, v: int, key: str, value, t: int): # pylint: disable=too-many-branches
585
+ voice = self.voices[v]
586
+ if key == "run":
587
+ if value is None:
588
+ voice.slots[CONTROL][0] = None
589
+ else:
590
+ self._start(v, value[0], value[1], t)
591
+ elif key == "pw":
592
+ voice.pw_cmd = None if value is None else [value, 0]
593
+ if value is None:
594
+ self._stop_pulse(v)
595
+ elif key == "filter":
596
+ self._stop_filter(v)
597
+ if self.cut_cmd and self.cut_cmd[2] == v:
598
+ self.cut_cmd = None
599
+ elif key in ("ad", "sr"):
600
+ setattr(voice, key, value & 0xFF)
601
+ elif key == "ctrl":
602
+ voice.ctrl = value & 0xFF
603
+ elif key == "resfilt":
604
+ self.res, self.route = value[0] & 15, value[1] & 15
605
+ elif key == "cut":
606
+ self.cut_cmd = [value, 0, v]
607
+ elif key == "freq":
608
+ voice.freq_cmd = None if value is None else [value, 0]
609
+ if value is not None:
610
+ voice.osc = None
611
+ elif key == "set":
612
+ if voice.osc is None:
613
+ raise PlayError(f"voice {v + 1}: !set at tick {t} with no frequency modulator")
614
+ voice.osc[1].update(dict(value) if value is not None else {})
615
+ else:
616
+ self._global(v, key, value)
617
+
618
+ def _arm_osc(self, voice, fn, p0):
619
+ if fn is None:
620
+ voice.osc = None
621
+ return
622
+ voice.osc = [fn, dict(p0) if p0 else {}, 0]
623
+
624
+ def _fire(self):
625
+ for vix, voice in enumerate(self.voices):
626
+ if voice.osc is not None:
627
+ n = voice.sounding
628
+ base = self.song.table(n) if n is not None else 0
629
+ fn, bag, tick = voice.osc
630
+ base = (base + bag.get("detune", 0) * vix) & FREQ_MASK
631
+ voice.freq = fn(bag, voice.freq, tick, base) & FREQ_MASK
632
+ voice.osc[2] += 1
633
+ continue
634
+ mod, n = voice.freq_cmd or voice.freq_prog, voice.sounding
635
+ if mod:
636
+ voice.freq = mod[0](voice.freq, mod[1], n or 0) & FREQ_MASK
637
+ mod[1] += 1
638
+ elif n is not None:
639
+ voice.freq = self.song.table(n)
640
+ for voice in self.voices:
641
+ mod = voice.pw_cmd or voice.pw_prog
642
+ if mod:
643
+ voice.pw = mod[0](voice.pw, mod[1], voice.sounding or 0) & PW_MASK
644
+ mod[1] += 1
645
+ mod = self.cut_cmd or self.cut_prog
646
+ if mod:
647
+ n = self.voices[mod[2]].sounding
648
+ self.cut = mod[0](self.cut, mod[1], n or 0) & CUT_MASK
649
+ mod[1] += 1
650
+
651
+
652
+ def play(source, ticks: int | None = None, progress: bool = False) -> np.ndarray:
653
+ """The registers at the end of each tick of a document's text or Song, shape ``(ticks,
654
+ 25)``: to the song's end when ``ticks`` is None (a looping song needs ``ticks``), and fewer
655
+ rows where the song ends first."""
656
+ song = load(source) if isinstance(source, str) else source
657
+ if ticks is None and song.loop is not None:
658
+ raise PlayError("a looping song needs ticks")
659
+ player = Player(song)
660
+ frames = np.zeros((ticks or 0, REGISTERS), dtype=np.uint8)
661
+ rows = []
662
+ span = range(ticks) if ticks is not None else itertools.count()
663
+ for t in tqdm(span, total=ticks, unit="tick", disable=not progress):
664
+ regs = player.step()
665
+ if regs is None:
666
+ break
667
+ if ticks is None:
668
+ rows.append(regs)
669
+ else:
670
+ frames[t] = regs
671
+ if ticks is None:
672
+ return np.array(rows, dtype=np.uint8).reshape(-1, REGISTERS)
673
+ return frames[: player.tick]