unitracker-player 0.2.0__tar.gz → 0.4.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 (21) hide show
  1. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/PKG-INFO +2 -1
  2. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/README.md +1 -0
  3. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/pyproject.toml +1 -1
  4. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/src/unitracker_player/player.py +23 -20
  5. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/src/unitracker_player.egg-info/PKG-INFO +2 -1
  6. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/tests/test_player.py +37 -0
  7. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/LICENSE +0 -0
  8. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/setup.cfg +0 -0
  9. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/src/unitracker_player/__init__.py +0 -0
  10. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/src/unitracker_player/cli.py +0 -0
  11. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/src/unitracker_player/document.py +0 -0
  12. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/src/unitracker_player/expr.py +0 -0
  13. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/src/unitracker_player/modulation.py +0 -0
  14. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/src/unitracker_player.egg-info/SOURCES.txt +0 -0
  15. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/src/unitracker_player.egg-info/dependency_links.txt +0 -0
  16. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/src/unitracker_player.egg-info/entry_points.txt +0 -0
  17. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/src/unitracker_player.egg-info/requires.txt +0 -0
  18. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/src/unitracker_player.egg-info/top_level.txt +0 -0
  19. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/tests/test_cli.py +0 -0
  20. {unitracker_player-0.2.0 → unitracker_player-0.4.0}/tests/test_document.py +0 -0
  21. {unitracker_player-0.2.0 → unitracker_player-0.4.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.2.0
3
+ Version: 0.4.0
4
4
  Summary: Plays canonical unitracker documents to SID register state per tick
5
5
  Author: Josh Bailey
6
6
  License: Apache License
@@ -247,6 +247,7 @@ frames = play(text, ticks=3000) # a looping song needs ticks
247
247
  song = load(text) # parse and compile once; play(song, ...) reuses it
248
248
  player = Player(song)
249
249
  regs = player.step() # the 25 registers after one tick; None once the song ends
250
+ player.regs # the register file the last tick wrote, one store per register
250
251
  ```
251
252
 
252
253
  `read(text)` returns the document as plain dataclasses. A text outside the grammar raises
@@ -22,6 +22,7 @@ frames = play(text, ticks=3000) # a looping song needs ticks
22
22
  song = load(text) # parse and compile once; play(song, ...) reuses it
23
23
  player = Player(song)
24
24
  regs = player.step() # the 25 registers after one tick; None once the song ends
25
+ player.regs # the register file the last tick wrote, one store per register
25
26
  ```
26
27
 
27
28
  `read(text)` returns the document as plain dataclasses. A text outside the grammar raises
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unitracker-player"
7
- version = "0.2.0"
7
+ version = "0.4.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"
@@ -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
 
@@ -364,11 +365,13 @@ class _Voice:
364
365
 
365
366
  class Player:
366
367
  """Plays a Song one tick at a time; ``step()`` returns the registers at the end of the
367
- tick, or None once the song has ended."""
368
+ tick, or None once the song has ended. ``regs`` is the register file the last tick wrote,
369
+ each register by its own statement, so a line tracer sees one store per register."""
368
370
 
369
371
  def __init__(self, song: Song):
370
372
  self.song = song
371
373
  self.tick = 0
374
+ self.regs = [0] * REGISTERS
372
375
  self.voices = [_Voice() for _ in range(song.voices)]
373
376
  self.cut = self.res = self.route = self.mode = self.vol = 0
374
377
  self.cut_prog = self.cut_cmd = None
@@ -396,6 +399,10 @@ class Player:
396
399
  return [(t, (v, event, transpose, ins, None, None))]
397
400
  *ahead, (last, _) = ins.restart
398
401
  if isinstance(event.note, int):
402
+ if ahead:
403
+ wait, hold, ops = ahead[-1][0]
404
+ envelope = tuple(op for op in event.ops if op[0] in ENVELOPE)
405
+ ahead[-1] = ((wait, hold, ops + envelope), ahead[-1][1])
399
406
  for row, before in ahead:
400
407
  if t - before >= 0:
401
408
  self._lookahead[t - before].append((v, row))
@@ -436,26 +443,22 @@ class Player:
436
443
  return self.registers()
437
444
 
438
445
  def registers(self) -> list:
439
- """The 25 register values."""
440
- regs = [0] * REGISTERS
446
+ """The 25 register values, written to ``regs``."""
447
+ regs = self.regs
441
448
  for v, voice in enumerate(self.voices):
442
- ctrl = ((voice.ctrl ^ voice.mask) & 0xFE) | (voice.ctrl & voice.gate & 1)
443
- regs[7 * v : 7 * v + 7] = [
444
- voice.freq & 0xFF,
445
- voice.freq >> 8,
446
- voice.pw & 0xFF,
447
- voice.pw >> 8,
448
- ctrl,
449
- voice.ad,
450
- voice.sr,
451
- ]
452
- regs[21:25] = [
453
- self.cut & 7,
454
- self.cut >> 3,
455
- self.res << 4 | self.route,
456
- self.mode | self.vol,
457
- ]
458
- return regs
449
+ at = 7 * v
450
+ regs[at] = voice.freq & 0xFF
451
+ regs[at + 1] = voice.freq >> 8
452
+ regs[at + 2] = voice.pw & 0xFF
453
+ regs[at + 3] = voice.pw >> 8
454
+ regs[at + 4] = ((voice.ctrl ^ voice.mask) & 0xFE) | (voice.ctrl & voice.gate & 1)
455
+ regs[at + 5] = voice.ad
456
+ regs[at + 6] = voice.sr
457
+ regs[21] = self.cut & 7
458
+ regs[22] = self.cut >> 3
459
+ regs[23] = self.res << 4 | self.route
460
+ regs[24] = self.mode | self.vol
461
+ return list(regs)
459
462
 
460
463
  def _start(self, v: int, kind: int, prog: CProgram | None, t: int):
461
464
  running = prog is not None and bool(prog.rows)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitracker-player
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Plays canonical unitracker documents to SID register state per tick
5
5
  Author: Josh Bailey
6
6
  License: Apache License
@@ -247,6 +247,7 @@ frames = play(text, ticks=3000) # a looping song needs ticks
247
247
  song = load(text) # parse and compile once; play(song, ...) reuses it
248
248
  player = Player(song)
249
249
  regs = player.step() # the 25 registers after one tick; None once the song ends
250
+ player.regs # the register file the last tick wrote, one store per register
250
251
  ```
251
252
 
252
253
  `read(text)` returns the document as plain dataclasses. A text outside the grammar raises
@@ -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))
@@ -479,6 +505,17 @@ def test_the_player_steps_until_the_song_ends():
479
505
  assert regs[24] is None and player.step() is None
480
506
 
481
507
 
508
+ def test_regs_is_the_register_file_each_tick_rewrites():
509
+ """`Player.regs` holds the last tick's registers; each step returns a copy of it."""
510
+ player = Player(load(doc()))
511
+ first = player.step()
512
+ assert first == player.regs and first is not player.regs
513
+ player.voices[0].ad = 0x5A
514
+ after = player.registers()
515
+ assert [i for i, (x, y) in enumerate(zip(first, after)) if x != y] == [5]
516
+ assert after[5] == player.regs[5] == 0x5A and first[5] == 9
517
+
518
+
482
519
  def _twice(text: str) -> str:
483
520
  return text.replace(" ins:\n", " ins:\n i1: {ad: 1}\n", 1)
484
521