Lofi_Generator/lib/audio/audioEngine.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

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;