Add ramp-to-chord with per-voice tracking and configurable siren IP
This commit is contained in:
parent
040ab9999d
commit
f3a5dda4bc
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -285,6 +285,10 @@
|
|||
<input type="number" id="fundamentalInput" value="110" style="width: 60px;">
|
||||
<span style="margin-left: 15px;">Octave:</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
|
@ -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');
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue