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
This commit is contained in:
Michael Winter 2026-04-04 19:56:39 +02:00
parent 62e6a75f4f
commit 90f00e56c6
2 changed files with 104 additions and 25 deletions

View file

@ -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;

View file

@ -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",
}
)