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>
|
<h1>Path Navigator</h1>
|
||||||
|
|
||||||
<div class="file-input">
|
<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">
|
<input type="file" id="fileInput" accept=".json">
|
||||||
<span style="margin-left: 10px;">(default: output/output_chords.json)</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
|
|
@ -201,6 +205,9 @@
|
||||||
// Global state
|
// Global state
|
||||||
let chords = [];
|
let chords = [];
|
||||||
let currentIndex = 0;
|
let currentIndex = 0;
|
||||||
|
let totalSteps = 0;
|
||||||
|
let hasPrev = false;
|
||||||
|
let hasNext = false;
|
||||||
let simulations = {};
|
let simulations = {};
|
||||||
|
|
||||||
// D3 setup - three graphs
|
// D3 setup - three graphs
|
||||||
|
|
@ -235,8 +242,164 @@
|
||||||
svg.call(zoom);
|
svg.call(zoom);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load default file
|
// Load from Flask API - get computed graph from Python
|
||||||
async function loadDefaultFile() {
|
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 {
|
try {
|
||||||
const response = await fetch("output/output_chords.json");
|
const response = await fetch("output/output_chords.json");
|
||||||
if (!response.ok) throw new Error("File not found");
|
if (!response.ok) throw new Error("File not found");
|
||||||
|
|
@ -270,286 +433,52 @@
|
||||||
updateDisplay();
|
updateDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute edge between two pitches if they differ by ±1 in exactly one non-dim-0 dimension
|
function updateChordPanel(elementId, data) {
|
||||||
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) {
|
|
||||||
const ul = document.getElementById(elementId);
|
const ul = document.getElementById(elementId);
|
||||||
ul.innerHTML = "";
|
ul.innerHTML = "";
|
||||||
|
|
||||||
if (!chord || chord.length === 0) {
|
if (!data || (Array.isArray(data) && data.length === 0)) {
|
||||||
ul.innerHTML = "<li>(none)</li>";
|
ul.innerHTML = "<li>(none)</li>";
|
||||||
return;
|
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");
|
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);
|
ul.appendChild(li);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDisplay(animateDirection = null) {
|
// Update display - now handled by loadFromAPI
|
||||||
document.getElementById("currentIndex").textContent = currentIndex;
|
function updateDisplay() {
|
||||||
document.getElementById("totalSteps").textContent = chords.length - 1;
|
loadFromAPI();
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigation with animation
|
// Navigation with animation
|
||||||
function navigate(direction) {
|
async function navigate(direction) {
|
||||||
const oldIndex = currentIndex;
|
// 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) {
|
if (direction === 'prev' && currentIndex > 0) {
|
||||||
currentIndex--;
|
currentIndex--;
|
||||||
} else if (direction === 'next' && currentIndex < chords.length - 1) {
|
} else if (direction === 'next' && currentIndex < chords.length - 1) {
|
||||||
|
|
@ -597,7 +526,8 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
loadDefaultFile();
|
loadFileList();
|
||||||
|
loadFromAPI();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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