diff --git a/webapp/path_navigator.html b/webapp/path_navigator.html
index 69e0ae7..9c58914 100644
--- a/webapp/path_navigator.html
+++ b/webapp/path_navigator.html
@@ -380,6 +380,7 @@
+
@@ -406,6 +407,7 @@
let chords = [];
let selectedSiren = 0; // 0 = use node's voice, 1-4 = selected siren
let rampMode = false; // false = play, true = ramp
+ let deleteMode = false; // true = delete ghost on click
let currentIndex = 0;
let totalSteps = 0;
let hasPrev = false;
@@ -422,6 +424,14 @@
}
}
+ function updateDeleteButton() {
+ const btn = document.getElementById('deleteBtn');
+ if (btn) {
+ btn.textContent = deleteMode ? 'DEL: ON' : 'DEL: OFF';
+ btn.classList.toggle('active', deleteMode);
+ }
+ }
+
function updateSirenButtons() {
const buttons = [
{ id: 'siren1Btn', value: 1 },
@@ -458,13 +468,36 @@
return parseFloat(fracStr);
}
+ // Multiply two fractions and return as string
+ function multiplyFractions(frac1, frac2) {
+ const f1 = parseFraction(frac1);
+ const f2 = parseFraction(frac2);
+ const result = f1 * f2;
+ // Try to express as simple fraction
+ // Find GCD to simplify
+ function gcd(a, b) {
+ return b === 0 ? a : gcd(b, a % b);
+ }
+ // Convert to fraction approximation
+ const tolerance = 0.0001;
+ for (let den = 1; den <= 1000; den++) {
+ const num = Math.round(result * den);
+ if (Math.abs(num / den - result) < tolerance) {
+ const common = gcd(num, den);
+ return (num / common) + '/' + (den / common);
+ }
+ }
+ // Fall back to decimal
+ return result.toString();
+ }
+
// Play all nodes in a chord on the siren
function playChordOnSiren(chordIdx) {
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
- // Find all nodes in this chord (exclude label nodes)
+ // Find all nodes in this chord (exclude label nodes and ghost nodes)
const nodes = cy.nodes().filter(n =>
- n.data('chordIndex') === chordIdx && !n.data('isLabel')
+ n.data('chordIndex') === chordIdx && !n.data('isLabel') && n.data('isGhost') !== 'true'
);
// Clear old sirenActive markers
@@ -491,15 +524,15 @@
}
// Ramp to a chord or single node on the siren
- function rampToChord(chordIdx, nodeIdx = null) {
+ function rampToChord(chordIdx, nodeIdx = null, frequency = null, targetNode = null) {
const durationSeconds = parseFloat(document.getElementById('rampDuration').value) || 3;
const durationMs = durationSeconds * 1000;
const exponent = parseFloat(document.getElementById('rampExponent').value) || 1.0;
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
- // Find nodes in this chord (exclude label nodes)
+ // Find nodes in this chord (exclude label nodes and ghost nodes)
const nodes = cy.nodes().filter(n =>
- n.data('chordIndex') === chordIdx && !n.data('isLabel')
+ n.data('chordIndex') === chordIdx && !n.data('isLabel') && n.data('isGhost') !== 'true'
);
// If nodeIdx is provided, clear only same-color nodes; otherwise clear all
@@ -516,13 +549,17 @@
if (n.data('borderColor') === nodeBorderColor && n.data('sirenActive')) {
n.data('sirenActive', '');
n.data('borderColor', '');
+ n.style('border-color', '');
+ n.style('border-width', 0);
}
});
- const targetNode = nodes.find(n => n.data('localId') === nodeIdx);
- if (targetNode) {
- targetNode.data('sirenActive', 'true');
- targetNode.data('borderColor', nodeBorderColor);
+ const targetNodeToUse = targetNode || nodes.find(n => n.data('localId') === nodeIdx);
+ if (targetNodeToUse) {
+ targetNodeToUse.data('sirenActive', 'true');
+ targetNodeToUse.data('borderColor', nodeBorderColor);
+ targetNodeToUse.style('border-color', nodeBorderColor);
+ targetNodeToUse.style('border-width', 3);
}
} else {
// Entire chord ramp - each node gets its own color
@@ -552,12 +589,9 @@
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;
- }
+ // Add frequency if provided (ghost node), otherwise server calculates from chordIndex/nodeIndex
+ if (frequency !== null) {
+ rampBody.frequency = frequency;
}
fetch('/api/ramp-to-chord', {
@@ -723,6 +757,8 @@
let isDragging = false;
let dragDirection = null; // 'x' or 'y'
let grabPos = null;
+ let previewGhostId = null;
+ let previewEdgeId = null;
// Calculate Y from cents using the same formula as rendering
function centsToY(cents, globalMinCents, globalCentsRange) {
@@ -767,8 +803,112 @@
// Determine which direction dominates
if (dy > dx) {
dragDirection = 'y';
- // Snap X to original, free Y
- node.position('x', grabPos.x);
+ // Original follows drag freely - just show preview ghost
+
+ // Show preview ghost at nearest snap position during drag
+ const currentY = node.position('y');
+ const currentCents = yToCents(currentY);
+ const centsDelta = currentCents - grabPos.cents;
+
+ const snapRatiosPositive = [
+ { ratio: 2/1, cents: 1200, label: '2/1' },
+ { ratio: 3/2, cents: Math.round(1200 * Math.log2(1.5)), label: '3/2' },
+ { ratio: 5/4, cents: Math.round(1200 * Math.log2(1.25)), label: '5/4' },
+ { ratio: 7/4, cents: Math.round(1200 * Math.log2(1.75)), label: '7/4' },
+ { ratio: 11/8, cents: Math.round(1200 * Math.log2(1.375)), label: '11/8' },
+ { ratio: 13/8, cents: Math.round(1200 * Math.log2(1.625)), label: '13/8' },
+ ];
+ const snapRatios = [
+ ...snapRatiosPositive,
+ ...snapRatiosPositive.map(s => {
+ const parts = s.label.split('/');
+ return { ratio: 1/s.ratio, cents: -s.cents, label: parts[1] + '/' + parts[0] };
+ })
+ ];
+
+ let nearestSnap = snapRatios[0];
+ let minDiff = Math.abs(centsDelta - nearestSnap.cents);
+ snapRatios.forEach(snap => {
+ const diff = Math.abs(centsDelta - snap.cents);
+ if (diff < minDiff) {
+ minDiff = diff;
+ nearestSnap = snap;
+ }
+ });
+
+ // Remove old preview
+ if (previewGhostId) {
+ cy.getElementById(previewGhostId).remove();
+ previewGhostId = null;
+ }
+ if (previewEdgeId) {
+ cy.getElementById(previewEdgeId).remove();
+ previewEdgeId = null;
+ }
+
+ // Always show preview of closest snap ratio
+ const snappedCents = grabPos.cents + nearestSnap.cents;
+ const snappedY = centsToY(snappedCents);
+ const sirenColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700'];
+ const actualSiren = selectedSiren > 0 ? selectedSiren : (node.data('localId') + 1);
+
+ previewGhostId = `preview_${Date.now()}`;
+ previewEdgeId = `pedge_${previewGhostId}`;
+
+// Add preview ghost
+ const originalFraction = node.data('fraction');
+ const newFraction = multiplyFractions(originalFraction, nearestSnap.label);
+ const fundamental = parseFloat(document.getElementById("fundamentalInput").value) || 110;
+ const newFrequency = fundamental * parseFraction(newFraction);
+
+ cy.add({
+ group: 'nodes',
+ data: {
+ id: previewGhostId,
+ localId: node.data('localId'),
+ chordIndex: node.data('chordIndex'),
+ cents: snappedCents,
+ fraction: newFraction,
+ ratioLabel: nearestSnap.label,
+ isGhost: 'preview'
+ },
+ position: { x: node.position('x') + 60, y: snappedY }
+ });
+
+ const pGhost = cy.getElementById(previewGhostId);
+ pGhost.style({
+ 'background-color': sirenColors[(actualSiren - 1) % 4],
+ 'border-width': 3,
+ 'border-color': sirenColors[(actualSiren - 1) % 4],
+ 'border-style': 'dashed',
+ 'opacity': 0.5,
+ 'label': (() => {
+ if (displayMode === 'frequency') {
+ return Math.round(newFrequency * 10) / 10;
+ }
+ return Math.round(snappedCents);
+ })(),
+ 'text-opacity': 0.8
+ });
+
+ // Add preview edge
+ cy.add({
+ group: 'edges',
+ data: {
+ id: previewEdgeId,
+ source: node.id(),
+ target: previewGhostId,
+ ratio: nearestSnap.label,
+ }
+ });
+
+ const pEdge = cy.getElementById(previewEdgeId);
+ pEdge.style({
+ 'line-color': sirenColors[(actualSiren - 1) % 4],
+ 'line-style': 'dashed',
+ 'opacity': 0.5,
+ 'line-dash-pattern': [4, 4],
+ });
} else {
dragDirection = 'x';
// Snap Y to original, free X
@@ -781,34 +921,125 @@
cy.on('dragfree', 'node', function(evt) {
const node = evt.target;
+ // Remove preview ghost and edge
+ if (previewGhostId) {
+ cy.getElementById(previewGhostId).remove();
+ previewGhostId = null;
+ }
+ if (previewEdgeId) {
+ cy.getElementById(previewEdgeId).remove();
+ previewEdgeId = null;
+ }
+
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);
+ // Calculate offset from original
+ // Quantization ratios (cents from original - positive for up, negative for down)
+ const snapRatiosPositive = [
+ { ratio: 2/1, cents: 1200, label: '2/1' },
+ { ratio: 3/2, cents: Math.round(1200 * Math.log2(1.5)), label: '3/2' },
+ { ratio: 5/4, cents: Math.round(1200 * Math.log2(1.25)), label: '5/4' },
+ { ratio: 7/4, cents: Math.round(1200 * Math.log2(1.75)), label: '7/4' },
+ { ratio: 11/8, cents: Math.round(1200 * Math.log2(1.375)), label: '11/8' },
+ { ratio: 13/8, cents: Math.round(1200 * Math.log2(1.625)), label: '13/8' },
+ ];
- // Snap to nearest octave
- const snappedCents = grabPos.cents + (octaveOffset * 1200);
- const snappedY = centsToY(snappedCents);
+ // Add negative versions for dragging down
+ const snapRatios = [
+ ...snapRatiosPositive,
+ ...snapRatiosPositive.map(s => {
+ const parts = s.label.split('/');
+ return { ratio: 1/s.ratio, cents: -s.cents, label: parts[1] + '/' + parts[0] };
+ })
+ ];
- node.data('cents', snappedCents);
- node.position('y', snappedY);
- node.data('octaveOffset', octaveOffset); // Store for click handler
+ // Find nearest snap point
+ const centsDelta = currentCents - grabPos.cents;
+ let nearestSnap = snapRatios[0];
+ let minDiff = Math.abs(centsDelta - nearestSnap.cents);
- // 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));
+ snapRatios.forEach(snap => {
+ const diff = Math.abs(centsDelta - snap.cents);
+ if (diff < minDiff) {
+ minDiff = diff;
+ nearestSnap = snap;
+ }
+ });
+
+ // Only create ghost if moved significantly (more than 100 cents from original)
+ if (Math.abs(centsDelta) > 100) {
+ // Snap to nearest ratio
+ const snappedCents = grabPos.cents + nearestSnap.cents;
+ const snappedY = centsToY(snappedCents);
+
+ // Compute new fraction: original fraction * snap ratio
+ const originalFraction = node.data('fraction');
+ const newFraction = multiplyFractions(originalFraction, nearestSnap.label);
+ const fundamental = parseFloat(document.getElementById("fundamentalInput").value || 110);
+ const newFrequency = fundamental * parseFraction(newFraction);
+
+ // Create ghost node at new position
+ const ghostId = `ghost_${Date.now()}`;
+ const sirenColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700'];
+ const actualSiren = selectedSiren > 0 ? selectedSiren : (node.data('localId') + 1);
+
+ cy.add({
+ group: 'nodes',
+ data: {
+ id: ghostId,
+ localId: node.data('localId'),
+ chordIndex: node.data('chordIndex'),
+ cents: snappedCents,
+ fraction: newFraction,
+ ratioLabel: nearestSnap.label,
+ frequency: newFrequency,
+ displayLabel: (() => {
+ return displayMode === 'frequency' ?
+ Math.round(newFrequency * 10) / 10 :
+ snappedCents;
+ })(),
+ color: node.data('color'),
+ isGhost: 'true'
+ },
+ position: { x: node.position('x') + 60, y: snappedY }
+ });
+
+ // Add edge between original and ghost to show ratio
+ const edgeId = `edge_${ghostId}`;
+ cy.add({
+ group: 'edges',
+ data: {
+ id: edgeId,
+ source: node.id(),
+ target: ghostId,
+ ratio: nearestSnap.label,
+ }
+ });
+
+ // Style the ghost node (slightly transparent, no border until clicked)
+ const ghostNode = cy.getElementById(ghostId);
+ ghostNode.style({
+ 'opacity': 0.7,
+ 'label': ghostNode.data('displayLabel')
+ });
+
+ console.log('Ghost node created:', ghostId, 'cents=', snappedCents, 'ratio=', nearestSnap.label);
}
- 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);
+ // Reset original node to original position
+ node.position('y', grabPos.y);
+ if (node.data('isGhost') !== 'true') {
+ node.data('cents', grabPos.cents);
+ node.data('displayLabel', displayMode === 'frequency' ?
+ Math.round((parseFloat(document.getElementById("fundamentalInput").value || 110) * parseFraction(node.data('fraction')) * 10) / 10) :
+ Math.round(grabPos.cents));
+ node.style('label', node.data('displayLabel'));
+ }
+
+ console.log('Y-drag: originalY=', grabPos.y, 'currentY=', currentY, 'originalCents=', grabPos.cents, 'currentCents=', currentCents.toFixed(1));
}
// For X drag, just reset Y to original (visual only - allow X to stay where dragged)
@@ -835,12 +1066,10 @@
// Determine which direction dominates
if (dy > dx) {
dragDirection = 'y';
- // Snap X to original, free Y
- node.position('x', grabPos.x);
+ // Original follows drag freely
} else {
dragDirection = 'x';
- // Snap Y to original, free X
- node.position('y', grabPos.y);
+ // Original follows drag freely
}
}
}
@@ -872,19 +1101,18 @@
const graphHeight = 450;
const centsPerPixel = globalRange / graphHeight;
// Invert: dragging UP (negative dy) = higher cents
- const centsDelta = -dy * centsPerPixel;
- const octaveOffset = Math.round(centsDelta / 1200);
+ const centsOffset = -dy * centsPerPixel;
// Calculate new cents and update position
- const newCents = originalCents + (octaveOffset * 1200);
- const newY = grabPos.y - (octaveOffset * 1200) / centsPerPixel;
+ const newCents = originalCents + centsOffset;
+ const newY = grabPos.y - centsOffset / 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);
+ console.log('Y-drag: dy=', dy, 'centsOffset=', centsOffset, 'newCents=', newCents);
}
// For X drag, just reset X to original (visual only)
@@ -910,6 +1138,15 @@
return;
}
+ // Delete ghost node if delete mode is on and node is a ghost
+ if (deleteMode && node.data('isGhost') === 'true') {
+ node.remove();
+ console.log('Ghost deleted');
+ deleteMode = false;
+ updateDeleteButton();
+ return;
+ }
+
// Check modifiers
const isShift = evt.originalEvent && evt.originalEvent.shiftKey;
@@ -950,7 +1187,8 @@
// 2. Ramp mode ON → ramp to siren
if (rampMode) {
- rampToChord(chordIndex, localId);
+ const nodeFrequency = node.data('frequency');
+ rampToChord(chordIndex, localId, nodeFrequency, node);
return;
}
@@ -963,14 +1201,20 @@
console.log('Sending to siren:', chordIndex, localId, 'siren:', actualSiren);
- const requestBody = {
+ // If ghost node with its own frequency, send directly; otherwise use chordIndex/nodeIndex
+ const requestBody = node.data('frequency') ? {
+ frequency: node.data('frequency'),
+ ip: sirenIp,
+ sirenNumber: actualSiren,
+ } : {
chordIndex: chordIndex,
nodeIndex: localId,
ip: sirenIp,
sirenNumber: actualSiren,
- octaveOffset: node.data('octaveOffset') || 0
};
+ console.log('Click node:', node.id(), 'isGhost=', node.data('isGhost'), 'frequency=', node.data('frequency'), 'cents=', node.data('cents'), 'fraction=', node.data('fraction'));
+
fetch(endpoint, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
@@ -987,12 +1231,15 @@
if (n.data('borderColor') === borderColor && n.data('sirenActive')) {
n.data('sirenActive', '');
n.data('borderColor', '');
+ n.style('border-color', '');
+ n.style('border-width', 0);
}
});
// Add sirenActive to clicked node
node.data('sirenActive', 'true');
node.data('borderColor', borderColor);
- console.log('Added sirenActive to', node.id(), 'color:', borderColor);
+ node.style('border-color', borderColor);
+ node.style('border-width', 3);
}).catch(err => {
console.log('Error playing freq:', err);
});
@@ -1575,6 +1822,11 @@
rampMode = !rampMode;
console.log('Ramp mode:', rampMode);
updateRampButton();
+ } else if (e.key === 'd' || e.key === 'D') {
+ // Toggle delete mode (for removing ghost nodes)
+ deleteMode = !deleteMode;
+ console.log('Delete mode:', deleteMode);
+ updateDeleteButton();
} else if (e.key === "k") {
// Soft kill - send 20 Hz to stop voices gently
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
@@ -1589,6 +1841,9 @@
n.removeData('sirenActive');
n.removeData('borderColor');
});
+ // Remove all ghost nodes
+ const ghosts = cy.nodes().filter(n => n.data('isGhost') === 'true');
+ ghosts.remove();
}).catch(err => {
console.log('Error sending kill:', err);
});
@@ -1606,6 +1861,13 @@
n.removeData('sirenActive');
n.removeData('borderColor');
});
+ // Remove all ghost nodes
+ const ghosts = cy.nodes().filter(n => n.data('isGhost') === 'true');
+ ghosts.remove();
+ cy.nodes().forEach(n => {
+ n.removeData('sirenActive');
+ n.removeData('borderColor');
+ });
}).catch(err => {
console.log('Error sending kill:', err);
});
diff --git a/webapp/server.py b/webapp/server.py
index 267e235..c49a09f 100644
--- a/webapp/server.py
+++ b/webapp/server.py
@@ -145,25 +145,28 @@ def play_siren():
node_index = data.get("nodeIndex")
siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP
siren_number = data.get("sirenNumber") # optional 1-4
- octave_offset = data.get(
- "octaveOffset", 0
- ) # optional octave offset for Y-drag transposition
+ frequency_input = data.get("frequency") # direct frequency for ghost nodes
- if chord_index is None or node_index is None:
- return jsonify({"error": "Missing chordIndex/nodeIndex"}), 400
+ # If frequency provided directly (ghost node), use it
+ if frequency_input is not None:
+ frequency = float(frequency_input)
+ voice = siren_number if siren_number else 1
+ else:
+ # Original node: calculate from chordIndex/nodeIndex
+ if chord_index is None or node_index is None:
+ return jsonify({"error": "Missing chordIndex/nodeIndex"}), 400
- if chord_index < 0 or chord_index >= len(chords):
- return jsonify({"error": "Invalid chord index"}), 400
+ if chord_index < 0 or chord_index >= len(chords):
+ return jsonify({"error": "Invalid chord index"}), 400
- chord = chords[chord_index]
- if node_index < 0 or node_index >= len(chord):
- return jsonify({"error": "Invalid node index"}), 400
+ 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"))
- 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
+ pitch = chord[node_index]
+ fraction = Fraction(pitch.get("fraction", "1"))
+ frequency = fundamental * float(fraction)
+ voice = siren_number if siren_number else node_index + 1 # 1-indexed
# Send to siren using cached sender
siren_sender = get_siren_sender(siren_ip)
@@ -175,7 +178,6 @@ def play_siren():
"frequency": frequency,
"voice": voice,
"siren": siren_number,
- "octaveOffset": octave_offset,
"destination": "siren",
"ip": siren_ip,
}
@@ -222,9 +224,7 @@ def ramp_to_chord():
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") # optional 1-4
- octave_offset = data.get(
- "octaveOffset", 0
- ) # optional octave offset for Y-drag transposition
+ frequency_input = data.get("frequency") # direct frequency for ghost nodes
if chord_index is None:
return jsonify({"error": "Missing chordIndex"}), 400
@@ -242,11 +242,16 @@ def ramp_to_chord():
# Ramp single voice
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"))
- 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
+
+ # If frequency provided directly (ghost node), use it
+ if frequency_input is not None:
+ frequency = float(frequency_input)
+ voice = siren_number if siren_number else 1
+ else:
+ pitch = chord[node_index]
+ fraction = Fraction(pitch.get("fraction", "1"))
+ frequency = fundamental * float(fraction)
+ voice = siren_number if siren_number else (node_index + 1) # 1-indexed
# Ramp single voice - let sender get start frequency from current position
siren_sender.ramp_to_pitch(
@@ -255,8 +260,7 @@ def ramp_to_chord():
else:
# Ramp entire chord - let sender get start frequencies from current positions
frequencies = [
- fundamental * float(Fraction(p.get("fraction", "1"))) * (2**octave_offset)
- for p in chord
+ fundamental * float(Fraction(p.get("fraction", "1"))) for p in chord
]
siren_sender.ramp_to_chord(