Skip to main content

unitracker-grammar

The canonical unitracker document as a Lark grammar, with an LALR parser for it and the note table its expressions read as F(k), and the modulator expression language: checked, printed in its one text and compiled to Python.

A document names no player. It holds everything it plays: instruments, programs, expressions, code, phrases and the tables they index. Its only constants are the note table at its clock and the header defaults (PAL clock, 3 voices, tempo 6). The grammar is format 2.1's, and refuses every other version.

After meta, each optional and in this order: clock ({cycles_per_tick: C, clock_hz: H}, and rounding: floor where the note table's words are rounded down rather than to the nearest), voices, tempo; slots, per cell its modulator slots in firing order, each with its default binding and the events it holds and resets on (slots: {freq: {wave: {mod: note(), reset: [row.note]}, tick: {hold: [step-1, row]}}}); order, the phases of a tick (order: [events, ctrl, pulse, filter, freq, pw, cut, read]); state, per cell its state variables and their sizes in bytes (state: {freq: {t: 1}, pw: {r: 1, q: 2}}); regs, the 25 SID registers' values before tick 0 (regs: [0, 0, ..., 15]); tables; then the definitions that are not song content, each kind a top-level section with records at 2 spaces: mod: (templates keyed by their signature, an expression or a code block at 4 spaces: vib(l, c, s): |, walk(s): v + s, note(): F(N)), restart:, ins:, ctrl:, pw:, filter:, phrase:; then init, and song:, whose rows hold only their play entries (- play: [p1, p2+2, ~]). A slot. prefix on a cell's row key, instrument field or command (tick.freq: vib(0,4, 64), !tick.freq:none) names the slot it binds or stops; a call binds a template wherever a cell is bound, its integer arguments in the signature's order without spaces. A modulated cell's stops are none, hold and reset (phi back to 0, the binding kept), as a row key's value and a command ({pw: ptab(3), pw: reset}, !pw:ptab(3)!pw:reset); a cell's keys may repeat, and the model keeps them in the order written.

A pw or filter record that is one binding is written as that binding (pw1: ptab(3), never pw1: [{pw: ptab(3)}, end]). A program record (restart, ctrl, pw, filter) may be a template keyed by its signature (arp(a, b): [{ctrl: 65, note: +a}, {wait: a, note: -b}, {loop: 0}]), its rows reading its parameters where they take an integer, as a relative note (+a, -a), as a cell value alone (pw: b) and as a call's argument (pw: walk(b)); a call binds it wherever a program's name stands (ctrl: arp(7,4), restart: hr(2,9,220), run: arp(7,4), !run:arp(7,4)), its section giving its kind. Any definition may also stand inline at its use: a program after its kind in run: and !run: (run: pw[{pw: 5}, end]), an instrument after an event's note (C4.{ad: 9, sr: 240}), a phrase with its transpose in a play entry (play: [[C4, -*3]+2, ~]) and, wherever a cell is bound, an anonymous code body (asm{lda v; up:; adc #1; sta v; bne up; rts}); a command and a slot's default also bind a quoted expression (!pw:'v + 40').

A named definition may also be written at its first reference instead of in its section, its name and = before its value, and is referenced by name after: an instrument C4.i3={ad: 9, ctrl: c1=[{ctrl: 65}, end]} then D4.i3, a program run: c3=[...], restart: r2=[...], pw: pw2=v + 3, a phrase p3=[C4.i3, -*3]+2 in a play entry; a template its signature and = before its body, then its call's arguments: !tick.freq:vib(c)=asm{...}(4), !pw:walk(s)='v + s'(2) (an expression body always quoted), a program template ctrl: arp(a, b)=[{ctrl: 65, note: +a}, end](7,4), after its kind in a run (!run:arp(a, b)=ctrl[...](7,4)), then vib(3), arp(0,7). It is the same definition as in its section; a section's records are defined throughout, one at its first use from there on: a reference before it, and a name defined twice, are refused. document.top_level(doc) moves every such definition to its section and document.first_use(doc) every definition the song reaches (from the slots, init and the rows, in reading order) to its first reference; the model keeps each where it is written (Def(name, value, args) at a use), so printed writes either.

A modulator's and a program's signature take the same parameter names: lowercase names but v, phi, n, nn, ne, none, hold, reset and a record's name (c1, pw2). A table is named by a capitalised name but F and N. A row's control mask is its own key ({ctrl: 65, mask: 8}), as are the routing's forms (route: m sets it, route_on: m and route_off: m add and remove this voice's bit and m); !res:r and !route:m set the resonance and the routing. What each construct plays is unitracker-player's semantics.

Install

pip install "unitracker-grammar @ git+https://github.com/anarkiwi/unitracker-grammar"

Use

from unitracker_grammar import parse, CanonicalError, frequency, table
tree = parse(text)        # a checked lark.Tree, or CanonicalError naming the line and column
frequency(57)             # F(57): A4 at the PAL clock
table(clock_hz=1022727)   # F(k) for every note whose word fits, from C0, at NTSC
frequency(57, 985240, "floor")  # F(k) = R(440 * 2**((k - 57)/12) * 2**24 / clock_hz), R round or floor

