#!/usr/bin/env python3 """ Enhanced Oscilloscope with Pipeline Switching This demo features: 1. Text-based oscilloscope (first 15 seconds) 2. Pygame renderer with PIL to ANSI conversion (next 15 seconds) 3. Continuous looping between the two modes Usage: uv run python scripts/demo_oscilloscope_pipeline.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.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): """Read current value, applying modulation if present.""" 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: mod_reading = self.modulator.read() if mod_reading: 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() def render_text_mode( width: int, height: int, modulator: OscillatorSensor, modulated: ModulatedOscillator, frame: int, ) -> str: """Render dual waveforms in text mode.""" mod_reading = modulator.read() mod_val = mod_reading.value if mod_reading else 0.5 modulated_reading = modulated.read() modulated_val = modulated_reading.value if modulated_reading else 0.5 lines = [] header1 = ( f"TEXT MODE | MODULATOR: {modulator.waveform} @ {modulator.frequency:.2f}Hz" ) header2 = ( f"MODULATED: {modulated.waveform} @ {modulated.get_effective_frequency():.2f}Hz" ) lines.append(header1) lines.append(header2) lines.append("─" * width) # Modulator waveform (top half) top_height = (height - 5) // 2 waveform_fn = modulator.WAVEFORMS[modulator.waveform] mod_time_offset = modulator._phase * modulator.frequency * 0.3 for row in range(top_height): row_pos = 1.0 - (row / (top_height - 1)) line_chars = [] for col in range(width): col_fraction = col / width time_pos = mod_time_offset + col_fraction sample = waveform_fn(time_pos * modulator.frequency * 2) tolerance = 1.0 / (top_height - 1) if abs(sample - row_pos) < tolerance: line_chars.append("█") else: line_chars.append(" ") lines.append("".join(line_chars)) lines.append( f"─ MODULATION: depth={modulated.modulation_depth:.2f} | mod_value={mod_val:.2f} ─" ) # Modulated waveform (bottom half) bottom_height = height - top_height - 5 waveform_fn = modulated.osc.WAVEFORMS[modulated.waveform] modulated_time_offset = ( modulated.get_phase() * modulated.get_effective_frequency() * 0.3 ) for row in range(bottom_height): row_pos = 1.0 - (row / (bottom_height - 1)) line_chars = [] for col in range(width): col_fraction = col / width time_pos = modulated_time_offset + col_fraction sample = waveform_fn(time_pos * modulated.get_effective_frequency() * 2) tolerance = 1.0 / (bottom_height - 1) if abs(sample - row_pos) < tolerance: line_chars.append("█") else: line_chars.append(" ") lines.append("".join(line_chars)) footer = ( f"Mod Value: {mod_val:.3f} | Modulated: {modulated_val:.3f} | Frame: {frame}" ) lines.append(footer) return "\n".join(lines) def render_pygame_to_ansi( width: int, height: int, modulator: OscillatorSensor, modulated: ModulatedOscillator, frame: int, font_path: str | None, ) -> str: """Render waveforms using Pygame, convert to ANSI with PIL.""" try: import pygame from PIL import Image except ImportError: return "Pygame or PIL not available\n\n" + render_text_mode( width, height, modulator, modulated, frame ) # Initialize Pygame surface (smaller for ANSI conversion) pygame_width = width * 2 # Double for better quality pygame_height = height * 4 surface = pygame.Surface((pygame_width, pygame_height)) surface.fill((10, 10, 20)) # Dark background # Get readings mod_reading = modulator.read() mod_val = mod_reading.value if mod_reading else 0.5 modulated_reading = modulated.read() modulated_val = modulated_reading.value if modulated_reading else 0.5 # Draw modulator waveform (top half) top_height = pygame_height // 2 waveform_fn = modulator.WAVEFORMS[modulator.waveform] mod_time_offset = modulator._phase * modulator.frequency * 0.3 prev_x, prev_y = 0, 0 for x in range(pygame_width): col_fraction = x / pygame_width time_pos = mod_time_offset + col_fraction sample = waveform_fn(time_pos * modulator.frequency * 2) y = int(top_height - (sample * (top_height - 20)) - 10) if x > 0: pygame.draw.line(surface, (100, 200, 255), (prev_x, prev_y), (x, y), 2) prev_x, prev_y = x, y # Draw separator pygame.draw.line( surface, (80, 80, 100), (0, top_height), (pygame_width, top_height), 1 ) # Draw modulated waveform (bottom half) bottom_start = top_height + 10 bottom_height = pygame_height - bottom_start - 20 waveform_fn = modulated.osc.WAVEFORMS[modulated.waveform] modulated_time_offset = ( modulated.get_phase() * modulated.get_effective_frequency() * 0.3 ) prev_x, prev_y = 0, 0 for x in range(pygame_width): col_fraction = x / pygame_width time_pos = modulated_time_offset + col_fraction sample = waveform_fn(time_pos * modulated.get_effective_frequency() * 2) y = int(bottom_start + (bottom_height - (sample * (bottom_height - 20))) - 10) if x > 0: pygame.draw.line(surface, (255, 150, 100), (prev_x, prev_y), (x, y), 2) prev_x, prev_y = x, y # Draw info text on pygame surface try: if font_path: font = pygame.font.Font(font_path, 16) info_text = f"PYGAME MODE | Mod: {mod_val:.2f} | Out: {modulated_val:.2f} | Frame: {frame}" text_surface = font.render(info_text, True, (200, 200, 200)) surface.blit(text_surface, (10, 10)) except Exception: pass # Convert Pygame surface to PIL Image img_str = pygame.image.tostring(surface, "RGB") pil_image = Image.frombytes("RGB", (pygame_width, pygame_height), img_str) # Convert to ANSI return pil_to_ansi(pil_image) def pil_to_ansi(image) -> str: """Convert PIL image to ANSI escape codes.""" # Resize for terminal display terminal_width = 80 terminal_height = 30 image = image.resize((terminal_width * 2, terminal_height * 2)) # Convert to grayscale image = image.convert("L") # ANSI character ramp (dark to light) chars = " .:-=+*#%@" lines = [] for y in range(0, image.height, 2): # Sample every 2nd row for aspect ratio line = "" for x in range(0, image.width, 2): pixel = image.getpixel((x, y)) char_index = int((pixel / 255) * (len(chars) - 1)) line += chars[char_index] lines.append(line) # Add header info header = "PYGAME → ANSI RENDER MODE" header_line = "─" * terminal_width return f"{header}\n{header_line}\n" + "\n".join(lines) def demo_with_pipeline_switching( 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 demo with pipeline switching every 15 seconds.""" frame_interval = 1.0 / 15.0 # 15 FPS mode_duration = 15.0 # 15 seconds per mode print("Enhanced Oscilloscope with Pipeline Switching") print(f"Mode duration: {mode_duration} seconds") 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, ) # Find font path font_path = Path("fonts/Pixel_Sparta.otf") if not font_path.exists(): font_path = Path("fonts/Pixel Sparta.otf") font_path = str(font_path) if font_path.exists() else None # Run demo loop try: frame = 0 mode_start_time = time.time() mode_index = 0 # 0 = text, 1 = pygame while frames == 0 or frame < frames: elapsed = time.time() - mode_start_time # Switch mode every 15 seconds if elapsed >= mode_duration: mode_index = (mode_index + 1) % 2 mode_start_time = time.time() print(f"\n{'=' * 60}") print( f"SWITCHING TO {'PYGAME+ANSI' if mode_index == 1 else 'TEXT'} MODE" ) print(f"{'=' * 60}\n") time.sleep(1.0) # Brief pause to show mode switch # Render based on mode if mode_index == 0: # Text mode visualization = render_text_mode(80, 30, modulator, modulated, frame) else: # Pygame + PIL to ANSI mode visualization = render_pygame_to_ansi( 80, 30, modulator, modulated, frame, font_path ) # Display with cursor positioning print("\033[H" + visualization) # Frame timing time.sleep(frame_interval) frame += 1 except KeyboardInterrupt: print("\n\nDemo stopped by user") finally: modulator.stop() modulated.stop() if __name__ == "__main__": parser = argparse.ArgumentParser( description="Enhanced oscilloscope with pipeline switching" ) 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 (LFO range)", ) 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 (0 = infinite)", ) args = parser.parse_args() base_freq = args.frequency if args.lfo: base_freq = 0.5 demo_with_pipeline_switching( 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, )