#!/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 output directory DATA_DIR = Path(__file__).parent.parent / "output" DEFAULT_FILE = "output_chords.json" # default file # State current_index = 0 chords = [] # Cache persistent OSCSenders per IP for the siren _sender_cache = {} # ip -> OSCSender def get_siren_sender(ip): """Get or create a persistent OSCSender for the given siren IP.""" if ip not in _sender_cache: _sender_cache[ip] = OSCSender(ip=ip, port=54001, fundamental=fundamental) return _sender_cache[ip] # OSC settings fundamental = 110.0 osc_sender = OSCSender(ip="127.0.0.1", port=57120, fundamental=fundamental) def load_chords(filepath=None): global chords, current_index if filepath is None: filepath = DATA_DIR / DEFAULT_FILE if filepath.exists(): with open(filepath) 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.""" import math fr = parse_fraction(fraction_str) if fr <= 0: return 0 return 1200 * math.log2(fr) @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/chords") def get_chords(): return jsonify({"chords": chords, "total": len(chords)}) @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") octave = data.get("octave", 0) 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) * (2**octave) 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") siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP siren_number = data.get("sirenNumber") # optional 1-4 frequency_input = data.get("frequency") # direct frequency for ghost nodes # If frequency provided directly (ghost node), use it if frequency_input is not None: frequency = float(frequency_input) voice = siren_number if siren_number else 1 else: # Original node: calculate from chordIndex/nodeIndex if chord_index is None or node_index is None: return jsonify({"error": "Missing chordIndex/nodeIndex"}), 400 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 = siren_number if siren_number else node_index + 1 # 1-indexed # Send to siren using cached sender siren_sender = get_siren_sender(siren_ip) siren_sender.set_chords(chords) # Set chords to ensure proper voice count siren_sender.send_single(frequency, voice) return jsonify( { "frequency": frequency, "voice": voice, "siren": siren_number, "destination": "siren", "ip": siren_ip, } ) @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 siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP kill_freq = 20.0 if soft else 0.0 kill_type = "soft" if soft else "hard" # Cancel any running ramp (for this IP) siren_sender = get_siren_sender(siren_ip) for event in siren_sender._ramp_events.values(): event.set() # Send kill to all voices using cached sender 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)), "ip": siren_ip, } ) @app.route("/api/ramp-to-chord", methods=["POST"]) def ramp_to_chord(): """Ramp to a chord or single voice on the siren.""" data = request.json chord_index = data.get("chordIndex") node_index = data.get("nodeIndex") # Optional - if present, ramp single voice duration_ms = data.get("duration", 3000) exponent = data.get("exponent", 1.0) siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP siren_number = data.get("sirenNumber") # optional 1-4 frequency_input = data.get("frequency") # direct frequency for ghost nodes if chord_index is None: return jsonify({"error": "Missing chordIndex"}), 400 if chord_index < 0 or chord_index >= len(chords): return jsonify({"error": "Invalid chord index"}), 400 chord = chords[chord_index] # Use cached sender siren_sender = get_siren_sender(siren_ip) siren_sender.set_chords(chords) if node_index is not None: # Ramp single voice if node_index < 0 or node_index >= len(chord): return jsonify({"error": "Invalid node index"}), 400 # If frequency provided directly (ghost node), use it if frequency_input is not None: frequency = float(frequency_input) voice = siren_number if siren_number else 1 else: pitch = chord[node_index] fraction = Fraction(pitch.get("fraction", "1")) frequency = fundamental * float(fraction) voice = siren_number if siren_number else (node_index + 1) # 1-indexed # Ramp single voice - let sender get start frequency from current position siren_sender.ramp_to_pitch( frequency, voice, duration_ms, start_freq=None, exponent=exponent ) else: # Ramp entire chord - let sender get start frequencies from current positions frequencies = [ fundamental * float(Fraction(p.get("fraction", "1"))) for p in chord ] siren_sender.ramp_to_chord( frequencies, duration_ms, start_freq=None, exponent=exponent ) return jsonify( { "status": "ramping", "chordIndex": chord_index, "voiceIndex": node_index, "duration": duration_ms, } ) @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 @app.route("/generate") def generate_page(): """Render the generator page.""" return send_from_directory(app.root_path, "generate.html") @app.route("/generator_settings.json") def default_settings(): """Serve default generator settings from output directory.""" settings_path = DATA_DIR / "generator_settings.json" if settings_path.exists(): return send_from_directory(DATA_DIR, "generator_settings.json") else: return jsonify({"error": "No default settings file"}), 404 @app.route("/api/transcribe", methods=["POST"]) def run_transcribe(): """Run the transcriber to create LilyPond output.""" data = request.json transcribe_name = data.get("name", "compact_sets_transcription") fundamental = data.get("fundamental", 55) try: import subprocess import os import sys import json input_file = DATA_DIR / "output_chords.json" if not input_file.exists(): return jsonify({"error": "No chords to transcribe. Generate first."}), 400 # Load chords to get count with open(input_file) as f: chords_data = json.load(f) chord_count = len(chords_data.get("chords", chords_data)) # Create lilypond directory if needed lilypond_dir = DATA_DIR.parent / "lilypond" os.makedirs(lilypond_dir, exist_ok=True) # Run the transcriber args = [ sys.executable, "-m", "src.io", "--transcribe", str(input_file), transcribe_name, "--fundamental", str(fundamental), ] result = subprocess.run( args, capture_output=True, text=True, cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ) if result.returncode != 0: return jsonify({"error": result.stderr}), 400 output_file = lilypond_dir / f"{transcribe_name}.pdf" return jsonify( { "status": "success", "chordCount": chord_count, "outputFile": str(output_file), "output": result.stdout, } ) except Exception as e: import traceback traceback.print_exc() return jsonify({"error": str(e)}), 400 @app.route("/api/generate", methods=["POST"]) def run_generator(): """Run the path generator with provided options.""" data = request.json try: import subprocess import os import sys # Build args list args = [ sys.executable, "-m", "src.io", "--dims", str(data.get("dims", 7)), "--chord-size", str(data.get("chordSize", 3)), "--max-path", str(data.get("maxPath", 50)), "--symdiff-min", str(data.get("symdiffMin", 2)), "--symdiff-max", str(data.get("symdiffMax", 2)), "--melodic-min", str(data.get("melodicMin", 0)), "--melodic-max", str(data.get("melodicMax", 500)), "--target-register", str(data.get("targetRegister", 0)), "--target-register-power", str(data.get("targetRegisterPower", 1.0)), "--target-register-oscillations", str(data.get("targetRegisterOscillations", 0)), "--target-register-amplitude", str(data.get("targetRegisterAmplitude", 0.25)), "--weight-melodic", str(data.get("weightMelodic", 1)), "--weight-contrary-motion", str(data.get("weightContraryMotion", 0)), "--weight-dca-hamiltonian", str(data.get("weightDcaHamiltonian", 1)), "--weight-dca-voice-movement", str(data.get("weightDcaVoiceMovement", 1)), "--weight-rgr-voice-movement", str(data.get("weightRgrVoiceMovement", 0)), "--rgr-voice-movement-threshold", str(data.get("rgrVoiceMovementThreshold", 5)), "--weight-harmonic-compactness", str(data.get("weightHarmonicCompactness", 0)), "--weight-target-register", str(data.get("weightTargetRegister", 1)), "--output-dir", data.get("outputDir", "output"), "--fundamental", str(data.get("fundamental", 55)), "--cache-dir", data.get("cacheDir", "cache"), ] seed = data.get("seed") # If no seed provided, generate a random one if seed is None: import random seed = random.randint(1, 999999) args.extend(["--seed", str(seed)]) if data.get("allowVoiceCrossing", False): args.append("--allow-voice-crossing") if data.get("disableDirectTuning", False): args.append("--disable-direct-tuning") if data.get("uniformSymdiff", False): args.append("--uniform-symdiff") if data.get("rebuildCache", False): args.append("--rebuild-cache") if data.get("noCache", False): args.append("--no-cache") # Create output directory os.makedirs(data.get("outputDir", "output"), exist_ok=True) # Run the generator result = subprocess.run( args, capture_output=True, text=True, cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ) if result.returncode != 0: return jsonify({"error": result.stderr}), 400 # Try to count generated chords from output total_chords = 0 for line in result.stdout.split("\n"): if "Path length:" in line: try: total_chords = int(line.split(":")[-1].strip()) except: pass return jsonify( { "totalChords": total_chords, "status": "success", "seed": seed, "output": result.stdout, } ) except Exception as e: import traceback traceback.print_exc() return jsonify({"error": str(e)}), 400 if __name__ == "__main__": print("Starting Path Navigator server...") filepath = DATA_DIR / DEFAULT_FILE print(f"Loading chords from: {filepath}") load_chords() print(f"Loaded {len(chords)} chords") app.run(host="0.0.0.0", port=8080, debug=False)