From 90f00e56c69a5e2c7863618f98534febaa95f836 Mon Sep 17 00:00:00 2001 From: Michael Winter Date: Sat, 4 Apr 2026 19:56:39 +0200 Subject: [PATCH] Add chord number labels with click-to-siren - Add label nodes at top of each chord column - Modify xforce layout to skip label nodes - Click label to play entire chord on siren - Use chordIndex/nodeIndex (server calculates frequency) - Fix server to support both calling conventions - Fix client to use proper JSON headers for kill-siren --- webapp/path_navigator.html | 92 ++++++++++++++++++++++++++++++++------ webapp/server.py | 37 ++++++++++----- 2 files changed, 104 insertions(+), 25 deletions(-) diff --git a/webapp/path_navigator.html b/webapp/path_navigator.html index 373bd16..e499cff 100644 --- a/webapp/path_navigator.html +++ b/webapp/path_navigator.html @@ -35,15 +35,17 @@ const state = new Map(); nodes.forEach(n => { - const chordLabel = n.data('chordLabel'); - const bounds = opts.bounds[chordLabel] || { min: 0, max: graphWidth }; - state.set(n.id(), { - x: n.position('x'), - y: n.position('y'), - vx: 0, - fx: 0, - bounds: bounds, - }); + if (n.data('isLabel')) return; // Skip label nodes + + const chordLabel = n.data('chordLabel'); + const bounds = opts.bounds[chordLabel] || { min: 0, max: graphWidth }; + state.set(n.id(), { + x: n.position('x'), + y: n.position('y'), + vx: 0, + fx: 0, + bounds: bounds, + }); }); const edgeList = edges.map(e => ({ @@ -330,6 +332,32 @@ return parseFloat(fracStr); } + // Play all nodes in a chord on the siren + function playChordOnSiren(chordIdx) { + // 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 + }) + }); + node.data('sirenActive', 'true'); + }); + } + // Toggle between cents and frequency display function toggleDisplayUnit() { displayMode = displayMode === 'cents' ? 'frequency' : 'cents'; @@ -386,7 +414,7 @@ container: document.getElementById('graph-container'), style: [ { - selector: 'node', + selector: 'node[color]', style: { 'background-color': 'data(color)', 'width': 32, @@ -402,6 +430,22 @@ '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"]', style: { @@ -501,6 +545,13 @@ return; } + // Handle label node clicks + if (node.data('isLabel')) { + const chordIdx = node.data('chordIndex'); + playChordOnSiren(chordIdx); + return; + } + const chordIndex = node.data('chordIndex'); const localId = node.data('localId'); @@ -598,10 +649,11 @@ allGraphsData.graphs.forEach((graph, chordIdx) => { if (!graph || !graph.nodes) return; - const nodes = graph.nodes; - const edges = graph.edges || []; - const xBase = 100 + chordIdx * chordSpacing; - const idMap = {}; + const nodes = graph.nodes; + const edges = graph.edges || []; + const gap = 50; + const xBase = 100 + chordIdx * (chordSpacing + gap); + const idMap = {}; // Create unique IDs per chord to avoid collisions const chordPrefix = `c${chordIdx}_`; @@ -641,6 +693,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; diff --git a/webapp/server.py b/webapp/server.py index 6226889..bd1a7cc 100644 --- a/webapp/server.py +++ b/webapp/server.py @@ -129,20 +129,37 @@ def play_freq(): def play_siren(): """Play a single frequency for a node on the siren.""" data = request.json + + # Support two modes: + # 1. chordIndex + nodeIndex (from clicking a single node) + # 2. frequency + voice (from clicking a chord label) chord_index = data.get("chordIndex") node_index = data.get("nodeIndex") + frequency = data.get("frequency") + voice = data.get("voice") - if chord_index < 0 or chord_index >= len(chords): - return jsonify({"error": "Invalid chord index"}), 400 + if frequency is None and voice is None: + # Mode 1: use chordIndex/nodeIndex + if chord_index is None or node_index is None: + return jsonify( + {"error": "Missing chordIndex/nodeIndex or frequency/voice"} + ), 400 - chord = chords[chord_index] - if node_index < 0 or node_index >= len(chord): - return jsonify({"error": "Invalid node index"}), 400 + if chord_index < 0 or chord_index >= len(chords): + return jsonify({"error": "Invalid chord index"}), 400 - pitch = chord[node_index] - fraction = Fraction(pitch.get("fraction", "1")) - frequency = fundamental * float(fraction) - voice = node_index + 1 # 1-indexed + chord = chords[chord_index] + 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) + voice = node_index + 1 # 1-indexed + else: + # Mode 2: use provided frequency/voice + if frequency is None or voice is None: + return jsonify({"error": "Missing frequency or voice"}), 400 # Send to siren (192.168.4.200:54001) using current fundamental siren_sender = OSCSender(ip="192.168.4.200", port=54001, fundamental=fundamental) @@ -153,8 +170,6 @@ def play_siren(): { "frequency": frequency, "voice": voice, - "fundamental": fundamental, - "fraction": pitch.get("fraction", "1"), "destination": "siren", } )