523 lines
19 KiB
JavaScript
523 lines
19 KiB
JavaScript
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 (
|
|
<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: "480px",
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
className="flex items-center justify-between px-6 py-4 border-b"
|
|
style={{ borderColor: "var(--border-primary)" }}
|
|
>
|
|
<div>
|
|
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
|
|
Melody Playback
|
|
</h2>
|
|
<p className="text-xs mt-0.5" style={mutedStyle}>
|
|
{melody?.information?.name?.en || "Melody"} — looping
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => { stopPlayback(); onClose(); }}
|
|
className="text-xl leading-none"
|
|
style={mutedStyle}
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="px-6 py-5 space-y-5">
|
|
{loading && (
|
|
<p className="text-sm text-center py-4" style={mutedStyle}>Loading binary...</p>
|
|
)}
|
|
{loadError && (
|
|
<div
|
|
className="text-sm rounded-md p-3 border"
|
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
|
>
|
|
{loadError}
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !loadError && totalSteps > 0 && (
|
|
<>
|
|
{/* Step info */}
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs" style={mutedStyle}>
|
|
{totalSteps} steps · {allBellsUsed.size} bell{allBellsUsed.size !== 1 ? "s" : ""}
|
|
</span>
|
|
{currentStep >= 0 && (
|
|
<span className="text-xs font-mono" style={{ color: "var(--accent)" }}>
|
|
Step {currentStep + 1} / {totalSteps}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Note → Bell assignment visualizer (shows when assignments exist) */}
|
|
{noteAssignments.length > 0 ? (
|
|
<div>
|
|
<p className="text-xs mb-2" style={mutedStyle}>Note → Assigned Bell</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{noteAssignments.map((assignedBell, noteIdx) => {
|
|
// A note is active (flashing) if its assigned bell is currently lit in activeBells
|
|
const firesABell = assignedBell && assignedBell > 0;
|
|
const isActive = firesABell && activeBells.has(assignedBell);
|
|
return (
|
|
<div
|
|
key={noteIdx}
|
|
className="flex flex-col items-center rounded-md border transition-all"
|
|
style={{
|
|
minWidth: "36px",
|
|
padding: "4px 6px",
|
|
backgroundColor: isActive && firesABell
|
|
? "var(--accent)"
|
|
: isActive && !firesABell
|
|
? "rgba(156,163,175,0.15)"
|
|
: "var(--bg-card-hover)",
|
|
borderColor: isActive ? "var(--accent)" : "var(--border-primary)",
|
|
transform: isActive && firesABell ? "scale(1.1)" : "scale(1)",
|
|
opacity: isActive && !firesABell ? 0.5 : 1,
|
|
}}
|
|
>
|
|
<span className="text-xs font-bold leading-tight" style={{ color: isActive && firesABell ? "var(--bg-primary)" : "var(--text-secondary)" }}>
|
|
{NOTE_LABELS[noteIdx]}
|
|
</span>
|
|
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: isActive && firesABell ? "rgba(255,255,255,0.4)" : "var(--border-primary)" }} />
|
|
<span className="text-xs leading-tight" style={{ color: isActive && firesABell ? "var(--bg-primary)" : "var(--text-muted)" }}>
|
|
{assignedBell > 0 ? assignedBell : "—"}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<p className="text-xs mt-1" style={mutedStyle}>Top = Note, Bottom = Bell assigned</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Active Bell circles (always shown) */}
|
|
{maxBell > 0 && (
|
|
<div>
|
|
<p className="text-xs mb-2" style={mutedStyle}>Active Bells</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => {
|
|
const isActive = activeBells.has(b);
|
|
const isUsed = allBellsUsed.has(b);
|
|
return (
|
|
<div
|
|
key={b}
|
|
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold"
|
|
style={{
|
|
backgroundColor: isActive ? "#22c55e" : isUsed ? "var(--bg-card-hover)" : "var(--bg-primary)",
|
|
color: isActive ? "#fff" : isUsed ? "var(--text-secondary)" : "var(--border-primary)",
|
|
border: `2px solid ${isActive ? "#22c55e" : "var(--border-primary)"}`,
|
|
transition: "background-color 0.05s, border-color 0.05s",
|
|
transform: isActive ? "scale(1.15)" : "scale(1)",
|
|
}}
|
|
>
|
|
{b}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Play / Stop */}
|
|
<div className="flex items-center gap-3">
|
|
{!playing ? (
|
|
<button
|
|
onClick={handlePlay}
|
|
className="px-5 py-2 text-sm rounded-md font-medium transition-colors"
|
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
|
>
|
|
Play
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={handleStop}
|
|
className="px-5 py-2 text-sm rounded-md font-medium transition-colors"
|
|
style={{ backgroundColor: "var(--danger-btn)", color: "var(--text-white)" }}
|
|
>
|
|
Stop
|
|
</button>
|
|
)}
|
|
<span className="text-xs" style={mutedStyle}>Loops continuously</span>
|
|
</div>
|
|
|
|
{/* Speed Slider */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<label className="text-sm font-medium" style={labelStyle}>Speed</label>
|
|
<div className="text-right">
|
|
<span className="text-sm font-bold" style={{ color: "var(--accent)" }}>
|
|
{speedPercent}%
|
|
</span>
|
|
{hasSpeedInfo && (
|
|
<span className="text-xs ml-2" style={mutedStyle}>
|
|
({speedMs} ms/step)
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="1"
|
|
max="100"
|
|
step="1"
|
|
value={speedPercent}
|
|
onChange={(e) => setSpeedPercent(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>1% (slowest)</span>
|
|
<span>100% (fastest)</span>
|
|
</div>
|
|
{!hasSpeedInfo && (
|
|
<p className="text-xs mt-1.5" style={{ color: "var(--warning, #f59e0b)" }}>
|
|
No MIN/MAX speed set for this melody — using linear fallback.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tone Length Slider */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<label className="text-sm font-medium" style={labelStyle}>Tone Length</label>
|
|
<span className="text-sm font-bold" style={{ color: "var(--accent)" }}>
|
|
{toneLengthMs} ms
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="20"
|
|
max="400"
|
|
step="10"
|
|
value={toneLengthMs}
|
|
onChange={(e) => setToneLengthMs(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>Short (20 ms)</span>
|
|
<span>Long (400 ms)</span>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div
|
|
className="flex justify-end px-6 py-4 border-t"
|
|
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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|