work in progress implementation of: - mixer with channel strips, faders, pan knobs, level meters - timeline with ruler, playhead, sections, keyframe tracks - pattern and progression pickers for drums/chords - automation lanes and mute tracks - loop bracket for loop region selection - export modal placeholder known issues: - drum pattern changes don't update audio engine - timeline keyframes not connected to scheduler - some UI bugs remain this is a checkpoint commit for further iteration
278 lines
6.7 KiB
TypeScript
278 lines
6.7 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import {
|
|
TimelineState,
|
|
Section,
|
|
SectionType,
|
|
PatternKeyframe,
|
|
ChordKeyframe,
|
|
MuteKeyframe,
|
|
AutomationPoint,
|
|
LoopRegion,
|
|
LayerName,
|
|
SECTION_COLORS,
|
|
} from '@/types/audio';
|
|
|
|
function generateId(): string {
|
|
return Math.random().toString(36).substring(2, 9);
|
|
}
|
|
|
|
const defaultState: TimelineState = {
|
|
durationBars: 16,
|
|
playheadBar: 0,
|
|
playheadBeat: 0,
|
|
sections: [],
|
|
drumKeyframes: [{ id: generateId(), bar: 0, patternIndex: 0 }],
|
|
chordKeyframes: [{ id: generateId(), bar: 0, progressionIndex: 0 }],
|
|
volumeAutomation: {
|
|
drums: [],
|
|
chords: [],
|
|
ambient: [],
|
|
},
|
|
muteKeyframes: {
|
|
drums: [],
|
|
chords: [],
|
|
ambient: [],
|
|
},
|
|
loopRegion: { start: 0, end: 16, enabled: false },
|
|
};
|
|
|
|
export function useTimeline() {
|
|
const [state, setState] = useState<TimelineState>(defaultState);
|
|
|
|
const setDuration = useCallback((bars: number) => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
durationBars: bars,
|
|
loopRegion: {
|
|
...prev.loopRegion,
|
|
end: Math.min(prev.loopRegion.end, bars),
|
|
},
|
|
}));
|
|
}, []);
|
|
|
|
const setPlayheadPosition = useCallback((bar: number, beat: number) => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
playheadBar: bar,
|
|
playheadBeat: beat,
|
|
}));
|
|
}, []);
|
|
|
|
const setLoopRegion = useCallback((start: number, end: number, enabled: boolean) => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
loopRegion: { start, end, enabled },
|
|
}));
|
|
}, []);
|
|
|
|
const addSection = useCallback(
|
|
(type: SectionType, startBar: number, endBar: number) => {
|
|
const newSection: Section = {
|
|
id: generateId(),
|
|
type,
|
|
name: type.charAt(0).toUpperCase() + type.slice(1),
|
|
startBar,
|
|
endBar,
|
|
color: SECTION_COLORS[type],
|
|
};
|
|
setState((prev) => ({
|
|
...prev,
|
|
sections: [...prev.sections, newSection],
|
|
}));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const resizeSection = useCallback((id: string, startBar: number, endBar: number) => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
sections: prev.sections.map((s) =>
|
|
s.id === id ? { ...s, startBar, endBar } : s
|
|
),
|
|
}));
|
|
}, []);
|
|
|
|
const moveSection = useCallback((id: string, startBar: number) => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
sections: prev.sections.map((s) => {
|
|
if (s.id !== id) return s;
|
|
const duration = s.endBar - s.startBar;
|
|
return { ...s, startBar, endBar: startBar + duration };
|
|
}),
|
|
}));
|
|
}, []);
|
|
|
|
const deleteSection = useCallback((id: string) => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
sections: prev.sections.filter((s) => s.id !== id),
|
|
}));
|
|
}, []);
|
|
|
|
const addDrumKeyframe = useCallback((bar: number, patternIndex: number) => {
|
|
const newKeyframe: PatternKeyframe = {
|
|
id: generateId(),
|
|
bar,
|
|
patternIndex,
|
|
};
|
|
setState((prev) => ({
|
|
...prev,
|
|
drumKeyframes: [...prev.drumKeyframes, newKeyframe],
|
|
}));
|
|
}, []);
|
|
|
|
const updateDrumKeyframe = useCallback((id: string, patternIndex: number) => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
drumKeyframes: prev.drumKeyframes.map((kf) =>
|
|
kf.id === id ? { ...kf, patternIndex } : kf
|
|
),
|
|
}));
|
|
}, []);
|
|
|
|
const deleteDrumKeyframe = useCallback((id: string) => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
drumKeyframes: prev.drumKeyframes.filter((kf) => kf.id !== id),
|
|
}));
|
|
}, []);
|
|
|
|
const addChordKeyframe = useCallback((bar: number, progressionIndex: number) => {
|
|
const newKeyframe: ChordKeyframe = {
|
|
id: generateId(),
|
|
bar,
|
|
progressionIndex,
|
|
};
|
|
setState((prev) => ({
|
|
...prev,
|
|
chordKeyframes: [...prev.chordKeyframes, newKeyframe],
|
|
}));
|
|
}, []);
|
|
|
|
const updateChordKeyframe = useCallback((id: string, progressionIndex: number) => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
chordKeyframes: prev.chordKeyframes.map((kf) =>
|
|
kf.id === id ? { ...kf, progressionIndex } : kf
|
|
),
|
|
}));
|
|
}, []);
|
|
|
|
const deleteChordKeyframe = useCallback((id: string) => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
chordKeyframes: prev.chordKeyframes.filter((kf) => kf.id !== id),
|
|
}));
|
|
}, []);
|
|
|
|
const toggleMuteKeyframe = useCallback((layer: LayerName, bar: number) => {
|
|
setState((prev) => {
|
|
const keyframes = prev.muteKeyframes[layer];
|
|
const existing = keyframes.find((kf) => kf.bar === bar);
|
|
|
|
if (existing) {
|
|
return {
|
|
...prev,
|
|
muteKeyframes: {
|
|
...prev.muteKeyframes,
|
|
[layer]: keyframes.map((kf) =>
|
|
kf.id === existing.id ? { ...kf, muted: !kf.muted } : kf
|
|
),
|
|
},
|
|
};
|
|
}
|
|
|
|
const sorted = [...keyframes].sort((a, b) => a.bar - b.bar);
|
|
const prevKeyframe = sorted.filter((kf) => kf.bar < bar).pop();
|
|
const wasMuted = prevKeyframe?.muted ?? false;
|
|
|
|
const newKeyframe: MuteKeyframe = {
|
|
id: generateId(),
|
|
bar,
|
|
muted: !wasMuted,
|
|
};
|
|
|
|
return {
|
|
...prev,
|
|
muteKeyframes: {
|
|
...prev.muteKeyframes,
|
|
[layer]: [...keyframes, newKeyframe],
|
|
},
|
|
};
|
|
});
|
|
}, []);
|
|
|
|
const addAutomationPoint = useCallback(
|
|
(layer: LayerName, bar: number, beat: number, value: number) => {
|
|
const newPoint: AutomationPoint = {
|
|
id: generateId(),
|
|
bar,
|
|
beat,
|
|
value,
|
|
};
|
|
setState((prev) => ({
|
|
...prev,
|
|
volumeAutomation: {
|
|
...prev.volumeAutomation,
|
|
[layer]: [...prev.volumeAutomation[layer], newPoint],
|
|
},
|
|
}));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const updateAutomationPoint = useCallback(
|
|
(layer: LayerName, id: string, bar: number, beat: number, value: number) => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
volumeAutomation: {
|
|
...prev.volumeAutomation,
|
|
[layer]: prev.volumeAutomation[layer].map((p) =>
|
|
p.id === id ? { ...p, bar, beat, value } : p
|
|
),
|
|
},
|
|
}));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const deleteAutomationPoint = useCallback((layer: LayerName, id: string) => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
volumeAutomation: {
|
|
...prev.volumeAutomation,
|
|
[layer]: prev.volumeAutomation[layer].filter((p) => p.id !== id),
|
|
},
|
|
}));
|
|
}, []);
|
|
|
|
const resetTimeline = useCallback(() => {
|
|
setState(defaultState);
|
|
}, []);
|
|
|
|
return {
|
|
state,
|
|
setDuration,
|
|
setPlayheadPosition,
|
|
setLoopRegion,
|
|
addSection,
|
|
resizeSection,
|
|
moveSection,
|
|
deleteSection,
|
|
addDrumKeyframe,
|
|
updateDrumKeyframe,
|
|
deleteDrumKeyframe,
|
|
addChordKeyframe,
|
|
updateChordKeyframe,
|
|
deleteChordKeyframe,
|
|
toggleMuteKeyframe,
|
|
addAutomationPoint,
|
|
updateAutomationPoint,
|
|
deleteAutomationPoint,
|
|
resetTimeline,
|
|
};
|
|
}
|