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
472 lines
13 KiB
TypeScript
472 lines
13 KiB
TypeScript
import * as Tone from 'tone';
|
|
import { DrumMachine } from './drumMachine';
|
|
import { ChordEngine } from './chordEngine';
|
|
import { AmbientLayer } from './ambientLayer';
|
|
import {
|
|
EngineState,
|
|
AudioEngineCallbacks,
|
|
LayerName,
|
|
MeterLevels,
|
|
LoopRegion,
|
|
} from '@/types/audio';
|
|
|
|
class AudioEngine {
|
|
private static instance: AudioEngine | null = null;
|
|
|
|
private drumMachine: DrumMachine | null = null;
|
|
private chordEngine: ChordEngine | null = null;
|
|
private ambientLayer: AmbientLayer | null = null;
|
|
|
|
private masterGain: Tone.Gain | null = null;
|
|
private masterCompressor: Tone.Compressor | null = null;
|
|
private masterLimiter: Tone.Limiter | null = null;
|
|
private masterReverb: Tone.Reverb | null = null;
|
|
|
|
// Meters for level detection
|
|
private drumMeter: Tone.Meter | null = null;
|
|
private chordMeter: Tone.Meter | null = null;
|
|
private ambientMeter: Tone.Meter | null = null;
|
|
private masterMeter: Tone.Meter | null = null;
|
|
|
|
// Panners for stereo positioning
|
|
private drumPanner: Tone.Panner | null = null;
|
|
private chordPanner: Tone.Panner | null = null;
|
|
private ambientPanner: Tone.Panner | null = null;
|
|
|
|
// Solo state tracking
|
|
private soloState: Record<LayerName, boolean> = {
|
|
drums: false,
|
|
chords: false,
|
|
ambient: false,
|
|
};
|
|
|
|
// Pre-solo volume states for restoration
|
|
private preSoloMuted: Record<LayerName, boolean> | null = null;
|
|
|
|
// Timeline state
|
|
private durationBars: number = 16;
|
|
private loopRegion: LoopRegion = { start: 0, end: 16, enabled: false };
|
|
private meterAnimationId: number | null = null;
|
|
private barTrackerId: number | null = null;
|
|
|
|
private callbacks: AudioEngineCallbacks = {};
|
|
|
|
private state: EngineState = {
|
|
isPlaying: false,
|
|
isInitialized: false,
|
|
bpm: 78,
|
|
swing: 0.12,
|
|
currentStep: 0,
|
|
volumes: {
|
|
master: 0.8,
|
|
drums: 0.8,
|
|
chords: 0.6,
|
|
ambient: 0.4,
|
|
},
|
|
muted: {
|
|
drums: false,
|
|
chords: false,
|
|
ambient: false,
|
|
},
|
|
};
|
|
|
|
private constructor() {}
|
|
|
|
static getInstance(): AudioEngine {
|
|
if (!AudioEngine.instance) {
|
|
AudioEngine.instance = new AudioEngine();
|
|
}
|
|
return AudioEngine.instance;
|
|
}
|
|
|
|
async initialize(): Promise<void> {
|
|
if (this.state.isInitialized) return;
|
|
|
|
// Start Tone.js audio context (requires user gesture)
|
|
await Tone.start();
|
|
|
|
// Set up transport
|
|
Tone.getTransport().bpm.value = this.state.bpm;
|
|
Tone.getTransport().swing = this.state.swing;
|
|
Tone.getTransport().swingSubdivision = '16n';
|
|
|
|
// Create meters
|
|
this.drumMeter = new Tone.Meter({ smoothing: 0.8 });
|
|
this.chordMeter = new Tone.Meter({ smoothing: 0.8 });
|
|
this.ambientMeter = new Tone.Meter({ smoothing: 0.8 });
|
|
this.masterMeter = new Tone.Meter({ smoothing: 0.8 });
|
|
|
|
// Create panners
|
|
this.drumPanner = new Tone.Panner(0);
|
|
this.chordPanner = new Tone.Panner(0);
|
|
this.ambientPanner = new Tone.Panner(0);
|
|
|
|
// Master chain
|
|
this.masterGain = new Tone.Gain(this.state.volumes.master);
|
|
this.masterCompressor = new Tone.Compressor({
|
|
threshold: -20,
|
|
ratio: 4,
|
|
attack: 0.003,
|
|
release: 0.25,
|
|
});
|
|
this.masterLimiter = new Tone.Limiter(-1);
|
|
this.masterReverb = new Tone.Reverb({
|
|
decay: 2,
|
|
wet: 0.15,
|
|
});
|
|
|
|
// Chain: gain -> reverb -> compressor -> limiter -> meter -> destination
|
|
this.masterGain.connect(this.masterReverb);
|
|
this.masterReverb.connect(this.masterCompressor);
|
|
this.masterCompressor.connect(this.masterLimiter);
|
|
this.masterLimiter.connect(this.masterMeter);
|
|
this.masterMeter.toDestination();
|
|
|
|
// Initialize layers with panners and meters in chain
|
|
this.drumMachine = new DrumMachine(this.drumPanner);
|
|
this.chordEngine = new ChordEngine(this.chordPanner);
|
|
this.ambientLayer = new AmbientLayer(this.ambientPanner);
|
|
|
|
// Connect panners -> meters -> master
|
|
this.drumPanner.connect(this.drumMeter);
|
|
this.drumMeter.connect(this.masterGain);
|
|
|
|
this.chordPanner.connect(this.chordMeter);
|
|
this.chordMeter.connect(this.masterGain);
|
|
|
|
this.ambientPanner.connect(this.ambientMeter);
|
|
this.ambientMeter.connect(this.masterGain);
|
|
|
|
// Create sequences
|
|
this.drumMachine.createSequence((step) => {
|
|
this.state.currentStep = step;
|
|
this.callbacks.onStepChange?.(step);
|
|
});
|
|
this.chordEngine.createSequence();
|
|
|
|
// Start meter animation loop
|
|
this.startMeterLoop();
|
|
|
|
// Start bar tracking
|
|
this.startBarTracking();
|
|
|
|
this.state.isInitialized = true;
|
|
this.notifyStateChange();
|
|
}
|
|
|
|
setCallbacks(callbacks: AudioEngineCallbacks): void {
|
|
this.callbacks = callbacks;
|
|
}
|
|
|
|
private notifyStateChange(): void {
|
|
this.callbacks.onStateChange?.({ ...this.state });
|
|
}
|
|
|
|
async play(): Promise<void> {
|
|
if (!this.state.isInitialized) {
|
|
await this.initialize();
|
|
}
|
|
|
|
this.ambientLayer?.start();
|
|
Tone.getTransport().start();
|
|
this.state.isPlaying = true;
|
|
this.notifyStateChange();
|
|
}
|
|
|
|
pause(): void {
|
|
Tone.getTransport().pause();
|
|
this.ambientLayer?.stop();
|
|
this.state.isPlaying = false;
|
|
this.notifyStateChange();
|
|
}
|
|
|
|
stop(): void {
|
|
Tone.getTransport().stop();
|
|
this.ambientLayer?.stop();
|
|
this.state.isPlaying = false;
|
|
this.state.currentStep = 0;
|
|
this.notifyStateChange();
|
|
}
|
|
|
|
generateNewBeat(): void {
|
|
this.drumMachine?.randomize();
|
|
this.chordEngine?.randomize();
|
|
this.notifyStateChange();
|
|
}
|
|
|
|
setBpm(bpm: number): void {
|
|
this.state.bpm = Math.max(60, Math.min(100, bpm));
|
|
Tone.getTransport().bpm.value = this.state.bpm;
|
|
this.notifyStateChange();
|
|
}
|
|
|
|
setSwing(swing: number): void {
|
|
this.state.swing = Math.max(0, Math.min(0.5, swing));
|
|
Tone.getTransport().swing = this.state.swing;
|
|
this.notifyStateChange();
|
|
}
|
|
|
|
setMasterVolume(volume: number): void {
|
|
this.state.volumes.master = Math.max(0, Math.min(1, volume));
|
|
this.masterGain?.gain.rampTo(this.state.volumes.master, 0.1);
|
|
this.notifyStateChange();
|
|
}
|
|
|
|
setLayerVolume(layer: LayerName, volume: number): void {
|
|
const normalizedVolume = Math.max(0, Math.min(1, volume));
|
|
this.state.volumes[layer] = normalizedVolume;
|
|
|
|
switch (layer) {
|
|
case 'drums':
|
|
this.drumMachine?.setVolume(normalizedVolume);
|
|
break;
|
|
case 'chords':
|
|
this.chordEngine?.setVolume(normalizedVolume);
|
|
break;
|
|
case 'ambient':
|
|
this.ambientLayer?.setVolume(normalizedVolume);
|
|
break;
|
|
}
|
|
|
|
this.notifyStateChange();
|
|
}
|
|
|
|
toggleMute(layer: LayerName): void {
|
|
this.state.muted[layer] = !this.state.muted[layer];
|
|
|
|
switch (layer) {
|
|
case 'drums':
|
|
this.drumMachine?.mute(this.state.muted[layer]);
|
|
break;
|
|
case 'chords':
|
|
this.chordEngine?.mute(this.state.muted[layer]);
|
|
break;
|
|
case 'ambient':
|
|
this.ambientLayer?.mute(this.state.muted[layer]);
|
|
break;
|
|
}
|
|
|
|
this.notifyStateChange();
|
|
}
|
|
|
|
setMuted(layer: LayerName, muted: boolean): void {
|
|
this.state.muted[layer] = muted;
|
|
|
|
switch (layer) {
|
|
case 'drums':
|
|
this.drumMachine?.mute(muted);
|
|
break;
|
|
case 'chords':
|
|
this.chordEngine?.mute(muted);
|
|
break;
|
|
case 'ambient':
|
|
this.ambientLayer?.mute(muted);
|
|
break;
|
|
}
|
|
|
|
this.notifyStateChange();
|
|
}
|
|
|
|
getState(): EngineState {
|
|
return { ...this.state };
|
|
}
|
|
|
|
// Meter methods
|
|
private startMeterLoop(): void {
|
|
const updateMeters = () => {
|
|
if (this.state.isPlaying && this.callbacks.onMeterUpdate) {
|
|
this.callbacks.onMeterUpdate(this.getMeterLevels());
|
|
}
|
|
this.meterAnimationId = requestAnimationFrame(updateMeters);
|
|
};
|
|
this.meterAnimationId = requestAnimationFrame(updateMeters);
|
|
}
|
|
|
|
private stopMeterLoop(): void {
|
|
if (this.meterAnimationId !== null) {
|
|
cancelAnimationFrame(this.meterAnimationId);
|
|
this.meterAnimationId = null;
|
|
}
|
|
}
|
|
|
|
getMeterLevels(): MeterLevels {
|
|
const normalize = (val: number | number[]): number => {
|
|
const v = typeof val === 'number' ? val : val[0] ?? -Infinity;
|
|
const db = Math.max(-60, Math.min(0, v));
|
|
return (db + 60) / 60;
|
|
};
|
|
|
|
return {
|
|
drums: normalize(this.drumMeter?.getValue() ?? -Infinity),
|
|
chords: normalize(this.chordMeter?.getValue() ?? -Infinity),
|
|
ambient: normalize(this.ambientMeter?.getValue() ?? -Infinity),
|
|
master: normalize(this.masterMeter?.getValue() ?? -Infinity),
|
|
};
|
|
}
|
|
|
|
// Panner methods
|
|
setPan(layer: LayerName, value: number): void {
|
|
const normalizedPan = Math.max(-1, Math.min(1, value));
|
|
|
|
switch (layer) {
|
|
case 'drums':
|
|
this.drumPanner?.pan.rampTo(normalizedPan, 0.1);
|
|
break;
|
|
case 'chords':
|
|
this.chordPanner?.pan.rampTo(normalizedPan, 0.1);
|
|
break;
|
|
case 'ambient':
|
|
this.ambientPanner?.pan.rampTo(normalizedPan, 0.1);
|
|
break;
|
|
}
|
|
}
|
|
|
|
getPan(layer: LayerName): number {
|
|
switch (layer) {
|
|
case 'drums':
|
|
return this.drumPanner?.pan.value ?? 0;
|
|
case 'chords':
|
|
return this.chordPanner?.pan.value ?? 0;
|
|
case 'ambient':
|
|
return this.ambientPanner?.pan.value ?? 0;
|
|
}
|
|
}
|
|
|
|
// Solo methods
|
|
setSolo(layer: LayerName, enabled: boolean): void {
|
|
const hadAnySolo = Object.values(this.soloState).some(Boolean);
|
|
this.soloState[layer] = enabled;
|
|
const hasAnySolo = Object.values(this.soloState).some(Boolean);
|
|
|
|
if (!hadAnySolo && hasAnySolo) {
|
|
this.preSoloMuted = { ...this.state.muted };
|
|
}
|
|
|
|
if (hasAnySolo) {
|
|
const layers: LayerName[] = ['drums', 'chords', 'ambient'];
|
|
layers.forEach((l) => {
|
|
const shouldMute = !this.soloState[l];
|
|
this.setMuted(l, shouldMute);
|
|
});
|
|
} else if (hadAnySolo && !hasAnySolo && this.preSoloMuted) {
|
|
const layers: LayerName[] = ['drums', 'chords', 'ambient'];
|
|
layers.forEach((l) => {
|
|
this.setMuted(l, this.preSoloMuted![l]);
|
|
});
|
|
this.preSoloMuted = null;
|
|
}
|
|
}
|
|
|
|
getSoloState(): Record<LayerName, boolean> {
|
|
return { ...this.soloState };
|
|
}
|
|
|
|
// Timeline methods
|
|
private startBarTracking(): void {
|
|
if (this.barTrackerId !== null) {
|
|
Tone.getTransport().clear(this.barTrackerId);
|
|
}
|
|
|
|
this.barTrackerId = Tone.getTransport().scheduleRepeat((time) => {
|
|
const position = Tone.getTransport().position;
|
|
const [bars, beats] = String(position).split(':').map(Number);
|
|
Tone.getDraw().schedule(() => {
|
|
this.callbacks.onBarChange?.(bars, beats);
|
|
}, time);
|
|
}, '4n');
|
|
}
|
|
|
|
seek(bar: number, beat: number = 0): void {
|
|
const position = `${bar}:${beat}:0`;
|
|
Tone.getTransport().position = position;
|
|
this.callbacks.onBarChange?.(bar, beat);
|
|
}
|
|
|
|
setDuration(bars: number): void {
|
|
this.durationBars = Math.max(4, Math.min(128, bars));
|
|
}
|
|
|
|
getDuration(): number {
|
|
return this.durationBars;
|
|
}
|
|
|
|
setLoopRegion(start: number, end: number, enabled: boolean): void {
|
|
this.loopRegion = { start, end, enabled };
|
|
|
|
if (enabled) {
|
|
Tone.getTransport().setLoopPoints(`${start}:0:0`, `${end}:0:0`);
|
|
Tone.getTransport().loop = true;
|
|
} else {
|
|
Tone.getTransport().loop = false;
|
|
}
|
|
}
|
|
|
|
getLoopRegion(): LoopRegion {
|
|
return { ...this.loopRegion };
|
|
}
|
|
|
|
getPlaybackPosition(): { bar: number; beat: number; sixteenth: number } {
|
|
const position = String(Tone.getTransport().position);
|
|
const [bars, beats, sixteenths] = position.split(':').map(Number);
|
|
return { bar: bars || 0, beat: beats || 0, sixteenth: sixteenths || 0 };
|
|
}
|
|
|
|
// Pattern access methods
|
|
getDrumMachine(): DrumMachine | null {
|
|
return this.drumMachine;
|
|
}
|
|
|
|
getChordEngine(): ChordEngine | null {
|
|
return this.chordEngine;
|
|
}
|
|
|
|
dispose(): void {
|
|
this.stop();
|
|
this.stopMeterLoop();
|
|
|
|
if (this.barTrackerId !== null) {
|
|
Tone.getTransport().clear(this.barTrackerId);
|
|
this.barTrackerId = null;
|
|
}
|
|
|
|
this.drumMachine?.dispose();
|
|
this.chordEngine?.dispose();
|
|
this.ambientLayer?.dispose();
|
|
this.masterGain?.dispose();
|
|
this.masterCompressor?.dispose();
|
|
this.masterLimiter?.dispose();
|
|
this.masterReverb?.dispose();
|
|
|
|
// Dispose new components
|
|
this.drumMeter?.dispose();
|
|
this.chordMeter?.dispose();
|
|
this.ambientMeter?.dispose();
|
|
this.masterMeter?.dispose();
|
|
this.drumPanner?.dispose();
|
|
this.chordPanner?.dispose();
|
|
this.ambientPanner?.dispose();
|
|
|
|
this.drumMachine = null;
|
|
this.chordEngine = null;
|
|
this.ambientLayer = null;
|
|
this.masterGain = null;
|
|
this.masterCompressor = null;
|
|
this.masterLimiter = null;
|
|
this.masterReverb = null;
|
|
this.drumMeter = null;
|
|
this.chordMeter = null;
|
|
this.ambientMeter = null;
|
|
this.masterMeter = null;
|
|
this.drumPanner = null;
|
|
this.chordPanner = null;
|
|
this.ambientPanner = null;
|
|
|
|
this.state.isInitialized = false;
|
|
this.state.isPlaying = false;
|
|
AudioEngine.instance = null;
|
|
}
|
|
}
|
|
|
|
export const audioEngine = AudioEngine.getInstance();
|
|
export default audioEngine;
|