#!/usr/bin/env python3 """ Oscilloscope Demo - Real-time waveform visualization This demonstrates a real oscilloscope-style display where: 1. A complete waveform is drawn on the canvas 2. The camera scrolls horizontally (time axis) 3. The "pen" traces the waveform vertically at the center Think of it as: - Canvas: Contains the waveform pattern (like a stamp) - Camera: Moves left-to-right, revealing different parts of the waveform - Pen: Always at center X, moves vertically with the signal value Usage: uv run python scripts/demo_oscilloscope.py --frequency 1.0 --speed 10 """ import argparse import math import time import sys 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 def render_oscilloscope( width: int, height: int, osc: OscillatorSensor, frame: int, ) -> str: """Render an oscilloscope-style display.""" # Get current reading (0.0 to 1.0) reading = osc.read() current_value = reading.value if reading else 0.5 phase = osc._phase frequency = osc.frequency # Build visualization lines = [] # Header with sensor info header = ( f"Oscilloscope: {osc.name} | Wave: {osc.waveform} | " f"Freq: {osc.frequency}Hz | Phase: {phase:.2f}" ) lines.append(header) lines.append("─" * width) # Center line (zero reference) center_row = height // 2 # Draw oscilloscope trace waveform_fn = osc.WAVEFORMS[osc._waveform] # Calculate time offset for scrolling # The trace scrolls based on phase - this creates the time axis movement # At frequency 1.0, the trace completes one full sweep per frequency cycle time_offset = phase * frequency * 2.0 # Pre-calculate all sample values for this frame # Each column represents a time point on the X axis samples = [] for col in range(width): # Time position for this column (0.0 to 1.0 across width) col_fraction = col / width # Combine with time offset for scrolling effect time_pos = time_offset + col_fraction # Sample the waveform at this time point # Multiply by frequency to get correct number of cycles shown sample_value = waveform_fn(time_pos * frequency * 2) samples.append(sample_value) # Draw the trace # For each row, check which columns have their sample value in this row for row in range(height - 3): # Reserve 3 lines for header/footer # Calculate vertical position (0.0 at bottom, 1.0 at top) row_pos = 1.0 - (row / (height - 4)) line_chars = [] for col in range(width): sample = samples[col] # Check if this sample falls in this row tolerance = 1.0 / (height - 4) if abs(sample - row_pos) < tolerance: line_chars.append("█") else: line_chars.append(" ") lines.append("".join(line_chars)) # Draw center indicator line center_line = list(" " * width) # Position the indicator based on current value indicator_x = int((current_value) * (width - 1)) if 0 <= indicator_x < width: center_line[indicator_x] = "◎" lines.append("".join(center_line)) # Footer with current value footer = f"Value: {current_value:.3f} | Frame: {frame} | Phase: {phase:.2f}" lines.append(footer) return "\n".join(lines) def demo_oscilloscope( waveform: str = "sine", frequency: float = 1.0, frames: int = 0, ): """Run oscilloscope demo.""" # Determine if this is LFO range is_lfo = frequency <= 20.0 and frequency >= 0.1 freq_type = "LFO" if is_lfo else "Audio" print(f"Oscilloscope demo: {waveform} wave") print(f"Frequency: {frequency}Hz ({freq_type} range)") if frames > 0: print(f"Running for {frames} frames") else: print("Press Ctrl+C to stop") print() # Create oscillator sensor register_oscillator_sensor( name="oscilloscope_osc", waveform=waveform, frequency=frequency ) osc = OscillatorSensor( name="oscilloscope_osc", waveform=waveform, frequency=frequency ) osc.start() # Run demo loop try: frame = 0 while frames == 0 or frame < frames: # Render oscilloscope display visualization = render_oscilloscope(80, 22, osc, frame) # Print with ANSI escape codes to clear screen and move cursor print("\033[H\033[J" + visualization) time.sleep(1.0 / 60.0) # 60 FPS frame += 1 except KeyboardInterrupt: print("\n\nDemo stopped by user") finally: osc.stop() if __name__ == "__main__": parser = argparse.ArgumentParser(description="Oscilloscope demo") parser.add_argument( "--waveform", choices=["sine", "square", "sawtooth", "triangle", "noise"], default="sine", help="Waveform type", ) parser.add_argument( "--frequency", type=float, default=1.0, help="Oscillator frequency in Hz (LFO: 0.1-20Hz, Audio: >20Hz)", ) parser.add_argument( "--lfo", action="store_true", help="Use LFO frequency (0.5Hz - slow modulation)", ) parser.add_argument( "--fast-lfo", action="store_true", help="Use fast LFO frequency (5Hz - rhythmic modulation)", ) parser.add_argument( "--frames", type=int, default=0, help="Number of frames to render (0 = infinite until Ctrl+C)", ) args = parser.parse_args() # Determine frequency based on mode frequency = args.frequency if args.lfo: frequency = 0.5 # Slow LFO for modulation elif args.fast_lfo: frequency = 5.0 # Fast LFO for rhythmic modulation demo_oscilloscope( waveform=args.waveform, frequency=frequency, frames=args.frames, )