parse also checks what the grammar cannot say (code.check_document): templates, state names, anonymous bodies, the tables read, no parameter read outside a template, and a pulse or filter record of one binding in its short form, raising CanonicalError, CodeError or ExpressionError. parser() is the cached lark.Lark parser (trees with positions, unchecked) and GRAMMAR the grammar file resource.

Expressions

unitracker_grammar.expr owns the modulator expression language. An expression is a stdlib ast node, read from a text by tree or from a parse tree's expr and fexpr rules by the Nodes transformer.

from unitracker_grammar import expr, frequency
expr.canonical("1024 + 16*min(3, phi)")     # '16*min(3, phi) + 1024', its one text
fn = expr.compile_expr("v + W[phi]", {"_F": frequency, "_T": {"W": expr.indexed([1, 2])}})
fn(10, 1, 48)                               # (v, phi, N, n=0) -> 12

v is the cell's value, phi the modulator's tick count, N the sounding note and n the event note; a template's other lowercase names are its parameters, which params lists and bind replaces with integers. source is the Python source of an expression (ExpressionError outside the grammar), printed the one text of a node and written an integer literal as an int. // and % raise ZeroDivisionError on a divisor of 0; with zero=True they are 0.

Code templates

unitracker_grammar.code owns the code templates: NMOS 6502 routines but brk, jsr and rti, with no indirect modes and no comments, that end in rts. A routine is a tuple of statements, each a Label(name) or an Op(mnemonic, mode, operand): mode is implied (lsr bare is the accumulator's), immediate (Imm(value, byte): #7, #p, #<p, #>p), memory, memory,x or memory,y (Mem(symbol, offset, index): v, v+1, G.lo+3,x), or label (a branch's or jmp's label name).

from unitracker_grammar import code, parse
ops = code.read("lda #c\nsta v\nrts\n")   # checked statements, or CodeError
code.printed(ops)                          # 'lda #c\nsta v\nrts\n', its one text
code.params(ops), code.symbols(ops)        # ('c',), ('v',): in first-use order
code.templates(parse(text))                # {name: statements} of a document's code blocks
code.state(parse(text))                    # {cell: ((name, size), ...)} of its state line

Memory symbols are v (2 bytes: in, the value before the slot; out, the modulator's value), phi (2, phi mod 65536, read-only), nn and ne (the low bytes of N and n), the state variables of the template's cell, and T.lo / T.hi (each entry's low and high byte) for a table T. A code slot fires where an expression would: v, phi, nn, ne and the voice's copy of its cell's state are set, A, X, Y and the flags (but I) are 0, and the routine runs to an rts on an empty stack, within 4096 instructions; v wrapped to the cell's width is its value and the state is stored back. A symbol that is none of these, or a state variable of another cell, is refused when the template is bound. F.lo / F.hi are the note table's bytes, entry k those of frequency(k, clock_hz, rounding) for k 0-255.

code.signature("vib(l, c, s)")            # ('vib', ('l', 'c', 's')), CodeError on a repeat
code.declare("vib(c)", ("c",), {"t"})      # the signature, if it declares what the body reads
code.call("vib(0,-4,32)")                  # Call(name='vib', args=(0, -4, 32))
code.inline(code.read("lda v\nrts\n"))     # 'asm{lda v; rts}', an anonymous body's one text
code.signatures(parse(text))               # {name: parameters} of its signed templates, checked
code.programs(parse(text))                 # {name: (kind, parameters)} of its program templates

code.programs checks a document's program templates: each signature declares exactly what its rows read (code.program_reads(body, declared), in first-use order), a program keyed by its name and everything outside the program records read no parameter, a parameter is never part of a larger cell expression nor read by an inline program, and no name is two templates', of programs or modulators. code.call reads a parameter argument as its name (call("pt(b,2)") is Call("pt", ("b", 2))).

In the parse tree each definition section is a def_KIND tree (def_mod, def_restart, def_ins, def_ctrl, def_pw, def_filter, def_phrase) of key tokens each followed by its value: a template's key, a modulator's or a program's, a SIGNATURE token and its value an expr or code tree. A call is one CALL token (vib(0,-4,32)), a parameter a row reads a PARAM (a REL_PARAM, +a, in a note) outside any factor, an inline program a run_program (a PROGRAM_KIND token, then its program), an inline instrument an instrument where an INS token may stand, an inline phrase a phrase where a PHRASE token may, an anonymous body an asm tree of code_* statements (code.Statements reads it as a code), and a command's quoted expression an fexpr.

Documents

unitracker_grammar.document is the document as typed, frozen dataclasses: read builds one from a text, its classes build one directly, and printed writes its one canonical text, so printed(read(text)) == text and read(printed(doc)) == doc.

from unitracker_grammar.document import (
    Call, Code, Document, Event, Expr, Header, Instrument, Play, Program, Signed, Slot, SongRow,
    printed, read, replace, signature,
)
vib = Code.of("lda v\nclc\nadc #c\nsta v\nrts")              # checked statements
doc = Document(
    Header(meta={"title": "T", "author": "A"}, slots={"freq": [Slot("wave", Call("note")), Slot("tick")]}),
    mods=[(signature("vib", ["c"]), vib), ("note()", Expr("F(N)"))],
    defs={
        "ins": [("i1", Instrument({"ad": 9, "sr": 240, "ctrl": Program([{"ctrl": 65}])}))],
        "phrase": [("p1", [Event("C4", "i1", {"tick.freq": Call("vib", (3,))}), Event("-", steps=3)])],
    },
    rows=[SongRow([Play("p1"), Play("p1", Signed(12)), "~"])],
    loop=0,
)
text = printed(doc)
doc = replace(doc, loop=None)                                # edits are dataclasses.replace
class holds
Document(header, tables, mods, defs, init, rows, loop) tables {name: ints}; mods the templates (signature, Expr | Code); defs the restart, ins, ctrl, pw, filter and phrase sections in that order (Sections, or {kind: [(name, value)]}); init events or None; rows SongRows (or play entry lists); loop the row it loops to, None for end
Header(meta, clock, voices, tempo, slots, order, state, regs) each line, None where not written (the version line is always VERSION, "2.1"); clock (cycles_per_tick, clock_hz), or with "floor" third; meta texts as written ("'A, B'" quoted); slots {cell: [Slot]}, state {cell: [(name, size)]}; slot(cell) its Slots
Slot(name, mod, hold, reset) a slot's default binding and its events ("note-1", "row.freq")
SongRow(play) play entries (Play(phrase, transpose), "~", "end"; a phrase is a name or inline events)
Section(kind, records) (name, value) records of one kind; a template's name is its signature (arp(a, b)), its rows reading parameters as strings (ad: "a", note: "+a", Call("walk", ("b",))); a pw or filter record Program([{"pw": b}]) (cut for filter) is stored as b
Instrument(fields), Row(keys) {key: value}, slot.key for a slot's, kept in the grammar's order
Program(rows, loop) Rows (or dicts), then end (loop None) or {loop: k}
Event(note, ins, commands, keep_pw, keep_filter, steps) ins a name or an Instrument; commands {key: value}, ("legato", True); steps the *k
Expr(text), Call(name, args), Code(statements) an expression, a call, a routine (an asm{...} body where it binds)
Run(kind, program) an inline program after its kind in run: or !run:
Def(name, value, args) a definition at its first use: name (a template's signature), its value, a template's call's arguments (None for a named one)
Signed an int written with its sign: note: +0, a transpose, !freq:-3

A leaf is otherwise an int or a str as written (a name, a note, none, hold). printed wraps flow collections and plain scalars as PyYAML's emitter does at width 100 (a break before an item, or at a scalar's space, once a line is past the width; continuing at the collection's indentation, a scalar's 2 deeper) but never the slots, order and state lines, and quotes an expression in a flow collection that holds a comma, and in a command or a slot's default always, and nowhere else. That is a document's one canonical text: the grammar also admits other line wraps, which read accepts and printed writes canonically. printed raises CanonicalError for a model the grammar refuses: no meta, no row or a row of no play entry, a section of no records, twice or out of order.

tests/roundtrip.py is a pytest plugin that checks every document a test parses is its one canonical text, printed(read(text)) == text (a test marked noncanonical reads other spellings); over unitracker-player's suite: PYTHONPATH=$GRAMMAR/tests pytest -p roundtrip in its checkout. tools/size.py measures the grammar and the time to build its parser.

Grammar

src/unitracker_grammar/canonical.lark; the document format and the meaning of each construct are described in unitracker's docs/format.

Metadata

Release files for unitracker-grammar 0.12.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for unitracker-grammar 0.12.0
File Size Uploaded Provenance
unitracker_grammar-0.12.0.tar.gz 70.1 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for unitracker-grammar 0.12.0
File Interpreter ABI Platform
unitracker_grammar-0.12.0-py3-none-any.whl Python 3 none any Details

Total release size: 114.4 kB

Release files / unitracker_grammar-0.12.0.tar.gz

Download URL unitracker_grammar-0.12.0.tar.gz
Size 70.1 kB
Tags Source
SHA-256 checksum
How to use checksums
14929b2a83052669ce5b342d79433ac3dd3f5a5702af090a74f5c8da044952d0
BLAKE2b-256 checksum
How to use checksums
8827f8d1eaa60750f2d28ee186f3e00e822601a55b7b8578cdb98d744af75493
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.

Transparency log

Release files / unitracker_grammar-0.12.0-py3-none-any.whl

Download URL unitracker_grammar-0.12.0-py3-none-any.whl
Size 44.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
167cbfb1780ae322107b80a2ceab9d74942408cbf32e8fb4cf246ba0d1ce5a57
BLAKE2b-256 checksum
How to use checksums
c6ad99367d6f2d3e18bd94689a6fe07328bde6dd7f1ad68023ab14ee8306320a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.

Transparency log

Security for unitracker-grammar 0.12.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for unitracker_grammar-0.12.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page