diff --git a/frontend/src/melodies/BinaryTableModal.jsx b/frontend/src/melodies/BinaryTableModal.jsx new file mode 100644 index 0000000..15a648d --- /dev/null +++ b/frontend/src/melodies/BinaryTableModal.jsx @@ -0,0 +1,228 @@ +import { useEffect, useState } from "react"; + +const NOTE_LABELS = "ABCDEFGHIJKLMNOP"; + +function parseBellNotation(notation) { + const raw = String(notation || "").trim(); + if (raw === "0" || !raw) return 0; + let value = 0; + for (const part of raw.split("+")) { + const n = Number.parseInt(part.trim(), 10); + if (Number.isInteger(n) && n >= 1 && n <= 16) value |= 1 << (n - 1); + } + return value; +} + +function parseStepsString(stepsStr) { + if (!stepsStr || !String(stepsStr).trim()) return []; + return String(stepsStr).trim().split(",").map((s) => parseBellNotation(s)); +} + +function normalizeFileUrl(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 fetchBinaryResponse(url) { + const token = localStorage.getItem("access_token"); + try { + const res = await fetch(url, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (res.ok) return res; + if (url.startsWith("http")) { + const retry = await fetch(url); + if (retry.ok) return retry; + throw new Error(`Failed to fetch binary: ${retry.statusText || retry.status}`); + } + throw new Error(`Failed to fetch binary: ${res.statusText || res.status}`); + } catch { + if (url.startsWith("http")) { + const retry = await fetch(url); + if (retry.ok) return retry; + throw new Error(`Failed to fetch binary: ${retry.statusText || retry.status}`); + } + throw new Error("Failed to fetch binary"); + } +} + +async function decodeBsmBinary(url) { + const res = await fetchBinaryResponse(url); + const buf = await res.arrayBuffer(); + const view = new DataView(buf); + const steps = []; + for (let i = 0; i + 1 < buf.byteLength; i += 2) { + steps.push(view.getUint16(i, false)); + } + return steps; +} + +export default function BinaryTableModal({ open, melody, builtMelody, files, archetypeCsv, onClose }) { + const info = melody?.information || {}; + const [steps, setSteps] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!open) { + setSteps([]); + setLoading(false); + setError(""); + return; + } + + const binaryUrlCandidate = builtMelody?.binary_url + ? `/api${builtMelody.binary_url}` + : files?.binary_url || melody?.url || null; + const binaryUrl = normalizeFileUrl(binaryUrlCandidate); + const csv = archetypeCsv || info.archetype_csv || null; + + if (binaryUrl) { + setLoading(true); + setError(""); + decodeBsmBinary(binaryUrl) + .then((decoded) => setSteps(decoded)) + .catch((err) => { + if (csv) { + setSteps(parseStepsString(csv)); + setError(""); + return; + } + setError(err.message || "Failed to load melody data."); + }) + .finally(() => setLoading(false)); + return; + } + + if (csv) { + setSteps(parseStepsString(csv)); + setError(""); + return; + } + + setError("No binary or archetype data available for this melody."); + }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!open) return 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 gridNoteCount = Math.max(1, Math.min(16, Number(info.totalNotes || 0) || detectedNoteCount || 1)); + + return ( +
e.target === e.currentTarget && onClose()} + > +
+
+
+

Archetype View

+

{steps.length} steps

