diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a5e1dc5..368669c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,7 @@ import MelodyList from "./melodies/MelodyList"; import MelodyDetail from "./melodies/MelodyDetail"; import MelodyForm from "./melodies/MelodyForm"; import MelodySettings from "./melodies/MelodySettings"; +import MelodyComposer from "./melodies/MelodyComposer"; import ArchetypeList from "./melodies/archetypes/ArchetypeList"; import ArchetypeForm from "./melodies/archetypes/ArchetypeForm"; import DeviceList from "./devices/DeviceList"; @@ -117,6 +118,7 @@ export default function App() { {/* Melodies */} } /> } /> + } /> } /> } /> } /> @@ -158,3 +160,5 @@ export default function App() { ); } + + diff --git a/frontend/src/layout/Sidebar.jsx b/frontend/src/layout/Sidebar.jsx index 650849e..57199d2 100644 --- a/frontend/src/layout/Sidebar.jsx +++ b/frontend/src/layout/Sidebar.jsx @@ -11,6 +11,7 @@ const navItems = [ { to: "/melodies", label: "Main Editor" }, { to: "/melodies/archetypes", label: "Archetypes" }, { to: "/melodies/settings", label: "Settings" }, + { to: "/melodies/composer", label: "Composer" }, ], }, { to: "/devices", label: "Devices", permission: "devices" }, @@ -172,3 +173,5 @@ function CollapsibleGroup({ label, children, currentPath, locked = false }) { ); } + + diff --git a/frontend/src/melodies/MelodyComposer.jsx b/frontend/src/melodies/MelodyComposer.jsx new file mode 100644 index 0000000..2092a93 --- /dev/null +++ b/frontend/src/melodies/MelodyComposer.jsx @@ -0,0 +1,449 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +const MAX_NOTES = 16; + +function bellFrequency(bellNumber) { + return 880 * Math.pow(Math.pow(2, 1 / 12), -2 * (bellNumber - 1)); +} + +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 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); + } + } +} + +export default function MelodyComposer() { + const [steps, setSteps] = useState(Array.from({ length: 16 }, () => 0)); + const [noteCount, setNoteCount] = useState(8); + const [stepDelayMs, setStepDelayMs] = useState(280); + const [noteDurationMs, setNoteDurationMs] = useState(110); + const [loopEnabled, setLoopEnabled] = useState(true); + const [isPlaying, setIsPlaying] = useState(false); + const [currentStep, setCurrentStep] = useState(-1); + + const audioCtxRef = useRef(null); + const playbackRef = useRef(null); + const stepsRef = useRef(steps); + const stepDelayRef = useRef(stepDelayMs); + const noteDurationRef = useRef(noteDurationMs); + const loopEnabledRef = useRef(loopEnabled); + + useEffect(() => { + stepsRef.current = steps; + }, [steps]); + + useEffect(() => { + stepDelayRef.current = stepDelayMs; + }, [stepDelayMs]); + + useEffect(() => { + noteDurationRef.current = noteDurationMs; + }, [noteDurationMs]); + + useEffect(() => { + loopEnabledRef.current = loopEnabled; + }, [loopEnabled]); + + 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]); + + 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 handlePlay = () => { + if (!stepsRef.current.length) return; + setIsPlaying(true); + scheduleStep(0); + }; + + 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]); + + return ( +
+
+

+ Melody Composer +

+

+ Build bell-step melodies visually. Notes map directly to bell numbers (1-16). +

+
+ +
+
+ + + + + + +
+ {steps.length} steps, {noteCount} notes +
+
+
+ +
+
+
+
+ + + {stepDelayMs} ms + +
+ setStepDelayMs(Number(e.target.value))} + className="w-full mt-2" + /> +
+ +
+
+ + + {noteDurationMs} ms + +
+ setNoteDurationMs(Number(e.target.value))} + className="w-full mt-2" + /> +
+ +
+ {!isPlaying ? ( + + ) : ( + + )} + + +
+
+ + {currentStep >= 0 && ( +

+ Playing step {currentStep + 1}/{steps.length} + {activeBellsInCurrentStep.length ? ` (bells: ${activeBellsInCurrentStep.join(", ")})` : " (silence)"} +

+ )} +
+ +
+
+ + + + + {steps.map((_, stepIndex) => { + const isCurrent = stepIndex === currentStep; + return ( + + ); + })} + + + + {Array.from({ length: noteCount }, (_, noteIndex) => ( + + + {steps.map((stepValue, stepIndex) => { + const enabled = Boolean(stepValue & (1 << noteIndex)); + const isCurrent = stepIndex === currentStep; + return ( + + ); + })} + + ))} + +
+ Note \\ Step + + {stepIndex + 1} +
+ {noteIndex + 1} + + +
+
+
+ +
+

+ Generated Output Preview +

+
+
+

CSV-like notation

+
+{`{${steps.map(stepToNotation).join(",")}}`}
+            
+
+
+

PROGMEM values

+
+{`const uint16_t PROGMEM melody_builtin_custom[] = {\n  ${steps.map(stepToHex).join(", ")}\n};`}
+            
+
+
+
+
+ ); +}