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 && (
+
+
+
+
+ |
+ Note \ Step
+ |
+ {steps.map((_, stepIdx) => (
+
+ {stepIdx + 1}
+ |
+ ))}
+
+
+
+ {Array.from({ length: gridNoteCount }, (_, noteIdx) => (
+
+ |
+ {NOTE_LABELS[noteIdx]}
+ |
+ {steps.map((stepValue, stepIdx) => {
+ const enabled = Boolean(stepValue & (1 << noteIdx));
+ return (
+
+
+
+
+ |
+ );
+ })}
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
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() {