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__)
|
|
|
|
|
|
|
|
|
|
# Path to data files
|
|
|
|
|
DATA_DIR = Path(__file__).parent.parent / "output"
|
|
|
|
|
DATA_FILE = "output_chords.json" # default file
|
|
|
|
|
|
|
|
|
|
# State
|
|
|
|
|
current_index = 0
|
|
|
|
|
chords = []
|
|
|
|
|
dims = (2, 3, 5, 7)
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
def get_chords_file():
|
|
|
|
|
return DATA_DIR / DATA_FILE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_chords():
|
|
|
|
|
global chords, current_index
|
|
|
|
|
chords_file = get_chords_file()
|
|
|
|
|
if chords_file.exists():
|
|
|
|
|
with open(chords_file) as f:
|
|
|
|
|
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."""
|
|
|
|
|
fr = parse_fraction(fraction_str)
|
|
|
|
|
if fr <= 0:
|
|
|
|
|
return 0
|
|
|
|
|
return 1200 * (
|
|
|
|
|
float(fr).bit_length()
|
|
|
|
|
- 1
|
|
|
|
|
+ (fr / (1 << (fr.bit_length() - 1)) - 1) / 0.6931471805599453
|
|
|
|
|
if hasattr(fr, "bit_length")
|
|
|
|
|
else 1200 * (float(fr).bit_length() - 1)
|
|
|
|
|
)
|
|
|
|
|
# Simpler: use log2
|
|
|
|
|
import math
|
|
|
|
|
|
|
|
|
|
return 1200 * math.log2(fr)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def calculate_graph(chord):
|
|
|
|
|
"""Calculate nodes and edges for a chord using pitch logic."""
|
|
|
|
|
if not chord:
|
|
|
|
|
return {"nodes": [], "edges": []}
|
|
|
|
|
|
|
|
|
|
# Calculate cents for each pitch
|
|
|
|
|
import math
|
|
|
|
|
|
|
|
|
|
nodes = []
|
|
|
|
|
cents_list = []
|
|
|
|
|
|
|
|
|
|
for i, pitch in enumerate(chord):
|
|
|
|
|
fr = parse_fraction(pitch.get("fraction", "1"))
|
|
|
|
|
cents = 1200 * math.log2(fr) if fr > 0 else 0
|
|
|
|
|
cents_list.append(cents)
|
|
|
|
|
|
|
|
|
|
nodes.append(
|
|
|
|
|
{
|
|
|
|
|
"id": i,
|
|
|
|
|
"cents": round(cents),
|
|
|
|
|
"fraction": pitch.get("fraction", "1"),
|
|
|
|
|
"hs_array": pitch.get("hs_array", []),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Find edges: differ by ±1 in exactly one dimension (ignoring dim 0)
|
|
|
|
|
edges = []
|
|
|
|
|
for i in range(len(chord)):
|
|
|
|
|
for j in range(i + 1, len(chord)):
|
|
|
|
|
hs1 = chord[i].get("hs_array", [])
|
|
|
|
|
hs2 = chord[j].get("hs_array", [])
|
|
|
|
|
|
|
|
|
|
if not hs1 or not hs2:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Count differences in dims 1, 2, 3
|
|
|
|
|
diff_count = 0
|
|
|
|
|
diff_dim = -1
|
|
|
|
|
|
|
|
|
|
for d in range(1, len(hs1)):
|
|
|
|
|
diff = hs2[d] - hs1[d]
|
|
|
|
|
if abs(diff) == 1:
|
|
|
|
|
diff_count += 1
|
|
|
|
|
diff_dim = d
|
|
|
|
|
elif diff != 0:
|
|
|
|
|
break # diff > 1 in this dimension
|
|
|
|
|
else:
|
|
|
|
|
# Check if exactly one dimension differs
|
|
|
|
|
if diff_count == 1 and diff_dim > 0:
|
|
|
|
|
# Calculate frequency ratio from pitch difference
|
|
|
|
|
# diff = hs1 - hs2 gives direction
|
|
|
|
|
# Convert to fraction
|
|
|
|
|
diff_hs = [hs1[d] - hs2[d] for d in range(len(hs1))]
|
|
|
|
|
|
|
|
|
|
numerator = 1
|
|
|
|
|
denominator = 1
|
|
|
|
|
for d_idx, d in enumerate(dims):
|
|
|
|
|
exp = diff_hs[d_idx]
|
|
|
|
|
if exp > 0:
|
|
|
|
|
numerator *= d**exp
|
|
|
|
|
elif exp < 0:
|
|
|
|
|
denominator *= d ** (-exp)
|
|
|
|
|
|
|
|
|
|
ratio = (
|
|
|
|
|
f"{numerator}/{denominator}"
|
|
|
|
|
if denominator > 1
|
|
|
|
|
else str(numerator)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
edges.append(
|
|
|
|
|
{"source": i, "target": j, "ratio": ratio, "dim": diff_dim}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {"nodes": nodes, "edges": edges}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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/files")
|
|
|
|
|
def list_files():
|
|
|
|
|
"""List available output files."""
|
|
|
|
|
files = []
|
|
|
|
|
if DATA_DIR.exists():
|
|
|
|
|
for f in DATA_DIR.iterdir():
|
|
|
|
|
if f.is_file() and f.suffix == ".json" and "chords" in f.name:
|
|
|
|
|
files.append(f.name)
|
|
|
|
|
return jsonify({"files": sorted(files)})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/api/set-file/<filename>", methods=["POST"])
|
|
|
|
|
def set_data_file(filename):
|
|
|
|
|
global DATA_FILE, current_index
|
|
|
|
|
DATA_FILE = filename
|
|
|
|
|
current_index = 0
|
|
|
|
|
load_chords()
|
|
|
|
|
return jsonify({"file": DATA_FILE, "loaded": len(chords), "index": current_index})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/api/chords")
|
|
|
|
|
def get_chords():
|
|
|
|
|
return jsonify({"chords": chords, "total": len(chords)})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/api/current")
|
|
|
|
|
def get_current():
|
|
|
|
|
if not chords:
|
|
|
|
|
return jsonify({"error": "No chords loaded"}), 404
|
|
|
|
|
|
|
|
|
|
prev_idx = current_index - 1 if current_index > 0 else None
|
|
|
|
|
next_idx = current_index + 1 if current_index < len(chords) - 1 else None
|
|
|
|
|
|
|
|
|
|
return jsonify(
|
|
|
|
|
{
|
|
|
|
|
"index": current_index,
|
|
|
|
|
"total": len(chords),
|
|
|
|
|
"prev": chords[prev_idx] if prev_idx is not None else None,
|
|
|
|
|
"current": chords[current_index],
|
|
|
|
|
"next": chords[next_idx] if next_idx is not None else None,
|
|
|
|
|
"has_prev": prev_idx is not None,
|
|
|
|
|
"has_next": next_idx is not None,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/api/graph/<int:index>")
|
|
|
|
|
def get_graph(index):
|
|
|
|
|
"""Get computed graph for prev/current/next at given index."""
|
|
|
|
|
if not chords:
|
|
|
|
|
return jsonify({"error": "No chords loaded"}), 404
|
|
|
|
|
|
|
|
|
|
if not (0 <= index < len(chords)):
|
|
|
|
|
return jsonify({"error": "Invalid index"}), 400
|
|
|
|
|
|
|
|
|
|
prev_idx = index - 1 if index > 0 else None
|
|
|
|
|
next_idx = index + 1 if index < len(chords) - 1 else None
|
|
|
|
|
|
|
|
|
|
return jsonify(
|
|
|
|
|
{
|
|
|
|
|
"index": index,
|
|
|
|
|
"total": len(chords),
|
|
|
|
|
"prev": calculate_graph(chords[prev_idx]) if prev_idx is not None else None,
|
|
|
|
|
"current": calculate_graph(chords[index]),
|
|
|
|
|
"next": calculate_graph(chords[next_idx]) if next_idx is not None else None,
|
|
|
|
|
"has_prev": prev_idx is not None,
|
|
|
|
|
"has_next": next_idx is not None,
|
|
|
|
|
}
|
2026-04-01 09:59:30 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/api/all-graphs")
|
|
|
|
|
def get_all_graphs():
|
|
|
|
|
"""Get computed graph for ALL chords at once for single-canvas rendering."""
|
|
|
|
|
if not chords:
|
|
|
|
|
return jsonify({"error": "No chords loaded"}), 404
|
|
|
|
|
|
|
|
|
|
all_graphs = []
|
|
|
|
|
for i, chord in enumerate(chords):
|
|
|
|
|
graph = calculate_graph(chord)
|
|
|
|
|
graph["index"] = i
|
|
|
|
|
all_graphs.append(graph)
|
|
|
|
|
|
|
|
|
|
return jsonify(
|
|
|
|
|
{
|
|
|
|
|
"total": len(chords),
|
|
|
|
|
"graphs": all_graphs,
|
|
|
|
|
}
|
2026-03-30 22:38:52 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/api/navigate", methods=["POST"])
|
|
|
|
|
def navigate():
|
|
|
|
|
global current_index
|
|
|
|
|
data = request.json
|
|
|
|
|
direction = data.get("direction", "next")
|
|
|
|
|
|
|
|
|
|
if direction == "prev" and current_index > 0:
|
|
|
|
|
current_index -= 1
|
|
|
|
|
elif direction == "next" and current_index < len(chords) - 1:
|
|
|
|
|
current_index += 1
|
|
|
|
|
|
|
|
|
|
return jsonify({"index": current_index})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/api/goto/<int:index>")
|
|
|
|
|
def goto(index):
|
|
|
|
|
global current_index
|
|
|
|
|
if 0 <= index < len(chords):
|
|
|
|
|
current_index = index
|
|
|
|
|
return jsonify({"index": current_index})
|
|
|
|
|
return jsonify({"error": "Invalid index"}), 400
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/api/reload", methods=["POST"])
|
|
|
|
|
def reload():
|
|
|
|
|
load_chords()
|
|
|
|
|
return jsonify({"loaded": len(chords), "index": current_index})
|
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
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"),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
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...")
|
|
|
|
|
print(f"Loading chords from: {get_chords_file()}")
|
|
|
|
|
load_chords()
|
|
|
|
|
print(f"Loaded {len(chords)} chords")
|
|
|
|
|
app.run(host="0.0.0.0", port=8080, debug=True)
|