Add Flask server with Python backend for Path Navigator

- Server calculates graph edges and ratios using src/pitch.py
- Frontend fetches computed graph from API
- Supports loading different output files via dropdown
- All pitch calculations now in Python, JS only for rendering
This commit is contained in:
Michael Winter 2026-03-30 22:38:52 +02:00
parent 2a027ba552
commit 88a9528e12
2 changed files with 467 additions and 268 deletions

View file

@ -165,8 +165,12 @@
<h1>Path Navigator</h1>
<div class="file-input">
<select id="fileSelect">
<option value="">Loading files...</option>
</select>
<button onclick="loadSelectedFile()">Load</button>
<span style="margin-left: 10px;">or upload:</span>
<input type="file" id="fileInput" accept=".json">
<span style="margin-left: 10px;">(default: output/output_chords.json)</span>
</div>
<div class="controls">
@ -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 = "<li>(none)</li>";
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();
</script>
</body>
</html>

269
webapp/server.py Normal file
View file

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