More Archetype Fixes

This commit is contained in:
2026-02-22 18:16:24 +02:00
parent ae4b31328f
commit 02fdcc473f
10 changed files with 232 additions and 1204 deletions

View File

@@ -42,6 +42,44 @@ function Field({ label, children }) {
); );
} }
function UrlField({ label, value }) {
const [copied, setCopied] = useState(false);
return (
<div>
<dt className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
{label}
</dt>
<dd className="flex items-center gap-2">
<span
className="text-sm font-mono flex-1 min-w-0"
style={{
color: "var(--text-primary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "block",
userSelect: "text",
}}
title={value}
>
{value}
</span>
<button
onClick={() => copyText(value, () => { setCopied(true); setTimeout(() => setCopied(false), 2000); })}
className="flex-shrink-0 px-2 py-0.5 text-xs rounded transition-colors"
style={{
backgroundColor: copied ? "var(--success-bg)" : "var(--bg-card-hover)",
color: copied ? "var(--success-text)" : "var(--text-muted)",
border: "1px solid var(--border-primary)",
}}
>
{copied ? "Copied!" : "Copy"}
</button>
</dd>
</div>
);
}
export default function MelodyDetail() { export default function MelodyDetail() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -333,9 +371,11 @@ export default function MelodyDetail() {
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4"> <dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Document ID">{melody.id}</Field> <Field label="Document ID">{melody.id}</Field>
<Field label="PID (Playback ID)">{melody.pid}</Field> <Field label="PID (Playback ID)">{melody.pid}</Field>
<div className="col-span-2 md:col-span-3"> {melody.url && (
<Field label="URL">{melody.url}</Field> <div className="col-span-2 md:col-span-3">
</div> <UrlField label="URL" value={melody.url} />
</div>
)}
</dl> </dl>
</section> </section>
</div> </div>
@@ -384,7 +424,7 @@ export default function MelodyDetail() {
}} }}
> >
<span className="text-xs font-bold leading-tight" style={{ color: "var(--text-secondary)" }}> <span className="text-xs font-bold leading-tight" style={{ color: "var(--text-secondary)" }}>
{noteIdx + 1} {String.fromCharCode(65 + noteIdx)}
</span> </span>
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: "var(--border-primary)" }} /> <div className="w-full my-0.5" style={{ height: "1px", backgroundColor: "var(--border-primary)" }} />
<span className="text-xs leading-tight" style={{ color: "var(--text-muted)" }}> <span className="text-xs leading-tight" style={{ color: "var(--text-muted)" }}>
@@ -396,7 +436,7 @@ export default function MelodyDetail() {
) : ( ) : (
<span style={{ color: "var(--text-muted)" }}>-</span> <span style={{ color: "var(--text-muted)" }}>-</span>
)} )}
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>Top = Note #, Bottom = Assigned Bell</p> <p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>Top = Note, Bottom = Assigned Bell</p>
</dd> </dd>
</div> </div>
</dl> </dl>
@@ -432,9 +472,13 @@ export default function MelodyDetail() {
e.preventDefault(); e.preventDefault();
try { try {
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
const res = await fetch(binaryUrl, { let res = await fetch(binaryUrl, {
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}); });
// For external URLs (e.g. Firebase Storage), retry without auth header
if (!res.ok && binaryUrl.startsWith("http")) {
res = await fetch(binaryUrl);
}
if (!res.ok) throw new Error(`Download failed: ${res.statusText}`); if (!res.ok) throw new Error(`Download failed: ${res.statusText}`);
const blob = await res.blob(); const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);

View File

@@ -616,7 +616,7 @@ export default function MelodyForm() {
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2"> <div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
{Array.from({ length: information.totalNotes }, (_, i) => ( {Array.from({ length: information.totalNotes }, (_, i) => (
<div key={i}> <div key={i}>
<label className="block text-xs mb-0.5 text-left" style={mutedStyle}>Note #{i + 1}</label> <label className="block text-xs mb-0.5 text-left" style={mutedStyle}>Note #{String.fromCharCode(65 + i)}</label>
<input type="number" min="0" required value={settings.noteAssignments[i] ?? 0} <input type="number" min="0" required value={settings.noteAssignments[i] ?? 0}
onChange={(e) => { const na = [...settings.noteAssignments]; while (na.length <= i) na.push(0); na[i] = parseInt(e.target.value, 10) || 0; updateSettings("noteAssignments", na); }} onChange={(e) => { const na = [...settings.noteAssignments]; while (na.length <= i) na.push(0); na[i] = parseInt(e.target.value, 10) || 0; updateSettings("noteAssignments", na); }}
className="w-full px-2 py-1.5 rounded-md text-sm text-center border" className="w-full px-2 py-1.5 rounded-md text-sm text-center border"
@@ -789,6 +789,7 @@ export default function MelodyForm() {
open={showSelectBuilt} open={showSelectBuilt}
melodyId={id || savedMelodyId} melodyId={id || savedMelodyId}
currentMelody={{ information, default_settings: settings, type, url, uid, pid }} currentMelody={{ information, default_settings: settings, type, url, uid, pid }}
currentBuiltMelody={builtMelody}
onClose={() => setShowSelectBuilt(false)} onClose={() => setShowSelectBuilt(false)}
onSuccess={(archetype) => { onSuccess={(archetype) => {
setShowSelectBuilt(false); setShowSelectBuilt(false);
@@ -801,6 +802,7 @@ export default function MelodyForm() {
open={showBuildOnTheFly} open={showBuildOnTheFly}
melodyId={id || savedMelodyId} melodyId={id || savedMelodyId}
currentMelody={{ information, default_settings: settings, type, url, uid, pid }} currentMelody={{ information, default_settings: settings, type, url, uid, pid }}
currentBuiltMelody={builtMelody}
defaultName={getLocalizedValue(information.name, "en", "") || getLocalizedValue(information.name, editLang, "")} defaultName={getLocalizedValue(information.name, "en", "") || getLocalizedValue(information.name, editLang, "")}
defaultPid={pid} defaultPid={pid}
onClose={() => setShowBuildOnTheFly(false)} onClose={() => setShowBuildOnTheFly(false)}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
// ============================================================================ // ============================================================================
// Web Audio Engine (shared with SpeedCalculatorModal pattern) // Web Audio Engine
// ============================================================================ // ============================================================================
function bellFrequency(bellNumber) { function bellFrequency(bellNumber) {
@@ -34,14 +34,6 @@ function playStep(audioCtx, stepValue, beatDurationMs) {
} }
} }
function getActiveBells(stepValue) {
const bells = [];
for (let bit = 0; bit < 16; bit++) {
if (stepValue & (1 << bit)) bells.push(bit + 1);
}
return bells;
}
function parseBellNotation(notation) { function parseBellNotation(notation) {
notation = notation.trim(); notation = notation.trim();
if (notation === "0" || !notation) return 0; if (notation === "0" || !notation) return 0;
@@ -59,10 +51,27 @@ function parseStepsString(stepsStr) {
} }
async function decodeBsmBinary(url) { async function decodeBsmBinary(url) {
// Try with auth token first (for our API endpoints), then without (for Firebase URLs)
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
const res = await fetch(url, { let res = null;
headers: token ? { Authorization: `Bearer ${token}` } : {},
}); try {
res = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
} catch {
throw new Error("Failed to fetch binary: network error");
}
// If unauthorized and it looks like a Firebase URL, try without auth header
if (!res.ok && res.status === 401 && url.startsWith("http")) {
try {
res = await fetch(url);
} catch {
throw new Error("Failed to fetch binary: network error");
}
}
if (!res.ok) throw new Error(`Failed to fetch binary: ${res.statusText}`); if (!res.ok) throw new Error(`Failed to fetch binary: ${res.statusText}`);
const buf = await res.arrayBuffer(); const buf = await res.arrayBuffer();
const view = new DataView(buf); const view = new DataView(buf);
@@ -74,9 +83,7 @@ async function decodeBsmBinary(url) {
} }
// ============================================================================ // ============================================================================
// Speed math — exponential mapping matching the Flutter app: // Speed math — exponential mapping
// 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) { function mapPercentageToSpeed(percent, minSpeed, maxSpeed) {
@@ -88,20 +95,44 @@ function mapPercentageToSpeed(percent, minSpeed, maxSpeed) {
return Math.round(a * Math.pow(b / a, t)); return Math.round(a * Math.pow(b / a, t));
} }
// ============================================================================
// Apply note assignments: map archetype note bits → assigned bell bits
//
// The archetype steps encode which NOTES fire using bit flags (note 1 = bit 0,
// note 2 = bit 1, etc). noteAssignments[noteIdx] gives the bell number to fire
// for that note (0 = silence / no bell). We rebuild the step value using the
// assigned bells instead of the raw note numbers.
// ============================================================================
function applyNoteAssignments(rawStepValue, noteAssignments) {
if (!noteAssignments || noteAssignments.length === 0) return rawStepValue;
let result = 0;
for (let bit = 0; bit < 16; bit++) {
if (rawStepValue & (1 << bit)) {
const noteIdx = bit; // bit 0 = note 1, bit 1 = note 2, ...
const assignedBell = noteAssignments[noteIdx];
if (assignedBell && assignedBell > 0) {
result |= 1 << (assignedBell - 1);
}
// assignedBell === 0 means silence — do not set any bell bit
}
}
return result;
}
// ============================================================================ // ============================================================================
// Component // Component
// ============================================================================ // ============================================================================
const BEAT_DURATION_MS = 80; // fixed tone length for playback
const mutedStyle = { color: "var(--text-muted)" }; const mutedStyle = { color: "var(--text-muted)" };
const labelStyle = { color: "var(--text-secondary)" }; const labelStyle = { color: "var(--text-secondary)" };
const NOTE_LABELS = "ABCDEFGHIJKLMNOP";
export default function PlaybackModal({ open, melody, builtMelody, files, archetypeCsv, onClose }) { export default function PlaybackModal({ open, melody, builtMelody, files, archetypeCsv, onClose }) {
const info = melody?.information || {}; const info = melody?.information || {};
const minSpeed = info.minSpeed || null; const minSpeed = info.minSpeed || null;
const maxSpeed = info.maxSpeed || null; const maxSpeed = info.maxSpeed || null;
// Note assignments: maps note index → bell number to fire
const noteAssignments = melody?.default_settings?.noteAssignments || []; const noteAssignments = melody?.default_settings?.noteAssignments || [];
const [steps, setSteps] = useState([]); const [steps, setSteps] = useState([]);
@@ -111,29 +142,37 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [currentStep, setCurrentStep] = useState(-1); const [currentStep, setCurrentStep] = useState(-1);
const [speedPercent, setSpeedPercent] = useState(50); const [speedPercent, setSpeedPercent] = useState(50);
const [toneLengthMs, setToneLengthMs] = useState(80);
// activeBells: Set of bell numbers currently lit (for flash effect)
const [activeBells, setActiveBells] = useState(new Set());
const audioCtxRef = useRef(null); const audioCtxRef = useRef(null);
const playbackRef = useRef(null); const playbackRef = useRef(null);
const stepsRef = useRef([]); const stepsRef = useRef([]);
const speedMsRef = useRef(500); const speedMsRef = useRef(500);
const toneLengthRef = useRef(80);
const noteAssignmentsRef = useRef(noteAssignments);
// Derived speed in ms from the current percent
const speedMs = mapPercentageToSpeed(speedPercent, minSpeed, maxSpeed) ?? 500; const speedMs = mapPercentageToSpeed(speedPercent, minSpeed, maxSpeed) ?? 500;
// Keep refs in sync so the playback loop reads live values
useEffect(() => { stepsRef.current = steps; }, [steps]); useEffect(() => { stepsRef.current = steps; }, [steps]);
useEffect(() => { speedMsRef.current = speedMs; }, [speedMs]); useEffect(() => { speedMsRef.current = speedMs; }, [speedMs]);
useEffect(() => { toneLengthRef.current = toneLengthMs; }, [toneLengthMs]);
useEffect(() => { noteAssignmentsRef.current = noteAssignments; }, [noteAssignments]); // eslint-disable-line react-hooks/exhaustive-deps
const stopPlayback = useCallback(() => { const stopPlayback = useCallback(() => {
if (playbackRef.current) { if (playbackRef.current) {
clearTimeout(playbackRef.current.timer); clearTimeout(playbackRef.current.timer);
if (playbackRef.current.flashTimer) clearTimeout(playbackRef.current.flashTimer);
playbackRef.current = null; playbackRef.current = null;
} }
setPlaying(false); setPlaying(false);
setCurrentStep(-1); setCurrentStep(-1);
setActiveBells(new Set());
}, []); }, []);
// Load steps on open — prefer archetype_csv, fall back to binary // Load steps on open
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
stopPlayback(); stopPlayback();
@@ -141,10 +180,10 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
setCurrentStep(-1); setCurrentStep(-1);
setLoadError(""); setLoadError("");
setSpeedPercent(50); setSpeedPercent(50);
setActiveBells(new Set());
return; return;
} }
// Prefer CSV from archetype (no network request needed)
const csv = archetypeCsv || info.archetype_csv || null; const csv = archetypeCsv || info.archetype_csv || null;
if (csv) { if (csv) {
const parsed = parseStepsString(csv); const parsed = parseStepsString(csv);
@@ -154,7 +193,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
return; return;
} }
// Fall back to binary fetch — prefer uploaded file, then legacy melody.url // Fall back to binary
const binaryUrl = builtMelody?.binary_url const binaryUrl = builtMelody?.binary_url
? `/api${builtMelody.binary_url}` ? `/api${builtMelody.binary_url}`
: files?.binary_url || melody?.url || null; : files?.binary_url || melody?.url || null;
@@ -191,49 +230,36 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
const playFrom = stepIndex % currentSteps.length; const playFrom = stepIndex % currentSteps.length;
// End of sequence — loop back
if (playFrom === 0 && stepIndex > 0) {
scheduleStep(0);
return;
}
const ctx = ensureAudioCtx(); const ctx = ensureAudioCtx();
const rawStepValue = currentSteps[playFrom]; const rawStepValue = currentSteps[playFrom];
// Apply note assignments: each note in the step maps to an assigned bell number // Map archetype notes → assigned bells
// noteAssignments[noteIndex] = bellNumber (1-based). We rebuild the step value const stepValue = applyNoteAssignments(rawStepValue, noteAssignmentsRef.current);
// using assigned bells instead of the raw ones.
let stepValue = rawStepValue;
if (noteAssignments.length > 0) {
// Determine which notes (1-based) are active in this step
const activeNotes = [];
for (let bit = 0; bit < 16; bit++) {
if (rawStepValue & (1 << bit)) activeNotes.push(bit + 1);
}
// For each active note, look up the noteAssignment by note index (note-1)
// noteAssignments array is indexed by note position (0-based)
stepValue = 0;
for (const note of activeNotes) {
const assignedBell = noteAssignments[note - 1];
const bellToFire = (assignedBell && assignedBell > 0) ? assignedBell : note;
stepValue |= 1 << (bellToFire - 1);
}
}
setCurrentStep(playFrom); setCurrentStep(playFrom);
playStep(ctx, stepValue, BEAT_DURATION_MS);
// Flash active bells for tone length, then clear
const bellsNow = new Set();
for (let bit = 0; bit < 16; bit++) {
if (stepValue & (1 << bit)) bellsNow.add(bit + 1);
}
setActiveBells(bellsNow);
playStep(ctx, stepValue, toneLengthRef.current);
// Clear bell highlight after tone length
const flashTimer = setTimeout(() => {
setActiveBells(new Set());
}, toneLengthRef.current);
// Schedule next step after step interval
const timer = setTimeout(() => { const timer = setTimeout(() => {
const next = playFrom + 1; const next = playFrom + 1;
if (next >= stepsRef.current.length) { scheduleStep(next >= stepsRef.current.length ? 0 : next);
scheduleStep(0);
} else {
scheduleStep(next);
}
}, speedMsRef.current); }, speedMsRef.current);
playbackRef.current = { timer, stepIndex: playFrom }; playbackRef.current = { timer, flashTimer, stepIndex: playFrom };
}, []); // no deps — reads everything from refs }, []); // eslint-disable-line react-hooks/exhaustive-deps
const handlePlay = () => { const handlePlay = () => {
if (!stepsRef.current.length) return; if (!stepsRef.current.length) return;
@@ -248,12 +274,16 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
if (!open) return null; if (!open) return null;
const totalSteps = steps.length; const totalSteps = steps.length;
// Compute which bells are actually used (after assignment mapping)
const allBellsUsed = steps.reduce((set, v) => { const allBellsUsed = steps.reduce((set, v) => {
getActiveBells(v).forEach((b) => set.add(b)); const mapped = applyNoteAssignments(v, noteAssignments);
for (let bit = 0; bit < 16; bit++) {
if (mapped & (1 << bit)) set.add(bit + 1);
}
return set; return set;
}, new Set()); }, new Set());
const maxBell = allBellsUsed.size > 0 ? Math.max(...Array.from(allBellsUsed)) : 0; 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; const hasSpeedInfo = minSpeed != null && maxSpeed != null;
@@ -268,7 +298,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
style={{ style={{
backgroundColor: "var(--bg-card)", backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)", borderColor: "var(--border-primary)",
maxWidth: "460px", maxWidth: "480px",
}} }}
> >
{/* Header */} {/* Header */}
@@ -295,7 +325,6 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
{/* Body */} {/* Body */}
<div className="px-6 py-5 space-y-5"> <div className="px-6 py-5 space-y-5">
{/* Loading / error states */}
{loading && ( {loading && (
<p className="text-sm text-center py-4" style={mutedStyle}>Loading binary...</p> <p className="text-sm text-center py-4" style={mutedStyle}>Loading binary...</p>
)} )}
@@ -322,15 +351,15 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
)} )}
</div> </div>
{/* Note + Assignment visualizer */} {/* Note → Bell assignment visualizer (shows when assignments exist) */}
{noteAssignments.length > 0 ? ( {noteAssignments.length > 0 ? (
<div> <div>
<p className="text-xs mb-2" style={mutedStyle}>Note Assigned Bell</p> <p className="text-xs mb-2" style={mutedStyle}>Note Assigned Bell</p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{noteAssignments.map((assignedBell, noteIdx) => { {noteAssignments.map((assignedBell, noteIdx) => {
const noteNum = noteIdx + 1; // A note is active if the current step has this note's bit set (raw archetype value)
// A note is active if the current step has this note bit set (raw) const isActive = currentStep >= 0 && Boolean(steps[currentStep] & (1 << noteIdx));
const isActive = currentStep >= 0 && Boolean(steps[currentStep] & (1 << (noteNum - 1))); const firesABell = assignedBell && assignedBell > 0;
return ( return (
<div <div
key={noteIdx} key={noteIdx}
@@ -338,47 +367,56 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
style={{ style={{
minWidth: "36px", minWidth: "36px",
padding: "4px 6px", padding: "4px 6px",
backgroundColor: isActive ? "var(--accent)" : "var(--bg-card-hover)", backgroundColor: isActive && firesABell
? "var(--accent)"
: isActive && !firesABell
? "rgba(156,163,175,0.15)"
: "var(--bg-card-hover)",
borderColor: isActive ? "var(--accent)" : "var(--border-primary)", borderColor: isActive ? "var(--accent)" : "var(--border-primary)",
transform: isActive ? "scale(1.1)" : "scale(1)", transform: isActive && firesABell ? "scale(1.1)" : "scale(1)",
opacity: isActive && !firesABell ? 0.5 : 1,
}} }}
> >
<span className="text-xs font-bold leading-tight" style={{ color: isActive ? "var(--bg-primary)" : "var(--text-secondary)" }}> <span className="text-xs font-bold leading-tight" style={{ color: isActive && firesABell ? "var(--bg-primary)" : "var(--text-secondary)" }}>
{noteNum} {NOTE_LABELS[noteIdx]}
</span> </span>
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: isActive ? "rgba(255,255,255,0.4)" : "var(--border-primary)" }} /> <div className="w-full my-0.5" style={{ height: "1px", backgroundColor: isActive && firesABell ? "rgba(255,255,255,0.4)" : "var(--border-primary)" }} />
<span className="text-xs leading-tight" style={{ color: isActive ? "var(--bg-primary)" : "var(--text-muted)" }}> <span className="text-xs leading-tight" style={{ color: isActive && firesABell ? "var(--bg-primary)" : "var(--text-muted)" }}>
{assignedBell > 0 ? assignedBell : "—"} {assignedBell > 0 ? assignedBell : "—"}
</span> </span>
</div> </div>
); );
})} })}
</div> </div>
<div className="flex gap-3 mt-1"> <p className="text-xs mt-1" style={mutedStyle}>Top = Note, Bottom = Bell assigned</p>
<span className="text-xs" style={mutedStyle}>Top = Note, Bottom = Bell</span>
</div>
</div> </div>
) : ( ) : null}
/* Fallback: simple bell circles when no assignments */
<div className="flex flex-wrap gap-1.5"> {/* Active Bell circles (always shown) */}
{Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => { {maxBell > 0 && (
const isActive = currentBells.includes(b); <div>
const isUsed = allBellsUsed.has(b); <p className="text-xs mb-2" style={mutedStyle}>Active Bells</p>
return ( <div className="flex flex-wrap gap-1.5">
<div {Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => {
key={b} const isActive = activeBells.has(b);
className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all" const isUsed = allBellsUsed.has(b);
style={{ return (
backgroundColor: isActive ? "var(--accent)" : isUsed ? "var(--bg-card-hover)" : "var(--bg-primary)", <div
color: isActive ? "var(--bg-primary)" : isUsed ? "var(--text-secondary)" : "var(--border-primary)", key={b}
border: `2px solid ${isActive ? "var(--accent)" : "var(--border-primary)"}`, className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold"
transform: isActive ? "scale(1.2)" : "scale(1)", style={{
}} backgroundColor: isActive ? "#22c55e" : isUsed ? "var(--bg-card-hover)" : "var(--bg-primary)",
> color: isActive ? "#fff" : isUsed ? "var(--text-secondary)" : "var(--border-primary)",
{b} border: `2px solid ${isActive ? "#22c55e" : "var(--border-primary)"}`,
</div> transition: "background-color 0.05s, border-color 0.05s",
); transform: isActive ? "scale(1.15)" : "scale(1)",
})} }}
>
{b}
</div>
);
})}
</div>
</div> </div>
)} )}
@@ -414,7 +452,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
</span> </span>
{hasSpeedInfo && ( {hasSpeedInfo && (
<span className="text-xs ml-2" style={mutedStyle}> <span className="text-xs ml-2" style={mutedStyle}>
({speedMs} ms) ({speedMs} ms/step)
</span> </span>
)} )}
</div> </div>
@@ -438,6 +476,29 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
</p> </p>
)} )}
</div> </div>
{/* Tone Length Slider */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-medium" style={labelStyle}>Tone Length</label>
<span className="text-sm font-bold" style={{ color: "var(--accent)" }}>
{toneLengthMs} ms
</span>
</div>
<input
type="range"
min="20"
max="400"
step="10"
value={toneLengthMs}
onChange={(e) => setToneLengthMs(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>Short (20 ms)</span>
<span>Long (400 ms)</span>
</div>
</div>
</> </>
)} )}
</div> </div>

View File

@@ -381,6 +381,14 @@ export default function ArchetypeList() {
<strong>{deleteTarget.assigned_melody_ids.length} {deleteTarget.assigned_melody_ids.length === 1 ? "melody" : "melodies"}</strong>. <strong>{deleteTarget.assigned_melody_ids.length} {deleteTarget.assigned_melody_ids.length === 1 ? "melody" : "melodies"}</strong>.
Deleting it will remove the archetype and its binary file, but existing melodies will keep their uploaded binary. Deleting it will remove the archetype and its binary file, but existing melodies will keep their uploaded binary.
</div> </div>
<button
onClick={(e) => handleShowAssigned(e, deleteTarget)}
className="text-sm underline text-left"
style={{ color: "var(--accent)", background: "none", border: "none", cursor: "pointer", padding: 0 }}
disabled={loadingAssigned}
>
View assigned {deleteTarget.assigned_melody_ids.length === 1 ? "melody" : "melodies"}
</button>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Do you still want to delete this archetype?</p> <p className="text-sm" style={{ color: "var(--text-muted)" }}>Do you still want to delete this archetype?</p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button

View File

@@ -39,7 +39,7 @@ function validateSteps(stepsStr) {
return null; return null;
} }
export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defaultName, defaultPid, onClose, onSuccess }) { export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, currentBuiltMelody, defaultName, defaultPid, onClose, onSuccess }) {
const [name, setName] = useState(defaultName || ""); const [name, setName] = useState(defaultName || "");
const [pid, setPid] = useState(defaultPid || ""); const [pid, setPid] = useState(defaultPid || "");
const [steps, setSteps] = useState(""); const [steps, setSteps] = useState("");
@@ -102,6 +102,12 @@ export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defa
await api.upload(`/melodies/${melodyId}/upload/binary`, file); await api.upload(`/melodies/${melodyId}/upload/binary`, file);
setStatusMsg("Linking to melody..."); setStatusMsg("Linking to melody...");
// Unassign from any previously assigned archetype first
if (currentBuiltMelody?.id) {
try {
await api.post(`/builder/melodies/${currentBuiltMelody.id}/unassign?firestore_melody_id=${melodyId}`);
} catch { /* best-effort */ }
}
await api.post(`/builder/melodies/${builtId}/assign?firestore_melody_id=${melodyId}`); await api.post(`/builder/melodies/${builtId}/assign?firestore_melody_id=${melodyId}`);
setStatusMsg("Saving archetype data..."); setStatusMsg("Saving archetype data...");

View File

@@ -14,7 +14,7 @@ function computeStepsAndNotes(stepsStr) {
return { steps: tokens.length, totalNotes: bellSet.size || 1 }; return { steps: tokens.length, totalNotes: bellSet.size || 1 };
} }
export default function SelectArchetypeModal({ open, melodyId, currentMelody, onClose, onSuccess }) { export default function SelectArchetypeModal({ open, melodyId, currentMelody, currentBuiltMelody, onClose, onSuccess }) {
const [archetypes, setArchetypes] = useState([]); const [archetypes, setArchetypes] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [assigning, setAssigning] = useState(null); const [assigning, setAssigning] = useState(null);
@@ -41,6 +41,13 @@ export default function SelectArchetypeModal({ open, melodyId, currentMelody, on
setAssigning(archetype.id); setAssigning(archetype.id);
setError(""); setError("");
try { try {
// Unassign from any previously assigned archetype first
if (currentBuiltMelody?.id && currentBuiltMelody.id !== archetype.id) {
try {
await api.post(`/builder/melodies/${currentBuiltMelody.id}/unassign?firestore_melody_id=${melodyId}`);
} catch { /* best-effort */ }
}
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
const res = await fetch(`/api${archetype.binary_url}`, { const res = await fetch(`/api${archetype.binary_url}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},

View File

@@ -1,235 +0,0 @@
import { useState } from "react";
import api from "../../api/client";
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 countSteps(stepsStr) {
if (!stepsStr || !stepsStr.trim()) return 0;
return stepsStr.trim().split(",").length;
}
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 };
}
/**
* Validate steps string. Each comma-separated token must be:
* - "0" (silence)
* - a number 1-16
* - multiple of the above joined by "+"
* Returns null if valid, or an error string if invalid.
*/
function validateSteps(stepsStr) {
if (!stepsStr || !stepsStr.trim()) return "Steps are required.";
const tokens = stepsStr.trim().split(",");
for (const token of tokens) {
const parts = token.split("+");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed === "") return `Invalid token near: "${token.trim()}" — empty part found.`;
const n = parseInt(trimmed, 10);
if (isNaN(n)) return `Invalid step value: "${trimmed}" — must be a number 016.`;
if (n < 0 || n > 16) return `Step value "${trimmed}" is out of range (must be 016).`;
}
}
return null;
}
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("");
const [building, setBuilding] = useState(false);
const [error, setError] = useState("");
const [statusMsg, setStatusMsg] = useState("");
if (!open) return null;
const handleBuildAndUpload = async () => {
// Mandatory field checks
if (!name.trim()) { setError("Name is required."); return; }
if (!pid.trim()) { setError("PID is required."); return; }
if (!steps.trim()) { setError("Steps are required."); return; }
// Steps format validation
const stepsError = validateSteps(steps);
if (stepsError) { setError(stepsError); return; }
setBuilding(true);
setError("");
setStatusMsg("");
try {
// Uniqueness check: fetch all existing archetypes
setStatusMsg("Checking for conflicts...");
const existing = await api.get("/builder/melodies");
const list = existing.melodies || [];
const dupName = list.find((m) => m.name.toLowerCase() === name.trim().toLowerCase());
if (dupName) {
setError(`An archetype with the name "${name.trim()}" already exists.`);
setBuilding(false);
setStatusMsg("");
return;
}
const dupPid = list.find((m) => m.pid && m.pid.toLowerCase() === pid.trim().toLowerCase());
if (dupPid) {
setError(`An archetype with the PID "${pid.trim()}" already exists.`);
setBuilding(false);
setStatusMsg("");
return;
}
// Step 1: Create the built melody record
setStatusMsg("Creating melody record...");
const created = await api.post("/builder/melodies", {
name: name.trim(),
pid: pid.trim(),
steps: steps.trim(),
});
const builtId = created.id;
// Step 2: Build the binary
setStatusMsg("Building binary...");
const built = await api.post(`/builder/melodies/${builtId}/build-binary`);
// Step 3: Fetch the .bsm file and upload to Firebase Storage
setStatusMsg("Uploading to cloud storage...");
const token = localStorage.getItem("access_token");
const res = await fetch(`/api${built.binary_url}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`Failed to fetch binary: ${res.statusText}`);
const blob = await res.blob();
// File is named by PID, not friendly name
const file = new File([blob], `${pid.trim()}.bsm`, { type: "application/octet-stream" });
await api.upload(`/melodies/${melodyId}/upload/binary`, file);
// Step 4: Assign to this melody
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(), steps: stepCount, totalNotes, archetype_csv: csv });
} catch (err) {
setError(err.message);
setStatusMsg("");
} finally {
setBuilding(false);
}
};
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
onClick={(e) => e.target === e.currentTarget && !building && onClose()}
>
<div
className="w-full max-w-xl rounded-lg border shadow-xl"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex items-center justify-between px-6 py-4 border-b" style={{ borderColor: "var(--border-primary)" }}>
<div>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Build on the Fly</h2>
<p className="text-xs mt-0.5" style={mutedStyle}>Enter steps, build binary, and upload all in one step.</p>
</div>
{!building && (
<button onClick={onClose} className="text-xl leading-none" style={mutedStyle}>&times;</button>
)}
</div>
<div className="p-6 space-y-4">
{error && (
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Name *</label>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} disabled={building} />
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID *</label>
<input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} disabled={building} />
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium" style={labelStyle}>Steps *</label>
<span className="text-xs" style={mutedStyle}>{countSteps(steps)} steps</span>
</div>
<textarea
value={steps}
onChange={(e) => setSteps(e.target.value)}
rows={5}
placeholder={"e.g. 1,2,2+1,1,2,3+1,0,3\n\n• Comma-separated values\n• Single bell: 1, 2, 3...\n• Multiple: 2+3+1\n• Silence: 0"}
className={inputClass}
style={{ fontFamily: "monospace", resize: "vertical" }}
disabled={building}
/>
</div>
{statusMsg && !error && (
<div className="text-sm rounded-md p-2 text-center" style={{ color: "var(--text-muted)", backgroundColor: "var(--bg-primary)" }}>
{statusMsg}
</div>
)}
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
<button
onClick={onClose}
disabled={building}
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
>
Cancel
</button>
<button
onClick={handleBuildAndUpload}
disabled={building}
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{building ? "Building & Uploading..." : "Build & Upload"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,403 +0,0 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import api from "../../api/client";
const sectionStyle = { backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" };
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;
}
/**
* Validate steps string. Each comma-separated token must be:
* - "0" (silence)
* - a number 1-16
* - multiple of the above joined by "+"
* Returns null if valid, or an error string if invalid.
*/
function validateSteps(stepsStr) {
if (!stepsStr || !stepsStr.trim()) return "Steps are required.";
const tokens = stepsStr.trim().split(",");
for (const token of tokens) {
const parts = token.split("+");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed === "") return `Invalid token near: "${token.trim()}" — empty part found.`;
const n = parseInt(trimmed, 10);
if (isNaN(n)) return `Invalid step value: "${trimmed}" — must be a number 016.`;
if (n < 0 || n > 16) return `Step value "${trimmed}" is out of range (must be 016).`;
}
}
return null;
}
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);
const navigate = useNavigate();
// Form state (what the user is editing)
const [name, setName] = useState("");
const [pid, setPid] = useState("");
const [steps, setSteps] = useState("");
// Saved state (what's actually stored — build actions use this)
const [savedPid, setSavedPid] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [buildingBinary, setBuildingBinary] = useState(false);
const [buildingBuiltin, setBuildingBuiltin] = useState(false);
const [error, setError] = useState("");
const [successMsg, setSuccessMsg] = useState("");
const [binaryBuilt, setBinaryBuilt] = useState(false);
const [binaryUrl, setBinaryUrl] = useState(null);
const [progmemCode, setProgmemCode] = useState("");
const [copied, setCopied] = useState(false);
// Track whether the form has unsaved changes relative to what's built
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const codeRef = useRef(null);
useEffect(() => {
if (isEdit) loadMelody();
}, [id]);
const loadMelody = async () => {
setLoading(true);
try {
const data = await api.get(`/builder/melodies/${id}`);
setName(data.name || "");
setPid(data.pid || "");
setSteps(data.steps || "");
setSavedPid(data.pid || "");
setBinaryBuilt(Boolean(data.binary_path));
setBinaryUrl(data.binary_url || null);
setProgmemCode(data.progmem_code || "");
setHasUnsavedChanges(false);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// Track form dirtiness whenever name/pid/steps change after initial load
const handleNameChange = (v) => { setName(v); setHasUnsavedChanges(true); };
const handlePidChange = (v) => { setPid(v); setHasUnsavedChanges(true); };
const handleStepsChange = (v) => { setSteps(v); setHasUnsavedChanges(true); };
const handleSave = async () => {
if (!name.trim()) { setError("Name is required."); return; }
if (!pid.trim()) { setError("PID is required."); return; }
const stepsError = validateSteps(steps);
if (stepsError) { setError(stepsError); return; }
setSaving(true);
setError("");
setSuccessMsg("");
try {
// Uniqueness check
const existing = await api.get("/builder/melodies");
const list = existing.melodies || [];
const dupName = list.find((m) => m.id !== id && m.name.toLowerCase() === name.trim().toLowerCase());
if (dupName) { setError(`An archetype with the name "${name.trim()}" already exists.`); return; }
const dupPid = list.find((m) => m.id !== id && m.pid && m.pid.toLowerCase() === pid.trim().toLowerCase());
if (dupPid) { setError(`An archetype with the PID "${pid.trim()}" already exists.`); return; }
const body = { name: name.trim(), pid: pid.trim(), steps: steps.trim() };
if (isEdit) {
await api.put(`/builder/melodies/${id}`, body);
setSavedPid(pid.trim());
setHasUnsavedChanges(false);
setSuccessMsg("Saved.");
} else {
const created = await api.post("/builder/melodies", body);
navigate(`/melodies/builder/${created.id}`, { replace: true });
}
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleBuildBinary = async () => {
if (!isEdit) { setError("Save the melody first before building."); return; }
if (hasUnsavedChanges) { setError("You have unsaved changes. Save before rebuilding."); return; }
setBuildingBinary(true);
setError("");
setSuccessMsg("");
try {
const data = await api.post(`/builder/melodies/${id}/build-binary`);
setBinaryBuilt(Boolean(data.binary_path));
setBinaryUrl(data.binary_url || null);
setSuccessMsg("Binary built successfully.");
} catch (err) {
setError(err.message);
} finally {
setBuildingBinary(false);
}
};
const handleBuildBuiltin = async () => {
if (!isEdit) { setError("Save the melody first before building."); return; }
if (hasUnsavedChanges) { setError("You have unsaved changes. Save before regenerating."); return; }
setBuildingBuiltin(true);
setError("");
setSuccessMsg("");
try {
const data = await api.post(`/builder/melodies/${id}/build-builtin`);
setProgmemCode(data.progmem_code || "");
setSuccessMsg("PROGMEM code generated.");
} catch (err) {
setError(err.message);
} finally {
setBuildingBuiltin(false);
}
};
const handleCopy = () => {
if (!progmemCode) return;
copyText(progmemCode, () => { setCopied(true); setTimeout(() => setCopied(false), 2000); });
};
if (loading) {
return <div className="text-center py-8" style={mutedStyle}>Loading...</div>;
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<button onClick={() => navigate("/melodies/builder")} className="text-sm mb-2 inline-block" style={{ color: "var(--accent)" }}>
&larr; Back to Builder
</button>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
{isEdit ? "Edit Archetype" : "New Archetype"}
</h1>
</div>
<div className="flex gap-3">
<button
onClick={() => navigate("/melodies/builder")}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 transition-colors"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{saving ? "Saving..." : isEdit ? "Save Changes" : "Create"}
</button>
</div>
</div>
{error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{successMsg && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--success-bg)", borderColor: "var(--success)", color: "var(--success-text)" }}>
{successMsg}
</div>
)}
<div className="space-y-6">
{/* --- Info Section --- */}
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Archetype Info</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Name *</label>
<input type="text" value={name} onChange={(e) => handleNameChange(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID (Playback ID) *</label>
<input type="text" value={pid} onChange={(e) => handlePidChange(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} />
<p className="text-xs mt-1" style={mutedStyle}>Used as the built-in firmware identifier. Must be unique.</p>
</div>
<div className="md:col-span-2">
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium" style={labelStyle}>Steps *</label>
<span className="text-xs" style={mutedStyle}>{countSteps(steps)} steps</span>
</div>
<textarea
value={steps}
onChange={(e) => handleStepsChange(e.target.value)}
rows={5}
placeholder={"e.g. 1,2,2+1,1,2,3+1,0,3\n\n• Comma-separated values\n• Single bell: 1, 2, 3...\n• Multiple bells: 2+3+1\n• Silence: 0"}
className={inputClass}
style={{ fontFamily: "monospace", resize: "vertical" }}
/>
<p className="text-xs mt-1" style={mutedStyle}>
Each value = one step. Bell numbers 116 (1 = highest). Combine with +. Silence = 0.
</p>
</div>
</div>
</section>
{/* --- Build Actions Section --- */}
{isEdit && (
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-1" style={{ color: "var(--text-heading)" }}>Build</h2>
<p className="text-sm mb-4" style={mutedStyle}>
Save any changes above before building. Rebuilding will overwrite previous output.
{hasUnsavedChanges && (
<span style={{ color: "var(--warning, #f59e0b)", fontWeight: 600 }}> You have unsaved changes.</span>
)}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Binary */}
<div className="rounded-lg p-4 border space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Binary (.bsm)</h3>
<p className="text-xs mt-0.5" style={mutedStyle}>For SD card playback on the controller</p>
</div>
{binaryBuilt && (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
Built
</span>
)}
</div>
<button
onClick={handleBuildBinary}
disabled={buildingBinary || hasUnsavedChanges}
className="w-full px-3 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
title={hasUnsavedChanges ? "Save changes first" : undefined}
>
{buildingBinary ? "Building..." : binaryBuilt ? "Rebuild Binary" : "Build Binary"}
</button>
{binaryUrl && (
<button
type="button"
onClick={() => downloadBinary(binaryUrl, `${savedPid}.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 {savedPid}.bsm
</button>
)}
</div>
{/* Builtin Code */}
<div className="rounded-lg p-4 border space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>PROGMEM Code</h3>
<p className="text-xs mt-0.5" style={mutedStyle}>For built-in firmware (ESP32 PROGMEM)</p>
</div>
{progmemCode && (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
Generated
</span>
)}
</div>
<button
onClick={handleBuildBuiltin}
disabled={buildingBuiltin || hasUnsavedChanges}
className="w-full px-3 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
title={hasUnsavedChanges ? "Save changes first" : undefined}
>
{buildingBuiltin ? "Generating..." : progmemCode ? "Regenerate Code" : "Build Builtin Code"}
</button>
</div>
</div>
{/* PROGMEM Code Block */}
{progmemCode && (
<div className="mt-4 rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
<div className="flex items-center justify-between px-4 py-2 border-b" style={{ backgroundColor: "var(--bg-card-hover)", borderColor: "var(--border-primary)" }}>
<span className="text-xs font-medium font-mono" style={{ color: "var(--text-muted)" }}>
PROGMEM C Code copy into your firmware
</span>
<button
onClick={handleCopy}
className="text-xs px-3 py-1 rounded transition-colors"
style={{
backgroundColor: copied ? "var(--success-bg)" : "var(--bg-primary)",
color: copied ? "var(--success-text)" : "var(--text-secondary)",
border: "1px solid var(--border-primary)",
}}
>
{copied ? "Copied!" : "Copy"}
</button>
</div>
<pre
ref={codeRef}
className="p-4 text-xs overflow-x-auto"
style={{
backgroundColor: "var(--bg-primary)",
color: "var(--text-primary)",
fontFamily: "monospace",
whiteSpace: "pre",
maxHeight: "400px",
overflowY: "auto",
}}
>
{progmemCode}
</pre>
</div>
)}
</section>
)}
{!isEdit && (
<div className="rounded-lg p-4 border text-sm" style={{ borderColor: "var(--border-primary)", ...sectionStyle, color: "var(--text-muted)" }}>
Build actions (Binary + PROGMEM Code) will be available after saving.
</div>
)}
</div>
</div>
);
}

View File

@@ -1,308 +0,0 @@
import { useState, useEffect } from "react";
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;
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.7)" }}
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div
className="w-full rounded-lg border shadow-xl"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxWidth: "720px", maxHeight: "80vh", display: "flex", flexDirection: "column" }}
>
<div className="flex items-center justify-between px-6 py-4 border-b flex-shrink-0" style={{ borderColor: "var(--border-primary)" }}>
<div>
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Firmware Code Snippet</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>{melody.name} · PID: <span className="font-mono">{melody.pid || "—"}</span></p>
</div>
<div className="flex items-center gap-3">
<button
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)",
color: copied ? "var(--success-text)" : "var(--text-secondary)",
border: "1px solid var(--border-primary)",
}}
>
{copied ? "Copied!" : "Copy"}
</button>
<button onClick={onClose} className="text-xl leading-none" style={{ color: "var(--text-muted)" }}>&times;</button>
</div>
</div>
<pre
className="flex-1 overflow-auto p-5 text-xs"
style={{ fontFamily: "monospace", color: "var(--text-primary)", backgroundColor: "var(--bg-primary)", whiteSpace: "pre", margin: 0 }}
>
{melody.progmem_code}
</pre>
</div>
</div>
);
}
const sectionStyle = { backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" };
export default function BuilderList() {
const navigate = useNavigate();
const [melodies, setMelodies] = useState([]);
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();
}, []);
const loadMelodies = async () => {
setLoading(true);
setError("");
try {
const data = await api.get("/builder/melodies");
setMelodies(data.melodies || []);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await api.delete(`/builder/melodies/${deleteTarget.id}`);
setDeleteTarget(null);
loadMelodies();
} catch (err) {
setError(err.message);
setDeleteTarget(null);
}
};
const countSteps = (stepsStr) => {
if (!stepsStr) return 0;
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 (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
Archetype Builder
</h1>
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
Add Archetypes, and Build binary (.bsm) files and firmware PROGMEM code.
</p>
</div>
<button
onClick={() => navigate("/melodies/builder/new")}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
+ Add Archetype
</button>
</div>
{error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{loading ? (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : melodies.length === 0 ? (
<div className="rounded-lg border p-12 text-center" style={sectionStyle}>
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>No built melodies yet.</p>
<button
onClick={() => navigate("/melodies/builder/new")}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Add Your First Melody
</button>
</div>
) : (
<div className="rounded-lg border overflow-hidden" style={sectionStyle}>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-card-hover)" }}>
<th className="px-4 py-3 font-medium" style={{ color: "var(--text-secondary)" }}>Name</th>
<th className="px-4 py-3 font-medium" style={{ color: "var(--text-secondary)" }}>PID</th>
<th className="px-4 py-3 font-medium text-center" style={{ color: "var(--text-secondary)" }}>Steps</th>
<th className="px-4 py-3 font-medium text-center" style={{ color: "var(--text-secondary)" }}>Binary</th>
<th className="px-4 py-3 font-medium text-center" style={{ color: "var(--text-secondary)" }}>Builtin Code</th>
<th className="px-4 py-3 font-medium text-center" style={{ color: "var(--text-secondary)" }}>Assigned</th>
<th className="px-4 py-3 font-medium" style={{ color: "var(--text-secondary)" }}>Updated</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{melodies.map((m, idx) => (
<tr
key={m.id}
onClick={() => navigate(`/melodies/builder/${m.id}`)}
className="border-b cursor-pointer transition-colors hover:bg-[var(--bg-card-hover)]"
style={{ borderColor: "var(--border-primary)" }}
>
<td className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>
{m.name}
</td>
<td className="px-4 py-3" style={{ color: "var(--text-secondary)" }}>
<span className="font-mono text-xs px-2 py-0.5 rounded" style={{ backgroundColor: "var(--bg-card-hover)" }}>
{m.pid || "-"}
</span>
</td>
<td className="px-4 py-3 text-center" style={{ color: "var(--text-secondary)" }}>
{countSteps(m.steps)}
</td>
<td className="px-4 py-3 text-center">
{m.binary_path ? (
<span
title="Click to Download"
onClick={(e) => handleDownloadBinary(e, m)}
className="px-2 py-0.5 text-xs rounded-full cursor-pointer transition-opacity hover:opacity-70"
style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}
>
Built
</span>
) : (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
</span>
)}
</td>
<td className="px-4 py-3 text-center">
{m.progmem_code ? (
<span
title="Click to View Code Snippet"
onClick={(e) => { e.stopPropagation(); setCodeSnippetMelody(m); }}
className="px-2 py-0.5 text-xs rounded-full cursor-pointer transition-opacity hover:opacity-70"
style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}
>
Generated
</span>
) : (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
</span>
)}
</td>
<td className="px-4 py-3 text-center" style={{ color: "var(--text-secondary)" }}>
{m.assigned_melody_ids?.length > 0 ? (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "rgba(59,130,246,0.15)", color: "#60a5fa" }}>
{m.assigned_melody_ids.length} melody{m.assigned_melody_ids.length !== 1 ? "s" : ""}
</span>
) : (
<span style={{ color: "var(--text-muted)" }}></span>
)}
</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{new Date(m.updated_at).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<button
onClick={(e) => { e.stopPropagation(); setDeleteTarget(m); }}
className="px-2 py-1 text-xs rounded transition-colors"
style={{ color: "var(--danger)", backgroundColor: "var(--danger-bg)" }}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Two-stage delete: warn if assigned, then confirm */}
{deleteTarget && !deleteWarningConfirmed && (deleteTarget.assigned_melody_ids?.length || 0) > 0 && (
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
<div className="w-full max-w-md rounded-lg border shadow-xl p-6 space-y-4" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h3 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Archetype In Use</h3>
<div className="rounded-md p-3 border text-sm" style={{ backgroundColor: "var(--warning-bg, rgba(245,158,11,0.1))", borderColor: "var(--warning, #f59e0b)", color: "var(--warning-text, #f59e0b)" }}>
<strong>"{deleteTarget.name}"</strong> is currently assigned to{" "}
<strong>{deleteTarget.assigned_melody_ids.length} melody{deleteTarget.assigned_melody_ids.length !== 1 ? "ies" : "y"}</strong>.
Deleting it will remove the archetype and its binary file, but existing melodies will keep their uploaded binary.
</div>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Do you still want to delete this archetype?</p>
<div className="flex justify-end gap-3">
<button
onClick={() => { setDeleteTarget(null); setDeleteWarningConfirmed(false); }}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
>
Cancel
</button>
<button
onClick={() => setDeleteWarningConfirmed(true)}
className="px-4 py-2 text-sm rounded-md transition-colors font-medium"
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
>
Yes, Delete Anyway
</button>
</div>
</div>
</div>
)}
<ConfirmDialog
open={Boolean(deleteTarget) && (deleteWarningConfirmed || (deleteTarget?.assigned_melody_ids?.length || 0) === 0)}
title="Delete Archetype"
message={`Are you sure you want to permanently delete "${deleteTarget?.name}"? This will also delete the .bsm binary file. This action cannot be undone.`}
onConfirm={handleDelete}
onCancel={() => { setDeleteTarget(null); setDeleteWarningConfirmed(false); }}
/>
<CodeSnippetModal
melody={codeSnippetMelody}
onClose={() => setCodeSnippetMelody(null)}
/>
</div>
);
}

View File

@@ -1,154 +0,0 @@
import { useState, useEffect } from "react";
import api from "../../api/client";
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
const [error, setError] = useState("");
useEffect(() => {
if (open) loadMelodies();
}, [open]);
const loadMelodies = async () => {
setLoading(true);
setError("");
try {
const data = await api.get("/builder/melodies");
// Only show those with a built binary
setMelodies((data.melodies || []).filter((m) => m.binary_path));
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleSelect = async (builtMelody) => {
setAssigning(builtMelody.id);
setError("");
try {
// 1. Fetch the .bsm file from the builder endpoint
const token = localStorage.getItem("access_token");
const res = await fetch(`/api${builtMelody.binary_url}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`Failed to download binary: ${res.statusText}`);
const blob = await res.blob();
const file = new File([blob], `${builtMelody.name}.bsm`, { type: "application/octet-stream" });
// 2. Upload to Firebase Storage via the existing melody upload endpoint
await api.upload(`/melodies/${melodyId}/upload/binary`, file);
// 3. Mark this built melody as assigned to this Firestore melody
await api.post(`/builder/melodies/${builtMelody.id}/assign?firestore_melody_id=${melodyId}`);
// 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 {
setAssigning(null);
}
};
if (!open) return null;
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div
className="w-full max-w-2xl rounded-lg border shadow-xl"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex items-center justify-between px-6 py-4 border-b" style={{ borderColor: "var(--border-primary)" }}>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Select Built Melody</h2>
<button onClick={onClose} className="text-xl leading-none" style={{ color: "var(--text-muted)" }}>&times;</button>
</div>
<div className="p-6">
{error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{loading ? (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : melodies.length === 0 ? (
<div className="text-center py-8 text-sm" style={{ color: "var(--text-muted)" }}>
No built binaries found. Go to <strong>Melody Builder</strong> to create one first.
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{melodies.map((m) => (
<div
key={m.id}
className="flex items-center justify-between rounded-lg px-4 py-3 border transition-colors"
style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}
>
<div>
<p className="text-sm font-medium" style={{ color: "var(--text-heading)" }}>{m.name}</p>
<p className="text-xs mt-0.5 font-mono" style={{ color: "var(--text-muted)" }}>
PID: {m.pid || "—"} &nbsp;·&nbsp; {m.steps?.split(",").length || 0} steps
</p>
</div>
<button
onClick={() => handleSelect(m)}
disabled={Boolean(assigning)}
className="px-3 py-1.5 text-xs rounded-md disabled:opacity-50 transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{assigning === m.id ? "Uploading..." : "Select & Upload"}
</button>
</div>
))}
</div>
)}
</div>
<div className="flex justify-end px-6 py-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
<button onClick={onClose} className="px-4 py-2 text-sm rounded-md transition-colors" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}>
Cancel
</button>
</div>
</div>
</div>
);
}