CODEX BUILD - Added Melody Composer
This commit is contained in:
@@ -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 */}
|
||||
<Route path="melodies" element={<PermissionGate section="melodies"><MelodyList /></PermissionGate>} />
|
||||
<Route path="melodies/settings" element={<PermissionGate section="melodies"><MelodySettings /></PermissionGate>} />
|
||||
<Route path="melodies/composer" element={<PermissionGate section="melodies" action="edit"><MelodyComposer /></PermissionGate>} />
|
||||
<Route path="melodies/new" element={<PermissionGate section="melodies" action="add"><MelodyForm /></PermissionGate>} />
|
||||
<Route path="melodies/archetypes" element={<PermissionGate section="melodies" action="edit"><ArchetypeList /></PermissionGate>} />
|
||||
<Route path="melodies/archetypes/new" element={<PermissionGate section="melodies" action="edit"><ArchetypeForm /></PermissionGate>} />
|
||||
@@ -158,3 +160,5 @@ export default function App() {
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
449
frontend/src/melodies/MelodyComposer.jsx
Normal file
449
frontend/src/melodies/MelodyComposer.jsx
Normal file
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||
Melody Composer
|
||||
</h1>
|
||||
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
Build bell-step melodies visually. Notes map directly to bell numbers (1-16).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="rounded-lg border p-4"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addStep}
|
||||
className="px-3 py-1.5 text-sm rounded-md"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
>
|
||||
+ Step
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeStep}
|
||||
className="px-3 py-1.5 text-sm rounded-md"
|
||||
style={{ backgroundColor: "var(--btn-neutral)", color: "var(--text-white)" }}
|
||||
>
|
||||
- Step
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addNote}
|
||||
disabled={noteCount >= MAX_NOTES}
|
||||
className="px-3 py-1.5 text-sm rounded-md disabled:opacity-50"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
>
|
||||
+ Note
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeNote}
|
||||
disabled={noteCount <= 1}
|
||||
className="px-3 py-1.5 text-sm rounded-md disabled:opacity-50"
|
||||
style={{ backgroundColor: "var(--btn-neutral)", color: "var(--text-white)" }}
|
||||
>
|
||||
- Note
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAll}
|
||||
className="px-3 py-1.5 text-sm rounded-md"
|
||||
style={{ backgroundColor: "var(--danger-btn)", color: "var(--text-white)" }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
|
||||
<div className="ml-auto text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
{steps.length} steps, {noteCount} notes
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="rounded-lg border p-4"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>
|
||||
Step Delay
|
||||
</label>
|
||||
<span className="text-sm font-semibold" style={{ color: "var(--accent)" }}>
|
||||
{stepDelayMs} ms
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="40"
|
||||
max="2000"
|
||||
step="10"
|
||||
value={stepDelayMs}
|
||||
onChange={(e) => setStepDelayMs(Number(e.target.value))}
|
||||
className="w-full mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>
|
||||
Note Duration
|
||||
</label>
|
||||
<span className="text-sm font-semibold" style={{ color: "var(--accent)" }}>
|
||||
{noteDurationMs} ms
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="20"
|
||||
max="1200"
|
||||
step="10"
|
||||
value={noteDurationMs}
|
||||
onChange={(e) => setNoteDurationMs(Number(e.target.value))}
|
||||
className="w-full mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||
{!isPlaying ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlay}
|
||||
className="px-4 py-2 rounded-md text-sm"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
>
|
||||
Play
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopPlayback}
|
||||
className="px-4 py-2 rounded-md text-sm"
|
||||
style={{ backgroundColor: "var(--danger-btn)", color: "var(--text-white)" }}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
|
||||
<label className="inline-flex items-center gap-2 text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={loopEnabled}
|
||||
onChange={(e) => setLoopEnabled(e.target.checked)}
|
||||
/>
|
||||
Loop
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentStep >= 0 && (
|
||||
<p className="text-xs mt-3" style={{ color: "var(--text-muted)" }}>
|
||||
Playing step {currentStep + 1}/{steps.length}
|
||||
{activeBellsInCurrentStep.length ? ` (bells: ${activeBellsInCurrentStep.join(", ")})` : " (silence)"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="rounded-lg border"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
<div className="overflow-auto" style={{ maxHeight: "60vh" }}>
|
||||
<table className="min-w-max border-separate border-spacing-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
className="sticky top-0 left-0 z-30 px-3 py-2 text-xs font-semibold border-b border-r"
|
||||
style={{ backgroundColor: "var(--bg-secondary)", borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
Note \\ Step
|
||||
</th>
|
||||
{steps.map((_, stepIndex) => {
|
||||
const isCurrent = stepIndex === currentStep;
|
||||
return (
|
||||
<th
|
||||
key={stepIndex}
|
||||
className="sticky top-0 z-20 px-2 py-2 text-xs font-semibold border-b border-r"
|
||||
style={{
|
||||
minWidth: "44px",
|
||||
backgroundColor: isCurrent ? "rgba(116,184,22,0.24)" : "var(--bg-secondary)",
|
||||
borderColor: "var(--border-primary)",
|
||||
color: isCurrent ? "var(--accent)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{stepIndex + 1}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: noteCount }, (_, noteIndex) => (
|
||||
<tr key={noteIndex}>
|
||||
<th
|
||||
className="sticky left-0 z-10 px-3 py-2 text-xs font-medium border-b border-r"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
borderColor: "var(--border-primary)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{noteIndex + 1}
|
||||
</th>
|
||||
{steps.map((stepValue, stepIndex) => {
|
||||
const enabled = Boolean(stepValue & (1 << noteIndex));
|
||||
const isCurrent = stepIndex === currentStep;
|
||||
return (
|
||||
<td
|
||||
key={`${noteIndex}-${stepIndex}`}
|
||||
className="border-b border-r p-1"
|
||||
style={{
|
||||
borderColor: "var(--border-primary)",
|
||||
backgroundColor: isCurrent ? "rgba(116,184,22,0.08)" : "transparent",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Toggle note ${noteIndex + 1} on step ${stepIndex + 1}`}
|
||||
onClick={() => toggleCell(noteIndex, stepIndex)}
|
||||
className="w-8 h-8 rounded-md border transition-colors"
|
||||
style={{
|
||||
borderColor: enabled ? "var(--accent)" : "var(--border-primary)",
|
||||
backgroundColor: enabled ? "var(--accent)" : "var(--bg-primary)",
|
||||
color: enabled ? "var(--bg-primary)" : "var(--text-muted)",
|
||||
fontSize: "11px",
|
||||
}}
|
||||
>
|
||||
{enabled ? "ON" : ""}
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="rounded-lg border p-4"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold mb-2" style={{ color: "var(--text-heading)" }}>
|
||||
Generated Output Preview
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>CSV-like notation</p>
|
||||
<pre
|
||||
className="rounded-md p-3 text-xs overflow-auto"
|
||||
style={{ backgroundColor: "var(--bg-primary)", border: "1px solid var(--border-primary)", color: "var(--text-primary)" }}
|
||||
>
|
||||
{`{${steps.map(stepToNotation).join(",")}}`}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>PROGMEM values</p>
|
||||
<pre
|
||||
className="rounded-md p-3 text-xs overflow-auto"
|
||||
style={{ backgroundColor: "var(--bg-primary)", border: "1px solid var(--border-primary)", color: "var(--text-primary)" }}
|
||||
>
|
||||
{`const uint16_t PROGMEM melody_builtin_custom[] = {\n ${steps.map(stepToHex).join(", ")}\n};`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user