CODEX - Major Composer Changes
This commit is contained in:
@@ -6,7 +6,8 @@ const MAX_NOTES = 16;
|
||||
const NOTE_LABELS = "ABCDEFGHIJKLMNOP";
|
||||
|
||||
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) {
|
||||
@@ -22,6 +23,14 @@ 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],
|
||||
@@ -103,6 +112,7 @@ export default function MelodyComposer() {
|
||||
const [noteCount, setNoteCount] = useState(8);
|
||||
const [stepDelayMs, setStepDelayMs] = useState(280);
|
||||
const [noteDurationMs, setNoteDurationMs] = useState(110);
|
||||
const [measureEvery, setMeasureEvery] = useState(0);
|
||||
const [loopEnabled, setLoopEnabled] = useState(true);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(-1);
|
||||
@@ -114,6 +124,7 @@ export default function MelodyComposer() {
|
||||
const [deployError, setDeployError] = useState("");
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
const [noteColors, setNoteColors] = useState([]);
|
||||
const [stepMenuIndex, setStepMenuIndex] = useState(null);
|
||||
|
||||
const audioCtxRef = useRef(null);
|
||||
const playbackRef = useRef(null);
|
||||
@@ -121,6 +132,7 @@ export default function MelodyComposer() {
|
||||
const stepDelayRef = useRef(stepDelayMs);
|
||||
const noteDurationRef = useRef(noteDurationMs);
|
||||
const loopEnabledRef = useRef(loopEnabled);
|
||||
const stepMenuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
stepsRef.current = steps;
|
||||
@@ -137,6 +149,16 @@ export default function MelodyComposer() {
|
||||
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") {
|
||||
@@ -238,6 +260,28 @@ export default function MelodyComposer() {
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -317,6 +361,9 @@ export default function MelodyComposer() {
|
||||
}
|
||||
return active;
|
||||
}, [currentStep, noteCount, steps]);
|
||||
const speedBpm = msToBpm(stepDelayMs);
|
||||
const measureChoices = [0, 4, 8, 16, 32];
|
||||
const measureSliderIdx = Math.max(0, measureChoices.indexOf(measureEvery));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -359,153 +406,80 @@ export default function MelodyComposer() {
|
||||
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={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>
|
||||
<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={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>
|
||||
<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>
|
||||
|
||||
<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="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
{steps.length} steps, {noteCount} notes
|
||||
</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 className="text-xs" style={{ color: "var(--text-muted)" }}>{steps.length} steps, {noteCount} notes</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>
|
||||
</section>
|
||||
|
||||
<section
|
||||
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 items-center gap-2">
|
||||
{!isPlaying ? (
|
||||
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{!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>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlay}
|
||||
className="px-4 py-2 rounded-md text-sm"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
onClick={() => setLoopEnabled((v) => !v)}
|
||||
className="px-4 py-2 rounded-md text-sm inline-flex items-center gap-2"
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
Play
|
||||
<span>Loop</span>
|
||||
<span className="inline-block w-2.5 h-2.5 rounded-full" style={{ backgroundColor: loopEnabled ? "#22c55e" : "var(--text-muted)" }} />
|
||||
</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>
|
||||
<span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-5">
|
||||
<div className="w-full sm:w-72">
|
||||
<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"
|
||||
/>
|
||||
<span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full sm:w-72">
|
||||
<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 className="flex flex-wrap items-end gap-5">
|
||||
<div className="w-full sm:w-72">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>Speed in BPM</label>
|
||||
<span className="text-sm inline-flex items-baseline gap-2">
|
||||
<span style={{ color: "var(--accent)", fontWeight: 600 }}>{speedBpm} bpm</span>
|
||||
<span style={{ color: "var(--text-muted)", fontSize: "11px" }}>{stepDelayMs} ms</span>
|
||||
</span>
|
||||
</div>
|
||||
<input type="range" min="30" max="1500" step="1" value={speedBpm} onChange={(e) => setStepDelayMs(bpmToMs(Number(e.target.value)))} className="w-full mt-2" />
|
||||
</div>
|
||||
|
||||
<div className="w-full sm:w-72">
|
||||
<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="500" step="10" value={noteDurationMs} onChange={(e) => setNoteDurationMs(Number(e.target.value))} className="w-full mt-2" />
|
||||
</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>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</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
|
||||
@@ -524,18 +498,65 @@ export default function MelodyComposer() {
|
||||
</th>
|
||||
{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 (
|
||||
<th
|
||||
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={{
|
||||
minWidth: "44px",
|
||||
backgroundColor: isCurrent ? "rgba(116,184,22,0.24)" : "var(--bg-secondary)",
|
||||
borderColor: "var(--border-primary)",
|
||||
backgroundColor: isCurrent
|
||||
? "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)",
|
||||
}}
|
||||
>
|
||||
{stepIndex + 1}
|
||||
<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}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
@@ -557,13 +578,18 @@ export default function MelodyComposer() {
|
||||
{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 (
|
||||
<td
|
||||
key={`${noteIndex}-${stepIndex}`}
|
||||
className="border-b border-r"
|
||||
style={{
|
||||
borderColor: "var(--border-primary)",
|
||||
backgroundColor: isCurrent ? "rgba(116,184,22,0.08)" : "transparent",
|
||||
borderColor: measureHit ? "rgba(255,255,255,0.34)" : "var(--border-primary)",
|
||||
borderRightWidth: measureHit ? "2px" : "1px",
|
||||
backgroundColor: isCurrent
|
||||
? "rgba(116,184,22,0.08)"
|
||||
: (measureBlockOdd ? "rgba(255,255,255,0.015)" : "transparent"),
|
||||
width: "44px",
|
||||
height: "44px",
|
||||
}}
|
||||
@@ -608,12 +634,9 @@ export default function MelodyComposer() {
|
||||
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>
|
||||
<p className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>Generated CSV 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)" }}
|
||||
@@ -622,7 +645,7 @@ export default function MelodyComposer() {
|
||||
</pre>
|
||||
</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
|
||||
className="rounded-md p-3 text-xs overflow-auto"
|
||||
style={{ backgroundColor: "var(--bg-primary)", border: "1px solid var(--border-primary)", color: "var(--text-primary)" }}
|
||||
|
||||
@@ -1165,7 +1165,7 @@ export default function MelodyList() {
|
||||
<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)" }}>
|
||||
{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`}
|
||||
</span>
|
||||
{canEdit && (
|
||||
|
||||
Reference in New Issue
Block a user