+
+ +
+ +
+ {loading &&

Loading binary...

} + {error && ( +
+ {error} +
+ )} + + {!loading && !error && steps.length > 0 && ( +
+ + + + + {steps.map((_, stepIdx) => ( + + ))} + + + + {Array.from({ length: gridNoteCount }, (_, noteIdx) => ( + + + {steps.map((stepValue, stepIdx) => { + const enabled = Boolean(stepValue & (1 << noteIdx)); + return ( + + ); + })} + + ))} + +
+ Note \ Step + + {stepIdx + 1} +
+ {NOTE_LABELS[noteIdx]} + +
+
+ )} +
+ +
+ +
+
+
+ ); +} + diff --git a/frontend/src/melodies/MelodyDetail.jsx b/frontend/src/melodies/MelodyDetail.jsx index 409beb1..62b9c55 100644 --- a/frontend/src/melodies/MelodyDetail.jsx +++ b/frontend/src/melodies/MelodyDetail.jsx @@ -5,6 +5,7 @@ import { useAuth } from "../auth/AuthContext"; import ConfirmDialog from "../components/ConfirmDialog"; import SpeedCalculatorModal from "./SpeedCalculatorModal"; import PlaybackModal from "./PlaybackModal"; +import BinaryTableModal from "./BinaryTableModal"; function fallbackCopy(text, onSuccess) { const ta = document.createElement("textarea"); @@ -54,12 +55,6 @@ function normalizeFileUrl(url) { return `/api/${url}`; } -function hueForDepth(index, count) { - const safeCount = Math.max(1, count); - const t = Math.max(0, Math.min(1, index / safeCount)); - return 190 + (15 - 190) * t; -} - function Field({ label, children }) { return (
@@ -128,6 +123,7 @@ export default function MelodyDetail() { const [codeCopied, setCodeCopied] = useState(false); const [showSpeedCalc, setShowSpeedCalc] = useState(false); const [showPlayback, setShowPlayback] = useState(false); + const [showBinaryView, setShowBinaryView] = useState(false); const [offlineSaving, setOfflineSaving] = useState(false); useEffect(() => { @@ -482,9 +478,6 @@ export default function MelodyDetail() { {settings.noteAssignments?.length > 0 ? (
{settings.noteAssignments.map((assignedBell, noteIdx) => { - const noteHue = hueForDepth(noteIdx, settings.noteAssignments.length - 1); - const bellDepthIdx = Math.max(0, Math.min(15, (assignedBell || 1) - 1)); - const bellHue = hueForDepth(bellDepthIdx, 15); return (
- + {String.fromCharCode(65 + noteIdx)}
- 0 ? `hsl(${bellHue}, 80%, 70%)` : "var(--text-muted)" }}> - {assignedBell > 0 ? assignedBell : "—"} + + {assignedBell > 0 ? assignedBell : "-"}
); @@ -561,11 +553,19 @@ export default function MelodyDetail() { e.preventDefault(); try { const token = localStorage.getItem("access_token"); - let res = await fetch(binaryUrl, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - // For external URLs (e.g. Firebase Storage), retry without auth header - if (!res.ok && binaryUrl.startsWith("http")) { + let res = null; + try { + res = await fetch(binaryUrl, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + } catch { + if (binaryUrl.startsWith("http")) { + res = await fetch(binaryUrl); + } else { + throw new Error("Download failed: network error"); + } + } + if ((!res || !res.ok) && binaryUrl.startsWith("http")) { res = await fetch(binaryUrl); } if (!res.ok) throw new Error(`Download failed: ${res.statusText}`); @@ -577,7 +577,7 @@ export default function MelodyDetail() { a.click(); URL.revokeObjectURL(objectUrl); } catch (err) { - console.error(err); + setError(err.message); } }; @@ -601,7 +601,7 @@ export default function MelodyDetail() {
- {existingFiles.preview_url ? ( + {normalizeFileUrl(existingFiles.preview_url) ? (
+ {(() => { + const previewUrl = normalizeFileUrl(existingFiles.preview_url); + const previewName = resolveFilename(previewUrl, "preview.mp3"); + return ( downloadExistingFile(existingFiles.preview_url, resolveFilename(existingFiles.preview_url, "preview.mp3"), e)} + href={previewUrl} + onClick={(e) => downloadExistingFile(previewUrl, previewName, e)} className="underline text-xs" style={{ color: "var(--accent)" }} > - {resolveFilename(existingFiles.preview_url, "Click to Download")} + {resolveFilename(previewUrl, "Click to Download")} -
) : (

No preview uploaded

@@ -869,7 +886,7 @@ export default function MelodyForm() { )} @@ -1327,7 +1327,7 @@ export default function MelodyList() {
)} - = 1 && n <= 16) value |= 1 << (n - 1); + for (const part of raw.split("+")) { + const n = Number.parseInt(part.trim(), 10); + if (Number.isInteger(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)); + if (!stepsStr || !String(stepsStr).trim()) return []; + return String(stepsStr).trim().split(",").map((s) => parseBellNotation(s)); } function normalizePlaybackUrl(url) { @@ -57,29 +53,31 @@ function normalizePlaybackUrl(url) { return `/api/${url}`; } -async function decodeBsmBinary(url) { - // Try with auth token first (for our API endpoints), then without (for Firebase URLs) +async function fetchBinaryResponse(url) { const token = localStorage.getItem("access_token"); - let res = null; - try { - res = await fetch(url, { + const 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) return res; + if (url.startsWith("http")) { + const retry = await fetch(url); + if (retry.ok) return retry; + throw new Error(`Failed to fetch binary: ${retry.statusText || retry.status}`); } + throw new Error(`Failed to fetch binary: ${res.statusText || res.status}`); + } catch (err) { + if (url.startsWith("http")) { + const retry = await fetch(url); + if (retry.ok) return retry; + throw new Error(`Failed to fetch binary: ${retry.statusText || retry.status}`); + } + throw err; } +} - if (!res.ok) throw new Error(`Failed to fetch binary: ${res.statusText}`); +async function decodeBsmBinary(url) { + const res = await fetchBinaryResponse(url); const buf = await res.arrayBuffer(); const view = new DataView(buf); const steps = []; @@ -89,10 +87,6 @@ async function decodeBsmBinary(url) { return steps; } -// ============================================================================ -// Speed math — exponential mapping -// ============================================================================ - function mapPercentageToSpeed(percent, minSpeed, maxSpeed) { if (minSpeed == null || maxSpeed == null) return null; const t = Math.max(0, Math.min(100, percent)) / 100; @@ -102,38 +96,22 @@ function mapPercentageToSpeed(percent, minSpeed, maxSpeed) { 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]; + const assignedBell = noteAssignments[bit]; if (assignedBell && assignedBell > 0) { result |= 1 << (assignedBell - 1); } - // assignedBell === 0 means silence — do not set any bell bit } } return result; } -// ============================================================================ -// Component -// ============================================================================ - const mutedStyle = { color: "var(--text-muted)" }; const labelStyle = { color: "var(--text-secondary)" }; - const NOTE_LABELS = "ABCDEFGHIJKLMNOP"; export default function PlaybackModal({ open, melody, builtMelody, files, archetypeCsv, onClose }) { @@ -151,8 +129,6 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet 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()); const audioCtxRef = useRef(null); @@ -168,7 +144,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet useEffect(() => { stepsRef.current = steps; }, [steps]); useEffect(() => { speedMsRef.current = speedMs; }, [speedMs]); useEffect(() => { toneLengthRef.current = toneLengthMs; }, [toneLengthMs]); - useEffect(() => { noteAssignmentsRef.current = noteAssignments; }, [noteAssignments]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { noteAssignmentsRef.current = noteAssignments; }, [noteAssignments]); useEffect(() => { loopEnabledRef.current = loopEnabled; }, [loopEnabled]); const stopPlayback = useCallback(() => { @@ -182,7 +158,6 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet setActiveBells(new Set()); }, []); - // Load steps on open useEffect(() => { if (!open) { stopPlayback(); @@ -217,7 +192,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet setLoadError(""); return; } - setLoadError(err.message); + setLoadError(err.message || "Failed to load melody data."); }) .finally(() => setLoading(false)); return; @@ -249,16 +224,12 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet if (!currentSteps.length) return; const playFrom = stepIndex % currentSteps.length; - const ctx = ensureAudioCtx(); const rawStepValue = currentSteps[playFrom]; - - // Map archetype notes → assigned bells const stepValue = applyNoteAssignments(rawStepValue, noteAssignmentsRef.current); setCurrentStep(playFrom); - // 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); @@ -267,27 +238,19 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet 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 flashTimer = setTimeout(() => setActiveBells(new Set()), toneLengthRef.current); const timer = setTimeout(() => { const next = playFrom + 1; if (next >= stepsRef.current.length) { - if (loopEnabledRef.current) { - scheduleStep(0); - } else { - stopPlayback(); - } + if (loopEnabledRef.current) scheduleStep(0); + else stopPlayback(); return; } scheduleStep(next); }, speedMsRef.current); playbackRef.current = { timer, flashTimer, stepIndex: playFrom }; - }, [stopPlayback]); // eslint-disable-line react-hooks/exhaustive-deps + }, [stopPlayback]); const handlePlay = () => { if (!stepsRef.current.length) return; @@ -295,15 +258,9 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet scheduleStep(0); }; - const handleStop = () => { - stopPlayback(); - }; - if (!open) return null; const totalSteps = steps.length; - - // Compute which bells are actually used (after assignment mapping) const allBellsUsed = steps.reduce((set, v) => { const mapped = applyNoteAssignments(v, noteAssignments); for (let bit = 0; bit < 16; bit++) { @@ -338,180 +295,139 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", - maxWidth: "480px", + maxWidth: "1100px", }} > - {/* Header */} -
+
-

- Melody Playback -

-

- {melody?.information?.name?.en || "Melody"} — looping -

+

Melody Playback

+

{melody?.information?.name?.en || "Melody"} - looping

- +
- {/* Body */}
- {loading && ( -

Loading binary...

- )} + {loading &&

Loading binary...

} {loadError && ( -
+
{loadError}
)} {!loading && !loadError && totalSteps > 0 && ( <> - {/* Step info */} -
- - {totalSteps} steps  ·  {allBellsUsed.size} bell{allBellsUsed.size !== 1 ? "s" : ""} - - {currentStep >= 0 && ( - - Step {currentStep + 1} / {totalSteps} - - )} -
- - {/* Note → Bell assignment visualizer (shows when assignments exist) */} - {noteAssignments.length > 0 ? ( -
-

Note → Assigned Bell

-
- {noteAssignments.map((assignedBell, noteIdx) => { - // A note is active (flashing) if its assigned bell is currently lit in activeBells - const firesABell = assignedBell && assignedBell > 0; - const isActive = firesABell && activeBells.has(assignedBell); - return ( -
- - {NOTE_LABELS[noteIdx]} - -
- - {assignedBell > 0 ? assignedBell : "—"} - -
- ); - })} +
+
+
+ {totalSteps} steps · {allBellsUsed.size} bell{allBellsUsed.size !== 1 ? "s" : ""} + {currentStep >= 0 && Step {currentStep + 1} / {totalSteps}}
-

Top = Note, Bottom = Bell assigned

-
- ) : null} - {/* Active Bell circles (always shown) */} - {maxBell > 0 && ( -
-

Active Bells

-
- {Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => { - const isActive = activeBells.has(b); - const isUsed = allBellsUsed.has(b); - return ( -
- {b} -
- ); - })} + {noteAssignments.length > 0 && ( +
+

Note to Assigned Bell

+
+ {noteAssignments.map((assignedBell, noteIdx) => { + const firesABell = assignedBell && assignedBell > 0; + const isActive = firesABell && activeBells.has(assignedBell); + return ( +
+ {NOTE_LABELS[noteIdx]} +
+ {assignedBell > 0 ? assignedBell : "-"} +
+ ); + })} +
+
+ )} + + {maxBell > 0 && ( +
+

Active Bells

+
+ {Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => { + const isActive = activeBells.has(b); + const isUsed = allBellsUsed.has(b); + return ( +
+ {b} +
+ ); + })} +
+
+ )} +
+ +
+
+ {!playing ? ( + + ) : ( + + )} + +
+ +
+
+ +
+ {speedPercent}% + {hasSpeedInfo && ({speedMs} ms/step)} +
+
+ setSpeedPercent(Number(e.target.value))} className="w-full h-2 rounded-lg appearance-none cursor-pointer" /> +
+ +
+
+ + {toneLengthMs} ms +
+ setToneLengthMs(Number(e.target.value))} className="w-full h-2 rounded-lg appearance-none cursor-pointer" />
- )} - - {/* Play / Stop */} -
- {!playing ? ( - - ) : ( - - )} -
- {/* Steps matrix */}

Note/Step Matrix

-
+
- + {steps.map((_, stepIdx) => ( {Array.from({ length: gridNoteCount }, (_, noteIdx) => ( - - + + {steps.map((stepValue, stepIdx) => { const enabled = Boolean(stepValue & (1 << noteIdx)); const isCurrent = currentStep === stepIdx; + const assignedBell = Number(noteAssignments[noteIdx] || 0); + const dotLabel = assignedBell > 0 ? assignedBell : noteIdx + 1; return ( ); @@ -574,77 +486,17 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet - {/* Speed Slider */} -
-
- -
- - {speedPercent}% - - {hasSpeedInfo && ( - - ({speedMs} ms/step) - - )} -
-
- setSpeedPercent(Number(e.target.value))} - className="w-full h-2 rounded-lg appearance-none cursor-pointer" - /> -
- 1% (slowest) - 100% (fastest) -
- {!hasSpeedInfo && ( -

- No MIN/MAX speed set for this melody — using linear fallback. -

- )} -
- - {/* Tone Length Slider */} -
-
- - - {toneLengthMs} ms - -
- setToneLengthMs(Number(e.target.value))} - className="w-full h-2 rounded-lg appearance-none cursor-pointer" - /> -
- Short (20 ms) - Long (400 ms) -
-
+ {!hasSpeedInfo && ( +

+ No MIN/MAX speed set for this melody - using linear fallback. +

+ )} )} - {/* Footer */} -
-
- Note \ Step - Note \\ Step
- {NOTE_LABELS[noteIdx]} -
{NOTE_LABELS[noteIdx]} -