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:
parent
2a027ba552
commit
88a9528e12
|
|
@ -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
269
webapp/server.py
Normal 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)
|
||||
Loading…
Reference in a new issue