import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import api from "../api/client"; const MAX_NOTES = 16; const NOTE_LABELS = "ABCDEFGHIJKLMNOP"; function bellFrequency(bellNumber) { // One octave every 8 notes. return 880 * Math.pow(2, -((bellNumber - 1) / 8)); } function stepToNotation(stepValue) { if (!stepValue) return "0"; const active = []; for (let bit = 0; bit < 16; bit++) { if (stepValue & (1 << bit)) active.push(bit + 1); } return active.join("+"); } function stepToHex(stepValue) { return `0x${(stepValue >>> 0).toString(16).toUpperCase().padStart(4, "0")}`; } function msToBpm(ms) { return Math.max(1, Math.round(60000 / Math.max(1, Number(ms) || 1))); } function bpmToMs(bpm) { return Math.max(20, Math.round(60000 / Math.max(1, Number(bpm) || 1))); } function interpolateHue(t) { const stops = [ [0.0, 190], [0.24, 140], [0.5, 56], [0.82, 30], [1.0, 0], ]; for (let i = 0; i < stops.length - 1; i++) { const [aPos, aHue] = stops[i]; const [bPos, bHue] = stops[i + 1]; if (t >= aPos && t <= bPos) { const local = (t - aPos) / (bPos - aPos || 1); return aHue + (bHue - aHue) * local; } } return stops[stops.length - 1][1]; } function noteDotColor(noteNumber) { const n = Number(noteNumber || 1); const t = Math.min(1, Math.max(0, (n - 1) / 15)); const hue = interpolateHue(t); return `hsl(${hue}, 78%, 68%)`; } function noteDotGlow(noteNumber) { const n = Number(noteNumber || 1); const t = Math.min(1, Math.max(0, (n - 1) / 15)); const hue = interpolateHue(t); return `hsla(${hue}, 78%, 56%, 0.45)`; } function noteDotColorFromSettings(noteNumber, colorPalette) { const n = Number(noteNumber || 1); const custom = colorPalette?.[n - 1]; return custom || noteDotColor(n); } function noteDotGlowFromSettings(noteNumber, colorPalette) { const n = Number(noteNumber || 1); const custom = colorPalette?.[n - 1]; return custom ? `${custom}66` : noteDotGlow(n); } function playStep(audioCtx, stepValue, noteDurationMs) { if (!audioCtx) return; const now = audioCtx.currentTime; const duration = Math.max(10, noteDurationMs) / 1000; const fadeIn = 0.005; const fadeOut = Math.min(0.03, duration / 2); for (let bit = 0; bit < 16; bit++) { if (stepValue & (1 << bit)) { const freq = bellFrequency(bit + 1); const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = "sine"; osc.frequency.setValueAtTime(freq, now); gain.gain.setValueAtTime(0, now); gain.gain.linearRampToValueAtTime(0.3, now + fadeIn); gain.gain.setValueAtTime(0.3, now + Math.max(duration - fadeOut, fadeIn + 0.001)); gain.gain.linearRampToValueAtTime(0, now + duration); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(now); osc.stop(now + duration); } } } function csvToSteps(csv) { if (!csv || !csv.trim()) return null; return csv.trim().split(",").map((token) => { const parts = token.split("+"); let val = 0; for (const p of parts) { const n = parseInt(p.trim(), 10); if (!isNaN(n) && n >= 1 && n <= 16) val |= (1 << (n - 1)); } return val; }); } export default function MelodyComposer() { const navigate = useNavigate(); const { state: routeState } = useLocation(); const loadedArchetype = routeState?.archetype || null; const initialSteps = () => { if (loadedArchetype?.steps) { const parsed = csvToSteps(loadedArchetype.steps); if (parsed?.length) return parsed; } return Array.from({ length: 16 }, () => 0); }; const [steps, setSteps] = useState(initialSteps); const [noteCount, setNoteCount] = useState(() => { if (loadedArchetype?.steps) { const parsed = csvToSteps(loadedArchetype.steps); if (parsed?.length) { let maxBit = 0; for (const v of parsed) { for (let b = 15; b >= 0; b--) { if (v & (1 << b)) { maxBit = Math.max(maxBit, b + 1); break; } } } return Math.max(8, maxBit); } } return 8; }); const [stepDelayMs, setStepDelayMs] = useState(280); const [noteDurationMs, setNoteDurationMs] = useState(110); const [measureEvery, setMeasureEvery] = useState(4); const [loopEnabled, setLoopEnabled] = useState(true); const [isPlaying, setIsPlaying] = useState(false); const [currentStep, setCurrentStep] = useState(-1); const [error, setError] = useState(""); const [successMsg, setSuccessMsg] = useState(""); const [showDeployModal, setShowDeployModal] = useState(false); const [deployName, setDeployName] = useState(""); const [deployPid, setDeployPid] = useState(""); const [deployError, setDeployError] = useState(""); const [deploying, setDeploying] = useState(false); const [deployMode, setDeployMode] = useState("new"); // "new" | "update" const [noteColors, setNoteColors] = useState([]); const [stepMenuIndex, setStepMenuIndex] = useState(null); const audioCtxRef = useRef(null); const playbackRef = useRef(null); const stepsRef = useRef(steps); const stepDelayRef = useRef(stepDelayMs); const noteDurationRef = useRef(noteDurationMs); const loopEnabledRef = useRef(loopEnabled); const stepMenuRef = useRef(null); useEffect(() => { stepsRef.current = steps; }, [steps]); useEffect(() => { stepDelayRef.current = stepDelayMs; }, [stepDelayMs]); useEffect(() => { noteDurationRef.current = noteDurationMs; }, [noteDurationMs]); useEffect(() => { loopEnabledRef.current = loopEnabled; }, [loopEnabled]); useEffect(() => { if (stepMenuIndex == null) return undefined; const onDocClick = (e) => { if (stepMenuRef.current && !stepMenuRef.current.contains(e.target)) { setStepMenuIndex(null); } }; document.addEventListener("mousedown", onDocClick); return () => document.removeEventListener("mousedown", onDocClick); }, [stepMenuIndex]); const ensureAudioContext = useCallback(() => { if (!audioCtxRef.current || audioCtxRef.current.state === "closed") { audioCtxRef.current = new (window.AudioContext || window.webkitAudioContext)(); } if (audioCtxRef.current.state === "suspended") { audioCtxRef.current.resume(); } return audioCtxRef.current; }, []); const stopPlayback = useCallback(() => { if (playbackRef.current?.timer) { clearTimeout(playbackRef.current.timer); } playbackRef.current = null; setIsPlaying(false); setCurrentStep(-1); }, []); const scheduleStep = useCallback((stepIndex) => { const currentSteps = stepsRef.current; if (!currentSteps.length) { stopPlayback(); return; } const ctx = ensureAudioContext(); const nextIndex = stepIndex % currentSteps.length; const stepValue = currentSteps[nextIndex]; setCurrentStep(nextIndex); playStep(ctx, stepValue, noteDurationRef.current); const isLastStep = nextIndex >= currentSteps.length - 1; const shouldContinue = !isLastStep || loopEnabledRef.current; if (!shouldContinue) { playbackRef.current = { timer: setTimeout(() => stopPlayback(), stepDelayRef.current), }; return; } playbackRef.current = { timer: setTimeout(() => { scheduleStep(isLastStep ? 0 : nextIndex + 1); }, stepDelayRef.current), }; }, [ensureAudioContext, stopPlayback]); useEffect(() => { return () => { stopPlayback(); }; }, [stopPlayback]); useEffect(() => { let canceled = false; api.get("/settings/melody") .then((s) => { if (canceled) return; setNoteColors((s?.note_assignment_colors || []).slice(0, 16)); }) .catch(() => { if (!canceled) setNoteColors([]); }); return () => { canceled = true; }; }, []); const toggleCell = (noteIndex, stepIndex) => { const bit = 1 << noteIndex; setSteps((prev) => { const next = [...prev]; next[stepIndex] = (next[stepIndex] || 0) ^ bit; return next; }); }; const addStep = () => setSteps((prev) => [...prev, 0]); const removeStep = () => { setSteps((prev) => { if (prev.length <= 1) return prev; const next = prev.slice(0, prev.length - 1); if (currentStep >= next.length) setCurrentStep(next.length - 1); return next; }); }; const addNote = () => setNoteCount((prev) => Math.min(MAX_NOTES, prev + 1)); const removeNote = () => { setNoteCount((prev) => { if (prev <= 1) return prev; const nextCount = prev - 1; const removedBitMask = ~((1 << nextCount) - 1); setSteps((currentSteps) => currentSteps.map((value) => value & ~removedBitMask)); return nextCount; }); }; const clearAll = () => setSteps((prev) => prev.map(() => 0)); const insertStepAt = (index) => { setSteps((prev) => { const next = [...prev]; next.splice(index, 0, 0); return next; }); setCurrentStep((prev) => (prev >= index ? prev + 1 : prev)); }; const deleteStepAt = (index) => { setSteps((prev) => { if (prev.length <= 1) return prev; const next = [...prev]; next.splice(index, 1); return next; }); setCurrentStep((prev) => { if (prev < 0) return prev; if (prev === index) return -1; if (prev > index) return prev - 1; return prev; }); }; const handlePlay = () => { if (!stepsRef.current.length) return; setError(""); setIsPlaying(true); scheduleStep(0); }; const openDeployModal = (mode = "new") => { setError(""); setSuccessMsg(""); setDeployError(""); setDeployMode(mode); if (mode === "update" && loadedArchetype) { setDeployName(loadedArchetype.name || ""); setDeployPid(loadedArchetype.pid || ""); } else { setDeployName(""); setDeployPid(""); } setShowDeployModal(true); }; const closeDeployModal = () => { if (deploying) return; setDeployError(""); setShowDeployModal(false); }; const handleDeploy = async () => { const name = deployName.trim(); const pid = deployPid.trim(); if (!name) { setDeployError("Name is required."); return; } if (!pid) { setDeployError("PID is required."); return; } setDeploying(true); setDeployError(""); setSuccessMsg(""); try { const stepsStr = steps.map(stepToNotation).join(","); if (deployMode === "update" && loadedArchetype?.id) { const updated = await api.put(`/builder/melodies/${loadedArchetype.id}`, { name, pid, steps: stepsStr }); setSuccessMsg(`Archetype "${name}" updated successfully.`); setShowDeployModal(false); if (updated?.id) navigate(`/melodies/archetypes/${updated.id}`); } else { const existing = await api.get("/builder/melodies"); const list = existing.melodies || []; const dupName = list.find((m) => m.name.toLowerCase() === name.toLowerCase()); if (dupName) { setDeployError(`An archetype with the name "${name}" already exists.`); return; } const dupPid = list.find((m) => m.pid && m.pid.toLowerCase() === pid.toLowerCase()); if (dupPid) { setDeployError(`An archetype with the PID "${pid}" already exists.`); return; } const created = await api.post("/builder/melodies", { name, pid, steps: stepsStr }); setSuccessMsg(`Archetype "${name}" deployed successfully.`); setShowDeployModal(false); setDeployName(""); setDeployPid(""); if (created?.id) navigate(`/melodies/archetypes/${created.id}`); } } catch (err) { setDeployError(err.message); } finally { setDeploying(false); } }; const activeBellsInCurrentStep = useMemo(() => { if (currentStep < 0 || !steps[currentStep]) return []; const active = []; for (let bit = 0; bit < noteCount; bit++) { if (steps[currentStep] & (1 << bit)) active.push(bit + 1); } return active; }, [currentStep, noteCount, steps]); const speedBpm = msToBpm(stepDelayMs); const measureSliderValue = Math.max(1, Math.min(16, Number(measureEvery) || 1)); return (
Build bell-step melodies visually. Notes map directly to bell numbers (1-16).
| Note \\ Step | {steps.map((_, stepIndex) => { const isCurrent = stepIndex === currentStep; const measureHit = measureEvery > 0 && (stepIndex + 1) % measureEvery === 0; const measureBlockOdd = measureEvery > 0 && Math.floor(stepIndex / measureEvery) % 2 === 1; return (
{stepMenuIndex === stepIndex && (
|
);
})}
|---|---|
| {NOTE_LABELS[noteIndex]} | {steps.map((stepValue, stepIndex) => { const enabled = Boolean(stepValue & (1 << noteIndex)); const isCurrent = stepIndex === currentStep; const measureHit = measureEvery > 0 && (stepIndex + 1) % measureEvery === 0; const measureBlockOdd = measureEvery > 0 && Math.floor(stepIndex / measureEvery) % 2 === 1; return (); })} |
Generated CSV notation
{`{${steps.map(stepToNotation).join(",")}}`}
Generated PROGMEM Values
{`const uint16_t PROGMEM melody_builtin_custom[] = {\n ${steps.map(stepToHex).join(", ")}\n};`}
{deployMode === "update" ? "Rebuild the existing archetype with the current composer pattern." : "Create a new archetype from this composer pattern."}