diff --git a/src/osc_sender.py b/src/osc_sender.py index 9297a5d..2f09fc5 100644 --- a/src/osc_sender.py +++ b/src/osc_sender.py @@ -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) + 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): - self.send_single(target_freq, voice_idx + 1) + # Send final exact target frequency (only if not cancelled) + self.send_single(target_freq, voice_idx + 1) + + # 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() - thread = threading.Thread(target=run_ramp) - thread.daemon = True - thread.start() print(f" [Started ramp to {len(frequencies)} voices over {duration_ms}ms]") def send_current(self): diff --git a/webapp/server.py b/webapp/server.py index fc4a4aa..a23f087 100644 --- a/webapp/server.py +++ b/webapp/server.py @@ -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( {