Add multi-siren control to Path Navigator

- Add siren selection (1-4) and auto (A) toggle buttons
- Keyboard: 1-4 select siren, 0/A auto, R ramp toggle
- Click plays to siren, Shift+click to SuperCollider
- Add RAMP toggle button
- Colored borders on nodes based on which siren is playing
- Fix label clicks to use auto (node's voice)
- Add toggle styling to cents/freq button
- Disable siren hotkeys when typing in input fields
- Disable Flask debug mode for background operation
This commit is contained in:
Michael Winter 2026-04-20 17:56:11 +02:00
parent 655cb68f31
commit 5a1a4f03dd
4 changed files with 250 additions and 49 deletions

View file

@ -610,7 +610,10 @@
const transcribeResponse = await fetch('/api/transcribe', { const transcribeResponse = await fetch('/api/transcribe', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name: transcribeName }) body: JSON.stringify({
name: transcribeName,
fundamental: parseFloat(document.getElementById('fundamental').value) || 55
})
}); });
const transcribeResult = await transcribeResponse.json(); const transcribeResult = await transcribeResponse.json();

View file

@ -308,6 +308,27 @@
color: #999999; color: #999999;
border-color: #444444; border-color: #444444;
} }
.toggle-btn, .siren-btn {
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
background: #0a0a0a;
color: #666666;
border: 1px solid #222222;
border-radius: 3px;
margin: 0 2px;
}
.toggle-btn:hover, .siren-btn:hover {
background: #151515;
color: #999999;
border-color: #444444;
}
.toggle-btn.active, .siren-btn.active {
background: #1a3a1a;
color: #66cc66;
border-color: #2a5a2a;
}
</style> </style>
</head> </head>
<body> <body>
@ -344,13 +365,21 @@
</div> </div>
<span style="margin-left: 15px;">Siren IP:</span> <span style="margin-left: 15px;">Siren IP:</span>
<input type="text" id="sirenIp" value="192.168.4.200" style="width: 100px;"> <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" class="toggle-btn">Show: Cents</button>
</div> </div>
<div class="controls"> <div class="controls">
<button id="prevBtn" disabled>← Previous</button> <button id="prevBtn" disabled>← Previous</button>
<span class="index-display">Index: <span id="currentIndex">0</span> / <span id="totalSteps">0</span></span> <span class="index-display">Index: <span id="currentIndex">0</span> / <span id="totalSteps">0</span></span>
<button id="nextBtn" disabled>Next →</button> <button id="nextBtn" disabled>Next →</button>
<span style="margin-left: 20px; border-left: 1px solid #333; padding-left: 20px;">
<button id="rampBtn" class="toggle-btn">RAMP: OFF</button>
<button id="siren1Btn" class="siren-btn">1</button>
<button id="siren2Btn" class="siren-btn">2</button>
<button id="siren3Btn" class="siren-btn">3</button>
<button id="siren4Btn" class="siren-btn">4</button>
<button id="sirenABtn" class="siren-btn">A</button>
</span>
</div> </div>
<div id="graph-container"></div> <div id="graph-container"></div>
@ -374,6 +403,8 @@
<script> <script>
// Global state // Global state
let chords = []; let chords = [];
let selectedSiren = 0; // 0 = use node's voice, 1-4 = selected siren
let rampMode = false; // false = play, true = ramp
let currentIndex = 0; let currentIndex = 0;
let totalSteps = 0; let totalSteps = 0;
let hasPrev = false; let hasPrev = false;
@ -381,6 +412,31 @@
let allGraphsData = null; let allGraphsData = null;
let displayMode = 'cents'; let displayMode = 'cents';
// Update UI buttons based on state
function updateRampButton() {
const btn = document.getElementById('rampBtn');
if (btn) {
btn.textContent = rampMode ? 'RAMP: ON' : 'RAMP: OFF';
btn.classList.toggle('active', rampMode);
}
}
function updateSirenButtons() {
const buttons = [
{ id: 'siren1Btn', value: 1 },
{ id: 'siren2Btn', value: 2 },
{ id: 'siren3Btn', value: 3 },
{ id: 'siren4Btn', value: 4 },
{ id: 'sirenABtn', value: 0 },
];
buttons.forEach(({ id, value }) => {
const btn = document.getElementById(id);
if (btn) {
btn.classList.toggle('active', selectedSiren === value);
}
});
}
function adjustValue(id, delta) { function adjustValue(id, delta) {
const input = document.getElementById(id); const input = document.getElementById(id);
const min = parseFloat(input.min) || -Infinity; const min = parseFloat(input.min) || -Infinity;
@ -416,17 +472,24 @@
// Play each node using chordIndex + localId (let server calculate frequency) // Play each node using chordIndex + localId (let server calculate frequency)
nodes.forEach((node) => { nodes.forEach((node) => {
const localId = node.data('localId'); const localId = node.data('localId');
// Label always uses auto (node's voice)
const body = {
chordIndex: chordIdx,
nodeIndex: localId,
ip: sirenIp,
};
// Only add sirenNumber if a specific siren is selected
if (selectedSiren > 0) {
body.sirenNumber = selectedSiren;
}
fetch('/api/play-siren', { fetch('/api/play-siren', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify(body)
chordIndex: chordIdx,
nodeIndex: localId,
ip: sirenIp
})
}); });
node.data('sirenActive', 'true'); node.data('sirenActive', 'true');
node.data('borderColor', voiceColors[localId % 4]);
}); });
} }
@ -459,16 +522,23 @@
nodes.forEach(node => node.data('sirenActive', 'true')); nodes.forEach(node => node.data('sirenActive', 'true'));
} }
const rampBody = {
chordIndex: chordIdx,
nodeIndex: nodeIdx,
duration: durationMs,
exponent: exponent,
ip: sirenIp
};
// Only add sirenNumber if a specific siren is selected (> 0), otherwise use node's voice
if (selectedSiren > 0) {
rampBody.sirenNumber = selectedSiren;
}
fetch('/api/ramp-to-chord', { fetch('/api/ramp-to-chord', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify(rampBody)
chordIndex: chordIdx,
nodeIndex: nodeIdx,
duration: durationMs,
exponent: exponent,
ip: sirenIp
})
}).then(r => r.json()).then(data => { }).then(r => r.json()).then(data => {
console.log('Ramping:', data); console.log('Ramping:', data);
}).catch(err => { }).catch(err => {
@ -482,6 +552,7 @@
const btn = document.getElementById('toggleUnitBtn'); const btn = document.getElementById('toggleUnitBtn');
if (btn) { if (btn) {
btn.textContent = displayMode === 'cents' ? 'Show: Cents' : 'Show: Frequency'; btn.textContent = displayMode === 'cents' ? 'Show: Cents' : 'Show: Frequency';
btn.classList.toggle('active', displayMode === 'frequency');
} }
if (!cy) return; if (!cy) return;
@ -568,7 +639,7 @@
selector: 'node[sirenActive = "true"]', selector: 'node[sirenActive = "true"]',
style: { style: {
'border-width': 4, 'border-width': 4,
'border-color': '#ffffff', 'border-color': 'data(borderColor)',
'border-opacity': 1, 'border-opacity': 1,
} }
}, },
@ -665,12 +736,11 @@
// Check modifiers // Check modifiers
const isShift = evt.originalEvent && evt.originalEvent.shiftKey; const isShift = evt.originalEvent && evt.originalEvent.shiftKey;
const isRamp = evt.originalEvent && evt.originalEvent.ctrlKey;
// Handle label node clicks // Handle label node clicks
if (node.data('isLabel')) { if (node.data('isLabel')) {
const chordIdx = node.data('chordIndex'); const chordIdx = node.data('chordIndex');
if (isRamp) { if (rampMode || isShift) {
rampToChord(chordIdx); rampToChord(chordIdx);
} else { } else {
playChordOnSiren(chordIdx); playChordOnSiren(chordIdx);
@ -680,45 +750,75 @@
const chordIndex = node.data('chordIndex'); const chordIndex = node.data('chordIndex');
const localId = node.data('localId'); const localId = node.data('localId');
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
// Handle ramp modifier // Determine action: ramp or play
if (isRamp) { if (rampMode || isShift) {
// Ramp to siren
rampToChord(chordIndex, localId); rampToChord(chordIndex, localId);
return; return;
} }
// Check if Shift key is held - send to siren, otherwise send to SuperCollider // Check if Shift key is held - send to SuperCollider
const endpoint = isShift ? '/api/play-siren' : '/api/play-freq'; if (isShift) {
const destination = isShift ? 'siren' : 'SuperCollider'; // Send to SuperCollider
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200"; const endpoint = '/api/play-freq';
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
console.log('Sending play request to', destination, ':', chordIndex, localId); const requestBody = {
chordIndex: chordIndex,
nodeIndex: localId,
octave: parseInt(document.getElementById('octaveInput').value) || 0,
};
console.log('Sending to SuperCollider:', chordIndex, localId);
fetch(endpoint, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(requestBody)
});
return;
}
// Plain click: play to siren
const endpoint = '/api/play-siren';
const destination = 'siren';
// Determine which siren: if selectedSiren > 0 use it, otherwise use node's voice (localId + 1)
const actualSiren = selectedSiren > 0 ? selectedSiren : localId + 1;
console.log('Sending to siren:', chordIndex, localId, 'siren:', actualSiren);
const requestBody = {
chordIndex: chordIndex,
nodeIndex: localId,
ip: sirenIp,
sirenNumber: actualSiren
};
fetch(endpoint, { fetch(endpoint, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify(requestBody)
chordIndex: chordIndex,
nodeIndex: localId,
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 siren', data.siren);
// If playing on siren, add white circle around node for this voice // Add colored circle around node based on siren
if (isShift) { const sirenColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700'];
const nodeColor = node.data('color'); const borderColor = sirenColors[(actualSiren - 1) % 4];
// Find any existing node with same color that has sirenActive and remove it
cy.nodes().forEach(n => { // Find any existing node with same border color that has sirenActive and remove it
if (n.data('color') === nodeColor && n.data('sirenActive')) { cy.nodes().forEach(n => {
n.data('sirenActive', ''); if (n.data('borderColor') === borderColor && n.data('sirenActive')) {
} n.data('sirenActive', '');
}); n.data('borderColor', '');
// Add sirenActive to clicked node }
node.data('sirenActive', 'true'); });
console.log('Added sirenActive to', node.id(), 'color:', nodeColor); // Add sirenActive to clicked node
} node.data('sirenActive', 'true');
node.data('borderColor', borderColor);
console.log('Added sirenActive to', node.id(), 'color:', borderColor);
}).catch(err => { }).catch(err => {
console.log('Error playing freq:', err); console.log('Error playing freq:', err);
}); });
@ -1224,8 +1324,35 @@
navigate('next'); navigate('next');
}); });
// Toggle buttons
document.getElementById("rampBtn").addEventListener("click", () => {
rampMode = !rampMode;
console.log('Ramp mode:', rampMode);
updateRampButton();
});
document.getElementById("siren1Btn").addEventListener("click", () => { selectedSiren = 1; updateSirenButtons(); });
document.getElementById("siren2Btn").addEventListener("click", () => { selectedSiren = 2; updateSirenButtons(); });
document.getElementById("siren3Btn").addEventListener("click", () => { selectedSiren = 3; updateSirenButtons(); });
document.getElementById("siren4Btn").addEventListener("click", () => { selectedSiren = 4; updateSirenButtons(); });
document.getElementById("sirenABtn").addEventListener("click", () => { selectedSiren = 0; updateSirenButtons(); });
// Initialize button states
updateRampButton();
updateSirenButtons();
// Keyboard navigation // Keyboard navigation
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
// Don't trigger siren/ramp hotkeys when typing in input fields
if (e.target.tagName === 'INPUT') {
// Skip siren/ramp hotkeys but allow other keys
if (e.key >= '1' && e.key <= '4' ||
e.key === '0' || e.key === 'a' || e.key === 'A' ||
e.key === 'r' || e.key === 'R') {
return;
}
}
if (e.key === "ArrowLeft") { if (e.key === "ArrowLeft") {
navigate('prev'); navigate('prev');
} else if (e.key === "ArrowRight") { } else if (e.key === "ArrowRight") {
@ -1242,6 +1369,21 @@
const zoom = cy.zoom(); const zoom = cy.zoom();
cy.zoom({ zoomLevel: Math.max(0.3, zoom / 1.1), renderedPosition: { x: cy.width()/2, y: cy.height()/2 } }); cy.zoom({ zoomLevel: Math.max(0.3, zoom / 1.1), renderedPosition: { x: cy.width()/2, y: cy.height()/2 } });
} }
} else if (e.key >= '1' && e.key <= '4') {
// Select siren 1-4
selectedSiren = parseInt(e.key);
console.log('Selected siren:', selectedSiren);
updateSirenButtons();
} else if (e.key === '0' || e.key === 'a' || e.key === 'A') {
// Auto mode - use node's voice
selectedSiren = 0;
console.log('Auto mode: use node voice');
updateSirenButtons();
} else if (e.key === 'r' || e.key === 'R') {
// Toggle ramp mode
rampMode = !rampMode;
console.log('Ramp mode:', rampMode);
updateRampButton();
} 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"; const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
@ -1252,7 +1394,10 @@
}).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
cy.nodes().forEach(n => n.removeData('sirenActive')); cy.nodes().forEach(n => {
n.removeData('sirenActive');
n.removeData('borderColor');
});
}).catch(err => { }).catch(err => {
console.log('Error sending kill:', err); console.log('Error sending kill:', err);
}); });
@ -1266,7 +1411,10 @@
}).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
cy.nodes().forEach(n => n.removeData('sirenActive')); cy.nodes().forEach(n => {
n.removeData('sirenActive');
n.removeData('borderColor');
});
}).catch(err => { }).catch(err => {
console.log('Error sending kill:', err); console.log('Error sending kill:', err);
}); });

