setShowBuildOnTheFly(false)}
diff --git a/frontend/src/melodies/PlaybackModal.jsx b/frontend/src/melodies/PlaybackModal.jsx
index f01fc0c..3e6b8e7 100644
--- a/frontend/src/melodies/PlaybackModal.jsx
+++ b/frontend/src/melodies/PlaybackModal.jsx
@@ -42,6 +42,22 @@ function getActiveBells(stepValue) {
return bells;
}
+function parseBellNotation(notation) {
+ notation = notation.trim();
+ if (notation === "0" || !notation) return 0;
+ let value = 0;
+ for (const part of notation.split("+")) {
+ const n = parseInt(part.trim(), 10);
+ if (!isNaN(n) && n >= 1 && n <= 16) value |= 1 << (n - 1);
+ }
+ return value;
+}
+
+function parseStepsString(stepsStr) {
+ if (!stepsStr || !stepsStr.trim()) return [];
+ return stepsStr.trim().split(",").map((s) => parseBellNotation(s));
+}
+
async function decodeBsmBinary(url) {
const token = localStorage.getItem("access_token");
const res = await fetch(url, {
@@ -81,7 +97,7 @@ 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 }) {
+export default function PlaybackModal({ open, melody, builtMelody, files, archetypeCsv, onClose }) {
const info = melody?.information || {};
const minSpeed = info.minSpeed || null;
const maxSpeed = info.maxSpeed || null;
@@ -115,7 +131,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, onClos
setCurrentStep(-1);
}, []);
- // Load binary on open
+ // Load steps on open — prefer archetype_csv, fall back to binary
useEffect(() => {
if (!open) {
stopPlayback();
@@ -126,14 +142,23 @@ export default function PlaybackModal({ open, melody, builtMelody, files, onClos
return;
}
- // builtMelody.binary_url is a relative path needing /api prefix;
- // files.binary_url from the /files endpoint is already a full URL path.
+ // Prefer CSV from archetype (no network request needed)
+ const csv = archetypeCsv || info.archetype_csv || null;
+ if (csv) {
+ const parsed = parseStepsString(csv);
+ setSteps(parsed);
+ stepsRef.current = parsed;
+ setLoadError("");
+ return;
+ }
+
+ // Fall back to binary fetch
const binaryUrl = builtMelody?.binary_url
? `/api${builtMelody.binary_url}`
: files?.binary_url || null;
if (!binaryUrl) {
- setLoadError("No binary file available for this melody.");
+ setLoadError("No binary or archetype data available for this melody.");
return;
}
diff --git a/frontend/src/melodies/SpeedCalculatorModal.jsx b/frontend/src/melodies/SpeedCalculatorModal.jsx
index 21970fd..6e98e0a 100644
--- a/frontend/src/melodies/SpeedCalculatorModal.jsx
+++ b/frontend/src/melodies/SpeedCalculatorModal.jsx
@@ -115,7 +115,7 @@ 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 }) {
+export default function SpeedCalculatorModal({ open, melody, builtMelody, archetypeCsv, onClose, onSaved }) {
const info = melody?.information || {};
// Raw steps input
@@ -164,19 +164,28 @@ export default function SpeedCalculatorModal({ open, melody, builtMelody, onClos
const maxWarning = capturedMax !== null && capturedMax < 100;
const orderWarning = capturedMax !== null && capturedNormal !== null && capturedNormal < capturedMax;
- // Reset on open
+ // Reset on open — auto-load archetype CSV if available
useEffect(() => {
if (open) {
setCapturedMax(info.maxSpeed > 0 ? info.maxSpeed : null);
setCapturedNormal(null);
- setStepsInput("");
- setSteps([]);
setBinaryLoadError("");
setCurrentStep(-1);
setPlaying(false);
setPaused(false);
setSaveError("");
setSaveSuccess(false);
+
+ const csv = archetypeCsv || info.archetype_csv || null;
+ if (csv) {
+ const parsed = parseStepsString(csv);
+ setStepsInput(csv);
+ setSteps(parsed);
+ stepsRef.current = parsed;
+ } else {
+ setStepsInput("");
+ setSteps([]);
+ }
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
diff --git a/frontend/src/melodies/builder/BuildOnTheFlyModal.jsx b/frontend/src/melodies/builder/BuildOnTheFlyModal.jsx
index 2a95071..da5a924 100644
--- a/frontend/src/melodies/builder/BuildOnTheFlyModal.jsx
+++ b/frontend/src/melodies/builder/BuildOnTheFlyModal.jsx
@@ -10,7 +10,20 @@ function countSteps(stepsStr) {
return stepsStr.trim().split(",").length;
}
-export default function BuildOnTheFlyModal({ open, melodyId, defaultName, defaultPid, onClose, onSuccess }) {
+function computeStepsAndNotes(stepsStr) {
+ if (!stepsStr || !stepsStr.trim()) return { steps: 0, totalNotes: 0 };
+ const tokens = stepsStr.trim().split(",");
+ const bellSet = new Set();
+ for (const token of tokens) {
+ for (const part of token.split("+")) {
+ const n = parseInt(part.trim(), 10);
+ if (!isNaN(n) && n >= 1 && n <= 16) bellSet.add(n);
+ }
+ }
+ return { steps: tokens.length, totalNotes: bellSet.size || 1 };
+}
+
+export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defaultName, defaultPid, onClose, onSuccess }) {
const [name, setName] = useState(defaultName || "");
const [pid, setPid] = useState(defaultPid || "");
const [steps, setSteps] = useState("");
@@ -57,8 +70,29 @@ export default function BuildOnTheFlyModal({ open, melodyId, defaultName, defaul
setStatusMsg("Linking to melody...");
await api.post(`/builder/melodies/${builtId}/assign?firestore_melody_id=${melodyId}`);
+ // Step 5: Update the melody's information with archetype_csv, steps, and totalNotes
+ setStatusMsg("Saving archetype data...");
+ const csv = steps.trim();
+ const { steps: stepCount, totalNotes } = computeStepsAndNotes(csv);
+ if (currentMelody && csv) {
+ const existingInfo = currentMelody.information || {};
+ await api.put(`/melodies/${melodyId}`, {
+ information: {
+ ...existingInfo,
+ archetype_csv: csv,
+ steps: stepCount,
+ totalNotes,
+ },
+ default_settings: currentMelody.default_settings,
+ type: currentMelody.type,
+ url: currentMelody.url,
+ uid: currentMelody.uid,
+ pid: currentMelody.pid,
+ });
+ }
+
setStatusMsg("Done!");
- onSuccess({ name: name.trim(), pid: pid.trim() });
+ onSuccess({ name: name.trim(), pid: pid.trim(), steps: stepCount, totalNotes, archetype_csv: csv });
} catch (err) {
setError(err.message);
setStatusMsg("");
diff --git a/frontend/src/melodies/builder/BuilderForm.jsx b/frontend/src/melodies/builder/BuilderForm.jsx
index 8714c9d..973f63e 100644
--- a/frontend/src/melodies/builder/BuilderForm.jsx
+++ b/frontend/src/melodies/builder/BuilderForm.jsx
@@ -7,6 +7,24 @@ const labelStyle = { color: "var(--text-secondary)" };
const mutedStyle = { color: "var(--text-muted)" };
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
+function copyText(text, onSuccess) {
+ const fallback = () => {
+ const ta = document.createElement("textarea");
+ ta.value = text;
+ ta.style.cssText = "position:fixed;top:0;left:0;opacity:0";
+ document.body.appendChild(ta);
+ ta.focus();
+ ta.select();
+ try { document.execCommand("copy"); onSuccess?.(); } catch (_) {}
+ document.body.removeChild(ta);
+ };
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(text).then(onSuccess).catch(fallback);
+ } else {
+ fallback();
+ }
+}
+
function countSteps(stepsStr) {
if (!stepsStr || !stepsStr.trim()) return 0;
return stepsStr.trim().split(",").length;
@@ -128,10 +146,7 @@ export default function BuilderForm() {
const handleCopy = () => {
if (!progmemCode) return;
- navigator.clipboard.writeText(progmemCode).then(() => {
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- });
+ copyText(progmemCode, () => { setCopied(true); setTimeout(() => setCopied(false), 2000); });
};
if (loading) {
diff --git a/frontend/src/melodies/builder/BuilderList.jsx b/frontend/src/melodies/builder/BuilderList.jsx
index ac8dbcc..db93361 100644
--- a/frontend/src/melodies/builder/BuilderList.jsx
+++ b/frontend/src/melodies/builder/BuilderList.jsx
@@ -3,6 +3,25 @@ import { useNavigate } from "react-router-dom";
import api from "../../api/client";
import ConfirmDialog from "../../components/ConfirmDialog";
+function fallbackCopy(text, onSuccess) {
+ const ta = document.createElement("textarea");
+ ta.value = text;
+ ta.style.cssText = "position:fixed;top:0;left:0;opacity:0";
+ document.body.appendChild(ta);
+ ta.focus();
+ ta.select();
+ try { document.execCommand("copy"); onSuccess?.(); } catch (_) {}
+ document.body.removeChild(ta);
+}
+
+function copyText(text, onSuccess) {
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(text).then(onSuccess).catch(() => fallbackCopy(text, onSuccess));
+ } else {
+ fallbackCopy(text, onSuccess);
+ }
+}
+
function CodeSnippetModal({ melody, onClose }) {
const [copied, setCopied] = useState(false);
if (!melody) return null;
@@ -23,12 +42,7 @@ function CodeSnippetModal({ melody, onClose }) {