Add per-voice ramp cancellation and frequency tracking in osc_sender

This commit is contained in:
Michael Winter 2026-04-04 22:11:27 +02:00
parent f3a5dda4bc
commit f6a98bf8a7
2 changed files with 70 additions and 61 deletions

View file

@ -16,15 +16,6 @@ except ImportError:
sys.exit(1)
# Module-level flag to cancel any running ramp
_ramp_cancelled = threading.Event()
def cancel_ramp():
"""Cancel any currently running ramp."""
_ramp_cancelled.set()
class OSCSender:
"""Send chord frequencies via OSC."""
@ -38,6 +29,9 @@ class OSCSender:
self.preview_mode = True
self.num_voices = 4 # Default to 4 voices
self.voice_indices = [] # Track position for each voice independently
self._ramp_events = {} # voice_idx -> threading.Event
self._ramp_threads = {} # voice_idx -> thread
self._current_frequencies = {} # voice_idx -> current frequency
def load_chords(self, chords_file):
"""Load chords from output_chords.json."""
@ -114,6 +108,12 @@ class OSCSender:
msg.add_arg(voice) # Voice number (1-indexed)
msg.add_arg(frequency)
self.client.send(msg.build())
# Track current frequency for this voice
self._current_frequencies[voice - 1] = frequency
def get_current_frequency(self, voice_idx):
"""Get current frequency for a voice (0-indexed)."""
return self._current_frequencies.get(voice_idx, 20)
def send_kill(self, soft=True):
"""Send kill to all voices (soft=20Hz, hard=0Hz)."""
@ -132,7 +132,6 @@ class OSCSender:
duration_ms: duration in milliseconds
start_freq: starting frequency (single or per-voice list)
"""
import threading
import time
# Handle single frequency vs list
@ -142,31 +141,54 @@ class OSCSender:
# Handle single start_freq vs list (per-voice)
if isinstance(start_freq, (int, float)):
start_freq = [start_freq] * len(frequencies)
elif start_freq is None:
# Default: use current frequency from sender
start_freq = [
self.get_current_frequency(i) for i in range(len(frequencies))
]
# Cancel any existing ramp for these voices before starting new ones
for voice_idx in range(len(frequencies)):
if voice_idx in self._ramp_events:
self._ramp_events[voice_idx].set()
if voice_idx in self._ramp_threads:
old_thread = self._ramp_threads[voice_idx]
if old_thread.is_alive():
print(f" [Cancelling existing ramp for voice {voice_idx + 1}]")
# Create new cancel events for each voice
for voice_idx in range(len(frequencies)):
self._ramp_events[voice_idx] = threading.Event()
# Fixed interval time for smooth ramp regardless of duration
step_interval = 0.02 # 20ms = 50 Hz update rate
num_steps = max(1, int(duration_ms / 1000 / step_interval))
def run_ramp():
_ramp_cancelled.clear() # Reset cancellation flag
def run_ramp(voice_idx, target_freq, voice_start):
cancel_event = self._ramp_events[voice_idx]
cancel_event.clear() # Reset cancellation flag
for step in range(num_steps + 1):
if _ramp_cancelled.is_set():
print(" [Ramp cancelled]")
if cancel_event.is_set():
print(f" [Ramp cancelled for voice {voice_idx + 1}]")
break
progress = step / num_steps
for voice_idx, target_freq in enumerate(frequencies):
voice_start = start_freq[voice_idx]
current_freq = voice_start + (target_freq - voice_start) * progress
self.send_single(current_freq, voice_idx + 1)
time.sleep(step_interval)
else:
# Send final exact target frequencies (only if not cancelled)
for voice_idx, target_freq in enumerate(frequencies):
# Send final exact target frequency (only if not cancelled)
self.send_single(target_freq, voice_idx + 1)
thread = threading.Thread(target=run_ramp)
# Start a thread for each voice
for voice_idx, target_freq in enumerate(frequencies):
voice_start = start_freq[voice_idx]
thread = threading.Thread(
target=run_ramp, args=(voice_idx, target_freq, voice_start)
)
thread.daemon = True
self._ramp_threads[voice_idx] = thread
thread.start()
print(f" [Started ramp to {len(frequencies)} voices over {duration_ms}ms]")
def send_current(self):

View file

@ -14,7 +14,7 @@ import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.pitch import Pitch
from src.osc_sender import OSCSender, cancel_ramp
from src.osc_sender import OSCSender
from fractions import Fraction
app = Flask(__name__)
@ -27,8 +27,16 @@ DEFAULT_FILE = "output_chords.json" # default file
current_index = 0
chords = []
# Track last frequency per voice per siren IP (in-memory)
last_frequencies = {} # {ip: {voice_index: frequency}}
# Cache persistent OSCSenders per IP for the siren
_sender_cache = {} # ip -> OSCSender
def get_siren_sender(ip):
"""Get or create a persistent OSCSender for the given siren IP."""
if ip not in _sender_cache:
_sender_cache[ip] = OSCSender(ip=ip, port=54001, fundamental=fundamental)
return _sender_cache[ip]
# OSC settings
fundamental = 110.0
@ -152,14 +160,11 @@ def play_siren():
frequency = fundamental * float(fraction)
voice = node_index + 1 # 1-indexed
# Send to siren using provided or default IP
siren_sender = OSCSender(ip=siren_ip, port=54001, fundamental=fundamental)
# Send to siren using cached sender
siren_sender = get_siren_sender(siren_ip)
siren_sender.set_chords(chords) # Set chords to ensure proper voice count
siren_sender.send_single(frequency, voice)
# Track frequency per voice (0-indexed)
last_frequencies.setdefault(siren_ip, {})[node_index] = frequency
return jsonify(
{
"frequency": frequency,
@ -180,19 +185,15 @@ def kill_siren():
kill_freq = 20.0 if soft else 0.0
kill_type = "soft" if soft else "hard"
# Cancel any running ramp
cancel_ramp()
# Cancel any running ramp (for this IP)
siren_sender = get_siren_sender(siren_ip)
for event in siren_sender._ramp_events.values():
event.set()
# Send kill to all voices on siren using send_kill
siren_sender = OSCSender(ip=siren_ip, port=54001, fundamental=fundamental)
# Send kill to all voices using cached sender
siren_sender.set_chords(chords) # Set chords to get correct num_voices
siren_sender.send_kill(soft=soft)
# Update tracking: set all voices to kill frequency (20 or 0)
if siren_ip in last_frequencies:
for voice_idx in last_frequencies[siren_ip]:
last_frequencies[siren_ip][voice_idx] = kill_freq
return jsonify(
{
"kill_freq": kill_freq,
@ -222,8 +223,8 @@ def ramp_to_chord():
chord = chords[chord_index]
# Create OSC sender with provided or default IP
siren_sender = OSCSender(ip=siren_ip, port=54001, fundamental=fundamental)
# Use cached sender
siren_sender = get_siren_sender(siren_ip)
siren_sender.set_chords(chords)
if node_index is not None:
@ -234,29 +235,15 @@ def ramp_to_chord():
fraction = Fraction(pitch.get("fraction", "1"))
frequency = fundamental * float(fraction)
# Get current frequency for this voice (or default to 20)
current_freqs = last_frequencies.get(siren_ip, {})
voice_start_freq = current_freqs.get(node_index, 20)
siren_sender.ramp_to_chord(frequency, duration_ms, voice_start_freq)
# Update tracking with target (will be reached after ramp completes)
last_frequencies.setdefault(siren_ip, {})[node_index] = frequency
# Ramp single voice - let sender get start frequency from current position
siren_sender.ramp_to_chord(frequency, duration_ms, start_freq=None)
else:
# Ramp entire chord
# Ramp entire chord - let sender get start frequencies from current positions
frequencies = [
fundamental * float(Fraction(p.get("fraction", "1"))) for p in chord
]
# Get current frequencies for each voice (or default to 20)
current_freqs = last_frequencies.get(siren_ip, {})
start_freqs = [current_freqs.get(i, 20) for i in range(len(frequencies))]
siren_sender.ramp_to_chord(frequencies, duration_ms, start_freqs)
# Update tracking with targets (will be reached after ramp completes)
for i, freq in enumerate(frequencies):
last_frequencies.setdefault(siren_ip, {})[i] = freq
siren_sender.ramp_to_chord(frequencies, duration_ms, start_freq=None)
return jsonify(
{