diff --git a/frontend/src/melodies/MelodyDetail.jsx b/frontend/src/melodies/MelodyDetail.jsx index 7e72450..996e406 100644 --- a/frontend/src/melodies/MelodyDetail.jsx +++ b/frontend/src/melodies/MelodyDetail.jsx @@ -42,6 +42,44 @@ function Field({ label, children }) { ); } +function UrlField({ label, value }) { + const [copied, setCopied] = useState(false); + return ( +
+
+ {label} +
+
+ + {value} + + +
+
+ ); +} + export default function MelodyDetail() { const { id } = useParams(); const navigate = useNavigate(); @@ -333,9 +371,11 @@ export default function MelodyDetail() {
{melody.id} {melody.pid} -
- {melody.url} -
+ {melody.url && ( +
+ +
+ )}
@@ -384,7 +424,7 @@ export default function MelodyDetail() { }} > - {noteIdx + 1} + {String.fromCharCode(65 + noteIdx)}
@@ -396,7 +436,7 @@ export default function MelodyDetail() { ) : ( - )} -

Top = Note #, Bottom = Assigned Bell

+

Top = Note, Bottom = Assigned Bell

@@ -432,9 +472,13 @@ export default function MelodyDetail() { e.preventDefault(); try { const token = localStorage.getItem("access_token"); - const res = await fetch(binaryUrl, { + 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")) { + res = await fetch(binaryUrl); + } if (!res.ok) throw new Error(`Download failed: ${res.statusText}`); const blob = await res.blob(); const objectUrl = URL.createObjectURL(blob); diff --git a/frontend/src/melodies/MelodyForm.jsx b/frontend/src/melodies/MelodyForm.jsx index 3b25901..304142d 100644 --- a/frontend/src/melodies/MelodyForm.jsx +++ b/frontend/src/melodies/MelodyForm.jsx @@ -616,7 +616,7 @@ export default function MelodyForm() {
{Array.from({ length: information.totalNotes }, (_, i) => (
- + { 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" @@ -789,6 +789,7 @@ export default function MelodyForm() { open={showSelectBuilt} melodyId={id || savedMelodyId} currentMelody={{ information, default_settings: settings, type, url, uid, pid }} + currentBuiltMelody={builtMelody} onClose={() => setShowSelectBuilt(false)} onSuccess={(archetype) => { setShowSelectBuilt(false); @@ -801,6 +802,7 @@ export default function MelodyForm() { open={showBuildOnTheFly} melodyId={id || savedMelodyId} currentMelody={{ information, default_settings: settings, type, url, uid, pid }} + currentBuiltMelody={builtMelody} defaultName={getLocalizedValue(information.name, "en", "") || getLocalizedValue(information.name, editLang, "")} defaultPid={pid} onClose={() => setShowBuildOnTheFly(false)} diff --git a/frontend/src/melodies/PlaybackModal.jsx b/frontend/src/melodies/PlaybackModal.jsx index d53a12a..10244d6 100644 --- a/frontend/src/melodies/PlaybackModal.jsx +++ b/frontend/src/melodies/PlaybackModal.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; // ============================================================================ -// Web Audio Engine (shared with SpeedCalculatorModal pattern) +// Web Audio Engine // ============================================================================ 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) { notation = notation.trim(); if (notation === "0" || !notation) return 0; @@ -59,10 +51,27 @@ function parseStepsString(stepsStr) { } 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 res = await fetch(url, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); + let res = null; + + 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}`); const buf = await res.arrayBuffer(); const view = new DataView(buf); @@ -74,9 +83,7 @@ async function decodeBsmBinary(url) { } // ============================================================================ -// Speed math — exponential mapping matching the Flutter app: -// value = minSpeed * pow(maxSpeed / minSpeed, t) where t = percent / 100 -// Note: in this system, MIN ms > MAX ms (MIN = slowest, MAX = fastest). +// Speed math — exponential mapping // ============================================================================ function mapPercentageToSpeed(percent, minSpeed, maxSpeed) { @@ -88,20 +95,44 @@ 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]; + if (assignedBell && assignedBell > 0) { + result |= 1 << (assignedBell - 1); + } + // assignedBell === 0 means silence — do not set any bell bit + } + } + return result; +} + // ============================================================================ // Component // ============================================================================ -const BEAT_DURATION_MS = 80; // fixed tone length for playback - 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 }) { const info = melody?.information || {}; const minSpeed = info.minSpeed || null; const maxSpeed = info.maxSpeed || null; - // Note assignments: maps note index → bell number to fire const noteAssignments = melody?.default_settings?.noteAssignments || []; const [steps, setSteps] = useState([]); @@ -111,29 +142,37 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet const [playing, setPlaying] = useState(false); const [currentStep, setCurrentStep] = useState(-1); 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 playbackRef = useRef(null); const stepsRef = useRef([]); 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; - // Keep refs in sync so the playback loop reads live values 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 const stopPlayback = useCallback(() => { if (playbackRef.current) { clearTimeout(playbackRef.current.timer); + if (playbackRef.current.flashTimer) clearTimeout(playbackRef.current.flashTimer); playbackRef.current = null; } setPlaying(false); setCurrentStep(-1); + setActiveBells(new Set()); }, []); - // Load steps on open — prefer archetype_csv, fall back to binary + // Load steps on open useEffect(() => { if (!open) { stopPlayback(); @@ -141,10 +180,10 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet setCurrentStep(-1); setLoadError(""); setSpeedPercent(50); + setActiveBells(new Set()); return; } - // Prefer CSV from archetype (no network request needed) const csv = archetypeCsv || info.archetype_csv || null; if (csv) { const parsed = parseStepsString(csv); @@ -154,7 +193,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet return; } - // Fall back to binary fetch — prefer uploaded file, then legacy melody.url + // Fall back to binary const binaryUrl = builtMelody?.binary_url ? `/api${builtMelody.binary_url}` : files?.binary_url || melody?.url || null; @@ -191,49 +230,36 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet const playFrom = stepIndex % currentSteps.length; - // End of sequence — loop back - if (playFrom === 0 && stepIndex > 0) { - scheduleStep(0); - return; - } - const ctx = ensureAudioCtx(); const rawStepValue = currentSteps[playFrom]; - // Apply note assignments: each note in the step maps to an assigned bell number - // noteAssignments[noteIndex] = bellNumber (1-based). We rebuild the step value - // 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); - } - } + // Map archetype notes → assigned bells + const stepValue = applyNoteAssignments(rawStepValue, noteAssignmentsRef.current); 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 next = playFrom + 1; - if (next >= stepsRef.current.length) { - scheduleStep(0); - } else { - scheduleStep(next); - } + scheduleStep(next >= stepsRef.current.length ? 0 : next); }, speedMsRef.current); - playbackRef.current = { timer, stepIndex: playFrom }; - }, []); // no deps — reads everything from refs + playbackRef.current = { timer, flashTimer, stepIndex: playFrom }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps const handlePlay = () => { if (!stepsRef.current.length) return; @@ -248,12 +274,16 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet if (!open) return null; const totalSteps = steps.length; + + // Compute which bells are actually used (after assignment mapping) 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; }, new Set()); 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; @@ -268,7 +298,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", - maxWidth: "460px", + maxWidth: "480px", }} > {/* Header */} @@ -295,7 +325,6 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet {/* Body */}
- {/* Loading / error states */} {loading && (

Loading binary...

)} @@ -322,15 +351,15 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet )}
- {/* Note + Assignment visualizer */} + {/* Note → Bell assignment visualizer (shows when assignments exist) */} {noteAssignments.length > 0 ? (

Note → Assigned Bell

{noteAssignments.map((assignedBell, noteIdx) => { - const noteNum = noteIdx + 1; - // A note is active if the current step has this note bit set (raw) - const isActive = currentStep >= 0 && Boolean(steps[currentStep] & (1 << (noteNum - 1))); + // A note is active if the current step has this note's bit set (raw archetype value) + const isActive = currentStep >= 0 && Boolean(steps[currentStep] & (1 << noteIdx)); + const firesABell = assignedBell && assignedBell > 0; return (
- - {noteNum} + + {NOTE_LABELS[noteIdx]} -
- +
+ {assignedBell > 0 ? assignedBell : "—"}
); })}
-
- Top = Note, Bottom = Bell -
+

Top = Note, Bottom = Bell assigned

- ) : ( - /* Fallback: simple bell circles when no assignments */ -
- {Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => { - const isActive = currentBells.includes(b); - const isUsed = allBellsUsed.has(b); - return ( -
- {b} -
- ); - })} + ) : 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} +
+ ); + })} +
)} @@ -414,7 +452,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet {hasSpeedInfo && ( - ({speedMs} ms) + ({speedMs} ms/step) )}
@@ -438,6 +476,29 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet

)}
+ + {/* 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) +
+
)}
diff --git a/frontend/src/melodies/archetypes/ArchetypeList.jsx b/frontend/src/melodies/archetypes/ArchetypeList.jsx index 7f25d19..66932eb 100644 --- a/frontend/src/melodies/archetypes/ArchetypeList.jsx +++ b/frontend/src/melodies/archetypes/ArchetypeList.jsx @@ -381,6 +381,14 @@ export default function ArchetypeList() { {deleteTarget.assigned_melody_ids.length} {deleteTarget.assigned_melody_ids.length === 1 ? "melody" : "melodies"}. Deleting it will remove the archetype and its binary file, but existing melodies will keep their uploaded binary.
+

Do you still want to delete this archetype?

- )} -
- -
- {error && ( -
- {error} -
- )} - -
-
- - setName(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} disabled={building} /> -
-
- - setPid(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} disabled={building} /> -
-
- -
-
- - {countSteps(steps)} steps -
-