From 30675756c96cbbd01cd01012586f2771184fba44 Mon Sep 17 00:00:00 2001 From: Michael Winter Date: Tue, 7 Apr 2026 08:48:06 +0200 Subject: [PATCH] Add ramp exponent UI and split ramp_to_pitch for single voice --- src/osc_sender.py | 72 +++++++++++++++++++++++++++++++++++--- webapp/path_navigator.html | 6 ++-- webapp/server.py | 11 ++++-- 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/src/osc_sender.py b/src/osc_sender.py index 2f09fc5..d079c3c 100644 --- a/src/osc_sender.py +++ b/src/osc_sender.py @@ -124,13 +124,16 @@ class OSCSender: f" [Sent kill {'soft' if soft else 'hard'} ({kill_freq}) to {self.num_voices} voices]" ) - def ramp_to_chord(self, frequencies, duration_ms=3000, start_freq=20): - """Ramp each voice from start_freq to target frequency(s) linearly. + def ramp_to_chord( + self, frequencies, duration_ms=3000, start_freq=None, exponent=1.0 + ): + """Ramp each voice from start_freq to target frequency(s). Args: frequencies: single frequency (int/float) or list of frequencies duration_ms: duration in milliseconds - start_freq: starting frequency (single or per-voice list) + start_freq: starting frequency (single or per-voice list, or None to use current) + exponent: exponent for curve (1.0 = linear, 2.0 = quadratic ease, etc.) """ import time @@ -172,7 +175,9 @@ class OSCSender: print(f" [Ramp cancelled for voice {voice_idx + 1}]") break progress = step / num_steps - current_freq = voice_start + (target_freq - voice_start) * progress + current_freq = voice_start + (target_freq - voice_start) * ( + progress**exponent + ) self.send_single(current_freq, voice_idx + 1) time.sleep(step_interval) else: @@ -191,6 +196,65 @@ class OSCSender: print(f" [Started ramp to {len(frequencies)} voices over {duration_ms}ms]") + def ramp_to_pitch( + self, frequency, voice, duration_ms=3000, start_freq=None, exponent=1.0 + ): + """Ramp a single voice from start_freq to target frequency. + + Args: + frequency: target frequency (float) + voice: voice number (1-indexed, e.g., 1-4) + duration_ms: duration in milliseconds + start_freq: starting frequency (or None to use current) + exponent: exponent for curve (1.0 = linear, 2.0 = quadratic ease, etc.) + """ + import time + + voice_idx = voice - 1 # Convert to 0-indexed + + # Get start frequency from sender if not provided + if start_freq is None: + start_freq = self.get_current_frequency(voice_idx) + + # Cancel any existing ramp for this voice + 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}]") + + # Create new cancel event + 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(): + cancel_event = self._ramp_events[voice_idx] + cancel_event.clear() # Reset cancellation flag + for step in range(num_steps + 1): + if cancel_event.is_set(): + print(f" [Ramp cancelled for voice {voice}]") + break + progress = step / num_steps + current_freq = start_freq + (frequency - start_freq) * ( + progress**exponent + ) + self.send_single(current_freq, voice) + time.sleep(step_interval) + else: + # Send final exact target frequency (only if not cancelled) + self.send_single(frequency, voice) + + thread = threading.Thread(target=run_ramp) + thread.daemon = True + self._ramp_threads[voice_idx] = thread + thread.start() + + print(f" [Started ramp for voice {voice} over {duration_ms}ms]") + def send_current(self): """Send all voices at their current positions.""" for voice_idx in range(self.num_voices): diff --git a/webapp/path_navigator.html b/webapp/path_navigator.html index 1ee4e45..d63dd9a 100644 --- a/webapp/path_navigator.html +++ b/webapp/path_navigator.html @@ -287,6 +287,8 @@ Ramp (s): + Exponent: + Siren IP: @@ -369,7 +371,7 @@ function rampToChord(chordIdx, nodeIdx = null) { const durationSeconds = parseFloat(document.getElementById('rampDuration').value) || 3; const durationMs = durationSeconds * 1000; - const startFreq = 20; // Default start frequency + const exponent = parseFloat(document.getElementById('rampExponent').value) || 1.0; const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200"; // Find nodes in this chord (exclude label nodes) @@ -401,7 +403,7 @@ chordIndex: chordIdx, nodeIndex: nodeIdx, duration: durationMs, - startFreq: startFreq, + exponent: exponent, ip: sirenIp }) }).then(r => r.json()).then(data => { diff --git a/webapp/server.py b/webapp/server.py index a23f087..c008157 100644 --- a/webapp/server.py +++ b/webapp/server.py @@ -212,7 +212,7 @@ def ramp_to_chord(): chord_index = data.get("chordIndex") node_index = data.get("nodeIndex") # Optional - if present, ramp single voice duration_ms = data.get("duration", 3000) - start_freq = data.get("startFreq", 20) + exponent = data.get("exponent", 1.0) siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP if chord_index is None: @@ -234,16 +234,21 @@ def ramp_to_chord(): pitch = chord[node_index] fraction = Fraction(pitch.get("fraction", "1")) frequency = fundamental * float(fraction) + voice = node_index + 1 # 1-indexed # Ramp single voice - let sender get start frequency from current position - siren_sender.ramp_to_chord(frequency, duration_ms, start_freq=None) + siren_sender.ramp_to_pitch( + frequency, voice, duration_ms, start_freq=None, exponent=exponent + ) else: # Ramp entire chord - let sender get start frequencies from current positions frequencies = [ fundamental * float(Fraction(p.get("fraction", "1"))) for p in chord ] - siren_sender.ramp_to_chord(frequencies, duration_ms, start_freq=None) + siren_sender.ramp_to_chord( + frequencies, duration_ms, start_freq=None, exponent=exponent + ) return jsonify( {