#!/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, )