CODEX - Added Modal Playback for Archetypes

This commit is contained in:
2026-02-23 09:57:12 +02:00
parent fb3bbac245
commit 12e793aa7e
4 changed files with 554 additions and 57 deletions

View File

@@ -50,6 +50,13 @@ function parseStepsString(stepsStr) {
return stepsStr.trim().split(",").map((s) => parseBellNotation(s));
}
function normalizePlaybackUrl(url) {
if (!url || typeof url !== "string") return null;
if (url.startsWith("http") || url.startsWith("/api")) return url;
if (url.startsWith("/")) return `/api${url}`;
return `/api/${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");
@@ -143,6 +150,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
const [currentStep, setCurrentStep] = useState(-1);
const [speedPercent, setSpeedPercent] = useState(50);
const [toneLengthMs, setToneLengthMs] = useState(80);
const [loopEnabled, setLoopEnabled] = useState(true);
// activeBells: Set of bell numbers currently lit (for flash effect)
const [activeBells, setActiveBells] = useState(new Set());
@@ -153,6 +161,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
const speedMsRef = useRef(500);
const toneLengthRef = useRef(80);
const noteAssignmentsRef = useRef(noteAssignments);
const loopEnabledRef = useRef(true);
const speedMs = mapPercentageToSpeed(speedPercent, minSpeed, maxSpeed) ?? 500;
@@ -160,6 +169,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
useEffect(() => { speedMsRef.current = speedMs; }, [speedMs]);
useEffect(() => { toneLengthRef.current = toneLengthMs; }, [toneLengthMs]);
useEffect(() => { noteAssignmentsRef.current = noteAssignments; }, [noteAssignments]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { loopEnabledRef.current = loopEnabled; }, [loopEnabled]);
const stopPlayback = useCallback(() => {
if (playbackRef.current) {
@@ -180,11 +190,39 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
setCurrentStep(-1);
setLoadError("");
setSpeedPercent(50);
setLoopEnabled(true);
setActiveBells(new Set());
return;
}
const binaryUrlCandidate = builtMelody?.binary_url
? `/api${builtMelody.binary_url}`
: files?.binary_url || melody?.url || null;
const binaryUrl = normalizePlaybackUrl(binaryUrlCandidate);
const csv = archetypeCsv || info.archetype_csv || null;
if (binaryUrl) {
setLoading(true);
setLoadError("");
decodeBsmBinary(binaryUrl)
.then((decoded) => {
setSteps(decoded);
stepsRef.current = decoded;
})
.catch((err) => {
if (csv) {
const parsed = parseStepsString(csv);
setSteps(parsed);
stepsRef.current = parsed;
setLoadError("");
return;
}
setLoadError(err.message);
})
.finally(() => setLoading(false));
return;
}
if (csv) {
const parsed = parseStepsString(csv);
setSteps(parsed);
@@ -193,25 +231,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
return;
}
// Fall back to binary
const binaryUrl = builtMelody?.binary_url
? `/api${builtMelody.binary_url}`
: files?.binary_url || melody?.url || null;
if (!binaryUrl) {
setLoadError("No binary or archetype data 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));
setLoadError("No binary or archetype data available for this melody.");
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
const ensureAudioCtx = () => {
@@ -255,11 +275,19 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
// Schedule next step after step interval
const timer = setTimeout(() => {
const next = playFrom + 1;
scheduleStep(next >= stepsRef.current.length ? 0 : next);
if (next >= stepsRef.current.length) {
if (loopEnabledRef.current) {
scheduleStep(0);
} else {
stopPlayback();
}
return;
}
scheduleStep(next);
}, speedMsRef.current);
playbackRef.current = { timer, flashTimer, stepIndex: playFrom };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, [stopPlayback]); // eslint-disable-line react-hooks/exhaustive-deps
const handlePlay = () => {
if (!stepsRef.current.length) return;
@@ -286,6 +314,18 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
const maxBell = allBellsUsed.size > 0 ? Math.max(...Array.from(allBellsUsed)) : 0;
const hasSpeedInfo = minSpeed != null && maxSpeed != null;
const detectedNoteCount = steps.reduce((max, stepValue) => {
let highest = 0;
for (let bit = 15; bit >= 0; bit--) {
if (stepValue & (1 << bit)) {
highest = bit + 1;
break;
}
}
return Math.max(max, highest);
}, 0);
const configuredNoteCount = Number(info.totalNotes || noteAssignments.length || 0);
const gridNoteCount = Math.max(1, Math.min(16, configuredNoteCount || detectedNoteCount || 1));
return (
<div
@@ -439,7 +479,99 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
Stop
</button>
)}
<span className="text-xs" style={mutedStyle}>Loops continuously</span>
<label className="inline-flex items-center gap-2 text-xs" style={mutedStyle}>
<input
type="checkbox"
checked={loopEnabled}
onChange={(e) => setLoopEnabled(e.target.checked)}
className="h-3.5 w-3.5 rounded"
/>
Loop
</label>
</div>
{/* Steps matrix */}
<div>
<p className="text-xs mb-2" style={mutedStyle}>Note/Step Matrix</p>
<div
className="rounded-md border overflow-auto"
style={{ borderColor: "var(--border-primary)", maxHeight: "280px" }}
>
<table className="min-w-max border-separate border-spacing-0 text-xs">
<thead>
<tr>
<th
className="sticky top-0 left-0 z-20 px-2 py-1.5 text-left border-b border-r"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
>
Note \ Step
</th>
{steps.map((_, stepIdx) => (
<th
key={stepIdx}
className="sticky top-0 z-10 px-2 py-1.5 text-center border-b border-r"
style={{
minWidth: "36px",
backgroundColor: currentStep === stepIdx ? "rgba(116,184,22,0.2)" : "var(--bg-primary)",
borderColor: "var(--border-primary)",
color: currentStep === stepIdx ? "var(--accent)" : "var(--text-muted)",
}}
>
{stepIdx + 1}
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: gridNoteCount }, (_, noteIdx) => (
<tr
key={noteIdx}
>
<th
className="sticky left-0 z-[1] px-2 py-1.5 text-left border-b border-r"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
{NOTE_LABELS[noteIdx]}
</th>
{steps.map((stepValue, stepIdx) => {
const enabled = Boolean(stepValue & (1 << noteIdx));
const isCurrent = currentStep === stepIdx;
return (
<td
key={`${noteIdx}-${stepIdx}`}
className="border-b border-r"
style={{
width: "36px",
height: "36px",
borderColor: "var(--border-primary)",
backgroundColor: isCurrent ? "rgba(116,184,22,0.06)" : "transparent",
}}
>
<span
className="w-full h-full flex items-center justify-center"
aria-hidden="true"
>
<span
style={{
width: "54%",
height: "54%",
borderRadius: "9999px",
backgroundColor: "var(--btn-primary)",
opacity: enabled ? 1 : 0,
transform: enabled ? "scale(1)" : "scale(0.4)",
boxShadow: enabled ? "0 0 10px 3px rgba(116, 184, 22, 0.5)" : "none",
transition: "opacity 140ms ease, transform 140ms ease, box-shadow 180ms ease",
}}
/>
</span>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Speed Slider */}