- {existingFiles.binary_url && (
Current file uploaded. Selecting a new file will replace it.
)}
+ {existingFiles.binary_url && (
+
+ {assignedBinaryName
+ ? <>{assignedBinaryName}.bsm is set. Selecting a new file will replace it.>
+ : "Current file uploaded. Selecting a new file will replace it."}
+
+ )}
setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
{isEdit && (
@@ -641,8 +645,10 @@ export default function MelodyForm() {
open={showSelectBuilt}
melodyId={id}
onClose={() => setShowSelectBuilt(false)}
- onSuccess={() => {
+ onSuccess={(archetype) => {
setShowSelectBuilt(false);
+ setAssignedBinaryName(archetype.name);
+ if (!pid.trim() && archetype.pid) setPid(archetype.pid);
loadMelody();
}}
/>
@@ -652,8 +658,10 @@ export default function MelodyForm() {
defaultName={getLocalizedValue(information.name, "en", "") || getLocalizedValue(information.name, editLang, "")}
defaultPid={pid}
onClose={() => setShowBuildOnTheFly(false)}
- onSuccess={() => {
+ onSuccess={(archetype) => {
setShowBuildOnTheFly(false);
+ setAssignedBinaryName(archetype.name);
+ if (!pid.trim() && archetype.pid) setPid(archetype.pid);
loadMelody();
}}
/>
diff --git a/frontend/src/melodies/PlaybackModal.jsx b/frontend/src/melodies/PlaybackModal.jsx
new file mode 100644
index 0000000..f01fc0c
--- /dev/null
+++ b/frontend/src/melodies/PlaybackModal.jsx
@@ -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 (
+
e.target === e.currentTarget && !playing && onClose()}
+ >
+
+ {/* Header */}
+
+
+
+ Melody Playback
+
+
+ {melody?.information?.name?.en || "Melody"} — looping
+
+
+
+
+
+ {/* Body */}
+
+ {/* Loading / error states */}
+ {loading && (
+
Loading binary...
+ )}
+ {loadError && (
+
+ {loadError}
+
+ )}
+
+ {!loading && !loadError && totalSteps > 0 && (
+ <>
+ {/* Step info */}
+
+
+ {totalSteps} steps · {allBellsUsed.size} bell{allBellsUsed.size !== 1 ? "s" : ""}
+
+ {currentStep >= 0 && (
+
+ Step {currentStep + 1} / {totalSteps}
+
+ )}
+
+
+ {/* Bell visualizer */}
+
+ {Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => {
+ const isActive = currentBells.includes(b);
+ const isUsed = allBellsUsed.has(b);
+ return (
+
+ {b}
+
+ );
+ })}
+
+
+ {/* Play / Stop */}
+
+ {!playing ? (
+
+ ) : (
+
+ )}
+ Loops continuously
+
+
+ {/* Speed Slider */}
+
+
+
+
+
+ {speedPercent}%
+
+ {hasSpeedInfo && (
+
+ ({speedMs} ms)
+
+ )}
+
+
+
setSpeedPercent(Number(e.target.value))}
+ className="w-full h-2 rounded-lg appearance-none cursor-pointer"
+ />
+
+ 1% (slowest)
+ 100% (fastest)
+
+ {!hasSpeedInfo && (
+
+ No MIN/MAX speed set for this melody — using linear fallback.
+
+ )}
+
+ >
+ )}
+
+
+ {/* Footer */}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/melodies/builder/BuildOnTheFlyModal.jsx b/frontend/src/melodies/builder/BuildOnTheFlyModal.jsx
index 740a1f7..2a95071 100644
--- a/frontend/src/melodies/builder/BuildOnTheFlyModal.jsx
+++ b/frontend/src/melodies/builder/BuildOnTheFlyModal.jsx
@@ -58,7 +58,7 @@ export default function BuildOnTheFlyModal({ open, melodyId, defaultName, defaul
await api.post(`/builder/melodies/${builtId}/assign?firestore_melody_id=${melodyId}`);
setStatusMsg("Done!");
- onSuccess();
+ onSuccess({ name: name.trim(), pid: pid.trim() });
} catch (err) {
setError(err.message);
setStatusMsg("");
diff --git a/frontend/src/melodies/builder/BuilderForm.jsx b/frontend/src/melodies/builder/BuilderForm.jsx
index 15c36c2..8714c9d 100644
--- a/frontend/src/melodies/builder/BuilderForm.jsx
+++ b/frontend/src/melodies/builder/BuilderForm.jsx
@@ -12,6 +12,21 @@ function countSteps(stepsStr) {
return stepsStr.trim().split(",").length;
}
+async function downloadBinary(binaryUrl, filename) {
+ const token = localStorage.getItem("access_token");
+ const res = await fetch(`/api${binaryUrl}`, {
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ });
+ if (!res.ok) throw new Error(`Download failed: ${res.statusText}`);
+ const blob = await res.blob();
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
export default function BuilderForm() {
const { id } = useParams();
const isEdit = Boolean(id);
@@ -229,13 +244,14 @@ export default function BuilderForm() {
{buildingBinary ? "Building..." : binaryBuilt ? "Rebuild Binary" : "Build Binary"}
{binaryUrl && (
-
downloadBinary(binaryUrl, `${name}.bsm`).catch((e) => setError(e.message))}
+ className="block w-full text-center text-xs underline cursor-pointer"
+ style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
>
Download {name}.bsm
-
+
)}
diff --git a/frontend/src/melodies/builder/BuilderList.jsx b/frontend/src/melodies/builder/BuilderList.jsx
index 8d02b24..ac8dbcc 100644
--- a/frontend/src/melodies/builder/BuilderList.jsx
+++ b/frontend/src/melodies/builder/BuilderList.jsx
@@ -3,6 +3,55 @@ import { useNavigate } from "react-router-dom";
import api from "../../api/client";
import ConfirmDialog from "../../components/ConfirmDialog";
+function CodeSnippetModal({ melody, onClose }) {
+ const [copied, setCopied] = useState(false);
+ if (!melody) return null;
+ return (
+
e.target === e.currentTarget && onClose()}
+ >
+
+
+
+
Firmware Code Snippet
+
{melody.name} · PID: {melody.pid || "—"}
+
+
+
+
+
+
+
+ {melody.progmem_code}
+
+
+
+ );
+}
+
const sectionStyle = { backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" };
export default function BuilderList() {
@@ -11,6 +60,8 @@ export default function BuilderList() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [deleteTarget, setDeleteTarget] = useState(null);
+ const [deleteWarningConfirmed, setDeleteWarningConfirmed] = useState(false);
+ const [codeSnippetMelody, setCodeSnippetMelody] = useState(null);
useEffect(() => {
loadMelodies();
@@ -46,6 +97,22 @@ export default function BuilderList() {
return stepsStr.split(",").length;
};
+ const handleDownloadBinary = async (e, m) => {
+ e.stopPropagation();
+ const token = localStorage.getItem("access_token");
+ const res = await fetch(`/api/builder/melodies/${m.id}/download`, {
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ });
+ if (!res.ok) return;
+ const blob = await res.blob();
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `${m.name || m.id}.bsm`;
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
return (
@@ -54,7 +121,7 @@ export default function BuilderList() {
Archetype Builder
- Build binary (.bsm) files and firmware PROGMEM code from melody step notation.
+ Add Archetypes, and Build binary (.bsm) files and firmware PROGMEM code.