Add ghost nodes for Y-drag with frequency ratios, fix display and play handling

- Y-drag creates ghost nodes at quantized ratios (2/1, 3/2, 5/4, etc.)
- Ghost nodes store their own frequency (fundamental * new fraction)
- Preview ghost shows frequency in real-time while dragging
- Final ghost created on release (if dragged >100 cents from original)
- Server API accepts frequency directly for ghost nodes
- Fix frequency display: fundamental * fraction (not cents-adjusted)
- Fix negative snap ratios to use correct reciprocals
- Add colored circle on click (both ramp and non-ramp modes)
- Ghost nodes: no border initially, opacity 0.7, border shows on click
- Chord label clicks exclude ghost nodes
- Remove octaveOffset, use cents or direct frequency instead
This commit is contained in:
Michael Winter 2026-04-21 16:55:40 +02:00
parent e709d1a7a7
commit b8f50a4563
2 changed files with 340 additions and 74 deletions

View file

@ -380,6 +380,7 @@
<button id="siren3Btn" class="siren-btn">3</button>
<button id="siren4Btn" class="siren-btn">4</button>
<button id="sirenABtn" class="siren-btn">A</button>
<button id="deleteBtn" class="toggle-btn">DEL: OFF</button>
</span>
</div>
@ -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);
// 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] };
})
];
// Find nearest snap point
const centsDelta = currentCents - grabPos.cents;
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;
}
});
// 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);
node.data('cents', snappedCents);
node.position('y', snappedY);
node.data('octaveOffset', octaveOffset); // Store for click handler
// 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);
// 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));
// 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,
}
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);
// 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);
}
// 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);
});

View file

@ -145,10 +145,14 @@ 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 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
@ -161,8 +165,7 @@ def play_siren():
pitch = chord[node_index]
fraction = Fraction(pitch.get("fraction", "1"))
base_frequency = fundamental * float(fraction)
frequency = base_frequency * (2**octave_offset) # Apply octave offset
frequency = fundamental * float(fraction)
voice = siren_number if siren_number else node_index + 1 # 1-indexed
# Send to siren using cached sender
@ -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,10 +242,15 @@ def ramp_to_chord():
# Ramp single voice
if node_index < 0 or node_index >= len(chord):
return jsonify({"error": "Invalid node index"}), 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:
pitch = chord[node_index]
fraction = Fraction(pitch.get("fraction", "1"))
base_frequency = fundamental * float(fraction)
frequency = base_frequency * (2**octave_offset) # Apply octave offset
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
@ -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(