- Server calculates graph edges and ratios using src/pitch.py - Frontend fetches computed graph from API - Supports loading different output files via dropdown - All pitch calculations now in Python, JS only for rendering
270 lines
7.4 KiB
Python
270 lines
7.4 KiB
Python
#!/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
|
|
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)
|
|
|
|
|
|
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,
|
|
}
|
|
)
|
|
|
|
|
|
@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})
|
|
|
|
|
|
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)
|