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)
|
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):
|
||||||
|
|
|
||||||
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>
|
<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");
|
||||||
|
|
|
||||||
|
|
@ -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()}")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue