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