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.
Files changed (22) hide show
  1. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/PKG-INFO +2 -2
  2. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/pyproject.toml +2 -2
  3. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player/document.py +14 -1
  4. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player/expr.py +4 -2
  5. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player/player.py +107 -43
  6. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/PKG-INFO +2 -2
  7. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/SOURCES.txt +1 -0
  8. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/requires.txt +1 -1
  9. unitracker_player-0.5.0/tests/test_hold.py +150 -0
  10. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/LICENSE +0 -0
  11. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/README.md +0 -0
  12. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/setup.cfg +0 -0
  13. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player/__init__.py +0 -0
  14. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player/cli.py +0 -0
  15. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player/modulation.py +0 -0
  16. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/dependency_links.txt +0 -0
  17. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/entry_points.txt +0 -0
  18. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/top_level.txt +0 -0
  19. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/tests/test_cli.py +0 -0
  20. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/tests/test_document.py +0 -0
  21. {unitracker_player-0.4.0 → unitracker_player-0.5.0}/tests/test_modulation.py +0 -0
  22. {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.4.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.3.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.4.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.3.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
- return _compiled(node, namespace, zero=True)
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 events whose steps begin by tick ``limit``, as ``(tick, voice, event,
318
- transpose)``."""
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
- continue
336
- event = self.phrase[v][self.pos[v]]
337
- self.pos[v] += 1
338
- self.rest[v] = event.steps - 1
339
- out.append((t, v, event, self.transpose[v]))
340
- self._retime(v, event)
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
- for tick, v, event, transpose in self._seq.advance(t + self.song.lookahead):
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
- def _apply(self, v: int, ops: tuple, t: int, first: bool): # pylint: disable=too-many-branches
499
- """A row's keys, in vocabulary order; ``first`` marks a restart's first-tick row."""
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, tick = voice.osc
685
+ fn, bag = voice.osc[0], voice.osc[1]
633
686
  base = (base + bag.get("detune", 0) * vix) & FREQ_MASK
634
- voice.freq = fn(bag, voice.freq, tick, base) & FREQ_MASK
635
- voice.osc[2] += 1
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, n = voice.freq_cmd or voice.freq_prog, voice.sounding
638
- if mod:
639
- voice.freq = mod[0](voice.freq, mod[1], n or 0) & FREQ_MASK
640
- mod[1] += 1
641
- elif n is not None:
642
- voice.freq = self.song.table(n)
643
- for voice in self.voices:
644
- mod = voice.pw_cmd or voice.pw_prog
645
- if mod:
646
- voice.pw = mod[0](voice.pw, mod[1], voice.sounding or 0) & PW_MASK
647
- mod[1] += 1
648
- mod = self.cut_cmd or self.cut_prog
649
- if mod:
650
- n = self.voices[mod[2]].sounding
651
- self.cut = mod[0](self.cut, mod[1], n or 0) & CUT_MASK
652
- mod[1] += 1
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.4.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.3.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
@@ -15,5 +15,6 @@ src/unitracker_player.egg-info/requires.txt
15
15
  src/unitracker_player.egg-info/top_level.txt
16
16
  tests/test_cli.py
17
17
  tests/test_document.py
18
+ tests/test_hold.py
18
19
  tests/test_modulation.py
19
20
  tests/test_player.py
@@ -1,4 +1,4 @@
1
- unitracker-grammar>=0.3.0
1
+ unitracker-grammar>=0.4.0
2
2
  lark>=1.2
3
3
  numpy>=1.26
4
4
  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