import { useState, useEffect, useRef, useCallback } from "react"; import api from "../api/client"; 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) { const raw = String(notation || "").trim(); if (raw === "0" || !raw) return 0; let value = 0; for (const part of raw.split("+")) { const n = Number.parseInt(part.trim(), 10); if (Number.isInteger(n) && n >= 1 && n <= 16) value |= 1 << (n - 1); } return value; } function parseStepsString(stepsStr) { if (!stepsStr || !String(stepsStr).trim()) return []; return String(stepsStr).trim().split(",").map((s) => parseBellNotation(s)); } function normalizePlaybackUrl(url) { if (!url || typeof url !== "string") return null; if (url.startsWith("http") || url.startsWith("/api")) return url; if (url.startsWith("/")) return `/api${url}`; return `/api/${url}`; } async function fetchBinaryResponse(url) { const token = localStorage.getItem("access_token"); try { const res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (res.ok) return res; if (url.startsWith("http")) { const retry = await fetch(url); if (retry.ok) return retry; throw new Error(`Failed to fetch binary: ${retry.statusText || retry.status}`); } throw new Error(`Failed to fetch binary: ${res.statusText || res.status}`); } catch (err) { if (url.startsWith("http")) { const retry = await fetch(url); if (retry.ok) return retry; throw new Error(`Failed to fetch binary: ${retry.statusText || retry.status}`); } throw err; } } async function decodeBsmBinary(url) { const res = await fetchBinaryResponse(url); 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; } 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)); } 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 assignedBell = noteAssignments[bit]; if (assignedBell && assignedBell > 0) { result |= 1 << (assignedBell - 1); } } } return result; } function interpolateHue(t) { const stops = [ [0.0, 190], // bright teal/blue [0.24, 140], // green [0.5, 56], // yellow [0.82, 30], // orange [1.0, 0], // red ]; for (let i = 0; i < stops.length - 1; i++) { const [aPos, aHue] = stops[i]; const [bPos, bHue] = stops[i + 1]; if (t >= aPos && t <= bPos) { const local = (t - aPos) / (bPos - aPos || 1); return aHue + (bHue - aHue) * local; } } return stops[stops.length - 1][1]; } function bellDotColor(assignedBell) { const bell = Number(assignedBell || 0); if (bell <= 0) return "rgba(148,163,184,0.7)"; const t = Math.min(1, Math.max(0, (bell - 1) / 15)); const hue = interpolateHue(t); return `hsl(${hue}, 78%, 68%)`; } function bellDotGlow(assignedBell) { const bell = Number(assignedBell || 0); if (bell <= 0) return "rgba(100,116,139,0.35)"; const t = Math.min(1, Math.max(0, (bell - 1) / 15)); const hue = interpolateHue(t); return `hsla(${hue}, 78%, 56%, 0.45)`; } function bellCustomGlow(color) { if (!color || typeof color !== "string") return null; return `${color}66`; } 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); const [loopEnabled, setLoopEnabled] = useState(true); const [activeBells, setActiveBells] = useState(new Set()); const [noteColors, setNoteColors] = useState([]); const audioCtxRef = useRef(null); const playbackRef = useRef(null); const stepsRef = useRef([]); const speedMsRef = useRef(500); const toneLengthRef = useRef(80); const noteAssignmentsRef = useRef(noteAssignments); const loopEnabledRef = useRef(true); 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]); useEffect(() => { loopEnabledRef.current = loopEnabled; }, [loopEnabled]); useEffect(() => { if (!open) return; let canceled = false; api.get("/settings/melody") .then((s) => { if (canceled) return; setNoteColors((s?.note_assignment_colors || []).slice(0, 16)); }) .catch(() => { if (!canceled) setNoteColors([]); }); return () => { canceled = true; }; }, [open]); 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()); }, []); useEffect(() => { if (!open) { stopPlayback(); setSteps([]); setCurrentStep(-1); setLoadError(""); setSpeedPercent(50); setLoopEnabled(true); setActiveBells(new Set()); return; } const binaryUrlCandidate = builtMelody?.binary_url ? `/api${builtMelody.binary_url}` : files?.binary_url || melody?.url || null; const binaryUrl = normalizePlaybackUrl(binaryUrlCandidate); const csv = archetypeCsv || info.archetype_csv || null; if (binaryUrl) { setLoading(true); setLoadError(""); decodeBsmBinary(binaryUrl) .then((decoded) => { setSteps(decoded); stepsRef.current = decoded; }) .catch((err) => { if (csv) { const parsed = parseStepsString(csv); setSteps(parsed); stepsRef.current = parsed; setLoadError(""); return; } setLoadError(err.message || "Failed to load melody data."); }) .finally(() => setLoading(false)); return; } if (csv) { const parsed = parseStepsString(csv); setSteps(parsed); stepsRef.current = parsed; setLoadError(""); return; } setLoadError("No binary or archetype data available for this melody."); }, [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]; const stepValue = applyNoteAssignments(rawStepValue, noteAssignmentsRef.current); setCurrentStep(playFrom); 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); const flashTimer = setTimeout(() => setActiveBells(new Set()), toneLengthRef.current); const timer = setTimeout(() => { const next = playFrom + 1; if (next >= stepsRef.current.length) { if (loopEnabledRef.current) scheduleStep(0); else stopPlayback(); return; } scheduleStep(next); }, speedMsRef.current); playbackRef.current = { timer, flashTimer, stepIndex: playFrom }; }, [stopPlayback]); const handlePlay = () => { if (!stepsRef.current.length) return; setPlaying(true); scheduleStep(0); }; if (!open) return null; const totalSteps = steps.length; 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; const detectedNoteCount = steps.reduce((max, stepValue) => { let highest = 0; for (let bit = 15; bit >= 0; bit--) { if (stepValue & (1 << bit)) { highest = bit + 1; break; } } return Math.max(max, highest); }, 0); const configuredNoteCount = Number(info.totalNotes || noteAssignments.length || 0); const gridNoteCount = Math.max(1, Math.min(16, configuredNoteCount || detectedNoteCount || 1)); return (
{melody?.information?.name?.en || "Melody"} - looping
Loading binary...
} {loadError && (Note to Assigned Bell
Active Bells
Note/Step Matrix
| Note \\ Step | {steps.map((_, stepIdx) => ({stepIdx + 1} | ))}
|---|---|
| {NOTE_LABELS[noteIdx]} | {steps.map((stepValue, stepIdx) => { const enabled = Boolean(stepValue & (1 << noteIdx)); const isCurrent = currentStep === stepIdx; const assignedBell = Number(noteAssignments[noteIdx] || 0); const assignedColor = assignedBell > 0 ? noteColors?.[assignedBell - 1] : null; const dotLabel = assignedBell > 0 ? assignedBell : ""; const isUnassigned = assignedBell <= 0; const dotVisible = enabled || (isUnassigned && Boolean(stepValue & (1 << noteIdx))); return (); })} |
No MIN/MAX speed set for this melody - using linear fallback.
)} > )}