Files
code-sinth/code_sinth/parser.py
Jose Luis Montañes 7debc7436e initial: code-sinth — DSL-driven modular synth (Python engine + web app)
Patch language with osc/noise/trig/seq/adsr/filter/delay/poly + voice templates
and inline live values. Two runtimes:

- code_sinth/ — Python engine (numpy + sounddevice). Hot-reload via mtime
  watcher. Offline render to WAV. Static-HTTP+WS visualizer (viz/) that
  injects waveforms next to each `node X = ...` line.
- web/ — port of the engine to JS running in AudioWorklet. Single static
  page with CodeMirror 6 editor (line widgets for live waveforms) and a
  control surface on the right with knobs/faders/step_seq/piano_roll
  declared from the patch. State preserved across hot-reload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:37:06 +02:00

232 lines
6.2 KiB
Python

import re
from dataclasses import dataclass
from typing import Any
@dataclass
class Number:
value: float
@dataclass
class Ident:
name: str
@dataclass
class Call:
func: str
args: list
kwargs: dict
@dataclass
class BinOp:
op: str
left: Any
right: Any
@dataclass
class UnaryOp:
op: str
operand: Any
@dataclass
class NodeDecl:
name: str
expr: Any
@dataclass
class OutDecl:
expr: Any
@dataclass
class ListExpr:
items: list
@dataclass
class VoiceDecl:
name: str
body: list # list of NodeDecl + OutDecl
TOKEN_SPEC = [
('NUMBER', r'\d+\.\d+|\d+'),
('ARROW', r'<-'),
('IDENT', r'[a-zA-Z_][a-zA-Z0-9_]*'),
('OP', r'[+\-*/=,()\[\]\{\}]'),
('NEWLINE', r'\r?\n'),
('SKIP', r'[ \t]+'),
('COMMENT', r'\#[^\n]*'),
('MISMATCH', r'.'),
]
TOKEN_RE = re.compile('|'.join(f'(?P<{n}>{p})' for n, p in TOKEN_SPEC))
KEYWORDS = {'node', 'out', 'voice'}
def tokenize(src):
tokens = []
for m in TOKEN_RE.finditer(src):
kind = m.lastgroup
val = m.group()
if kind in ('SKIP', 'COMMENT'):
continue
if kind == 'NEWLINE':
tokens.append(('NEWLINE', '\n'))
continue
if kind == 'MISMATCH':
raise SyntaxError(f'Unexpected character: {val!r}')
if kind == 'IDENT' and val in KEYWORDS:
tokens.append(('KW', val))
continue
if kind == 'NUMBER':
tokens.append(('NUMBER', float(val)))
continue
tokens.append((kind, val))
tokens.append(('EOF', None))
return tokens
class Parser:
def __init__(self, tokens):
self.tokens = tokens
self.i = 0
def peek(self, offset=0):
return self.tokens[self.i + offset]
def advance(self):
tok = self.tokens[self.i]
self.i += 1
return tok
def expect(self, kind, val=None):
tok = self.advance()
if tok[0] != kind or (val is not None and tok[1] != val):
raise SyntaxError(f'Expected {kind} {val!r}, got {tok!r}')
return tok
def skip_newlines(self):
while self.peek()[0] == 'NEWLINE':
self.advance()
def parse(self):
out = []
self.skip_newlines()
while self.peek()[0] != 'EOF':
out.append(self.parse_statement())
self.skip_newlines()
return out
def parse_statement(self):
tok = self.peek()
if tok == ('KW', 'node'):
self.advance()
name = self.expect('IDENT')[1]
self.expect('OP', '=')
return NodeDecl(name, self.parse_expr())
if tok == ('KW', 'out'):
self.advance()
self.expect('ARROW')
return OutDecl(self.parse_expr())
if tok == ('KW', 'voice'):
self.advance()
name = self.expect('IDENT')[1]
self.expect('OP', '{')
body = []
self.skip_newlines()
while self.peek() != ('OP', '}'):
inner = self.parse_statement()
if isinstance(inner, VoiceDecl):
raise SyntaxError('voice blocks cannot be nested')
body.append(inner)
self.skip_newlines()
self.expect('OP', '}')
return VoiceDecl(name, body)
raise SyntaxError(f'Unexpected token at start of statement: {tok!r}')
def parse_call_expr(self):
name = self.expect('IDENT')[1]
self.expect('OP', '(')
self.skip_newlines()
args, kwargs = [], {}
while self.peek() != ('OP', ')'):
first = self.peek()
# kwarg pattern: NAME = expr. NAME is normally IDENT, but we also let the
# 'voice' keyword be used as a kwarg key (poly(voice=...)).
is_kwarg_key = (
(first[0] == 'IDENT' or first == ('KW', 'voice'))
and self.peek(1) == ('OP', '=')
)
if is_kwarg_key:
key = self.advance()[1]
self.advance() # '='
kwargs[key] = self.parse_expr()
else:
args.append(self.parse_expr())
if self.peek() == ('OP', ','):
self.advance()
self.skip_newlines()
else:
break
self.skip_newlines()
self.expect('OP', ')')
return Call(name, args, kwargs)
def parse_expr(self):
return self.parse_addsub()
def parse_addsub(self):
left = self.parse_muldiv()
while self.peek() in (('OP', '+'), ('OP', '-')):
op = self.advance()[1]
left = BinOp(op, left, self.parse_muldiv())
return left
def parse_muldiv(self):
left = self.parse_unary()
while self.peek() in (('OP', '*'), ('OP', '/')):
op = self.advance()[1]
left = BinOp(op, left, self.parse_unary())
return left
def parse_unary(self):
if self.peek() == ('OP', '-'):
self.advance()
return UnaryOp('-', self.parse_atom())
return self.parse_atom()
def parse_atom(self):
tok = self.peek()
if tok[0] == 'NUMBER':
self.advance()
return Number(tok[1])
if tok[0] == 'IDENT':
if self.peek(1) == ('OP', '('):
return self.parse_call_expr()
self.advance()
return Ident(tok[1])
if tok == ('OP', '('):
self.advance()
self.skip_newlines()
e = self.parse_expr()
self.skip_newlines()
self.expect('OP', ')')
return e
if tok == ('OP', '['):
self.advance()
self.skip_newlines()
items = []
while self.peek() != ('OP', ']'):
items.append(self.parse_expr())
if self.peek() == ('OP', ','):
self.advance()
self.skip_newlines()
else:
break
self.skip_newlines()
self.expect('OP', ']')
return ListExpr(items)
raise SyntaxError(f'Unexpected token in expression: {tok!r}')
def parse(src):
return Parser(tokenize(src)).parse()