First Fixes/Adjustments to the MelodyBuilder and SpeedCalc

This commit is contained in:
2026-02-22 13:59:31 +02:00
parent 8703c4fe26
commit cfae55025d
6 changed files with 418 additions and 289 deletions

View File

@@ -9,7 +9,8 @@
"Bash(wc:*)", "Bash(wc:*)",
"Bash(ls:*)", "Bash(ls:*)",
"Bash(node -c:*)", "Bash(node -c:*)",
"Bash(npm run lint:*)" "Bash(npm run lint:*)",
"Bash(python:*)"
] ]
} }
} }

View File

@@ -11,7 +11,7 @@ from fastapi import HTTPException
logger = logging.getLogger("builder.service") logger = logging.getLogger("builder.service")
# Storage directory for built .bsm files # Storage directory for built .bsm files
STORAGE_DIR = Path("storage/built_melodies") STORAGE_DIR = Path(__file__).parent.parent / "storage" / "built_melodies"
def _ensure_storage_dir(): def _ensure_storage_dir():

0
backend/mqtt_data.db Normal file
View File

View File

@@ -443,6 +443,7 @@ export default function MelodyDetail() {
<SpeedCalculatorModal <SpeedCalculatorModal
open={showSpeedCalc} open={showSpeedCalc}
melody={melody} melody={melody}
builtMelody={builtMelody}
onClose={() => setShowSpeedCalc(false)} onClose={() => setShowSpeedCalc(false)}
onSaved={() => { onSaved={() => {
setShowSpeedCalc(false); setShowSpeedCalc(false);

View File

@@ -4,6 +4,7 @@ import api from "../api/client";
import TranslationModal from "./TranslationModal"; import TranslationModal from "./TranslationModal";
import SelectBuiltMelodyModal from "./builder/SelectBuiltMelodyModal"; import SelectBuiltMelodyModal from "./builder/SelectBuiltMelodyModal";
import BuildOnTheFlyModal from "./builder/BuildOnTheFlyModal"; import BuildOnTheFlyModal from "./builder/BuildOnTheFlyModal";
import SpeedCalculatorModal from "./SpeedCalculatorModal";
import { import {
getLocalizedValue, getLocalizedValue,
getLanguageName, getLanguageName,
@@ -84,6 +85,8 @@ export default function MelodyForm() {
const [showSelectBuilt, setShowSelectBuilt] = useState(false); const [showSelectBuilt, setShowSelectBuilt] = useState(false);
const [showBuildOnTheFly, setShowBuildOnTheFly] = useState(false); const [showBuildOnTheFly, setShowBuildOnTheFly] = useState(false);
const [showSpeedCalc, setShowSpeedCalc] = useState(false);
const [builtMelody, setBuiltMelody] = useState(null);
useEffect(() => { useEffect(() => {
api.get("/settings/melody").then((ms) => { api.get("/settings/melody").then((ms) => {
@@ -124,6 +127,13 @@ export default function MelodyForm() {
setPid(melody.pid || ""); setPid(melody.pid || "");
setMelodyStatus(melody.status || "published"); setMelodyStatus(melody.status || "published");
setExistingFiles(files); setExistingFiles(files);
// Load built melody assignment (non-fatal)
try {
const bm = await api.get(`/builder/melodies/for-melody/${id}`);
setBuiltMelody(bm || null);
} catch {
setBuiltMelody(null);
}
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -259,6 +269,16 @@ export default function MelodyForm() {
{isEdit ? "Edit Melody" : "Add Melody"} {isEdit ? "Edit Melody" : "Add Melody"}
</h1> </h1>
<div className="flex gap-3"> <div className="flex gap-3">
{isEdit && (
<button
type="button"
onClick={() => setShowSpeedCalc(true)}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
>
Speed Calculator
</button>
)}
<button <button
type="button" type="button"
onClick={() => navigate(isEdit ? `/melodies/${id}` : "/melodies")} onClick={() => navigate(isEdit ? `/melodies/${id}` : "/melodies")}
@@ -610,6 +630,13 @@ export default function MelodyForm() {
{isEdit && ( {isEdit && (
<> <>
<SpeedCalculatorModal
open={showSpeedCalc}
melody={{ id, information, default_settings: settings, type, url, uid, pid }}
builtMelody={builtMelody}
onClose={() => setShowSpeedCalc(false)}
onSaved={() => { setShowSpeedCalc(false); loadMelody(); }}
/>
<SelectBuiltMelodyModal <SelectBuiltMelodyModal
open={showSelectBuilt} open={showSelectBuilt}
melodyId={id} melodyId={id}

View File

@@ -35,7 +35,7 @@ function playStep(audioCtx, stepValue, beatDurationMs) {
gain.gain.setValueAtTime(0, now); gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.3, now + fadeIn); gain.gain.linearRampToValueAtTime(0.3, now + fadeIn);
gain.gain.setValueAtTime(0.3, now + duration - fadeOut); gain.gain.setValueAtTime(0.3, now + Math.max(duration - fadeOut, fadeIn + 0.001));
gain.gain.linearRampToValueAtTime(0, now + duration); gain.gain.linearRampToValueAtTime(0, now + duration);
osc.start(now); osc.start(now);
@@ -45,7 +45,7 @@ function playStep(audioCtx, stepValue, beatDurationMs) {
} }
// ============================================================================ // ============================================================================
// Parse raw steps string into list of uint16 values // Parse / decode helpers
// ============================================================================ // ============================================================================
function parseBellNotation(notation) { function parseBellNotation(notation) {
@@ -72,10 +72,28 @@ function getActiveBells(stepValue) {
return bells; return bells;
} }
/** Decode a .bsm binary (big-endian uint16 per step) to uint16 array */
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)); // big-endian
}
return steps;
}
// ============================================================================ // ============================================================================
// Speed math // Speed math
// MIN is derived so that at 50%, speed = normal (geometric mean) // MIN is derived so that at 50%, speed = normal (geometric mean)
// normal = sqrt(MIN * MAX) => MIN = normal^2 / MAX // normal = sqrt(MIN * MAX) => MIN = normal^2 / MAX
// Note: higher ms = SLOWER. MAX ms = fastest hardware speed.
// Normal ms must be HIGHER (slower) than MAX ms to make sense.
// ============================================================================ // ============================================================================
function calcMin(normal, max) { function calcMin(normal, max) {
@@ -90,27 +108,47 @@ function calcMin(normal, max) {
const labelStyle = { color: "var(--text-secondary)" }; const labelStyle = { color: "var(--text-secondary)" };
const mutedStyle = { color: "var(--text-muted)" }; const mutedStyle = { color: "var(--text-muted)" };
export default function SpeedCalculatorModal({ open, melody, onClose, onSaved }) { // Step Delay slider is inverted: right = fast (low ms), left = slow (high ms)
// We store actual ms in state, but display with inverted slider value.
const DELAY_MIN = 50;
const DELAY_MAX = 3000;
function delayToSlider(ms) { return DELAY_MIN + DELAY_MAX - ms; }
function sliderToDelay(val) { return DELAY_MIN + DELAY_MAX - val; }
export default function SpeedCalculatorModal({ open, melody, builtMelody, onClose, onSaved }) {
const info = melody?.information || {}; const info = melody?.information || {};
// Raw steps input (not stored in Firestore — only used locally for playback) // Raw steps input
const [stepsInput, setStepsInput] = useState(""); const [stepsInput, setStepsInput] = useState("");
const [steps, setSteps] = useState([]); // parsed uint16 values const [steps, setSteps] = useState([]);
const [loadingBinary, setLoadingBinary] = useState(false);
const [binaryLoadError, setBinaryLoadError] = useState("");
// Playback // Playback
const audioCtxRef = useRef(null); const audioCtxRef = useRef(null);
const playbackRef = useRef(null); // { timer, stepIndex } const playbackRef = useRef(null); // { timer, stepIndex }
const stepsRef = useRef([]);
const stepDelayRef = useRef(500);
const effectiveBeatRef = useRef(100);
const loopRef = useRef(false);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [paused, setPaused] = useState(false); const [paused, setPaused] = useState(false);
const [loop, setLoop] = useState(false); const [loop, setLoop] = useState(false);
const [currentStep, setCurrentStep] = useState(-1); const [currentStep, setCurrentStep] = useState(-1);
// Sliders // Sliders
const [stepDelay, setStepDelay] = useState(500); // ms const [stepDelay, setStepDelay] = useState(500);
const [beatDuration, setBeatDuration] = useState(100); // ms const [beatDuration, setBeatDuration] = useState(100);
const effectiveBeat = Math.min(beatDuration, Math.max(20, stepDelay - 20)); const effectiveBeat = Math.min(beatDuration, Math.max(20, stepDelay - 20));
// Keep refs in sync so playback loop always reads current values
useEffect(() => { stepsRef.current = steps; }, [steps]);
useEffect(() => { stepDelayRef.current = stepDelay; }, [stepDelay]);
useEffect(() => { effectiveBeatRef.current = effectiveBeat; }, [effectiveBeat]);
useEffect(() => { loopRef.current = loop; }, [loop]);
// Speed capture // Speed capture
const [capturedMax, setCapturedMax] = useState(null); const [capturedMax, setCapturedMax] = useState(null);
const [capturedNormal, setCapturedNormal] = useState(null); const [capturedNormal, setCapturedNormal] = useState(null);
@@ -120,22 +158,27 @@ export default function SpeedCalculatorModal({ open, melody, onClose, onSaved })
const [saveError, setSaveError] = useState(""); const [saveError, setSaveError] = useState("");
const [saveSuccess, setSaveSuccess] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false);
// Warnings // Warnings:
// MAX is the fastest (lowest ms). Normal must be > MAX (slower than MAX).
// If Normal < MAX, it means Normal is faster than MAX — nonsensical.
const maxWarning = capturedMax !== null && capturedMax < 100; const maxWarning = capturedMax !== null && capturedMax < 100;
const orderWarning = capturedMax !== null && capturedNormal !== null && capturedNormal > capturedMax; const orderWarning = capturedMax !== null && capturedNormal !== null && capturedNormal < capturedMax;
// Pre-fill existing speeds from melody // Reset on open
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setCapturedMax(info.maxSpeed > 0 ? info.maxSpeed : null); setCapturedMax(info.maxSpeed > 0 ? info.maxSpeed : null);
setCapturedNormal(null); setCapturedNormal(null);
setStepsInput(""); setStepsInput("");
setSteps([]); setSteps([]);
setBinaryLoadError("");
setCurrentStep(-1); setCurrentStep(-1);
setPlaying(false); setPlaying(false);
setPaused(false); setPaused(false);
setSaveError("");
setSaveSuccess(false);
} }
}, [open]); }, [open]); // eslint-disable-line react-hooks/exhaustive-deps
const stopPlayback = useCallback(() => { const stopPlayback = useCallback(() => {
if (playbackRef.current) { if (playbackRef.current) {
@@ -147,7 +190,6 @@ export default function SpeedCalculatorModal({ open, melody, onClose, onSaved })
setCurrentStep(-1); setCurrentStep(-1);
}, []); }, []);
// Stop when modal closes
useEffect(() => { useEffect(() => {
if (!open) stopPlayback(); if (!open) stopPlayback();
}, [open, stopPlayback]); }, [open, stopPlayback]);
@@ -162,44 +204,51 @@ export default function SpeedCalculatorModal({ open, melody, onClose, onSaved })
return audioCtxRef.current; return audioCtxRef.current;
}; };
const scheduleStep = useCallback( // Recursive scheduler using refs for live updates — no restart needed when sliders change
(stepIndex, startFrom = 0) => { const scheduleStep = useCallback((stepIndex) => {
if (!steps.length) return; const currentSteps = stepsRef.current;
if (!currentSteps.length) return;
const playFrom = stepIndex % steps.length; const playFrom = stepIndex % currentSteps.length;
if (!loop && playFrom === 0 && stepIndex > 0) {
stopPlayback(); // End of sequence without loop
if (!loopRef.current && playFrom === 0 && stepIndex > 0) {
setPlaying(false);
setPaused(false);
setCurrentStep(-1);
playbackRef.current = null;
return; return;
} }
const ctx = ensureAudioCtx(); const ctx = ensureAudioCtx();
const stepValue = steps[playFrom]; const stepValue = currentSteps[playFrom];
setCurrentStep(playFrom); setCurrentStep(playFrom);
playStep(ctx, stepValue, effectiveBeat); playStep(ctx, stepValue, effectiveBeatRef.current);
const delay = stepDelayRef.current;
const timer = setTimeout(() => { const timer = setTimeout(() => {
const next = playFrom + 1; const next = playFrom + 1;
if (next >= steps.length) { if (next >= stepsRef.current.length) {
if (loop) { if (loopRef.current) {
scheduleStep(0); scheduleStep(0);
} else { } else {
stopPlayback(); setPlaying(false);
setPaused(false);
setCurrentStep(-1);
playbackRef.current = null;
} }
} else { } else {
scheduleStep(next); scheduleStep(next);
} }
}, stepDelay); }, delay);
playbackRef.current = { timer, stepIndex: playFrom }; playbackRef.current = { timer, stepIndex: playFrom };
}, }, []); // no deps — reads everything from refs
[steps, stepDelay, effectiveBeat, loop, stopPlayback]
);
const handlePlay = () => { const handlePlay = () => {
if (!steps.length) return; if (!stepsRef.current.length) return;
if (paused && playbackRef.current) { if (paused && playbackRef.current) {
// Resume from current step
setPaused(false); setPaused(false);
setPlaying(true); setPlaying(true);
scheduleStep(playbackRef.current.stepIndex); scheduleStep(playbackRef.current.stepIndex);
@@ -218,14 +267,34 @@ export default function SpeedCalculatorModal({ open, melody, onClose, onSaved })
setPlaying(false); setPlaying(false);
}; };
const handleStop = () => {
stopPlayback();
};
const handleStepsParse = () => { const handleStepsParse = () => {
const parsed = parseStepsString(stepsInput); const parsed = parseStepsString(stepsInput);
setSteps(parsed); setSteps(parsed);
stepsRef.current = parsed;
stopPlayback(); stopPlayback();
setBinaryLoadError("");
};
const handleLoadFromBinary = async () => {
if (!builtMelody?.binary_url) return;
setLoadingBinary(true);
setBinaryLoadError("");
try {
const decoded = await decodeBsmBinary(`/api${builtMelody.binary_url}`);
setSteps(decoded);
stepsRef.current = decoded;
stopPlayback();
// Also reconstruct a human-readable steps string for the textarea
const stepsStr = decoded.map((v) => {
if (!v) return "0";
return getActiveBells(v).join("+");
}).join(",");
setStepsInput(stepsStr);
} catch (err) {
setBinaryLoadError(err.message);
} finally {
setLoadingBinary(false);
}
}; };
const handleSetSliderToCapture = (value) => { const handleSetSliderToCapture = (value) => {
@@ -233,7 +302,7 @@ export default function SpeedCalculatorModal({ open, melody, onClose, onSaved })
}; };
const handleSave = async () => { const handleSave = async () => {
if (!capturedMax || !derivedMin) return; if (!capturedMax || !capturedNormal || !derivedMin) return;
setSaving(true); setSaving(true);
setSaveError(""); setSaveError("");
setSaveSuccess(false); setSaveSuccess(false);
@@ -251,9 +320,7 @@ export default function SpeedCalculatorModal({ open, melody, onClose, onSaved })
pid: melody.pid, pid: melody.pid,
}); });
setSaveSuccess(true); setSaveSuccess(true);
setTimeout(() => { setTimeout(() => onSaved(), 800);
onSaved();
}, 800);
} catch (err) { } catch (err) {
setSaveError(err.message); setSaveError(err.message);
} finally { } finally {
@@ -269,6 +336,7 @@ export default function SpeedCalculatorModal({ open, melody, onClose, onSaved })
return set; return set;
}, new Set()); }, new Set());
const currentBells = currentStep >= 0 ? getActiveBells(steps[currentStep] || 0) : []; const currentBells = currentStep >= 0 ? getActiveBells(steps[currentStep] || 0) : [];
const maxBell = allBellsUsed.size > 0 ? Math.max(...Array.from(allBellsUsed)) : 0;
return ( return (
<div <div
@@ -277,11 +345,18 @@ export default function SpeedCalculatorModal({ open, melody, onClose, onSaved })
onClick={(e) => e.target === e.currentTarget && !playing && onClose()} onClick={(e) => e.target === e.currentTarget && !playing && onClose()}
> >
<div <div
className="w-full max-w-2xl rounded-lg border shadow-xl" className="w-full rounded-lg border shadow-xl"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxHeight: "90vh", overflowY: "auto" }} style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
maxWidth: "900px",
maxHeight: "92vh",
display: "flex",
flexDirection: "column",
}}
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b" style={{ borderColor: "var(--border-primary)" }}> <div className="flex items-center justify-between px-6 py-4 border-b flex-shrink-0" style={{ borderColor: "var(--border-primary)" }}>
<div> <div>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Speed Calculator</h2> <h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Speed Calculator</h2>
<p className="text-xs mt-0.5" style={mutedStyle}> <p className="text-xs mt-0.5" style={mutedStyle}>
@@ -291,25 +366,45 @@ export default function SpeedCalculatorModal({ open, melody, onClose, onSaved })
<button onClick={() => { stopPlayback(); onClose(); }} className="text-xl leading-none" style={mutedStyle}>&times;</button> <button onClick={() => { stopPlayback(); onClose(); }} className="text-xl leading-none" style={mutedStyle}>&times;</button>
</div> </div>
<div className="p-6 space-y-5"> {/* Two-column body */}
<div className="flex-1 overflow-y-auto">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0", minHeight: 0 }}>
{/* LEFT COLUMN: Steps, visualizer, controls, sliders */}
<div className="p-6 space-y-5 border-r" style={{ borderColor: "var(--border-primary)" }}>
{/* Steps Input */} {/* Steps Input */}
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}> <div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium" style={labelStyle}>
Melody Steps Melody Steps
<span className="font-normal ml-2" style={mutedStyle}>(paste your step notation)</span>
</label> </label>
{builtMelody?.binary_url && (
<button
onClick={handleLoadFromBinary}
disabled={loadingBinary}
className="text-xs px-2 py-1 rounded transition-colors disabled:opacity-50"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
{loadingBinary ? "Loading..." : "Load from Binary"}
</button>
)}
</div>
{binaryLoadError && (
<p className="text-xs mb-1" style={{ color: "var(--danger-text)" }}>{binaryLoadError}</p>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<textarea <textarea
value={stepsInput} value={stepsInput}
onChange={(e) => setStepsInput(e.target.value)} onChange={(e) => setStepsInput(e.target.value)}
rows={2} rows={3}
placeholder="e.g. 1,2,2+1,1,2,3+1,0,3,2..." placeholder="e.g. 1,2,2+1,1,2,3+1,0,3,2..."
className="flex-1 px-3 py-2 rounded-md text-sm border" className="flex-1 px-3 py-2 rounded-md text-sm border"
style={{ fontFamily: "monospace", resize: "none" }} style={{ fontFamily: "monospace", resize: "none" }}
/> />
<button <button
onClick={handleStepsParse} onClick={handleStepsParse}
className="px-4 py-2 text-sm rounded-md self-start transition-colors" className="px-3 py-2 text-sm rounded-md self-start transition-colors"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }} style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
> >
Load Load
@@ -317,7 +412,7 @@ export default function SpeedCalculatorModal({ open, melody, onClose, onSaved })
</div> </div>
{totalSteps > 0 && ( {totalSteps > 0 && (
<p className="text-xs mt-1" style={{ color: "var(--success-text)" }}> <p className="text-xs mt-1" style={{ color: "var(--success-text)" }}>
{totalSteps} steps loaded &nbsp;·&nbsp; {allBellsUsed.size} unique bell{allBellsUsed.size !== 1 ? "s" : ""} used {totalSteps} steps &nbsp;·&nbsp; {allBellsUsed.size} bell{allBellsUsed.size !== 1 ? "s" : ""}
</p> </p>
)} )}
</div> </div>
@@ -325,23 +420,22 @@ export default function SpeedCalculatorModal({ open, melody, onClose, onSaved })
{/* Bell visualizer */} {/* Bell visualizer */}
{totalSteps > 0 && ( {totalSteps > 0 && (
<div> <div>
<p className="text-xs mb-2" style={mutedStyle}>Bell indicator (current step)</p> <p className="text-xs mb-2" style={mutedStyle}>
Bell indicator
{currentStep >= 0 && <span> &nbsp;·&nbsp; Step {currentStep + 1} / {totalSteps}</span>}
</p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{Array.from({ length: Math.max(...Array.from(allBellsUsed), 1) }, (_, i) => i + 1).map((b) => { {Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => {
const isActive = currentBells.includes(b); const isActive = currentBells.includes(b);
const isUsed = allBellsUsed.has(b); const isUsed = allBellsUsed.has(b);
return ( return (
<div <div
key={b} key={b}
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-all" className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all"
style={{ style={{
backgroundColor: isActive backgroundColor: isActive ? "var(--accent)" : isUsed ? "var(--bg-card-hover)" : "var(--bg-primary)",
? "var(--accent)"
: isUsed
? "var(--bg-card-hover)"
: "var(--bg-primary)",
color: isActive ? "var(--bg-primary)" : isUsed ? "var(--text-secondary)" : "var(--border-primary)", color: isActive ? "var(--bg-primary)" : isUsed ? "var(--text-secondary)" : "var(--border-primary)",
border: `2px solid ${isActive ? "var(--accent)" : isUsed ? "var(--border-primary)" : "var(--border-primary)"}`, border: `2px solid ${isActive ? "var(--accent)" : "var(--border-primary)"}`,
transform: isActive ? "scale(1.2)" : "scale(1)", transform: isActive ? "scale(1.2)" : "scale(1)",
}} }}
> >
@@ -350,16 +444,11 @@ export default function SpeedCalculatorModal({ open, melody, onClose, onSaved })
); );
})} })}
</div> </div>
{currentStep >= 0 && (
<p className="text-xs mt-1" style={mutedStyle}>
Step {currentStep + 1} / {totalSteps}
</p>
)}
</div> </div>
)} )}
{/* Playback Controls */} {/* Playback Controls */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
{!playing ? ( {!playing ? (
<button <button
onClick={handlePlay} onClick={handlePlay}
@@ -379,20 +468,25 @@ export default function SpeedCalculatorModal({ open, melody, onClose, onSaved })
</button> </button>
)} )}
<button <button
onClick={handleStop} onClick={stopPlayback}
disabled={!playing && !paused} disabled={!playing && !paused}
className="px-4 py-2 text-sm rounded-md disabled:opacity-40 transition-colors" className="px-4 py-2 text-sm rounded-md disabled:opacity-40 transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }} style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
> >
Stop Stop
</button> </button>
<label className="flex items-center gap-2 ml-2 text-sm cursor-pointer" style={labelStyle}> <label className="flex items-center gap-1.5 ml-1 text-sm cursor-pointer" style={labelStyle}>
<input type="checkbox" checked={loop} onChange={(e) => setLoop(e.target.checked)} className="h-4 w-4 rounded" /> <input
type="checkbox"
checked={loop}
onChange={(e) => { setLoop(e.target.checked); loopRef.current = e.target.checked; }}
className="h-4 w-4 rounded"
/>
Loop Loop
</label> </label>
</div> </div>
{/* Step Delay Slider */} {/* Step Delay Slider — inverted: right = fast (low ms) */}
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium" style={labelStyle}>Step Delay (Speed)</label> <label className="text-sm font-medium" style={labelStyle}>Step Delay (Speed)</label>
@@ -400,16 +494,16 @@ export default function SpeedCalculatorModal({ open, melody, onClose, onSaved })
</div> </div>
<input <input
type="range" type="range"
min="50" min={DELAY_MIN}
max="3000" max={DELAY_MAX}
step="10" step="10"
value={stepDelay} value={delayToSlider(stepDelay)}
onChange={(e) => setStepDelay(Number(e.target.value))} onChange={(e) => setStepDelay(sliderToDelay(Number(e.target.value)))}
className="w-full h-2 rounded-lg appearance-none cursor-pointer" className="w-full h-2 rounded-lg appearance-none cursor-pointer"
/> />
<div className="flex justify-between text-xs mt-0.5" style={mutedStyle}> <div className="flex justify-between text-xs mt-0.5" style={mutedStyle}>
<span>50ms (fastest)</span> <span>Slow (3000ms)</span>
<span>3000ms (slowest)</span> <span>Fast (50ms)</span>
</div> </div>
</div> </div>
@@ -436,109 +530,115 @@ export default function SpeedCalculatorModal({ open, melody, onClose, onSaved })
<span>500ms (long)</span> <span>500ms (long)</span>
</div> </div>
</div> </div>
</div>
{/* Speed Capture Panel */} {/* RIGHT COLUMN: Speed Capture */}
<div className="rounded-lg border p-4 space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}> <div className="p-6">
<div className="rounded-lg border p-4 space-y-4 h-full" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
<div>
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Speed Capture</h3> <h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Speed Capture</h3>
<p className="text-xs" style={mutedStyle}> <p className="text-xs mt-1" style={mutedStyle}>
Find the fastest speed the controller can handle (MAX), then find the speed that feels "normal" (Normal). Play and find the fastest speed the controller can handle (MAX). Then find a speed that feels natural (Normal). MIN is auto-derived so that 50% slider = Normal.
MIN is auto-calculated so that 50% = Normal speed.
</p> </p>
</div>
{/* MAX */} {/* MAX */}
<div className="flex items-center gap-3"> <div className="rounded-md p-3 border" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-card)" }}>
<div className="flex-1"> <span className="text-xs font-semibold uppercase tracking-wide block mb-2" style={{ color: "var(--text-muted)" }}>MAX fastest safe</span>
<span className="text-xs font-medium block mb-0.5" style={labelStyle}>MAX (fastest safe)</span> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <span className="text-2xl font-bold" style={{ color: capturedMax !== null ? "var(--accent)" : "var(--text-muted)" }}>
<span className="text-lg font-bold w-20" style={{ color: capturedMax ? "var(--accent)" : "var(--text-muted)" }}>
{capturedMax !== null ? `${capturedMax} ms` : "—"} {capturedMax !== null ? `${capturedMax} ms` : "—"}
</span> </span>
<div className="flex gap-2">
<button <button
onClick={() => handleSetSliderToCapture(capturedMax)} onClick={() => handleSetSliderToCapture(capturedMax)}
disabled={capturedMax === null} disabled={capturedMax === null}
className="px-2 py-1 text-xs rounded disabled:opacity-40 transition-colors" className="px-2 py-1 text-xs rounded disabled:opacity-40 transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }} style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
title="Load this value to slider" title="Set slider to this value"
> >
Set Set
</button> </button>
</div>
</div>
<button <button
onClick={() => setCapturedMax(stepDelay)} onClick={() => setCapturedMax(stepDelay)}
className="px-3 py-2 text-xs rounded-md transition-colors font-medium" className="px-3 py-1.5 text-xs rounded-md transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }} style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
> >
Capture Capture
</button> </button>
</div> </div>
</div>
</div>
{/* Normal */} {/* Normal */}
<div className="flex items-center gap-3"> <div className="rounded-md p-3 border" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-card)" }}>
<div className="flex-1"> <span className="text-xs font-semibold uppercase tracking-wide block mb-2" style={{ color: "var(--text-muted)" }}>Normal at 50% speed</span>
<span className="text-xs font-medium block mb-0.5" style={labelStyle}>Normal (50% speed feel)</span> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <span className="text-2xl font-bold" style={{ color: capturedNormal !== null ? "var(--accent)" : "var(--text-muted)" }}>
<span className="text-lg font-bold w-20" style={{ color: capturedNormal ? "var(--accent)" : "var(--text-muted)" }}>
{capturedNormal !== null ? `${capturedNormal} ms` : "—"} {capturedNormal !== null ? `${capturedNormal} ms` : "—"}
</span> </span>
<div className="flex gap-2">
<button <button
onClick={() => handleSetSliderToCapture(capturedNormal)} onClick={() => handleSetSliderToCapture(capturedNormal)}
disabled={capturedNormal === null} disabled={capturedNormal === null}
className="px-2 py-1 text-xs rounded disabled:opacity-40 transition-colors" className="px-2 py-1 text-xs rounded disabled:opacity-40 transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }} style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
title="Load this value to slider" title="Set slider to this value"
> >
Set Set
</button> </button>
</div>
</div>
<button <button
onClick={() => setCapturedNormal(stepDelay)} onClick={() => setCapturedNormal(stepDelay)}
className="px-3 py-2 text-xs rounded-md transition-colors font-medium" className="px-3 py-1.5 text-xs rounded-md transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }} style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
> >
Capture Capture
</button> </button>
</div> </div>
</div>
</div>
{/* MIN (auto-calculated) */} {/* MIN (derived) */}
<div className="pt-1 border-t" style={{ borderColor: "var(--border-primary)" }}> <div className="rounded-md p-3 border" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-card)" }}>
<span className="text-xs font-medium block mb-0.5" style={mutedStyle}>MIN (auto-calculated)</span> <span className="text-xs font-semibold uppercase tracking-wide block mb-1" style={{ color: "var(--text-muted)" }}>
<span className="text-lg font-bold" style={{ color: derivedMin ? "var(--text-secondary)" : "var(--text-muted)" }}> MIN auto-calculated
{derivedMin !== null && <span className="font-normal ml-1">(normal² / max)</span>}
</span>
<span className="text-2xl font-bold" style={{ color: derivedMin !== null ? "var(--text-secondary)" : "var(--text-muted)" }}>
{derivedMin !== null ? `${derivedMin} ms` : "—"} {derivedMin !== null ? `${derivedMin} ms` : "—"}
</span> </span>
{derivedMin !== null && (
<span className="text-xs ml-2" style={mutedStyle}>(normal² / max)</span>
)}
</div> </div>
{/* Warnings */} {/* Warnings */}
{maxWarning && ( {maxWarning && (
<div className="text-xs rounded p-2" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}> <div className="text-xs rounded p-2" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>
Warning: MAX speed of {capturedMax}ms is very fast (&lt;100ms). This could damage the device. Warning: MAX of {capturedMax}ms is very fast (&lt;100ms). This could damage the device.
</div> </div>
)} )}
{orderWarning && ( {orderWarning && (
<div className="text-xs rounded p-2" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}> <div className="text-xs rounded p-2" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>
Warning: Normal speed ({capturedNormal}ms) is slower than MAX ({capturedMax}ms). Please check your values. Warning: Normal ({capturedNormal}ms) is faster than MAX ({capturedMax}ms). Normal should be slower (higher ms). Please re-capture.
</div> </div>
)} )}
</div>
{/* Save */} {/* Save messages */}
{saveError && ( {saveError && (
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}> <div className="text-xs rounded-md p-2 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{saveError} {saveError}
</div> </div>
)} )}
{saveSuccess && ( {saveSuccess && (
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--success-bg)", borderColor: "var(--success)", color: "var(--success-text)" }}> <div className="text-xs rounded-md p-2 border" style={{ backgroundColor: "var(--success-bg)", borderColor: "var(--success)", color: "var(--success-text)" }}>
Speeds saved to melody! Speeds saved to melody!
</div> </div>
)} )}
</div> </div>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t" style={{ borderColor: "var(--border-primary)" }}> {/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t flex-shrink-0" style={{ borderColor: "var(--border-primary)" }}>
<button <button
onClick={() => { stopPlayback(); onClose(); }} onClick={() => { stopPlayback(); onClose(); }}
className="px-4 py-2 text-sm rounded-md transition-colors" className="px-4 py-2 text-sm rounded-md transition-colors"