refactor: remove legacy demo code, integrate metrics via pipeline context
- Remove ~700 lines of legacy code from app.py (run_demo_mode, run_pipeline_demo, run_preset_mode, font picker, effects picker) - HUD now reads metrics from pipeline context (first-class citizen) with fallback to global monitor for backwards compatibility - Add validate_signal_flow() for PureData-style type validation in presets - Update MicSensor documentation (self-contained, doesn't use MicMonitor) - Delete test_app.py (was testing removed legacy code) - Update AGENTS.md with pipeline architecture documentation
This commit is contained in:
43
AGENTS.md
43
AGENTS.md
@@ -161,7 +161,7 @@ The project uses pytest with strict marker enforcement. Test configuration is in
|
|||||||
|
|
||||||
### Test Coverage Strategy
|
### Test Coverage Strategy
|
||||||
|
|
||||||
Current coverage: 56% (336 tests)
|
Current coverage: 56% (433 tests)
|
||||||
|
|
||||||
Key areas with lower coverage (acceptable for now):
|
Key areas with lower coverage (acceptable for now):
|
||||||
- **app.py** (8%): Main entry point - integration heavy, requires terminal
|
- **app.py** (8%): Main entry point - integration heavy, requires terminal
|
||||||
@@ -192,6 +192,47 @@ Performance regression tests are in `tests/test_benchmark.py` with `@pytest.mark
|
|||||||
- **effects/** - plugin architecture with performance monitoring
|
- **effects/** - plugin architecture with performance monitoring
|
||||||
- The render pipeline: fetch → render → effects → scroll → terminal output
|
- The render pipeline: fetch → render → effects → scroll → terminal output
|
||||||
|
|
||||||
|
### Pipeline Architecture
|
||||||
|
|
||||||
|
The new Stage-based pipeline architecture provides capability-based dependency resolution:
|
||||||
|
|
||||||
|
- **Stage** (`engine/pipeline/core.py`): Base class for pipeline stages
|
||||||
|
- **Pipeline** (`engine/pipeline/controller.py`): Executes stages with capability-based dependency resolution
|
||||||
|
- **StageRegistry** (`engine/pipeline/registry.py`): Discovers and registers stages
|
||||||
|
- **Stage Adapters** (`engine/pipeline/adapters.py`): Wraps existing components as stages
|
||||||
|
|
||||||
|
#### Capability-Based Dependencies
|
||||||
|
|
||||||
|
Stages declare capabilities (what they provide) and dependencies (what they need). The Pipeline resolves dependencies using prefix matching:
|
||||||
|
- `"source"` matches `"source.headlines"`, `"source.poetry"`, etc.
|
||||||
|
- This allows flexible composition without hardcoding specific stage names
|
||||||
|
|
||||||
|
#### Sensor Framework
|
||||||
|
|
||||||
|
- **Sensor** (`engine/sensors/__init__.py`): Base class for real-time input sensors
|
||||||
|
- **SensorRegistry**: Discovers available sensors
|
||||||
|
- **SensorStage**: Pipeline adapter that provides sensor values to effects
|
||||||
|
- **MicSensor** (`engine/sensors/mic.py`): Self-contained microphone input
|
||||||
|
- **OscillatorSensor** (`engine/sensors/oscillator.py`): Test sensor for development
|
||||||
|
|
||||||
|
Sensors support param bindings to drive effect parameters in real-time.
|
||||||
|
|
||||||
|
### Preset System
|
||||||
|
|
||||||
|
Presets use TOML format (no external dependencies):
|
||||||
|
|
||||||
|
- Built-in: `engine/presets.toml`
|
||||||
|
- User config: `~/.config/mainline/presets.toml`
|
||||||
|
- Local override: `./presets.toml`
|
||||||
|
|
||||||
|
- **Preset loader** (`engine/pipeline/preset_loader.py`): Loads and validates presets
|
||||||
|
- **PipelinePreset** (`engine/pipeline/presets.py`): Dataclass for preset configuration
|
||||||
|
|
||||||
|
Functions:
|
||||||
|
- `validate_preset()` - Validate preset structure
|
||||||
|
- `validate_signal_path()` - Detect circular dependencies
|
||||||
|
- `generate_preset_toml()` - Generate skeleton preset
|
||||||
|
|
||||||
### Display System
|
### Display System
|
||||||
|
|
||||||
- **Display abstraction** (`engine/display/`): swap display backends via the Display protocol
|
- **Display abstraction** (`engine/display/`): swap display backends via the Display protocol
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from engine.effects.performance import get_monitor
|
|
||||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
@@ -8,15 +7,34 @@ class HudEffect(EffectPlugin):
|
|||||||
|
|
||||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
result = list(buf)
|
result = list(buf)
|
||||||
monitor = get_monitor()
|
|
||||||
|
|
||||||
fps = 0.0
|
# Read metrics from pipeline context (first-class citizen)
|
||||||
frame_time = 0.0
|
# Falls back to global monitor for backwards compatibility
|
||||||
|
metrics = ctx.get_state("metrics")
|
||||||
|
if not metrics:
|
||||||
|
# Fallback to global monitor for backwards compatibility
|
||||||
|
from engine.effects.performance import get_monitor
|
||||||
|
|
||||||
|
monitor = get_monitor()
|
||||||
if monitor:
|
if monitor:
|
||||||
stats = monitor.get_stats()
|
stats = monitor.get_stats()
|
||||||
if stats and "pipeline" in stats:
|
if stats and "pipeline" in stats:
|
||||||
frame_time = stats["pipeline"].get("avg_ms", 0.0)
|
metrics = stats
|
||||||
frame_count = stats.get("frame_count", 0)
|
|
||||||
|
fps = 0.0
|
||||||
|
frame_time = 0.0
|
||||||
|
if metrics:
|
||||||
|
if "error" in metrics:
|
||||||
|
pass # No metrics available yet
|
||||||
|
elif "pipeline" in metrics:
|
||||||
|
frame_time = metrics["pipeline"].get("avg_ms", 0.0)
|
||||||
|
frame_count = metrics.get("frame_count", 0)
|
||||||
|
if frame_count > 0 and frame_time > 0:
|
||||||
|
fps = 1000.0 / frame_time
|
||||||
|
elif "avg_ms" in metrics:
|
||||||
|
# Direct metrics format
|
||||||
|
frame_time = metrics.get("avg_ms", 0.0)
|
||||||
|
frame_count = metrics.get("frame_count", 0)
|
||||||
if frame_count > 0 and frame_time > 0:
|
if frame_count > 0 and frame_time > 0:
|
||||||
fps = 1000.0 / frame_time
|
fps = 1000.0 / frame_time
|
||||||
|
|
||||||
@@ -44,10 +62,16 @@ class HudEffect(EffectPlugin):
|
|||||||
f"\033[2;1H\033[38;5;45mEFFECT:\033[0m \033[1;38;5;227m{effect_name:12s}\033[0m \033[38;5;245m|\033[0m {bar} \033[38;5;245m|\033[0m \033[38;5;219m{effect_intensity * 100:.0f}%\033[0m"
|
f"\033[2;1H\033[38;5;45mEFFECT:\033[0m \033[1;38;5;227m{effect_name:12s}\033[0m \033[38;5;245m|\033[0m {bar} \033[38;5;245m|\033[0m \033[38;5;219m{effect_intensity * 100:.0f}%\033[0m"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Try to get pipeline order from context
|
||||||
|
pipeline_order = ctx.get_state("pipeline_order")
|
||||||
|
if pipeline_order:
|
||||||
|
pipeline_str = ",".join(pipeline_order)
|
||||||
|
else:
|
||||||
|
# Fallback to legacy effect chain
|
||||||
from engine.effects import get_effect_chain
|
from engine.effects import get_effect_chain
|
||||||
|
|
||||||
chain = get_effect_chain()
|
chain = get_effect_chain()
|
||||||
order = chain.get_order()
|
order = chain.get_order() if chain else []
|
||||||
pipeline_str = ",".join(order) if order else "(none)"
|
pipeline_str = ",".join(order) if order else "(none)"
|
||||||
hud_lines.append(f"\033[3;1H\033[38;5;44mPIPELINE:\033[0m {pipeline_str}")
|
hud_lines.append(f"\033[3;1H\033[38;5;44mPIPELINE:\033[0m {pipeline_str}")
|
||||||
|
|
||||||
|
|||||||
1008
engine/app.py
1008
engine/app.py
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ Gracefully degrades if sounddevice/numpy are unavailable.
|
|||||||
|
|
||||||
.. deprecated::
|
.. deprecated::
|
||||||
For pipeline integration, use :class:`engine.sensors.mic.MicSensor` instead.
|
For pipeline integration, use :class:`engine.sensors.mic.MicSensor` instead.
|
||||||
MicMonitor is still used as the backend for MicSensor.
|
MicSensor is a self-contained implementation and does not use MicMonitor.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
|
|||||||
@@ -152,6 +152,64 @@ def validate_preset(preset: dict[str, Any]) -> list[str]:
|
|||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_signal_flow(stages: list[dict]) -> list[str]:
|
||||||
|
"""Validate signal flow based on inlet/outlet types.
|
||||||
|
|
||||||
|
This validates that the preset's stage configuration produces valid
|
||||||
|
data flow using the PureData-style type system.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stages: List of stage configs with 'name', 'category', 'inlet_types', 'outlet_types'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of errors (empty if valid)
|
||||||
|
"""
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
if not stages:
|
||||||
|
errors.append("Signal flow is empty")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
# Define expected types for each category
|
||||||
|
type_map = {
|
||||||
|
"source": {"inlet": "NONE", "outlet": "SOURCE_ITEMS"},
|
||||||
|
"data": {"inlet": "ANY", "outlet": "SOURCE_ITEMS"},
|
||||||
|
"transform": {"inlet": "SOURCE_ITEMS", "outlet": "TEXT_BUFFER"},
|
||||||
|
"effect": {"inlet": "TEXT_BUFFER", "outlet": "TEXT_BUFFER"},
|
||||||
|
"overlay": {"inlet": "TEXT_BUFFER", "outlet": "TEXT_BUFFER"},
|
||||||
|
"camera": {"inlet": "TEXT_BUFFER", "outlet": "TEXT_BUFFER"},
|
||||||
|
"display": {"inlet": "TEXT_BUFFER", "outlet": "NONE"},
|
||||||
|
"render": {"inlet": "SOURCE_ITEMS", "outlet": "TEXT_BUFFER"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check stage order and type compatibility
|
||||||
|
for i, stage in enumerate(stages):
|
||||||
|
category = stage.get("category", "unknown")
|
||||||
|
name = stage.get("name", f"stage_{i}")
|
||||||
|
|
||||||
|
if category not in type_map:
|
||||||
|
continue # Skip unknown categories
|
||||||
|
|
||||||
|
expected = type_map[category]
|
||||||
|
|
||||||
|
# Check against previous stage
|
||||||
|
if i > 0:
|
||||||
|
prev = stages[i - 1]
|
||||||
|
prev_category = prev.get("category", "unknown")
|
||||||
|
if prev_category in type_map:
|
||||||
|
prev_outlet = type_map[prev_category]["outlet"]
|
||||||
|
inlet = expected["inlet"]
|
||||||
|
|
||||||
|
# Validate type compatibility
|
||||||
|
if inlet != "ANY" and prev_outlet != "ANY" and inlet != prev_outlet:
|
||||||
|
errors.append(
|
||||||
|
f"Type mismatch at '{name}': "
|
||||||
|
f"expects {inlet} but previous stage outputs {prev_outlet}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
def validate_signal_path(stages: list[str]) -> list[str]:
|
def validate_signal_path(stages: list[str]) -> list[str]:
|
||||||
"""Validate signal path for circular dependencies and connectivity.
|
"""Validate signal path for circular dependencies and connectivity.
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for engine.app module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from engine.app import _normalize_preview_rows
|
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizePreviewRows:
|
|
||||||
"""Tests for _normalize_preview_rows function."""
|
|
||||||
|
|
||||||
def test_empty_rows(self):
|
|
||||||
"""Empty input returns empty list."""
|
|
||||||
result = _normalize_preview_rows([])
|
|
||||||
assert result == [""]
|
|
||||||
|
|
||||||
def test_strips_left_padding(self):
|
|
||||||
"""Left padding is stripped."""
|
|
||||||
result = _normalize_preview_rows([" content", " more"])
|
|
||||||
assert all(not r.startswith(" ") for r in result)
|
|
||||||
|
|
||||||
def test_preserves_content(self):
|
|
||||||
"""Content is preserved."""
|
|
||||||
result = _normalize_preview_rows([" hello world "])
|
|
||||||
assert "hello world" in result[0]
|
|
||||||
|
|
||||||
def test_handles_all_empty_rows(self):
|
|
||||||
"""All empty rows returns single empty string."""
|
|
||||||
result = _normalize_preview_rows(["", " ", ""])
|
|
||||||
assert result == [""]
|
|
||||||
|
|
||||||
|
|
||||||
class TestAppConstants:
|
|
||||||
"""Tests for app module constants."""
|
|
||||||
|
|
||||||
def test_title_defined(self):
|
|
||||||
"""TITLE is defined."""
|
|
||||||
from engine.app import TITLE
|
|
||||||
|
|
||||||
assert len(TITLE) > 0
|
|
||||||
|
|
||||||
def test_title_lines_are_strings(self):
|
|
||||||
"""TITLE contains string lines."""
|
|
||||||
from engine.app import TITLE
|
|
||||||
|
|
||||||
assert all(isinstance(line, str) for line in TITLE)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAppImports:
|
|
||||||
"""Tests for app module imports."""
|
|
||||||
|
|
||||||
def test_app_imports_without_error(self):
|
|
||||||
"""Module imports without error."""
|
|
||||||
from engine import app
|
|
||||||
|
|
||||||
assert app is not None
|
|
||||||
Reference in New Issue
Block a user