diff --git a/webapp/path_navigator.html b/webapp/path_navigator.html
index bfcf8db..69e0ae7 100644
--- a/webapp/path_navigator.html
+++ b/webapp/path_navigator.html
@@ -503,20 +503,40 @@
);
// If nodeIdx is provided, clear only same-color nodes; otherwise clear all
+ // Use siren's color if selected, otherwise use node's voice color
+ // Use exact same coloring logic as click handler
+ const sirenColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700'];
+
if (nodeIdx !== null) {
+ // Single node ramp - use actualSiren
+ const actualSiren = selectedSiren > 0 ? selectedSiren : (nodeIdx + 1);
+ const nodeBorderColor = sirenColors[(actualSiren - 1) % 4];
+
+ cy.nodes().forEach(n => {
+ if (n.data('borderColor') === nodeBorderColor && n.data('sirenActive')) {
+ n.data('sirenActive', '');
+ n.data('borderColor', '');
+ }
+ });
+
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');
+ targetNode.data('borderColor', nodeBorderColor);
}
} else {
- cy.nodes().forEach(n => n.data('sirenActive', ''));
- nodes.forEach(node => node.data('sirenActive', 'true'));
+ // Entire chord ramp - each node gets its own color
+ cy.nodes().forEach(n => {
+ n.data('sirenActive', '');
+ n.data('borderColor', '');
+ });
+
+ nodes.forEach(node => {
+ const nodeLocalId = node.data('localId');
+ const nodeActualSiren = selectedSiren > 0 ? selectedSiren : (nodeLocalId + 1);
+ node.data('sirenActive', 'true');
+ node.data('borderColor', sirenColors[(nodeActualSiren - 1) % 4]);
+ });
}
const rampBody = {
@@ -532,6 +552,14 @@
rampBody.sirenNumber = selectedSiren;
}
+ // Add octaveOffset if single node was transposed
+ if (nodeIdx !== null) {
+ const targetNode = nodes.find(n => n.data('localId') === nodeIdx);
+ if (targetNode) {
+ rampBody.octaveOffset = targetNode.data('octaveOffset') || 0;
+ }
+ }
+
fetch('/api/ramp-to-chord', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
@@ -588,6 +616,9 @@
const graphHeight = 450;
let chordSpacing = 350;
+ // Fixed pixels per cent (0.15 = 360 pixels per octave = 2 octaves on screen)
+ const PIXELS_PER_CENT = 0.15;
+
// Voice colors - sleek pastel scheme
const voiceColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700'];
@@ -688,35 +719,183 @@
boxSelectionEnabled: false,
});
- // Lock y on drag - only allow x movement
+// Track drag for X-move and Y-octave drag
let isDragging = false;
- let grabPosition = null;
+ let dragDirection = null; // 'x' or 'y'
+ let grabPos = null;
+
+ // Calculate Y from cents using the same formula as rendering
+ function centsToY(cents, globalMinCents, globalCentsRange) {
+ const graphHeight = 450;
+ const yBase = graphHeight * 0.1;
+ const ySpread = graphHeight * 0.8;
+ return yBase + ySpread - (cents * PIXELS_PER_CENT);
+ }
+
+ // Calculate cents from Y using the reverse formula
+ function yToCents(y, globalMinCents, globalCentsRange) {
+ const graphHeight = 450;
+ const yBase = graphHeight * 0.1;
+ const ySpread = graphHeight * 0.8;
+ return (yBase + ySpread - y) / PIXELS_PER_CENT;
+ }
cy.on('grab', 'node', function(evt) {
- console.log('GRAB event');
const node = evt.target;
- grabPosition = node.position('y');
- node.data('originalY', grabPosition);
+ // Only set trueOriginalCents on first grab (never overwritten)
+ if (!node.data('trueOriginalCents')) {
+ node.data('trueOriginalCents', node.data('cents'));
+ }
+ grabPos = {
+ x: node.position('x'),
+ y: node.position('y'),
+ cents: node.data('trueOriginalCents'), // Use preserved original
+ };
+ node.data('originalCents', grabPos.cents);
});
cy.on('drag', 'node', function(evt) {
const node = evt.target;
- const originalY = node.data('originalY');
- // Only mark as dragging if it actually moved from grab position
- if (grabPosition !== null && Math.abs(node.position('y') - grabPosition) > 1) {
- isDragging = true;
- }
-
- if (originalY !== undefined) {
- node.position('y', originalY);
+ // Track X vs Y movement to determine drag direction
+ if (grabPos) {
+ const dx = Math.abs(node.position('x') - grabPos.x);
+ const dy = Math.abs(node.position('y') - grabPos.y);
+
+ if (dx > 3 || dy > 3) {
+ isDragging = true;
+ // Determine which direction dominates
+ if (dy > dx) {
+ dragDirection = 'y';
+ // Snap X to original, free Y
+ node.position('x', grabPos.x);
+ } else {
+ dragDirection = 'x';
+ // Snap Y to original, free X
+ node.position('y', grabPos.y);
+ }
+ }
}
});
cy.on('dragfree', 'node', function(evt) {
- console.log('DRAGFREE event');
+ const node = evt.target;
+
+ if (grabPos && isDragging && dragDirection === 'y') {
+ // Calculate current cents from Y position using reverse formula
+ const currentY = node.position('y');
+ const currentCents = yToCents(currentY);
+
+ // Calculate octave offset from original
+ const octaveOffset = Math.round((currentCents - grabPos.cents) / 1200);
+
+ // Snap to nearest octave
+ const snappedCents = grabPos.cents + (octaveOffset * 1200);
+ const snappedY = centsToY(snappedCents);
+
+ node.data('cents', snappedCents);
+ node.position('y', snappedY);
+ node.data('octaveOffset', octaveOffset); // Store for click handler
+
+ // Update display label based on displayMode
+ if (displayMode === 'frequency') {
+ const frac = parseFraction(node.data('fraction'));
+ const fundamental = parseFloat(document.getElementById("fundamentalInput").value) || 110;
+ const freq = fundamental * frac * Math.pow(2, octaveOffset);
+ node.data('displayLabel', Math.round(freq * 10) / 10);
+ } else {
+ node.data('displayLabel', Math.round(snappedCents));
+ }
+ node.style('label', node.data('displayLabel'));
+
+ console.log('Y-drag: originalY=', grabPos.y, 'currentY=', currentY, 'originalCents=', grabPos.cents, 'currentCents=', currentCents.toFixed(1), 'octaveOffset=', octaveOffset, 'snappedCents=', snappedCents);
+ }
+
+ // For X drag, just reset Y to original (visual only - allow X to stay where dragged)
+ if (grabPos && isDragging && dragDirection === 'x') {
+ node.position('y', grabPos.y);
+ console.log('X-drag: visual only');
+ }
+
isDragging = false;
- grabPosition = null;
+ dragDirection = null;
+ grabPos = null;
+ });
+
+ cy.on('drag', 'node', function(evt) {
+ const node = evt.target;
+
+ // Track X vs Y movement to determine drag direction
+ if (grabPos) {
+ const dx = Math.abs(node.position('x') - grabPos.x);
+ const dy = Math.abs(node.position('y') - grabPos.y);
+
+ if (dx > 3 || dy > 3) {
+ isDragging = true;
+ // Determine which direction dominates
+ if (dy > dx) {
+ dragDirection = 'y';
+ // Snap X to original, free Y
+ node.position('x', grabPos.x);
+ } else {
+ dragDirection = 'x';
+ // Snap Y to original, free X
+ node.position('y', grabPos.y);
+ }
+ }
+ }
+ });
+
+ cy.on('dragfree', 'node', function(evt) {
+ const node = evt.target;
+ const originalCents = node.data('originalCents');
+
+ if (grabPos && isDragging && dragDirection === 'y') {
+ const dy = node.position('y') - grabPos.y;
+
+ // Calculate octave offset from drag distance
+ // Each octave = 1200 cents
+ // Get global cents range from graph data
+ const allGraphsData_json = document.getElementById('allGraphsData');
+ let globalRange = 1200;
+ if (allGraphsData_json) {
+ const data = JSON.parse(allGraphsData_json.textContent);
+ const allCents = [];
+ data.graphs.forEach(g => {
+ if (g.nodes) allCents.push(...g.nodes.map(n => n.cents));
+ });
+ if (allCents.length > 0) {
+ globalRange = Math.max(...allCents) - Math.min(...allCents);
+ }
+ }
+
+ const graphHeight = 450;
+ const centsPerPixel = globalRange / graphHeight;
+ // Invert: dragging UP (negative dy) = higher cents
+ const centsDelta = -dy * centsPerPixel;
+ const octaveOffset = Math.round(centsDelta / 1200);
+
+ // Calculate new cents and update position
+ const newCents = originalCents + (octaveOffset * 1200);
+ const newY = grabPos.y - (octaveOffset * 1200) / centsPerPixel;
+
+ node.data('cents', newCents);
+ node.position('y', newY);
+ node.data('displayLabel', Math.round(newCents));
+ node.style('label', Math.round(newCents));
+
+ console.log('Y-drag: dy=', dy, 'centsDelta=', centsDelta, 'octaves=', octaveOffset, 'newCents=', newCents);
+ }
+
+ // For X drag, just reset X to original (visual only)
+ if (grabPos && isDragging && dragDirection === 'x') {
+ node.position('x', grabPos.x);
+ console.log('X-drag: visual only');
+ }
+
+ isDragging = false;
+ dragDirection = null;
+ grabPos = null;
});
// Click to play - send OSC
@@ -788,7 +967,8 @@
chordIndex: chordIndex,
nodeIndex: localId,
ip: sirenIp,
- sirenNumber: actualSiren
+ sirenNumber: actualSiren,
+ octaveOffset: node.data('octaveOffset') || 0
};
fetch(endpoint, {
@@ -891,7 +1071,7 @@
// Spread nodes within each chord
const x = xBase + (chordSpacing * 0.15) * (i / (nodes.length - 1 || 1));
- const y = yBase + ySpread - ((n.cents - globalMinCents) / globalCentsRange) * ySpread;
+ const y = yBase + ySpread - (n.cents * PIXELS_PER_CENT);
elements.push({
group: 'nodes',
@@ -921,6 +1101,10 @@
});
});
+ // Find max cents in this chord for label positioning
+ const maxCents = Math.max(...nodes.map(n => n.cents));
+ const labelY = 405 - ((maxCents + 400) * PIXELS_PER_CENT);
+
// Add label node for this chord (locked so layout doesn't move it)
elements.push({
group: 'nodes',
@@ -929,7 +1113,7 @@
chordIndex: chordIdx,
isLabel: "true",
},
- position: { x: xBase + (chordSpacing * 0.15), y: 30 },
+ position: { x: xBase + (chordSpacing * 0.15), y: labelY },
locked: true,
});
});
diff --git a/webapp/server.py b/webapp/server.py
index 5b23418..267e235 100644
--- a/webapp/server.py
+++ b/webapp/server.py
@@ -144,7 +144,10 @@ 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
- siren_number = data.get("sirenNumber") # NEW: optional 1-4
+ siren_number = data.get("sirenNumber") # optional 1-4
+ octave_offset = data.get(
+ "octaveOffset", 0
+ ) # optional octave offset for Y-drag transposition
if chord_index is None or node_index is None:
return jsonify({"error": "Missing chordIndex/nodeIndex"}), 400
@@ -158,7 +161,8 @@ def play_siren():
pitch = chord[node_index]
fraction = Fraction(pitch.get("fraction", "1"))
- frequency = fundamental * float(fraction)
+ base_frequency = fundamental * float(fraction)
+ frequency = base_frequency * (2**octave_offset) # Apply octave offset
voice = siren_number if siren_number else node_index + 1 # 1-indexed
# Send to siren using cached sender
@@ -171,6 +175,7 @@ def play_siren():
"frequency": frequency,
"voice": voice,
"siren": siren_number,
+ "octaveOffset": octave_offset,
"destination": "siren",
"ip": siren_ip,
}
@@ -216,7 +221,10 @@ def ramp_to_chord():
duration_ms = data.get("duration", 3000)
exponent = data.get("exponent", 1.0)
siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP
- siren_number = data.get("sirenNumber") # NEW: optional 1-4
+ siren_number = data.get("sirenNumber") # optional 1-4
+ octave_offset = data.get(
+ "octaveOffset", 0
+ ) # optional octave offset for Y-drag transposition
if chord_index is None:
return jsonify({"error": "Missing chordIndex"}), 400
@@ -236,7 +244,8 @@ def ramp_to_chord():
return jsonify({"error": "Invalid node index"}), 400
pitch = chord[node_index]
fraction = Fraction(pitch.get("fraction", "1"))
- frequency = fundamental * float(fraction)
+ base_frequency = fundamental * float(fraction)
+ frequency = base_frequency * (2**octave_offset) # Apply octave offset
voice = siren_number if siren_number else (node_index + 1) # 1-indexed
# Ramp single voice - let sender get start frequency from current position
@@ -246,7 +255,8 @@ def ramp_to_chord():
else:
# Ramp entire chord - let sender get start frequencies from current positions
frequencies = [
- fundamental * float(Fraction(p.get("fraction", "1"))) for p in chord
+ fundamental * float(Fraction(p.get("fraction", "1"))) * (2**octave_offset)
+ for p in chord
]
siren_sender.ramp_to_chord(