import { useState, useEffect, useRef, useCallback } from "react"; // ============================================================================ // Web Audio Engine (shared with SpeedCalculatorModal pattern) // ============================================================================ 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 freq = bellFrequency(bit + 1); 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); } } } function getActiveBells(stepValue) { const bells = []; for (let bit = 0; bit < 16; bit++) { if (stepValue & (1 << bit)) bells.push(bit + 1); } return bells; } 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)); } return steps; } // ============================================================================ // Speed math — exponential mapping matching the Flutter app: // value = minSpeed * pow(maxSpeed / minSpeed, t) where t = percent / 100 // Note: in this system, MIN ms > MAX ms (MIN = slowest, MAX = fastest). // ============================================================================ function mapPercentageToSpeed(percent, minSpeed, maxSpeed) { if (minSpeed == null || maxSpeed == null) return null; const t = Math.max(0, Math.min(100, percent)) / 100; const a = minSpeed; const b = maxSpeed; if (a <= 0 || b <= 0) return Math.round(a + (b - a) * t); return Math.round(a * Math.pow(b / a, t)); } // ============================================================================ // Component // ============================================================================ const BEAT_DURATION_MS = 80; // fixed tone length for playback const mutedStyle = { color: "var(--text-muted)" }; const labelStyle = { color: "var(--text-secondary)" }; export default function PlaybackModal({ open, melody, builtMelody, files, onClose }) { const info = melody?.information || {}; const minSpeed = info.minSpeed || null; const maxSpeed = info.maxSpeed || null; const [steps, setSteps] = useState([]); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(""); const [playing, setPlaying] = useState(false); const [currentStep, setCurrentStep] = useState(-1); const [speedPercent, setSpeedPercent] = useState(50); const audioCtxRef = useRef(null); const playbackRef = useRef(null); const stepsRef = useRef([]); const speedMsRef = useRef(500); // Derived speed in ms from the current percent const speedMs = mapPercentageToSpeed(speedPercent, minSpeed, maxSpeed) ?? 500; // Keep refs in sync so the playback loop reads live values useEffect(() => { stepsRef.current = steps; }, [steps]); useEffect(() => { speedMsRef.current = speedMs; }, [speedMs]); const stopPlayback = useCallback(() => { if (playbackRef.current) { clearTimeout(playbackRef.current.timer); playbackRef.current = null; } setPlaying(false); setCurrentStep(-1); }, []); // Load binary on open useEffect(() => { if (!open) { stopPlayback(); setSteps([]); setCurrentStep(-1); setLoadError(""); setSpeedPercent(50); return; } // builtMelody.binary_url is a relative path needing /api prefix; // files.binary_url from the /files endpoint is already a full URL path. const binaryUrl = builtMelody?.binary_url ? `/api${builtMelody.binary_url}` : files?.binary_url || null; if (!binaryUrl) { setLoadError("No binary file available for this melody."); return; } setLoading(true); setLoadError(""); decodeBsmBinary(binaryUrl) .then((decoded) => { setSteps(decoded); stepsRef.current = decoded; }) .catch((err) => setLoadError(err.message)) .finally(() => setLoading(false)); }, [open]); // eslint-disable-line react-hooks/exhaustive-deps 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; }; const scheduleStep = useCallback((stepIndex) => { const currentSteps = stepsRef.current; if (!currentSteps.length) return; const playFrom = stepIndex % currentSteps.length; // End of sequence — loop back if (playFrom === 0 && stepIndex > 0) { scheduleStep(0); return; } const ctx = ensureAudioCtx(); const stepValue = currentSteps[playFrom]; setCurrentStep(playFrom); playStep(ctx, stepValue, BEAT_DURATION_MS); const timer = setTimeout(() => { const next = playFrom + 1; if (next >= stepsRef.current.length) { scheduleStep(0); } else { scheduleStep(next); } }, speedMsRef.current); playbackRef.current = { timer, stepIndex: playFrom }; }, []); // no deps — reads everything from refs const handlePlay = () => { if (!stepsRef.current.length) return; setPlaying(true); scheduleStep(0); }; const handleStop = () => { stopPlayback(); }; 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 maxBell = allBellsUsed.size > 0 ? Math.max(...Array.from(allBellsUsed)) : 0; const currentBells = currentStep >= 0 ? getActiveBells(steps[currentStep] || 0) : []; const hasSpeedInfo = minSpeed != null && maxSpeed != null; return (
e.target === e.currentTarget && !playing && onClose()} >
{/* Header */}

Melody Playback

{melody?.information?.name?.en || "Melody"} — looping

{/* Body */}
{/* Loading / error states */} {loading && (

Loading binary...

)} {loadError && (
{loadError}
)} {!loading && !loadError && totalSteps > 0 && ( <> {/* Step info */}
{totalSteps} steps  ·  {allBellsUsed.size} bell{allBellsUsed.size !== 1 ? "s" : ""} {currentStep >= 0 && ( Step {currentStep + 1} / {totalSteps} )}
{/* Bell visualizer */}
{Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => { const isActive = currentBells.includes(b); const isUsed = allBellsUsed.has(b); return (
{b}
); })}
{/* Play / Stop */}
{!playing ? ( ) : ( )} Loops continuously
{/* Speed Slider */}
{speedPercent}% {hasSpeedInfo && ( ({speedMs} ms) )}
setSpeedPercent(Number(e.target.value))} className="w-full h-2 rounded-lg appearance-none cursor-pointer" />
1% (slowest) 100% (fastest)
{!hasSpeedInfo && (

No MIN/MAX speed set for this melody — using linear fallback.

)}
)}
{/* Footer */}
); }