Lofi_Generator/hooks/useTimeline.ts
Nicholai d3158e4c6a feat(timeline-mixer): WIP timeline and mixer components
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
2026-01-20 18:22:10 -07:00

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,
};
}