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(