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.
- unitracker_player/__init__.py +6 -0
- unitracker_player/cli.py +37 -0
- unitracker_player/document.py +447 -0
- unitracker_player/expr.py +34 -0
- unitracker_player/modulation.py +110 -0
- unitracker_player/player.py +673 -0
- unitracker_player-0.2.0.dist-info/METADATA +263 -0
- unitracker_player-0.2.0.dist-info/RECORD +12 -0
- unitracker_player-0.2.0.dist-info/WHEEL +5 -0
- unitracker_player-0.2.0.dist-info/entry_points.txt +2 -0
- unitracker_player-0.2.0.dist-info/licenses/LICENSE +201 -0
- unitracker_player-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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"]
|
unitracker_player/cli.py
ADDED
|
@@ -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
|
+
}
|