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:
parent
dff8a4e6c2
commit
6ab162003e
|
|
@ -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):
|
||||
|
|
|
|||
36
supercollider/osc_receiver.scd
Normal file
36
supercollider/osc_receiver.scd
Normal 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; };
|
||||
}
|
||||
|
|
@ -277,6 +277,9 @@
|
|||
<button onclick="loadSelectedFile()">Load</button>
|
||||
<span style="margin-left: 10px;">or upload:</span>
|
||||
<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 class="controls">
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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()}")
|
||||
|
|
|
|||
Loading…
Reference in a new issue