From 161bb522bee13ad21ee23f10490b43060fd5b792 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Thu, 19 Mar 2026 04:11:53 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20Add=20oscilloscope=20with=20pipeline=20?= =?UTF-8?q?switching=20(text=20=E2=86=94=20pygame)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - demo_oscilloscope_pipeline.py: Switches between text mode and Pygame+PIL mode - 15 FPS frame rate for smooth viewing - Mode switches every 15 seconds automatically - Pygame renderer with waveform visualization - PIL converts Pygame output to ANSI for terminal display - Uses fonts/Pixel_Sparta.otf for font rendering Usage: uv run python scripts/demo_oscilloscope_pipeline.py --lfo --modulate Pipeline: Text Mode (15s) → Pygame+PIL to ANSI (15s) → Repeat Related to #46 --- scripts/demo_oscilloscope_pipeline.py | 411 ++++++++++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 scripts/demo_oscilloscope_pipeline.py diff --git a/scripts/demo_oscilloscope_pipeline.py b/scripts/demo_oscilloscope_pipeline.py new file mode 100644 index 0000000..9b987ae --- /dev/null +++ b/scripts/demo_oscilloscope_pipeline.py @@ -0,0 +1,411 @@ +#!/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, + )