diff --git a/scripts/demo_oscilloscope_mod.py b/scripts/demo_oscilloscope_mod.py new file mode 100644 index 0000000..b809274 --- /dev/null +++ b/scripts/demo_oscilloscope_mod.py @@ -0,0 +1,380 @@ +#!/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, + )