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,
}
)