Files
bellsystems-cp/frontend/src/melodies/SpeedCalculatorModal.jsx

681 lines
27 KiB
JavaScript

import { useState, useEffect, useRef, useCallback } from "react";
import api from "../api/client";
// ============================================================================
// Web Audio Engine
// ============================================================================
/**
* Bell frequencies: Bell 1 = highest (880 Hz A5), each subsequent bell 2 semitones lower.
* freq = 880 * (2^(1/12))^(-2*(bell-1))
*/
function bellFrequency(bellNumber) {
return 880 * Math.pow(Math.pow(2, 1 / 12), -2 * (bellNumber - 1));
}
function playStep(audioCtx, stepValue, beatDurationMs) {
if (!stepValue || !audioCtx) return;
const now = audioCtx.currentTime;
const duration = beatDurationMs / 1000;
const fadeIn = 0.005;
const fadeOut = 0.03;
for (let bit = 0; bit < 16; bit++) {
if (stepValue & (1 << bit)) {
const bellNum = bit + 1;
const freq = bellFrequency(bellNum);
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.type = "sine";
osc.frequency.setValueAtTime(freq, now);
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.3, now + fadeIn);
gain.gain.setValueAtTime(0.3, now + Math.max(duration - fadeOut, fadeIn + 0.001));
gain.gain.linearRampToValueAtTime(0, now + duration);
osc.start(now);
osc.stop(now + duration);
}
}
}
// ============================================================================
// Parse / decode helpers
// ============================================================================
function parseBellNotation(notation) {
notation = notation.trim();
if (notation === "0" || !notation) return 0;
let value = 0;
for (const part of notation.split("+")) {
const n = parseInt(part.trim(), 10);
if (!isNaN(n) && n >= 1 && n <= 16) value |= 1 << (n - 1);
}
return value;
}
function parseStepsString(stepsStr) {
if (!stepsStr || !stepsStr.trim()) return [];
return stepsStr.trim().split(",").map((s) => parseBellNotation(s));
}
function getActiveBells(stepValue) {
const bells = [];
for (let bit = 0; bit < 16; bit++) {
if (stepValue & (1 << bit)) bells.push(bit + 1);
}
return bells;
}
/** Decode a .bsm binary (big-endian uint16 per step) to uint16 array */
async function decodeBsmBinary(url) {
const token = localStorage.getItem("access_token");
const res = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`Failed to fetch binary: ${res.statusText}`);
const buf = await res.arrayBuffer();
const view = new DataView(buf);
const steps = [];
for (let i = 0; i + 1 < buf.byteLength; i += 2) {
steps.push(view.getUint16(i, false)); // big-endian
}
return steps;
}
// ============================================================================
// Speed math
// MIN is derived so that at 50%, speed = normal (geometric mean)
// normal = sqrt(MIN * MAX) => MIN = normal^2 / MAX
// Note: higher ms = SLOWER. MAX ms = fastest hardware speed.
// Normal ms must be HIGHER (slower) than MAX ms to make sense.
// ============================================================================
function calcMin(normal, max) {
if (!normal || !max || max <= 0) return null;
return Math.round((normal * normal) / max);
}
// ============================================================================
// Component
// ============================================================================
const labelStyle = { color: "var(--text-secondary)" };
const mutedStyle = { color: "var(--text-muted)" };
// Step Delay slider is inverted: right = fast (low ms), left = slow (high ms)
// We store actual ms in state, but display with inverted slider value.
const DELAY_MIN = 50;
const DELAY_MAX = 3000;
function delayToSlider(ms) { return DELAY_MIN + DELAY_MAX - ms; }
function sliderToDelay(val) { return DELAY_MIN + DELAY_MAX - val; }
export default function SpeedCalculatorModal({ open, melody, builtMelody, archetypeCsv, onClose, onSaved }) {
const info = melody?.information || {};
// Raw steps input
const [stepsInput, setStepsInput] = useState("");
const [steps, setSteps] = useState([]);
const [loadingBinary, setLoadingBinary] = useState(false);
const [binaryLoadError, setBinaryLoadError] = useState("");
// Playback
const audioCtxRef = useRef(null);
const playbackRef = useRef(null); // { timer, stepIndex }
const stepsRef = useRef([]);
const stepDelayRef = useRef(500);
const effectiveBeatRef = useRef(100);
const loopRef = useRef(true);
const [playing, setPlaying] = useState(false);
const [paused, setPaused] = useState(false);
const [loop, setLoop] = useState(true);
const [currentStep, setCurrentStep] = useState(-1);
// Sliders
const [stepDelay, setStepDelay] = useState(500);
const [beatDuration, setBeatDuration] = useState(100);
const effectiveBeat = Math.min(beatDuration, Math.max(20, stepDelay - 20));
// Keep refs in sync so playback loop always reads current values
useEffect(() => { stepsRef.current = steps; }, [steps]);
useEffect(() => { stepDelayRef.current = stepDelay; }, [stepDelay]);
useEffect(() => { effectiveBeatRef.current = effectiveBeat; }, [effectiveBeat]);
useEffect(() => { loopRef.current = loop; }, [loop]);
// Speed capture
const [capturedMax, setCapturedMax] = useState(null);
const [capturedNormal, setCapturedNormal] = useState(null);
const derivedMin = calcMin(capturedNormal, capturedMax);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const [saveSuccess, setSaveSuccess] = useState(false);
// Warnings:
// MAX is the fastest (lowest ms). Normal must be > MAX (slower than MAX).
// If Normal < MAX, it means Normal is faster than MAX — nonsensical.
const maxWarning = capturedMax !== null && capturedMax < 100;
const orderWarning = capturedMax !== null && capturedNormal !== null && capturedNormal < capturedMax;
// Reset on open — auto-load archetype CSV if available
useEffect(() => {
if (open) {
const max = info.maxSpeed > 0 ? info.maxSpeed : null;
const min = info.minSpeed > 0 ? info.minSpeed : null;
// Reverse-calculate Normal from MIN and MAX: normal = sqrt(min * max)
const normal = (max !== null && min !== null) ? Math.round(Math.sqrt(min * max)) : null;
setCapturedMax(max);
setCapturedNormal(normal);
setBinaryLoadError("");
setCurrentStep(-1);
setPlaying(false);
setPaused(false);
setSaveError("");
setSaveSuccess(false);
const csv = archetypeCsv || info.archetype_csv || null;
if (csv) {
const parsed = parseStepsString(csv);
setStepsInput(csv);
setSteps(parsed);
stepsRef.current = parsed;
} else {
setStepsInput("");
setSteps([]);
}
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
const stopPlayback = useCallback(() => {
if (playbackRef.current) {
clearTimeout(playbackRef.current.timer);
playbackRef.current = null;
}
setPlaying(false);
setPaused(false);
setCurrentStep(-1);
}, []);
useEffect(() => {
if (!open) stopPlayback();
}, [open, stopPlayback]);
const ensureAudioCtx = () => {
if (!audioCtxRef.current || audioCtxRef.current.state === "closed") {
audioCtxRef.current = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtxRef.current.state === "suspended") {
audioCtxRef.current.resume();
}
return audioCtxRef.current;
};
// Recursive scheduler using refs for live updates — no restart needed when sliders change
const scheduleStep = useCallback((stepIndex) => {
const currentSteps = stepsRef.current;
if (!currentSteps.length) return;
const playFrom = stepIndex % currentSteps.length;
// End of sequence without loop
if (!loopRef.current && playFrom === 0 && stepIndex > 0) {
setPlaying(false);
setPaused(false);
setCurrentStep(-1);
playbackRef.current = null;
return;
}
const ctx = ensureAudioCtx();
const stepValue = currentSteps[playFrom];
setCurrentStep(playFrom);
playStep(ctx, stepValue, effectiveBeatRef.current);
const delay = stepDelayRef.current;
const timer = setTimeout(() => {
const next = playFrom + 1;
if (next >= stepsRef.current.length) {
if (loopRef.current) {
scheduleStep(0);
} else {
setPlaying(false);
setPaused(false);
setCurrentStep(-1);
playbackRef.current = null;
}
} else {
scheduleStep(next);
}
}, delay);
playbackRef.current = { timer, stepIndex: playFrom };
}, []); // no deps — reads everything from refs
const handlePlay = () => {
if (!stepsRef.current.length) return;
if (paused && playbackRef.current) {
setPaused(false);
setPlaying(true);
scheduleStep(playbackRef.current.stepIndex);
return;
}
setPlaying(true);
setPaused(false);
scheduleStep(0);
};
const handlePause = () => {
if (playbackRef.current) {
clearTimeout(playbackRef.current.timer);
}
setPaused(true);
setPlaying(false);
};
const handleStepsParse = () => {
const parsed = parseStepsString(stepsInput);
setSteps(parsed);
stepsRef.current = parsed;
stopPlayback();
setBinaryLoadError("");
};
const handleLoadFromBinary = async () => {
const binaryUrl = builtMelody?.binary_url
? `/api${builtMelody.binary_url}`
: melody?.url || null;
if (!binaryUrl) return;
setLoadingBinary(true);
setBinaryLoadError("");
try {
const decoded = await decodeBsmBinary(binaryUrl);
setSteps(decoded);
stepsRef.current = decoded;
stopPlayback();
// Also reconstruct a human-readable steps string for the textarea
const stepsStr = decoded.map((v) => {
if (!v) return "0";
return getActiveBells(v).join("+");
}).join(",");
setStepsInput(stepsStr);
} catch (err) {
setBinaryLoadError(err.message);
} finally {
setLoadingBinary(false);
}
};
const handleSetSliderToCapture = (value) => {
if (value !== null) setStepDelay(value);
};
const handleSave = async () => {
if (!capturedMax || !capturedNormal || !derivedMin) return;
stopPlayback();
setSaving(true);
setSaveError("");
setSaveSuccess(false);
try {
const body = {
information: {
...info,
minSpeed: derivedMin,
maxSpeed: capturedMax,
},
default_settings: melody.default_settings,
type: melody.type,
uid: melody.uid,
pid: melody.pid,
};
if (melody.url) body.url = melody.url;
await api.put(`/melodies/${melody.id}`, body);
setSaveSuccess(true);
setTimeout(() => onSaved(), 800);
} catch (err) {
setSaveError(err.message);
} finally {
setSaving(false);
}
};
if (!open) return null;
const totalSteps = steps.length;
const allBellsUsed = steps.reduce((set, v) => {
getActiveBells(v).forEach((b) => set.add(b));
return set;
}, new Set());
const currentBells = currentStep >= 0 ? getActiveBells(steps[currentStep] || 0) : [];
const maxBell = allBellsUsed.size > 0 ? Math.max(...Array.from(allBellsUsed)) : 0;
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.7)" }}
onClick={(e) => e.target === e.currentTarget && !playing && onClose()}
>
<div
className="w-full rounded-lg border shadow-xl"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
maxWidth: "900px",
maxHeight: "92vh",
display: "flex",
flexDirection: "column",
}}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b flex-shrink-0" style={{ borderColor: "var(--border-primary)" }}>
<div>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Speed Calculator</h2>
<p className="text-xs mt-0.5" style={mutedStyle}>
Play the melody at different speeds to find the right MIN / MAX.
</p>
</div>
<button onClick={() => { stopPlayback(); onClose(); }} className="text-xl leading-none" style={mutedStyle}>&times;</button>
</div>
{/* Two-column body */}
<div className="flex-1 overflow-y-auto">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0", minHeight: 0 }}>
{/* LEFT COLUMN: Steps, visualizer, controls, sliders */}
<div className="p-6 space-y-5 border-r" style={{ borderColor: "var(--border-primary)" }}>
{/* Steps Input */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium" style={labelStyle}>
Melody Steps
</label>
{(builtMelody?.binary_url || melody?.url) && (
<button
onClick={handleLoadFromBinary}
disabled={loadingBinary}
className="text-xs px-2 py-1 rounded transition-colors disabled:opacity-50"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
{loadingBinary ? "Loading..." : "Load from Binary"}
</button>
)}
</div>
{binaryLoadError && (
<p className="text-xs mb-1" style={{ color: "var(--danger-text)" }}>{binaryLoadError}</p>
)}
<div className="flex gap-2">
<textarea
value={stepsInput}
onChange={(e) => setStepsInput(e.target.value)}
rows={3}
placeholder="e.g. 1,2,2+1,1,2,3+1,0,3,2..."
className="flex-1 px-3 py-2 rounded-md text-sm border"
style={{ fontFamily: "monospace", resize: "none" }}
/>
<button
onClick={handleStepsParse}
className="px-3 py-2 text-sm rounded-md self-start transition-colors"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Load
</button>
</div>
{totalSteps > 0 && (
<p className="text-xs mt-1" style={{ color: "var(--success-text)" }}>
{totalSteps} steps &nbsp;·&nbsp; {allBellsUsed.size} bell{allBellsUsed.size !== 1 ? "s" : ""}
</p>
)}
</div>
{/* Bell visualizer */}
{totalSteps > 0 && (
<div>
<p className="text-xs mb-2" style={mutedStyle}>
Bell indicator
{currentStep >= 0 && <span> &nbsp;·&nbsp; Step {currentStep + 1} / {totalSteps}</span>}
</p>
<div className="flex flex-wrap gap-1.5">
{Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => {
const isActive = currentBells.includes(b);
const isUsed = allBellsUsed.has(b);
return (
<div
key={b}
className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all"
style={{
backgroundColor: isActive ? "var(--accent)" : isUsed ? "var(--bg-card-hover)" : "var(--bg-primary)",
color: isActive ? "var(--bg-primary)" : isUsed ? "var(--text-secondary)" : "var(--border-primary)",
border: `2px solid ${isActive ? "var(--accent)" : "var(--border-primary)"}`,
transform: isActive ? "scale(1.2)" : "scale(1)",
}}
>
{b}
</div>
);
})}
</div>
</div>
)}
{/* Playback Controls */}
<div className="flex items-center gap-2">
{!playing ? (
<button
onClick={handlePlay}
disabled={!totalSteps}
className="px-4 py-2 text-sm rounded-md disabled:opacity-40 transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{paused ? "Resume" : "Play"}
</button>
) : (
<button
onClick={handlePause}
className="px-4 py-2 text-sm rounded-md transition-colors font-medium"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
>
Pause
</button>
)}
<button
onClick={stopPlayback}
disabled={!playing && !paused}
className="px-4 py-2 text-sm rounded-md disabled:opacity-40 transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
>
Stop
</button>
<label className="flex items-center gap-1.5 ml-1 text-sm cursor-pointer" style={labelStyle}>
<input
type="checkbox"
checked={loop}
onChange={(e) => { setLoop(e.target.checked); loopRef.current = e.target.checked; }}
className="h-4 w-4 rounded"
/>
Loop
</label>
</div>
{/* Step Delay Slider — inverted: right = fast (low ms) */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium" style={labelStyle}>Step Delay (Speed)</label>
<span className="text-sm font-bold" style={{ color: "var(--accent)" }}>{stepDelay} ms</span>
</div>
<input
type="range"
min={DELAY_MIN}
max={DELAY_MAX}
step="10"
value={delayToSlider(stepDelay)}
onChange={(e) => setStepDelay(sliderToDelay(Number(e.target.value)))}
className="w-full h-2 rounded-lg appearance-none cursor-pointer"
/>
<div className="flex justify-between text-xs mt-0.5" style={mutedStyle}>
<span>Slow (3000ms)</span>
<span>Fast (50ms)</span>
</div>
</div>
{/* Beat Duration Slider */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium" style={labelStyle}>Tone Length</label>
<span className="text-sm" style={mutedStyle}>
{effectiveBeat} ms
{effectiveBeat < beatDuration && <span style={{ color: "var(--warning, #f59e0b)" }}> (capped)</span>}
</span>
</div>
<input
type="range"
min="20"
max="500"
step="5"
value={beatDuration}
onChange={(e) => setBeatDuration(Number(e.target.value))}
className="w-full h-2 rounded-lg appearance-none cursor-pointer"
/>
<div className="flex justify-between text-xs mt-0.5" style={mutedStyle}>
<span>20ms (short)</span>
<span>500ms (long)</span>
</div>
</div>
</div>
{/* RIGHT COLUMN: Speed Capture */}
<div className="p-6">
<div className="rounded-lg border p-4 space-y-4 h-full" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
<div>
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Speed Capture</h3>
<p className="text-xs mt-1" style={mutedStyle}>
Play and find the fastest speed the controller can handle (MAX). Then find a speed that feels natural (Normal). MIN is auto-derived so that 50% slider = Normal.
</p>
</div>
{/* MAX */}
<div className="rounded-md p-3 border" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-card)" }}>
<span className="text-xs font-semibold uppercase tracking-wide block mb-2" style={{ color: "var(--text-muted)" }}>MAX fastest safe</span>
<div className="flex items-center justify-between">
<span className="text-2xl font-bold" style={{ color: capturedMax !== null ? "var(--accent)" : "var(--text-muted)" }}>
{capturedMax !== null ? `${capturedMax} ms` : "—"}
</span>
<div className="flex gap-2">
<button
onClick={() => handleSetSliderToCapture(capturedMax)}
disabled={capturedMax === null}
className="px-2 py-1 text-xs rounded disabled:opacity-40 transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
title="Set slider to this value"
>
Set
</button>
<button
onClick={() => setCapturedMax(stepDelay)}
className="px-3 py-1.5 text-xs rounded-md transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Capture
</button>
</div>
</div>
</div>
{/* Normal */}
<div className="rounded-md p-3 border" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-card)" }}>
<span className="text-xs font-semibold uppercase tracking-wide block mb-2" style={{ color: "var(--text-muted)" }}>Normal at 50% speed</span>
<div className="flex items-center justify-between">
<span className="text-2xl font-bold" style={{ color: capturedNormal !== null ? "var(--accent)" : "var(--text-muted)" }}>
{capturedNormal !== null ? `${capturedNormal} ms` : "—"}
</span>
<div className="flex gap-2">
<button
onClick={() => handleSetSliderToCapture(capturedNormal)}
disabled={capturedNormal === null}
className="px-2 py-1 text-xs rounded disabled:opacity-40 transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
title="Set slider to this value"
>
Set
</button>
<button
onClick={() => setCapturedNormal(stepDelay)}
className="px-3 py-1.5 text-xs rounded-md transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Capture
</button>
</div>
</div>
</div>
{/* MIN (derived) */}
<div className="rounded-md p-3 border" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-card)" }}>
<span className="text-xs font-semibold uppercase tracking-wide block mb-1" style={{ color: "var(--text-muted)" }}>
MIN auto-calculated
{derivedMin !== null && <span className="font-normal ml-1">(normal² / max)</span>}
</span>
<span className="text-2xl font-bold" style={{ color: derivedMin !== null ? "var(--text-secondary)" : "var(--text-muted)" }}>
{derivedMin !== null ? `${derivedMin} ms` : "—"}
</span>
</div>
{/* Warnings */}
{maxWarning && (
<div className="text-xs rounded p-2" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>
Warning: MAX of {capturedMax}ms is very fast (&lt;100ms). This could damage the device.
</div>
)}
{orderWarning && (
<div className="text-xs rounded p-2" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>
Warning: Normal ({capturedNormal}ms) is faster than MAX ({capturedMax}ms). Normal should be slower (higher ms). Please re-capture.
</div>
)}
{/* Save messages */}
{saveError && (
<div className="text-xs rounded-md p-2 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{saveError}
</div>
)}
{saveSuccess && (
<div className="text-xs rounded-md p-2 border" style={{ backgroundColor: "var(--success-bg)", borderColor: "var(--success)", color: "var(--success-text)" }}>
Speeds saved to melody!
</div>
)}
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t flex-shrink-0" style={{ borderColor: "var(--border-primary)" }}>
<button
onClick={() => { stopPlayback(); onClose(); }}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
>
Close
</button>
<button
onClick={handleSave}
disabled={!capturedMax || !capturedNormal || !derivedMin || saving || orderWarning}
className="px-4 py-2 text-sm rounded-md disabled:opacity-40 transition-colors font-medium"
style={{ backgroundColor: "#16a34a", color: "#fff" }}
title={!capturedMax || !capturedNormal ? "Capture MAX and Normal speeds first" : ""}
>
{saving ? "Saving..." : "Save to Melody"}
</button>
</div>
</div>
</div>
);
}