47
webapp/server.log Normal file
View file

@ -0,0 +1,47 @@
Starting Path Navigator server...
Loading chords from: /home/mwinter/Sketches/compact_sets/output/output_chords.json
Loaded 64 chords
* Serving Flask app 'server'
* Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:8080
* Running on http://192.168.178.32:8080
Press CTRL+C to quit
* Restarting with stat
* Debugger is active!
* Debugger PIN: 477-276-956
127.0.0.1 - - [20/Apr/2026 12:19:07] "GET / HTTP/1.1" 304 -
127.0.0.1 - - [20/Apr/2026 12:19:08] "GET /api/chords HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:08] "POST /api/batch-calculate-cents HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:24] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:27] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:29] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:31] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:32] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:35] "POST /api/play-freq HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:37] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:38] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:41] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:41] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:41] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:41] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:45] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:45] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:45] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:45] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:56] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:57] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:58] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:19:59] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:20:02] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:20:03] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:20:06] "POST /api/ramp-to-chord HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:20:11] "POST /api/ramp-to-chord HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:20:23] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:20:23] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:20:23] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:20:23] "POST /api/play-siren HTTP/1.1" 200 -
127.0.0.1 - - [20/Apr/2026 12:20:26] "POST /api/ramp-to-chord HTTP/1.1" 200 -
/usr/lib/python3.14/multiprocessing/resource_tracker.py:396: UserWarning: resource_tracker: There appear to be 1 leaked semaphore objects to clean up at shutdown: {'/mp-naea0l96'}
warnings.warn(

View file

@ -144,6 +144,7 @@ def play_siren():
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 siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP
siren_number = data.get("sirenNumber") # NEW: optional 1-4
if chord_index is None or node_index is None: if chord_index is None or node_index is None:
return jsonify({"error": "Missing chordIndex/nodeIndex"}), 400 return jsonify({"error": "Missing chordIndex/nodeIndex"}), 400
@ -158,7 +159,7 @@ def play_siren():
pitch = chord[node_index] pitch = chord[node_index]
fraction = Fraction(pitch.get("fraction", "1")) fraction = Fraction(pitch.get("fraction", "1"))
frequency = fundamental * float(fraction) frequency = fundamental * float(fraction)
voice = node_index + 1 # 1-indexed voice = siren_number if siren_number else node_index + 1 # 1-indexed
# Send to siren using cached sender # Send to siren using cached sender
siren_sender = get_siren_sender(siren_ip) siren_sender = get_siren_sender(siren_ip)
@ -169,6 +170,7 @@ def play_siren():
{ {
"frequency": frequency, "frequency": frequency,
"voice": voice, "voice": voice,
"siren": siren_number,
"destination": "siren", "destination": "siren",
"ip": siren_ip, "ip": siren_ip,
} }
@ -214,6 +216,7 @@ def ramp_to_chord():
duration_ms = data.get("duration", 3000) duration_ms = data.get("duration", 3000)
exponent = data.get("exponent", 1.0) exponent = data.get("exponent", 1.0)
siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP
siren_number = data.get("sirenNumber") # NEW: optional 1-4
if chord_index is None: if chord_index is None:
return jsonify({"error": "Missing chordIndex"}), 400 return jsonify({"error": "Missing chordIndex"}), 400
@ -234,7 +237,7 @@ def ramp_to_chord():
pitch = chord[node_index] pitch = chord[node_index]
fraction = Fraction(pitch.get("fraction", "1")) fraction = Fraction(pitch.get("fraction", "1"))
frequency = fundamental * float(fraction) frequency = fundamental * float(fraction)
voice = node_index + 1 # 1-indexed voice = siren_number if siren_number else (node_index + 1) # 1-indexed
# Ramp single voice - let sender get start frequency from current position # Ramp single voice - let sender get start frequency from current position
siren_sender.ramp_to_pitch( siren_sender.ramp_to_pitch(
@ -527,4 +530,4 @@ if __name__ == "__main__":
print(f"Loading chords from: {filepath}") print(f"Loading chords from: {filepath}")
load_chords() load_chords()
print(f"Loaded {len(chords)} chords") print(f"Loaded {len(chords)} chords")
app.run(host="0.0.0.0", port=8080, debug=True) app.run(host="0.0.0.0", port=8080, debug=False)