Skip to content

Commit f734e4a

Browse files
committed
feat: connect drum sequencer to clip model
1 parent bc3de0a commit f734e4a

9 files changed

Lines changed: 384 additions & 55 deletions

File tree

PLANS.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,8 @@ M1 should include:
2929

3030
Use this section as the current execution order for agent work. Feature document numbering describes the planned product sequence, but actual issue order may change as dependencies, review feedback, or implementation risks become clearer.
3131

32-
1. #5 Lookahead Scheduler -> `docs/features/03-lookahead-scheduler.md` (PR #14 in review)
33-
2. #3 Drum Step Sequencer -> `docs/features/04-drum-step-sequencer.md`
34-
3. #2 Basic Piano Roll -> `docs/features/05-basic-piano-roll.md`
32+
1. #3 Drum Step Sequencer -> `docs/features/04-drum-step-sequencer.md` (implementation in progress)
33+
2. #2 Basic Piano Roll -> `docs/features/05-basic-piano-roll.md`
3534

3635
## Planned Milestones
3736

docs/data-model.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,19 @@ Early clips may contain both drum events and note events. This keeps the M1 edit
2828

2929
Later, the model can evolve toward separate drum, MIDI, and audio clip types if arrangement and editing workflows need stronger separation.
3030

31+
## Initial Drum Clip Implementation
32+
33+
The initial drum step sequencer stores drum hits as serializable `DrumEvent` objects in the selected hybrid clip. A 16-step grid maps step indices to ticks with `stepIndex * 120`.
34+
35+
Initial drum lanes map to bundled sample IDs:
36+
37+
- `kick` -> `kick`
38+
- `snare` -> `snare`
39+
- `closedHat` -> `closed-hat`
40+
- `openHat` -> `open-hat`
41+
42+
Drum event IDs are deterministic within a clip using the clip ID, lane ID, and start tick. Runtime playback converts these serializable events into audio engine sample loop events; the project model itself does not store `AudioBuffer` or other Web Audio objects.
43+
3144
## Illustrative Types
3245

3346
These snippets show model intent. Implementation may refine names and fields, but changes to model semantics must update this document.

docs/features/04-drum-step-sequencer.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Feature: 04 Drum Step Sequencer
22

33
## Status
4-
Planned
4+
In Progress
55

66
## Goal
77

src/app/App.module.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@
7171
padding: var(--space-unit) var(--space-gutter);
7272
}
7373

74+
.clipMeta .errorMeta {
75+
border-color: var(--color-status-error);
76+
color: var(--color-status-error);
77+
text-transform: none;
78+
}
79+
7480
.editorStack {
7581
display: grid;
7682
gap: var(--space-panel);

src/app/App.tsx

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState } from "react";
22

