diff --git a/webapp/path_navigator.html b/webapp/path_navigator.html index d2dc2c9..54bf16c 100644 --- a/webapp/path_navigator.html +++ b/webapp/path_navigator.html @@ -165,8 +165,12 @@

Path Navigator

+ + + or upload: - (default: output/output_chords.json)
@@ -201,6 +205,9 @@ // Global state let chords = []; let currentIndex = 0; + let totalSteps = 0; + let hasPrev = false; + let hasNext = false; let simulations = {}; // D3 setup - three graphs @@ -235,8 +242,164 @@ svg.call(zoom); }); - // Load default file - async function loadDefaultFile() { + // Load from Flask API - get computed graph from Python + async function loadFromAPI() { + try { + const response = await fetch(`/api/graph/${currentIndex}`); + if (!response.ok) throw new Error("API not available"); + const data = await response.json(); + + currentIndex = data.index; + totalSteps = data.total - 1; + hasPrev = data.has_prev; + hasNext = data.has_next; + + // Render graphs from API data + renderGraphFromData(data.prev, groups.prev); + renderGraphFromData(data.current, groups.current); + renderGraphFromData(data.next, groups.next); + + // Update displays + document.getElementById("currentIndex").textContent = currentIndex; + document.getElementById("totalSteps").textContent = totalSteps; + document.getElementById("prevBtn").disabled = !hasPrev; + document.getElementById("nextBtn").disabled = !hasNext; + + // Update chord panels + updateChordPanel("prevPitches", data.prev ? data.prev.nodes : null); + updateChordPanel("currentPitches", data.current ? data.current.nodes : null); + updateChordPanel("nextPitches", data.next ? data.next.nodes : null); + + } catch (e) { + console.log("API not available, trying local file", e); + loadFromLocalFile(); + } + } + + // Render graph from API data (nodes + edges already calculated by Python) + function renderGraphFromData(graphData, group) { + const { nodes, edges } = graphData || { nodes: [], edges: [] }; + + // Clear previous + group.selectAll("*").remove(); + + if (nodes.length === 0) { + group.append("text") + .attr("x", graphWidth / 2) + .attr("y", graphHeight / 2) + .attr("text-anchor", "middle") + .attr("fill", "#666") + .style("font-size", "14px") + .text("(empty)"); + return; + } + + // Calculate y positions (Python only gives cents, not positions) + const centsList = nodes.map(n => n.cents); + const minCents = Math.min(...centsList); + const maxCents = Math.max(...centsList); + const range = maxCents - minCents || 1; + + // Add positions to nodes + const nodesWithPos = nodes.map((n, i) => ({ + ...n, + x: graphWidth * 0.2 + (graphWidth * 0.6) * (i / (nodes.length - 1 || 1)), + y: graphHeight * 0.1 + (graphHeight * 0.8) * (1 - (n.cents - minCents) / range) + })); + + // Force simulation with fixed y positions + const simulation = d3.forceSimulation(nodesWithPos) + .force("link", d3.forceLink(edges).id(d => d.id).distance(60)) + .force("charge", d3.forceManyBody().strength(-200)) + .force("y", d3.forceY(d => d.y).strength(1)) + .force("collision", d3.forceCollide().radius(25)); + + // Draw links + const link = group.selectAll(".link") + .data(edges) + .enter() + .append("line") + .attr("class", "link"); + + // Draw link labels + const linkLabel = group.selectAll(".link-label") + .data(edges) + .enter() + .append("text") + .attr("class", "link-label") + .attr("text-anchor", "middle") + .style("font-size", "10px") + .text(d => d.ratio); + + // Draw nodes + const node = group.selectAll(".node") + .data(nodesWithPos) + .enter() + .append("g") + .attr("class", "node"); + + node.append("circle") + .attr("r", 15) + .attr("fill", (d, i) => d3.schemeCategory10[i % 10]); + + node.append("text") + .attr("class", "node-label") + .attr("dy", 3) + .style("font-size", "9px") + .text(d => d.cents); + + // Update positions on tick + simulation.on("tick", () => { + link + .attr("x1", d => d.source.x) + .attr("y1", d => d.source.y) + .attr("x2", d => d.target.x) + .attr("y2", d => d.target.y); + + linkLabel + .attr("x", d => (d.source.x + d.target.x) / 2) + .attr("y", d => (d.source.y + d.target.y) / 2 - 8); + + node.attr("transform", d => `translate(${d.x},${d.y})`); + }); + } + + // Fallback to local file + // Load file list and setup file selection + async function loadFileList() { + try { + const response = await fetch("/api/files"); + const data = await response.json(); + const select = document.getElementById("fileSelect"); + select.innerHTML = ""; + data.files.forEach(f => { + const opt = document.createElement("option"); + opt.value = f; + opt.textContent = f; + if (f === "output_chords.json") opt.selected = true; + select.appendChild(opt); + }); + } catch (e) { + console.log("Could not load file list", e); + } + } + + async function loadSelectedFile() { + const select = document.getElementById("fileSelect"); + const filename = select.value; + if (!filename) return; + + try { + const response = await fetch(`/api/set-file/${filename}`, { method: "POST" }); + const data = await response.json(); + currentIndex = 0; + loadFromAPI(); + } catch (e) { + console.log("Error loading file", e); + } + } + + async function loadFromLocalFile() { try { const response = await fetch("output/output_chords.json"); if (!response.ok) throw new Error("File not found"); @@ -270,286 +433,52 @@ updateDisplay(); } - // Compute edge between two pitches if they differ by ±1 in exactly one non-dim-0 dimension - function getEdgeInfo(pitch1, pitch2) { - const hs1 = pitch1.hs_array; - const hs2 = pitch2.hs_array; - - // Check if they differ by ±1 in exactly one dimension (ignoring dim 0) - let diffCount = 0; - let diffDim = -1; - - for (let i = 1; i < hs1.length; i++) { - const diff = hs2[i] - hs1[i]; - if (Math.abs(diff) === 1) { - diffCount++; - diffDim = i; - } else if (diff !== 0) { - return null; // Difference > 1 in this dimension - } - } - - // Must differ by exactly ±1 in exactly one dimension - if (diffCount !== 1) return null; - - // Calculate frequency ratio from pitch difference (hs1 - hs2) - // Using the formula from pitch.py: - // numerator *= dims[i] ** diff[i] for diff[i] >= 0 - // denominator *= dims[i] ** (-diff[i]) for diff[i] < 0 - const dims = [2, 3, 5, 7]; - let numerator = 1; - let denominator = 1; - - for (let i = 0; i < hs1.length; i++) { - const diff = hs1[i] - hs2[i]; // pitch1 - pitch2 - if (diff > 0) { - numerator *= Math.pow(dims[i], diff); - } else if (diff < 0) { - denominator *= Math.pow(dims[i], -diff); - } - } - - const ratio = numerator + "/" + denominator; - - return { - dim: diffDim, - ratio: ratio - }; - } - - function parseFraction(frac) { - if (typeof frac === 'number') return frac; - const parts = frac.split('/'); - if (parts.length === 1) return parseFloat(parts[0]); - return parseInt(parts[0]) / parseInt(parts[1]); - } - - function formatRatio(ratio) { - // Simplify ratio to simple intervals - const tolerance = 0.01; - - // Common ratios - const commonRatios = [ - { r: 2, label: "2/1" }, - { r: 3/2, label: "3/2" }, - { r: 4/3, label: "4/3" }, - { r: 5/4, label: "5/4" }, - { r: 6/5, label: "6/5" }, - { r: 5/3, label: "5/3" }, - { r: 8/5, label: "8/5" }, - { r: 7/4, label: "7/4" }, - { r: 7/5, label: "7/5" }, - { r: 7/6, label: "7/6" }, - { r: 9/8, label: "9/8" }, - { r: 10/9, label: "10/9" }, - { r: 16/15, label: "16/15" }, - { r: 15/14, label: "15/14" }, - { r: 9/7, label: "9/7" }, - { r: 10/7, label: "10/7" }, - { r: 1, label: "1/1" }, - ]; - - // Check both ratio and its inverse - for (const {r, label} of commonRatios) { - if (Math.abs(ratio - r) < tolerance || Math.abs(ratio - 1/r) < tolerance) { - return ratio < 1 ? label : reverseRatio(label); - } - } - - // Default: show simplified fraction - return ratio < 1 ? simplifyRatio(ratio) : simplifyRatio(1/ratio); - } - - function reverseRatio(ratio) { - const parts = ratio.split('/'); - if (parts.length !== 2) return ratio; - return parts[1] + "/" + parts[0]; - } - - function simplifyRatio(ratio) { - // Simple fraction simplification - for (let d = 2; d <= 16; d++) { - for (let n = 1; n < d; n++) { - if (Math.abs(ratio - n/d) < 0.01) { - return n + "/" + d; - } - } - } - return ratio.toFixed(2); - } - - function buildGraph(chord, w, h) { - // Calculate cents for each pitch - const centsData = chord.map((pitch, i) => { - const fr = parseFraction(pitch.fraction); - const cents = 1200 * Math.log2(fr); - return { i, cents }; - }); - - // Find cents for node 0 (voice 0) - use as bottom reference - const node0Cents = centsData.find(d => d.i === 0)?.cents || 0; - const maxCents = Math.max(...centsData.map(d => d.cents)); - const range = maxCents - node0Cents || 1; - - // Create nodes with y position: node 0 at bottom, others relative to it - const nodes = chord.map((pitch, i) => { - const fr = parseFraction(pitch.fraction); - const cents = 1200 * Math.log2(fr); - // Map to screen: top = high cents, bottom = node 0 - const y = h * 0.1 + (h * 0.8) * (1 - (cents - node0Cents) / range); - // Initial x position: spread horizontally - const x = w * 0.2 + (w * 0.6) * (i / (chord.length - 1 || 1)); - return { - id: i, - pitch: pitch, - label: String(i), - cents: cents.toFixed(0), - hs: pitch.hs_array, - x: x, // Initial x - y: y // Initial y (will be fixed by forceY) - }; - }); - - const links = []; - - for (let i = 0; i < nodes.length; i++) { - for (let j = i + 1; j < nodes.length; j++) { - const edgeInfo = getEdgeInfo(nodes[i].pitch, nodes[j].pitch); - if (edgeInfo) { - links.push({ - source: i, - target: j, - label: edgeInfo.ratio - }); - } - } - } - - return { nodes, links }; - } - - function renderSingleGraph(chord, group) { - const { nodes, links } = buildGraph(chord, graphWidth, graphHeight); - - // Clear previous - group.selectAll("*").remove(); - - if (nodes.length === 0) { - group.append("text") - .attr("x", graphWidth / 2) - .attr("y", graphHeight / 2) - .attr("text-anchor", "middle") - .attr("fill", "#666") - .style("font-size", "14px") - .text("(empty)"); - return; - } - - // Force simulation with fixed y positions - const simulation = d3.forceSimulation(nodes) - .force("link", d3.forceLink(links).id(d => d.id).distance(60)) - .force("charge", d3.forceManyBody().strength(-200)) - .force("y", d3.forceY(d => d.y).strength(1)) - .force("collision", d3.forceCollide().radius(25)); - - simulations[group.attr("id")] = simulation; - - // Draw links - const link = group.selectAll(".link") - .data(links) - .enter() - .append("line") - .attr("class", "link"); - - // Draw link labels - const linkLabel = group.selectAll(".link-label") - .data(links) - .enter() - .append("text") - .attr("class", "link-label") - .attr("text-anchor", "middle") - .style("font-size", "10px") - .text(d => d.label); - - // Draw nodes - const node = group.selectAll(".node") - .data(nodes) - .enter() - .append("g") - .attr("class", "node"); - - node.append("circle") - .attr("r", 15) - .attr("fill", (d, i) => d3.schemeCategory10[i % 10]); - - node.append("text") - .attr("class", "node-label") - .attr("dy", 3) - .style("font-size", "9px") - .text(d => d.cents); - - // Update positions on tick - simulation.on("tick", () => { - link - .attr("x1", d => d.source.x) - .attr("y1", d => d.source.y) - .attr("x2", d => d.target.x) - .attr("y2", d => d.target.y); - - linkLabel - .attr("x", d => (d.source.x + d.target.x) / 2) - .attr("y", d => (d.source.y + d.target.y) / 2 - 8); - - node.attr("transform", d => `translate(${d.x},${d.y})`); - }); - } - - function renderAllGraphs(prevChord, currentChord, nextChord) { - renderSingleGraph(prevChord, groups.prev); - renderSingleGraph(currentChord, groups.current); - renderSingleGraph(nextChord, groups.next); - } - - function updateChordPanel(elementId, chord) { + function updateChordPanel(elementId, data) { const ul = document.getElementById(elementId); ul.innerHTML = ""; - if (!chord || chord.length === 0) { + if (!data || (Array.isArray(data) && data.length === 0)) { ul.innerHTML = "
  • (none)
  • "; return; } - chord.forEach(pitch => { + // Handle both formats: raw chord array or nodes array from graph API + const items = Array.isArray(data) ? data : (data.nodes || []); + + items.forEach(item => { const li = document.createElement("li"); - li.textContent = `${pitch.fraction} (${pitch.hs_array.join(", ")})`; + const fraction = item.fraction || item.fraction; + const hs_array = item.hs_array || []; + li.textContent = `${fraction} (${hs_array.join(", ")})`; ul.appendChild(li); }); } - function updateDisplay(animateDirection = null) { - document.getElementById("currentIndex").textContent = currentIndex; - document.getElementById("totalSteps").textContent = chords.length - 1; - - document.getElementById("prevBtn").disabled = currentIndex === 0; - document.getElementById("nextBtn").disabled = currentIndex >= chords.length - 1; - - // Update chord panels - const prevChord = currentIndex > 0 ? chords[currentIndex - 1] : null; - const currentChord = chords[currentIndex]; - const nextChord = currentIndex < chords.length - 1 ? chords[currentIndex + 1] : null; - - updateChordPanel("prevPitches", prevChord); - updateChordPanel("currentPitches", currentChord); - updateChordPanel("nextPitches", nextChord); - - // Render all three graphs - renderAllGraphs(prevChord, currentChord, nextChord); + // Update display - now handled by loadFromAPI + function updateDisplay() { + loadFromAPI(); } // Navigation with animation - function navigate(direction) { - const oldIndex = currentIndex; + async function navigate(direction) { + // Try to update via API first + try { + const response = await fetch("/api/navigate", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({direction: direction}) + }); + if (response.ok) { + const data = await response.json(); + currentIndex = data.index; + await loadFromAPI(); + return; + } + } catch (e) { + // API not available, use local navigation + } + // Fallback to local navigation if (direction === 'prev' && currentIndex > 0) { currentIndex--; } else if (direction === 'next' && currentIndex < chords.length - 1) { @@ -597,7 +526,8 @@ }); // Initialize - loadDefaultFile(); + loadFileList(); + loadFromAPI(); diff --git a/webapp/server.py b/webapp/server.py new file mode 100644 index 0000000..23d04da --- /dev/null +++ b/webapp/server.py @@ -0,0 +1,269 @@ +#!/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)