More Archetype Fixes
This commit is contained in:
@@ -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 */}
|
||||
<div className="px-6 py-5 space-y-5">
|
||||
{/* Loading / error states */}
|
||||
{loading && (
|
||||
<p className="text-sm text-center py-4" style={mutedStyle}>Loading binary...</p>
|
||||
)}
|
||||
@@ -322,15 +351,15 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Note + Assignment visualizer */}
|
||||
{/* Note → Bell assignment visualizer (shows when assignments exist) */}
|
||||
{noteAssignments.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-xs mb-2" style={mutedStyle}>Note → Assigned Bell</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{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 (
|
||||
<div
|
||||
key={noteIdx}
|
||||
@@ -338,47 +367,56 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
||||
style={{
|
||||
minWidth: "36px",
|
||||
padding: "4px 6px",
|
||||
backgroundColor: isActive ? "var(--accent)" : "var(--bg-card-hover)",
|
||||
backgroundColor: isActive && firesABell
|
||||
? "var(--accent)"
|
||||
: isActive && !firesABell
|
||||
? "rgba(156,163,175,0.15)"
|
||||
: "var(--bg-card-hover)",
|
||||
borderColor: isActive ? "var(--accent)" : "var(--border-primary)",
|
||||
transform: isActive ? "scale(1.1)" : "scale(1)",
|
||||
transform: isActive && firesABell ? "scale(1.1)" : "scale(1)",
|
||||
opacity: isActive && !firesABell ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-bold leading-tight" style={{ color: isActive ? "var(--bg-primary)" : "var(--text-secondary)" }}>
|
||||
{noteNum}
|
||||
<span className="text-xs font-bold leading-tight" style={{ color: isActive && firesABell ? "var(--bg-primary)" : "var(--text-secondary)" }}>
|
||||
{NOTE_LABELS[noteIdx]}
|
||||
</span>
|
||||
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: isActive ? "rgba(255,255,255,0.4)" : "var(--border-primary)" }} />
|
||||
<span className="text-xs leading-tight" style={{ color: isActive ? "var(--bg-primary)" : "var(--text-muted)" }}>
|
||||
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: isActive && firesABell ? "rgba(255,255,255,0.4)" : "var(--border-primary)" }} />
|
||||
<span className="text-xs leading-tight" style={{ color: isActive && firesABell ? "var(--bg-primary)" : "var(--text-muted)" }}>
|
||||
{assignedBell > 0 ? assignedBell : "—"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1">
|
||||
<span className="text-xs" style={mutedStyle}>Top = Note, Bottom = Bell</span>
|
||||
</div>
|
||||
<p className="text-xs mt-1" style={mutedStyle}>Top = Note, Bottom = Bell assigned</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Fallback: simple bell circles when no assignments */
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => {
|
||||
const isActive = currentBells.includes(b);
|
||||
const isUsed = allBellsUsed.has(b);
|
||||
return (
|
||||
<div
|
||||
key={b}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all"
|
||||
style={{
|
||||
backgroundColor: isActive ? "var(--accent)" : isUsed ? "var(--bg-card-hover)" : "var(--bg-primary)",
|
||||
color: isActive ? "var(--bg-primary)" : isUsed ? "var(--text-secondary)" : "var(--border-primary)",
|
||||
border: `2px solid ${isActive ? "var(--accent)" : "var(--border-primary)"}`,
|
||||
transform: isActive ? "scale(1.2)" : "scale(1)",
|
||||
}}
|
||||
>
|
||||
{b}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
) : null}
|
||||
|
||||
{/* Active Bell circles (always shown) */}
|
||||
{maxBell > 0 && (
|
||||
<div>
|
||||
<p className="text-xs mb-2" style={mutedStyle}>Active Bells</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => {
|
||||
const isActive = activeBells.has(b);
|
||||
const isUsed = allBellsUsed.has(b);
|
||||
return (
|
||||
<div
|
||||
key={b}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{
|
||||
backgroundColor: isActive ? "#22c55e" : isUsed ? "var(--bg-card-hover)" : "var(--bg-primary)",
|
||||
color: isActive ? "#fff" : isUsed ? "var(--text-secondary)" : "var(--border-primary)",
|
||||
border: `2px solid ${isActive ? "#22c55e" : "var(--border-primary)"}`,
|
||||
transition: "background-color 0.05s, border-color 0.05s",
|
||||
transform: isActive ? "scale(1.15)" : "scale(1)",
|
||||
}}
|
||||
>
|
||||
{b}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -414,7 +452,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
||||
</span>
|
||||
{hasSpeedInfo && (
|
||||
<span className="text-xs ml-2" style={mutedStyle}>
|
||||
({speedMs} ms)
|
||||
({speedMs} ms/step)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -438,6 +476,29 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tone Length Slider */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-sm font-medium" style={labelStyle}>Tone Length</label>
|
||||
<span className="text-sm font-bold" style={{ color: "var(--accent)" }}>
|
||||
{toneLengthMs} ms
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="20"
|
||||
max="400"
|
||||
step="10"
|
||||
value={toneLengthMs}
|
||||
onChange={(e) => setToneLengthMs(Number(e.target.value))}
|
||||
className="w-full h-2 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<div className="flex justify-between text-xs mt-0.5" style={mutedStyle}>
|
||||
<span>Short (20 ms)</span>
|
||||
<span>Long (400 ms)</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user