Files
sideline/scripts/demo_image_oscilloscope.py
David Gwilliam ef98add0c5 feat(integration): Complete feature rewrite with pipeline architecture, effects system, and display improvements
Major changes:
- Pipeline architecture with capability-based dependency resolution
- Effects plugin system with performance monitoring
- Display abstraction with multiple backends (terminal, null, websocket)
- Camera system for viewport scrolling
- Sensor framework for real-time input
- Command-and-control system via ntfy
- WebSocket display backend for browser clients
- Comprehensive test suite and documentation

Issue #48: ADR for preset scripting language included

This commit consolidates 110 individual commits into a single
feature integration that can be reviewed and tested before
further refinement.
2026-03-20 04:41:44 -07:00

379 lines
11 KiB
Python

#!/usr/bin/env python3
"""
Oscilloscope with Image Data Source Integration
This demo:
1. Uses pygame to render oscillator waveforms
2. Converts to PIL Image (8-bit grayscale with transparency)
3. Renders to ANSI using image data source patterns
4. Features LFO modulation chain
Usage:
uv run python scripts/demo_image_oscilloscope.py --lfo --modulate
"""
import argparse
import sys
import time
from pathlib import Path
# Add mainline to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from engine.data_sources.sources import DataSource, ImageItem
from engine.sensors.oscillator import OscillatorSensor, register_oscillator_sensor
class ModulatedOscillator:
"""Oscillator with frequency modulation from another oscillator."""
def __init__(
self,
name: str,
waveform: str = "sine",
base_frequency: float = 1.0,
modulator: "OscillatorSensor | None" = None,
modulation_depth: float = 0.5,
):
self.name = name
self.waveform = waveform
self.base_frequency = base_frequency
self.modulator = modulator
self.modulation_depth = modulation_depth
register_oscillator_sensor(
name=name, waveform=waveform, frequency=base_frequency
)
self.osc = OscillatorSensor(
name=name, waveform=waveform, frequency=base_frequency
)
self.osc.start()
def read(self):
if self.modulator:
mod_reading = self.modulator.read()
if mod_reading:
mod_offset = (mod_reading.value - 0.5) * 2 * self.modulation_depth
effective_freq = self.base_frequency + mod_offset
effective_freq = max(0.1, min(effective_freq, 20.0))
self.osc._frequency = effective_freq
return self.osc.read()
def get_phase(self):
return self.osc._phase
def get_effective_frequency(self):
if self.modulator and self.modulator.read():
mod_reading = self.modulator.read()
mod_offset = (mod_reading.value - 0.5) * 2 * self.modulation_depth
return max(0.1, min(self.base_frequency + mod_offset, 20.0))
return self.base_frequency
def stop(self):
self.osc.stop()
class OscilloscopeDataSource(DataSource):
"""Dynamic data source that generates oscilloscope images from oscillators."""
def __init__(
self,
modulator: OscillatorSensor,
modulated: ModulatedOscillator,
width: int = 200,
height: int = 100,
):
self.modulator = modulator
self.modulated = modulated
self.width = width
self.height = height
self.frame = 0
# Check if pygame and PIL are available
import importlib.util
self.pygame_available = importlib.util.find_spec("pygame") is not None
self.pil_available = importlib.util.find_spec("PIL") is not None
@property
def name(self) -> str:
return "oscilloscope_image"
@property
def is_dynamic(self) -> bool:
return True
def fetch(self) -> list[ImageItem]:
"""Generate oscilloscope image from oscillators."""
if not self.pygame_available or not self.pil_available:
# Fallback to text-based source
return []
import pygame
from PIL import Image
# Create Pygame surface
surface = pygame.Surface((self.width, self.height))
surface.fill((10, 10, 20)) # Dark background
# Get readings
mod_reading = self.modulator.read()
mod_val = mod_reading.value if mod_reading else 0.5
modulated_reading = self.modulated.read()
modulated_val = modulated_reading.value if modulated_reading else 0.5
# Draw modulator waveform (top half)
top_height = self.height // 2
waveform_fn = self.modulator.WAVEFORMS[self.modulator.waveform]
mod_time_offset = self.modulator._phase * self.modulator.frequency * 0.3
prev_x, prev_y = 0, 0
for x in range(self.width):
col_fraction = x / self.width
time_pos = mod_time_offset + col_fraction
sample = waveform_fn(time_pos * self.modulator.frequency * 2)
y = int(top_height - (sample * (top_height - 10)) - 5)
if x > 0:
pygame.draw.line(surface, (100, 200, 255), (prev_x, prev_y), (x, y), 1)
prev_x, prev_y = x, y
# Draw separator
pygame.draw.line(
surface, (80, 80, 100), (0, top_height), (self.width, top_height), 1
)
# Draw modulated waveform (bottom half)
bottom_start = top_height + 1
bottom_height = self.height - bottom_start - 1
waveform_fn = self.modulated.osc.WAVEFORMS[self.modulated.waveform]
modulated_time_offset = (
self.modulated.get_phase() * self.modulated.get_effective_frequency() * 0.3
)
prev_x, prev_y = 0, 0
for x in range(self.width):
col_fraction = x / self.width
time_pos = modulated_time_offset + col_fraction
sample = waveform_fn(
time_pos * self.modulated.get_effective_frequency() * 2
)
y = int(
bottom_start + (bottom_height - (sample * (bottom_height - 10))) - 5
)
if x > 0:
pygame.draw.line(surface, (255, 150, 100), (prev_x, prev_y), (x, y), 1)
prev_x, prev_y = x, y
# Convert Pygame surface to PIL Image (8-bit grayscale with alpha)
img_str = pygame.image.tostring(surface, "RGB")
pil_rgb = Image.frombytes("RGB", (self.width, self.height), img_str)
# Convert to 8-bit grayscale
pil_gray = pil_rgb.convert("L")
# Create alpha channel (full opacity for now)
alpha = Image.new("L", (self.width, self.height), 255)
# Combine into RGBA
pil_rgba = Image.merge("RGBA", (pil_gray, pil_gray, pil_gray, alpha))
# Create ImageItem
item = ImageItem(
image=pil_rgba,
source="oscilloscope_image",
timestamp=str(time.time()),
path=None,
metadata={
"frame": self.frame,
"mod_value": mod_val,
"modulated_value": modulated_val,
},
)
self.frame += 1
return [item]
def render_pil_to_ansi(
pil_image, terminal_width: int = 80, terminal_height: int = 30
) -> str:
"""Convert PIL image (8-bit grayscale with transparency) to ANSI."""
# Resize for terminal display
resized = pil_image.resize((terminal_width * 2, terminal_height * 2))
# Extract grayscale and alpha channels
gray = resized.convert("L")
alpha = resized.split()[3] if len(resized.split()) > 3 else None
# ANSI character ramp (dark to light)
chars = " .:-=+*#%@"
lines = []
for y in range(0, resized.height, 2): # Sample every 2nd row for aspect ratio
line = ""
for x in range(0, resized.width, 2):
pixel = gray.getpixel((x, y))
# Check alpha if available
if alpha:
a = alpha.getpixel((x, y))
if a < 128: # Transparent
line += " "
continue
char_index = int((pixel / 255) * (len(chars) - 1))
line += chars[char_index]
lines.append(line)
return "\n".join(lines)
def demo_image_oscilloscope(
waveform: str = "sine",
base_freq: float = 0.5,
modulate: bool = False,
mod_waveform: str = "sine",
mod_freq: float = 0.5,
mod_depth: float = 0.5,
frames: int = 0,
):
"""Run oscilloscope with image data source integration."""
frame_interval = 1.0 / 15.0 # 15 FPS
print("Oscilloscope with Image Data Source Integration")
print("Frame rate: 15 FPS")
print()
# Create oscillators
modulator = OscillatorSensor(
name="modulator", waveform=mod_waveform, frequency=mod_freq
)
modulator.start()
modulated = ModulatedOscillator(
name="modulated",
waveform=waveform,
base_frequency=base_freq,
modulator=modulator if modulate else None,
modulation_depth=mod_depth,
)
# Create image data source
image_source = OscilloscopeDataSource(
modulator=modulator,
modulated=modulated,
width=200,
height=100,
)
# Run demo loop
try:
frame = 0
last_time = time.time()
while frames == 0 or frame < frames:
# Fetch image from data source
images = image_source.fetch()
if images:
# Convert to ANSI
visualization = render_pil_to_ansi(
images[0].image, terminal_width=80, terminal_height=30
)
else:
# Fallback to text message
visualization = (
"Pygame or PIL not available\n\n[Image rendering disabled]"
)
# Add header
header = f"IMAGE SOURCE MODE | Frame: {frame}"
header_line = "" * 80
visualization = f"{header}\n{header_line}\n" + visualization
# Display
print("\033[H" + visualization)
# Frame timing
elapsed = time.time() - last_time
sleep_time = max(0, frame_interval - elapsed)
time.sleep(sleep_time)
last_time = time.time()
frame += 1
except KeyboardInterrupt:
print("\n\nDemo stopped by user")
finally:
modulator.stop()
modulated.stop()
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Oscilloscope with image data source integration"
)
parser.add_argument(
"--waveform",
choices=["sine", "square", "sawtooth", "triangle", "noise"],
default="sine",
help="Main waveform type",
)
parser.add_argument(
"--frequency",
type=float,
default=0.5,
help="Main oscillator frequency",
)
parser.add_argument(
"--lfo",
action="store_true",
help="Use slow LFO frequency (0.5Hz)",
)
parser.add_argument(
"--modulate",
action="store_true",
help="Enable LFO modulation chain",
)
parser.add_argument(
"--mod-waveform",
choices=["sine", "square", "sawtooth", "triangle", "noise"],
default="sine",
help="Modulator waveform type",
)
parser.add_argument(
"--mod-freq",
type=float,
default=0.5,
help="Modulator frequency in Hz",
)
parser.add_argument(
"--mod-depth",
type=float,
default=0.5,
help="Modulation depth",
)
parser.add_argument(
"--frames",
type=int,
default=0,
help="Number of frames to render",
)
args = parser.parse_args()
base_freq = args.frequency
if args.lfo:
base_freq = 0.5
demo_image_oscilloscope(
waveform=args.waveform,
base_freq=base_freq,
modulate=args.modulate,
mod_waveform=args.mod_waveform,
mod_freq=args.mod_freq,
mod_depth=args.mod_depth,
frames=args.frames,
)