From f829e992c79d2e36546c3d59f50a30f592bc6d87 Mon Sep 17 00:00:00 2001 From: b1rdmania <102524336+b1rdmania@users.noreply.github.com> Date: Mon, 26 Jan 2026 00:10:48 +0000 Subject: [PATCH] v2: Fix playback engine with progressive scheduling and gain tuning - Add progressive note scheduling (2 seconds ahead) to prevent browser overwhelm - Tune channel gains: pulse 0.12, wave 0.15, noise 0.02 - Reduce per-note noise gains for cleaner drums - Re-enable arpeggiation for harmony tracks - Limit to 8 channels max - Add scheduler cleanup on stop --- src-v2/audio/apu/APU.ts | 14 ++--- src-v2/audio/apu/NoiseChannel.ts | 10 ++-- src-v2/audio/midi/ChannelMapper.ts | 4 +- src-v2/core/GameBoyPlayer.ts | 92 +++++++++++++++++++++++++++--- 4 files changed, 99 insertions(+), 21 deletions(-) diff --git a/src-v2/audio/apu/APU.ts b/src-v2/audio/apu/APU.ts index 9a96ce6..46e1881 100644 --- a/src-v2/audio/apu/APU.ts +++ b/src-v2/audio/apu/APU.ts @@ -112,32 +112,32 @@ export class GameBoyAPU { * The limiter will catch peaks, but we want to minimize its work */ private initializeChannels(): void { - // Create pulse channels (4 × 0.08 = 0.32 max) + // Create pulse channels (4 × 0.12 = 0.48 max) for (const config of CHANNEL_CONFIG.pulse) { - const gain = this.createChannelGain(config.id, 0.08); + 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 - slightly higher for bass presence (2 × 0.12 = 0.24 max) + // 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.12); + 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 (2 × 0.05 = 0.10 max) + // Create noise channels - very quiet (2 × 0.02 = 0.04 max) for (const config of CHANNEL_CONFIG.noise) { - const gain = this.createChannelGain(config.id, 0.05); + const gain = this.createChannelGain(config.id, 0.02); const channel = new NoiseChannel(this.audioContext, gain, config.mode); this.noiseChannels.set(config.id, channel); this.initChannelState(config.id); } } - // Total max: 0.32 + 0.24 + 0.10 = 0.66 (headroom for note overlap) + // Total max: 0.48 + 0.30 + 0.08 = 0.86 (limiter handles peaks) /** * Create a gain node for a channel and connect to master. diff --git a/src-v2/audio/apu/NoiseChannel.ts b/src-v2/audio/apu/NoiseChannel.ts index 7484dbe..3b53df1 100644 --- a/src-v2/audio/apu/NoiseChannel.ts +++ b/src-v2/audio/apu/NoiseChannel.ts @@ -125,8 +125,8 @@ export class NoiseChannel { // Create gain for envelope const gain = this.audioContext.createGain(); - // Calculate gain from velocity - const maxGain = (velocity / 127) * 0.7; // Noise is loud, keep headroom + // Calculate gain from velocity - keep very quiet + const maxGain = (velocity / 127) * 0.3; // Noise envelope: instant attack, decay to sustain, release const attackTime = 0.001; // Nearly instant @@ -201,7 +201,7 @@ export class NoiseChannel { source.buffer = buffer; const gain = this.audioContext.createGain(); - const maxGain = (velocity / 127) * 0.9; + const maxGain = (velocity / 127) * 0.4; // Kick envelope: instant attack, fast decay gain.gain.setValueAtTime(maxGain, now); @@ -237,7 +237,7 @@ export class NoiseChannel { source.buffer = buffer; const gain = this.audioContext.createGain(); - const maxGain = (velocity / 127) * 0.8; + const maxGain = (velocity / 127) * 0.3; // Snare envelope: fast attack, medium decay gain.gain.setValueAtTime(0, now); @@ -279,7 +279,7 @@ export class NoiseChannel { source.buffer = buffer; const gain = this.audioContext.createGain(); - const maxGain = (velocity / 127) * 0.5; // Hihats are quieter + const maxGain = (velocity / 127) * 0.2; // Hihats very quiet // Hihat envelope: instant attack, quick decay gain.gain.setValueAtTime(maxGain, now); diff --git a/src-v2/audio/midi/ChannelMapper.ts b/src-v2/audio/midi/ChannelMapper.ts index 23f92e5..a3b8946 100644 --- a/src-v2/audio/midi/ChannelMapper.ts +++ b/src-v2/audio/midi/ChannelMapper.ts @@ -38,8 +38,8 @@ export interface ChannelMapperConfig { } const DEFAULT_CONFIG: ChannelMapperConfig = { - maxTracks: 8, - arpeggiateHarmony: true, + maxTracks: 8, // Full 8 GB channels + arpeggiateHarmony: true, // Re-enabled for classic GB sound leadDuty: 2, // 50% for full sound harmonyDuty: 1, // 25% for thinner, less intrusive sound }; diff --git a/src-v2/core/GameBoyPlayer.ts b/src-v2/core/GameBoyPlayer.ts index aa4b21e..e832fed 100644 --- a/src-v2/core/GameBoyPlayer.ts +++ b/src-v2/core/GameBoyPlayer.ts @@ -53,6 +53,13 @@ export class GameBoyPlayer { private currentPlaybackInfo: PlaybackInfo | null = null; private playbackStartTime: number = 0; + // Progressive scheduling state + private pendingNotes: ChannelNote[] = []; + private scheduleIndex: number = 0; + private schedulerInterval: ReturnType | null = null; + private readonly SCHEDULE_AHEAD_TIME = 2.0; // Schedule 2 seconds ahead + private readonly SCHEDULER_INTERVAL_MS = 250; // Check every 250ms + constructor(config: Partial = {}) { this.config = { ...DEFAULT_PLAYER_CONFIG, ...config }; this.apu = new GameBoyAPU(undefined, this.config); @@ -115,13 +122,15 @@ export class GameBoyPlayer { const startTime = this.apu.getCurrentTime() + 0.1; // Small lookahead this.playbackStartTime = startTime; - // Schedule all notes - for (const note of gbNotes) { - this.apu.scheduleNote({ - ...note, - startTime: startTime + note.startTime, - }); - } + // Store notes for progressive scheduling + this.pendingNotes = gbNotes; + this.scheduleIndex = 0; + + // Schedule initial batch + this.scheduleNextBatch(); + + // Start the scheduler for progressive note scheduling + this.startScheduler(); this.isPlaying = true; @@ -225,11 +234,80 @@ export class GameBoyPlayer { } } + /** + * Schedule notes progressively - only schedule notes within the lookahead window. + */ + private scheduleNextBatch(): void { + if (!this.isPlaying && this.scheduleIndex > 0) return; + + const currentTime = this.apu.getCurrentTime(); + const elapsedTime = currentTime - this.playbackStartTime; + const scheduleUntil = elapsedTime + this.SCHEDULE_AHEAD_TIME; + + let scheduledCount = 0; + + while (this.scheduleIndex < this.pendingNotes.length) { + const note = this.pendingNotes[this.scheduleIndex]; + + // If note is beyond our scheduling window, stop + if (note.startTime > scheduleUntil) { + break; + } + + // Schedule the note + this.apu.scheduleNote({ + ...note, + startTime: this.playbackStartTime + note.startTime, + }); + + this.scheduleIndex++; + scheduledCount++; + } + + if (scheduledCount > 0) { + console.log(`Scheduled ${scheduledCount} notes (${this.scheduleIndex}/${this.pendingNotes.length})`); + } + } + + /** + * Start the progressive scheduler. + */ + private startScheduler(): void { + this.stopScheduler(); + + this.schedulerInterval = setInterval(() => { + if (!this.isPlaying) { + this.stopScheduler(); + return; + } + + this.scheduleNextBatch(); + + // Check if all notes have been scheduled + if (this.scheduleIndex >= this.pendingNotes.length) { + this.stopScheduler(); + } + }, this.SCHEDULER_INTERVAL_MS); + } + + /** + * Stop the progressive scheduler. + */ + private stopScheduler(): void { + if (this.schedulerInterval) { + clearInterval(this.schedulerInterval); + this.schedulerInterval = null; + } + } + /** * Stop playback. */ stop(): void { this.isPlaying = false; + this.stopScheduler(); + this.pendingNotes = []; + this.scheduleIndex = 0; this.apu.stopAll(); console.log('Playback stopped'); }