Compare commits

..

No commits in common. "path_navigator_app" and "main" have entirely different histories.

4 changed files with 26 additions and 311 deletions

View file

@ -5,7 +5,6 @@ OSC Sender - send chord frequencies via OSC for real-time playback.
import json import json
import sys import sys
import threading
from pathlib import Path from pathlib import Path
try: try:
@ -29,9 +28,6 @@ class OSCSender:
self.preview_mode = True self.preview_mode = True
self.num_voices = 4 # Default to 4 voices self.num_voices = 4 # Default to 4 voices
self.voice_indices = [] # Track position for each voice independently 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): def load_chords(self, chords_file):
"""Load chords from output_chords.json.""" """Load chords from output_chords.json."""
@ -108,12 +104,6 @@ class OSCSender:
msg.add_arg(voice) # Voice number (1-indexed) msg.add_arg(voice) # Voice number (1-indexed)
msg.add_arg(frequency) msg.add_arg(frequency)
self.client.send(msg.build()) 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): def send_kill(self, soft=True):
"""Send kill to all voices (soft=20Hz, hard=0Hz).""" """Send kill to all voices (soft=20Hz, hard=0Hz)."""
@ -124,73 +114,6 @@ class OSCSender:
f" [Sent kill {'soft' if soft else 'hard'} ({kill_freq}) to {self.num_voices} voices]" 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 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)
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(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 cancel_event.is_set():
print(f" [Ramp cancelled for voice {voice_idx + 1}]")
break
progress = step / num_steps
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 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()
print(f" [Started ramp to {len(frequencies)} voices over {duration_ms}ms]")
def send_current(self): def send_current(self):
"""Send all voices at their current positions.""" """Send all voices at their current positions."""
for voice_idx in range(self.num_voices): for voice_idx in range(self.num_voices):

View file

@ -3,14 +3,13 @@
// Then click nodes in the webapp to play tones // Then click nodes in the webapp to play tones
s.boot; s.boot;
(
s.waitForBoot { s.waitForBoot {
// Define synth on the server with long attack and decay // Define synth on the server with long attack and decay
SynthDef(\sineTone, { SynthDef(\sineTone, {
|freq = 440, amp = 0.15| |freq = 440, amp = 0.15|
var env = EnvGen.kr( var env = EnvGen.kr(
Env([0, 1, 0.8, 0], [1, 1.0, 3], \sin), Env([0, 1, 0.8, 0], [2, 3.0, 3], \sin),
doneAction: Done.freeSelf doneAction: Done.freeSelf
); );
Out.ar(0, SinOsc.ar(freq) * env * amp); Out.ar(0, SinOsc.ar(freq) * env * amp);
@ -35,4 +34,3 @@ s.waitForBoot {
// Keep alive // Keep alive
while { true } { 10.wait; }; while { true } { 10.wait; };
} }
)

View file

@ -35,8 +35,6 @@
const state = new Map(); const state = new Map();
nodes.forEach(n => { nodes.forEach(n => {
if (n.data('isLabel')) return; // Skip label nodes
const chordLabel = n.data('chordLabel'); const chordLabel = n.data('chordLabel');
const bounds = opts.bounds[chordLabel] || { min: 0, max: graphWidth }; const bounds = opts.bounds[chordLabel] || { min: 0, max: graphWidth };
state.set(n.id(), { state.set(n.id(), {
@ -285,10 +283,6 @@
<input type="number" id="fundamentalInput" value="110" style="width: 60px;"> <input type="number" id="fundamentalInput" value="110" style="width: 60px;">
<span style="margin-left: 15px;">Octave:</span> <span style="margin-left: 15px;">Octave:</span>
<input type="number" id="octaveInput" value="2" style="width: 40px;"> <input type="number" id="octaveInput" value="2" style="width: 40px;">
<span style="margin-left: 15px;">Ramp (s):</span>
<input type="number" id="rampDuration" value="3" step="0.1" style="width: 60px;">
<span style="margin-left: 15px;">Siren IP:</span>
<input type="text" id="sirenIp" value="192.168.4.200" style="width: 100px;">
<button id="toggleUnitBtn" style="margin-left: 15px;">Show: Cents</button> <button id="toggleUnitBtn" style="margin-left: 15px;">Show: Cents</button>
</div> </div>
@ -336,81 +330,6 @@
return parseFloat(fracStr); return parseFloat(fracStr);
} }
// 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')
);
// Clear old sirenActive markers
cy.nodes().forEach(n => n.data('sirenActive', ''));
// Play each node using chordIndex + localId (let server calculate frequency)
nodes.forEach((node) => {
const localId = node.data('localId');
fetch('/api/play-siren', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
chordIndex: chordIdx,
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 // Toggle between cents and frequency display
function toggleDisplayUnit() { function toggleDisplayUnit() {
displayMode = displayMode === 'cents' ? 'frequency' : 'cents'; displayMode = displayMode === 'cents' ? 'frequency' : 'cents';
@ -467,7 +386,7 @@
container: document.getElementById('graph-container'), container: document.getElementById('graph-container'),
style: [ style: [
{ {
selector: 'node[color]', selector: 'node',
style: { style: {
'background-color': 'data(color)', 'background-color': 'data(color)',
'width': 32, 'width': 32,
@ -483,22 +402,6 @@
'border-width': 0, 'border-width': 0,
} }
}, },
{
selector: 'node[isLabel = "true"]',
style: {
'background-color': '#888888',
'width': 40,
'height': 40,
'label': 'data(chordIndex)',
'color': '#ffffff',
'font-size': '18px',
'font-weight': 'bold',
'text-valign': 'center',
'text-halign': 'center',
'text-outline-width': 0,
'border-width': 0,
}
},
{ {
selector: 'node[sirenActive = "true"]', selector: 'node[sirenActive = "true"]',
style: { style: {
@ -598,34 +501,13 @@
return; 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');
if (isRamp) {
rampToChord(chordIdx);
} else {
playChordOnSiren(chordIdx);
}
return;
}
const chordIndex = node.data('chordIndex'); const chordIndex = node.data('chordIndex');
const localId = node.data('localId'); 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 // 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 endpoint = isShift ? '/api/play-siren' : '/api/play-freq';
const destination = isShift ? 'siren' : 'SuperCollider'; const destination = isShift ? 'siren' : 'SuperCollider';
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
console.log('Sending play request to', destination, ':', chordIndex, localId); console.log('Sending play request to', destination, ':', chordIndex, localId);
@ -635,8 +517,7 @@
body: JSON.stringify({ body: JSON.stringify({
chordIndex: chordIndex, chordIndex: chordIndex,
nodeIndex: localId, nodeIndex: localId,
octave: parseInt(document.getElementById('octaveInput').value) || 0, octave: parseInt(document.getElementById('octaveInput').value) || 0
ip: sirenIp
}) })
}).then(r => r.json()).then(data => { }).then(r => r.json()).then(data => {
console.log('Playing on', destination + ':', data.frequency.toFixed(2), 'Hz on voice', data.voice); console.log('Playing on', destination + ':', data.frequency.toFixed(2), 'Hz on voice', data.voice);
@ -719,8 +600,7 @@
const nodes = graph.nodes; const nodes = graph.nodes;
const edges = graph.edges || []; const edges = graph.edges || [];
const gap = 50; const xBase = 100 + chordIdx * chordSpacing;
const xBase = 100 + chordIdx * (chordSpacing + gap);
const idMap = {}; const idMap = {};
// Create unique IDs per chord to avoid collisions // Create unique IDs per chord to avoid collisions
@ -761,18 +641,6 @@
}, },
}); });
}); });
// Add label node for this chord (locked so layout doesn't move it)
elements.push({
group: 'nodes',
data: {
id: `label_${chordIdx}`,
chordIndex: chordIdx,
isLabel: "true",
},
position: { x: xBase + (chordSpacing * 0.15), y: 30 },
locked: true,
});
}); });
if (elements.length === 0) return; if (elements.length === 0) return;
@ -1179,11 +1047,10 @@
} }
} else if (e.key === "k") { } else if (e.key === "k") {
// Soft kill - send 20 Hz to stop voices gently // Soft kill - send 20 Hz to stop voices gently
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
fetch('/api/kill-siren', { fetch('/api/kill-siren', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ soft: true, ip: sirenIp }) body: JSON.stringify({ soft: true })
}).then(r => r.json()).then(data => { }).then(r => r.json()).then(data => {
console.log('Soft kill sent (20 Hz)'); console.log('Soft kill sent (20 Hz)');
// Clear all siren circles // Clear all siren circles
@ -1193,11 +1060,10 @@
}); });
} else if (e.key === "K") { } else if (e.key === "K") {
// Hard kill - send 0 Hz to stop voices immediately // Hard kill - send 0 Hz to stop voices immediately
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
fetch('/api/kill-siren', { fetch('/api/kill-siren', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ soft: false, ip: sirenIp }) body: JSON.stringify({ soft: false })
}).then(r => r.json()).then(data => { }).then(r => r.json()).then(data => {
console.log('Hard kill sent (0 Hz)'); console.log('Hard kill sent (0 Hz)');
// Clear all siren circles // Clear all siren circles

View file

@ -27,17 +27,6 @@ DEFAULT_FILE = "output_chords.json" # default file
current_index = 0 current_index = 0
chords = [] chords = []
# 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 # OSC settings
fundamental = 110.0 fundamental = 110.0
osc_sender = OSCSender(ip="127.0.0.1", port=57120, fundamental=fundamental) osc_sender = OSCSender(ip="127.0.0.1", port=57120, fundamental=fundamental)
@ -140,13 +129,8 @@ def play_freq():
def play_siren(): def play_siren():
"""Play a single frequency for a node on the siren.""" """Play a single frequency for a node on the siren."""
data = request.json data = request.json
chord_index = data.get("chordIndex") chord_index = data.get("chordIndex")
node_index = data.get("nodeIndex") 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
if chord_index < 0 or chord_index >= len(chords): if chord_index < 0 or chord_index >= len(chords):
return jsonify({"error": "Invalid chord index"}), 400 return jsonify({"error": "Invalid chord index"}), 400
@ -160,8 +144,8 @@ def play_siren():
frequency = fundamental * float(fraction) frequency = fundamental * float(fraction)
voice = node_index + 1 # 1-indexed voice = node_index + 1 # 1-indexed
# Send to siren using cached sender # Send to siren (192.168.4.200:54001) using current fundamental
siren_sender = get_siren_sender(siren_ip) siren_sender = OSCSender(ip="192.168.4.200", port=54001, fundamental=fundamental)
siren_sender.set_chords(chords) # Set chords to ensure proper voice count siren_sender.set_chords(chords) # Set chords to ensure proper voice count
siren_sender.send_single(frequency, voice) siren_sender.send_single(frequency, voice)
@ -169,8 +153,9 @@ def play_siren():
{ {
"frequency": frequency, "frequency": frequency,
"voice": voice, "voice": voice,
"fundamental": fundamental,
"fraction": pitch.get("fraction", "1"),
"destination": "siren", "destination": "siren",
"ip": siren_ip,
} }
) )
@ -180,17 +165,12 @@ def kill_siren():
"""Send kill message to siren (soft or hard).""" """Send kill message to siren (soft or hard)."""
data = request.json data = request.json
soft = data.get("soft", True) # default to soft kill 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_freq = 20.0 if soft else 0.0
kill_type = "soft" if soft else "hard" kill_type = "soft" if soft else "hard"
# Cancel any running ramp (for this IP) # Send kill to all voices on siren using send_kill
siren_sender = get_siren_sender(siren_ip) siren_sender = OSCSender(ip="192.168.4.200", port=54001, fundamental=fundamental)
for event in siren_sender._ramp_events.values():
event.set()
# Send kill to all voices using cached sender
siren_sender.set_chords(chords) # Set chords to get correct num_voices siren_sender.set_chords(chords) # Set chords to get correct num_voices
siren_sender.send_kill(soft=soft) siren_sender.send_kill(soft=soft)
@ -199,58 +179,6 @@ def kill_siren():
"kill_freq": kill_freq, "kill_freq": kill_freq,
"kill_type": kill_type, "kill_type": kill_type,
"voices": list(range(1, siren_sender.num_voices + 1)), "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]
# Use cached sender
siren_sender = get_siren_sender(siren_ip)
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)
# 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 - 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)
return jsonify(
{
"status": "ramping",
"chordIndex": chord_index,
"voiceIndex": node_index,
"duration": duration_ms,
} }
) )