Compare commits
5 commits
main
...
path_navig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6a98bf8a7 | ||
|
|
f3a5dda4bc | ||
|
|
040ab9999d | ||
|
|
42aa7798d2 | ||
|
|
90f00e56c6 |
|
|
@ -5,6 +5,7 @@ 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:
|
||||||
|
|
@ -28,6 +29,9 @@ 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."""
|
||||||
|
|
@ -104,6 +108,12 @@ 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)."""
|
||||||
|
|
@ -114,6 +124,73 @@ 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):
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,14 @@
|
||||||
// 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], [2, 3.0, 3], \sin),
|
Env([0, 1, 0.8, 0], [1, 1.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);
|
||||||
|
|
@ -34,3 +35,4 @@ s.waitForBoot {
|
||||||
// Keep alive
|
// Keep alive
|
||||||
while { true } { 10.wait; };
|
while { true } { 10.wait; };
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
@ -35,15 +35,17 @@
|
||||||
|
|
||||||
const state = new Map();
|
const state = new Map();
|
||||||
nodes.forEach(n => {
|
nodes.forEach(n => {
|
||||||
const chordLabel = n.data('chordLabel');
|
if (n.data('isLabel')) return; // Skip label nodes
|
||||||
const bounds = opts.bounds[chordLabel] || { min: 0, max: graphWidth };
|
|
||||||
state.set(n.id(), {
|
const chordLabel = n.data('chordLabel');
|
||||||
x: n.position('x'),
|
const bounds = opts.bounds[chordLabel] || { min: 0, max: graphWidth };
|
||||||
y: n.position('y'),
|
state.set(n.id(), {
|
||||||
vx: 0,
|
x: n.position('x'),
|
||||||
fx: 0,
|
y: n.position('y'),
|
||||||
bounds: bounds,
|
vx: 0,
|
||||||
});
|
fx: 0,
|
||||||
|
bounds: bounds,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const edgeList = edges.map(e => ({
|
const edgeList = edges.map(e => ({
|
||||||
|
|
@ -283,6 +285,10 @@
|
||||||
<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>
|
||||||
|
|
||||||
|
|
@ -330,6 +336,81 @@
|
||||||
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';
|
||||||
|
|
@ -386,7 +467,7 @@
|
||||||
container: document.getElementById('graph-container'),
|
container: document.getElementById('graph-container'),
|
||||||
style: [
|
style: [
|
||||||
{
|
{
|
||||||
selector: 'node',
|
selector: 'node[color]',
|
||||||
style: {
|
style: {
|
||||||
'background-color': 'data(color)',
|
'background-color': 'data(color)',
|
||||||
'width': 32,
|
'width': 32,
|
||||||
|
|
@ -402,6 +483,22 @@
|
||||||
'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: {
|
||||||
|
|
@ -501,13 +598,34 @@
|
||||||
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);
|
||||||
|
|
||||||
|
|
@ -517,7 +635,8 @@
|
||||||
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);
|
||||||
|
|
@ -598,10 +717,11 @@
|
||||||
allGraphsData.graphs.forEach((graph, chordIdx) => {
|
allGraphsData.graphs.forEach((graph, chordIdx) => {
|
||||||
if (!graph || !graph.nodes) return;
|
if (!graph || !graph.nodes) return;
|
||||||
|
|
||||||
const nodes = graph.nodes;
|
const nodes = graph.nodes;
|
||||||
const edges = graph.edges || [];
|
const edges = graph.edges || [];
|
||||||
const xBase = 100 + chordIdx * chordSpacing;
|
const gap = 50;
|
||||||
const idMap = {};
|
const xBase = 100 + chordIdx * (chordSpacing + gap);
|
||||||
|
const idMap = {};
|
||||||
|
|
||||||
// Create unique IDs per chord to avoid collisions
|
// Create unique IDs per chord to avoid collisions
|
||||||
const chordPrefix = `c${chordIdx}_`;
|
const chordPrefix = `c${chordIdx}_`;
|
||||||
|
|
@ -641,6 +761,18 @@
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
@ -1047,10 +1179,11 @@
|
||||||
}
|
}
|
||||||
} 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 })
|
body: JSON.stringify({ soft: true, ip: sirenIp })
|
||||||
}).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
|
||||||
|
|
@ -1060,10 +1193,11 @@
|
||||||
});
|
});
|
||||||
} 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 })
|
body: JSON.stringify({ soft: false, ip: sirenIp })
|
||||||
}).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
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,17 @@ 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)
|
||||||
|
|
@ -129,8 +140,13 @@ 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
|
||||||
|
|
@ -144,8 +160,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 (192.168.4.200:54001) using current fundamental
|
# Send to siren using cached sender
|
||||||
siren_sender = OSCSender(ip="192.168.4.200", port=54001, fundamental=fundamental)
|
siren_sender = get_siren_sender(siren_ip)
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -153,9 +169,8 @@ 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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -165,12 +180,17 @@ 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"
|
||||||
|
|
||||||
# Send kill to all voices on siren using send_kill
|
# Cancel any running ramp (for this IP)
|
||||||
siren_sender = OSCSender(ip="192.168.4.200", port=54001, fundamental=fundamental)
|
siren_sender = get_siren_sender(siren_ip)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
@ -179,6 +199,58 @@ 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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue