forked from averyfelts/Lofi_Generator
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
177 lines
5.3 KiB
TypeScript
177 lines
5.3 KiB
TypeScript
import * as Tone from 'tone';
|
|
import {
|
|
PatternKeyframe,
|
|
ChordKeyframe,
|
|
MuteKeyframe,
|
|
AutomationPoint,
|
|
LayerName,
|
|
} from '@/types/audio';
|
|
import { DrumMachine } from './drumMachine';
|
|
import { ChordEngine } from './chordEngine';
|
|
import { drumPatterns, chordProgressions } from './patterns';
|
|
|
|
type ScheduledEventId = number;
|
|
|
|
interface ScheduledEvents {
|
|
drumPatterns: ScheduledEventId[];
|
|
chordProgressions: ScheduledEventId[];
|
|
muteEvents: Record<LayerName, ScheduledEventId[]>;
|
|
automationEvents: Record<LayerName, ScheduledEventId[]>;
|
|
}
|
|
|
|
export class TimelineScheduler {
|
|
private transport = Tone.getTransport();
|
|
private events: ScheduledEvents = {
|
|
drumPatterns: [],
|
|
chordProgressions: [],
|
|
muteEvents: { drums: [], chords: [], ambient: [] },
|
|
automationEvents: { drums: [], chords: [], ambient: [] },
|
|
};
|
|
|
|
private drumMachine: DrumMachine | null = null;
|
|
private chordEngine: ChordEngine | null = null;
|
|
private volumeCallback: ((layer: LayerName, value: number) => void) | null = null;
|
|
private muteCallback: ((layer: LayerName, muted: boolean) => void) | null = null;
|
|
|
|
setDrumMachine(dm: DrumMachine | null): void {
|
|
this.drumMachine = dm;
|
|
}
|
|
|
|
setChordEngine(ce: ChordEngine | null): void {
|
|
this.chordEngine = ce;
|
|
}
|
|
|
|
setVolumeCallback(cb: (layer: LayerName, value: number) => void): void {
|
|
this.volumeCallback = cb;
|
|
}
|
|
|
|
setMuteCallback(cb: (layer: LayerName, muted: boolean) => void): void {
|
|
this.muteCallback = cb;
|
|
}
|
|
|
|
scheduleDrumKeyframes(keyframes: PatternKeyframe[]): void {
|
|
this.clearDrumEvents();
|
|
|
|
const sorted = [...keyframes].sort((a, b) => a.bar - b.bar);
|
|
|
|
sorted.forEach((kf) => {
|
|
const eventId = this.transport.schedule((time) => {
|
|
if (this.drumMachine && drumPatterns[kf.patternIndex]) {
|
|
this.drumMachine.setPattern(drumPatterns[kf.patternIndex]);
|
|
}
|
|
}, `${kf.bar}:0:0`);
|
|
|
|
this.events.drumPatterns.push(eventId);
|
|
});
|
|
}
|
|
|
|
scheduleChordKeyframes(keyframes: ChordKeyframe[]): void {
|
|
this.clearChordEvents();
|
|
|
|
const sorted = [...keyframes].sort((a, b) => a.bar - b.bar);
|
|
|
|
sorted.forEach((kf) => {
|
|
const eventId = this.transport.schedule((time) => {
|
|
if (this.chordEngine && chordProgressions[kf.progressionIndex]) {
|
|
this.chordEngine.setProgression(chordProgressions[kf.progressionIndex]);
|
|
}
|
|
}, `${kf.bar}:0:0`);
|
|
|
|
this.events.chordProgressions.push(eventId);
|
|
});
|
|
}
|
|
|
|
scheduleMuteKeyframes(layer: LayerName, keyframes: MuteKeyframe[]): void {
|
|
this.clearMuteEvents(layer);
|
|
|
|
const sorted = [...keyframes].sort((a, b) => a.bar - b.bar);
|
|
|
|
sorted.forEach((kf) => {
|
|
const eventId = this.transport.schedule((time) => {
|
|
this.muteCallback?.(layer, kf.muted);
|
|
}, `${kf.bar}:0:0`);
|
|
|
|
this.events.muteEvents[layer].push(eventId);
|
|
});
|
|
}
|
|
|
|
scheduleVolumeAutomation(layer: LayerName, points: AutomationPoint[]): void {
|
|
this.clearAutomationEvents(layer);
|
|
|
|
if (points.length === 0) return;
|
|
|
|
const sorted = [...points].sort((a, b) => {
|
|
if (a.bar !== b.bar) return a.bar - b.bar;
|
|
return a.beat - b.beat;
|
|
});
|
|
|
|
for (let i = 0; i < sorted.length; i++) {
|
|
const current = sorted[i];
|
|
const next = sorted[i + 1];
|
|
|
|
if (!next) {
|
|
const eventId = this.transport.schedule((time) => {
|
|
this.volumeCallback?.(layer, current.value);
|
|
}, `${current.bar}:${current.beat}:0`);
|
|
this.events.automationEvents[layer].push(eventId);
|
|
continue;
|
|
}
|
|
|
|
const startTime = this.transport.toSeconds(`${current.bar}:${current.beat}:0`);
|
|
const endTime = this.transport.toSeconds(`${next.bar}:${next.beat}:0`);
|
|
const duration = endTime - startTime;
|
|
const steps = Math.max(1, Math.floor(duration * 16));
|
|
const stepDuration = duration / steps;
|
|
|
|
for (let step = 0; step <= steps; step++) {
|
|
const t = step / steps;
|
|
const value = current.value + (next.value - current.value) * t;
|
|
const scheduleTime = startTime + step * stepDuration;
|
|
|
|
const eventId = this.transport.schedule((time) => {
|
|
this.volumeCallback?.(layer, value);
|
|
}, scheduleTime);
|
|
this.events.automationEvents[layer].push(eventId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private clearDrumEvents(): void {
|
|
this.events.drumPatterns.forEach((id) => this.transport.clear(id));
|
|
this.events.drumPatterns = [];
|
|
}
|
|
|
|
private clearChordEvents(): void {
|
|
this.events.chordProgressions.forEach((id) => this.transport.clear(id));
|
|
this.events.chordProgressions = [];
|
|
}
|
|
|
|
private clearMuteEvents(layer: LayerName): void {
|
|
this.events.muteEvents[layer].forEach((id) => this.transport.clear(id));
|
|
this.events.muteEvents[layer] = [];
|
|
}
|
|
|
|
private clearAutomationEvents(layer: LayerName): void {
|
|
this.events.automationEvents[layer].forEach((id) => this.transport.clear(id));
|
|
this.events.automationEvents[layer] = [];
|
|
}
|
|
|
|
clearAll(): void {
|
|
this.clearDrumEvents();
|
|
this.clearChordEvents();
|
|
const layers: LayerName[] = ['drums', 'chords', 'ambient'];
|
|
layers.forEach((l) => {
|
|
this.clearMuteEvents(l);
|
|
this.clearAutomationEvents(l);
|
|
});
|
|
}
|
|
|
|
dispose(): void {
|
|
this.clearAll();
|
|
this.drumMachine = null;
|
|
this.chordEngine = null;
|
|
this.volumeCallback = null;
|
|
this.muteCallback = null;
|
|
}
|
|
}
|