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()}")