CODEX - Added Modal Playback for Archetypes
This commit is contained in:
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user