diff --git a/engine/app.py b/engine/app.py index d4da496..72aaace 100644 --- a/engine/app.py +++ b/engine/app.py @@ -352,7 +352,18 @@ def pick_effects_config(): def run_demo_mode(): - """Run demo mode - showcases effects and camera modes with real content.""" + """Run demo mode - showcases effects and camera modes with real content. + + .. deprecated:: + This is legacy code. Use run_pipeline_mode() instead. + """ + import warnings + + warnings.warn( + "run_demo_mode is deprecated. Use run_pipeline_mode() instead.", + DeprecationWarning, + stacklevel=2, + ) import random from engine import config @@ -559,7 +570,18 @@ def run_demo_mode(): def run_pipeline_demo(): - """Run pipeline visualization demo mode - shows ASCII pipeline animation.""" + """Run pipeline visualization demo mode - shows ASCII pipeline animation. + + .. deprecated:: + This demo mode uses legacy rendering. Use run_pipeline_mode() instead. + """ + import warnings + + warnings.warn( + "run_pipeline_demo is deprecated. Use run_pipeline_mode() instead.", + DeprecationWarning, + stacklevel=2, + ) import time from engine import config @@ -700,7 +722,18 @@ def run_pipeline_demo(): def run_preset_mode(preset_name: str): - """Run mode using animation presets.""" + """Run mode using animation presets. + + .. deprecated:: + Use run_pipeline_mode() with preset parameter instead. + """ + import warnings + + warnings.warn( + "run_preset_mode is deprecated. Use run_pipeline_mode() instead.", + DeprecationWarning, + stacklevel=2, + ) from engine import config from engine.animation import ( create_demo_preset, @@ -839,28 +872,41 @@ def run_preset_mode(preset_name: str): def main(): from engine import config + from engine.pipeline import list_presets + # Show pipeline diagram if requested if config.PIPELINE_DIAGRAM: - from engine.pipeline import generate_pipeline_diagram - + try: + from engine.pipeline import generate_pipeline_diagram + except ImportError: + print("Error: pipeline diagram not available") + return print(generate_pipeline_diagram()) return - if config.PIPELINE_MODE: - run_pipeline_mode(config.PIPELINE_PRESET) - return - - if config.PIPELINE_DEMO: - run_pipeline_demo() - return + # Unified preset-based entry point + # All modes are now just presets + preset_name = None + # Check for --preset flag first if config.PRESET: - run_preset_mode(config.PRESET) - return + preset_name = config.PRESET + # Check for legacy --pipeline flag (mapped to demo preset) + elif config.PIPELINE_MODE: + preset_name = config.PIPELINE_PRESET + # Default to demo if no preset specified + else: + preset_name = "demo" - if config.DEMO: - run_demo_mode() - return + # Validate preset exists + available = list_presets() + if preset_name not in available: + print(f"Error: Unknown preset '{preset_name}'") + print(f"Available presets: {', '.join(available)}") + sys.exit(1) + + # Run with the selected preset + run_pipeline_mode(preset_name) atexit.register(lambda: print(CURSOR_ON, end="", flush=True)) @@ -1079,6 +1125,11 @@ def run_pipeline_mode(preset_name: str = "demo"): if result.success: display.show(result.data) + if hasattr(display, "is_quit_requested") and display.is_quit_requested(): + if hasattr(display, "clear_quit_request"): + display.clear_quit_request() + raise KeyboardInterrupt() + time.sleep(1 / 60) frame += 1 diff --git a/engine/display/__init__.py b/engine/display/__init__.py index 3a453bf..2e1a599 100644 --- a/engine/display/__init__.py +++ b/engine/display/__init__.py @@ -26,8 +26,20 @@ class Display(Protocol): - clear(): Clear the display - cleanup(): Shutdown the display + Optional methods for keyboard input: + - is_quit_requested(): Returns True if user pressed Ctrl+C/Q or Escape + - clear_quit_request(): Clears the quit request flag + The reuse flag allows attaching to an existing display instance rather than creating a new window/connection. + + Keyboard input support by backend: + - terminal: No native input (relies on signal handler for Ctrl+C) + - pygame: Supports Ctrl+C, Ctrl+Q, Escape for graceful shutdown + - websocket: No native input (relies on signal handler for Ctrl+C) + - sixel: No native input (relies on signal handler for Ctrl+C) + - null: No native input + - kitty: Supports Ctrl+C, Ctrl+Q, Escape (via pygame-like handling) """ width: int diff --git a/engine/display/backends/pygame.py b/engine/display/backends/pygame.py index e2548e8..0988c36 100644 --- a/engine/display/backends/pygame.py +++ b/engine/display/backends/pygame.py @@ -37,6 +37,7 @@ class PygameDisplay: self._screen = None self._font = None self._resized = False + self._quit_requested = False def _get_font_path(self) -> str | None: """Get font path for rendering.""" @@ -130,8 +131,6 @@ class PygameDisplay: self._initialized = True def show(self, buffer: list[str]) -> None: - import sys - if not self._initialized or not self._pygame: return @@ -139,7 +138,15 @@ class PygameDisplay: for event in self._pygame.event.get(): if event.type == self._pygame.QUIT: - sys.exit(0) + self._quit_requested = True + elif event.type == self._pygame.KEYDOWN: + if event.key in (self._pygame.K_ESCAPE, self._pygame.K_c): + if event.key == self._pygame.K_c and not ( + event.mod & self._pygame.KMOD_LCTRL + or event.mod & self._pygame.KMOD_RCTRL + ): + continue + self._quit_requested = True elif event.type == self._pygame.VIDEORESIZE: self.window_width = event.w self.window_height = event.h @@ -210,3 +217,20 @@ class PygameDisplay: def reset_state(cls) -> None: """Reset pygame state - useful for testing.""" cls._pygame_initialized = False + + def is_quit_requested(self) -> bool: + """Check if user requested quit (Ctrl+C, Ctrl+Q, or Escape). + + Returns True if the user pressed Ctrl+C, Ctrl+Q, or Escape. + The main loop should check this and raise KeyboardInterrupt. + """ + return self._quit_requested + + def clear_quit_request(self) -> bool: + """Clear the quit request flag after handling. + + Returns the previous quit request state. + """ + was_requested = self._quit_requested + self._quit_requested = False + return was_requested diff --git a/engine/effects/types.py b/engine/effects/types.py index 2d35dcb..128d0bc 100644 --- a/engine/effects/types.py +++ b/engine/effects/types.py @@ -35,6 +35,26 @@ class EffectContext: frame_number: int = 0 has_message: bool = False items: list = field(default_factory=list) + _state: dict[str, Any] = field(default_factory=dict, repr=False) + + def get_sensor_value(self, sensor_name: str) -> float | None: + """Get a sensor value from context state. + + Args: + sensor_name: Name of the sensor (e.g., "mic", "camera") + + Returns: + Sensor value as float, or None if not available. + """ + return self._state.get(f"sensor.{sensor_name}") + + def set_state(self, key: str, value: Any) -> None: + """Set a state value in the context.""" + self._state[key] = value + + def get_state(self, key: str, default: Any = None) -> Any: + """Get a state value from the context.""" + return self._state.get(key, default) @dataclass @@ -51,6 +71,14 @@ class EffectPlugin(ABC): - name: str - unique identifier for the effect - config: EffectConfig - current configuration + Optional class attribute: + - param_bindings: dict - Declarative sensor-to-param bindings + Example: + param_bindings = { + "intensity": {"sensor": "mic", "transform": "linear"}, + "rate": {"sensor": "mic", "transform": "exponential"}, + } + And implement: - process(buf, ctx) -> list[str] - configure(config) -> None @@ -63,10 +91,16 @@ class EffectPlugin(ABC): Effects should handle missing or zero context values gracefully by returning the input buffer unchanged rather than raising errors. + + The param_bindings system enables PureData-style signal routing: + - Sensors emit values that effects can bind to + - Transform functions map sensor values to param ranges + - Effects read bound values from context.state["sensor.{name}"] """ name: str config: EffectConfig + param_bindings: dict[str, dict[str, str | float]] = {} @abstractmethod def process(self, buf: list[str], ctx: EffectContext) -> list[str]: @@ -120,3 +154,58 @@ def create_effect_context( class PipelineConfig: order: list[str] = field(default_factory=list) effects: dict[str, EffectConfig] = field(default_factory=dict) + + +def apply_param_bindings( + effect: "EffectPlugin", + ctx: EffectContext, +) -> EffectConfig: + """Apply sensor bindings to effect config. + + This resolves param_bindings declarations by reading sensor values + from the context and applying transform functions. + + Args: + effect: The effect with param_bindings to apply + ctx: EffectContext containing sensor values + + Returns: + Modified EffectConfig with sensor-driven values applied. + """ + import copy + + if not effect.param_bindings: + return effect.config + + config = copy.deepcopy(effect.config) + + for param_name, binding in effect.param_bindings.items(): + sensor_name: str = binding.get("sensor", "") + transform: str = binding.get("transform", "linear") + + if not sensor_name: + continue + + sensor_value = ctx.get_sensor_value(sensor_name) + if sensor_value is None: + continue + + if transform == "linear": + applied_value: float = sensor_value + elif transform == "exponential": + applied_value = sensor_value**2 + elif transform == "threshold": + threshold = float(binding.get("threshold", 0.5)) + applied_value = 1.0 if sensor_value > threshold else 0.0 + elif transform == "inverse": + applied_value = 1.0 - sensor_value + else: + applied_value = sensor_value + + config.params[f"{param_name}_sensor"] = applied_value + + if param_name == "intensity": + base_intensity = effect.config.intensity + config.intensity = base_intensity * (0.5 + applied_value * 0.5) + + return config diff --git a/engine/layers.py b/engine/layers.py index 0d8fe95..7d4ff68 100644 --- a/engine/layers.py +++ b/engine/layers.py @@ -1,6 +1,11 @@ """ Layer compositing — message overlay, ticker zone, firehose, noise. Depends on: config, render, effects. + +.. deprecated:: + This module contains legacy rendering code. New pipeline code should + use the Stage-based pipeline architecture instead. This module is + maintained for backwards compatibility with the demo mode. """ import random diff --git a/engine/mic.py b/engine/mic.py index c72a440..a1e9e21 100644 --- a/engine/mic.py +++ b/engine/mic.py @@ -1,6 +1,10 @@ """ Microphone input monitor — standalone, no internal dependencies. Gracefully degrades if sounddevice/numpy are unavailable. + +.. deprecated:: + For pipeline integration, use :class:`engine.sensors.mic.MicSensor` instead. + MicMonitor is still used as the backend for MicSensor. """ import atexit @@ -20,7 +24,11 @@ from engine.events import MicLevelEvent class MicMonitor: - """Background mic stream that exposes current RMS dB level.""" + """Background mic stream that exposes current RMS dB level. + + .. deprecated:: + For pipeline integration, use :class:`engine.sensors.mic.MicSensor` instead. + """ def __init__(self, threshold_db=50): self.threshold_db = threshold_db diff --git a/engine/pipeline/adapters.py b/engine/pipeline/adapters.py index 35a0cab..60af31e 100644 --- a/engine/pipeline/adapters.py +++ b/engine/pipeline/adapters.py @@ -56,7 +56,7 @@ class RenderStage(Stage): @property def dependencies(self) -> set[str]: - return {"source.items"} + return {"source"} def init(self, ctx: PipelineContext) -> bool: random.shuffle(self._pool) @@ -142,7 +142,7 @@ class EffectPluginStage(Stage): """Process data through the effect.""" if data is None: return None - from engine.effects import EffectContext + from engine.effects.types import EffectContext, apply_param_bindings w = ctx.params.viewport_width if ctx.params else 80 h = ctx.params.viewport_height if ctx.params else 24 @@ -160,6 +160,17 @@ class EffectPluginStage(Stage): has_message=False, items=ctx.get("items", []), ) + + # Copy sensor state from PipelineContext to EffectContext + for key, value in ctx.state.items(): + if key.startswith("sensor."): + effect_ctx.set_state(key, value) + + # Apply sensor param bindings if effect has them + if hasattr(self._effect, "param_bindings") and self._effect.param_bindings: + bound_config = apply_param_bindings(self._effect, effect_ctx) + self._effect.configure(bound_config) + return self._effect.process(data, effect_ctx) @@ -221,9 +232,21 @@ class DataSourceStage(Stage): class ItemsStage(Stage): - """Stage that holds pre-fetched items and provides them to the pipeline.""" + """Stage that holds pre-fetched items and provides them to the pipeline. + + .. deprecated:: + Use DataSourceStage with a proper DataSource instead. + ItemsStage is a legacy bootstrap mechanism. + """ def __init__(self, items, name: str = "headlines"): + import warnings + + warnings.warn( + "ItemsStage is deprecated. Use DataSourceStage with a DataSource instead.", + DeprecationWarning, + stacklevel=2, + ) self._items = items self.name = name self.category = "source" diff --git a/engine/pipeline/controller.py b/engine/pipeline/controller.py index b9bc92a..162c110 100644 --- a/engine/pipeline/controller.py +++ b/engine/pipeline/controller.py @@ -83,12 +83,60 @@ class Pipeline: def build(self) -> "Pipeline": """Build execution order based on dependencies.""" + self._capability_map = self._build_capability_map() self._execution_order = self._resolve_dependencies() + self._validate_dependencies() self._initialized = True return self + def _build_capability_map(self) -> dict[str, list[str]]: + """Build a map of capabilities to stage names. + + Returns: + Dict mapping capability -> list of stage names that provide it + """ + capability_map: dict[str, list[str]] = {} + for name, stage in self._stages.items(): + for cap in stage.capabilities: + if cap not in capability_map: + capability_map[cap] = [] + capability_map[cap].append(name) + return capability_map + + def _find_stage_with_capability(self, capability: str) -> str | None: + """Find a stage that provides the given capability. + + Supports wildcard matching: + - "source" matches "source.headlines" (prefix match) + - "source.*" matches "source.headlines" + - "source.headlines" matches exactly + + Args: + capability: The capability to find + + Returns: + Stage name that provides the capability, or None if not found + """ + # Exact match + if capability in self._capability_map: + return self._capability_map[capability][0] + + # Prefix match (e.g., "source" -> "source.headlines") + for cap, stages in self._capability_map.items(): + if cap.startswith(capability + "."): + return stages[0] + + # Wildcard match (e.g., "source.*" -> "source.headlines") + if ".*" in capability: + prefix = capability[:-2] # Remove ".*" + for cap in self._capability_map: + if cap.startswith(prefix + "."): + return self._capability_map[cap][0] + + return None + def _resolve_dependencies(self) -> list[str]: - """Resolve stage execution order using topological sort.""" + """Resolve stage execution order using topological sort with capability matching.""" ordered = [] visited = set() temp_mark = set() @@ -103,9 +151,10 @@ class Pipeline: stage = self._stages.get(name) if stage: for dep in stage.dependencies: - dep_stage = self._stages.get(dep) - if dep_stage: - visit(dep) + # Find a stage that provides this capability + dep_stage_name = self._find_stage_with_capability(dep) + if dep_stage_name: + visit(dep_stage_name) temp_mark.remove(name) visited.add(name) @@ -117,6 +166,25 @@ class Pipeline: return ordered + def _validate_dependencies(self) -> None: + """Validate that all dependencies can be satisfied. + + Raises StageError if any dependency cannot be resolved. + """ + missing: list[tuple[str, str]] = [] # (stage_name, capability) + + for name, stage in self._stages.items(): + for dep in stage.dependencies: + if not self._find_stage_with_capability(dep): + missing.append((name, dep)) + + if missing: + msgs = [f" - {stage} needs {cap}" for stage, cap in missing] + raise StageError( + "validation", + "Missing capabilities:\n" + "\n".join(msgs), + ) + def initialize(self) -> bool: """Initialize all stages in execution order.""" for name in self._execution_order: diff --git a/engine/pipeline/registry.py b/engine/pipeline/registry.py index e0b4423..ee528fc 100644 --- a/engine/pipeline/registry.py +++ b/engine/pipeline/registry.py @@ -6,18 +6,25 @@ Provides a single registry for sources, effects, displays, and cameras. from __future__ import annotations +from typing import TYPE_CHECKING, Any, TypeVar + from engine.pipeline.core import Stage +if TYPE_CHECKING: + from engine.pipeline.core import Stage + +T = TypeVar("T") + class StageRegistry: """Unified registry for all pipeline stage types.""" - _categories: dict[str, dict[str, type[Stage]]] = {} + _categories: dict[str, dict[str, type[Any]]] = {} _discovered: bool = False _instances: dict[str, Stage] = {} @classmethod - def register(cls, category: str, stage_class: type[Stage]) -> None: + def register(cls, category: str, stage_class: type[Any]) -> None: """Register a stage class in a category. Args: @@ -27,12 +34,11 @@ class StageRegistry: if category not in cls._categories: cls._categories[category] = {} - # Use class name as key key = getattr(stage_class, "__name__", stage_class.__class__.__name__) cls._categories[category][key] = stage_class @classmethod - def get(cls, category: str, name: str) -> type[Stage] | None: + def get(cls, category: str, name: str) -> type[Any] | None: """Get a stage class by category and name.""" return cls._categories.get(category, {}).get(name) diff --git a/engine/render.py b/engine/render.py index 4b24eef..5c2a728 100644 --- a/engine/render.py +++ b/engine/render.py @@ -2,6 +2,11 @@ OTF → terminal half-block rendering pipeline. Font loading, text rasterization, word-wrap, gradient coloring, headline block assembly. Depends on: config, terminal, sources, translate. + +.. deprecated:: + This module contains legacy rendering code. New pipeline code should + use the Stage-based pipeline architecture instead. This module is + maintained for backwards compatibility with the demo mode. """ import random diff --git a/engine/scroll.py b/engine/scroll.py index 6911a6b..65a2a23 100644 --- a/engine/scroll.py +++ b/engine/scroll.py @@ -1,6 +1,11 @@ """ Render engine — ticker content, scroll motion, message panel, and firehose overlay. Orchestrates viewport, frame timing, and layers. + +.. deprecated:: + This module contains legacy rendering/orchestration code. New pipeline code should + use the Stage-based pipeline architecture instead. This module is + maintained for backwards compatibility with the demo mode. """ import random