Various more changes to the Archetype Builder
This commit is contained in:
375
frontend/src/melodies/PlaybackModal.jsx
Normal file
375
frontend/src/melodies/PlaybackModal.jsx
Normal file
@@ -0,0 +1,375 @@
|
||||
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 (
|
||||
<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: "460px",
|
||||
}}
|
||||
>
|
||||
{/* 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 / error states */}
|
||||
{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>
|
||||
|
||||
{/* Bell visualizer */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => {
|
||||
const isActive = currentBells.includes(b);
|
||||
const isUsed = allBellsUsed.has(b);
|
||||
return (
|
||||
<div
|
||||
key={b}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all"
|
||||
style={{
|
||||
backgroundColor: isActive ? "var(--accent)" : isUsed ? "var(--bg-card-hover)" : "var(--bg-primary)",
|
||||
color: isActive ? "var(--bg-primary)" : isUsed ? "var(--text-secondary)" : "var(--border-primary)",
|
||||
border: `2px solid ${isActive ? "var(--accent)" : "var(--border-primary)"}`,
|
||||
transform: isActive ? "scale(1.2)" : "scale(1)",
|
||||
}}
|
||||
>
|
||||
{b}
|
||||
</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)
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user