Add OSC playback to path navigator webapp

- Add /api/set-fundamental and /api/play-freq endpoints to server.py
- Add send_single method to osc_sender.py for sending single frequencies
- Add fundamental frequency input and click handler to path_navigator.html
- Distinguish between click (play) and drag (move) on nodes
- Add osc_receiver.scd for SuperCollider to receive /freq messages
This commit is contained in:
Michael Winter 2026-04-01 11:23:24 +02:00
parent dff8a4e6c2
commit 6ab162003e
4 changed files with 161 additions and 1 deletions

View file

@ -91,6 +91,13 @@ class OSCSender:
msg.add_arg(freq) msg.add_arg(freq)
self.client.send(msg.build()) self.client.send(msg.build())
def send_single(self, frequency, voice):
"""Send a single frequency to a specific voice (1-indexed)."""
msg = osc_message_builder.OscMessageBuilder(address="/freq")
msg.add_arg(voice) # Voice number (1-indexed)
msg.add_arg(frequency)
self.client.send(msg.build())
def send_current(self): def send_current(self):
"""Send all voices at their current positions.""" """Send all voices at their current positions."""
for voice_idx in range(self.num_voices): for voice_idx in range(self.num_voices):

View file

@ -0,0 +1,36 @@
// osc_receiver.scd - OSC receiver for webapp
// Run this in SuperCollider: sclang supercollider/osc_receiver.scd
// Then click nodes in the webapp to play tones
s.boot;
s.waitForBoot {
// Define synth on the server (2-second ring)
SynthDef(\sineTone, {
|freq = 440, amp = 0.15|
var env = EnvGen.kr(
Env([0, 1, 1, 0], [0.05, 0.1, 1.85], \sin),
doneAction: Done.freeSelf
);
Out.ar(0, SinOsc.ar(freq) * env * amp);
}).add;
// Wait for synth to be added to server
s.sync;
// OSC handler for /freq messages
~oscHandler = OSCFunc({ |msg, time, addr, recvPort|
if (msg.size >= 3, {
var freq = msg[2].asFloat;
if (freq > 0, {
Synth(\sineTone, [\freq, freq, \amp, 0.15]);
});
});
}, '/freq');
"OSC receiver ready on port 57120".postln;
"Click nodes in webapp to play".postln;
// Keep alive
while { true } { 10.wait; };
}

View file

@ -277,6 +277,9 @@
<button onclick="loadSelectedFile()">Load</button> <button onclick="loadSelectedFile()">Load</button>
<span style="margin-left: 10px;">or upload:</span> <span style="margin-left: 10px;">or upload:</span>
<input type="file" id="fileInput" accept=".json"> <input type="file" id="fileInput" accept=".json">
<span style="margin-left: 20px;">Fundamental (Hz):</span>
<input type="number" id="fundamentalInput" value="110" style="width: 60px;">
<button onclick="setFundamental()">Set</button>
</div> </div>
<div class="controls"> <div class="controls">
@ -385,18 +388,66 @@
}); });
// Lock y on drag - only allow x movement // Lock y on drag - only allow x movement
let isDragging = false;
let grabPosition = null;
cy.on('grab', 'node', function(evt) { cy.on('grab', 'node', function(evt) {
console.log('GRAB event');
const node = evt.target; const node = evt.target;
node.data('originalY', node.position('y')); grabPosition = node.position('y');
node.data('originalY', grabPosition);
}); });
cy.on('drag', 'node', function(evt) { cy.on('drag', 'node', function(evt) {
const node = evt.target; const node = evt.target;
const originalY = node.data('originalY'); 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) { if (originalY !== undefined) {
node.position('y', originalY); node.position('y', originalY);
} }
}); });
cy.on('dragfree', 'node', function(evt) {
console.log('DRAGFREE event');
isDragging = false;
grabPosition = null;
});
// Click to play - send OSC
cy.on('tap', 'node', function(evt) {
console.log('TAP event fired', isDragging);
const node = evt.target;
console.log('Node data:', node.data());
if (isDragging) {
console.log('Was dragging, skipping');
return;
}
const chordIndex = node.data('chordIndex');
const localId = node.data('localId');
console.log('Sending play request:', chordIndex, localId);
fetch('/api/play-freq', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
chordIndex: chordIndex,
nodeIndex: localId
})
}).then(r => r.json()).then(data => {
console.log('Playing:', data.frequency.toFixed(2), 'Hz on voice', data.voice);
}).catch(err => {
console.log('Error playing freq:', err);
});
});
} }
// Render all three chords using Cytoscape (legacy - not used with all-chords approach) // Render all three chords using Cytoscape (legacy - not used with all-chords approach)
@ -734,6 +785,26 @@
} }
} }
async function setFundamental() {
const input = document.getElementById("fundamentalInput");
const fundamental = parseFloat(input.value);
if (!fundamental || fundamental <= 0) {
alert("Please enter a valid positive number");
return;
}
try {
const response = await fetch("/api/set-fundamental", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ fundamental: fundamental })
});
const data = await response.json();
console.log("Fundamental set to:", data.fundamental, "Hz");
} catch (e) {
console.log("Error setting fundamental", e);
}
}
async function loadFromLocalFile() { async function loadFromLocalFile() {
try { try {
const response = await fetch("output/output_chords.json"); const response = await fetch("output/output_chords.json");

View file

@ -14,6 +14,7 @@ import sys
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from src.pitch import Pitch from src.pitch import Pitch
from src.osc_sender import OSCSender
from fractions import Fraction from fractions import Fraction
app = Flask(__name__) app = Flask(__name__)
@ -27,6 +28,10 @@ current_index = 0
chords = [] chords = []
dims = (2, 3, 5, 7) dims = (2, 3, 5, 7)
# OSC settings
fundamental = 110.0
osc_sender = OSCSender(ip="127.0.0.1", port=57120, fundamental=fundamental)
def get_chords_file(): def get_chords_file():
return DATA_DIR / DATA_FILE return DATA_DIR / DATA_FILE
@ -281,6 +286,47 @@ def reload():
return jsonify({"loaded": len(chords), "index": current_index}) return jsonify({"loaded": len(chords), "index": current_index})
@app.route("/api/set-fundamental", methods=["POST"])
def set_fundamental():
"""Set the fundamental frequency for OSC playback."""
global fundamental, osc_sender
data = request.json
fundamental = data.get("fundamental", 110.0)
osc_sender = OSCSender(ip="127.0.0.1", port=57120, fundamental=fundamental)
return jsonify({"fundamental": fundamental})
@app.route("/api/play-freq", methods=["POST"])
def play_freq():
"""Play a single frequency for a node."""
data = request.json
chord_index = data.get("chordIndex")
node_index = data.get("nodeIndex")
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
pitch = chord[node_index]
fraction = Fraction(pitch.get("fraction", "1"))
frequency = fundamental * float(fraction)
voice = node_index + 1 # 1-indexed
osc_sender.send_single(frequency, voice)
return jsonify(
{
"frequency": frequency,
"voice": voice,
"fundamental": fundamental,
"fraction": pitch.get("fraction", "1"),
}
)
if __name__ == "__main__": if __name__ == "__main__":
print("Starting Path Navigator server...") print("Starting Path Navigator server...")
print(f"Loading chords from: {get_chords_file()}") print(f"Loading chords from: {get_chords_file()}")