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-01 11:23:24 +02:00
|
|
|
from src.osc_sender import OSCSender
|
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-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
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
# Send to siren (192.168.4.200:54001) using current fundamental
|
|
|
|
|
siren_sender = OSCSender(ip="192.168.4.200", port=54001, fundamental=fundamental)
|
|
|
|
|
siren_sender.set_chords(chords) # Set chords to ensure proper voice count
|
|
|
|
|
siren_sender.send_single(frequency, voice)
|
|
|
|
|
|
|
|
|
|
return jsonify(
|
|
|
|
|
{
|
|
|
|
|
"frequency": frequency,
|
|
|
|
|
"voice": voice,
|
|
|
|
|
"fundamental": fundamental,
|
|
|
|
|
"fraction": pitch.get("fraction", "1"),
|
|
|
|
|
"destination": "siren",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
kill_freq = 20.0 if soft else 0.0
|
|
|
|
|
kill_type = "soft" if soft else "hard"
|
|
|
|
|
|
|
|
|
|
# Send kill to all voices on siren using send_kill
|
|
|
|
|
siren_sender = OSCSender(ip="192.168.4.200", port=54001, fundamental=fundamental)
|
|
|
|
|
siren_sender.set_chords(chords) # Set chords to get correct num_voices
|
|
|
|
|
siren_sender.send_kill(soft=soft)
|
|
|
|
|
|
|
|
|
|
return jsonify(
|
|
|
|
|
{
|
|
|
|
|
"kill_freq": kill_freq,
|
|
|
|
|
"kill_type": kill_type,
|
|
|
|
|
"voices": list(range(1, siren_sender.num_voices + 1)),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
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)
|