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:
parent
62e6a75f4f
commit
90f00e56c6
|
|
@ -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 => ({
|
||||||
|
|
@ -330,6 +332,32 @@
|
||||||
return parseFloat(fracStr);
|
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
|
// Toggle between cents and frequency display
|
||||||
function toggleDisplayUnit() {
|
function toggleDisplayUnit() {
|
||||||
displayMode = displayMode === 'cents' ? 'frequency' : 'cents';
|
displayMode = displayMode === 'cents' ? 'frequency' : 'cents';
|
||||||
|
|
@ -386,7 +414,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 +430,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,6 +545,13 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle label node clicks
|
||||||
|
if (node.data('isLabel')) {
|
||||||
|
const chordIdx = node.data('chordIndex');
|
||||||
|
playChordOnSiren(chordIdx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const chordIndex = node.data('chordIndex');
|
const chordIndex = node.data('chordIndex');
|
||||||
const localId = node.data('localId');
|
const localId = node.data('localId');
|
||||||
|
|
||||||
|
|
@ -598,10 +649,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 +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;
|
if (elements.length === 0) return;
|
||||||
|
|
|
||||||
|
|
@ -129,20 +129,37 @@ 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
|
||||||
|
|
||||||
|
# Support two modes:
|
||||||
|
# 1. chordIndex + nodeIndex (from clicking a single node)
|
||||||
|
# 2. frequency + voice (from clicking a chord label)
|
||||||
chord_index = data.get("chordIndex")
|
chord_index = data.get("chordIndex")
|
||||||
node_index = data.get("nodeIndex")
|
node_index = data.get("nodeIndex")
|
||||||
|
frequency = data.get("frequency")
|
||||||
|
voice = data.get("voice")
|
||||||
|
|
||||||
if chord_index < 0 or chord_index >= len(chords):
|
if frequency is None and voice is None:
|
||||||
return jsonify({"error": "Invalid chord index"}), 400
|
# 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 chord_index < 0 or chord_index >= len(chords):
|
||||||
if node_index < 0 or node_index >= len(chord):
|
return jsonify({"error": "Invalid chord index"}), 400
|
||||||
return jsonify({"error": "Invalid node index"}), 400
|
|
||||||
|
|
||||||
pitch = chord[node_index]
|
chord = chords[chord_index]
|
||||||
fraction = Fraction(pitch.get("fraction", "1"))
|
if node_index < 0 or node_index >= len(chord):
|
||||||
frequency = fundamental * float(fraction)
|
return jsonify({"error": "Invalid node index"}), 400
|
||||||
voice = node_index + 1 # 1-indexed
|
|
||||||
|
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
|
# Send to siren (192.168.4.200:54001) using current fundamental
|
||||||
siren_sender = OSCSender(ip="192.168.4.200", port=54001, fundamental=fundamental)
|
siren_sender = OSCSender(ip="192.168.4.200", port=54001, fundamental=fundamental)
|
||||||
|
|
@ -153,8 +170,6 @@ def play_siren():
|
||||||
{
|
{
|
||||||
"frequency": frequency,
|
"frequency": frequency,
|
||||||
"voice": voice,
|
"voice": voice,
|
||||||
"fundamental": fundamental,
|
|
||||||
"fraction": pitch.get("fraction", "1"),
|
|
||||||
"destination": "siren",
|
"destination": "siren",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue