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.
- unitracker_player/__init__.py +6 -0
- unitracker_player/cli.py +37 -0
- unitracker_player/document.py +447 -0
- unitracker_player/expr.py +34 -0
- unitracker_player/modulation.py +110 -0
- unitracker_player/player.py +673 -0
- unitracker_player-0.2.0.dist-info/METADATA +263 -0
- unitracker_player-0.2.0.dist-info/RECORD +12 -0
- unitracker_player-0.2.0.dist-info/WHEEL +5 -0
- unitracker_player-0.2.0.dist-info/entry_points.txt +2 -0
- unitracker_player-0.2.0.dist-info/licenses/LICENSE +201 -0
- unitracker_player-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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]
|