CODEX - Major Composer Changes

This commit is contained in:
2026-02-23 20:56:29 +02:00
parent cd218a55fe
commit 64ea0a9a5d
5 changed files with 169 additions and 155 deletions

1
.gitignore vendored
View File

@@ -21,3 +21,4 @@ dist/
Thumbs.db Thumbs.db
MAIN-APP-REFERENCE/ MAIN-APP-REFERENCE/
SecondaryApps/

View File

@@ -1,10 +0,0 @@
You are working on the bellsystems-cp project at ~/bellsystems-cp/.
Read BellSystems_AdminPanel_Strategy.md for the full project strategy.
We are now building Phase X — [Phase Name].
Review the existing codebase first, then implement the following:
[list of tasks for that phase]
Ask me before making any major architectural decisions.
Commit when done.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -6,7 +6,8 @@ const MAX_NOTES = 16;
const NOTE_LABELS = "ABCDEFGHIJKLMNOP"; const NOTE_LABELS = "ABCDEFGHIJKLMNOP";
function bellFrequency(bellNumber) { function bellFrequency(bellNumber) {
return 880 * Math.pow(Math.pow(2, 1 / 12), -2 * (bellNumber - 1)); // One octave every 8 notes.
return 880 * Math.pow(2, -((bellNumber - 1) / 8));
} }
function stepToNotation(stepValue) { function stepToNotation(stepValue) {
@@ -22,6 +23,14 @@ function stepToHex(stepValue) {
return `0x${(stepValue >>> 0).toString(16).toUpperCase().padStart(4, "0")}`; 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) { function interpolateHue(t) {
const stops = [ const stops = [
[0.0, 190], [0.0, 190],
@@ -103,6 +112,7 @@ export default function MelodyComposer() {
const [noteCount, setNoteCount] = useState(8); const [noteCount, setNoteCount] = useState(8);
const [stepDelayMs, setStepDelayMs] = useState(280); const [stepDelayMs, setStepDelayMs] = useState(280);
const [noteDurationMs, setNoteDurationMs] = useState(110); const [noteDurationMs, setNoteDurationMs] = useState(110);
const [measureEvery, setMeasureEvery] = useState(0);
const [loopEnabled, setLoopEnabled] = useState(true); const [loopEnabled, setLoopEnabled] = useState(true);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [currentStep, setCurrentStep] = useState(-1); const [currentStep, setCurrentStep] = useState(-1);
@@ -114,6 +124,7 @@ export default function MelodyComposer() {
const [deployError, setDeployError] = useState(""); const [deployError, setDeployError] = useState("");
const [deploying, setDeploying] = useState(false); const [deploying, setDeploying] = useState(false);
const [noteColors, setNoteColors] = useState([]); const [noteColors, setNoteColors] = useState([]);
const [stepMenuIndex, setStepMenuIndex] = useState(null);
const audioCtxRef = useRef(null); const audioCtxRef = useRef(null);
const playbackRef = useRef(null); const playbackRef = useRef(null);
@@ -121,6 +132,7 @@ export default function MelodyComposer() {
const stepDelayRef = useRef(stepDelayMs); const stepDelayRef = useRef(stepDelayMs);
const noteDurationRef = useRef(noteDurationMs); const noteDurationRef = useRef(noteDurationMs);
const loopEnabledRef = useRef(loopEnabled); const loopEnabledRef = useRef(loopEnabled);
const stepMenuRef = useRef(null);
useEffect(() => { useEffect(() => {
stepsRef.current = steps; stepsRef.current = steps;
@@ -137,6 +149,16 @@ export default function MelodyComposer() {
useEffect(() => { useEffect(() => {
loopEnabledRef.current = loopEnabled; loopEnabledRef.current = loopEnabled;
}, [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(() => { const ensureAudioContext = useCallback(() => {
if (!audioCtxRef.current || audioCtxRef.current.state === "closed") { if (!audioCtxRef.current || audioCtxRef.current.state === "closed") {
@@ -238,6 +260,28 @@ export default function MelodyComposer() {
}; };
const clearAll = () => setSteps((prev) => prev.map(() => 0)); 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 = () => { const handlePlay = () => {
if (!stepsRef.current.length) return; if (!stepsRef.current.length) return;
@@ -317,6 +361,9 @@ export default function MelodyComposer() {
} }
return active; return active;
}, [currentStep, noteCount, steps]); }, [currentStep, noteCount, steps]);
const speedBpm = msToBpm(stepDelayMs);
const measureChoices = [0, 4, 8, 16, 32];
const measureSliderIdx = Math.max(0, measureChoices.indexOf(measureEvery));
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -359,153 +406,80 @@ export default function MelodyComposer() {
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
> >
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<button <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>
type="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>
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>
<span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span> <span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span>
<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>
type="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>
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>
<span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span> <span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span>
<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>
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 flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
<div className="text-xs" style={{ color: "var(--text-muted)" }}> <div className="text-xs" style={{ color: "var(--text-muted)" }}>{steps.length} steps, {noteCount} notes</div>
{steps.length} steps, {noteCount} notes <button type="button" onClick={openDeployModal} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}>Deploy Archetype</button>
</div>
<button
type="button"
onClick={openDeployModal}
className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap"
style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}
>
Deploy Archetype
</button>
</div> </div>
</div> </div>
</section>
<section <div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
className="rounded-lg border p-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex flex-wrap items-end gap-4"> <div className="flex flex-wrap items-end gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isPlaying ? ( {!isPlaying ? (
<button <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>
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>
)}
<button <button
type="button" type="button"
onClick={stopPlayback} onClick={() => setLoopEnabled((v) => !v)}
className="px-4 py-2 rounded-md text-sm" className="px-4 py-2 rounded-md text-sm inline-flex items-center gap-2"
style={{ backgroundColor: "var(--danger-btn)", color: "var(--text-white)" }} style={{
backgroundColor: loopEnabled ? "rgba(34,197,94,0.2)" : "var(--bg-card-hover)",
color: loopEnabled ? "#22c55e" : "var(--text-secondary)",
border: "1px solid var(--border-primary)",
}}
> >
Stop <span>Loop</span>
<span className="inline-block w-2.5 h-2.5 rounded-full" style={{ backgroundColor: loopEnabled ? "#22c55e" : "var(--text-muted)" }} />
</button> </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>
<span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span> <span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span>
</div> </div>
<div className="flex flex-wrap items-end gap-5"> <div className="flex flex-wrap items-end gap-5">
<div className="w-full sm:w-72"> <div className="w-full sm:w-72">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}> <label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>Speed in BPM</label>
Step Delay <span className="text-sm inline-flex items-baseline gap-2">
</label> <span style={{ color: "var(--accent)", fontWeight: 600 }}>{speedBpm} bpm</span>
<span className="text-sm font-semibold" style={{ color: "var(--accent)" }}> <span style={{ color: "var(--text-muted)", fontSize: "11px" }}>{stepDelayMs} ms</span>
{stepDelayMs} ms
</span> </span>
</div> </div>
<input <input type="range" min="30" max="1500" step="1" value={speedBpm} onChange={(e) => setStepDelayMs(bpmToMs(Number(e.target.value)))} className="w-full mt-2" />
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="w-full sm:w-72"> <div className="w-full sm:w-72">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}> <label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>Note Duration</label>
Note Duration <span className="text-sm font-semibold" style={{ color: "var(--accent)" }}>{noteDurationMs} ms</span>
</label>
<span className="text-sm font-semibold" style={{ color: "var(--accent)" }}>
{noteDurationMs} ms
</span>
</div>
<input
type="range"
min="20"
max="500"
step="10"
value={noteDurationMs}
onChange={(e) => setNoteDurationMs(Number(e.target.value))}
className="w-full mt-2"
/>
</div> </div>
<input type="range" min="20" max="500" step="10" value={noteDurationMs} onChange={(e) => setNoteDurationMs(Number(e.target.value))} className="w-full mt-2" />
</div> </div>
<span className="mx-1 text-sm self-center" style={{ color: "var(--border-primary)" }}>|</span>
<div className="w-full sm:w-36">
<div className="flex items-center justify-between">
<label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>Measure</label>
<span className="text-sm font-semibold" style={{ color: "var(--accent)" }}>{measureEvery || "Off"}</span>
</div>
<input type="range" min="0" max="4" step="1" value={measureSliderIdx} onChange={(e) => setMeasureEvery(measureChoices[Number(e.target.value)] || 0)} className="w-full mt-2" />
</div>
</div>
</div> </div>
{currentStep >= 0 && ( {currentStep >= 0 && (
<p className="text-xs mt-3" style={{ color: "var(--text-muted)" }}> <p className="text-xs mt-3" style={{ color: "var(--text-muted)" }}>
Playing step {currentStep + 1}/{steps.length} Playing step {currentStep + 1}/{steps.length}
{activeBellsInCurrentStep.length ? ` (bells: ${activeBellsInCurrentStep.join(", ")})` : " (silence)"} {activeBellsInCurrentStep.length ? ` (bells: ${activeBellsInCurrentStep.join(", ")})` : " (silence)"}
</p> </p>
)} )}
</div>
</section> </section>
<section <section
@@ -524,18 +498,65 @@ export default function MelodyComposer() {
</th> </th>
{steps.map((_, stepIndex) => { {steps.map((_, stepIndex) => {
const isCurrent = stepIndex === currentStep; const isCurrent = stepIndex === currentStep;
const measureHit = measureEvery > 0 && (stepIndex + 1) % measureEvery === 0;
const measureBlockOdd = measureEvery > 0 && Math.floor(stepIndex / measureEvery) % 2 === 1;
return ( return (
<th <th
key={stepIndex} key={stepIndex}
className="sticky top-0 z-20 px-2 py-2 text-xs font-semibold border-b border-r" className="sticky top-0 z-20 px-1 py-1 text-xs font-semibold border-b border-r relative"
style={{ style={{
minWidth: "44px", minWidth: "44px",
backgroundColor: isCurrent ? "rgba(116,184,22,0.24)" : "var(--bg-secondary)", backgroundColor: isCurrent
borderColor: "var(--border-primary)", ? "rgba(116,184,22,0.24)"
: (measureBlockOdd ? "rgba(255,255,255,0.02)" : "var(--bg-secondary)"),
borderColor: measureHit ? "rgba(255,255,255,0.38)" : "var(--border-primary)",
borderRightWidth: measureHit ? "2px" : "1px",
color: isCurrent ? "var(--accent)" : "var(--text-secondary)", color: isCurrent ? "var(--accent)" : "var(--text-secondary)",
}} }}
>
<button
type="button"
className="w-full px-1 py-1"
style={{ background: "transparent", border: "none", color: "inherit" }}
onClick={(e) => {
e.stopPropagation();
setStepMenuIndex((prev) => (prev === stepIndex ? null : stepIndex));
}}
> >
{stepIndex + 1} {stepIndex + 1}
</button>
{stepMenuIndex === stepIndex && (
<div
ref={stepMenuRef}
className="absolute right-0 top-full mt-1 w-28 rounded-md border shadow-lg z-50"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<button
type="button"
className="w-full text-left px-2 py-1.5 text-xs"
style={{ color: "var(--text-primary)", background: "transparent", border: "none" }}
onClick={() => { insertStepAt(stepIndex + 1); setStepMenuIndex(null); }}
>
Add After
</button>
<button
type="button"
className="w-full text-left px-2 py-1.5 text-xs"
style={{ color: "var(--text-primary)", background: "transparent", border: "none" }}
onClick={() => { insertStepAt(stepIndex); setStepMenuIndex(null); }}
>
Add Before
</button>
<button
type="button"
className="w-full text-left px-2 py-1.5 text-xs"
style={{ color: "var(--danger)", background: "transparent", border: "none" }}
onClick={() => { deleteStepAt(stepIndex); setStepMenuIndex(null); }}
>
Delete
</button>
</div>
)}
</th> </th>
); );
})} })}
@@ -557,13 +578,18 @@ export default function MelodyComposer() {
{steps.map((stepValue, stepIndex) => { {steps.map((stepValue, stepIndex) => {
const enabled = Boolean(stepValue & (1 << noteIndex)); const enabled = Boolean(stepValue & (1 << noteIndex));
const isCurrent = stepIndex === currentStep; const isCurrent = stepIndex === currentStep;
const measureHit = measureEvery > 0 && (stepIndex + 1) % measureEvery === 0;
const measureBlockOdd = measureEvery > 0 && Math.floor(stepIndex / measureEvery) % 2 === 1;
return ( return (
<td <td
key={`${noteIndex}-${stepIndex}`} key={`${noteIndex}-${stepIndex}`}
className="border-b border-r" className="border-b border-r"
style={{ style={{
borderColor: "var(--border-primary)", borderColor: measureHit ? "rgba(255,255,255,0.34)" : "var(--border-primary)",
backgroundColor: isCurrent ? "rgba(116,184,22,0.08)" : "transparent", borderRightWidth: measureHit ? "2px" : "1px",
backgroundColor: isCurrent
? "rgba(116,184,22,0.08)"
: (measureBlockOdd ? "rgba(255,255,255,0.015)" : "transparent"),
width: "44px", width: "44px",
height: "44px", height: "44px",
}} }}
@@ -608,12 +634,9 @@ export default function MelodyComposer() {
className="rounded-lg border p-4" className="rounded-lg border p-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} 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 className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div> <div>
<p className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>CSV-like notation</p> <p className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>Generated CSV notation</p>
<pre <pre
className="rounded-md p-3 text-xs overflow-auto" className="rounded-md p-3 text-xs overflow-auto"
style={{ backgroundColor: "var(--bg-primary)", border: "1px solid var(--border-primary)", color: "var(--text-primary)" }} style={{ backgroundColor: "var(--bg-primary)", border: "1px solid var(--border-primary)", color: "var(--text-primary)" }}
@@ -622,7 +645,7 @@ export default function MelodyComposer() {
</pre> </pre>
</div> </div>
<div> <div>
<p className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>PROGMEM values</p> <p className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>Generated PROGMEM Values</p>
<pre <pre
className="rounded-md p-3 text-xs overflow-auto" className="rounded-md p-3 text-xs overflow-auto"
style={{ backgroundColor: "var(--bg-primary)", border: "1px solid var(--border-primary)", color: "var(--text-primary)" }} style={{ backgroundColor: "var(--bg-primary)", border: "1px solid var(--border-primary)", color: "var(--text-primary)" }}

View File

@@ -1165,7 +1165,7 @@ export default function MelodyList() {
<div className="flex items-center justify-end gap-3 shrink-0 flex-wrap ml-auto"> <div className="flex items-center justify-end gap-3 shrink-0 flex-wrap ml-auto">
<span className="text-sm whitespace-nowrap" style={{ color: "var(--text-muted)" }}> <span className="text-sm whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
{hasAnyFilter {hasAnyFilter
? `Filtered - Showing ${displayRows.length} / ${allMelodyCount || melodies.length} Melodies | ${offlineTaggedCount} Melodies tagged for Offline` ? `Filtered - Showing ${displayRows.length} / ${allMelodyCount || melodies.length} Melodies | ${offlineTaggedCount} Offline-tagged`
: `Showing all (${allMelodyCount || melodies.length}) Melodies | ${offlineTaggedCount} Melodies tagged for Offline`} : `Showing all (${allMelodyCount || melodies.length}) Melodies | ${offlineTaggedCount} Melodies tagged for Offline`}
</span> </span>
{canEdit && ( {canEdit && (