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>
This commit is contained in:
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Rendered audio (regenerable from patches)
|
||||||
|
*.wav
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Editors / OS
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
5
code_sinth/__init__.py
Normal file
5
code_sinth/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from .parser import parse
|
||||||
|
from .graph import build_graph
|
||||||
|
from .engine import Engine
|
||||||
|
|
||||||
|
__all__ = ['parse', 'build_graph', 'Engine']
|
||||||
155
code_sinth/engine.py
Normal file
155
code_sinth/engine.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .graph import topo_sort
|
||||||
|
|
||||||
|
|
||||||
|
# State-bearing attributes for each Node type. Used by transfer_state() to keep
|
||||||
|
# phase / time / filter memory continuous across hot-reloads.
|
||||||
|
STATE_ATTRS = {
|
||||||
|
'Osc': ('phase',),
|
||||||
|
'Trig': ('t',),
|
||||||
|
'Seq': ('t',),
|
||||||
|
'Adsr': ('state', 'value', 'last_gate', 'release_start'),
|
||||||
|
'Filter': ('x1', 'x2', 'y1', 'y2'),
|
||||||
|
'Delay': ('buffer', 'size', 'write_idx'),
|
||||||
|
'Poly': ('t', 'last_step'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def transfer_state(old_graph, new_graph):
|
||||||
|
"""Copy state from old.named[name] to new.named[name] when the class matches.
|
||||||
|
Returns count of nodes whose state was preserved."""
|
||||||
|
n = 0
|
||||||
|
for name, new_node in new_graph.named.items():
|
||||||
|
old_node = old_graph.named.get(name)
|
||||||
|
if old_node is None or type(old_node) is not type(new_node):
|
||||||
|
continue
|
||||||
|
attrs = STATE_ATTRS.get(type(new_node).__name__)
|
||||||
|
if not attrs:
|
||||||
|
continue
|
||||||
|
for a in attrs:
|
||||||
|
if hasattr(old_node, a):
|
||||||
|
setattr(new_node, a, getattr(old_node, a))
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
class Engine:
|
||||||
|
def __init__(self, graph=None, sr=48000, block_size=512, gain=0.3):
|
||||||
|
self.sr = sr
|
||||||
|
self.block_size = block_size
|
||||||
|
self.gain = gain
|
||||||
|
# `live` is a single tuple (graph, order). Replacing it is one atomic ref-store
|
||||||
|
# under the GIL, which is enough to swap from the audio callback's point of view.
|
||||||
|
self.live = None
|
||||||
|
# Per-block snapshot of named-node output buffers, replaced atomically each block.
|
||||||
|
# Readers (e.g. WS thread) see either the pre- or post-block dict, never a torn one.
|
||||||
|
self.taps_snapshot = {}
|
||||||
|
# Latest patch source loaded into the engine. Updated by watch_and_reload.
|
||||||
|
self.current_patch_text = ''
|
||||||
|
if graph is not None:
|
||||||
|
self.set_graph(graph)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def graph(self):
|
||||||
|
return self.live[0] if self.live is not None else None
|
||||||
|
|
||||||
|
def set_graph(self, graph, preserve_from=None):
|
||||||
|
n_kept = 0
|
||||||
|
if preserve_from is not None:
|
||||||
|
n_kept = transfer_state(preserve_from, graph)
|
||||||
|
order = topo_sort(graph.all)
|
||||||
|
self.live = (graph, order)
|
||||||
|
return n_kept
|
||||||
|
|
||||||
|
def render_block(self, n):
|
||||||
|
graph, order = self.live # snapshot — survives a mid-block swap
|
||||||
|
for node in order:
|
||||||
|
node.output_buffer = node.process(n, self.sr)
|
||||||
|
# Cheap snapshot of named-node outputs (refs only — process() returns a fresh
|
||||||
|
# array each block so old refs stay valid until GC). Single dict-ref swap.
|
||||||
|
self.taps_snapshot = {name: node.output_buffer
|
||||||
|
for name, node in graph.named.items()
|
||||||
|
if node.output_buffer is not None}
|
||||||
|
return graph.out.output_buffer
|
||||||
|
|
||||||
|
def render_offline(self, duration_s):
|
||||||
|
if self.live is None:
|
||||||
|
raise RuntimeError('No graph set. Pass graph= to Engine() or call set_graph().')
|
||||||
|
total = int(duration_s * self.sr)
|
||||||
|
out = np.zeros(total, dtype=np.float32)
|
||||||
|
offset = 0
|
||||||
|
while offset < total:
|
||||||
|
n = min(self.block_size, total - offset)
|
||||||
|
out[offset:offset + n] = self.render_block(n) * self.gain
|
||||||
|
offset += n
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _audio_callback(self, outdata, frames, time_info, status):
|
||||||
|
if status:
|
||||||
|
print(f'[stream] {status}', flush=True)
|
||||||
|
offset = 0
|
||||||
|
while offset < frames:
|
||||||
|
n = min(self.block_size, frames - offset)
|
||||||
|
block = self.render_block(n) * self.gain
|
||||||
|
outdata[offset:offset + n, 0] = block
|
||||||
|
if outdata.shape[1] > 1:
|
||||||
|
outdata[offset:offset + n, 1] = block
|
||||||
|
offset += n
|
||||||
|
|
||||||
|
def run(self, duration_s):
|
||||||
|
import sounddevice as sd
|
||||||
|
if self.live is None:
|
||||||
|
raise RuntimeError('No graph set.')
|
||||||
|
with sd.OutputStream(samplerate=self.sr, channels=2,
|
||||||
|
callback=self._audio_callback,
|
||||||
|
blocksize=self.block_size, dtype='float32'):
|
||||||
|
sd.sleep(int(duration_s * 1000))
|
||||||
|
|
||||||
|
def watch_and_reload(self, patch_path, build_fn, poll_interval=0.15, stop_event=None):
|
||||||
|
"""Poll patch_path's mtime; on change, parse+build+swap. Audio-agnostic.
|
||||||
|
Stops when stop_event (threading.Event) is set, or on KeyboardInterrupt."""
|
||||||
|
try:
|
||||||
|
last_mtime = os.path.getmtime(patch_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
last_mtime = 0
|
||||||
|
try:
|
||||||
|
while stop_event is None or not stop_event.is_set():
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
try:
|
||||||
|
mt = os.path.getmtime(patch_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
if mt == last_mtime:
|
||||||
|
continue
|
||||||
|
last_mtime = mt
|
||||||
|
try:
|
||||||
|
with open(patch_path, 'r', encoding='utf-8') as f:
|
||||||
|
src = f.read()
|
||||||
|
new_graph = build_fn(src)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[reload error] {type(e).__name__}: {e}')
|
||||||
|
continue
|
||||||
|
n_kept = self.set_graph(new_graph, preserve_from=self.graph)
|
||||||
|
self.current_patch_text = src
|
||||||
|
ts = time.strftime('%H:%M:%S')
|
||||||
|
print(f'[{ts}] reloaded ({n_kept} nodes kept state)')
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def run_live(self, patch_path, build_fn, poll_interval=0.15):
|
||||||
|
"""Play indefinitely, hot-reloading patch_path whenever its mtime changes.
|
||||||
|
build_fn(src_text) -> Graph. Ctrl+C to stop. Reload errors are reported
|
||||||
|
on stdout; the previous graph keeps playing."""
|
||||||
|
import sounddevice as sd
|
||||||
|
if self.live is None:
|
||||||
|
raise RuntimeError('No graph set.')
|
||||||
|
print(f'[live] watching {patch_path} (Ctrl+C to stop)')
|
||||||
|
with sd.OutputStream(samplerate=self.sr, channels=2,
|
||||||
|
callback=self._audio_callback,
|
||||||
|
blocksize=self.block_size, dtype='float32'):
|
||||||
|
self.watch_and_reload(patch_path, build_fn, poll_interval)
|
||||||
|
print('\n[live] stopping')
|
||||||
180
code_sinth/graph.py
Normal file
180
code_sinth/graph.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
from .nodes import Const, BinOpNode, Negate, NODE_REGISTRY, SYMBOLIC_ARGS
|
||||||
|
from .parser import Number, Ident, Call, BinOp, UnaryOp, NodeDecl, OutDecl, ListExpr, VoiceDecl
|
||||||
|
|
||||||
|
# Patch-language kwargs that collide with Python keywords get renamed before
|
||||||
|
# being passed into Node constructors.
|
||||||
|
KWARG_RENAME = {'in': 'in_'}
|
||||||
|
|
||||||
|
|
||||||
|
class Graph:
|
||||||
|
def __init__(self):
|
||||||
|
self.named = {}
|
||||||
|
self.all = []
|
||||||
|
self.out = None
|
||||||
|
self.voice_templates = {} # name -> body (list of stmts)
|
||||||
|
|
||||||
|
def add(self, node):
|
||||||
|
self.all.append(node)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceInstance:
|
||||||
|
"""One independently-stateful copy of a voice template. Owned and processed by Poly."""
|
||||||
|
def __init__(self, freq_slot, gate_slot, order, output):
|
||||||
|
self.freq_slot = freq_slot
|
||||||
|
self.gate_slot = gate_slot
|
||||||
|
self.order = order
|
||||||
|
self.output = output
|
||||||
|
self.gate_off_at = -1.0 # absolute time at which to release gate; -1 = idle
|
||||||
|
self.last_on_at = -1.0 # for LRU voice stealing
|
||||||
|
|
||||||
|
|
||||||
|
def build_voice_instance(template_body):
|
||||||
|
"""Instantiate a fresh sub-graph from a voice template. Returns a VoiceInstance whose
|
||||||
|
freq_slot and gate_slot are mutable Const nodes controlled by the Poly node."""
|
||||||
|
sub = Graph()
|
||||||
|
freq_slot = sub.add(Const(440.0))
|
||||||
|
gate_slot = sub.add(Const(0.0))
|
||||||
|
sub.named['freq'] = freq_slot
|
||||||
|
sub.named['gate'] = gate_slot
|
||||||
|
for stmt in template_body:
|
||||||
|
if isinstance(stmt, NodeDecl):
|
||||||
|
node = build_expr(sub, stmt.expr)
|
||||||
|
if stmt.name in sub.named:
|
||||||
|
raise ValueError(f'Duplicate node name in voice: {stmt.name!r}')
|
||||||
|
sub.named[stmt.name] = node
|
||||||
|
elif isinstance(stmt, OutDecl):
|
||||||
|
sub.out = build_expr(sub, stmt.expr)
|
||||||
|
else:
|
||||||
|
raise ValueError(f'voice body cannot contain {type(stmt).__name__}')
|
||||||
|
if sub.out is None:
|
||||||
|
raise ValueError('voice block missing "out <- ..." declaration')
|
||||||
|
order = topo_sort(sub.all)
|
||||||
|
return VoiceInstance(freq_slot, gate_slot, order, sub.out)
|
||||||
|
|
||||||
|
|
||||||
|
def build_arg(g, expr):
|
||||||
|
"""Like build_expr, but lists become Python lists of literal floats."""
|
||||||
|
if isinstance(expr, ListExpr):
|
||||||
|
items = []
|
||||||
|
for item in expr.items:
|
||||||
|
if isinstance(item, Number):
|
||||||
|
items.append(item.value)
|
||||||
|
elif isinstance(item, UnaryOp) and item.op == '-' and isinstance(item.operand, Number):
|
||||||
|
items.append(-item.operand.value)
|
||||||
|
else:
|
||||||
|
raise ValueError('List elements must be numeric literals')
|
||||||
|
return items
|
||||||
|
return build_expr(g, expr)
|
||||||
|
|
||||||
|
|
||||||
|
def build_call(g, call):
|
||||||
|
func = call.func
|
||||||
|
if func == 'poly':
|
||||||
|
return build_poly(g, call)
|
||||||
|
if func not in NODE_REGISTRY:
|
||||||
|
raise ValueError(f'Unknown node function: {func!r}')
|
||||||
|
cls = NODE_REGISTRY[func]
|
||||||
|
symbolic = SYMBOLIC_ARGS.get(func, [])
|
||||||
|
args = []
|
||||||
|
for i, arg in enumerate(call.args):
|
||||||
|
if i in symbolic:
|
||||||
|
if not isinstance(arg, Ident):
|
||||||
|
raise ValueError(f'Argument {i} of {func}() must be a bare symbol')
|
||||||
|
args.append(arg.name)
|
||||||
|
else:
|
||||||
|
args.append(build_arg(g, arg))
|
||||||
|
kwargs = {KWARG_RENAME.get(k, k): build_arg(g, v) for k, v in call.kwargs.items()}
|
||||||
|
return g.add(cls(*args, **kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
def build_poly(g, call):
|
||||||
|
"""poly(voice=NAME, voices=N, rate=R, gate_duration=GD, notes=[...]) — special-cased
|
||||||
|
because it needs the voice template AST and instantiates sub-graphs."""
|
||||||
|
from .nodes import Poly
|
||||||
|
kw = call.kwargs
|
||||||
|
voice_arg = kw.get('voice')
|
||||||
|
if not isinstance(voice_arg, Ident):
|
||||||
|
raise ValueError('poly() requires voice=<voice_template_name>')
|
||||||
|
if voice_arg.name not in g.voice_templates:
|
||||||
|
raise ValueError(f'Unknown voice template: {voice_arg.name!r}')
|
||||||
|
voice_body = g.voice_templates[voice_arg.name]
|
||||||
|
|
||||||
|
voices_arg = kw.get('voices')
|
||||||
|
if not isinstance(voices_arg, Number):
|
||||||
|
raise ValueError('poly(voices=) must be a literal integer')
|
||||||
|
n_voices = int(voices_arg.value)
|
||||||
|
if n_voices < 1:
|
||||||
|
raise ValueError('poly(voices=) must be >= 1')
|
||||||
|
|
||||||
|
if 'rate' not in kw or 'gate_duration' not in kw or 'notes' not in kw:
|
||||||
|
raise ValueError('poly() requires rate=, gate_duration=, notes=')
|
||||||
|
rate_node = build_expr(g, kw['rate'])
|
||||||
|
gd_node = build_expr(g, kw['gate_duration'])
|
||||||
|
notes_arg = kw['notes']
|
||||||
|
if not isinstance(notes_arg, ListExpr):
|
||||||
|
raise ValueError('poly(notes=) must be a list literal')
|
||||||
|
notes = build_arg(g, notes_arg)
|
||||||
|
|
||||||
|
instances = [build_voice_instance(voice_body) for _ in range(n_voices)]
|
||||||
|
return g.add(Poly(rate_node, gd_node, instances, notes))
|
||||||
|
|
||||||
|
|
||||||
|
def build_expr(g, expr):
|
||||||
|
if isinstance(expr, Number):
|
||||||
|
return g.add(Const(expr.value))
|
||||||
|
if isinstance(expr, Ident):
|
||||||
|
if expr.name not in g.named:
|
||||||
|
raise ValueError(f'Unknown node reference: {expr.name!r}')
|
||||||
|
return g.named[expr.name]
|
||||||
|
if isinstance(expr, Call):
|
||||||
|
return build_call(g, expr)
|
||||||
|
if isinstance(expr, BinOp):
|
||||||
|
return g.add(BinOpNode(expr.op,
|
||||||
|
build_expr(g, expr.left),
|
||||||
|
build_expr(g, expr.right)))
|
||||||
|
if isinstance(expr, UnaryOp):
|
||||||
|
return g.add(Negate(build_expr(g, expr.operand)))
|
||||||
|
raise ValueError(f'Unknown expression node: {type(expr).__name__}')
|
||||||
|
|
||||||
|
|
||||||
|
def build_graph(statements):
|
||||||
|
g = Graph()
|
||||||
|
# Voice declarations first (they can be referenced from any order in the source).
|
||||||
|
for stmt in statements:
|
||||||
|
if isinstance(stmt, VoiceDecl):
|
||||||
|
if stmt.name in g.voice_templates:
|
||||||
|
raise ValueError(f'Duplicate voice template: {stmt.name!r}')
|
||||||
|
g.voice_templates[stmt.name] = stmt.body
|
||||||
|
for stmt in statements:
|
||||||
|
if isinstance(stmt, VoiceDecl):
|
||||||
|
continue
|
||||||
|
if isinstance(stmt, NodeDecl):
|
||||||
|
node = build_expr(g, stmt.expr)
|
||||||
|
if stmt.name in g.named:
|
||||||
|
raise ValueError(f'Duplicate node name: {stmt.name!r}')
|
||||||
|
g.named[stmt.name] = node
|
||||||
|
elif isinstance(stmt, OutDecl):
|
||||||
|
g.out = build_expr(g, stmt.expr)
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unknown statement: {type(stmt).__name__}')
|
||||||
|
if g.out is None:
|
||||||
|
raise ValueError('No "out <- ..." declaration found in patch')
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
def topo_sort(nodes):
|
||||||
|
visited = set()
|
||||||
|
order = []
|
||||||
|
|
||||||
|
def visit(node):
|
||||||
|
if id(node) in visited:
|
||||||
|
return
|
||||||
|
visited.add(id(node))
|
||||||
|
for inp in node.inputs:
|
||||||
|
visit(inp)
|
||||||
|
order.append(node)
|
||||||
|
|
||||||
|
for node in nodes:
|
||||||
|
visit(node)
|
||||||
|
return order
|
||||||
368
code_sinth/nodes.py
Normal file
368
code_sinth/nodes.py
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class Node:
|
||||||
|
def __init__(self):
|
||||||
|
self.inputs = []
|
||||||
|
self.output_buffer = None
|
||||||
|
|
||||||
|
def process(self, n, sr):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class Const(Node):
|
||||||
|
def __init__(self, value):
|
||||||
|
super().__init__()
|
||||||
|
self.value = float(value)
|
||||||
|
|
||||||
|
def process(self, n, sr):
|
||||||
|
return np.full(n, self.value, dtype=np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
class Osc(Node):
|
||||||
|
WAVEFORMS = ('sine', 'saw', 'square', 'tri')
|
||||||
|
|
||||||
|
def __init__(self, waveform, freq):
|
||||||
|
super().__init__()
|
||||||
|
if waveform not in self.WAVEFORMS:
|
||||||
|
raise ValueError(f'Unknown waveform: {waveform!r}')
|
||||||
|
self.waveform = waveform
|
||||||
|
self.freq = freq
|
||||||
|
self.inputs = [freq]
|
||||||
|
self.phase = 0.0
|
||||||
|
|
||||||
|
def process(self, n, sr):
|
||||||
|
freq = self.freq.output_buffer
|
||||||
|
phase_inc = freq / sr
|
||||||
|
phases = self.phase + np.cumsum(phase_inc)
|
||||||
|
self.phase = float(phases[-1] % 1.0)
|
||||||
|
p = phases % 1.0
|
||||||
|
if self.waveform == 'sine':
|
||||||
|
out = np.sin(2.0 * np.pi * p)
|
||||||
|
elif self.waveform == 'saw':
|
||||||
|
out = 2.0 * p - 1.0
|
||||||
|
elif self.waveform == 'square':
|
||||||
|
out = np.where(p < 0.5, 1.0, -1.0)
|
||||||
|
else: # tri
|
||||||
|
out = 4.0 * np.abs(p - 0.5) - 1.0
|
||||||
|
return out.astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
class Trig(Node):
|
||||||
|
"""Periodic gate: high for `duration` seconds every `period` seconds, starting at t=0."""
|
||||||
|
def __init__(self, period, duration):
|
||||||
|
super().__init__()
|
||||||
|
self.period = period
|
||||||
|
self.duration = duration
|
||||||
|
self.inputs = [period, duration]
|
||||||
|
self.t = 0.0
|
||||||
|
|
||||||
|
def process(self, n, sr):
|
||||||
|
period = float(self.period.output_buffer[0])
|
||||||
|
duration = float(self.duration.output_buffer[0])
|
||||||
|
dt = 1.0 / sr
|
||||||
|
times = self.t + np.arange(n, dtype=np.float64) * dt
|
||||||
|
self.t = float(times[-1] + dt)
|
||||||
|
phase = np.mod(times, period)
|
||||||
|
return (phase < duration).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
class Adsr(Node):
|
||||||
|
def __init__(self, a, d, s, r, gate):
|
||||||
|
super().__init__()
|
||||||
|
self.a, self.d, self.s, self.r = a, d, s, r
|
||||||
|
self.gate = gate
|
||||||
|
self.inputs = [a, d, s, r, gate]
|
||||||
|
self.state = 'idle'
|
||||||
|
self.value = 0.0
|
||||||
|
self.last_gate = 0.0
|
||||||
|
self.release_start = 0.0
|
||||||
|
|
||||||
|
def process(self, n, sr):
|
||||||
|
a = float(self.a.output_buffer[0])
|
||||||
|
d = float(self.d.output_buffer[0])
|
||||||
|
s_lvl = float(self.s.output_buffer[0])
|
||||||
|
r = float(self.r.output_buffer[0])
|
||||||
|
gate = self.gate.output_buffer
|
||||||
|
|
||||||
|
att_inc = 1.0 / max(a * sr, 1.0)
|
||||||
|
dec_dec = (1.0 - s_lvl) / max(d * sr, 1.0)
|
||||||
|
rel_norm = 1.0 / max(r * sr, 1.0)
|
||||||
|
|
||||||
|
out = np.empty(n, dtype=np.float32)
|
||||||
|
for i in range(n):
|
||||||
|
g = gate[i]
|
||||||
|
if g > 0.5 and self.last_gate <= 0.5:
|
||||||
|
self.state = 'attack'
|
||||||
|
elif g <= 0.5 and self.last_gate > 0.5:
|
||||||
|
self.state = 'release'
|
||||||
|
self.release_start = self.value
|
||||||
|
self.last_gate = g
|
||||||
|
|
||||||
|
if self.state == 'attack':
|
||||||
|
self.value += att_inc
|
||||||
|
if self.value >= 1.0:
|
||||||
|
self.value = 1.0
|
||||||
|
self.state = 'decay'
|
||||||
|
elif self.state == 'decay':
|
||||||
|
self.value -= dec_dec
|
||||||
|
if self.value <= s_lvl:
|
||||||
|
self.value = s_lvl
|
||||||
|
self.state = 'sustain'
|
||||||
|
elif self.state == 'sustain':
|
||||||
|
self.value = s_lvl
|
||||||
|
elif self.state == 'release':
|
||||||
|
self.value -= rel_norm * self.release_start
|
||||||
|
if self.value <= 0.0:
|
||||||
|
self.value = 0.0
|
||||||
|
self.state = 'idle'
|
||||||
|
out[i] = self.value
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class Seq(Node):
|
||||||
|
"""Step sequencer: emits steps[i] held continuously, advancing at `rate` steps per second.
|
||||||
|
Pair with trig(period=1/rate) for a synced gate."""
|
||||||
|
def __init__(self, rate, steps):
|
||||||
|
super().__init__()
|
||||||
|
if not isinstance(steps, list) or not steps:
|
||||||
|
raise ValueError('seq() needs steps=[v1, v2, ...] with at least one element')
|
||||||
|
self.rate = rate
|
||||||
|
self.steps = np.asarray(steps, dtype=np.float32)
|
||||||
|
self.inputs = [rate]
|
||||||
|
self.t = 0.0
|
||||||
|
|
||||||
|
def process(self, n, sr):
|
||||||
|
rate = float(self.rate.output_buffer[0])
|
||||||
|
dt = 1.0 / sr
|
||||||
|
times = self.t + np.arange(n, dtype=np.float64) * dt
|
||||||
|
self.t = float(times[-1] + dt)
|
||||||
|
idx = (times * rate).astype(np.int64) % len(self.steps)
|
||||||
|
return self.steps[idx]
|
||||||
|
|
||||||
|
|
||||||
|
class Noise(Node):
|
||||||
|
"""White noise in [-1, 1]."""
|
||||||
|
def __init__(self, seed=None):
|
||||||
|
super().__init__()
|
||||||
|
seed_val = int(seed.value) if hasattr(seed, 'value') else None
|
||||||
|
self.rng = np.random.default_rng(seed_val)
|
||||||
|
|
||||||
|
def process(self, n, sr):
|
||||||
|
return (self.rng.random(n).astype(np.float32) * 2.0 - 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class Filter(Node):
|
||||||
|
"""RBJ biquad: lp / hp / bp. Coefficients recomputed per sample so cutoff and q can be audio-rate modulated."""
|
||||||
|
KINDS = ('lp', 'hp', 'bp')
|
||||||
|
|
||||||
|
def __init__(self, kind, in_, cutoff, q):
|
||||||
|
super().__init__()
|
||||||
|
if kind not in self.KINDS:
|
||||||
|
raise ValueError(f'Unknown filter kind: {kind!r}')
|
||||||
|
self.kind = kind
|
||||||
|
self.in_ = in_
|
||||||
|
self.cutoff = cutoff
|
||||||
|
self.q = q
|
||||||
|
self.inputs = [in_, cutoff, q]
|
||||||
|
self.x1 = self.x2 = 0.0
|
||||||
|
self.y1 = self.y2 = 0.0
|
||||||
|
|
||||||
|
def process(self, n, sr):
|
||||||
|
x_buf = self.in_.output_buffer
|
||||||
|
c_buf = self.cutoff.output_buffer
|
||||||
|
q_buf = self.q.output_buffer
|
||||||
|
out = np.empty(n, dtype=np.float32)
|
||||||
|
nyq = 0.499 * sr
|
||||||
|
kind = self.kind
|
||||||
|
x1, x2, y1, y2 = self.x1, self.x2, self.y1, self.y2
|
||||||
|
two_pi_over_sr = 2.0 * np.pi / sr
|
||||||
|
for i in range(n):
|
||||||
|
cutoff = float(c_buf[i])
|
||||||
|
if cutoff > nyq:
|
||||||
|
cutoff = nyq
|
||||||
|
elif cutoff < 1.0:
|
||||||
|
cutoff = 1.0
|
||||||
|
qv = float(q_buf[i])
|
||||||
|
if qv < 0.001:
|
||||||
|
qv = 0.001
|
||||||
|
w = two_pi_over_sr * cutoff
|
||||||
|
cw = np.cos(w)
|
||||||
|
sw = np.sin(w)
|
||||||
|
alpha = sw / (2.0 * qv)
|
||||||
|
if kind == 'lp':
|
||||||
|
b0 = (1.0 - cw) * 0.5
|
||||||
|
b1 = 1.0 - cw
|
||||||
|
b2 = b0
|
||||||
|
elif kind == 'hp':
|
||||||
|
b0 = (1.0 + cw) * 0.5
|
||||||
|
b1 = -(1.0 + cw)
|
||||||
|
b2 = b0
|
||||||
|
else: # bp
|
||||||
|
b0 = sw * 0.5
|
||||||
|
b1 = 0.0
|
||||||
|
b2 = -b0
|
||||||
|
a0 = 1.0 + alpha
|
||||||
|
a1 = -2.0 * cw
|
||||||
|
a2 = 1.0 - alpha
|
||||||
|
x0 = float(x_buf[i])
|
||||||
|
y0 = (b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2) / a0
|
||||||
|
out[i] = y0
|
||||||
|
x2 = x1
|
||||||
|
x1 = x0
|
||||||
|
y2 = y1
|
||||||
|
y1 = y0
|
||||||
|
self.x1, self.x2, self.y1, self.y2 = x1, x2, y1, y2
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class Delay(Node):
|
||||||
|
"""Delay line with feedback. `time` in seconds, `feedback` 0..0.99, `mix` dry/wet 0..1.
|
||||||
|
`max_time` (literal) sets the buffer size; defaults to 2.0s."""
|
||||||
|
def __init__(self, in_, time, feedback, mix, max_time=2.0):
|
||||||
|
super().__init__()
|
||||||
|
self.in_ = in_
|
||||||
|
self.time = time
|
||||||
|
self.feedback = feedback
|
||||||
|
self.mix = mix
|
||||||
|
self.inputs = [in_, time, feedback, mix]
|
||||||
|
# max_time is treated as compile-time constant (sets buffer size).
|
||||||
|
self.max_t = float(max_time.value) if hasattr(max_time, 'value') else float(max_time)
|
||||||
|
self.buffer = None
|
||||||
|
self.size = 0
|
||||||
|
self.write_idx = 0
|
||||||
|
|
||||||
|
def process(self, n, sr):
|
||||||
|
if self.buffer is None:
|
||||||
|
self.size = max(int(self.max_t * sr), 1)
|
||||||
|
self.buffer = np.zeros(self.size, dtype=np.float32)
|
||||||
|
x = self.in_.output_buffer
|
||||||
|
t = float(self.time.output_buffer[0])
|
||||||
|
fb = float(self.feedback.output_buffer[0])
|
||||||
|
if fb > 0.99:
|
||||||
|
fb = 0.99
|
||||||
|
elif fb < 0.0:
|
||||||
|
fb = 0.0
|
||||||
|
mix = float(self.mix.output_buffer[0])
|
||||||
|
d = int(t * sr)
|
||||||
|
if d < 1:
|
||||||
|
d = 1
|
||||||
|
elif d >= self.size:
|
||||||
|
d = self.size - 1
|
||||||
|
out = np.empty(n, dtype=np.float32)
|
||||||
|
buf = self.buffer
|
||||||
|
size = self.size
|
||||||
|
w = self.write_idx
|
||||||
|
for i in range(n):
|
||||||
|
r = (w - d) % size
|
||||||
|
delayed = buf[r]
|
||||||
|
buf[w] = x[i] + fb * delayed
|
||||||
|
out[i] = (1.0 - mix) * x[i] + mix * delayed
|
||||||
|
w = (w + 1) % size
|
||||||
|
self.write_idx = w
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class BinOpNode(Node):
|
||||||
|
def __init__(self, op, a, b):
|
||||||
|
super().__init__()
|
||||||
|
self.op = op
|
||||||
|
self.a = a
|
||||||
|
self.b = b
|
||||||
|
self.inputs = [a, b]
|
||||||
|
|
||||||
|
def process(self, n, sr):
|
||||||
|
a = self.a.output_buffer
|
||||||
|
b = self.b.output_buffer
|
||||||
|
if self.op == '+':
|
||||||
|
return a + b
|
||||||
|
if self.op == '-':
|
||||||
|
return a - b
|
||||||
|
if self.op == '*':
|
||||||
|
return a * b
|
||||||
|
if self.op == '/':
|
||||||
|
return a / b
|
||||||
|
raise ValueError(self.op)
|
||||||
|
|
||||||
|
|
||||||
|
class Negate(Node):
|
||||||
|
def __init__(self, a):
|
||||||
|
super().__init__()
|
||||||
|
self.a = a
|
||||||
|
self.inputs = [a]
|
||||||
|
|
||||||
|
def process(self, n, sr):
|
||||||
|
return -self.a.output_buffer
|
||||||
|
|
||||||
|
|
||||||
|
class Poly(Node):
|
||||||
|
"""Polyphonic voice allocator. Holds N independent VoiceInstances and dispatches notes
|
||||||
|
from a step sequence at `rate` steps per second. Each note triggers an LRU voice for
|
||||||
|
`gate_duration` seconds. notes[i] == 0 is treated as a rest (no trigger)."""
|
||||||
|
def __init__(self, rate, gate_duration, instances, notes):
|
||||||
|
super().__init__()
|
||||||
|
self.rate = rate
|
||||||
|
self.gate_duration = gate_duration
|
||||||
|
self.instances = instances
|
||||||
|
self.notes = list(notes)
|
||||||
|
self.inputs = [rate, gate_duration]
|
||||||
|
self.t = 0.0
|
||||||
|
self.last_step = -1
|
||||||
|
|
||||||
|
def _alloc_voice(self):
|
||||||
|
# Pick the voice idle the longest (lowest last_on_at). If tie, first found.
|
||||||
|
best = self.instances[0]
|
||||||
|
for v in self.instances[1:]:
|
||||||
|
if v.last_on_at < best.last_on_at:
|
||||||
|
best = v
|
||||||
|
return best
|
||||||
|
|
||||||
|
def process(self, n, sr):
|
||||||
|
rate = float(self.rate.output_buffer[0])
|
||||||
|
gd = float(self.gate_duration.output_buffer[0])
|
||||||
|
|
||||||
|
# Trigger any step boundaries that fell into this block (catch up if multiple).
|
||||||
|
cur_step = int(self.t * rate)
|
||||||
|
while self.last_step < cur_step:
|
||||||
|
self.last_step += 1
|
||||||
|
note = self.notes[self.last_step % len(self.notes)]
|
||||||
|
if note > 0.0:
|
||||||
|
v = self._alloc_voice()
|
||||||
|
v.freq_slot.value = float(note)
|
||||||
|
v.gate_slot.value = 1.0
|
||||||
|
step_t = self.last_step / rate
|
||||||
|
v.gate_off_at = step_t + gd
|
||||||
|
v.last_on_at = step_t
|
||||||
|
|
||||||
|
# Release gates whose timeout has elapsed (block-rate granularity).
|
||||||
|
for v in self.instances:
|
||||||
|
if v.gate_off_at > 0.0 and self.t >= v.gate_off_at:
|
||||||
|
v.gate_slot.value = 0.0
|
||||||
|
v.gate_off_at = -1.0
|
||||||
|
|
||||||
|
# Render every voice's sub-graph and sum.
|
||||||
|
out = np.zeros(n, dtype=np.float32)
|
||||||
|
for v in self.instances:
|
||||||
|
for node in v.order:
|
||||||
|
node.output_buffer = node.process(n, sr)
|
||||||
|
out += v.output.output_buffer
|
||||||
|
|
||||||
|
self.t += n / sr
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
NODE_REGISTRY = {
|
||||||
|
'osc': Osc,
|
||||||
|
'trig': Trig,
|
||||||
|
'adsr': Adsr,
|
||||||
|
'noise': Noise,
|
||||||
|
'filter': Filter,
|
||||||
|
'seq': Seq,
|
||||||
|
'delay': Delay,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Positional arg indices that must be bare identifiers (treated as strings).
|
||||||
|
SYMBOLIC_ARGS = {
|
||||||
|
'osc': [0], # waveform name
|
||||||
|
'filter': [0], # filter kind
|
||||||
|
}
|
||||||
231
code_sinth/parser.py
Normal file
231
code_sinth/parser.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
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()
|
||||||
64
code_sinth/viz.py
Normal file
64
code_sinth/viz.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""WebSocket + static-HTTP server for the live visualizer.
|
||||||
|
|
||||||
|
Push model: the WS handler polls engine.taps_snapshot at `fps` Hz and sends a
|
||||||
|
JSON frame to every connected client. The audio thread is never blocked — it
|
||||||
|
just replaces the snapshot dict ref each block.
|
||||||
|
|
||||||
|
Frame schema:
|
||||||
|
{ "sr": 48000,
|
||||||
|
"block_size": 512,
|
||||||
|
"patch": "<source text>",
|
||||||
|
"taps": { "<node-name>": [float, float, ...], ... } }
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import functools
|
||||||
|
import http.server
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
|
||||||
|
|
||||||
|
def _serve_http(directory, port):
|
||||||
|
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=directory)
|
||||||
|
server = http.server.ThreadingHTTPServer(('localhost', port), handler)
|
||||||
|
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
t.start()
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
def _serve_ws(engine, port, fps):
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
async def handler(ws):
|
||||||
|
try:
|
||||||
|
interval = 1.0 / fps
|
||||||
|
while True:
|
||||||
|
snap = engine.taps_snapshot or {}
|
||||||
|
# arr.tolist() is slow per-tap; OK for a proof. ~10 taps × 512 floats × 30 Hz
|
||||||
|
# is ~150k floats/sec, well under WS budget.
|
||||||
|
taps = {name: arr.tolist() for name, arr in snap.items()}
|
||||||
|
msg = {
|
||||||
|
'sr': engine.sr,
|
||||||
|
'block_size': engine.block_size,
|
||||||
|
'patch': engine.current_patch_text,
|
||||||
|
'taps': taps,
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(msg))
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
except websockets.exceptions.ConnectionClosed:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
async with websockets.serve(handler, 'localhost', port):
|
||||||
|
await asyncio.Future() # serve forever
|
||||||
|
|
||||||
|
threading.Thread(target=lambda: asyncio.run(run()), daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def serve(engine, viz_dir, http_port=9001, ws_port=9000, fps=30):
|
||||||
|
"""Start HTTP (static) + WS (taps) servers in background threads.
|
||||||
|
Returns the HTTP server so the caller can shut it down if desired."""
|
||||||
|
server = _serve_http(viz_dir, http_port)
|
||||||
|
_serve_ws(engine, ws_port, fps)
|
||||||
|
print(f'[viz] open http://localhost:{http_port}/ (ws://localhost:{ws_port})')
|
||||||
|
return server
|
||||||
10
examples/arp.patch
Normal file
10
examples/arp.patch
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Arpegio: Am7 ascendente y descendente sobre 8 pasos.
|
||||||
|
# clk y mel comparten rate (8 pasos/seg) por lo que arrancan en fase desde t=0.
|
||||||
|
node clk = trig(period=0.125, duration=0.06)
|
||||||
|
node mel = seq(rate=8, steps=[220.0, 261.63, 329.63, 392.0, 523.25, 392.0, 329.63, 261.63])
|
||||||
|
|
||||||
|
node o1 = osc(saw, freq=mel)
|
||||||
|
node env = adsr(a=0.005, d=0.08, s=0.3, r=0.08, gate=clk)
|
||||||
|
node lp = filter(lp, in=o1, cutoff=300 + env*3000, q=3.0)
|
||||||
|
|
||||||
|
out <- lp * env * 0.5
|
||||||
12
examples/arp_delay.patch
Normal file
12
examples/arp_delay.patch
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Mismo arpegio pero con delay con feedback (ecos a tiempo de corchea).
|
||||||
|
# rate=8 -> step=125ms; delay 250ms = 2 steps; feedback 0.45 da ~3-4 ecos.
|
||||||
|
node clk = trig(period=0.125, duration=0.06)
|
||||||
|
node mel = seq(rate=8, steps=[220.0, 261.63, 329.63, 392.0, 523.25, 392.0, 329.63, 261.63])
|
||||||
|
|
||||||
|
node o1 = osc(saw, freq=mel)
|
||||||
|
node env = adsr(a=0.005, d=0.08, s=0.3, r=0.08, gate=clk)
|
||||||
|
node lp = filter(lp, in=o1, cutoff=300 + env*3000, q=3.0)
|
||||||
|
node dry = lp * env * 0.5
|
||||||
|
node wet = delay(in=dry, time=0.25, feedback=0.45, mix=0.5)
|
||||||
|
|
||||||
|
out <- wet
|
||||||
8
examples/hello.patch
Normal file
8
examples/hello.patch
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Primer patch: oscilador saw modulado por una envolvente ADSR.
|
||||||
|
# trig dispara una compuerta cada 2s (1s alto, 1s bajo) que retriguerea el ADSR.
|
||||||
|
|
||||||
|
node g1 = trig(period=2.0, duration=1.0)
|
||||||
|
node o1 = osc(saw, freq=220)
|
||||||
|
node e1 = adsr(a=0.01, d=0.2, s=0.6, r=0.4, gate=g1)
|
||||||
|
|
||||||
|
out <- o1 * e1
|
||||||
25
examples/pad.patch
Normal file
25
examples/pad.patch
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Pad polifonico. Cada voz tiene dos saws desafinados, env con release largo, LP suave.
|
||||||
|
# rate=4 (250ms/nota) con gate_duration=0.6 -> notas se solapan. voices=8 cubre el stack.
|
||||||
|
|
||||||
|
voice pad {
|
||||||
|
node o1 = osc(saw, freq=freq)
|
||||||
|
node o2 = osc(saw, freq=freq * 1.005)
|
||||||
|
node sum = (o1 + o2) * 0.5
|
||||||
|
node env = adsr(a=0.06, d=0.3, s=0.5, r=1.2, gate=gate)
|
||||||
|
node lp = filter(lp, in=sum, cutoff=400 + env*1500, q=1.2)
|
||||||
|
out <- lp * env
|
||||||
|
}
|
||||||
|
|
||||||
|
# Progresion en C: Cmaj - Am - Fmaj - Gmaj, 4 notas por acorde.
|
||||||
|
node p = poly(
|
||||||
|
voice=pad, voices=8,
|
||||||
|
rate=4, gate_duration=0.6,
|
||||||
|
notes=[
|
||||||
|
261.63, 329.63, 392.00, 523.25,
|
||||||
|
220.00, 261.63, 329.63, 440.00,
|
||||||
|
174.61, 220.00, 261.63, 349.23,
|
||||||
|
196.00, 246.94, 293.66, 392.00
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
out <- p * 0.18
|
||||||
25
examples/pad_delay.patch
Normal file
25
examples/pad_delay.patch
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Pad polifonico + delay sincronizado al rate (250ms = una nota).
|
||||||
|
voice pad {
|
||||||
|
node o1 = osc(saw, freq=freq)
|
||||||
|
node o2 = osc(saw, freq=freq * 1.005)
|
||||||
|
node sum = (o1 + o2) * 0.5
|
||||||
|
node env = adsr(a=0.06, d=0.3, s=0.5, r=1.2, gate=gate)
|
||||||
|
node lp = filter(lp, in=sum, cutoff=400 + env*1500, q=1.2)
|
||||||
|
out <- lp * env
|
||||||
|
}
|
||||||
|
|
||||||
|
node p = poly(
|
||||||
|
voice=pad, voices=8,
|
||||||
|
rate=4, gate_duration=0.6,
|
||||||
|
notes=[
|
||||||
|
261.63, 329.63, 392.00, 523.25,
|
||||||
|
220.00, 261.63, 329.63, 440.00,
|
||||||
|
174.61, 220.00, 261.63, 349.23,
|
||||||
|
196.00, 246.94, 293.66, 392.00
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
node dry = p * 0.18
|
||||||
|
node wet = delay(in=dry, time=0.375, feedback=0.35, mix=0.35)
|
||||||
|
|
||||||
|
out <- wet
|
||||||
7
examples/perc.patch
Normal file
7
examples/perc.patch
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Hi-hat: ruido blanco filtrado en pasa-altos con envolvente percusiva.
|
||||||
|
node g1 = trig(period=0.5, duration=0.01)
|
||||||
|
node ne = adsr(a=0.001, d=0.06, s=0.0, r=0.04, gate=g1)
|
||||||
|
node n1 = noise()
|
||||||
|
node hp = filter(hp, in=n1, cutoff=4000, q=1.0)
|
||||||
|
|
||||||
|
out <- hp * ne
|
||||||
8
examples/sweep.patch
Normal file
8
examples/sweep.patch
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Bass con barrido: ADSR modula el cutoff de un pasa-bajos resonante.
|
||||||
|
# La salida se atenua tambien por la envolvente para tener attack y release de amplitud.
|
||||||
|
node g1 = trig(period=2.0, duration=0.6)
|
||||||
|
node o1 = osc(saw, freq=110)
|
||||||
|
node e1 = adsr(a=0.005, d=0.4, s=0.2, r=0.5, gate=g1)
|
||||||
|
node lp = filter(lp, in=o1, cutoff=200 + e1*3500, q=4.0)
|
||||||
|
|
||||||
|
out <- lp * e1 * 0.7
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
numpy>=1.24
|
||||||
|
sounddevice>=0.4
|
||||||
59
run.py
Normal file
59
run.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import wave
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from code_sinth import parse, build_graph, Engine
|
||||||
|
|
||||||
|
|
||||||
|
def write_wav(path, samples, sr):
|
||||||
|
pcm = np.clip(samples, -1.0, 1.0)
|
||||||
|
pcm = (pcm * 32767.0).astype(np.int16)
|
||||||
|
with wave.open(path, 'wb') as w:
|
||||||
|
w.setnchannels(1)
|
||||||
|
w.setsampwidth(2)
|
||||||
|
w.setframerate(sr)
|
||||||
|
w.writeframes(pcm.tobytes())
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description='code-sinth: tiny modular synth from .patch files')
|
||||||
|
ap.add_argument('patch', help='Path to .patch file')
|
||||||
|
ap.add_argument('--duration', type=float, default=6.0, help='Seconds to render/play (default 6)')
|
||||||
|
ap.add_argument('--wav', help='Render to WAV file instead of playing live')
|
||||||
|
ap.add_argument('--live', action='store_true',
|
||||||
|
help='Play indefinitely and hot-reload the patch on file changes')
|
||||||
|
ap.add_argument('--viz', action='store_true',
|
||||||
|
help='Start the inline-waveform visualizer (web UI). Implies --live.')
|
||||||
|
ap.add_argument('--sr', type=int, default=48000)
|
||||||
|
ap.add_argument('--block', type=int, default=512)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
def load(path):
|
||||||
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
|
src = f.read()
|
||||||
|
return build_graph(parse(src)), src
|
||||||
|
|
||||||
|
graph, src_text = load(args.patch)
|
||||||
|
engine = Engine(graph, sr=args.sr, block_size=args.block)
|
||||||
|
engine.current_patch_text = src_text
|
||||||
|
|
||||||
|
if args.wav:
|
||||||
|
print(f'Rendering {args.duration}s to {args.wav}...')
|
||||||
|
samples = engine.render_offline(args.duration)
|
||||||
|
write_wav(args.wav, samples, args.sr)
|
||||||
|
print('Done.')
|
||||||
|
elif args.viz or args.live:
|
||||||
|
if args.viz:
|
||||||
|
from code_sinth import viz
|
||||||
|
viz_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'viz')
|
||||||
|
viz.serve(engine, viz_dir)
|
||||||
|
engine.run_live(args.patch, lambda src: build_graph(parse(src)))
|
||||||
|
else:
|
||||||
|
print(f'Playing {args.patch} for {args.duration}s...')
|
||||||
|
engine.run(args.duration)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
162
viz/index.html
Normal file
162
viz/index.html
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>code-sinth viz</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0e0f12;
|
||||||
|
--fg: #d6dae0;
|
||||||
|
--gutter: #2a2f38;
|
||||||
|
--comment: #5a6470;
|
||||||
|
--kw: #c678dd;
|
||||||
|
--num: #d19a66;
|
||||||
|
--wave: #7af0c0;
|
||||||
|
--wave-bg: #161a1f;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--fg);
|
||||||
|
font-family: 'JetBrains Mono', 'Cascadia Code', Consolas, monospace;
|
||||||
|
font-size: 14px; }
|
||||||
|
body { display: flex; flex-direction: column; }
|
||||||
|
header { padding: 8px 14px; font-size: 12px; color: var(--gutter);
|
||||||
|
border-bottom: 1px solid #1c1f25; display: flex; gap: 14px;
|
||||||
|
flex-wrap: wrap; align-items: center; }
|
||||||
|
header .dot { width: 8px; height: 8px; border-radius: 50%; background: #555;
|
||||||
|
display: inline-block; vertical-align: middle; margin-right: 6px; }
|
||||||
|
header .dot.live { background: var(--wave); box-shadow: 0 0 8px var(--wave); }
|
||||||
|
#patch { flex: 1; overflow: auto; padding: 16px 0; }
|
||||||
|
.line { display: flex; align-items: center; min-height: 22px;
|
||||||
|
padding: 0 14px 0 0; white-space: pre; }
|
||||||
|
.ln { color: var(--gutter); width: 38px; flex: none; text-align: right;
|
||||||
|
padding-right: 12px; user-select: none; }
|
||||||
|
.code { white-space: pre; }
|
||||||
|
.kw { color: var(--kw); }
|
||||||
|
.num { color: var(--num); }
|
||||||
|
.com { color: var(--comment); font-style: italic; }
|
||||||
|
.wave { display: inline-block; margin-left: 12px; background: var(--wave-bg);
|
||||||
|
border-radius: 3px; vertical-align: middle; }
|
||||||
|
.empty { color: var(--gutter); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<span><span id="dot" class="dot"></span><span id="status">connecting…</span></span>
|
||||||
|
<span id="info"></span>
|
||||||
|
<span id="diag" style="margin-left:auto;color:var(--comment);"></span>
|
||||||
|
</header>
|
||||||
|
<div id="patch"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const taps = {}; // name -> Array<float>
|
||||||
|
let lastPatch = null;
|
||||||
|
|
||||||
|
const dot = document.getElementById('dot');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const info = document.getElementById('info');
|
||||||
|
const diag = document.getElementById('diag');
|
||||||
|
|
||||||
|
function setStatus(live, text) { dot.classList.toggle('live', live); status.textContent = text; }
|
||||||
|
|
||||||
|
// ---- patch rendering (line-by-line, inserts canvas after `node X = ...`) ----
|
||||||
|
function highlight(line) {
|
||||||
|
// Very small syntax sprinkle: keywords, numbers, comments.
|
||||||
|
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const ci = line.indexOf('#');
|
||||||
|
if (ci !== -1) {
|
||||||
|
return esc(line.slice(0, ci)) + `<span class="com">${esc(line.slice(ci))}</span>`;
|
||||||
|
}
|
||||||
|
return esc(line)
|
||||||
|
.replace(/\b(node|out|voice)\b/g, '<span class="kw">$1</span>')
|
||||||
|
.replace(/\b(\d+\.\d+|\d+)\b/g, '<span class="num">$1</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuild(patchText) {
|
||||||
|
const container = document.getElementById('patch');
|
||||||
|
container.innerHTML = '';
|
||||||
|
const lines = patchText.split('\n');
|
||||||
|
const re = /^\s*node\s+([A-Za-z_][A-Za-z0-9_]*)\s*=/;
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'line';
|
||||||
|
div.innerHTML = `<span class="ln">${i + 1}</span><span class="code">${highlight(line) || ' '}</span>`;
|
||||||
|
const m = re.exec(line);
|
||||||
|
if (m) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = 240; c.height = 30;
|
||||||
|
c.dataset.tap = m[1];
|
||||||
|
c.className = 'wave';
|
||||||
|
div.appendChild(c);
|
||||||
|
}
|
||||||
|
container.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- websocket ----
|
||||||
|
function connect() {
|
||||||
|
const url = `ws://${location.hostname || 'localhost'}:9000`;
|
||||||
|
setStatus(false, 'connecting…');
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
ws.onopen = () => setStatus(true, 'connected');
|
||||||
|
ws.onclose = () => { setStatus(false, 'disconnected — retry in 1s'); setTimeout(connect, 1000); };
|
||||||
|
ws.onerror = () => ws.close();
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
const msg = JSON.parse(ev.data);
|
||||||
|
info.textContent = `sr=${msg.sr} block=${msg.block_size} taps=${Object.keys(msg.taps).length}`;
|
||||||
|
for (const [name, arr] of Object.entries(msg.taps)) taps[name] = arr;
|
||||||
|
if (msg.patch !== lastPatch) {
|
||||||
|
lastPatch = msg.patch;
|
||||||
|
rebuild(msg.patch || '# (empty patch)');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// ---- draw loop ----
|
||||||
|
function drawCanvas(c) {
|
||||||
|
const samples = taps[c.dataset.tap];
|
||||||
|
const ctx = c.getContext('2d');
|
||||||
|
const w = c.width, h = c.height;
|
||||||
|
const css = getComputedStyle(document.body);
|
||||||
|
ctx.fillStyle = css.getPropertyValue('--wave-bg').trim();
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
if (!samples || samples.length === 0) {
|
||||||
|
ctx.fillStyle = '#3a4048'; ctx.font = '10px monospace';
|
||||||
|
ctx.fillText('no data', 6, h - 8);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let peak = 0;
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
const a = Math.abs(samples[i]); if (a > peak) peak = a;
|
||||||
|
}
|
||||||
|
const scale = peak > 0.001 ? 1 / Math.max(peak, 0.05) : 1;
|
||||||
|
ctx.strokeStyle = css.getPropertyValue('--wave').trim();
|
||||||
|
ctx.lineWidth = 1.2;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
const x = (i / (samples.length - 1)) * w;
|
||||||
|
const y = h * 0.5 - samples[i] * scale * (h * 0.45);
|
||||||
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
if (peak < 0.5 && peak > 0) {
|
||||||
|
ctx.fillStyle = '#5a6470';
|
||||||
|
ctx.font = '9px monospace';
|
||||||
|
ctx.fillText(`pk ${peak.toFixed(2)}`, 4, 11);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let frames = 0;
|
||||||
|
function tick() {
|
||||||
|
const canvases = document.querySelectorAll('canvas[data-tap]');
|
||||||
|
canvases.forEach(drawCanvas);
|
||||||
|
frames++;
|
||||||
|
if (frames % 30 === 0) {
|
||||||
|
diag.textContent = `${canvases.length} canvases • ${Object.keys(taps).length} taps known`;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
843
web/index.html
Normal file
843
web/index.html
Normal file
@@ -0,0 +1,843 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>code-sinth</title>
|
||||||
|
|
||||||
|
<!-- importmap: every @codemirror/* package pinned and forced to share a single
|
||||||
|
instance of @codemirror/state via esm.sh's ?external= flag. This is what
|
||||||
|
fixes the "widgets never appear" bug from before. -->
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"@codemirror/state": "https://esm.sh/@codemirror/state@6.4.1",
|
||||||
|
"@codemirror/view": "https://esm.sh/@codemirror/view@6.26.3?external=@codemirror/state",
|
||||||
|
"@codemirror/language": "https://esm.sh/@codemirror/language@6.10.1?external=@codemirror/state,@codemirror/view,@lezer/highlight,@lezer/common,@lezer/lr",
|
||||||
|
"@codemirror/commands": "https://esm.sh/@codemirror/commands@6.3.3?external=@codemirror/state,@codemirror/view,@codemirror/language",
|
||||||
|
"@codemirror/search": "https://esm.sh/@codemirror/search@6.5.6?external=@codemirror/state,@codemirror/view",
|
||||||
|
"@codemirror/autocomplete": "https://esm.sh/@codemirror/autocomplete@6.12.0?external=@codemirror/state,@codemirror/view,@codemirror/language",
|
||||||
|
"@codemirror/lint": "https://esm.sh/@codemirror/lint@6.5.0?external=@codemirror/state,@codemirror/view",
|
||||||
|
"@lezer/common": "https://esm.sh/@lezer/common@1.2.1",
|
||||||
|
"@lezer/highlight": "https://esm.sh/@lezer/highlight@1.2.0?external=@lezer/common",
|
||||||
|
"@lezer/lr": "https://esm.sh/@lezer/lr@1.4.0?external=@lezer/common",
|
||||||
|
"codemirror": "https://esm.sh/codemirror@6.0.1?external=@codemirror/state,@codemirror/view,@codemirror/language,@codemirror/commands,@codemirror/search,@codemirror/autocomplete,@codemirror/lint",
|
||||||
|
"@codemirror/theme-one-dark": "https://esm.sh/@codemirror/theme-one-dark@6.1.2?external=@codemirror/state,@codemirror/view,@codemirror/language,@lezer/highlight"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0e0f12;
|
||||||
|
--panel: #14161a;
|
||||||
|
--gutter: #2a2f38;
|
||||||
|
--fg: #d6dae0;
|
||||||
|
--comment: #5a6470;
|
||||||
|
--kw: #c678dd;
|
||||||
|
--num: #d19a66;
|
||||||
|
--atom: #56b6c2;
|
||||||
|
--func: #61afef;
|
||||||
|
--wave: #7af0c0;
|
||||||
|
--wave-bg: #161a1f;
|
||||||
|
--error: #f08080;
|
||||||
|
--accent: #7af0c0;
|
||||||
|
--knob-track: #2a2f38;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--fg);
|
||||||
|
font-family: 'JetBrains Mono', 'Cascadia Code', Consolas, monospace;
|
||||||
|
font-size: 14px; }
|
||||||
|
body { display: flex; flex-direction: column; }
|
||||||
|
header { padding: 8px 14px; font-size: 12px; color: var(--comment);
|
||||||
|
border-bottom: 1px solid #1c1f25; display: flex; gap: 14px;
|
||||||
|
flex-wrap: wrap; align-items: center; }
|
||||||
|
header button { background: var(--panel); border: 1px solid var(--gutter);
|
||||||
|
color: var(--fg); padding: 5px 14px; cursor: pointer;
|
||||||
|
font-family: inherit; font-size: 12px; border-radius: 3px; }
|
||||||
|
header button:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
header button:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
header .dot { width: 8px; height: 8px; border-radius: 50%; background: #555;
|
||||||
|
display: inline-block; vertical-align: middle; margin-right: 6px; }
|
||||||
|
header .dot.live { background: var(--accent); box-shadow: 0 0 8px var(--accent); }
|
||||||
|
#error { color: var(--error); flex: 1; min-width: 0; overflow: hidden;
|
||||||
|
text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
main { flex: 1; display: grid; grid-template-columns: 1fr 320px; min-height: 0; }
|
||||||
|
#editor { background: var(--panel); border-right: 1px solid #1c1f25;
|
||||||
|
overflow: hidden; }
|
||||||
|
.cm-editor { height: 100%; font-size: 14px; }
|
||||||
|
.cm-editor.cm-focused { outline: none; }
|
||||||
|
.cm-content { font-family: inherit; }
|
||||||
|
.wave-widget { display: inline-block; vertical-align: middle; margin-left: 12px;
|
||||||
|
background: var(--wave-bg); border-radius: 3px; }
|
||||||
|
|
||||||
|
/* control surface */
|
||||||
|
#controls { padding: 16px; overflow: auto;
|
||||||
|
display: flex; flex-wrap: wrap; gap: 18px; align-content: flex-start; }
|
||||||
|
#controls.empty::before { content: 'declare faders or knobs in the patch to populate this surface →←';
|
||||||
|
color: var(--comment); font-size: 11px; line-height: 1.5;
|
||||||
|
display: block; padding: 20px 8px; }
|
||||||
|
.ctrl { display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: 6px; min-width: 70px; padding: 10px;
|
||||||
|
background: var(--panel); border: 1px solid var(--gutter);
|
||||||
|
border-radius: 6px; }
|
||||||
|
.ctrl-label { font-size: 11px; color: var(--fg); user-select: none;
|
||||||
|
max-width: 80px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.ctrl-value { font-size: 10px; color: var(--accent); font-variant-numeric: tabular-nums;
|
||||||
|
user-select: none; }
|
||||||
|
.knob-canvas { cursor: grab; touch-action: none; }
|
||||||
|
.knob-canvas.dragging { cursor: grabbing; }
|
||||||
|
.fader { display: flex; flex-direction: column; align-items: center; height: 140px; }
|
||||||
|
.fader-track { position: relative; width: 6px; height: 100px; background: var(--knob-track);
|
||||||
|
border-radius: 3px; cursor: grab; touch-action: none; }
|
||||||
|
.fader-track.dragging { cursor: grabbing; }
|
||||||
|
|
||||||
|
/* step sequencer */
|
||||||
|
.ctrl.stepseq { width: 100%; align-items: stretch; padding: 8px 10px; }
|
||||||
|
.stepseq-row { display: flex; gap: 2px; flex-wrap: nowrap; overflow-x: auto;
|
||||||
|
padding: 2px; touch-action: pan-x; }
|
||||||
|
.step { flex: 0 0 18px; height: 24px; border-radius: 2px;
|
||||||
|
background: #1c2026; cursor: pointer;
|
||||||
|
transition: background 60ms; }
|
||||||
|
.step:hover { background: #2a3038; }
|
||||||
|
.step.on { background: var(--accent); box-shadow: 0 0 4px var(--accent); }
|
||||||
|
.step.beat { border-top: 1px solid #3a4048; }
|
||||||
|
.step.playhead { outline: 1px solid #fff; outline-offset: -1px; }
|
||||||
|
.ctrl.stepseq .ctrl-label { align-self: flex-start; padding-left: 2px; }
|
||||||
|
|
||||||
|
/* piano roll */
|
||||||
|
.ctrl.pianoroll { width: 100%; align-items: stretch; padding: 8px 10px; gap: 4px; }
|
||||||
|
.pianoroll-grid { display: grid; grid-auto-rows: 14px; gap: 1px;
|
||||||
|
background: #0a0c10; padding: 2px; border-radius: 3px;
|
||||||
|
overflow: auto; touch-action: none; user-select: none; }
|
||||||
|
.pr-row { display: contents; }
|
||||||
|
.pr-key { background: #1a1d22; color: #aab; font-size: 9px; line-height: 14px;
|
||||||
|
padding: 0 4px; user-select: none; text-align: right; min-width: 32px; }
|
||||||
|
.pr-key.black { background: #0e1014; color: #66c; }
|
||||||
|
.pr-key.octave { background: #232730; color: var(--accent); }
|
||||||
|
.pr-cell { height: 14px; background: #1c2026; cursor: pointer;
|
||||||
|
transition: background 60ms; }
|
||||||
|
.pr-cell.row-black { background: #161a20; }
|
||||||
|
.pr-cell.beat { border-left: 1px solid #2a3038; }
|
||||||
|
.pr-cell:hover { background: #2a3038; }
|
||||||
|
.pr-cell.on { background: var(--accent); box-shadow: 0 0 3px var(--accent); }
|
||||||
|
.pr-cell.col-playhead { outline: 1px solid rgba(255,255,255,0.4); outline-offset: -1px; }
|
||||||
|
.ctrl.pianoroll .ctrl-label { align-self: flex-start; padding-left: 2px; }
|
||||||
|
.fader-fill { position: absolute; bottom: 0; left: 0; right: 0;
|
||||||
|
background: var(--accent); border-radius: 3px; }
|
||||||
|
.fader-cap { position: absolute; left: -9px; right: -9px; height: 14px;
|
||||||
|
background: var(--fg); border-radius: 2px; transform: translateY(50%); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<button id="start">Start audio</button>
|
||||||
|
<span><span id="dot" class="dot"></span><span id="status">stopped</span></span>
|
||||||
|
<span>gain <input id="gain" type="range" min="0" max="1" step="0.01" value="0.3"></span>
|
||||||
|
<span id="info"></span>
|
||||||
|
<span id="error"></span>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div id="editor"></div>
|
||||||
|
<div id="controls" class="empty"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { EditorView, basicSetup } from "codemirror";
|
||||||
|
import { EditorState, RangeSetBuilder } from "@codemirror/state";
|
||||||
|
import { Decoration, ViewPlugin, WidgetType } from "@codemirror/view";
|
||||||
|
import { StreamLanguage, HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||||
|
import { tags as t } from "@lezer/highlight";
|
||||||
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// default patch — shows osc + adsr + filter + faders/knobs declared
|
||||||
|
// =====================================================================
|
||||||
|
const DEFAULT_PATCH = `# pinta celdas en kick/hat (drum) y mel (piano). drag knobs/faders.
|
||||||
|
node tempo = knob(min=4, max=16, default=8)
|
||||||
|
node cutoff = fader(min=200, max=4000, default=900)
|
||||||
|
node res = knob(min=0.5, max=8, default=2.5)
|
||||||
|
|
||||||
|
# drum: 4-on-the-floor + off-beats
|
||||||
|
node kick = step_seq(rate=tempo, steps=16,
|
||||||
|
default=[1,0,0,0, 1,0,0,0, 1,0,0,0, 1,0,0,0])
|
||||||
|
node hat = step_seq(rate=tempo, steps=16,
|
||||||
|
default=[0,0,1,0, 0,0,1,0, 0,0,1,0, 0,0,1,0])
|
||||||
|
|
||||||
|
# bass — el kick dispara la nota
|
||||||
|
node o1 = osc(saw, freq=55)
|
||||||
|
node ke = adsr(a=0.003, d=0.25, s=0.25, r=0.2, gate=kick)
|
||||||
|
node lp = filter(lp, in=o1, cutoff=cutoff + ke*2200, q=res)
|
||||||
|
node bass = lp * ke
|
||||||
|
|
||||||
|
# hihat de noise
|
||||||
|
node n = noise()
|
||||||
|
node hp = filter(hp, in=n, cutoff=4500, q=1.5)
|
||||||
|
node he = adsr(a=0.001, d=0.04, s=0, r=0.03, gate=hat)
|
||||||
|
node hits = hp * he * 0.4
|
||||||
|
|
||||||
|
# piano roll polifonico — la melodia. dibuja notas en las celdas.
|
||||||
|
voice synth {
|
||||||
|
node o = osc(saw, freq=freq)
|
||||||
|
node e = adsr(a=0.005, d=0.2, s=0.4, r=0.3, gate=gate)
|
||||||
|
node f = filter(lp, in=o, cutoff=600 + e*1800, q=2.0)
|
||||||
|
out <- f * e
|
||||||
|
}
|
||||||
|
node mel = piano_roll(voice=synth, voices=4,
|
||||||
|
rate=tempo, length=16, octaves=2, base=220,
|
||||||
|
gate_duration=0.18)
|
||||||
|
|
||||||
|
out <- bass * 0.6 + hits + mel * 0.35
|
||||||
|
`;
|
||||||
|
|
||||||
|
// trig() doesn't accept fader directly as a Const-typed "period" if the engine wants
|
||||||
|
// a literal — but in our engine all kwargs are Node refs already. fader outputs a
|
||||||
|
// Const-buffer of its current value, so it works.
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// syntax: a small StreamLanguage tokenizer for the DSL
|
||||||
|
// =====================================================================
|
||||||
|
const NODE_FNS = new Set(['osc','trig','seq','adsr','noise','filter','delay','poly','fader','knob']);
|
||||||
|
const ATOMS = new Set(['sine','saw','square','tri','lp','hp','bp']);
|
||||||
|
const KEYWORDS = new Set(['node','out','voice']);
|
||||||
|
|
||||||
|
const codeSinthLang = StreamLanguage.define({
|
||||||
|
name: 'codesinth',
|
||||||
|
startState() { return null; },
|
||||||
|
token(stream) {
|
||||||
|
if (stream.eatSpace()) return null;
|
||||||
|
if (stream.match(/#.*/)) return 'comment';
|
||||||
|
if (stream.match(/<-/)) return 'operator';
|
||||||
|
if (stream.match(/\d+\.\d+|\d+/)) return 'number';
|
||||||
|
if (stream.match(/[A-Za-z_][A-Za-z0-9_]*/)) {
|
||||||
|
const w = stream.current();
|
||||||
|
if (KEYWORDS.has(w)) return 'keyword';
|
||||||
|
if (NODE_FNS.has(w)) return 'meta';
|
||||||
|
if (ATOMS.has(w)) return 'atom';
|
||||||
|
return 'variableName';
|
||||||
|
}
|
||||||
|
if (stream.match(/[+\-*/=,()\[\]\{\}]/)) return 'operator';
|
||||||
|
stream.next();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const highlightStyle = HighlightStyle.define([
|
||||||
|
{ tag: t.keyword, color: 'var(--kw)' },
|
||||||
|
{ tag: t.atom, color: 'var(--atom)' },
|
||||||
|
{ tag: t.number, color: 'var(--num)' },
|
||||||
|
{ tag: t.comment, color: 'var(--comment)', fontStyle: 'italic' },
|
||||||
|
{ tag: t.operator, color: '#abb2bf' },
|
||||||
|
{ tag: t.meta, color: 'var(--func)' },
|
||||||
|
{ tag: t.variableName, color: 'var(--fg)' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// inline wave widgets (one canvas at end of each `node X = ...` line,
|
||||||
|
// except for control declarations whose UI lives on the right pane)
|
||||||
|
// =====================================================================
|
||||||
|
const NODE_LINE_RE = /^\s*node\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([A-Za-z_][A-Za-z0-9_]*)?/;
|
||||||
|
const CONTROL_FNS = new Set(['fader', 'knob', 'step_seq', 'piano_roll']);
|
||||||
|
const taps = {}; // name -> Float32Array (latest snapshot from worklet)
|
||||||
|
|
||||||
|
class WaveWidget extends WidgetType {
|
||||||
|
constructor(name) { super(); this.name = name; }
|
||||||
|
eq(other) { return other.name === this.name; }
|
||||||
|
toDOM() {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.className = 'wave-widget';
|
||||||
|
c.width = 220; c.height = 26;
|
||||||
|
c.dataset.tap = this.name;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
ignoreEvent() { return true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWaveDecorations(view) {
|
||||||
|
const builder = new RangeSetBuilder();
|
||||||
|
const doc = view.state.doc;
|
||||||
|
for (let i = 1; i <= doc.lines; i++) {
|
||||||
|
const line = doc.line(i);
|
||||||
|
const m = NODE_LINE_RE.exec(line.text);
|
||||||
|
if (!m) continue;
|
||||||
|
if (CONTROL_FNS.has(m[2])) continue; // controls have their own surface widget
|
||||||
|
builder.add(line.to, line.to,
|
||||||
|
Decoration.widget({ widget: new WaveWidget(m[1]), side: 1 }));
|
||||||
|
}
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
const wavePlugin = ViewPlugin.fromClass(class {
|
||||||
|
constructor(view) { this.decorations = buildWaveDecorations(view); }
|
||||||
|
update(u) { if (u.docChanged) this.decorations = buildWaveDecorations(u.view); }
|
||||||
|
}, { decorations: v => v.decorations });
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// audio + worklet wiring
|
||||||
|
// =====================================================================
|
||||||
|
const startBtn = document.getElementById('start');
|
||||||
|
const dot = document.getElementById('dot');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const info = document.getElementById('info');
|
||||||
|
const errBox = document.getElementById('error');
|
||||||
|
const gainSl = document.getElementById('gain');
|
||||||
|
const ctrlBox = document.getElementById('controls');
|
||||||
|
|
||||||
|
let audioCtx = null;
|
||||||
|
let workletNode = null;
|
||||||
|
let debounceTimer = null;
|
||||||
|
let activeControls = new Map(); // name -> control widget instance (preserved across reloads)
|
||||||
|
|
||||||
|
const view = new EditorView({
|
||||||
|
doc: DEFAULT_PATCH,
|
||||||
|
extensions: [
|
||||||
|
basicSetup,
|
||||||
|
oneDark,
|
||||||
|
codeSinthLang,
|
||||||
|
syntaxHighlighting(highlightStyle),
|
||||||
|
wavePlugin,
|
||||||
|
EditorView.updateListener.of((u) => {
|
||||||
|
if (u.docChanged) {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(sendPatch, 200);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
parent: document.getElementById('editor'),
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendPatch() {
|
||||||
|
if (!workletNode) return;
|
||||||
|
workletNode.port.postMessage({ type: 'patch', text: view.state.doc.toString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(live, text) { dot.classList.toggle('live', live); statusEl.textContent = text; }
|
||||||
|
function setError(msg) { errBox.textContent = msg || ''; }
|
||||||
|
|
||||||
|
async function startAudio() {
|
||||||
|
if (audioCtx) return;
|
||||||
|
startBtn.disabled = true;
|
||||||
|
startBtn.textContent = 'starting…';
|
||||||
|
try {
|
||||||
|
audioCtx = new AudioContext();
|
||||||
|
await audioCtx.audioWorklet.addModule('worklet.js');
|
||||||
|
workletNode = new AudioWorkletNode(audioCtx, 'synth-engine', {
|
||||||
|
numberOfInputs: 0,
|
||||||
|
numberOfOutputs: 1,
|
||||||
|
outputChannelCount: [2],
|
||||||
|
});
|
||||||
|
workletNode.port.onmessage = (ev) => handleWorkletMsg(ev.data);
|
||||||
|
workletNode.connect(audioCtx.destination);
|
||||||
|
await audioCtx.resume();
|
||||||
|
workletNode.port.postMessage({ type: 'gain', value: parseFloat(gainSl.value) });
|
||||||
|
sendPatch();
|
||||||
|
setStatus(true, `running @ ${audioCtx.sampleRate} Hz`);
|
||||||
|
startBtn.textContent = 'stop audio';
|
||||||
|
startBtn.disabled = false;
|
||||||
|
startBtn.onclick = stopAudio;
|
||||||
|
} catch (e) {
|
||||||
|
setError(`audio init: ${e && e.message || e}`);
|
||||||
|
startBtn.textContent = 'Start audio';
|
||||||
|
startBtn.disabled = false;
|
||||||
|
audioCtx = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function stopAudio() {
|
||||||
|
if (!audioCtx) return;
|
||||||
|
try { await audioCtx.close(); } catch {}
|
||||||
|
audioCtx = null; workletNode = null;
|
||||||
|
setStatus(false, 'stopped');
|
||||||
|
startBtn.textContent = 'Start audio';
|
||||||
|
startBtn.onclick = startAudio;
|
||||||
|
for (const k of Object.keys(taps)) delete taps[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWorkletMsg(msg) {
|
||||||
|
if (msg.type === 'taps') {
|
||||||
|
for (const [name, arr] of Object.entries(msg.taps)) taps[name] = arr;
|
||||||
|
if (msg.playheads) {
|
||||||
|
for (const [name, idx] of Object.entries(msg.playheads)) {
|
||||||
|
const ctrl = activeControls.get(name);
|
||||||
|
if (ctrl && ctrl.kind === 'step_seq') ctrl.setPlayhead(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info.textContent = `taps: ${Object.keys(msg.taps).length}`;
|
||||||
|
} else if (msg.type === 'reloaded') {
|
||||||
|
setError('');
|
||||||
|
rebuildControls(msg.controls || []);
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
setError(msg.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startBtn.onclick = startAudio;
|
||||||
|
gainSl.addEventListener('input', () => {
|
||||||
|
if (workletNode) workletNode.port.postMessage({ type: 'gain', value: parseFloat(gainSl.value) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// control surface: knobs + faders, populated from worklet's `reloaded`
|
||||||
|
// =====================================================================
|
||||||
|
function sendControl(name, value) {
|
||||||
|
if (!workletNode) return;
|
||||||
|
workletNode.port.postMessage({ type: 'control', name, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeKnob(spec) {
|
||||||
|
const wrap = document.createElement('div'); wrap.className = 'ctrl';
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.className = 'knob-canvas';
|
||||||
|
canvas.width = 56; canvas.height = 56;
|
||||||
|
const valEl = document.createElement('div'); valEl.className = 'ctrl-value';
|
||||||
|
const labEl = document.createElement('div'); labEl.className = 'ctrl-label';
|
||||||
|
labEl.textContent = spec.name;
|
||||||
|
wrap.appendChild(canvas); wrap.appendChild(valEl); wrap.appendChild(labEl);
|
||||||
|
|
||||||
|
const state = { ...spec, el: wrap, canvas, valEl };
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const w = canvas.width, h = canvas.height;
|
||||||
|
const cx = w/2, cy = h/2 + 2;
|
||||||
|
const r = Math.min(w, h) * 0.4;
|
||||||
|
const norm = (state.value - state.min) / (state.max - state.min);
|
||||||
|
const startA = Math.PI * 0.75;
|
||||||
|
const endA = Math.PI * 2.25;
|
||||||
|
const css = getComputedStyle(document.body);
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineWidth = 4;
|
||||||
|
ctx.strokeStyle = css.getPropertyValue('--knob-track').trim();
|
||||||
|
ctx.beginPath(); ctx.arc(cx, cy, r, startA, endA); ctx.stroke();
|
||||||
|
ctx.strokeStyle = css.getPropertyValue('--accent').trim();
|
||||||
|
ctx.beginPath(); ctx.arc(cx, cy, r, startA, startA + norm * (endA - startA)); ctx.stroke();
|
||||||
|
const ang = startA + norm * (endA - startA);
|
||||||
|
ctx.strokeStyle = css.getPropertyValue('--fg').trim();
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx + Math.cos(ang) * r * 0.45, cy + Math.sin(ang) * r * 0.45);
|
||||||
|
ctx.lineTo(cx + Math.cos(ang) * r * 0.95, cy + Math.sin(ang) * r * 0.95);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
function updateLabel() { valEl.textContent = formatVal(state.value); }
|
||||||
|
function setValue(v) {
|
||||||
|
if (v < state.min) v = state.min; else if (v > state.max) v = state.max;
|
||||||
|
state.value = v; draw(); updateLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// drag interaction: vertical drag = adjust value
|
||||||
|
canvas.addEventListener('pointerdown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
canvas.setPointerCapture(e.pointerId);
|
||||||
|
canvas.classList.add('dragging');
|
||||||
|
document.body.style.cursor = 'grabbing';
|
||||||
|
const startY = e.clientY;
|
||||||
|
const startV = state.value;
|
||||||
|
const span = state.max - state.min;
|
||||||
|
const onMove = (ev) => {
|
||||||
|
const dy = startY - ev.clientY; // up = positive
|
||||||
|
const factor = ev.shiftKey ? 4 : 1; // shift = fine adjust
|
||||||
|
const delta = (dy / 200) * span / factor;
|
||||||
|
setValue(startV + delta);
|
||||||
|
sendControl(state.name, state.value);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
canvas.classList.remove('dragging');
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
canvas.removeEventListener('pointermove', onMove);
|
||||||
|
canvas.removeEventListener('pointerup', onUp);
|
||||||
|
canvas.removeEventListener('pointercancel', onUp);
|
||||||
|
};
|
||||||
|
canvas.addEventListener('pointermove', onMove);
|
||||||
|
canvas.addEventListener('pointerup', onUp);
|
||||||
|
canvas.addEventListener('pointercancel', onUp);
|
||||||
|
});
|
||||||
|
canvas.addEventListener('dblclick', () => {
|
||||||
|
setValue((state.min + state.max) / 2);
|
||||||
|
sendControl(state.name, state.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
draw(); updateLabel();
|
||||||
|
return { el: wrap, setValue, getValue: () => state.value, kind: 'knob', spec: state };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFader(spec) {
|
||||||
|
const wrap = document.createElement('div'); wrap.className = 'ctrl';
|
||||||
|
const fader = document.createElement('div'); fader.className = 'fader';
|
||||||
|
const track = document.createElement('div'); track.className = 'fader-track';
|
||||||
|
const fill = document.createElement('div'); fill.className = 'fader-fill';
|
||||||
|
const cap = document.createElement('div'); cap.className = 'fader-cap';
|
||||||
|
track.appendChild(fill); track.appendChild(cap);
|
||||||
|
fader.appendChild(track);
|
||||||
|
const valEl = document.createElement('div'); valEl.className = 'ctrl-value';
|
||||||
|
const labEl = document.createElement('div'); labEl.className = 'ctrl-label';
|
||||||
|
labEl.textContent = spec.name;
|
||||||
|
wrap.appendChild(fader); wrap.appendChild(valEl); wrap.appendChild(labEl);
|
||||||
|
|
||||||
|
const state = { ...spec };
|
||||||
|
|
||||||
|
function paint() {
|
||||||
|
const norm = (state.value - state.min) / (state.max - state.min);
|
||||||
|
const pct = Math.max(0, Math.min(1, norm));
|
||||||
|
fill.style.height = (pct * 100) + '%';
|
||||||
|
cap.style.bottom = (pct * 100) + '%';
|
||||||
|
valEl.textContent = formatVal(state.value);
|
||||||
|
}
|
||||||
|
function setValue(v) {
|
||||||
|
if (v < state.min) v = state.min; else if (v > state.max) v = state.max;
|
||||||
|
state.value = v; paint();
|
||||||
|
}
|
||||||
|
function valueAt(clientY) {
|
||||||
|
const rect = track.getBoundingClientRect();
|
||||||
|
const norm = 1 - (clientY - rect.top) / rect.height;
|
||||||
|
return state.min + Math.max(0, Math.min(1, norm)) * (state.max - state.min);
|
||||||
|
}
|
||||||
|
|
||||||
|
track.addEventListener('pointerdown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
track.setPointerCapture(e.pointerId);
|
||||||
|
track.classList.add('dragging');
|
||||||
|
document.body.style.cursor = 'grabbing';
|
||||||
|
setValue(valueAt(e.clientY));
|
||||||
|
sendControl(state.name, state.value);
|
||||||
|
const onMove = (ev) => { setValue(valueAt(ev.clientY)); sendControl(state.name, state.value); };
|
||||||
|
const onUp = () => {
|
||||||
|
track.classList.remove('dragging');
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
track.removeEventListener('pointermove', onMove);
|
||||||
|
track.removeEventListener('pointerup', onUp);
|
||||||
|
track.removeEventListener('pointercancel', onUp);
|
||||||
|
};
|
||||||
|
track.addEventListener('pointermove', onMove);
|
||||||
|
track.addEventListener('pointerup', onUp);
|
||||||
|
track.addEventListener('pointercancel', onUp);
|
||||||
|
});
|
||||||
|
|
||||||
|
paint();
|
||||||
|
return { el: wrap, setValue, getValue: () => state.value, kind: 'fader', spec: state };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVal(v) {
|
||||||
|
if (Math.abs(v) >= 100) return v.toFixed(0);
|
||||||
|
if (Math.abs(v) >= 10) return v.toFixed(1);
|
||||||
|
return v.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStepSeq(spec) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'ctrl stepseq';
|
||||||
|
const labEl = document.createElement('div'); labEl.className = 'ctrl-label';
|
||||||
|
labEl.textContent = spec.name;
|
||||||
|
const row = document.createElement('div'); row.className = 'stepseq-row';
|
||||||
|
wrap.appendChild(labEl); wrap.appendChild(row);
|
||||||
|
|
||||||
|
const state = { name: spec.name, numSteps: spec.numSteps, pattern: spec.pattern.slice(), cells: [] };
|
||||||
|
|
||||||
|
function buildCells() {
|
||||||
|
row.innerHTML = '';
|
||||||
|
state.cells = [];
|
||||||
|
for (let i = 0; i < state.numSteps; i++) {
|
||||||
|
const c = document.createElement('div');
|
||||||
|
c.className = 'step' + (i % 4 === 0 ? ' beat' : '') + (state.pattern[i] ? ' on' : '');
|
||||||
|
c.dataset.step = i;
|
||||||
|
row.appendChild(c);
|
||||||
|
state.cells.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setPattern(arr) {
|
||||||
|
const m = Math.min(arr.length, state.numSteps);
|
||||||
|
for (let i = 0; i < m; i++) {
|
||||||
|
state.pattern[i] = arr[i] ? 1 : 0;
|
||||||
|
if (state.cells[i]) state.cells[i].classList.toggle('on', !!arr[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setNumSteps(n) {
|
||||||
|
if (n === state.numSteps) return;
|
||||||
|
const old = state.pattern;
|
||||||
|
state.pattern = new Array(n).fill(0);
|
||||||
|
const m = Math.min(old.length, n);
|
||||||
|
for (let i = 0; i < m; i++) state.pattern[i] = old[i];
|
||||||
|
state.numSteps = n;
|
||||||
|
buildCells();
|
||||||
|
}
|
||||||
|
|
||||||
|
// click + drag: paint cells (drag across to set many at once, like FL Studio)
|
||||||
|
let painting = null; // 0 or 1, the value we're painting onto cells we cross
|
||||||
|
function cellFromEvent(ev) {
|
||||||
|
const t = document.elementFromPoint(ev.clientX, ev.clientY);
|
||||||
|
if (t && t.classList && t.classList.contains('step') && t.parentNode === row) {
|
||||||
|
return parseInt(t.dataset.step, 10);
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
row.addEventListener('pointerdown', (e) => {
|
||||||
|
const i = cellFromEvent(e);
|
||||||
|
if (i < 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
row.setPointerCapture(e.pointerId);
|
||||||
|
// first cell determines paint direction (toggle: paint opposite of current)
|
||||||
|
painting = state.pattern[i] ? 0 : 1;
|
||||||
|
state.pattern[i] = painting;
|
||||||
|
state.cells[i].classList.toggle('on', !!painting);
|
||||||
|
sendPattern(state.name, i, painting);
|
||||||
|
const onMove = (ev) => {
|
||||||
|
const j = cellFromEvent(ev);
|
||||||
|
if (j < 0 || state.pattern[j] === painting) return;
|
||||||
|
state.pattern[j] = painting;
|
||||||
|
state.cells[j].classList.toggle('on', !!painting);
|
||||||
|
sendPattern(state.name, j, painting);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
painting = null;
|
||||||
|
row.removeEventListener('pointermove', onMove);
|
||||||
|
row.removeEventListener('pointerup', onUp);
|
||||||
|
row.removeEventListener('pointercancel', onUp);
|
||||||
|
};
|
||||||
|
row.addEventListener('pointermove', onMove);
|
||||||
|
row.addEventListener('pointerup', onUp);
|
||||||
|
row.addEventListener('pointercancel', onUp);
|
||||||
|
});
|
||||||
|
|
||||||
|
buildCells();
|
||||||
|
return {
|
||||||
|
el: wrap,
|
||||||
|
kind: 'step_seq',
|
||||||
|
spec: state,
|
||||||
|
setPattern,
|
||||||
|
setNumSteps,
|
||||||
|
setPlayhead(idx) {
|
||||||
|
for (let i = 0; i < state.cells.length; i++) {
|
||||||
|
state.cells[i].classList.toggle('playhead', i === idx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendPattern(name, step, value) {
|
||||||
|
if (!workletNode) return;
|
||||||
|
workletNode.port.postMessage({ type: 'pattern', name, step, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendNote(name, step, pitch, value) {
|
||||||
|
if (!workletNode) return;
|
||||||
|
workletNode.port.postMessage({ type: 'pattern', name, step, pitch, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIDI helpers — derive note names from a base frequency.
|
||||||
|
const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
|
||||||
|
const BLACK_PCS = new Set([1, 3, 6, 8, 10]); // C#, D#, F#, G#, A# in pitch-class space
|
||||||
|
function freqToMidi(f) { return 69 + 12 * Math.log2(f / 440); }
|
||||||
|
function noteLabel(midi) {
|
||||||
|
const m = Math.round(midi);
|
||||||
|
return NOTE_NAMES[((m % 12) + 12) % 12] + (Math.floor(m / 12) - 1);
|
||||||
|
}
|
||||||
|
function isBlackKey(midi) { return BLACK_PCS.has(((Math.round(midi) % 12) + 12) % 12); }
|
||||||
|
function isOctaveC(midi) { return ((Math.round(midi) % 12) + 12) % 12 === 0; }
|
||||||
|
|
||||||
|
function makePianoRoll(spec) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'ctrl pianoroll';
|
||||||
|
const labEl = document.createElement('div'); labEl.className = 'ctrl-label';
|
||||||
|
labEl.textContent = spec.name;
|
||||||
|
const grid = document.createElement('div'); grid.className = 'pianoroll-grid';
|
||||||
|
wrap.appendChild(labEl); wrap.appendChild(grid);
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
name: spec.name,
|
||||||
|
length: spec.length,
|
||||||
|
numPitches: spec.numPitches,
|
||||||
|
baseFreq: spec.baseFreq,
|
||||||
|
pattern: spec.pattern.slice(),
|
||||||
|
cells: null, // [pitch][step] -> div (pitch index 0..numPitches-1, low to high)
|
||||||
|
};
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
grid.innerHTML = '';
|
||||||
|
grid.style.gridTemplateColumns = `32px repeat(${state.length}, 1fr)`;
|
||||||
|
state.cells = [];
|
||||||
|
const baseMidi = freqToMidi(state.baseFreq);
|
||||||
|
// Top-to-bottom in DOM = highest pitch first. Iterate pitches descending.
|
||||||
|
for (let p = state.numPitches - 1; p >= 0; p--) {
|
||||||
|
const midi = baseMidi + p;
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'pr-key' + (isBlackKey(midi) ? ' black' : '') + (isOctaveC(midi) ? ' octave' : '');
|
||||||
|
label.textContent = noteLabel(midi);
|
||||||
|
grid.appendChild(label);
|
||||||
|
const rowCells = [];
|
||||||
|
for (let s = 0; s < state.length; s++) {
|
||||||
|
const c = document.createElement('div');
|
||||||
|
c.className = 'pr-cell'
|
||||||
|
+ (isBlackKey(midi) ? ' row-black' : '')
|
||||||
|
+ (s % 4 === 0 ? ' beat' : '')
|
||||||
|
+ (state.pattern[s * state.numPitches + p] ? ' on' : '');
|
||||||
|
c.dataset.step = s;
|
||||||
|
c.dataset.pitch = p;
|
||||||
|
grid.appendChild(c);
|
||||||
|
rowCells.push(c);
|
||||||
|
}
|
||||||
|
state.cells[p] = rowCells;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCellVisual(step, pitch, on) {
|
||||||
|
const row = state.cells[pitch];
|
||||||
|
if (!row) return;
|
||||||
|
const c = row[step];
|
||||||
|
if (c) c.classList.toggle('on', !!on);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPattern(arr) {
|
||||||
|
const sz = state.length * state.numPitches;
|
||||||
|
for (let i = 0; i < sz && i < arr.length; i++) state.pattern[i] = arr[i] ? 1 : 0;
|
||||||
|
for (let p = 0; p < state.numPitches; p++)
|
||||||
|
for (let s = 0; s < state.length; s++)
|
||||||
|
setCellVisual(s, p, state.pattern[s * state.numPitches + p]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setShape(spec) {
|
||||||
|
if (spec.length === state.length && spec.numPitches === state.numPitches
|
||||||
|
&& spec.baseFreq === state.baseFreq) return false;
|
||||||
|
state.length = spec.length;
|
||||||
|
state.numPitches = spec.numPitches;
|
||||||
|
state.baseFreq = spec.baseFreq;
|
||||||
|
state.pattern = spec.pattern.slice();
|
||||||
|
build();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlayhead(idx) {
|
||||||
|
// Toggle .col-playhead class on the column. A single column = numPitches cells.
|
||||||
|
const cells = grid.querySelectorAll('.pr-cell');
|
||||||
|
cells.forEach((c) => {
|
||||||
|
const s = +c.dataset.step;
|
||||||
|
c.classList.toggle('col-playhead', s === idx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// drag-paint (same idea as step_seq)
|
||||||
|
let painting = null;
|
||||||
|
function cellAt(ev) {
|
||||||
|
const t = document.elementFromPoint(ev.clientX, ev.clientY);
|
||||||
|
if (t && t.classList && t.classList.contains('pr-cell') && t.parentNode === grid) {
|
||||||
|
return { step: +t.dataset.step, pitch: +t.dataset.pitch, el: t };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
grid.addEventListener('pointerdown', (e) => {
|
||||||
|
const c = cellAt(e); if (!c) return;
|
||||||
|
e.preventDefault();
|
||||||
|
grid.setPointerCapture(e.pointerId);
|
||||||
|
const idx = c.step * state.numPitches + c.pitch;
|
||||||
|
painting = state.pattern[idx] ? 0 : 1;
|
||||||
|
state.pattern[idx] = painting;
|
||||||
|
c.el.classList.toggle('on', !!painting);
|
||||||
|
sendNote(state.name, c.step, c.pitch, painting);
|
||||||
|
const onMove = (ev) => {
|
||||||
|
const c2 = cellAt(ev); if (!c2) return;
|
||||||
|
const idx2 = c2.step * state.numPitches + c2.pitch;
|
||||||
|
if (state.pattern[idx2] === painting) return;
|
||||||
|
state.pattern[idx2] = painting;
|
||||||
|
c2.el.classList.toggle('on', !!painting);
|
||||||
|
sendNote(state.name, c2.step, c2.pitch, painting);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
painting = null;
|
||||||
|
grid.removeEventListener('pointermove', onMove);
|
||||||
|
grid.removeEventListener('pointerup', onUp);
|
||||||
|
grid.removeEventListener('pointercancel', onUp);
|
||||||
|
};
|
||||||
|
grid.addEventListener('pointermove', onMove);
|
||||||
|
grid.addEventListener('pointerup', onUp);
|
||||||
|
grid.addEventListener('pointercancel', onUp);
|
||||||
|
});
|
||||||
|
|
||||||
|
build();
|
||||||
|
return {
|
||||||
|
el: wrap,
|
||||||
|
kind: 'piano_roll',
|
||||||
|
spec: state,
|
||||||
|
setPattern,
|
||||||
|
setShape,
|
||||||
|
setPlayhead,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildControls(controls) {
|
||||||
|
// Reuse existing widgets when possible (preserve DOM focus / drag-in-progress).
|
||||||
|
const seen = new Set();
|
||||||
|
const newOrder = [];
|
||||||
|
for (const spec of controls) {
|
||||||
|
seen.add(spec.name);
|
||||||
|
let ctrl = activeControls.get(spec.name);
|
||||||
|
if (ctrl && ctrl.kind === spec.kind) {
|
||||||
|
// Update spec but don't snap visible state — the worklet preserves it.
|
||||||
|
if (spec.kind === 'step_seq') {
|
||||||
|
ctrl.setNumSteps(spec.numSteps);
|
||||||
|
ctrl.setPattern(spec.pattern);
|
||||||
|
} else if (spec.kind === 'piano_roll') {
|
||||||
|
if (!ctrl.setShape(spec)) ctrl.setPattern(spec.pattern);
|
||||||
|
} else {
|
||||||
|
ctrl.spec.min = spec.min;
|
||||||
|
ctrl.spec.max = spec.max;
|
||||||
|
ctrl.setValue(spec.value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctrl = (spec.kind === 'knob') ? makeKnob(spec)
|
||||||
|
: (spec.kind === 'fader') ? makeFader(spec)
|
||||||
|
: (spec.kind === 'step_seq') ? makeStepSeq(spec)
|
||||||
|
: (spec.kind === 'piano_roll') ? makePianoRoll(spec)
|
||||||
|
: null;
|
||||||
|
if (!ctrl) continue;
|
||||||
|
activeControls.set(spec.name, ctrl);
|
||||||
|
}
|
||||||
|
newOrder.push(ctrl);
|
||||||
|
}
|
||||||
|
for (const name of [...activeControls.keys()]) {
|
||||||
|
if (!seen.has(name)) activeControls.delete(name);
|
||||||
|
}
|
||||||
|
ctrlBox.innerHTML = '';
|
||||||
|
for (const c of newOrder) ctrlBox.appendChild(c.el);
|
||||||
|
ctrlBox.classList.toggle('empty', newOrder.length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// draw loop for the inline wave widgets
|
||||||
|
// =====================================================================
|
||||||
|
function drawCanvas(c) {
|
||||||
|
const samples = taps[c.dataset.tap];
|
||||||
|
const ctx = c.getContext('2d');
|
||||||
|
const w = c.width, h = c.height;
|
||||||
|
const css = getComputedStyle(document.body);
|
||||||
|
ctx.fillStyle = css.getPropertyValue('--wave-bg').trim();
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
if (!samples || samples.length === 0) {
|
||||||
|
ctx.fillStyle = '#3a4048'; ctx.font = '10px monospace';
|
||||||
|
ctx.fillText('no data', 6, h - 8);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let peak = 0;
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
const a = Math.abs(samples[i]); if (a > peak) peak = a;
|
||||||
|
}
|
||||||
|
const scale = peak > 0.001 ? 1 / Math.max(peak, 0.05) : 1;
|
||||||
|
ctx.strokeStyle = css.getPropertyValue('--wave').trim();
|
||||||
|
ctx.lineWidth = 1.2;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
const x = (i / (samples.length - 1)) * w;
|
||||||
|
const y = h * 0.5 - samples[i] * scale * (h * 0.45);
|
||||||
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
if (peak < 0.5 && peak > 0) {
|
||||||
|
ctx.fillStyle = '#5a6470'; ctx.font = '9px monospace';
|
||||||
|
ctx.fillText(`pk ${peak.toFixed(2)}`, 4, 11);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function tick() {
|
||||||
|
document.querySelectorAll('canvas[data-tap]').forEach(drawCanvas);
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1079
web/worklet.js
Normal file
1079
web/worklet.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user