From f3a5dda4bcd6297538d8760dfea0c76acc2bfc6f Mon Sep 17 00:00:00 2001 From: Michael Winter Date: Sat, 4 Apr 2026 21:46:15 +0200 Subject: [PATCH] Add ramp-to-chord with per-voice tracking and configurable siren IP --- src/osc_sender.py | 55 +++++++++++++++++++++++ webapp/path_navigator.html | 82 +++++++++++++++++++++++++++++++--- webapp/server.py | 91 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 218 insertions(+), 10 deletions(-) diff --git a/src/osc_sender.py b/src/osc_sender.py index 5b7676a..9297a5d 100644 --- a/src/osc_sender.py +++ b/src/osc_sender.py @@ -5,6 +5,7 @@ OSC Sender - send chord frequencies via OSC for real-time playback. import json import sys +import threading from pathlib import Path try: @@ -15,6 +16,15 @@ 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.""" @@ -114,6 +124,51 @@ 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. + + Args: + frequencies: single frequency (int/float) or list of frequencies + duration_ms: duration in milliseconds + start_freq: starting frequency (single or per-voice list) + """ + import threading + import time + + # Handle single frequency vs list + if isinstance(frequencies, (int, float)): + frequencies = [frequencies] + + # Handle single start_freq vs list (per-voice) + if isinstance(start_freq, (int, float)): + start_freq = [start_freq] * len(frequencies) + + # 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 + for step in range(num_steps + 1): + if _ramp_cancelled.is_set(): + print(" [Ramp cancelled]") + 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): + self.send_single(target_freq, voice_idx + 1) + + 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): """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 e499cff..1ee4e45 100644 --- a/webapp/path_navigator.html +++ b/webapp/path_navigator.html @@ -285,6 +285,10 @@ Octave: + Ramp (s): + + Siren IP: + @@ -334,6 +338,8 @@ // Play all nodes in a chord on the siren function playChordOnSiren(chordIdx) { + const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200"; + // Find all nodes in this chord (exclude label nodes) const nodes = cy.nodes().filter(n => n.data('chordIndex') === chordIdx && !n.data('isLabel') @@ -351,13 +357,60 @@ headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ chordIndex: chordIdx, - nodeIndex: localId + nodeIndex: localId, + ip: sirenIp }) }); node.data('sirenActive', 'true'); }); } + // Ramp to a chord or single node on the siren + function rampToChord(chordIdx, nodeIdx = null) { + const durationSeconds = parseFloat(document.getElementById('rampDuration').value) || 3; + const durationMs = durationSeconds * 1000; + const startFreq = 20; // Default start frequency + const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200"; + + // Find nodes in this chord (exclude label nodes) + const nodes = cy.nodes().filter(n => + n.data('chordIndex') === chordIdx && !n.data('isLabel') + ); + + // If nodeIdx is provided, clear only same-color nodes; otherwise clear all + if (nodeIdx !== null) { + const targetNode = nodes.find(n => n.data('localId') === nodeIdx); + if (targetNode) { + const nodeColor = targetNode.data('color'); + cy.nodes().forEach(n => { + if (n.data('color') === nodeColor && n.data('sirenActive')) { + n.data('sirenActive', ''); + } + }); + targetNode.data('sirenActive', 'true'); + } + } else { + cy.nodes().forEach(n => n.data('sirenActive', '')); + nodes.forEach(node => node.data('sirenActive', 'true')); + } + + fetch('/api/ramp-to-chord', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + chordIndex: chordIdx, + nodeIndex: nodeIdx, + duration: durationMs, + startFreq: startFreq, + ip: sirenIp + }) + }).then(r => r.json()).then(data => { + console.log('Ramping:', data); + }).catch(err => { + console.log('Error ramping:', err); + }); + } + // Toggle between cents and frequency display function toggleDisplayUnit() { displayMode = displayMode === 'cents' ? 'frequency' : 'cents'; @@ -545,20 +598,34 @@ return; } + // Check modifiers + const isShift = evt.originalEvent && evt.originalEvent.shiftKey; + const isRamp = evt.originalEvent && evt.originalEvent.ctrlKey; + // Handle label node clicks if (node.data('isLabel')) { const chordIdx = node.data('chordIndex'); - playChordOnSiren(chordIdx); + if (isRamp) { + rampToChord(chordIdx); + } else { + playChordOnSiren(chordIdx); + } return; } const chordIndex = node.data('chordIndex'); const localId = node.data('localId'); + // Handle ramp modifier + if (isRamp) { + rampToChord(chordIndex, localId); + return; + } + // Check if Shift key is held - send to siren, otherwise send to SuperCollider - const isShift = evt.originalEvent && evt.originalEvent.shiftKey; const endpoint = isShift ? '/api/play-siren' : '/api/play-freq'; const destination = isShift ? 'siren' : 'SuperCollider'; + const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200"; console.log('Sending play request to', destination, ':', chordIndex, localId); @@ -568,7 +635,8 @@ body: JSON.stringify({ chordIndex: chordIndex, nodeIndex: localId, - octave: parseInt(document.getElementById('octaveInput').value) || 0 + octave: parseInt(document.getElementById('octaveInput').value) || 0, + ip: sirenIp }) }).then(r => r.json()).then(data => { console.log('Playing on', destination + ':', data.frequency.toFixed(2), 'Hz on voice', data.voice); @@ -1111,10 +1179,11 @@ } } else if (e.key === "k") { // Soft kill - send 20 Hz to stop voices gently + const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200"; fetch('/api/kill-siren', { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ soft: true }) + body: JSON.stringify({ soft: true, ip: sirenIp }) }).then(r => r.json()).then(data => { console.log('Soft kill sent (20 Hz)'); // Clear all siren circles @@ -1124,10 +1193,11 @@ }); } else if (e.key === "K") { // Hard kill - send 0 Hz to stop voices immediately + const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200"; fetch('/api/kill-siren', { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ soft: false }) + body: JSON.stringify({ soft: false, ip: sirenIp }) }).then(r => r.json()).then(data => { console.log('Hard kill sent (0 Hz)'); // Clear all siren circles diff --git a/webapp/server.py b/webapp/server.py index 3556675..fc4a4aa 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 +from src.osc_sender import OSCSender, cancel_ramp from fractions import Fraction app = Flask(__name__) @@ -27,6 +27,9 @@ 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}} + # OSC settings fundamental = 110.0 osc_sender = OSCSender(ip="127.0.0.1", port=57120, fundamental=fundamental) @@ -132,6 +135,7 @@ def play_siren(): chord_index = data.get("chordIndex") node_index = data.get("nodeIndex") + siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP if chord_index is None or node_index is None: return jsonify({"error": "Missing chordIndex/nodeIndex"}), 400 @@ -148,16 +152,20 @@ def play_siren(): frequency = fundamental * float(fraction) voice = node_index + 1 # 1-indexed - # Send to siren (192.168.4.200:54001) using current fundamental - siren_sender = OSCSender(ip="192.168.4.200", port=54001, fundamental=fundamental) + # Send to siren using provided or default IP + siren_sender = OSCSender(ip=siren_ip, port=54001, fundamental=fundamental) 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, "voice": voice, "destination": "siren", + "ip": siren_ip, } ) @@ -167,20 +175,95 @@ def kill_siren(): """Send kill message to siren (soft or hard).""" data = request.json soft = data.get("soft", True) # default to soft kill + siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP kill_freq = 20.0 if soft else 0.0 kill_type = "soft" if soft else "hard" + # Cancel any running ramp + cancel_ramp() + # Send kill to all voices on siren using send_kill - siren_sender = OSCSender(ip="192.168.4.200", port=54001, fundamental=fundamental) + siren_sender = OSCSender(ip=siren_ip, port=54001, fundamental=fundamental) 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, "kill_type": kill_type, "voices": list(range(1, siren_sender.num_voices + 1)), + "ip": siren_ip, + } + ) + + +@app.route("/api/ramp-to-chord", methods=["POST"]) +def ramp_to_chord(): + """Ramp to a chord or single voice on the siren.""" + data = request.json + + 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) + siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP + + if chord_index is None: + return jsonify({"error": "Missing chordIndex"}), 400 + + if chord_index < 0 or chord_index >= len(chords): + return jsonify({"error": "Invalid chord index"}), 400 + + chord = chords[chord_index] + + # Create OSC sender with provided or default IP + siren_sender = OSCSender(ip=siren_ip, port=54001, fundamental=fundamental) + siren_sender.set_chords(chords) + + if node_index is not None: + # Ramp single voice + if node_index < 0 or node_index >= len(chord): + return jsonify({"error": "Invalid node index"}), 400 + pitch = chord[node_index] + 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 + else: + # Ramp entire chord + 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 + + return jsonify( + { + "status": "ramping", + "chordIndex": chord_index, + "voiceIndex": node_index, + "duration": duration_ms, } )