3+
import { createAudioEngine, type SampleLoopEvent } from "../audio";
34
import {
45
DrumSequencer,
56
PianoRoll,
@@ -9,20 +10,75 @@ import {
910
type TransportMode,
1011
type TransportState,
1112
} from "../features";
13+
import {
14+
createEmptyHybridClip,
15+
toggleDrumStep,
16+
type DrumEvent,
17+
type DrumLaneId,
18+
} from "../model";
1219
import styles from "./App.module.css";
1320

21+
const audioEngine = createAudioEngine();
22+
1423
const instrumentLabels: Record<InstrumentId, string> = {
1524
drums: "Drums",
1625
leadSynth: "Lead Synth",
1726
subBass: "Sub Bass",
1827
};
1928

29+
function drumEventsToSampleLoopEvents(
30+
drumEvents: readonly DrumEvent[],
31+
): SampleLoopEvent[] {
32+
return drumEvents.map((event) => ({
33+
gain: event.velocity,
34+
id: event.id,
35+
sampleId: event.sampleId,
36+
startTick: event.startTick,
37+
}));
38+
}
39+
2040
export function App() {
2141
const [transportState, setTransportState] = useState<TransportState>("stopped");
2242
const [transportMode, setTransportMode] = useState<TransportMode>("pattern");
2343
const [bpm, setBpm] = useState(124);
2444
const [selectedInstrumentId, setSelectedInstrumentId] =
2545
useState<InstrumentId>("leadSynth");
46+
const [selectedClip, setSelectedClip] = useState(() => createEmptyHybridClip());
47+
const [audioError, setAudioError] = useState<string | null>(null);
48+
49+
function handleDrumStepToggle(laneId: DrumLaneId, stepIndex: number) {
50+
setSelectedClip((currentClip) =>
51+
toggleDrumStep({
52+
clip: currentClip,
53+
laneId,
54+
stepIndex,
55+
}),
56+
);
57+
}
58+
59+
async function handleTransportStateChange(nextTransportState: TransportState) {
60+
setAudioError(null);
61+
62+
if (nextTransportState === "stopped") {
63+
audioEngine.stopLoop();
64+
setTransportState("stopped");
65+
return;
66+
}
67+
68+
setTransportState("playing");
69+
70+
try {
71+
await audioEngine.startSampleLoop({
72+
events: drumEventsToSampleLoopEvents(selectedClip.drumEvents),
73+
tempoBpm: bpm,
74+
});
75+
} catch (error) {
76+
setTransportState("stopped");
77+
setAudioError(
78+
error instanceof Error ? error.message : "Audio playback failed.",
79+
);
80+
}
81+
}
2682

2783
return (
2884
<div className={styles.appShell}>
@@ -31,7 +87,7 @@ export function App() {
3187
mode={transportMode}
3288
onBpmChange={setBpm}
3389
onModeChange={setTransportMode}
34-
onTransportStateChange={setTransportState}
90+
onTransportStateChange={handleTransportStateChange}
3591
transportState={transportState}
3692
/>
3793

@@ -45,17 +101,24 @@ export function App() {
45101
<header className={styles.workspaceHeader}>
46102
<div>
47103
<p className={styles.eyebrow}>M1 Hybrid Clip Editor</p>
48-
<h1 className={styles.title}>Clip 1</h1>
104+
<h1 className={styles.title}>{selectedClip.name}</h1>
49105
</div>
50106
<div className={styles.clipMeta}>
51107
<span>1 bar</span>
52108
<span>4/4</span>
53109
<span>PPQ 480</span>
110+
<span>{selectedClip.drumEvents.length} drum events</span>
111+
{audioError ? (
112+
<span className={styles.errorMeta}>{audioError}</span>
113+
) : null}
54114
</div>
55115
</header>
56116

57117
<div className={styles.editorStack}>
58-
<DrumSequencer />
118+
<DrumSequencer
119+
drumEvents={selectedClip.drumEvents}
120+
onStepToggle={handleDrumStepToggle}
121+
/>
59122
<PianoRoll instrumentName={instrumentLabels[selectedInstrumentId]} />
60123
</div>
61124
</main>

src/features/drum-sequencer/DrumSequencer.tsx

Lines changed: 23 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,22 @@
1-
import { useState } from "react";
2-
31
import { Panel } from "../../components";
2+
import {
3+
DRUM_LANES,
4+
DRUM_STEP_COUNT,
5+
isDrumStepActive,
6+
type DrumEvent,
7+
type DrumLaneId,
8+
} from "../../model";
49
import styles from "./DrumSequencer.module.css";
510

6-
interface DrumLane {
7-
id: string;
8-
label: string;
11+
interface DrumSequencerProps {
12+
drumEvents: readonly DrumEvent[];
13+
onStepToggle: (laneId: DrumLaneId, stepIndex: number) => void;
914
}
1015

11-
const drumLanes: DrumLane[] = [
12-
{ id: "kick", label: "KICK" },
13-
{ id: "snare", label: "SNARE" },
14-
{ id: "closedHat", label: "CLOSED HI-HAT" },
15-
{ id: "openHat", label: "OPEN HI-HAT" },
16-
];
17-
18-
type StepState = Record<string, Set<number>>;
19-
20-
export function DrumSequencer() {
21-
const [stepState, setStepState] = useState<StepState>(() => {
22-
const initialState: StepState = {};
23-
24-
for (const lane of drumLanes) {
25-
initialState[lane.id] = new Set<number>();
26-
}
27-
28-
return initialState;
29-
});
30-
31-
function handleStepToggle(laneId: string, stepIndex: number) {
32-
setStepState((currentState) => {
33-
const nextLaneSteps = new Set(currentState[laneId]);
34-
35-
if (nextLaneSteps.has(stepIndex)) {
36-
nextLaneSteps.delete(stepIndex);
37-
} else {
38-
nextLaneSteps.add(stepIndex);
39-
}
40-
41-
return {
42-
...currentState,
43-
[laneId]: nextLaneSteps,
44-
};
45-
});
46-
}
47-
16+
export function DrumSequencer({
17+
drumEvents,
18+
onStepToggle,
19+
}: DrumSequencerProps) {
4820
return (
4921
<Panel
5022
actions={<span className={styles.stepMeta}>1 BAR / 16 STEPS</span>}
@@ -55,7 +27,7 @@ export function DrumSequencer() {
5527
<div className={styles.beatHeader} aria-hidden="true">
5628
<span />
5729
<div className={styles.stepNumbers}>
58-
{Array.from({ length: 16 }, (_, stepIndex) => {
30+
{Array.from({ length: DRUM_STEP_COUNT }, (_, stepIndex) => {
5931
const isGroupStart = stepIndex > 0 && stepIndex % 4 === 0;
6032

6133
return (
@@ -72,7 +44,7 @@ export function DrumSequencer() {
7244
</div>
7345
</div>
7446

75-
{drumLanes.map((lane) => (
47+
{DRUM_LANES.map((lane) => (
7648
<div className={styles.lane} key={lane.id}>
7749
<div className={styles.laneControls}>
7850
<span className={styles.laneLabel}>{lane.label}</span>
@@ -83,8 +55,12 @@ export function DrumSequencer() {
8355
</div>
8456

8557
<div className={styles.steps}>
86-
{Array.from({ length: 16 }, (_, stepIndex) => {
87-
const isActive = stepState[lane.id]?.has(stepIndex) ?? false;
58+
{Array.from({ length: DRUM_STEP_COUNT }, (_, stepIndex) => {
59+
const isActive = isDrumStepActive(
60+
drumEvents,
61+
lane.id,
62+
stepIndex,
63+
);
8864
const isAlternateGroup = Math.floor(stepIndex / 4) % 2 === 1;
8965
const isGroupStart = stepIndex > 0 && stepIndex % 4 === 0;
9066

@@ -100,7 +76,7 @@ export function DrumSequencer() {
10076
isActive ? styles.stepButtonActive : ""
10177
}`}
10278
key={stepIndex}
103-
onClick={() => handleStepToggle(lane.id, stepIndex)}
79+
onClick={() => onStepToggle(lane.id, stepIndex)}
10480
type="button"
10581
/>
10682
);

0 commit comments

Comments
 (0)