#!/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("/") 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/", 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/") 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/") 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)