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.
Files changed (22) hide show
  1. {unitracker_player-0.3.0 → unitracker_player-0.5.0}/PKG-INFO +2 -2
  2. {unitracker_player-0.3.0 → unitracker_player-0.5.0}/pyproject.toml +2 -2
  3. {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player/document.py +14 -1
  4. {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player/expr.py +4 -2
  5. {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player/player.py +112 -43
  6. {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/PKG-INFO +2 -2
  7. {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/SOURCES.txt +1 -0
  8. {unitracker_player-0.3.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.3.0 → unitracker_player-0.5.0}/tests/test_player.py +26 -0
  11. {unitracker_player-0.3.0 → unitracker_player-0.5.0}/LICENSE +0 -0
  12. {unitracker_player-0.3.0 → unitracker_player-0.5.0}/README.md +0 -0
  13. {unitracker_player-0.3.0 → unitracker_player-0.5.0}/setup.cfg +0 -0
  14. {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player/__init__.py +0 -0
  15. {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player/cli.py +0 -0
  16. {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player/modulation.py +0 -0
  17. {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/dependency_links.txt +0 -0
  18. {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/entry_points.txt +0 -0
  19. {unitracker_player-0.3.0 → unitracker_player-0.5.0}/src/unitracker_player.egg-info/top_level.txt +0 -0
  20. {unitracker_player-0.3.0 → unitracker_player-0.5.0}/tests/test_cli.py +0 -0
  21. {unitracker_player-0.3.0 → unitracker_player-0.5.0}/tests/test_document.py +0 -0
  22. {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.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.3.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:
@@ -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 events whose steps begin by tick ``limit``, as ``(tick, voice, event,
317
- 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."""
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
- 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)
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
- for tick, v, event, transpose in self._seq.advance(t + self.song.lookahead):
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
- def _apply(self, v: int, ops: tuple, t: int, first: bool): # pylint: disable=too-many-branches
494
- """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 ""
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, tick = voice.osc
685
+ fn, bag = voice.osc[0], voice.osc[1]
628
686
  base = (base + bag.get("detune", 0) * vix) & FREQ_MASK
629
- voice.freq = fn(bag, voice.freq, tick, base) & FREQ_MASK
630
- 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
631
689
  continue
632
- mod, n = voice.freq_cmd or voice.freq_prog, voice.sounding
633
- if mod:
634
- voice.freq = mod[0](voice.freq, mod[1], n or 0) & FREQ_MASK
635
- mod[1] += 1
636
- elif n is not None:
637
- voice.freq = self.song.table(n)
638
- for voice in self.voices:
639
- mod = voice.pw_cmd or voice.pw_prog
640
- if mod:
641
- voice.pw = mod[0](voice.pw, mod[1], voice.sounding or 0) & PW_MASK
642
- mod[1] += 1
643
- mod = self.cut_cmd or self.cut_prog
644
- if mod:
645
- n = self.voices[mod[2]].sounding
646
- self.cut = mod[0](self.cut, mod[1], n or 0) & CUT_MASK
647
- 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]
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.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
@@ -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))