#!/usr/bin/env python3 """ Enhanced Oscilloscope with LFO Modulation Chain This demo features: 1. Slower frame rate (15 FPS) for human appreciation 2. Reduced flicker using cursor positioning 3. LFO modulation chain: LFO1 modulates LFO2 frequency 4. Multiple visualization modes Usage: # Simple LFO uv run python scripts/demo_oscilloscope_mod.py --lfo # LFO modulation chain: LFO1 modulates LFO2 frequency uv run python scripts/demo_oscilloscope_mod.py --modulate --lfo # Custom modulation depth and rate uv run python scripts/demo_oscilloscope_mod.py --modulate --lfo --mod-depth 0.5 --mod-rate 0.25 """ 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. Frequency = base_frequency + (modulator_value * modulation_depth) """ 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 # Create the oscillator sensor 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.""" # Update frequency based on modulator if self.modulator: mod_reading = self.modulator.read() if mod_reading: # Modulator value (0-1) affects frequency # Map 0-1 to -modulation_depth to +modulation_depth mod_offset = (mod_reading.value - 0.5) * 2 * self.modulation_depth effective_freq = self.base_frequency + mod_offset # Clamp to reasonable range effective_freq = max(0.1, min(effective_freq, 20.0)) self.osc._frequency = effective_freq return self.osc.read() def get_phase(self): """Get current phase.""" return self.osc._phase def get_effective_frequency(self): """Get current effective frequency (after modulation).""" 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): """Stop the oscillator.""" self.osc.stop() def render_dual_waveform( width: int, height: int, modulator: OscillatorSensor, modulated: ModulatedOscillator, frame: int, ) -> str: """Render both modulator and modulated waveforms.""" # 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 # Build visualization lines = [] # Header with sensor info header1 = f"MODULATOR: {modulator.name} | Wave: {modulator.waveform} | Freq: {modulator.frequency:.2f}Hz" header2 = f"MODULATED: {modulated.name} | Wave: {modulated.waveform} | Base: {modulated.base_frequency:.2f}Hz | Eff: {modulated.get_effective_frequency():.2f}Hz" lines.append(header1) lines.append(header2) lines.append("─" * width) # Render modulator waveform (top half) top_height = (height - 5) // 2 waveform_fn = modulator.WAVEFORMS[modulator.waveform] # Calculate time offset for scrolling 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)) # Separator line with modulation info lines.append( f"─ MODULATION: depth={modulated.modulation_depth:.2f} | mod_value={mod_val:.2f} ─" ) # Render modulated waveform (bottom half) bottom_height = height - top_height - 5 waveform_fn = modulated.osc.WAVEFORMS[modulated.waveform] # Calculate time offset for scrolling 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 with current values footer = f"Mod Value: {mod_val:.3f} | Modulated Value: {modulated_val:.3f} | Frame: {frame}" lines.append(footer) return "\n".join(lines) def render_single_waveform( width: int, height: int, osc: OscillatorSensor, frame: int, ) -> str: """Render a single waveform (for non-modulated mode).""" 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: {frequency:.2f}Hz | Phase: {phase:.2f}" ) lines.append(header) lines.append("─" * width) # Draw oscilloscope trace waveform_fn = osc.WAVEFORMS[osc.waveform] time_offset = phase * frequency * 0.3 for row in range(height - 3): row_pos = 1.0 - (row / (height - 4)) line_chars = [] for col in range(width): col_fraction = col / width time_pos = time_offset + col_fraction sample = waveform_fn(time_pos * frequency * 2) tolerance = 1.0 / (height - 4) if abs(sample - row_pos) < tolerance: line_chars.append("█") else: line_chars.append(" ") lines.append("".join(line_chars)) # Footer footer = f"Value: {current_value:.3f} | Frame: {frame} | Phase: {phase:.2f}" lines.append(footer) return "\n".join(lines) def demo_oscilloscope_mod( waveform: str = "sine", base_freq: float = 1.0, modulate: bool = False, mod_waveform: str = "sine", mod_freq: float = 0.5, mod_depth: float = 0.5, frames: int = 0, ): """Run enhanced oscilloscope demo with modulation support.""" # Frame timing for smooth 15 FPS frame_interval = 1.0 / 15.0 # 66.67ms per frame print("Enhanced Oscilloscope Demo") print("Frame rate: 15 FPS (66ms per frame)") if modulate: print( f"Modulation: {mod_waveform} @ {mod_freq}Hz -> {waveform} @ {base_freq}Hz" ) print(f"Modulation depth: {mod_depth}") else: print(f"Waveform: {waveform} @ {base_freq}Hz") if frames > 0: print(f"Running for {frames} frames") else: print("Press Ctrl+C to stop") print() # Create oscillators if modulate: # Create modulation chain: modulator -> modulated modulator = OscillatorSensor( name="modulator", waveform=mod_waveform, frequency=mod_freq ) modulator.start() modulated = ModulatedOscillator( name="modulated", waveform=waveform, base_frequency=base_freq, modulator=modulator, modulation_depth=mod_depth, ) else: # Single oscillator register_oscillator_sensor( name="oscilloscope", waveform=waveform, frequency=base_freq ) osc = OscillatorSensor( name="oscilloscope", waveform=waveform, frequency=base_freq ) osc.start() # Run demo loop with consistent timing try: frame = 0 last_time = time.time() while frames == 0 or frame < frames: # Render based on mode if modulate: visualization = render_dual_waveform( 80, 30, modulator, modulated, frame ) else: visualization = render_single_waveform(80, 22, osc, frame) # Use cursor positioning instead of full clear to reduce flicker print("\033[H" + visualization) # Calculate sleep time for consistent 15 FPS 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: if modulate: modulator.stop() modulated.stop() else: osc.stop() if __name__ == "__main__": parser = argparse.ArgumentParser( description="Enhanced oscilloscope with LFO modulation chain" ) parser.add_argument( "--waveform", choices=["sine", "square", "sawtooth", "triangle", "noise"], default="sine", help="Main waveform type", ) parser.add_argument( "--frequency", type=float, default=1.0, help="Main oscillator frequency (LFO range: 0.1-20Hz)", ) parser.add_argument( "--lfo", action="store_true", help="Use slow LFO frequency (0.5Hz) for main oscillator", ) parser.add_argument( "--modulate", action="store_true", help="Enable LFO modulation chain (modulator modulates main oscillator)", ) 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 (0.0-1.0, higher = more frequency variation)", ) parser.add_argument( "--frames", type=int, default=0, help="Number of frames to render (0 = infinite until Ctrl+C)", ) args = parser.parse_args() # Set frequency based on LFO flag base_freq = args.frequency if args.lfo: base_freq = 0.5 demo_oscilloscope_mod( 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, )