Files
Mainline/scripts/demo_oscilloscope_mod.py
David Gwilliam 3fa9eabe36 feat: Add enhanced oscilloscope with LFO modulation chain
- demo_oscilloscope_mod.py: 15 FPS for smooth human viewing
- Uses cursor positioning instead of full clear to reduce flicker
- ModulatedOscillator class for LFO modulation chain
- Shows both modulator and modulated waveforms
- Supports modulation depth and frequency control

Usage:
  # Simple LFO (slow, smooth)
  uv run python scripts/demo_oscilloscope_mod.py --lfo

  # LFO modulation chain: modulator modulates main oscillator
  uv run python scripts/demo_oscilloscope_mod.py --modulate --lfo --mod-depth 0.3

  # Square wave modulation
  uv run python scripts/demo_oscilloscope_mod.py --modulate --lfo --mod-waveform square

Related to #46
2026-03-19 04:05:38 -07:00

381 lines
12 KiB
Python

#!/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,
)