unitracker-player 0.4.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.4.0 → unitracker_player-0.5.0}/PKG-INFO +2 -2
- {unitracker_player-0.4.0 → unitracker_player-0.5.0}/pyproject.toml +2 -2
- {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player/document.py +14 -1
- {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player/expr.py +4 -2
- {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player/player.py +107 -43
- {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/PKG-INFO +2 -2
- {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/SOURCES.txt +1 -0
- {unitracker_player-0.4.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.4.0 → unitracker_player-0.5.0}/LICENSE +0 -0
- {unitracker_player-0.4.0 → unitracker_player-0.5.0}/README.md +0 -0
- {unitracker_player-0.4.0 → unitracker_player-0.5.0}/setup.cfg +0 -0
- {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player/__init__.py +0 -0
- {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player/cli.py +0 -0
- {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player/modulation.py +0 -0
- {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/dependency_links.txt +0 -0
- {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/entry_points.txt +0 -0
- {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/top_level.txt +0 -0
- {unitracker_player-0.4.0 → unitracker_player-0.5.0}/tests/test_cli.py +0 -0
- {unitracker_player-0.4.0 → unitracker_player-0.5.0}/tests/test_document.py +0 -0
- {unitracker_player-0.4.0 → unitracker_player-0.5.0}/tests/test_modulation.py +0 -0
- {unitracker_player-0.4.0 → unitracker_player-0.5.0}/tests/test_player.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:
|
|
@@ -78,6 +78,8 @@ class Song:
|
|
|
78
78
|
loop: int | None
|
|
79
79
|
lookahead: int
|
|
80
80
|
table: object
|
|
81
|
+
hold: dict
|
|
82
|
+
horizon: int
|
|
81
83
|
|
|
82
84
|
|
|
83
85
|
def _tempo(value, where: str) -> tuple:
|
|
@@ -257,6 +259,8 @@ def compile_document(doc: Document) -> Song:
|
|
|
257
259
|
raise PlayError(f"song: loop row {doc.loop} out of range")
|
|
258
260
|
instruments = [v for k, v in comp.memo.items() if k[0] == "ins"]
|
|
259
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)
|
|
260
264
|
return Song(
|
|
261
265
|
head.voices,
|
|
262
266
|
_tempo(head.tempo, "tempo"),
|
|
@@ -266,6 +270,8 @@ def compile_document(doc: Document) -> Song:
|
|
|
266
270
|
doc.loop,
|
|
267
271
|
lookahead,
|
|
268
272
|
table,
|
|
273
|
+
hold,
|
|
274
|
+
lookahead + earliest,
|
|
269
275
|
)
|
|
270
276
|
|
|
271
277
|
|
|
@@ -314,8 +320,8 @@ class _Sequencer:
|
|
|
314
320
|
self.end = t
|
|
315
321
|
|
|
316
322
|
def advance(self, limit: int) -> list:
|
|
317
|
-
"""The
|
|
318
|
-
|
|
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."""
|
|
319
325
|
out = []
|
|
320
326
|
while self.end is None:
|
|
321
327
|
live = [v for v in range(self.voices) if not self.stopped[v]]
|
|
@@ -330,14 +336,16 @@ class _Sequencer:
|
|
|
330
336
|
break
|
|
331
337
|
due = [v for v in due if not self.stopped[v]]
|
|
332
338
|
for v in due:
|
|
339
|
+
event = None
|
|
333
340
|
if self.rest[v]:
|
|
334
341
|
self.rest[v] -= 1
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
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))
|
|
341
349
|
for v in due:
|
|
342
350
|
self.next[v] = t + self.tempo[v][self.phase[v]]
|
|
343
351
|
self.phase[v] ^= 1
|
|
@@ -363,7 +371,7 @@ class _Voice:
|
|
|
363
371
|
return None if self.note is None else self.note + self.relative
|
|
364
372
|
|
|
365
373
|
|
|
366
|
-
class Player:
|
|
374
|
+
class Player: # pylint: disable=too-many-instance-attributes
|
|
367
375
|
"""Plays a Song one tick at a time; ``step()`` returns the registers at the end of the
|
|
368
376
|
tick, or None once the song has ended. ``regs`` is the register file the last tick wrote,
|
|
369
377
|
each register by its own statement, so a line tracer sees one store per register."""
|
|
@@ -379,6 +387,8 @@ class Player:
|
|
|
379
387
|
self._ins = [None] * song.voices
|
|
380
388
|
self._events = defaultdict(list)
|
|
381
389
|
self._lookahead = defaultdict(list)
|
|
390
|
+
self._held = defaultdict(set)
|
|
391
|
+
self._noted = [None] * song.voices
|
|
382
392
|
self._init = [i for v, e in enumerate(song.init) for _, i in self._schedule(0, v, e, 0)]
|
|
383
393
|
|
|
384
394
|
@property
|
|
@@ -416,12 +426,35 @@ class Player:
|
|
|
416
426
|
if has
|
|
417
427
|
]
|
|
418
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
|
+
|
|
419
454
|
def step(self) -> list | None:
|
|
420
455
|
"""Plays one tick."""
|
|
421
456
|
t = self.tick
|
|
422
|
-
|
|
423
|
-
for at, item in self._schedule(tick, v, event, transpose):
|
|
424
|
-
self._events[at].append(item)
|
|
457
|
+
self._advance(t)
|
|
425
458
|
if self.finished:
|
|
426
459
|
return None
|
|
427
460
|
if t == 0:
|
|
@@ -481,7 +514,9 @@ class Player:
|
|
|
481
514
|
raise PlayError(f"voice {v + 1}: a program loops in no time at tick {t}")
|
|
482
515
|
seen.add((id(prog), index))
|
|
483
516
|
_, hold, ops = prog.rows[index]
|
|
484
|
-
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)
|
|
485
520
|
if slots[kind] is not slot or slot[0] is None:
|
|
486
521
|
continue
|
|
487
522
|
index += 1
|
|
@@ -495,8 +530,11 @@ class Player:
|
|
|
495
530
|
def _row(self, v: int, row: tuple, t: int, first: bool = False):
|
|
496
531
|
self._apply(v, row[2], t, first)
|
|
497
532
|
|
|
498
|
-
|
|
499
|
-
|
|
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 ""
|
|
500
538
|
voice = self.voices[v]
|
|
501
539
|
for key, value in ops:
|
|
502
540
|
if key == "ctrl":
|
|
@@ -520,11 +558,11 @@ class Player:
|
|
|
520
558
|
if value is None:
|
|
521
559
|
self._stop_pulse(v)
|
|
522
560
|
else:
|
|
523
|
-
voice.pw_prog = [value, 0]
|
|
561
|
+
voice.pw_prog = [value, 0, held + "pw", None, None]
|
|
524
562
|
elif key == "cut":
|
|
525
|
-
self.cut_prog = [value, 0, v]
|
|
563
|
+
self.cut_prog = [value, 0, v, held + "cut", None, None]
|
|
526
564
|
elif key == "freq":
|
|
527
|
-
voice.freq_prog = [value, 0]
|
|
565
|
+
voice.freq_prog = [value, 0, held + "freq", self._before(v, t), None]
|
|
528
566
|
elif key == "run":
|
|
529
567
|
self._start(v, value[0], value[1], t)
|
|
530
568
|
elif key == "filter":
|
|
@@ -559,6 +597,7 @@ class Player:
|
|
|
559
597
|
voice.note = event.note + transpose
|
|
560
598
|
if not event.legato:
|
|
561
599
|
voice.relative, voice.absolute, voice.gate = 0, None, 1
|
|
600
|
+
self._noted[v] = t
|
|
562
601
|
if ins is not None:
|
|
563
602
|
self._instrument(v, ins, event, t)
|
|
564
603
|
elif event.note is not None and part != "ops":
|
|
@@ -578,7 +617,7 @@ class Player:
|
|
|
578
617
|
self._start(v, PULSE, ins.pw, t)
|
|
579
618
|
if ins.filter is not None and not event.keep_filter:
|
|
580
619
|
self._start(v, FILTER, ins.filter, t)
|
|
581
|
-
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]
|
|
582
621
|
self._arm_osc(voice, ins.fn, ins.p0)
|
|
583
622
|
voice.freq_cmd = voice.pw_cmd = None
|
|
584
623
|
if self.cut_cmd and self.cut_cmd[2] == v:
|
|
@@ -592,7 +631,7 @@ class Player:
|
|
|
592
631
|
else:
|
|
593
632
|
self._start(v, value[0], value[1], t)
|
|
594
633
|
elif key == "pw":
|
|
595
|
-
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]
|
|
596
635
|
if value is None:
|
|
597
636
|
self._stop_pulse(v)
|
|
598
637
|
elif key == "filter":
|
|
@@ -606,9 +645,9 @@ class Player:
|
|
|
606
645
|
elif key == "resfilt":
|
|
607
646
|
self.res, self.route = value[0] & 15, value[1] & 15
|
|
608
647
|
elif key == "cut":
|
|
609
|
-
self.cut_cmd = [value, 0, v]
|
|
648
|
+
self.cut_cmd = [value, 0, v, "cut", None, None]
|
|
610
649
|
elif key == "freq":
|
|
611
|
-
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]
|
|
612
651
|
if value is not None:
|
|
613
652
|
voice.osc = None
|
|
614
653
|
elif key == "set":
|
|
@@ -622,34 +661,59 @@ class Player:
|
|
|
622
661
|
if fn is None:
|
|
623
662
|
voice.osc = None
|
|
624
663
|
return
|
|
625
|
-
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
|
|
626
678
|
|
|
627
679
|
def _fire(self):
|
|
680
|
+
t = self.tick
|
|
628
681
|
for vix, voice in enumerate(self.voices):
|
|
682
|
+
n = voice.sounding
|
|
629
683
|
if voice.osc is not None:
|
|
630
|
-
n = voice.sounding
|
|
631
684
|
base = self.song.table(n) if n is not None else 0
|
|
632
|
-
fn, bag
|
|
685
|
+
fn, bag = voice.osc[0], voice.osc[1]
|
|
633
686
|
base = (base + bag.get("detune", 0) * vix) & FREQ_MASK
|
|
634
|
-
voice.
|
|
635
|
-
|
|
687
|
+
if at := _firing(voice.osc, 2, self._holds(vix, "freq", t), voice.freq):
|
|
688
|
+
voice.freq = fn(bag, *at, base) & FREQ_MASK
|
|
636
689
|
continue
|
|
637
|
-
mod,
|
|
638
|
-
if mod:
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
elif
|
|
642
|
-
voice.freq =
|
|
643
|
-
for voice in self.voices:
|
|
644
|
-
mod = voice.pw_cmd
|
|
645
|
-
if mod:
|
|
646
|
-
voice.pw = mod[0](
|
|
647
|
-
|
|
648
|
-
mod
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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]
|
|
653
717
|
|
|
654
718
|
|
|
655
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{unitracker_player-0.4.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
|
|
File without changes
|