forked from genewildish/Mainline
Major changes: - Pipeline architecture with capability-based dependency resolution - Effects plugin system with performance monitoring - Display abstraction with multiple backends (terminal, null, websocket) - Camera system for viewport scrolling - Sensor framework for real-time input - Command-and-control system via ntfy - WebSocket display backend for browser clients - Comprehensive test suite and documentation Issue #48: ADR for preset scripting language included This commit consolidates 110 individual commits into a single feature integration that can be reviewed and tested before further refinement.
381 lines
12 KiB
Python
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,
|
|
)
|