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(false); const [playing, setPlaying] = useState(false); const [paused, setPaused] = useState(false); const [loop, setLoop] = useState(false); 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) { setCapturedMax(info.maxSpeed > 0 ? info.maxSpeed : null); setCapturedNormal(null); 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 () => { if (!builtMelody?.binary_url) return; setLoadingBinary(true); setBinaryLoadError(""); try { const decoded = await decodeBsmBinary(`/api${builtMelody.binary_url}`); 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; setSaving(true); setSaveError(""); setSaveSuccess(false); try { await api.put(`/melodies/${melody.id}`, { information: { ...info, minSpeed: derivedMin, maxSpeed: capturedMax, }, default_settings: melody.default_settings, type: melody.type, url: melody.url, uid: melody.uid, pid: melody.pid, }); 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 (
e.target === e.currentTarget && !playing && onClose()} >
{/* Header */}

Speed Calculator

Play the melody at different speeds to find the right MIN / MAX.

{/* Two-column body */}
{/* LEFT COLUMN: Steps, visualizer, controls, sliders */}
{/* Steps Input */}
{builtMelody?.binary_url && ( )}
{binaryLoadError && (

{binaryLoadError}

)}