2026-03-30 22:38:52 +02:00
|
|
|
#!/usr/bin/env python
|
|
|
|
|
"""
|
|
|
|
|
Flask server for Path Navigator with OSC support.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from flask import Flask, jsonify, request, send_from_directory
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
# Add parent directory to path for imports
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
|
|
|
|
|
|
from src.pitch import Pitch
|
2026-04-04 21:46:15 +02:00
|
|
|
from src.osc_sender import OSCSender, cancel_ramp
|
2026-03-30 22:38:52 +02:00
|
|
|
from fractions import Fraction
|
|
|
|
|
|
|
|
|
|
app = Flask(__name__)
|
|
|
|
|
|
2026-04-01 17:14:15 +02:00
|
|
|
# Path to output directory
|
2026-03-30 22:38:52 +02:00
|
|
|
DATA_DIR = Path(__file__).parent.parent / "output"
|
2026-04-01 17:14:15 +02:00
|
|
|
DEFAULT_FILE = "output_chords.json" # default file
|
2026-03-30 22:38:52 +02:00
|
|
|
|
|
|
|
|
# State
|
|
|
|
|
current_index = 0
|
|
|
|
|
chords = []
|
|
|
|
|
|
2026-04-04 21:46:15 +02:00
|
|
|
# Track last frequency per voice per siren IP (in-memory)
|
|
|
|
|
last_frequencies = {} # {ip: {voice_index: frequency}}
|
|
|
|
|
|
2026-04-01 11:23:24 +02:00
|
|
|
# OSC settings
|
|
|
|
|
fundamental = 110.0
|
|
|
|
|
osc_sender = OSCSender(ip="127.0.0.1", port=57120, fundamental=fundamental)
|
|
|
|
|
|
2026-03-30 22:38:52 +02:00
|
|
|
|
2026-04-01 17:14:15 +02:00
|
|
|
def load_chords(filepath=None):
|
2026-03-30 22:38:52 +02:00
|
|
|
global chords, current_index
|
2026-04-01 17:14:15 +02:00
|
|
|
if filepath is None:
|
|
|
|
|
filepath = DATA_DIR / DEFAULT_FILE
|
|
|
|
|
if filepath.exists():
|
|
|
|
|
with open(filepath) as f:
|
2026-03-30 22:38:52 +02:00
|
|
|
data = json.load(f)
|
|
|
|
|
chords = data.get("chords", [])
|
|
|
|
|
current_index = 0
|
|
|
|
|
else:
|
|
|
|
|
chords = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
load_chords()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_fraction(frac_str):
|
|
|
|
|
"""Parse a fraction string to float."""
|
|
|
|
|
if isinstance(frac_str, (int, float)):
|
|
|
|
|
return float(frac_str)
|
|
|
|
|
if "/" in frac_str:
|
|
|
|
|
num, den = frac_str.split("/")
|
|
|
|
|
return int(num) / int(den)
|
|
|
|
|
return float(frac_str)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def calculate_cents(fraction_str):
|
|
|
|
|
"""Calculate cents from fraction string."""
|
2026-04-01 16:47:45 +02:00
|
|
|
import math
|
|
|
|
|
|
2026-03-30 22:38:52 +02:00
|
|
|
fr = parse_fraction(fraction_str)
|
|
|
|
|
if fr <= 0:
|
|
|
|
|
return 0
|
|
|
|
|
return 1200 * math.log2(fr)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/")
|
|
|
|
|
def index():
|
|
|
|
|
return send_from_directory(".", "path_navigator.html")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/<path:filename>")
|
|
|
|
|
def serve_static(filename):
|
|
|
|
|
return send_from_directory(".", filename)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/api/chords")
|
|
|
|
|
def get_chords():
|
|
|
|
|
return jsonify({"chords": chords, "total": len(chords)})
|
|
|
|
|
|
|
|
|
|
|
2026-04-01 11:23:24 +02:00
|
|
|
@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")
|
2026-04-01 18:39:13 +02:00
|
|
|
octave = data.get("octave", 0)
|
2026-04-01 11:23:24 +02:00
|
|
|
|
|
|
|
|
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"))
|
2026-04-01 18:39:13 +02:00
|
|
|
frequency = fundamental * float(fraction) * (2**octave)
|
2026-04-01 11:23:24 +02:00
|
|
|
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"),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-04-01 13:01:50 +02:00
|
|
|
@app.route("/api/play-siren", methods=["POST"])
|
|
|
|
|
def play_siren():
|
|
|
|
|
"""Play a single frequency for a node on the siren."""
|
|
|
|
|
data = request.json
|
2026-04-04 19:56:39 +02:00
|
|
|
|
2026-04-01 13:01:50 +02:00
|
|
|
chord_index = data.get("chordIndex")
|
|
|
|
|
node_index = data.get("nodeIndex")
|
2026-04-04 21:46:15 +02:00
|
|
|
siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP
|
2026-04-04 19:57:54 +02:00
|
|
|
|
|
|
|
|
if chord_index is None or node_index is None:
|
|
|
|
|
return jsonify({"error": "Missing chordIndex/nodeIndex"}), 400
|
|
|
|
|
|
|
|
|
|
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
|
2026-04-01 13:01:50 +02:00
|
|
|
|
2026-04-04 21:46:15 +02:00
|
|
|
# Send to siren using provided or default IP
|
|
|
|
|
siren_sender = OSCSender(ip=siren_ip, port=54001, fundamental=fundamental)
|
2026-04-01 13:01:50 +02:00
|
|
|
siren_sender.set_chords(chords) # Set chords to ensure proper voice count
|
|
|
|
|
siren_sender.send_single(frequency, voice)
|
|
|
|
|
|
2026-04-04 21:46:15 +02:00
|
|
|
# Track frequency per voice (0-indexed)
|
|
|
|
|
last_frequencies.setdefault(siren_ip, {})[node_index] = frequency
|
|
|
|
|
|
2026-04-01 13:01:50 +02:00
|
|
|
return jsonify(
|
|
|
|
|
{
|
|
|
|
|
"frequency": frequency,
|
|
|
|
|
"voice": voice,
|
|
|
|
|
"destination": "siren",
|
2026-04-04 21:46:15 +02:00
|
|
|
"ip": siren_ip,
|
2026-04-01 13:01:50 +02:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/api/kill-siren", methods=["POST"])
|
|
|
|
|
def kill_siren():
|
|
|
|
|
"""Send kill message to siren (soft or hard)."""
|
|
|
|
|
data = request.json
|
|
|
|
|
soft = data.get("soft", True) # default to soft kill
|
2026-04-04 21:46:15 +02:00
|
|
|
siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP
|
2026-04-01 13:01:50 +02:00
|
|
|
|
|
|
|
|
kill_freq = 20.0 if soft else 0.0
|
|
|
|
|
kill_type = "soft" if soft else "hard"
|
|
|
|
|
|
2026-04-04 21:46:15 +02:00
|
|
|
# Cancel any running ramp
|
|
|
|
|
cancel_ramp()
|
|
|
|
|
|
2026-04-01 13:01:50 +02:00
|
|
|
# Send kill to all voices on siren using send_kill
|
2026-04-04 21:46:15 +02:00
|
|
|
siren_sender = OSCSender(ip=siren_ip, port=54001, fundamental=fundamental)
|
2026-04-01 13:01:50 +02:00
|
|
|
siren_sender.set_chords(chords) # Set chords to get correct num_voices
|
|
|
|
|
siren_sender.send_kill(soft=soft)
|
|
|
|
|
|
2026-04-04 21:46:15 +02:00
|
|
|
# Update tracking: set all voices to kill frequency (20 or 0)
|
|
|
|
|
if siren_ip in last_frequencies:
|
|
|
|
|
for voice_idx in last_frequencies[siren_ip]:
|
|
|
|
|
last_frequencies[siren_ip][voice_idx] = kill_freq
|
|
|
|
|
|
2026-04-01 13:01:50 +02:00
|
|
|
return jsonify(
|
|
|
|
|
{
|
|
|
|
|
"kill_freq": kill_freq,
|
|
|
|
|
"kill_type": kill_type,
|
|
|
|
|
"voices": list(range(1, siren_sender.num_voices + 1)),
|
2026-04-04 21:46:15 +02:00
|
|
|
"ip": siren_ip,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/api/ramp-to-chord", methods=["POST"])
|
|
|
|
|
def ramp_to_chord():
|
|
|
|
|
"""Ramp to a chord or single voice on the siren."""
|
|
|
|
|
data = request.json
|
|
|
|
|
|
|
|
|
|
chord_index = data.get("chordIndex")
|
|
|
|
|
node_index = data.get("nodeIndex") # Optional - if present, ramp single voice
|
|
|
|
|
duration_ms = data.get("duration", 3000)
|
|
|
|
|
start_freq = data.get("startFreq", 20)
|
|
|
|
|
siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP
|
|
|
|
|
|
|
|
|
|
if chord_index is None:
|
|
|
|
|
return jsonify({"error": "Missing chordIndex"}), 400
|
|
|
|
|
|
|
|
|
|
if chord_index < 0 or chord_index >= len(chords):
|
|
|
|
|
return jsonify({"error": "Invalid chord index"}), 400
|
|
|
|
|
|
|
|
|
|
chord = chords[chord_index]
|
|
|
|
|
|
|
|
|
|
# Create OSC sender with provided or default IP
|
|
|
|
|
siren_sender = OSCSender(ip=siren_ip, port=54001, fundamental=fundamental)
|
|
|
|
|
siren_sender.set_chords(chords)
|
|
|
|
|
|
|
|
|
|
if node_index is not None:
|
|
|
|
|
# Ramp single voice
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
# Get current frequency for this voice (or default to 20)
|
|
|
|
|
current_freqs = last_frequencies.get(siren_ip, {})
|
|
|
|
|
voice_start_freq = current_freqs.get(node_index, 20)
|
|
|
|
|
|
|
|
|
|
siren_sender.ramp_to_chord(frequency, duration_ms, voice_start_freq)
|
|
|
|
|
|
|
|
|
|
# Update tracking with target (will be reached after ramp completes)
|
|
|
|
|
last_frequencies.setdefault(siren_ip, {})[node_index] = frequency
|
|
|
|
|
else:
|
|
|
|
|
# Ramp entire chord
|
|
|
|
|
frequencies = [
|
|
|
|
|
fundamental * float(Fraction(p.get("fraction", "1"))) for p in chord
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
# Get current frequencies for each voice (or default to 20)
|
|
|
|
|
current_freqs = last_frequencies.get(siren_ip, {})
|
|
|
|
|
start_freqs = [current_freqs.get(i, 20) for i in range(len(frequencies))]
|
|
|
|
|
|
|
|
|
|
siren_sender.ramp_to_chord(frequencies, duration_ms, start_freqs)
|
|
|
|
|
|
|
|
|
|
# Update tracking with targets (will be reached after ramp completes)
|
|
|
|
|
for i, freq in enumerate(frequencies):
|
|
|
|
|
last_frequencies.setdefault(siren_ip, {})[i] = freq
|
|
|
|
|
|
|
|
|
|
return jsonify(
|
|
|
|
|
{
|
|
|
|
|
"status": "ramping",
|
|
|
|
|
"chordIndex": chord_index,
|
|
|
|
|
"voiceIndex": node_index,
|
|
|
|
|
"duration": duration_ms,
|
2026-04-01 13:01:50 +02:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-04-01 16:33:13 +02:00
|
|
|
@app.route("/api/load-file", methods=["POST"])
|
|
|
|
|
def load_file():
|
|
|
|
|
global current_index, chords
|
|
|
|
|
data = request.json
|
|
|
|
|
filepath = data.get("filepath")
|
|
|
|
|
|
|
|
|
|
if not filepath:
|
|
|
|
|
return jsonify({"error": "No filepath provided"}), 400
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
with open(filepath) as f:
|
|
|
|
|
chords_data = json.load(f)
|
|
|
|
|
chords = chords_data.get("chords", [])
|
|
|
|
|
current_index = 0
|
|
|
|
|
return jsonify({"loaded": len(chords), "filepath": filepath})
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
return jsonify({"error": f"File not found: {filepath}"}), 404
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
return jsonify({"error": f"Invalid JSON in: {filepath}"}), 400
|
|
|
|
|
|
|
|
|
|
|
2026-04-01 16:44:20 +02:00
|
|
|
@app.route("/api/parse-fraction", methods=["POST"])
|
|
|
|
|
def parse_fraction_api():
|
|
|
|
|
"""Parse a fraction string to float."""
|
|
|
|
|
data = request.json
|
|
|
|
|
fraction = data.get("fraction", "1")
|
|
|
|
|
try:
|
|
|
|
|
value = parse_fraction(fraction)
|
|
|
|
|
return jsonify({"fraction": fraction, "value": value})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return jsonify({"error": str(e)}), 400
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/api/calculate-cents", methods=["POST"])
|
|
|
|
|
def calculate_cents_api():
|
|
|
|
|
"""Calculate cents from fraction string."""
|
|
|
|
|
data = request.json
|
|
|
|
|
fraction = data.get("fraction", "1")
|
|
|
|
|
try:
|
|
|
|
|
cents = calculate_cents(fraction)
|
|
|
|
|
return jsonify({"fraction": fraction, "cents": cents})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return jsonify({"error": str(e)}), 400
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/api/batch-calculate-cents", methods=["POST"])
|
|
|
|
|
def batch_calculate_cents_api():
|
|
|
|
|
"""Calculate cents for multiple fractions at once."""
|
|
|
|
|
data = request.json
|
|
|
|
|
fractions = data.get("fractions", [])
|
|
|
|
|
try:
|
|
|
|
|
results = []
|
|
|
|
|
for fraction in fractions:
|
|
|
|
|
cents = calculate_cents(fraction)
|
|
|
|
|
results.append({"fraction": fraction, "cents": cents})
|
|
|
|
|
return jsonify({"results": results})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return jsonify({"error": str(e)}), 400
|
|
|
|
|
|
|
|
|
|
|
2026-03-30 22:38:52 +02:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
print("Starting Path Navigator server...")
|
2026-04-01 17:14:15 +02:00
|
|
|
filepath = DATA_DIR / DEFAULT_FILE
|
|
|
|
|
print(f"Loading chords from: {filepath}")
|
2026-03-30 22:38:52 +02:00
|
|
|
load_chords()
|
|
|
|
|
print(f"Loaded {len(chords)} chords")
|
|
|
|
|
app.run(host="0.0.0.0", port=8080, debug=True)
|