compact_sets/webapp/server.py

336 lines
9.2 KiB
Python
Raw Normal View History

#!/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 src.osc_sender import OSCSender
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)
# OSC settings
fundamental = 110.0
osc_sender = OSCSender(ip="127.0.0.1", port=57120, fundamental=fundamental)
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/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,
}
)
@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})
@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__":
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)