unitracker-player 0.3.0__tar.gz → 0.5.0__tar.gz
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-0.3.0 → unitracker_player-0.5.0}/PKG-INFO +2 -2
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/pyproject.toml +2 -2
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player/document.py +14 -1
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player/expr.py +4 -2
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player/player.py +112 -43
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/PKG-INFO +2 -2
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/SOURCES.txt +1 -0
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/requires.txt +1 -1
- unitracker_player-0.5.0/tests/test_hold.py +150 -0
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/tests/test_player.py +26 -0
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/LICENSE +0 -0
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/README.md +0 -0
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/setup.cfg +0 -0
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player/__init__.py +0 -0
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player/cli.py +0 -0
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player/modulation.py +0 -0
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/dependency_links.txt +0 -0
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/entry_points.txt +0 -0
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/top_level.txt +0 -0
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/tests/test_cli.py +0 -0
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/tests/test_document.py +0 -0
- {unitracker_player-0.3.0 → unitracker_player-0.5.0}/tests/test_modulation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: unitracker-player
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Plays canonical unitracker documents to SID register state per tick
|
|
5
5
|
Author: Josh Bailey
|
|
6
6
|
License: Apache License
|
|
@@ -211,7 +211,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
211
211
|
Requires-Python: >=3.12
|
|
212
212
|
Description-Content-Type: text/markdown
|
|
213
213
|
License-File: LICENSE
|
|
214
|
-
Requires-Dist: unitracker-grammar>=0.
|
|
214
|
+
Requires-Dist: unitracker-grammar>=0.4.0
|
|
215
215
|
Requires-Dist: lark>=1.2
|
|
216
216
|
Requires-Dist: numpy>=1.26
|
|
217
217
|
Requires-Dist: tqdm>=4.66
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "unitracker-player"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Plays canonical unitracker documents to SID register state per tick"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -12,7 +12,7 @@ license = { file = "LICENSE" }
|
|
|
12
12
|
authors = [{ name = "Josh Bailey" }]
|
|
13
13
|
classifiers = ["Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3"]
|
|
14
14
|
dependencies = [
|
|
15
|
-
"unitracker-grammar>=0.
|
|
15
|
+
"unitracker-grammar>=0.4.0",
|
|
16
16
|
"lark>=1.2",
|
|
17
17
|
"numpy>=1.26",
|
|
18
18
|
"tqdm>=4.66",
|
|
@@ -33,6 +33,8 @@ _VALUES = {
|
|
|
33
33
|
"CTRLMASK",
|
|
34
34
|
"NONE",
|
|
35
35
|
"NAME",
|
|
36
|
+
"HOLD_CELL",
|
|
37
|
+
"HOLD_EVENT",
|
|
36
38
|
}
|
|
37
39
|
_PITCH = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
|
38
40
|
_NOTE = re.compile(r"([A-G])(#?)(-?\d+)")
|
|
@@ -108,7 +110,7 @@ class Play:
|
|
|
108
110
|
|
|
109
111
|
@dataclass(frozen=True)
|
|
110
112
|
class Header:
|
|
111
|
-
"""The header lines, with their defaults
|
|
113
|
+
"""The header lines, with their defaults; ``hold`` is ``{cell: ((event, k), ...)}``."""
|
|
112
114
|
|
|
113
115
|
version: str
|
|
114
116
|
meta: dict = field(default_factory=dict)
|
|
@@ -116,6 +118,7 @@ class Header:
|
|
|
116
118
|
clock_hz: int | None = None
|
|
117
119
|
voices: int = 3
|
|
118
120
|
tempo: tuple = (6, 6)
|
|
121
|
+
hold: dict = field(default_factory=dict)
|
|
119
122
|
|
|
120
123
|
|
|
121
124
|
@dataclass(frozen=True)
|
|
@@ -204,6 +207,7 @@ class ToDocument(Nodes):
|
|
|
204
207
|
*clock,
|
|
205
208
|
head.pop("voices", 3),
|
|
206
209
|
head.pop("tempo", (6, 6)),
|
|
210
|
+
head.pop("hold", {}),
|
|
207
211
|
)
|
|
208
212
|
programs = {**defs["restart"], **defs["ctrl"], **defs["pw"], **defs["filter"]}
|
|
209
213
|
return Document(
|
|
@@ -248,6 +252,15 @@ class ToDocument(Nodes):
|
|
|
248
252
|
values = _values(children)
|
|
249
253
|
return ("tempo", (values[0], values[-1]))
|
|
250
254
|
|
|
255
|
+
@staticmethod
|
|
256
|
+
def hold(children):
|
|
257
|
+
return ("hold", dict(_values(children)))
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def hold_cell(children):
|
|
261
|
+
cell, *events = _values(children)
|
|
262
|
+
return cell, tuple((name, int(k or 0)) for name, _, k in (e.partition("-") for e in events))
|
|
263
|
+
|
|
251
264
|
@staticmethod
|
|
252
265
|
def tables(children):
|
|
253
266
|
values = _values(children)
|
|
@@ -24,9 +24,11 @@ def constant(node: ast.expr) -> int | None:
|
|
|
24
24
|
|
|
25
25
|
def compile_expr(node: ast.expr, table: Callable[[int], int], tables: dict) -> Fn:
|
|
26
26
|
"""``node`` as a callable of ``(v, phi, n)``, reading ``F`` from ``table`` and ``T[k]`` from
|
|
27
|
-
``tables``."""
|
|
27
|
+
``tables``; the callable's ``node`` is ``node``."""
|
|
28
28
|
namespace = {"_F": table, "_T": {name: indexed(values) for name, values in tables.items()}}
|
|
29
|
-
|
|
29
|
+
fn = _compiled(node, namespace, zero=True)
|
|
30
|
+
fn.node = node
|
|
31
|
+
return fn
|
|
30
32
|
|
|
31
33
|
|
|
32
34
|
def offset(n: int) -> ast.expr:
|
|
@@ -16,6 +16,7 @@ from unitracker_player.modulation import SHAPES
|
|
|
16
16
|
VERSION = "1.20"
|
|
17
17
|
MAX_VOICES = 3
|
|
18
18
|
REGISTERS = 25
|
|
19
|
+
ENVELOPE = ("ad", "sr")
|
|
19
20
|
CONTROL, PULSE, FILTER = range(3)
|
|
20
21
|
FREQ_MASK, PW_MASK, CUT_MASK = 0xFFFF, 0xFFF, 0x7FF
|
|
21
22
|
|
|
@@ -77,6 +78,8 @@ class Song:
|
|
|
77
78
|
loop: int | None
|
|
78
79
|
lookahead: int
|
|
79
80
|
table: object
|
|
81
|
+
hold: dict
|
|
82
|
+
horizon: int
|
|
80
83
|
|
|
81
84
|
|
|
82
85
|
def _tempo(value, where: str) -> tuple:
|
|
@@ -256,6 +259,8 @@ def compile_document(doc: Document) -> Song:
|
|
|
256
259
|
raise PlayError(f"song: loop row {doc.loop} out of range")
|
|
257
260
|
instruments = [v for k, v in comp.memo.items() if k[0] == "ins"]
|
|
258
261
|
lookahead = max((r[0][1] for i in instruments if (r := i.restart)), default=0)
|
|
262
|
+
hold = {cell: frozenset(events) for cell, events in head.hold.items()}
|
|
263
|
+
earliest = max((k for events in hold.values() for _, k in events), default=0)
|
|
259
264
|
return Song(
|
|
260
265
|
head.voices,
|
|
261
266
|
_tempo(head.tempo, "tempo"),
|
|
@@ -265,6 +270,8 @@ def compile_document(doc: Document) -> Song:
|
|
|
265
270
|
doc.loop,
|
|
266
271
|
lookahead,
|
|
267
272
|
table,
|
|
273
|
+
hold,
|
|
274
|
+
lookahead + earliest,
|
|
268
275
|
)
|
|
269
276
|
|
|
270
277
|
|
|
@@ -313,8 +320,8 @@ class _Sequencer:
|
|
|
313
320
|
self.end = t
|
|
314
321
|
|
|
315
322
|
def advance(self, limit: int) -> list:
|
|
316
|
-
"""The
|
|
317
|
-
|
|
323
|
+
"""The steps that begin by tick ``limit``, as ``(tick, voice, event, transpose, last)``:
|
|
324
|
+
``event`` None for a step an event's ``*k`` times, ``last`` for a phrase's last step."""
|
|
318
325
|
out = []
|
|
319
326
|
while self.end is None:
|
|
320
327
|
live = [v for v in range(self.voices) if not self.stopped[v]]
|
|
@@ -329,14 +336,16 @@ class _Sequencer:
|
|
|
329
336
|
break
|
|
330
337
|
due = [v for v in due if not self.stopped[v]]
|
|
331
338
|
for v in due:
|
|
339
|
+
event = None
|
|
332
340
|
if self.rest[v]:
|
|
333
341
|
self.rest[v] -= 1
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
self.
|
|
342
|
+
else:
|
|
343
|
+
event = self.phrase[v][self.pos[v]]
|
|
344
|
+
self.pos[v] += 1
|
|
345
|
+
self.rest[v] = event.steps - 1
|
|
346
|
+
self._retime(v, event)
|
|
347
|
+
last = not self.rest[v] and self.pos[v] == len(self.phrase[v])
|
|
348
|
+
out.append((t, v, event, self.transpose[v], last))
|
|
340
349
|
for v in due:
|
|
341
350
|
self.next[v] = t + self.tempo[v][self.phase[v]]
|
|
342
351
|
self.phase[v] ^= 1
|
|
@@ -362,7 +371,7 @@ class _Voice:
|
|
|
362
371
|
return None if self.note is None else self.note + self.relative
|
|
363
372
|
|
|
364
373
|
|
|
365
|
-
class Player:
|
|
374
|
+
class Player: # pylint: disable=too-many-instance-attributes
|
|
366
375
|
"""Plays a Song one tick at a time; ``step()`` returns the registers at the end of the
|
|
367
376
|
tick, or None once the song has ended. ``regs`` is the register file the last tick wrote,
|
|
368
377
|
each register by its own statement, so a line tracer sees one store per register."""
|
|
@@ -378,6 +387,8 @@ class Player:
|
|
|
378
387
|
self._ins = [None] * song.voices
|
|
379
388
|
self._events = defaultdict(list)
|
|
380
389
|
self._lookahead = defaultdict(list)
|
|
390
|
+
self._held = defaultdict(set)
|
|
391
|
+
self._noted = [None] * song.voices
|
|
381
392
|
self._init = [i for v, e in enumerate(song.init) for _, i in self._schedule(0, v, e, 0)]
|
|
382
393
|
|
|
383
394
|
@property
|
|
@@ -398,6 +409,10 @@ class Player:
|
|
|
398
409
|
return [(t, (v, event, transpose, ins, None, None))]
|
|
399
410
|
*ahead, (last, _) = ins.restart
|
|
400
411
|
if isinstance(event.note, int):
|
|
412
|
+
if ahead:
|
|
413
|
+
wait, hold, ops = ahead[-1][0]
|
|
414
|
+
envelope = tuple(op for op in event.ops if op[0] in ENVELOPE)
|
|
415
|
+
ahead[-1] = ((wait, hold, ops + envelope), ahead[-1][1])
|
|
401
416
|
for row, before in ahead:
|
|
402
417
|
if t - before >= 0:
|
|
403
418
|
self._lookahead[t - before].append((v, row))
|
|
@@ -411,12 +426,35 @@ class Player:
|
|
|
411
426
|
if has
|
|
412
427
|
]
|
|
413
428
|
|
|
429
|
+
def _hold_step(self, t: int, v: int, event: CEvent | None, last: bool):
|
|
430
|
+
"""The hold ticks of voice ``v``'s step at tick ``t``: the tick of each event it is, and
|
|
431
|
+
of its read, by the restart of the voice's instrument before the step's event."""
|
|
432
|
+
at = {"step": t, "last": t if last else None, "read": None}
|
|
433
|
+
if event is not None and isinstance(event.note, int):
|
|
434
|
+
at["legato" if event.legato else "note"] = t
|
|
435
|
+
ins = self._ins[v]
|
|
436
|
+
if ins is not None:
|
|
437
|
+
at["read"] = t - (ins.restart[0][1] if ins.restart else 0)
|
|
438
|
+
for cell, events in self.song.hold.items():
|
|
439
|
+
self._held[v, cell].update(at[e] - k for e, k in events if at.get(e) is not None)
|
|
440
|
+
|
|
441
|
+
def _holds(self, v: int, cell: str, t: int) -> bool:
|
|
442
|
+
"""Whether tick ``t`` is one of voice ``v``'s hold events of ``cell``."""
|
|
443
|
+
rowed = ("row", 0) in self.song.hold.get(cell, ()) and t in self._held[v, "row"]
|
|
444
|
+
return rowed or t in self._held[v, cell]
|
|
445
|
+
|
|
446
|
+
def _advance(self, t: int):
|
|
447
|
+
"""Schedules the steps that begin by the song's horizon past tick ``t``."""
|
|
448
|
+
for tick, v, event, transpose, last in self._seq.advance(t + self.song.horizon):
|
|
449
|
+
self._hold_step(tick, v, event, last)
|
|
450
|
+
if event is not None:
|
|
451
|
+
for at, item in self._schedule(tick, v, event, transpose):
|
|
452
|
+
self._events[at].append(item)
|
|
453
|
+
|
|
414
454
|
def step(self) -> list | None:
|
|
415
455
|
"""Plays one tick."""
|
|
416
456
|
t = self.tick
|
|
417
|
-
|
|
418
|
-
for at, item in self._schedule(tick, v, event, transpose):
|
|
419
|
-
self._events[at].append(item)
|
|
457
|
+
self._advance(t)
|
|
420
458
|
if self.finished:
|
|
421
459
|
return None
|
|
422
460
|
if t == 0:
|
|
@@ -476,7 +514,9 @@ class Player:
|
|
|
476
514
|
raise PlayError(f"voice {v + 1}: a program loops in no time at tick {t}")
|
|
477
515
|
seen.add((id(prog), index))
|
|
478
516
|
_, hold, ops = prog.rows[index]
|
|
479
|
-
self._apply(v, ops, t, False)
|
|
517
|
+
self._apply(v, ops, t, False, kind == CONTROL)
|
|
518
|
+
if kind == CONTROL and any(key not in ("ctrl", "gate") for key, _ in ops):
|
|
519
|
+
self._held[v, "row"].add(t)
|
|
480
520
|
if slots[kind] is not slot or slot[0] is None:
|
|
481
521
|
continue
|
|
482
522
|
index += 1
|
|
@@ -490,8 +530,11 @@ class Player:
|
|
|
490
530
|
def _row(self, v: int, row: tuple, t: int, first: bool = False):
|
|
491
531
|
self._apply(v, row[2], t, first)
|
|
492
532
|
|
|
493
|
-
|
|
494
|
-
|
|
533
|
+
# pylint: disable-next=too-many-branches,too-many-arguments,too-many-positional-arguments
|
|
534
|
+
def _apply(self, v: int, ops: tuple, t: int, first: bool, ctrl: bool = False):
|
|
535
|
+
"""A row's keys, in vocabulary order; ``first`` marks a restart's first-tick row,
|
|
536
|
+
``ctrl`` a control program's row, whose modulators hold on the ``ctrl.`` cells."""
|
|
537
|
+
held = "ctrl." if ctrl else ""
|
|
495
538
|
voice = self.voices[v]
|
|
496
539
|
for key, value in ops:
|
|
497
540
|
if key == "ctrl":
|
|
@@ -515,11 +558,11 @@ class Player:
|
|
|
515
558
|
if value is None:
|
|
516
559
|
self._stop_pulse(v)
|
|
517
560
|
else:
|
|
518
|
-
voice.pw_prog = [value, 0]
|
|
561
|
+
voice.pw_prog = [value, 0, held + "pw", None, None]
|
|
519
562
|
elif key == "cut":
|
|
520
|
-
self.cut_prog = [value, 0, v]
|
|
563
|
+
self.cut_prog = [value, 0, v, held + "cut", None, None]
|
|
521
564
|
elif key == "freq":
|
|
522
|
-
voice.freq_prog = [value, 0]
|
|
565
|
+
voice.freq_prog = [value, 0, held + "freq", self._before(v, t), None]
|
|
523
566
|
elif key == "run":
|
|
524
567
|
self._start(v, value[0], value[1], t)
|
|
525
568
|
elif key == "filter":
|
|
@@ -554,6 +597,7 @@ class Player:
|
|
|
554
597
|
voice.note = event.note + transpose
|
|
555
598
|
if not event.legato:
|
|
556
599
|
voice.relative, voice.absolute, voice.gate = 0, None, 1
|
|
600
|
+
self._noted[v] = t
|
|
557
601
|
if ins is not None:
|
|
558
602
|
self._instrument(v, ins, event, t)
|
|
559
603
|
elif event.note is not None and part != "ops":
|
|
@@ -573,7 +617,7 @@ class Player:
|
|
|
573
617
|
self._start(v, PULSE, ins.pw, t)
|
|
574
618
|
if ins.filter is not None and not event.keep_filter:
|
|
575
619
|
self._start(v, FILTER, ins.filter, t)
|
|
576
|
-
voice.freq_prog = None if ins.freq is None else [ins.freq, 0]
|
|
620
|
+
voice.freq_prog = None if ins.freq is None else [ins.freq, 0, "freq", -1, None]
|
|
577
621
|
self._arm_osc(voice, ins.fn, ins.p0)
|
|
578
622
|
voice.freq_cmd = voice.pw_cmd = None
|
|
579
623
|
if self.cut_cmd and self.cut_cmd[2] == v:
|
|
@@ -587,7 +631,7 @@ class Player:
|
|
|
587
631
|
else:
|
|
588
632
|
self._start(v, value[0], value[1], t)
|
|
589
633
|
elif key == "pw":
|
|
590
|
-
voice.pw_cmd = None if value is None else [value, 0]
|
|
634
|
+
voice.pw_cmd = None if value is None else [value, 0, "pw", None, None]
|
|
591
635
|
if value is None:
|
|
592
636
|
self._stop_pulse(v)
|
|
593
637
|
elif key == "filter":
|
|
@@ -601,9 +645,9 @@ class Player:
|
|
|
601
645
|
elif key == "resfilt":
|
|
602
646
|
self.res, self.route = value[0] & 15, value[1] & 15
|
|
603
647
|
elif key == "cut":
|
|
604
|
-
self.cut_cmd = [value, 0, v]
|
|
648
|
+
self.cut_cmd = [value, 0, v, "cut", None, None]
|
|
605
649
|
elif key == "freq":
|
|
606
|
-
voice.freq_cmd = None if value is None else [value, 0]
|
|
650
|
+
voice.freq_cmd = None if value is None else [value, 0, "freq", self._before(v, t), None]
|
|
607
651
|
if value is not None:
|
|
608
652
|
voice.osc = None
|
|
609
653
|
elif key == "set":
|
|
@@ -617,34 +661,59 @@ class Player:
|
|
|
617
661
|
if fn is None:
|
|
618
662
|
voice.osc = None
|
|
619
663
|
return
|
|
620
|
-
voice.osc = [fn, dict(p0) if p0 else {}, 0]
|
|
664
|
+
voice.osc = [fn, dict(p0) if p0 else {}, 0, "freq", -1, None]
|
|
665
|
+
|
|
666
|
+
def _owner(self, t: int, cmd: list | None, prog: list | None, v: int | None = None) -> tuple:
|
|
667
|
+
"""``(modulator, held)`` of the command, else the program modulator, held by voice
|
|
668
|
+
``v`` (the cutoff's: the voice that set it); ``(None, False)`` for neither."""
|
|
669
|
+
mod = cmd or prog
|
|
670
|
+
if mod is None:
|
|
671
|
+
return None, False
|
|
672
|
+
return mod, self._holds(mod[2] if v is None else v, mod[-3], t)
|
|
673
|
+
|
|
674
|
+
def _before(self, v: int, t: int) -> int | None:
|
|
675
|
+
"""A frequency modulator's φ before its first firing: -1 for one set on the tick a note
|
|
676
|
+
starts, else None (the frequency keeps its value)."""
|
|
677
|
+
return -1 if self._noted[v] == t else None
|
|
621
678
|
|
|
622
679
|
def _fire(self):
|
|
680
|
+
t = self.tick
|
|
623
681
|
for vix, voice in enumerate(self.voices):
|
|
682
|
+
n = voice.sounding
|
|
624
683
|
if voice.osc is not None:
|
|
625
|
-
n = voice.sounding
|
|
626
684
|
base = self.song.table(n) if n is not None else 0
|
|
627
|
-
fn, bag
|
|
685
|
+
fn, bag = voice.osc[0], voice.osc[1]
|
|
628
686
|
base = (base + bag.get("detune", 0) * vix) & FREQ_MASK
|
|
629
|
-
voice.
|
|
630
|
-
|
|
687
|
+
if at := _firing(voice.osc, 2, self._holds(vix, "freq", t), voice.freq):
|
|
688
|
+
voice.freq = fn(bag, *at, base) & FREQ_MASK
|
|
631
689
|
continue
|
|
632
|
-
mod,
|
|
633
|
-
if mod:
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
elif
|
|
637
|
-
voice.freq =
|
|
638
|
-
for voice in self.voices:
|
|
639
|
-
mod = voice.pw_cmd
|
|
640
|
-
if mod:
|
|
641
|
-
voice.pw = mod[0](
|
|
642
|
-
|
|
643
|
-
mod
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
690
|
+
mod, held = self._owner(t, voice.freq_cmd, voice.freq_prog, vix)
|
|
691
|
+
if mod is None:
|
|
692
|
+
if n is not None:
|
|
693
|
+
voice.freq = self.song.table(n)
|
|
694
|
+
elif at := _firing(mod, 1, held, voice.freq):
|
|
695
|
+
voice.freq = mod[0](*at, n or 0) & FREQ_MASK
|
|
696
|
+
for vix, voice in enumerate(self.voices):
|
|
697
|
+
mod, held = self._owner(t, voice.pw_cmd, voice.pw_prog, vix)
|
|
698
|
+
if mod and (at := _firing(mod, 1, held, voice.pw)):
|
|
699
|
+
voice.pw = mod[0](*at, voice.sounding or 0) & PW_MASK
|
|
700
|
+
mod, held = self._owner(t, self.cut_cmd, self.cut_prog)
|
|
701
|
+
if mod and (at := _firing(mod, 1, held, self.cut)):
|
|
702
|
+
self.cut = mod[0](*at, self.voices[mod[2]].sounding or 0) & CUT_MASK
|
|
703
|
+
for ticks in self._held.values():
|
|
704
|
+
ticks.discard(t)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _firing(mod: list, at: int, held: bool, v: int) -> tuple | None:
|
|
708
|
+
"""The ``(v, φ)`` modulator ``mod`` (its φ at ``at``, then its key, its φ before its first
|
|
709
|
+
firing and its last firing's ``(v, φ)``) is evaluated at with the cell's value ``v``, or
|
|
710
|
+
None to keep it: on a hold tick its last firing's, before its first ``(v, φ before)`` or
|
|
711
|
+
None, φ not advancing; else ``v`` and its φ, which then advances."""
|
|
712
|
+
if held:
|
|
713
|
+
return mod[-1] or (None if mod[-2] is None else (v, mod[-2]))
|
|
714
|
+
mod[-1] = (v, mod[at])
|
|
715
|
+
mod[at] += 1
|
|
716
|
+
return mod[-1]
|
|
648
717
|
|
|
649
718
|
|
|
650
719
|
def play(source, ticks: int | None = None, progress: bool = False) -> np.ndarray:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: unitracker-player
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Plays canonical unitracker documents to SID register state per tick
|
|
5
5
|
Author: Josh Bailey
|
|
6
6
|
License: Apache License
|
|
@@ -211,7 +211,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
211
211
|
Requires-Python: >=3.12
|
|
212
212
|
Description-Content-Type: text/markdown
|
|
213
213
|
License-File: LICENSE
|
|
214
|
-
Requires-Dist: unitracker-grammar>=0.
|
|
214
|
+
Requires-Dist: unitracker-grammar>=0.4.0
|
|
215
215
|
Requires-Dist: lark>=1.2
|
|
216
216
|
Requires-Dist: numpy>=1.26
|
|
217
217
|
Requires-Dist: tqdm>=4.66
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Holds: a modulator repeats its last firing on its cell's hold events (docs/semantics.md)."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from song import cutoff, doc, freq, pulse
|
|
7
|
+
from unitracker_grammar import frequency as F
|
|
8
|
+
|
|
9
|
+
from unitracker_player import Player, load, play
|
|
10
|
+
|
|
11
|
+
SWEEP = "{ctrl: [{ctrl: 65}, end], pw: 10*φ + 1}"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sweep(held: set, ticks: int, start: int = 0) -> list:
|
|
15
|
+
"""``10*φ + 1`` from ``start``, φ advancing on every tick but ``held``, 0 before; a held
|
|
16
|
+
tick before the first firing keeps the pulse width."""
|
|
17
|
+
out, phi, last = [], 0, 0
|
|
18
|
+
for t in range(ticks):
|
|
19
|
+
if t >= start:
|
|
20
|
+
if t in held:
|
|
21
|
+
out.append(last)
|
|
22
|
+
continue
|
|
23
|
+
last = 10 * phi + 1
|
|
24
|
+
phi += 1
|
|
25
|
+
out.append(last)
|
|
26
|
+
return out
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.parametrize(
|
|
30
|
+
"hold, held",
|
|
31
|
+
[
|
|
32
|
+
("step", {0, 3, 6, 9}),
|
|
33
|
+
("step-1", {2, 5, 8}),
|
|
34
|
+
("step-2", {1, 4, 7}),
|
|
35
|
+
("last", {9}),
|
|
36
|
+
("last-1", {8}),
|
|
37
|
+
("note", {0, 9}),
|
|
38
|
+
("note-1", {8}),
|
|
39
|
+
("legato", {3}),
|
|
40
|
+
],
|
|
41
|
+
)
|
|
42
|
+
def test_a_pulse_holds_on_its_events(hold, held):
|
|
43
|
+
text = doc(
|
|
44
|
+
phrase="[C4.i1, D4!legato, -.i1!pw, E4!pw]",
|
|
45
|
+
ins=SWEEP,
|
|
46
|
+
head=f"tempo: 3\nhold: {{pw: [{hold}]}}\n",
|
|
47
|
+
)
|
|
48
|
+
assert pulse(play(text, 12)) == sweep(held, 12)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_a_modulator_held_on_the_tick_it_starts_fires_first_on_its_next():
|
|
52
|
+
"""A note starts the instrument's pulse on a held step: the step keeps the pulse width, and
|
|
53
|
+
the pulse fires first on the next tick."""
|
|
54
|
+
text = doc(phrase="[C4.i1, D4]", ins=SWEEP, head="tempo: 3\nhold: {pw: [step]}\n")
|
|
55
|
+
assert pulse(play(text, 6)) == [0, 1, 11, 11, 1, 11]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_a_read_is_the_step_less_its_instrument_s_restart_lookahead():
|
|
59
|
+
"""The voice reads its step its instrument's lookahead earlier, the instrument it plays before
|
|
60
|
+
the step names another; a voice with no instrument reads nothing."""
|
|
61
|
+
ins = {
|
|
62
|
+
"i1": "{ctrl: [{ctrl: 65}, end], pw: 10*φ + 1, restart: [{hold: 2, gate: 0}, {}, end]}",
|
|
63
|
+
"i2": "{ctrl: [{ctrl: 65}, end], restart: [{hold: 1, gate: 0}, {}, end]}",
|
|
64
|
+
}
|
|
65
|
+
text = doc(
|
|
66
|
+
phrase="[-, C4.i1, -, -.i2!pw, -!pw]", ins=ins, head="tempo: 4\nhold: {pw: [read]}\n"
|
|
67
|
+
)
|
|
68
|
+
held = {6, 10, 15}
|
|
69
|
+
assert pulse(play(text, 20)) == sweep(held, 20, start=4)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_a_control_program_row_holds_on_any_key_but_ctrl_and_gate():
|
|
73
|
+
"""A row setting a note is a row event; one setting only ctrl and gate is not, nor is a
|
|
74
|
+
restart's row. The held frequency repeats its last firing at the row's new note."""
|
|
75
|
+
ins = (
|
|
76
|
+
"{ctrl: [{ctrl: 65}, {gate: 1}, {note: +12}, {ctrl: 64}, end], freq: F(N) + φ,"
|
|
77
|
+
" restart: [{hold: 1, sr: 1}, {}, end]}"
|
|
78
|
+
)
|
|
79
|
+
text = doc(phrase="[C4.i1, -*2]", ins=ins, head="tempo: 3\nhold: {freq: [row]}\n")
|
|
80
|
+
c4, c5 = F(48), F(60)
|
|
81
|
+
assert freq(play(text, 6)) == [c4, c4 + 1, c5 + 1, c5 + 2, c5 + 3, c5 + 4]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_a_held_frequency_follows_its_note_and_a_recurrence_holds_its_value():
|
|
85
|
+
"""On a held legato tick a closed form takes the new note, a recurrence keeps its word."""
|
|
86
|
+
for expr, expected in (
|
|
87
|
+
("F(N) + φ", [F(48), F(48) + 1, F(50) + 1, F(50) + 2]),
|
|
88
|
+
("v + 1 + (φ < 1)*F(N)", [F(48) + 1, F(48) + 2, F(48) + 2, F(48) + 3]),
|
|
89
|
+
):
|
|
90
|
+
ins = f"{{ctrl: [{{ctrl: 65}}, end], freq: {expr}}}"
|
|
91
|
+
text = doc(phrase="[C4.i1, D4!legato]", ins=ins, head="tempo: 2\nhold: {freq: [legato]}\n")
|
|
92
|
+
assert freq(play(text, 4)) == expected
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_a_note_s_frequency_is_at_phi_minus_1_before_its_first_firing():
|
|
96
|
+
"""A note's command and its instrument's frequency, held before their first firings, play
|
|
97
|
+
their values at φ -1; a command no note starts keeps the frequency."""
|
|
98
|
+
text = doc(
|
|
99
|
+
phrase="[C4.i1!freq:x1, -!freq:none, D4!freq:x2!legato, -]",
|
|
100
|
+
ins="{ctrl: [{ctrl: 65}, end], freq: F(N) + φ}",
|
|
101
|
+
x={"x1": "F(N) + 100", "x2": "v + 7"},
|
|
102
|
+
head="tempo: 2\nhold: {freq: [step]}\n",
|
|
103
|
+
)
|
|
104
|
+
c4 = F(48)
|
|
105
|
+
assert freq(play(text, 6)) == [c4 + 100, c4 + 100, c4 - 1, c4, c4, c4 + 7]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_a_shape_holds_its_tick():
|
|
109
|
+
"""An instrument's modulator shape repeats its last tick too."""
|
|
110
|
+
text = doc(
|
|
111
|
+
phrase="[C4.i1, -]",
|
|
112
|
+
ins="{ctrl: [{ctrl: 65}, end], mod: ramp {step: 5}}",
|
|
113
|
+
head="tempo: 2\nhold: {freq: [step]}\n",
|
|
114
|
+
)
|
|
115
|
+
base = F(48)
|
|
116
|
+
assert freq(play(text, 4)) == [base, base, base, base + 5]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_the_cutoff_holds_on_the_events_of_the_voice_that_set_it():
|
|
120
|
+
"""Voice 2 sets the cutoff; voice 1's steps do not hold it, voice 2's do."""
|
|
121
|
+
head = "voices: 2\ntempo: 2\nhold: {cut: [step]}\n"
|
|
122
|
+
text = doc(
|
|
123
|
+
phrase={"p1": "[C4.i1!vtempo:3*3]", "p2": "[C4.i2, -, -*2]"},
|
|
124
|
+
ins={"i1": "{ctrl: [{ctrl: 65}, end]}", "i2": "{ctrl: [{ctrl: 65}, end], filter: 8*φ + 8}"},
|
|
125
|
+
rows=("[p1, p2]",),
|
|
126
|
+
head=head,
|
|
127
|
+
)
|
|
128
|
+
assert cutoff(play(text, 8)) == [0, 8, 8, 16, 16, 24, 24, 32]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_no_hold_line_holds_nothing():
|
|
132
|
+
text = doc(phrase="[C4.i1, D4!legato, -]", ins=SWEEP, head="tempo: 3\n")
|
|
133
|
+
assert pulse(play(text, 9)) == sweep(set(), 9)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_a_compiled_expression_carries_its_node():
|
|
137
|
+
song = load(doc(ins="{ctrl: [{ctrl: 65}, end], pw: 10*φ + 1}"))
|
|
138
|
+
player = Player(song)
|
|
139
|
+
player.step()
|
|
140
|
+
assert ast.unparse(player.voices[0].pw_prog[0].node) == "10 * φ + 1"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@pytest.mark.parametrize(
|
|
144
|
+
"hold, expected", [("freq", [0, 1, 2, 3, 4]), ("ctrl.freq", [-1, -1, -1, 0, 1])]
|
|
145
|
+
)
|
|
146
|
+
def test_a_control_row_s_modulator_holds_on_the_ctrl_cell(hold, expected):
|
|
147
|
+
"""A control row's frequency holds on ``ctrl.freq``'s events alone; its rows hold it."""
|
|
148
|
+
ins = "{ctrl: [{ctrl: 65, freq: F(N) + φ}, {note: +0}, {note: +0}, end]}"
|
|
149
|
+
text = doc(phrase="[C4.i1, -*3]", ins=ins, head=f"tempo: 4\nhold: {{{hold}: [row]}}\n")
|
|
150
|
+
assert [f - F(48) for f in freq(play(text, 5))] == expected
|
|
@@ -202,6 +202,32 @@ def test_a_note_less_event_splits_at_its_restart_lookahead(phrase, address, expe
|
|
|
202
202
|
assert reg(play(doc(phrase, LEAD % HR3)), address) == expected
|
|
203
203
|
|
|
204
204
|
|
|
205
|
+
FIRST_TICK = "[{hold: 2, gate: 0, ad: 15, sr: 0}, {ctrl: 9, gate: 1, ad: 9, sr: 240}, {}, end]"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@pytest.mark.parametrize(
|
|
209
|
+
"phrase, restart, address, expected",
|
|
210
|
+
[
|
|
211
|
+
("[C4.i1, D4, -*2]", FIRST_TICK, 5, [9] * 3 + [15] * 2 + [9] * 19),
|
|
212
|
+
("[C4.i1, D4!ad:10, -*2]", FIRST_TICK, 5, [9] * 3 + [15] * 2 + [10] * 19),
|
|
213
|
+
("[C4.i1, D4!sr:155, -*2]", FIRST_TICK, 6, [240] * 3 + [0] * 2 + [155] * 19),
|
|
214
|
+
("[C4.i1, D4!sr:155, -*2]", FIRST_TICK, 5, [9] * 3 + [15] * 2 + [9] * 19),
|
|
215
|
+
("[C4.i1, D4!ad:10!legato, -*2]", FIRST_TICK, 5, [9] * 6 + [10] * 18),
|
|
216
|
+
("[C4.i1!ad:10, -*3]", FIRST_TICK, 5, [10] * 24),
|
|
217
|
+
("[C4.i1, D4!ad:10, -*2]", HR, 5, [9] * 5 + [10] * 19),
|
|
218
|
+
("[C4.i1, D4!ad:10, -*2]", "[{ctrl: 9}, end]", 5, [9] * 6 + [10] * 18),
|
|
219
|
+
],
|
|
220
|
+
)
|
|
221
|
+
def test_a_notes_envelope_commands_apply_with_its_restarts_first_tick_row(
|
|
222
|
+
phrase, restart, address, expected
|
|
223
|
+
):
|
|
224
|
+
"""A note's !ad and !sr apply after its restart's last lookahead row, on that row's tick,
|
|
225
|
+
winning over the row's own ad and sr, and again on the note's tick after its instrument;
|
|
226
|
+
its other commands, a dropped row, a restart of one row and !legato leave them on its
|
|
227
|
+
tick."""
|
|
228
|
+
assert reg(play(doc(phrase, LEAD % restart)), address) == expected
|
|
229
|
+
|
|
230
|
+
|
|
205
231
|
def test_a_note_triggers_on_its_step_after_its_restart_lookahead():
|
|
206
232
|
"""The frequency and the instrument's envelope take the note on its step."""
|
|
207
233
|
frames = play(doc("[C4.i1, D4, -*2]", LEAD % HR))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|