unitracker-player 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,6 @@
1
+ """The generic player of canonical unitracker documents: SID register state per tick."""
2
+
3
+ from unitracker_player.document import Document, read
4
+ from unitracker_player.player import Player, PlayError, Song, compile_document, load, play
5
+
6
+ __all__ = ["Document", "PlayError", "Player", "Song", "compile_document", "load", "play", "read"]
@@ -0,0 +1,37 @@
1
+ """``unitracker-play FILE [--ticks N] [--out frames.npz|frames.csv]``."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+
9
+ from unitracker_player.player import REGISTERS, load, play
10
+
11
+ HEADER = ",".join(f"d4{r:02x}" for r in range(REGISTERS))
12
+
13
+
14
+ def write(frames: np.ndarray, out) -> None:
15
+ """``frames`` as an ``.npz`` (array ``frames``) or as CSV, one row per tick, to ``out``: a
16
+ path or a text stream."""
17
+ if isinstance(out, Path) and out.suffix == ".npz":
18
+ np.savez_compressed(out, frames=frames)
19
+ else:
20
+ np.savetxt(out, frames, fmt="%d", delimiter=",", header=HEADER, comments="")
21
+
22
+
23
+ def main(argv=None) -> int:
24
+ """Plays FILE and writes its registers per tick to ``--out``, or as CSV to stdout."""
25
+ args = argparse.ArgumentParser(prog="unitracker-play", description=main.__doc__)
26
+ args.add_argument("file", type=Path)
27
+ args.add_argument("--ticks", type=int, help="ticks to play; required for a looping song")
28
+ args.add_argument("--out", type=Path, help="frames.npz or frames.csv")
29
+ opts = args.parse_args(argv)
30
+ if opts.out is not None and opts.out.suffix not in (".npz", ".csv"):
31
+ args.error(f"--out {opts.out}: write .npz or .csv")
32
+ try:
33
+ frames = play(load(opts.file.read_text(encoding="utf-8")), opts.ticks, progress=True)
34
+ except ValueError as exc:
35
+ args.error(f"{opts.file}: {exc}")
36
+ write(frames, opts.out if opts.out is not None else sys.stdout)
37
+ return 0
@@ -0,0 +1,447 @@
1
+ """A canonical document as plain dataclasses, transformed from its parse tree."""
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass, field
6
+
7
+ import ast
8
+
9
+ from lark import Token, exceptions
10
+ from unitracker_grammar import CanonicalError, parser
11
+ from unitracker_grammar.expr import Nodes
12
+
13
+ NONE = None
14
+ _MISSING = object()
15
+ _VALUES = {
16
+ "INT",
17
+ "SIGNED",
18
+ "NOTE",
19
+ "NOTE_ATOM",
20
+ "INS",
21
+ "CTRL",
22
+ "PW",
23
+ "FILTER",
24
+ "RESTART",
25
+ "X",
26
+ "PHRASE",
27
+ "XTABLE",
28
+ "META_KEY",
29
+ "TEXT",
30
+ "SQUOTED",
31
+ "DQUOTED",
32
+ "VERSION",
33
+ "CTRLMASK",
34
+ "NONE",
35
+ "NAME",
36
+ }
37
+ _PITCH = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
38
+ _NOTE = re.compile(r"([A-G])(#?)(-?\d+)")
39
+ _KINDS = {"restart", "ins", "ctrl", "pw", "filter", "x", "phrase"}
40
+
41
+
42
+ def note_number(name: str) -> int:
43
+ """The format note of a note name: ``12*octave + pitch class`` (C2 is 24, B-1 is -1)."""
44
+ letter, sharp, octave = _NOTE.fullmatch(name).groups()
45
+ return 12 * int(octave) + _PITCH[letter] + (1 if sharp else 0)
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Expr:
50
+ """A modulator expression as its node (:mod:`unitracker_grammar.expr`)."""
51
+
52
+ node: ast.expr
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Row:
57
+ """A program row: its timing and its other keys in vocabulary order."""
58
+
59
+ wait: int = 0
60
+ hold: int = 1
61
+ keys: tuple = ()
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Program:
66
+ """Rows in the order they run, then ``end`` (``loop`` None) or ``{loop: k}``."""
67
+
68
+ rows: tuple
69
+ loop: int | None = None
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Instrument:
74
+ """An instrument's fields; a program field holds a name or a program, ``freq`` an Expr."""
75
+
76
+ ad: int | None = None
77
+ sr: int | None = None
78
+ ctrl: str | Program | None = None
79
+ pw: str | Program | None = None
80
+ filter: str | Program | None = None
81
+ freq: Expr | None = None
82
+ fn: object | None = None
83
+ p0: dict | None = None
84
+ restart: str | Program | None = None
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Event:
89
+ """An event atom: its note atom, instrument name, commands in vocabulary order, keep flags
90
+ and steps."""
91
+
92
+ note: str
93
+ ins: str | None = None
94
+ commands: tuple = ()
95
+ keep_pw: bool = False
96
+ keep_filter: bool = False
97
+ steps: int = 1
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class Play:
102
+ """A play entry: ``phrase`` (a name and a transpose), ``cont`` (``~``) or ``end``."""
103
+
104
+ kind: str
105
+ phrase: str | None = None
106
+ transpose: int = 0
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class Header:
111
+ """The header lines, with their defaults."""
112
+
113
+ version: str
114
+ meta: dict = field(default_factory=dict)
115
+ cycles_per_tick: int | None = None
116
+ clock_hz: int | None = None
117
+ voices: int = 3
118
+ tempo: tuple = (6, 6)
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class Document:
123
+ """A whole document: the header, its tables and init row, every definition by name and the
124
+ timeline's rows of play entries with its ending (``loop`` None for ``end``)."""
125
+
126
+ header: Header
127
+ tables: dict
128
+ init: tuple | None
129
+ instruments: dict
130
+ programs: dict
131
+ expressions: dict
132
+ phrases: dict
133
+ timeline: tuple
134
+ loop: int | None
135
+
136
+
137
+ def _tok(token: Token):
138
+ if token.type in ("INT", "SIGNED"):
139
+ return int(token)
140
+ return NONE if token.type == "NONE" else str(token)
141
+
142
+
143
+ def _values(children) -> list:
144
+ """The children that carry meaning: subtrees' results and value tokens."""
145
+ return [
146
+ _tok(c) if isinstance(c, Token) else c
147
+ for c in children
148
+ if not isinstance(c, Token) or c.type in _VALUES
149
+ ]
150
+
151
+
152
+ def _words(children) -> list[str]:
153
+ """The keyword tokens among ``children``, stripped."""
154
+ return [c.strip() for c in children if isinstance(c, Token) and c.type not in _VALUES]
155
+
156
+
157
+ def _link(key):
158
+ """A link of a key chain: its own key's value (one value or a tuple) before the chain's
159
+ rest."""
160
+
161
+ def method(_self, children):
162
+ rest, own = {}, []
163
+ for c in _values(children):
164
+ if isinstance(c, dict):
165
+ rest = c
166
+ else:
167
+ own.append(c)
168
+ if not own:
169
+ return rest
170
+ return {key: own[0] if len(own) == 1 else tuple(own), **rest}
171
+
172
+ return method
173
+
174
+
175
+ def _chain(prefix, keys):
176
+ return {f"{prefix}_{k}": _link(k) for k in keys}
177
+
178
+
179
+ class ToDocument(Nodes):
180
+ """The parse tree of ``canonical.lark`` to a Document; each method is the grammar rule of
181
+ its name, and an expression's rules are :class:`Nodes`'."""
182
+
183
+ # pylint: disable=too-many-public-methods,missing-function-docstring
184
+
185
+ def start(self, children):
186
+ head, defs, timeline = {"tables": {}, "init": None}, {k: {} for k in _KINDS}, []
187
+ for item in _values(children):
188
+ tag = item[0]
189
+ if isinstance(item, str):
190
+ head["version"] = item
191
+ elif tag == "row":
192
+ for kind, entries in item[1]:
193
+ for name, value in entries:
194
+ if name in defs[kind]:
195
+ raise CanonicalError(f"{name} is defined twice")
196
+ defs[kind][name] = value
197
+ timeline.append(item[2])
198
+ else:
199
+ head[tag] = item[1]
200
+ clock = head.pop("clock", (None, None))
201
+ header = Header(
202
+ head.pop("version"),
203
+ head.pop("meta"),
204
+ *clock,
205
+ head.pop("voices", 3),
206
+ head.pop("tempo", (6, 6)),
207
+ )
208
+ programs = {**defs["restart"], **defs["ctrl"], **defs["pw"], **defs["filter"]}
209
+ return Document(
210
+ header,
211
+ head["tables"],
212
+ head["init"],
213
+ defs["ins"],
214
+ programs,
215
+ defs["x"],
216
+ defs["phrase"],
217
+ tuple(timeline),
218
+ head["ending"],
219
+ )
220
+
221
+ @staticmethod
222
+ def meta(children):
223
+ return ("meta", dict(_values(children)))
224
+
225
+ @staticmethod
226
+ def meta_item(children):
227
+ return tuple(_values(children))
228
+
229
+ @staticmethod
230
+ def text(children):
231
+ (token,) = children
232
+ if token.type == "SQUOTED":
233
+ return token[1:-1].replace("''", "'")
234
+ if token.type == "DQUOTED":
235
+ return json.loads(token)
236
+ return str(token)
237
+
238
+ @staticmethod
239
+ def clock(children):
240
+ return ("clock", tuple(_values(children)))
241
+
242
+ @staticmethod
243
+ def voices(children):
244
+ return ("voices", _values(children)[0])
245
+
246
+ @staticmethod
247
+ def tempo(children):
248
+ values = _values(children)
249
+ return ("tempo", (values[0], values[-1]))
250
+
251
+ @staticmethod
252
+ def tables(children):
253
+ values = _values(children)
254
+ return ("tables", dict(zip(values[::2], values[1::2])))
255
+
256
+ @staticmethod
257
+ def intlist(children):
258
+ return tuple(_values(children))
259
+
260
+ @staticmethod
261
+ def init(children):
262
+ return ("init", tuple(_values(children)))
263
+
264
+ @staticmethod
265
+ def row(children):
266
+ values = _values(children)
267
+ return ("row", values[:-1], values[-1])
268
+
269
+ @staticmethod
270
+ def ending(children):
271
+ values = _values(children)
272
+ return ("ending", values[0] if values else None)
273
+
274
+ @staticmethod
275
+ def plays(children):
276
+ return tuple(_values(children))
277
+
278
+ @staticmethod
279
+ def play(children):
280
+ values = _values(children)
281
+ if values:
282
+ return Play("phrase", values[0], values[1] if len(values) > 1 else 0)
283
+ return Play("cont" if _words(children) == ["~"] else "end")
284
+
285
+ @staticmethod
286
+ def section(children):
287
+ kind = children[0].type.lower()
288
+ values = _values(children)
289
+ entries = list(zip(values[::2], values[1::2]))
290
+ if kind == "x":
291
+ entries = [(n, Expr(v)) for n, v in entries]
292
+ elif kind in ("pw", "filter"):
293
+ entries = [(n, _modulation(kind, v)) for n, v in entries]
294
+ return (kind, entries)
295
+
296
+ @staticmethod
297
+ def instrument(children):
298
+ values = dict(_values(children)[0])
299
+ for kind in ("pw", "filter"):
300
+ if kind in values:
301
+ values[kind] = _modulation(kind, values[kind])
302
+ return Instrument(**values)
303
+
304
+ @staticmethod
305
+ def program(children):
306
+ values = _values(children)
307
+ if values and isinstance(values[-1], int):
308
+ return Program(tuple(values[:-1]), values[-1])
309
+ return Program(tuple(values))
310
+
311
+ @staticmethod
312
+ def modulation(children):
313
+ return _values(children)[0]
314
+
315
+ @staticmethod
316
+ def ins_fn(children):
317
+ """``mod: NAME`` and its parameter bag as ``{fn: NAME, p0: the bag}``, the instrument's
318
+ ``restart`` where it has no modulator, and either where the two share an instrument."""
319
+ values = _values(children)
320
+ name = next((v for v in values if not isinstance(v, dict)), None)
321
+ if name is None:
322
+ return next((c for c in values if isinstance(c, dict)), {})
323
+ bag = next((c for c in values if isinstance(c, dict) and "restart" not in c), {})
324
+ rest = next((c for c in values if isinstance(c, dict) and "restart" in c), {})
325
+ return {"fn": name, "p0": bag, **rest}
326
+
327
+ @staticmethod
328
+ def param_bag(children):
329
+ return dict(_values(children))
330
+
331
+ @staticmethod
332
+ def pname_val(children):
333
+ name, value = _values(children)
334
+ return str(name), value
335
+
336
+ @staticmethod
337
+ def pname_list(children):
338
+ return list(_values(children))
339
+
340
+ @staticmethod
341
+ def cmd_set(children):
342
+ """The event's ``!set`` writes, ``{key: value}``, into a one ``set`` command."""
343
+ flat = [c for c in _values(children) if not isinstance(c, dict)]
344
+ rest = next((c for c in children if isinstance(c, dict)), {})
345
+ bag = dict(zip(flat[::2], flat[1::2]))
346
+ return {"set": bag, **rest} if bag else rest
347
+
348
+ @staticmethod
349
+ def row_(children):
350
+ keys = dict(*_values(children))
351
+ return Row(keys.pop("wait", 0), keys.pop("hold", 1), tuple(keys.items()))
352
+
353
+ @staticmethod
354
+ def ctrl_value(children):
355
+ (value,) = _values(children)
356
+ if isinstance(value, int):
357
+ return (value, None)
358
+ ctrl, mask = value.split("^")
359
+ return (int(ctrl) if ctrl else None, int(mask))
360
+
361
+ @staticmethod
362
+ def note_value(children):
363
+ (value,) = _values(children)
364
+ return ("rel", value) if isinstance(value, int) else ("abs", note_number(value))
365
+
366
+ @staticmethod
367
+ def pw_value(children):
368
+ values = _values(children)
369
+ return values[0] if values else NONE
370
+
371
+ @staticmethod
372
+ def route_value(children):
373
+ values = _values(children)
374
+ mask = values[0] if values else 0
375
+ words = "".join(_words(children))
376
+ if words.startswith("+"):
377
+ return ("add", mask)
378
+ return ("remove", mask) if "-" in words else ("mask", mask)
379
+
380
+ @staticmethod
381
+ def program_name(children):
382
+ return _values(children)[0]
383
+
384
+ @staticmethod
385
+ def phrase(children):
386
+ return tuple(_values(children))
387
+
388
+ @staticmethod
389
+ def event(children):
390
+ values = _values(children)
391
+ note, rest = values[0], values[1:]
392
+ ins = rest.pop(0) if rest and isinstance(rest[0], str) else None
393
+ commands, keep_pw, keep_filter = rest[0], rest[1], rest[2]
394
+ steps = rest[3] if len(rest) > 3 else 1
395
+ return Event(note, ins, tuple(commands.items()), keep_pw, keep_filter, steps)
396
+
397
+ @staticmethod
398
+ def cmd_filter(children):
399
+ rest = next((c for c in children if isinstance(c, dict)), {})
400
+ return {"filter": NONE, **rest} if _words(children) else rest
401
+
402
+ @staticmethod
403
+ def cmd_legato(children):
404
+ return {"legato": True} if children else {}
405
+
406
+ @staticmethod
407
+ def keep_pw(children):
408
+ return bool(children)
409
+
410
+ @staticmethod
411
+ def keep_filter(children):
412
+ return bool(children)
413
+
414
+ def fexpr(self, children):
415
+ return Expr(super().fexpr(children))
416
+
417
+
418
+ for _name, _method in {
419
+ **_chain(
420
+ "row", "wait hold ctrl gate note ad sr pw cut res route mode freq run filter vol".split()
421
+ ),
422
+ **_chain("ins", "ad sr ctrl pw filter freq restart".split()),
423
+ **_chain("cmd", "run pw ad sr ctrl resfilt mode vol cut tempo vtempo freq".split()),
424
+ }.items():
425
+ setattr(ToDocument, _name, _method)
426
+
427
+
428
+ def _modulation(kind: str, value):
429
+ """A pulse or filter modulation as a program: an expression is a one-row program setting
430
+ the slot's cell."""
431
+ if isinstance(value, Expr):
432
+ return Program((Row(keys=(("pw" if kind == "pw" else "cut", value),)),))
433
+ return value
434
+
435
+
436
+ def read(text: str) -> Document:
437
+ """The Document of canonical document ``text``."""
438
+ try:
439
+ tree = parser().parse(text)
440
+ except exceptions.UnexpectedInput as exc:
441
+ raise CanonicalError(
442
+ f"line {exc.line} column {exc.column}: {str(exc).splitlines()[0]}"
443
+ ) from exc
444
+ try:
445
+ return ToDocument().transform(tree)
446
+ except exceptions.VisitError as exc:
447
+ raise exc.orig_exc from exc
@@ -0,0 +1,34 @@
1
+ """Modulator expressions compiled once to Python callables of ``(v, phi, n)`` by
2
+ :mod:`unitracker_grammar.expr`, ``//`` and ``%`` 0 by a divisor of 0."""
3
+
4
+ import ast
5
+ import functools
6
+ from collections.abc import Callable
7
+
8
+ from unitracker_grammar import frequency
9
+ from unitracker_grammar.expr import compile_expr as _compiled
10
+ from unitracker_grammar.expr import indexed, tree
11
+
12
+ Fn = Callable[[int, int, int], int]
13
+
14
+
15
+ def note_table(clock_hz: int) -> Callable[[int], int]:
16
+ """``F(k)`` at ``clock_hz``, cached."""
17
+ return functools.lru_cache(maxsize=None)(functools.partial(frequency, clock_hz=clock_hz))
18
+
19
+
20
+ def constant(node: ast.expr) -> int | None:
21
+ """The integer an expression is, when it is an integer literal."""
22
+ return node.value if isinstance(node, ast.Constant) else None
23
+
24
+
25
+ def compile_expr(node: ast.expr, table: Callable[[int], int], tables: dict) -> Fn:
26
+ """``node`` as a callable of ``(v, phi, n)``, reading ``F`` from ``table`` and ``T[k]`` from
27
+ ``tables``."""
28
+ namespace = {"_F": table, "_T": {name: indexed(values) for name, values in tables.items()}}
29
+ return _compiled(node, namespace, zero=True)
30
+
31
+
32
+ def offset(n: int) -> ast.expr:
33
+ """``F(N) + n``: the expression of an integer ``freq``."""
34
+ return tree(f"F(N) + {n}")
@@ -0,0 +1,110 @@
1
+ """The native frequency oscillations as one arithmetic function each over a parameter bag.
2
+
3
+ A shape is ``f(bag, v, t, note)``: the bag (the instrument's ``p0``, merged with the
4
+ event's ``!set`` writes), the voice's current word ``v``, the tick ``t`` since its arm, and
5
+ the note's base word ``note``; each is one closed form the player re-evaluates each tick.
6
+ """
7
+
8
+ F16 = 0xFFFF
9
+
10
+ # pylint: disable=unused-argument
11
+
12
+
13
+ def _steps(bag, t, at_delay=False):
14
+ """The steps applied by tick ``t``: the ticks past the ``delay`` (or from it, when the shape
15
+ steps on the tick the delay elapses), none on a ``skip``."""
16
+ delay = bag.get("delay", 0)
17
+ first = delay if at_delay else delay + 1
18
+ if t < first:
19
+ return 0
20
+ held = len([s for s in (bag.get("skip") or ()) if first <= s <= t])
21
+ return t - first + 1 - held
22
+
23
+
24
+ def set_(bag, v, t, note=0):
25
+ """The note plus the bag's offset, held."""
26
+ return (note + bag.get("offset", 0)) & bag.get("mask", F16)
27
+
28
+
29
+ def ramp(bag, v, t, note=0):
30
+ """The note plus offset, stepping ``step`` per step, clamped to ``max`` when it climbs,
31
+ back at the note once ``ticks`` has run, wrapped to the ``mask``."""
32
+ q = _steps(bag, t)
33
+ ticks = bag.get("ticks")
34
+ if ticks is not None and q >= ticks:
35
+ q = 0
36
+ step = bag.get("step", 0)
37
+ out = q * step
38
+ hi = bag.get("max")
39
+ if hi is not None and step >= 0:
40
+ out = min(out, hi)
41
+ return (note + bag.get("offset", 0) + out) & bag.get("mask", F16)
42
+
43
+
44
+ def slew_to_target(bag, v, t, note=0):
45
+ """The note plus offset, walking ``target`` over the steps of ``step``, the last landing
46
+ on it."""
47
+ q = _steps(bag, t)
48
+ target = bag.get("target", 0)
49
+ step = max(1, abs(bag.get("step", 1)))
50
+ land = (abs(target) + step - 1) // step
51
+ if target == 0 or q >= land:
52
+ out = target
53
+ else:
54
+ out = q * step if target > 0 else -q * step
55
+ return (note + bag.get("offset", 0) + out) & bag.get("mask", F16)
56
+
57
+
58
+ def slew_to_zero(bag, v, t, note=0):
59
+ """The note plus offset, with a ``start`` run back to zero, ``amp`` plus one per step,
60
+ the last landing on zero."""
61
+ amp = bag.get("amp", 0)
62
+ s = bag.get("start", 0)
63
+ if s > 0x7FFF:
64
+ s -= 0x10000
65
+ if amp <= 0 or s == 0:
66
+ out = s
67
+ else:
68
+ sign, s0 = (1, s) if s > 0 else (-1, -s)
69
+ land = s0 // (amp + 1)
70
+ q = _steps(bag, t)
71
+ out = 0 if q > land else sign * (s0 - q * (amp + 1))
72
+ return (note + bag.get("offset", 0) + out) & bag.get("mask", F16)
73
+
74
+
75
+ def _triangle_prefix(k: int, vcmp: int) -> int:
76
+ """The sum of the native vibrato's step signs, step 1 to ``k``: the flip is checked on the
77
+ running time before the step, so the period is 2*extent + 4: extent//2 + 1 plus steps, the
78
+ negative run, and its complement, net zero."""
79
+ if k == 0 or vcmp >= 126:
80
+ return k
81
+ a = vcmp // 2 + 1
82
+ b = 2 * a + (vcmp & 1)
83
+ rem = k % (2 * vcmp + 4)
84
+ if rem <= a:
85
+ return rem
86
+ if rem <= a + b:
87
+ return a - (rem - a)
88
+ return a - b + (rem - a - b)
89
+
90
+
91
+ def triangle(bag, v, t, note=0):
92
+ """The note plus offset, with the native vibrato's step of ``step`` on each step of the
93
+ ``extent``'s period."""
94
+ q = _steps(bag, t, at_delay=True)
95
+ if q == 0:
96
+ return (note + bag.get("offset", 0)) & bag.get("mask", F16)
97
+ amp = abs(bag.get("step", 0)) or 1
98
+ vcmp = bag.get("extent", 128)
99
+ return (note + bag.get("offset", 0) + (amp * _triangle_prefix(q, vcmp)) & F16) & bag.get(
100
+ "mask", F16
101
+ )
102
+
103
+
104
+ SHAPES = {
105
+ "set": set_,
106
+ "ramp": ramp,
107
+ "slew_to_target": slew_to_target,
108
+ "slew_to_zero": slew_to_zero,
109
+ "triangle": triangle,
110
+ }