diff --git a/scripts/demo_image_oscilloscope.py b/scripts/demo_image_oscilloscope.py new file mode 100644 index 0000000..d72a1d5 --- /dev/null +++ b/scripts/demo_image_oscilloscope.py @@ -0,0 +1,378 @@ +#!/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, + )