import { useState, useEffect, useRef, useCallback } from "react"; // ============================================================================ // Web Audio Engine // ============================================================================ 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 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)); } async function decodeBsmBinary(url) { // Try with auth token first (for our API endpoints), then without (for Firebase URLs) const token = localStorage.getItem("access_token"); let res = null; try { res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); } catch { throw new Error("Failed to fetch binary: network error"); } // If unauthorized and it looks like a Firebase URL, try without auth header if (!res.ok && res.status === 401 && url.startsWith("http")) { try { res = await fetch(url); } catch { throw new Error("Failed to fetch binary: network error"); } } 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 // ============================================================================ 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)); } // ============================================================================ // Apply note assignments: map archetype note bits → assigned bell bits // // The archetype steps encode which NOTES fire using bit flags (note 1 = bit 0, // note 2 = bit 1, etc). noteAssignments[noteIdx] gives the bell number to fire // for that note (0 = silence / no bell). We rebuild the step value using the // assigned bells instead of the raw note numbers. // ============================================================================ function applyNoteAssignments(rawStepValue, noteAssignments) { if (!noteAssignments || noteAssignments.length === 0) return rawStepValue; let result = 0; for (let bit = 0; bit < 16; bit++) { if (rawStepValue & (1 << bit)) { const noteIdx = bit; // bit 0 = note 1, bit 1 = note 2, ... const assignedBell = noteAssignments[noteIdx]; if (assignedBell && assignedBell > 0) { result |= 1 << (assignedBell - 1); } // assignedBell === 0 means silence — do not set any bell bit } } return result; } // ============================================================================ // Component // ============================================================================ const mutedStyle = { color: "var(--text-muted)" }; const labelStyle = { color: "var(--text-secondary)" }; const NOTE_LABELS = "ABCDEFGHIJKLMNOP"; export default function PlaybackModal({ open, melody, builtMelody, files, archetypeCsv, onClose }) { const info = melody?.information || {}; const minSpeed = info.minSpeed || null; const maxSpeed = info.maxSpeed || null; const noteAssignments = melody?.default_settings?.noteAssignments || []; 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 [toneLengthMs, setToneLengthMs] = useState(80); // activeBells: Set of bell numbers currently lit (for flash effect) const [activeBells, setActiveBells] = useState(new Set()); const audioCtxRef = useRef(null); const playbackRef = useRef(null); const stepsRef = useRef([]); const speedMsRef = useRef(500); const toneLengthRef = useRef(80); const noteAssignmentsRef = useRef(noteAssignments); const speedMs = mapPercentageToSpeed(speedPercent, minSpeed, maxSpeed) ?? 500; useEffect(() => { stepsRef.current = steps; }, [steps]); useEffect(() => { speedMsRef.current = speedMs; }, [speedMs]); useEffect(() => { toneLengthRef.current = toneLengthMs; }, [toneLengthMs]); useEffect(() => { noteAssignmentsRef.current = noteAssignments; }, [noteAssignments]); // eslint-disable-line react-hooks/exhaustive-deps const stopPlayback = useCallback(() => { if (playbackRef.current) { clearTimeout(playbackRef.current.timer); if (playbackRef.current.flashTimer) clearTimeout(playbackRef.current.flashTimer); playbackRef.current = null; } setPlaying(false); setCurrentStep(-1); setActiveBells(new Set()); }, []); // Load steps on open useEffect(() => { if (!open) { stopPlayback(); setSteps([]); setCurrentStep(-1); setLoadError(""); setSpeedPercent(50); setActiveBells(new Set()); return; } const csv = archetypeCsv || info.archetype_csv || null; if (csv) { const parsed = parseStepsString(csv); setSteps(parsed); stepsRef.current = parsed; setLoadError(""); return; } // Fall back to binary const binaryUrl = builtMelody?.binary_url ? `/api${builtMelody.binary_url}` : files?.binary_url || melody?.url || null; if (!binaryUrl) { setLoadError("No binary or archetype data 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; const ctx = ensureAudioCtx(); const rawStepValue = currentSteps[playFrom]; // Map archetype notes → assigned bells const stepValue = applyNoteAssignments(rawStepValue, noteAssignmentsRef.current); setCurrentStep(playFrom); // Flash active bells for tone length, then clear const bellsNow = new Set(); for (let bit = 0; bit < 16; bit++) { if (stepValue & (1 << bit)) bellsNow.add(bit + 1); } setActiveBells(bellsNow); playStep(ctx, stepValue, toneLengthRef.current); // Clear bell highlight after tone length const flashTimer = setTimeout(() => { setActiveBells(new Set()); }, toneLengthRef.current); // Schedule next step after step interval const timer = setTimeout(() => { const next = playFrom + 1; scheduleStep(next >= stepsRef.current.length ? 0 : next); }, speedMsRef.current); playbackRef.current = { timer, flashTimer, stepIndex: playFrom }; }, []); // eslint-disable-line react-hooks/exhaustive-deps const handlePlay = () => { if (!stepsRef.current.length) return; setPlaying(true); scheduleStep(0); }; const handleStop = () => { stopPlayback(); }; if (!open) return null; const totalSteps = steps.length; // Compute which bells are actually used (after assignment mapping) const allBellsUsed = steps.reduce((set, v) => { const mapped = applyNoteAssignments(v, noteAssignments); for (let bit = 0; bit < 16; bit++) { if (mapped & (1 << bit)) set.add(bit + 1); } return set; }, new Set()); const maxBell = allBellsUsed.size > 0 ? Math.max(...Array.from(allBellsUsed)) : 0; const hasSpeedInfo = minSpeed != null && maxSpeed != null; return (
{melody?.information?.name?.en || "Melody"} — looping
Loading binary...
)} {loadError && (Note → Assigned Bell
Top = Note, Bottom = Bell assigned
Active Bells
No MIN/MAX speed set for this melody — using linear fallback.
)}