/** * Game Boy APU (Audio Processing Unit) Coordinator * * This is the main audio engine for v2, coordinating 8 channels: * - 4 Pulse channels (p1-p4) with duty cycle control * - 2 Wave channels (w1-w2) with custom wavetables * - 2 Noise channels (n1-n2) with LFSR noise * * This "Super Game Boy" configuration allows handling complex MIDIs * while maintaining authentic GB sound character. */ import { PulseChannel } from './PulseChannel'; import { WaveChannel } from './WaveChannel'; import { NoiseChannel } from './NoiseChannel'; import { GameBoyColorizer, type ColorizerConfig } from '../effects/GameBoyColorizer'; import { DEFAULT_V2_CONFIG, type ChannelId, type ChannelNote, type ChannelState, type PulseChannelId, type WaveChannelId, type NoiseChannelId, type V2Config, } from '../../types'; import type { DutyIndex } from '../synthesis/DutyCycle'; import type { WavePreset } from '../synthesis/WaveTable'; import type { LFSRMode } from '../synthesis/LFSR'; /** * Channel configuration for the 8-channel setup */ const CHANNEL_CONFIG = { pulse: [ { id: 'p1' as const, hasSweep: true, defaultDuty: 2 as DutyIndex }, { id: 'p2' as const, hasSweep: true, defaultDuty: 2 as DutyIndex }, { id: 'p3' as const, hasSweep: false, defaultDuty: 1 as DutyIndex }, { id: 'p4' as const, hasSweep: false, defaultDuty: 1 as DutyIndex }, ], wave: [ { id: 'w1' as const, preset: 'bass' as WavePreset }, { id: 'w2' as const, preset: 'pad' as WavePreset }, ], noise: [ { id: 'n1' as const, mode: '7bit' as LFSRMode }, { id: 'n2' as const, mode: '15bit' as LFSRMode }, ], }; export class GameBoyAPU { private audioContext: AudioContext; private config: V2Config; // Master output chain private masterGain: GainNode; private limiter: DynamicsCompressorNode; private colorizer: GameBoyColorizer; // Individual channel instances private pulseChannels: Map = new Map(); private waveChannels: Map = new Map(); private noiseChannels: Map = new Map(); // Per-channel gain nodes for mixing private channelGains: Map = new Map(); // Channel state tracking private channelStates: Map = new Map(); // Note scheduling stats (no limit - Web Audio handles scheduling) private scheduledNoteCount = 0; // Track active audio nodes for stop functionality private activeNodes: Set = new Set(); constructor(audioContext?: AudioContext, config?: Partial) { this.audioContext = audioContext || new AudioContext(); this.config = { ...DEFAULT_V2_CONFIG, ...config }; // Create colorizer (but don't use it for now - bypassed) this.colorizer = new GameBoyColorizer( this.audioContext, GameBoyColorizer.createPreset() ); // Create master gain this.masterGain = this.audioContext.createGain(); this.masterGain.gain.value = this.config.masterVolume; // Create limiter to prevent clipping when many notes overlap this.limiter = this.audioContext.createDynamicsCompressor(); this.limiter.threshold.value = -6; // Start compressing at -6dB this.limiter.knee.value = 3; // Soft knee this.limiter.ratio.value = 20; // Heavy compression (limiter) this.limiter.attack.value = 0.001; // Fast attack this.limiter.release.value = 0.1; // Medium release // Signal chain: master -> limiter -> destination this.masterGain.connect(this.limiter); this.limiter.connect(this.audioContext.destination); // Initialize all channels this.initializeChannels(); } /** * Initialize all 8 channels with their gain nodes. * * Gain staging: Keep total well under 1.0 to leave headroom * Notes stack when overlapping, so we use conservative values * The limiter will catch peaks, but we want to minimize its work */ private initializeChannels(): void { // Create pulse channels (4 × 0.12 = 0.48 max) for (const config of CHANNEL_CONFIG.pulse) { const gain = this.createChannelGain(config.id, 0.12); const channel = new PulseChannel(this.audioContext, gain, config.hasSweep); channel.setDutyCycle(config.defaultDuty); this.pulseChannels.set(config.id, channel); this.initChannelState(config.id); } // Create wave channels for bass (2 × 0.15 = 0.30 max) for (const config of CHANNEL_CONFIG.wave) { const gain = this.createChannelGain(config.id, 0.15); const channel = new WaveChannel(this.audioContext, gain, config.preset); this.waveChannels.set(config.id, channel); this.initChannelState(config.id); } // Create noise channels - MUTED for now for (const config of CHANNEL_CONFIG.noise) { const gain = this.createChannelGain(config.id, 0); const channel = new NoiseChannel(this.audioContext, gain, config.mode); this.noiseChannels.set(config.id, channel); this.initChannelState(config.id); } } // Total max: 0.48 + 0.30 + 0.08 = 0.86 (limiter handles peaks) /** * Create a gain node for a channel and connect to master. */ private createChannelGain(id: ChannelId, defaultGain: number): GainNode { const gain = this.audioContext.createGain(); gain.gain.value = defaultGain; gain.connect(this.masterGain); this.channelGains.set(id, gain); return gain; } /** * Initialize channel state tracking. */ private initChannelState(id: ChannelId): void { this.channelStates.set(id, { id, isBusy: false, busyUntil: 0, currentGain: this.channelGains.get(id)?.gain.value || 0, }); } /** * Get the AudioContext. */ getAudioContext(): AudioContext { return this.audioContext; } /** * Resume audio context if suspended. */ async resume(): Promise { if (this.audioContext.state === 'suspended') { await this.audioContext.resume(); } } /** * Schedule a note on a specific channel. * * Web Audio handles scheduling of future notes efficiently, so we don't * limit the number of scheduled notes. The browser will automatically * manage memory for nodes that have finished playing. */ scheduleNote(note: ChannelNote): void { const { channel, midiNote, startTime, duration, velocity } = note; if (channel.startsWith('p')) { this.schedulePulseNote(channel as PulseChannelId, midiNote, duration, velocity, startTime); } else if (channel.startsWith('w')) { this.scheduleWaveNote(channel as WaveChannelId, midiNote, duration, velocity, startTime); } else if (channel.startsWith('n')) { this.scheduleNoiseNote(channel as NoiseChannelId, midiNote, duration, velocity, startTime); } // Update channel state this.updateChannelBusy(channel, startTime + duration); this.scheduledNoteCount++; } /** * Schedule a pulse channel note. */ private schedulePulseNote( channelId: PulseChannelId, midiNote: number, duration: number, velocity: number, startTime: number ): void { const channel = this.pulseChannels.get(channelId); if (!channel) return; const result = channel.playNote(midiNote, duration, velocity, startTime); this.trackNode(result.oscillator, result.stopTime); } /** * Schedule a wave channel note. */ private scheduleWaveNote( channelId: WaveChannelId, midiNote: number, duration: number, velocity: number, startTime: number ): void { const channel = this.waveChannels.get(channelId); if (!channel) return; const result = channel.playNote(midiNote, duration, velocity, startTime); this.trackNode(result.oscillator, result.stopTime); } /** * Schedule a noise channel note. */ private scheduleNoiseNote( channelId: NoiseChannelId, midiNote: number, duration: number, velocity: number, startTime: number ): void { const channel = this.noiseChannels.get(channelId); if (!channel) return; const result = channel.playNote(midiNote, duration, velocity, startTime); this.trackNode(result.source, result.stopTime); } /** * Track an audio node for stop functionality. */ private trackNode(node: OscillatorNode | AudioBufferSourceNode, stopTime: number): void { this.activeNodes.add(node); // Auto-remove when the node ends naturally const cleanup = () => { this.activeNodes.delete(node); }; node.onended = cleanup; } /** * Update channel busy state. */ private updateChannelBusy(channelId: ChannelId, busyUntil: number): void { const state = this.channelStates.get(channelId); if (state) { state.isBusy = true; state.busyUntil = Math.max(state.busyUntil, busyUntil); } } /** * Check if a channel is free at a given time. */ isChannelFree(channelId: ChannelId, atTime?: number): boolean { const time = atTime ?? this.audioContext.currentTime; const state = this.channelStates.get(channelId); if (!state) return false; return time >= state.busyUntil; } /** * Find a free pulse channel. */ findFreePulseChannel(atTime?: number): PulseChannelId | null { const time = atTime ?? this.audioContext.currentTime; for (const id of ['p1', 'p2', 'p3', 'p4'] as PulseChannelId[]) { if (this.isChannelFree(id, time)) { return id; } } return null; } /** * Find a free wave channel. */ findFreeWaveChannel(atTime?: number): WaveChannelId | null { const time = atTime ?? this.audioContext.currentTime; for (const id of ['w1', 'w2'] as WaveChannelId[]) { if (this.isChannelFree(id, time)) { return id; } } return null; } /** * Find a free noise channel. */ findFreeNoiseChannel(atTime?: number): NoiseChannelId | null { const time = atTime ?? this.audioContext.currentTime; for (const id of ['n1', 'n2'] as NoiseChannelId[]) { if (this.isChannelFree(id, time)) { return id; } } return null; } /** * Set duty cycle for a pulse channel. */ setPulseDuty(channelId: PulseChannelId, duty: DutyIndex): void { const channel = this.pulseChannels.get(channelId); if (channel) { channel.setDutyCycle(duty); } } /** * Set preset for a wave channel. */ setWavePreset(channelId: WaveChannelId, preset: WavePreset): void { const channel = this.waveChannels.get(channelId); if (channel) { channel.loadPreset(preset); } } /** * Set mode for a noise channel. */ setNoiseMode(channelId: NoiseChannelId, mode: LFSRMode): void { const channel = this.noiseChannels.get(channelId); if (channel) { channel.setMode(mode); } } /** * Set individual channel volume. */ setChannelVolume(channelId: ChannelId, volume: number): void { const gain = this.channelGains.get(channelId); if (gain) { gain.gain.value = Math.max(0, Math.min(1, volume)); } } /** * Set master volume. */ setMasterVolume(volume: number): void { this.masterGain.gain.value = Math.max(0, Math.min(1, volume)); this.config.masterVolume = volume; } /** * Get master volume. */ getMasterVolume(): number { return this.config.masterVolume; } /** * Get a pulse channel instance. */ getPulseChannel(id: PulseChannelId): PulseChannel | undefined { return this.pulseChannels.get(id); } /** * Get a wave channel instance. */ getWaveChannel(id: WaveChannelId): WaveChannel | undefined { return this.waveChannels.get(id); } /** * Get a noise channel instance. */ getNoiseChannel(id: NoiseChannelId): NoiseChannel | undefined { return this.noiseChannels.get(id); } /** * Get all channel states. */ getChannelStates(): Map { return new Map(this.channelStates); } /** * Get current time from audio context. */ getCurrentTime(): number { return this.audioContext.currentTime; } /** * Reset all channel states. */ reset(): void { for (const id of this.channelStates.keys()) { this.initChannelState(id); } this.scheduledNoteCount = 0; } /** * Stop all currently playing and scheduled sounds immediately. */ stopAll(): void { const now = this.audioContext.currentTime; // Stop all tracked nodes for (const node of this.activeNodes) { try { node.stop(now); } catch { // Node may have already stopped } } this.activeNodes.clear(); // Reset channel states this.reset(); } /** * Get scheduled note count. */ getScheduledNoteCount(): number { return this.scheduledNoteCount; } // ===== COLORIZER CONTROLS ===== /** * Get the colorizer instance. */ getColorizer(): GameBoyColorizer { return this.colorizer; } /** * Set colorizer preset. */ setColorizerPreset(preset: 'dmg' | 'gbc' | 'gba' | 'clean'): void { this.colorizer.setConfig(GameBoyColorizer.createPreset(preset)); } /** * Enable/disable the colorizer. */ setColorizerEnabled(enabled: boolean): void { this.colorizer.setEnabled(enabled); } /** * Initialize bit crusher (call after user interaction). */ initializeBitCrusher(): void { this.colorizer.initializeBitCrusher(); } } // Re-export default config for convenience export { DEFAULT_V2_CONFIG } from '../../types';