diff --git a/src/osc_sender.py b/src/osc_sender.py index c022efb..b1c67ce 100644 --- a/src/osc_sender.py +++ b/src/osc_sender.py @@ -91,6 +91,13 @@ class OSCSender: msg.add_arg(freq) 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): """Send all voices at their current positions.""" for voice_idx in range(self.num_voices): diff --git a/supercollider/osc_receiver.scd b/supercollider/osc_receiver.scd new file mode 100644 index 0000000..517caab --- /dev/null +++ b/supercollider/osc_receiver.scd @@ -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; }; +} \ No newline at end of file diff --git a/webapp/path_navigator.html b/webapp/path_navigator.html index 6dda9c4..10555a6 100644 --- a/webapp/path_navigator.html +++ b/webapp/path_navigator.html @@ -277,6 +277,9 @@ or upload: + Fundamental (Hz): + +
@@ -385,18 +388,66 @@ }); // Lock y on drag - only allow x movement + let isDragging = false; + let grabPosition = null; + cy.on('grab', 'node', function(evt) { + console.log('GRAB event'); const node = evt.target; - node.data('originalY', node.position('y')); + grabPosition = node.position('y'); + node.data('originalY', grabPosition); }); 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); } }); + + 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) @@ -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() { try { const response = await fetch("output/output_chords.json"); diff --git a/webapp/server.py b/webapp/server.py index 865083b..6cf6ee8 100644 --- a/webapp/server.py +++ b/webapp/server.py @@ -14,6 +14,7 @@ import sys sys.path.insert(0, str(Path(__file__).parent.parent)) from src.pitch import Pitch +from src.osc_sender import OSCSender from fractions import Fraction app = Flask(__name__) @@ -27,6 +28,10 @@ current_index = 0 chords = [] 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(): return DATA_DIR / DATA_FILE @@ -281,6 +286,47 @@ def reload(): 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__": print("Starting Path Navigator server...") print(f"Loading chords from: {get_chords_file()}")