First Fixes/Adjustments to the MelodyBuilder and SpeedCalc
This commit is contained in:
@@ -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:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
0
backend/mqtt_data.db
Normal 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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = ensureAudioCtx();
|
// End of sequence without loop
|
||||||
const stepValue = steps[playFrom];
|
if (!loopRef.current && playFrom === 0 && stepIndex > 0) {
|
||||||
|
setPlaying(false);
|
||||||
|
setPaused(false);
|
||||||
|
setCurrentStep(-1);
|
||||||
|
playbackRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setCurrentStep(playFrom);
|
const ctx = ensureAudioCtx();
|
||||||
playStep(ctx, stepValue, effectiveBeat);
|
const stepValue = currentSteps[playFrom];
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
setCurrentStep(playFrom);
|
||||||
const next = playFrom + 1;
|
playStep(ctx, stepValue, effectiveBeatRef.current);
|
||||||
if (next >= steps.length) {
|
|
||||||
if (loop) {
|
const delay = stepDelayRef.current;
|
||||||
scheduleStep(0);
|
const timer = setTimeout(() => {
|
||||||
} else {
|
const next = playFrom + 1;
|
||||||
stopPlayback();
|
if (next >= stepsRef.current.length) {
|
||||||
}
|
if (loopRef.current) {
|
||||||
|
scheduleStep(0);
|
||||||
} else {
|
} else {
|
||||||
scheduleStep(next);
|
setPlaying(false);
|
||||||
|
setPaused(false);
|
||||||
|
setCurrentStep(-1);
|
||||||
|
playbackRef.current = null;
|
||||||
}
|
}
|
||||||
}, stepDelay);
|
} else {
|
||||||
|
scheduleStep(next);
|
||||||
|
}
|
||||||
|
}, 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,254 +366,279 @@ export default function SpeedCalculatorModal({ open, melody, onClose, onSaved })
|
|||||||
<button onClick={() => { stopPlayback(); onClose(); }} className="text-xl leading-none" style={mutedStyle}>×</button>
|
<button onClick={() => { stopPlayback(); onClose(); }} className="text-xl leading-none" style={mutedStyle}>×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 space-y-5">
|
{/* Two-column body */}
|
||||||
{/* Steps Input */}
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0", minHeight: 0 }}>
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>
|
|
||||||
Melody Steps
|
|
||||||
<span className="font-normal ml-2" style={mutedStyle}>(paste your step notation)</span>
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<textarea
|
|
||||||
value={stepsInput}
|
|
||||||
onChange={(e) => setStepsInput(e.target.value)}
|
|
||||||
rows={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"
|
|
||||||
style={{ fontFamily: "monospace", resize: "none" }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleStepsParse}
|
|
||||||
className="px-4 py-2 text-sm rounded-md self-start transition-colors"
|
|
||||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
|
||||||
>
|
|
||||||
Load
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{totalSteps > 0 && (
|
|
||||||
<p className="text-xs mt-1" style={{ color: "var(--success-text)" }}>
|
|
||||||
{totalSteps} steps loaded · {allBellsUsed.size} unique bell{allBellsUsed.size !== 1 ? "s" : ""} used
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bell visualizer */}
|
{/* LEFT COLUMN: Steps, visualizer, controls, sliders */}
|
||||||
{totalSteps > 0 && (
|
<div className="p-6 space-y-5 border-r" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
<div>
|
|
||||||
<p className="text-xs mb-2" style={mutedStyle}>Bell indicator (current step)</p>
|
{/* Steps Input */}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div>
|
||||||
{Array.from({ length: Math.max(...Array.from(allBellsUsed), 1) }, (_, i) => i + 1).map((b) => {
|
<div className="flex items-center justify-between mb-1">
|
||||||
const isActive = currentBells.includes(b);
|
<label className="text-sm font-medium" style={labelStyle}>
|
||||||
const isUsed = allBellsUsed.has(b);
|
Melody Steps
|
||||||
return (
|
</label>
|
||||||
<div
|
{builtMelody?.binary_url && (
|
||||||
key={b}
|
<button
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-all"
|
onClick={handleLoadFromBinary}
|
||||||
style={{
|
disabled={loadingBinary}
|
||||||
backgroundColor: isActive
|
className="text-xs px-2 py-1 rounded transition-colors disabled:opacity-50"
|
||||||
? "var(--accent)"
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
|
||||||
: 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)" : isUsed ? "var(--border-primary)" : "var(--border-primary)"}`,
|
|
||||||
transform: isActive ? "scale(1.2)" : "scale(1)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{b}
|
{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">
|
||||||
|
<textarea
|
||||||
|
value={stepsInput}
|
||||||
|
onChange={(e) => setStepsInput(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
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"
|
||||||
|
style={{ fontFamily: "monospace", resize: "none" }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleStepsParse}
|
||||||
|
className="px-3 py-2 text-sm rounded-md self-start transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
Load
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{totalSteps > 0 && (
|
||||||
|
<p className="text-xs mt-1" style={{ color: "var(--success-text)" }}>
|
||||||
|
{totalSteps} steps · {allBellsUsed.size} bell{allBellsUsed.size !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bell visualizer */}
|
||||||
|
{totalSteps > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs mb-2" style={mutedStyle}>
|
||||||
|
Bell indicator
|
||||||
|
{currentStep >= 0 && <span> · Step {currentStep + 1} / {totalSteps}</span>}
|
||||||
|
</p>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Playback Controls */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!playing ? (
|
||||||
|
<button
|
||||||
|
onClick={handlePlay}
|
||||||
|
disabled={!totalSteps}
|
||||||
|
className="px-4 py-2 text-sm rounded-md disabled:opacity-40 transition-colors font-medium"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
{paused ? "Resume" : "Play"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handlePause}
|
||||||
|
className="px-4 py-2 text-sm rounded-md transition-colors font-medium"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
Pause
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={stopPlayback}
|
||||||
|
disabled={!playing && !paused}
|
||||||
|
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)" }}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<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); loopRef.current = e.target.checked; }}
|
||||||
|
className="h-4 w-4 rounded"
|
||||||
|
/>
|
||||||
|
Loop
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Delay Slider — inverted: right = fast (low ms) */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="text-sm font-medium" style={labelStyle}>Step Delay (Speed)</label>
|
||||||
|
<span className="text-sm font-bold" style={{ color: "var(--accent)" }}>{stepDelay} ms</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={DELAY_MIN}
|
||||||
|
max={DELAY_MAX}
|
||||||
|
step="10"
|
||||||
|
value={delayToSlider(stepDelay)}
|
||||||
|
onChange={(e) => setStepDelay(sliderToDelay(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>Slow (3000ms)</span>
|
||||||
|
<span>Fast (50ms)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Beat Duration Slider */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="text-sm font-medium" style={labelStyle}>Tone Length</label>
|
||||||
|
<span className="text-sm" style={mutedStyle}>
|
||||||
|
{effectiveBeat} ms
|
||||||
|
{effectiveBeat < beatDuration && <span style={{ color: "var(--warning, #f59e0b)" }}> (capped)</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="20"
|
||||||
|
max="500"
|
||||||
|
step="5"
|
||||||
|
value={beatDuration}
|
||||||
|
onChange={(e) => setBeatDuration(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>20ms (short)</span>
|
||||||
|
<span>500ms (long)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT COLUMN: Speed Capture */}
|
||||||
|
<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>
|
||||||
|
<p className="text-xs mt-1" style={mutedStyle}>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MAX */}
|
||||||
|
<div className="rounded-md p-3 border" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-card)" }}>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide block mb-2" style={{ color: "var(--text-muted)" }}>MAX — fastest safe</span>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-2xl font-bold" style={{ color: capturedMax !== null ? "var(--accent)" : "var(--text-muted)" }}>
|
||||||
|
{capturedMax !== null ? `${capturedMax} ms` : "—"}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSetSliderToCapture(capturedMax)}
|
||||||
|
disabled={capturedMax === null}
|
||||||
|
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)" }}
|
||||||
|
title="Set slider to this value"
|
||||||
|
>
|
||||||
|
Set ↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCapturedMax(stepDelay)}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md transition-colors font-medium"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
Capture
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{currentStep >= 0 && (
|
|
||||||
<p className="text-xs mt-1" style={mutedStyle}>
|
|
||||||
Step {currentStep + 1} / {totalSteps}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Playback Controls */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{!playing ? (
|
|
||||||
<button
|
|
||||||
onClick={handlePlay}
|
|
||||||
disabled={!totalSteps}
|
|
||||||
className="px-4 py-2 text-sm rounded-md disabled:opacity-40 transition-colors font-medium"
|
|
||||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
|
||||||
>
|
|
||||||
{paused ? "Resume" : "Play"}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={handlePause}
|
|
||||||
className="px-4 py-2 text-sm rounded-md transition-colors font-medium"
|
|
||||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
|
|
||||||
>
|
|
||||||
Pause
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={handleStop}
|
|
||||||
disabled={!playing && !paused}
|
|
||||||
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)" }}
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
<label className="flex items-center gap-2 ml-2 text-sm cursor-pointer" style={labelStyle}>
|
|
||||||
<input type="checkbox" checked={loop} onChange={(e) => setLoop(e.target.checked)} className="h-4 w-4 rounded" />
|
|
||||||
Loop
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step Delay Slider */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<label className="text-sm font-medium" style={labelStyle}>Step Delay (Speed)</label>
|
|
||||||
<span className="text-sm font-bold" style={{ color: "var(--accent)" }}>{stepDelay} ms</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="50"
|
|
||||||
max="3000"
|
|
||||||
step="10"
|
|
||||||
value={stepDelay}
|
|
||||||
onChange={(e) => setStepDelay(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>50ms (fastest)</span>
|
|
||||||
<span>3000ms (slowest)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Beat Duration Slider */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<label className="text-sm font-medium" style={labelStyle}>Tone Length</label>
|
|
||||||
<span className="text-sm" style={mutedStyle}>
|
|
||||||
{effectiveBeat} ms
|
|
||||||
{effectiveBeat < beatDuration && <span style={{ color: "var(--warning, #f59e0b)" }}> (capped)</span>}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="20"
|
|
||||||
max="500"
|
|
||||||
step="5"
|
|
||||||
value={beatDuration}
|
|
||||||
onChange={(e) => setBeatDuration(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>20ms (short)</span>
|
|
||||||
<span>500ms (long)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Speed Capture Panel */}
|
|
||||||
<div className="rounded-lg border p-4 space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
|
|
||||||
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Speed Capture</h3>
|
|
||||||
<p className="text-xs" style={mutedStyle}>
|
|
||||||
Find the fastest speed the controller can handle (MAX), then find the speed that feels "normal" (Normal).
|
|
||||||
MIN is auto-calculated so that 50% = Normal speed.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* MAX */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<span className="text-xs font-medium block mb-0.5" style={labelStyle}>MAX (fastest safe)</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-lg font-bold w-20" style={{ color: capturedMax ? "var(--accent)" : "var(--text-muted)" }}>
|
|
||||||
{capturedMax !== null ? `${capturedMax} ms` : "—"}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSetSliderToCapture(capturedMax)}
|
|
||||||
disabled={capturedMax === null}
|
|
||||||
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)" }}
|
|
||||||
title="Load this value to slider"
|
|
||||||
>
|
|
||||||
Set ↑
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setCapturedMax(stepDelay)}
|
|
||||||
className="px-3 py-2 text-xs rounded-md transition-colors font-medium"
|
|
||||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
|
||||||
>
|
|
||||||
Capture
|
|
||||||
</button>
|
|
||||||
</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>
|
||||||
|
<button
|
||||||
|
onClick={() => setCapturedNormal(stepDelay)}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md transition-colors font-medium"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
Capture
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setCapturedNormal(stepDelay)}
|
|
||||||
className="px-3 py-2 text-xs rounded-md transition-colors font-medium"
|
|
||||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
|
||||||
>
|
|
||||||
Capture
|
|
||||||
</button>
|
|
||||||
</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 ? `${derivedMin} ms` : "—"}
|
{derivedMin !== null && <span className="font-normal ml-1">(normal² / max)</span>}
|
||||||
</span>
|
</span>
|
||||||
{derivedMin !== null && (
|
<span className="text-2xl font-bold" style={{ color: derivedMin !== null ? "var(--text-secondary)" : "var(--text-muted)" }}>
|
||||||
<span className="text-xs ml-2" style={mutedStyle}>(normal² / max)</span>
|
{derivedMin !== null ? `${derivedMin} ms` : "—"}
|
||||||
)}
|
</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 (<100ms). This could damage the device.
|
Warning: MAX of {capturedMax}ms is very fast (<100ms). This could damage the device.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{orderWarning && (
|
||||||
|
<div className="text-xs rounded p-2" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>
|
||||||
|
Warning: Normal ({capturedNormal}ms) is faster than MAX ({capturedMax}ms). Normal should be slower (higher ms). Please re-capture.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save messages */}
|
||||||
|
{saveError && (
|
||||||
|
<div className="text-xs rounded-md p-2 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{saveSuccess && (
|
||||||
|
<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!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{orderWarning && (
|
|
||||||
<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.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Save */}
|
|
||||||
{saveError && (
|
|
||||||
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
|
||||||
{saveError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{saveSuccess && (
|
|
||||||
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--success-bg)", borderColor: "var(--success)", color: "var(--success-text)" }}>
|
|
||||||
Speeds saved to melody!
|
|
||||||
</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"
|
||||||
|
|||||||
Reference in New Issue
Block a user