forked from genewildish/Mainline
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.
379 lines
11 KiB
Python
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,
|
|
)
|