CODEX BUILD - Added Melody Composer

This commit is contained in:
2026-02-22 20:03:21 +02:00
parent 1fe8c542db
commit fcc513a842
3 changed files with 456 additions and 0 deletions

View 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>
);
}