""" Generic Streaming S-expression Interpreter. Executes streaming sexp recipes frame-by-frame. The sexp defines the pipeline logic - interpreter just provides primitives. Primitives: (read source-name) - read frame from source (rotate frame :angle N) - rotate frame (zoom frame :amount N) - zoom frame (invert frame :amount N) - invert colors (hue-shift frame :degrees N) - shift hue (blend frame1 frame2 :opacity N) - blend two frames (blend-weighted [frames...] [weights...]) - weighted blend (ripple frame :amplitude N :cx N :cy N ...) - ripple effect (bind scan-name :field) - get scan state field (map value [lo hi]) - map 0-1 value to range energy - current energy (0-1) beat - 1 if beat, 0 otherwise t - current time beat-count - total beats so far Example sexp: (stream "test" :fps 30 (source vid "video.mp4") (audio aud "music.mp3") (scan spin beat :init {:angle 0 :dir 1} :step (dict :angle (+ angle (* dir 10)) :dir dir)) (frame (-> (read vid) (rotate :angle (bind spin :angle)) (zoom :amount (map energy [1 1.5]))))) """ import sys import time import json import hashlib import numpy as np import subprocess from pathlib import Path from dataclasses import dataclass, field from typing import Dict, List, Any, Optional, Tuple, Union sys.path.insert(0, str(Path(__file__).parent.parent.parent / "artdag")) from artdag.sexp.parser import parse, parse_all, Symbol, Keyword @dataclass class StreamContext: """Runtime context for streaming.""" t: float = 0.0 frame_num: int = 0 fps: float = 30.0 energy: float = 0.0 is_beat: bool = False beat_count: int = 0 output_size: Tuple[int, int] = (720, 720) class StreamCache: """Cache for streaming data.""" def __init__(self, cache_dir: Path, recipe_hash: str): self.cache_dir = cache_dir / recipe_hash self.cache_dir.mkdir(parents=True, exist_ok=True) self.analysis_buffer: Dict[str, List] = {} self.scan_states: Dict[str, List] = {} self.keyframe_interval = 5.0 def record_analysis(self, name: str, t: float, value: float): if name not in self.analysis_buffer: self.analysis_buffer[name] = [] t = float(t) if hasattr(t, 'item') else t value = float(value) if hasattr(value, 'item') else value self.analysis_buffer[name].append((t, value)) def record_scan_state(self, name: str, t: float, state: dict): if name not in self.scan_states: self.scan_states[name] = [] states = self.scan_states[name] if not states or t - states[-1][0] >= self.keyframe_interval: t = float(t) if hasattr(t, 'item') else t clean = {k: (float(v) if hasattr(v, 'item') else v) for k, v in state.items()} self.scan_states[name].append((t, clean)) def flush(self): for name, data in self.analysis_buffer.items(): path = self.cache_dir / f"analysis_{name}.json" existing = json.loads(path.read_text()) if path.exists() else [] existing.extend(data) path.write_text(json.dumps(existing)) self.analysis_buffer.clear() for name, states in self.scan_states.items(): path = self.cache_dir / f"scan_{name}.json" existing = json.loads(path.read_text()) if path.exists() else [] existing.extend(states) path.write_text(json.dumps(existing)) self.scan_states.clear() class VideoSource: """Video source - reads frames sequentially.""" def __init__(self, path: str, fps: float = 30): self.path = Path(path) if not self.path.exists(): raise FileNotFoundError(f"Video not found: {path}") # Get info cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", str(self.path)] info = json.loads(subprocess.run(cmd, capture_output=True, text=True).stdout) for s in info.get("streams", []): if s.get("codec_type") == "video": self.width = s.get("width", 720) self.height = s.get("height", 720) break else: self.width, self.height = 720, 720 self.duration = float(info.get("format", {}).get("duration", 60)) self.size = (self.width, self.height) # Start decoder cmd = ["ffmpeg", "-v", "quiet", "-i", str(self.path), "-f", "rawvideo", "-pix_fmt", "rgb24", "-r", str(fps), "-"] self._proc = subprocess.Popen(cmd, stdout=subprocess.PIPE) self._frame_size = self.width * self.height * 3 self._current_frame = None def read(self) -> Optional[np.ndarray]: """Read next frame.""" data = self._proc.stdout.read(self._frame_size) if len(data) < self._frame_size: return self._current_frame # Return last frame if stream ends self._current_frame = np.frombuffer(data, dtype=np.uint8).reshape( self.height, self.width, 3).copy() return self._current_frame def skip(self): """Read and discard frame (keep pipe in sync).""" self._proc.stdout.read(self._frame_size) def close(self): if self._proc: self._proc.terminate() self._proc.wait() class AudioAnalyzer: """Real-time audio analysis.""" def __init__(self, path: str, sample_rate: int = 22050): self.path = Path(path) # Load audio cmd = ["ffmpeg", "-v", "quiet", "-i", str(self.path), "-f", "f32le", "-ac", "1", "-ar", str(sample_rate), "-"] self._audio = np.frombuffer( subprocess.run(cmd, capture_output=True).stdout, dtype=np.float32) self.sample_rate = sample_rate # Get duration cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", str(self.path)] info = json.loads(subprocess.run(cmd, capture_output=True, text=True).stdout) self.duration = float(info.get("format", {}).get("duration", 60)) self._flux_history = [] self._last_beat_time = -1 def get_energy(self, t: float) -> float: idx = int(t * self.sample_rate) start = max(0, idx - 512) end = min(len(self._audio), idx + 512) if start >= end: return 0.0 return min(1.0, np.sqrt(np.mean(self._audio[start:end] ** 2)) * 3.0) def get_beat(self, t: float) -> bool: idx = int(t * self.sample_rate) size = 2048 start, end = max(0, idx - size//2), min(len(self._audio), idx + size//2) if end - start < size//2: return False curr = self._audio[start:end] pstart, pend = max(0, start - 512), max(0, end - 512) if pend <= pstart: return False prev = self._audio[pstart:pend] curr_spec = np.abs(np.fft.rfft(curr * np.hanning(len(curr)))) prev_spec = np.abs(np.fft.rfft(prev * np.hanning(len(prev)))) n = min(len(curr_spec), len(prev_spec)) flux = np.sum(np.maximum(0, curr_spec[:n] - prev_spec[:n])) / (n + 1) self._flux_history.append((t, flux)) while self._flux_history and self._flux_history[0][0] < t - 1.5: self._flux_history.pop(0) if len(self._flux_history) < 3: return False vals = [f for _, f in self._flux_history] threshold = np.mean(vals) + np.std(vals) * 0.3 + 0.001 is_beat = flux > threshold and t - self._last_beat_time > 0.1 if is_beat: self._last_beat_time = t return is_beat class StreamInterpreter: """ Generic streaming sexp interpreter. Evaluates the frame pipeline expression each frame. """ def __init__(self, sexp_path: str, cache_dir: str = None): self.sexp_path = Path(sexp_path) self.sexp_dir = self.sexp_path.parent text = self.sexp_path.read_text() self.ast = parse(text) self.config = self._parse_config() recipe_hash = hashlib.sha256(text.encode()).hexdigest()[:16] cache_path = Path(cache_dir) if cache_dir else self.sexp_dir / ".stream_cache" self.cache = StreamCache(cache_path, recipe_hash) self.ctx = StreamContext(fps=self.config.get('fps', 30)) self.sources: Dict[str, VideoSource] = {} self.frames: Dict[str, np.ndarray] = {} # Current frame per source self._sources_read: set = set() # Track which sources read this frame self.audios: Dict[str, AudioAnalyzer] = {} # Multiple named audio sources self.audio_paths: Dict[str, str] = {} self.audio_state: Dict[str, dict] = {} # Per-audio: {energy, is_beat, beat_count, last_beat} self.scans: Dict[str, dict] = {} # Registries for external definitions self.primitives: Dict[str, Any] = {} # name -> Python function self.effects: Dict[str, dict] = {} # name -> {params, body} self.macros: Dict[str, dict] = {} # name -> {params, body} self.primitive_lib_dir = self.sexp_dir.parent / "sexp_effects" / "primitive_libs" self.frame_pipeline = None # The (frame ...) expression import random self.rng = random.Random(self.config.get('seed', 42)) def _parse_config(self) -> dict: """Parse config from (stream name :key val ...).""" config = {'fps': 30, 'seed': 42} if not self.ast or not isinstance(self.ast[0], Symbol): return config if self.ast[0].name != 'stream': return config i = 2 while i < len(self.ast): if isinstance(self.ast[i], Keyword): config[self.ast[i].name] = self.ast[i + 1] if i + 1 < len(self.ast) else None i += 2 elif isinstance(self.ast[i], list): break else: i += 1 return config def _load_primitives(self, lib_name: str): """Load primitives from a Python library file.""" import importlib.util # Try multiple paths lib_paths = [ self.primitive_lib_dir / f"{lib_name}.py", self.sexp_dir / "primitive_libs" / f"{lib_name}.py", self.sexp_dir.parent / "sexp_effects" / "primitive_libs" / f"{lib_name}.py", ] lib_path = None for p in lib_paths: if p.exists(): lib_path = p break if not lib_path: print(f"Warning: primitive library '{lib_name}' not found", file=sys.stderr) return spec = importlib.util.spec_from_file_location(lib_name, lib_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) # Extract all prim_* functions count = 0 for name in dir(module): if name.startswith('prim_'): func = getattr(module, name) prim_name = name[5:] # Remove 'prim_' prefix self.primitives[prim_name] = func # Also register with dashes instead of underscores dash_name = prim_name.replace('_', '-') self.primitives[dash_name] = func # Also register with -img suffix (sexp convention) self.primitives[dash_name + '-img'] = func count += 1 # Also check for PRIMITIVES dict (some modules use this for additional exports) if hasattr(module, 'PRIMITIVES'): prims = getattr(module, 'PRIMITIVES') if isinstance(prims, dict): for name, func in prims.items(): self.primitives[name] = func # Also register underscore version underscore_name = name.replace('-', '_') self.primitives[underscore_name] = func count += 1 print(f"Loaded primitives: {lib_name} ({count} functions)", file=sys.stderr) def _load_effect(self, effect_path: Path): """Load and register an effect from a .sexp file.""" if not effect_path.exists(): print(f"Warning: effect file not found: {effect_path}", file=sys.stderr) return text = effect_path.read_text() ast = parse_all(text) for form in ast: if not isinstance(form, list) or not form: continue if not isinstance(form[0], Symbol): continue cmd = form[0].name if cmd == 'require-primitives': lib_name = form[1] if isinstance(form[1], str) else str(form[1]).strip('"') self._load_primitives(lib_name) elif cmd == 'define-effect': name = form[1].name if isinstance(form[1], Symbol) else str(form[1]) params = {} body = None i = 2 while i < len(form): if isinstance(form[i], Keyword): if form[i].name == 'params' and i + 1 < len(form): # Parse params list params_list = form[i + 1] for p in params_list: if isinstance(p, list) and p: pname = p[0].name if isinstance(p[0], Symbol) else str(p[0]) pdef = {'default': 0} j = 1 while j < len(p): if isinstance(p[j], Keyword): pdef[p[j].name] = p[j + 1] if j + 1 < len(p) else None j += 2 else: j += 1 params[pname] = pdef i += 2 else: i += 2 else: # Body expression body = form[i] i += 1 self.effects[name] = {'params': params, 'body': body, 'path': str(effect_path)} print(f"Effect: {name}", file=sys.stderr) elif cmd == 'defmacro': name = form[1].name if isinstance(form[1], Symbol) else str(form[1]) params = [] body = None if len(form) > 2 and isinstance(form[2], list): params = [p.name if isinstance(p, Symbol) else str(p) for p in form[2]] if len(form) > 3: body = form[3] self.macros[name] = {'params': params, 'body': body} print(f"Macro: {name}", file=sys.stderr) def _init(self): """Initialize sources, scans, and pipeline from sexp.""" for form in self.ast: if not isinstance(form, list) or not form: continue if not isinstance(form[0], Symbol): continue cmd = form[0].name # === External loading === if cmd == 'require-primitives': lib_name = form[1] if isinstance(form[1], str) else str(form[1]).strip('"') self._load_primitives(lib_name) elif cmd == 'effect': # (effect name :path "...") name = form[1].name if isinstance(form[1], Symbol) else str(form[1]) i = 2 while i < len(form): if isinstance(form[i], Keyword) and form[i].name == 'path': path = str(form[i + 1]).strip('"') full = (self.sexp_dir / path).resolve() self._load_effect(full) i += 2 else: i += 1 elif cmd == 'include': # (include :path "...") i = 1 while i < len(form): if isinstance(form[i], Keyword) and form[i].name == 'path': path = str(form[i + 1]).strip('"') full = (self.sexp_dir / path).resolve() self._load_effect(full) # Reuse effect loader for includes i += 2 else: i += 1 # === Sources === elif cmd == 'source': name = form[1].name if isinstance(form[1], Symbol) else str(form[1]) path = str(form[2]).strip('"') full = (self.sexp_dir / path).resolve() if full.exists(): self.sources[name] = VideoSource(str(full), self.ctx.fps) print(f"Source: {name} -> {full}", file=sys.stderr) else: print(f"Warning: {full} not found", file=sys.stderr) elif cmd == 'audio': name = form[1].name if isinstance(form[1], Symbol) else str(form[1]) path = str(form[2]).strip('"') full = (self.sexp_dir / path).resolve() if full.exists(): self.audios[name] = AudioAnalyzer(str(full)) self.audio_paths[name] = str(full) self.audio_state[name] = {'energy': 0.0, 'is_beat': False, 'beat_count': 0, 'last_beat': False} print(f"Audio: {name} -> {full}", file=sys.stderr) elif cmd == 'scan': name = form[1].name if isinstance(form[1], Symbol) else str(form[1]) # Trigger can be: # (beat audio-name) - trigger on beat from specific audio # beat - legacy: trigger on beat from first audio trigger_expr = form[2] if isinstance(trigger_expr, list) and len(trigger_expr) >= 2: # (beat audio-name) trigger_type = trigger_expr[0].name if isinstance(trigger_expr[0], Symbol) else str(trigger_expr[0]) trigger_audio = trigger_expr[1].name if isinstance(trigger_expr[1], Symbol) else str(trigger_expr[1]) trigger = (trigger_type, trigger_audio) else: # Legacy bare symbol trigger = trigger_expr.name if isinstance(trigger_expr, Symbol) else str(trigger_expr) init_val, step_expr = {}, None i = 3 while i < len(form): if isinstance(form[i], Keyword): if form[i].name == 'init' and i + 1 < len(form): init_val = self._eval(form[i + 1], {}) elif form[i].name == 'step' and i + 1 < len(form): step_expr = form[i + 1] i += 2 else: i += 1 self.scans[name] = { 'state': dict(init_val) if isinstance(init_val, dict) else {'acc': init_val}, 'init': init_val, 'step': step_expr, 'trigger': trigger, } trigger_str = f"{trigger[0]} {trigger[1]}" if isinstance(trigger, tuple) else trigger print(f"Scan: {name} (on {trigger_str})", file=sys.stderr) elif cmd == 'frame': # (frame expr) - the pipeline to evaluate each frame self.frame_pipeline = form[1] if len(form) > 1 else None # Set output size from first source if self.sources: first = next(iter(self.sources.values())) self.ctx.output_size = first.size def _eval(self, expr, env: dict) -> Any: """Evaluate an expression.""" import cv2 # Primitives if isinstance(expr, (int, float)): return expr if isinstance(expr, str): return expr if isinstance(expr, Symbol): name = expr.name # Built-in values if name == 't' or name == '_time': return self.ctx.t if name == 'pi': import math return math.pi if name == 'true': return True if name == 'false': return False if name == 'nil': return None # Environment lookup if name in env: return env[name] # Scan state lookup if name in self.scans: return self.scans[name]['state'] return 0 if isinstance(expr, Keyword): return expr.name if not isinstance(expr, list) or not expr: return expr # Dict literal {:key val ...} if isinstance(expr[0], Keyword): result = {} i = 0 while i < len(expr): if isinstance(expr[i], Keyword): result[expr[i].name] = self._eval(expr[i + 1], env) if i + 1 < len(expr) else None i += 2 else: i += 1 return result head = expr[0] if not isinstance(head, Symbol): return [self._eval(e, env) for e in expr] op = head.name args = expr[1:] # Check if op is a closure in environment if op in env: val = env[op] if isinstance(val, dict) and val.get('_type') == 'closure': # Invoke closure closure = val closure_env = dict(closure['env']) for i, pname in enumerate(closure['params']): closure_env[pname] = self._eval(args[i], env) if i < len(args) else None return self._eval(closure['body'], closure_env) # Threading macro if op == '->': result = self._eval(args[0], env) for form in args[1:]: if isinstance(form, list) and form: # Insert result as first arg new_form = [form[0], result] + form[1:] result = self._eval(new_form, env) else: result = self._eval([form, result], env) return result # === Audio analysis (explicit) === if op == 'energy': # (energy audio-name) - get current energy from named audio audio_name = args[0].name if isinstance(args[0], Symbol) else str(args[0]) if audio_name in self.audio_state: return self.audio_state[audio_name]['energy'] return 0.0 if op == 'beat': # (beat audio-name) - 1 if beat this frame, 0 otherwise audio_name = args[0].name if isinstance(args[0], Symbol) else str(args[0]) if audio_name in self.audio_state: return 1.0 if self.audio_state[audio_name]['is_beat'] else 0.0 return 0.0 if op == 'beat-count': # (beat-count audio-name) - total beats from named audio audio_name = args[0].name if isinstance(args[0], Symbol) else str(args[0]) if audio_name in self.audio_state: return self.audio_state[audio_name]['beat_count'] return 0 # === Frame operations === if op == 'read': # (read source-name) - get current frame from source (lazy read) name = args[0].name if isinstance(args[0], Symbol) else str(args[0]) if name not in self.frames: if name in self.sources: self.frames[name] = self.sources[name].read() self._sources_read.add(name) return self.frames.get(name) # === Binding and mapping === if op == 'bind': # (bind scan-name :field) or (bind scan-name) scan_name = args[0].name if isinstance(args[0], Symbol) else str(args[0]) field = None if len(args) > 1 and isinstance(args[1], Keyword): field = args[1].name if scan_name in self.scans: state = self.scans[scan_name]['state'] if field: return state.get(field, 0) return state return 0 if op == 'map': # (map value [lo hi]) val = self._eval(args[0], env) range_list = self._eval(args[1], env) if len(args) > 1 else [0, 1] if isinstance(range_list, list) and len(range_list) >= 2: lo, hi = range_list[0], range_list[1] return lo + val * (hi - lo) return val # === Arithmetic === if op == '+': return sum(self._eval(a, env) for a in args) if op == '-': vals = [self._eval(a, env) for a in args] return vals[0] - sum(vals[1:]) if len(vals) > 1 else -vals[0] if op == '*': result = 1 for a in args: result *= self._eval(a, env) return result if op == '/': vals = [self._eval(a, env) for a in args] return vals[0] / vals[1] if len(vals) > 1 and vals[1] != 0 else 0 if op == 'mod': vals = [self._eval(a, env) for a in args] return vals[0] % vals[1] if len(vals) > 1 and vals[1] != 0 else 0 if op == 'map-range': # (map-range val from-lo from-hi to-lo to-hi) val = self._eval(args[0], env) from_lo = self._eval(args[1], env) from_hi = self._eval(args[2], env) to_lo = self._eval(args[3], env) to_hi = self._eval(args[4], env) # Normalize val to 0-1 in source range, then scale to target range if from_hi == from_lo: return to_lo t = (val - from_lo) / (from_hi - from_lo) return to_lo + t * (to_hi - to_lo) # === Comparison === if op == '<': return self._eval(args[0], env) < self._eval(args[1], env) if op == '>': return self._eval(args[0], env) > self._eval(args[1], env) if op == '=': return self._eval(args[0], env) == self._eval(args[1], env) if op == '<=': return self._eval(args[0], env) <= self._eval(args[1], env) if op == '>=': return self._eval(args[0], env) >= self._eval(args[1], env) if op == 'and': for arg in args: if not self._eval(arg, env): return False return True if op == 'or': # Lisp-style or: returns first truthy value, or last value if none truthy result = False for arg in args: result = self._eval(arg, env) if result: return result return result if op == 'not': return not self._eval(args[0], env) # === Logic === if op == 'if': cond = self._eval(args[0], env) if cond: return self._eval(args[1], env) return self._eval(args[2], env) if len(args) > 2 else None if op == 'cond': # (cond pred1 expr1 pred2 expr2 ... true else-expr) i = 0 while i < len(args) - 1: pred = self._eval(args[i], env) if pred: return self._eval(args[i + 1], env) i += 2 return None if op == 'lambda': # (lambda (params...) body) - create a closure params = args[0] body = args[1] param_names = [p.name if isinstance(p, Symbol) else str(p) for p in params] # Return a closure dict that captures the current env return {'_type': 'closure', 'params': param_names, 'body': body, 'env': dict(env)} if op == 'let' or op == 'let*': # Support both formats: # (let [name val name val ...] body) - flat vector # (let ((name val) (name val) ...) body) - nested list # Note: our let already evaluates sequentially like let* bindings = args[0] body = args[1] new_env = dict(env) if bindings and isinstance(bindings[0], list): # Nested format: ((name val) (name val) ...) for binding in bindings: if isinstance(binding, list) and len(binding) >= 2: name = binding[0].name if isinstance(binding[0], Symbol) else str(binding[0]) val = self._eval(binding[1], new_env) new_env[name] = val else: # Flat format: [name val name val ...] i = 0 while i < len(bindings): name = bindings[i].name if isinstance(bindings[i], Symbol) else str(bindings[i]) val = self._eval(bindings[i + 1], new_env) new_env[name] = val i += 2 return self._eval(body, new_env) # === Random === if op == 'rand': return self.rng.random() if op == 'rand-int': lo = int(self._eval(args[0], env)) hi = int(self._eval(args[1], env)) return self.rng.randint(lo, hi) if op == 'rand-range': lo = self._eval(args[0], env) hi = self._eval(args[1], env) return lo + self.rng.random() * (hi - lo) # === Dict === if op == 'dict': result = {} i = 0 while i < len(args): if isinstance(args[i], Keyword): result[args[i].name] = self._eval(args[i + 1], env) if i + 1 < len(args) else None i += 2 else: i += 1 return result if op == 'get': d = self._eval(args[0], env) key = args[1].name if isinstance(args[1], Keyword) else self._eval(args[1], env) if isinstance(d, dict): return d.get(key, 0) return 0 # === List === if op == 'list': return [self._eval(a, env) for a in args] if op == 'nth': lst = self._eval(args[0], env) idx = int(self._eval(args[1], env)) if isinstance(lst, list) and 0 <= idx < len(lst): return lst[idx] return None if op == 'len': lst = self._eval(args[0], env) return len(lst) if isinstance(lst, (list, dict, str)) else 0 # === External effects === if op in self.effects: effect = self.effects[op] effect_env = dict(env) effect_env['t'] = self.ctx.t # Set defaults for all params param_names = list(effect['params'].keys()) for pname, pdef in effect['params'].items(): effect_env[pname] = pdef.get('default', 0) # Parse args: first is frame, then positional params, then kwargs positional_idx = 0 i = 0 while i < len(args): if isinstance(args[i], Keyword): # Keyword arg pname = args[i].name if pname in effect['params'] and i + 1 < len(args): effect_env[pname] = self._eval(args[i + 1], env) i += 2 else: # Positional arg val = self._eval(args[i], env) if positional_idx == 0: effect_env['frame'] = val elif positional_idx - 1 < len(param_names): effect_env[param_names[positional_idx - 1]] = val positional_idx += 1 i += 1 return self._eval(effect['body'], effect_env) # === External primitives === if op in self.primitives: prim_func = self.primitives[op] # Evaluate all args evaluated_args = [] kwargs = {} i = 0 while i < len(args): if isinstance(args[i], Keyword): k = args[i].name v = self._eval(args[i + 1], env) if i + 1 < len(args) else None kwargs[k] = v i += 2 else: evaluated_args.append(self._eval(args[i], env)) i += 1 # Call primitive try: if kwargs: return prim_func(*evaluated_args, **kwargs) return prim_func(*evaluated_args) except Exception as e: print(f"Primitive {op} error: {e}", file=sys.stderr) return None # === Macros === if op in self.macros: macro = self.macros[op] # Bind macro params to args (unevaluated) macro_env = dict(env) for i, pname in enumerate(macro['params']): macro_env[pname] = args[i] if i < len(args) else None # Expand and evaluate return self._eval(macro['body'], macro_env) # === Primitive-style call (name-with-dashes -> prim_name_with_underscores) === prim_name = op.replace('-', '_') if prim_name in self.primitives: prim_func = self.primitives[prim_name] evaluated_args = [] kwargs = {} i = 0 while i < len(args): if isinstance(args[i], Keyword): k = args[i].name.replace('-', '_') v = self._eval(args[i + 1], env) if i + 1 < len(args) else None kwargs[k] = v i += 2 else: evaluated_args.append(self._eval(args[i], env)) i += 1 try: if kwargs: return prim_func(*evaluated_args, **kwargs) return prim_func(*evaluated_args) except Exception as e: print(f"Primitive {op} error: {e}", file=sys.stderr) return None # Unknown - return as-is return expr def _step_scans(self): """Step scans on beat from specific audio.""" for name, scan in self.scans.items(): trigger = scan['trigger'] # Check if this scan should step should_step = False audio_name = None if isinstance(trigger, tuple) and trigger[0] == 'beat': # Explicit: (beat audio-name) audio_name = trigger[1] if audio_name in self.audio_state: should_step = self.audio_state[audio_name]['is_beat'] elif trigger == 'beat': # Legacy: use first audio if self.audio_state: audio_name = next(iter(self.audio_state)) should_step = self.audio_state[audio_name]['is_beat'] if should_step and audio_name: state = self.audio_state[audio_name] env = dict(scan['state']) env['beat_count'] = state['beat_count'] env['t'] = self.ctx.t env['energy'] = state['energy'] if scan['step']: new_state = self._eval(scan['step'], env) if isinstance(new_state, dict): scan['state'] = new_state elif new_state is not None: scan['state'] = {'acc': new_state} self.cache.record_scan_state(name, self.ctx.t, scan['state']) def run(self, duration: float = None, output: str = "pipe"): """Run the streaming pipeline.""" from .output import PipeOutput, DisplayOutput, FileOutput self._init() if not self.sources: print("Error: no sources", file=sys.stderr) return if not self.frame_pipeline: print("Error: no (frame ...) pipeline defined", file=sys.stderr) return w, h = self.ctx.output_size # Duration from first audio or default if duration is None: if self.audios: first_audio = next(iter(self.audios.values())) duration = first_audio.duration else: duration = 60.0 n_frames = int(duration * self.ctx.fps) frame_time = 1.0 / self.ctx.fps print(f"Streaming {n_frames} frames @ {self.ctx.fps}fps", file=sys.stderr) # Use first audio for playback sync first_audio_path = next(iter(self.audio_paths.values())) if self.audio_paths else None # Output if output == "pipe": out = PipeOutput(size=(w, h), fps=self.ctx.fps, audio_source=first_audio_path) elif output == "preview": out = DisplayOutput(size=(w, h), fps=self.ctx.fps, audio_source=first_audio_path) else: out = FileOutput(output, size=(w, h), fps=self.ctx.fps, audio_source=first_audio_path) try: for frame_num in range(n_frames): if not out.is_open: print(f"\nOutput closed at {frame_num}", file=sys.stderr) break self.ctx.t = frame_num * frame_time self.ctx.frame_num = frame_num # Update all audio states for audio_name, analyzer in self.audios.items(): state = self.audio_state[audio_name] energy = analyzer.get_energy(self.ctx.t) is_beat_raw = analyzer.get_beat(self.ctx.t) is_beat = is_beat_raw and not state['last_beat'] state['last_beat'] = is_beat_raw state['energy'] = energy state['is_beat'] = is_beat if is_beat: state['beat_count'] += 1 self.cache.record_analysis(f'{audio_name}_energy', self.ctx.t, energy) self.cache.record_analysis(f'{audio_name}_beat', self.ctx.t, 1.0 if is_beat else 0.0) # Step scans self._step_scans() # Clear frames - will be read lazily self.frames.clear() self._sources_read = set() # Evaluate pipeline (reads happen on-demand) result = self._eval(self.frame_pipeline, {}) # Skip unread sources to keep pipes in sync for name, src in self.sources.items(): if name not in self._sources_read: src.skip() # Ensure output size if result is not None: import cv2 if result.shape[:2] != (h, w): result = cv2.resize(result, (w, h)) out.write(result, self.ctx.t) # Progress if frame_num % 30 == 0: pct = 100 * frame_num / n_frames # Show beats from first audio total_beats = 0 if self.audio_state: first_state = next(iter(self.audio_state.values())) total_beats = first_state['beat_count'] print(f"\r{pct:5.1f}% | beats:{total_beats}", end="", file=sys.stderr) sys.stderr.flush() if frame_num % 300 == 0: self.cache.flush() except KeyboardInterrupt: print("\nInterrupted", file=sys.stderr) except Exception as e: print(f"\nError: {e}", file=sys.stderr) import traceback traceback.print_exc() finally: out.close() for src in self.sources.values(): src.close() self.cache.flush() print("\nDone", file=sys.stderr) def run_stream(sexp_path: str, duration: float = None, output: str = "pipe", fps: float = None): """Run a streaming sexp.""" interp = StreamInterpreter(sexp_path) if fps: interp.ctx.fps = fps interp.run(duration=duration, output=output) if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="Run streaming sexp") parser.add_argument("sexp", help="Path to .sexp file") parser.add_argument("-d", "--duration", type=float, default=None) parser.add_argument("-o", "--output", default="pipe") parser.add_argument("--fps", type=float, default=None, help="Override fps (default: from sexp)") args = parser.parse_args() run_stream(args.sexp, duration=args.duration, output=args.output, fps=args.fps)