improvemtns again, to the archetype builder, and playback

This commit is contained in:
2026-02-22 15:03:23 +02:00
parent 99c7004ac2
commit c5ef4406f6
9 changed files with 179 additions and 29 deletions

View File

@@ -28,6 +28,7 @@ class MelodyInfo(BaseModel):
color: str = ""
isTrueRing: bool = False
previewURL: str = ""
archetype_csv: Optional[str] = None
class MelodyAttributes(BaseModel):

View File

@@ -5,6 +5,25 @@ import { useAuth } from "../auth/AuthContext";
import ConfirmDialog from "../components/ConfirmDialog";
import SpeedCalculatorModal from "./SpeedCalculatorModal";
import PlaybackModal from "./PlaybackModal";
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);
}
}
import {
getLocalizedValue,
getLanguageName,
@@ -415,12 +434,7 @@ export default function MelodyDetail() {
</p>
</div>
<button
onClick={() => {
navigator.clipboard.writeText(builtMelody.progmem_code).then(() => {
setCodeCopied(true);
setTimeout(() => setCodeCopied(false), 2000);
});
}}
onClick={() => copyText(builtMelody.progmem_code, () => { setCodeCopied(true); setTimeout(() => setCodeCopied(false), 2000); })}
className="px-3 py-1.5 text-xs rounded transition-colors"
style={{
backgroundColor: codeCopied ? "var(--success-bg)" : "var(--bg-card-hover)",
@@ -453,6 +467,7 @@ export default function MelodyDetail() {
melody={melody}
builtMelody={builtMelody}
files={files}
archetypeCsv={melody?.information?.archetype_csv || null}
onClose={() => setShowPlayback(false)}
/>
@@ -460,6 +475,7 @@ export default function MelodyDetail() {
open={showSpeedCalc}
melody={melody}
builtMelody={builtMelody}
archetypeCsv={melody?.information?.archetype_csv || null}
onClose={() => setShowSpeedCalc(false)}
onSaved={() => {
setShowSpeedCalc(false);

View File

@@ -638,12 +638,14 @@ export default function MelodyForm() {
open={showSpeedCalc}
melody={{ id, information, default_settings: settings, type, url, uid, pid }}
builtMelody={builtMelody}
archetypeCsv={information.archetype_csv || null}
onClose={() => setShowSpeedCalc(false)}
onSaved={() => { setShowSpeedCalc(false); loadMelody(); }}
/>
<SelectBuiltMelodyModal
open={showSelectBuilt}
melodyId={id}
currentMelody={{ information, default_settings: settings, type, url, uid, pid }}
onClose={() => setShowSelectBuilt(false)}
onSuccess={(archetype) => {
setShowSelectBuilt(false);
@@ -655,6 +657,7 @@ export default function MelodyForm() {
<BuildOnTheFlyModal
open={showBuildOnTheFly}
melodyId={id}
currentMelody={{ information, default_settings: settings, type, url, uid, pid }}
defaultName={getLocalizedValue(information.name, "en", "") || getLocalizedValue(information.name, editLang, "")}
defaultPid={pid}
onClose={() => setShowBuildOnTheFly(false)}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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("");

View File

@@ -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) {

View File

@@ -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 }) {
</div>
<div className="flex items-center gap-3">
<button
onClick={() => {
navigator.clipboard.writeText(melody.progmem_code).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
onClick={() => copyText(melody.progmem_code, () => { setCopied(true); setTimeout(() => setCopied(false), 2000); })}
className="px-3 py-1.5 text-xs rounded transition-colors"
style={{
backgroundColor: copied ? "var(--success-bg)" : "var(--bg-card-hover)",

View File

@@ -1,7 +1,20 @@
import { useState, useEffect } from "react";
import api from "../../api/client";
export default function SelectBuiltMelodyModal({ open, melodyId, 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 SelectBuiltMelodyModal({ open, melodyId, currentMelody, onClose, onSuccess }) {
const [melodies, setMelodies] = useState([]);
const [loading, setLoading] = useState(false);
const [assigning, setAssigning] = useState(null); // id of the one being assigned
@@ -44,7 +57,27 @@ export default function SelectBuiltMelodyModal({ open, melodyId, onClose, onSucc
// 3. Mark this built melody as assigned to this Firestore melody
await api.post(`/builder/melodies/${builtMelody.id}/assign?firestore_melody_id=${melodyId}`);
onSuccess({ name: builtMelody.name, pid: builtMelody.pid });
// 4. Update the melody's information with archetype_csv, steps, and totalNotes
const csv = builtMelody.steps || "";
const { steps, totalNotes } = computeStepsAndNotes(csv);
if (currentMelody && csv) {
const existingInfo = currentMelody.information || {};
await api.put(`/melodies/${melodyId}`, {
information: {
...existingInfo,
archetype_csv: csv,
steps,
totalNotes,
},
default_settings: currentMelody.default_settings,
type: currentMelody.type,
url: currentMelody.url,
uid: currentMelody.uid,
pid: currentMelody.pid,
});
}
onSuccess({ name: builtMelody.name, pid: builtMelody.pid, steps, totalNotes, archetype_csv: csv });
} catch (err) {
setError(err.message);
} finally {