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
This commit is contained in:
b1rdmania
2026-01-26 00:10:48 +00:00
parent f095f9a2a7
commit f829e992c7
4 changed files with 99 additions and 21 deletions
+7 -7
View File
@@ -112,32 +112,32 @@ export class GameBoyAPU {
* The limiter will catch peaks, but we want to minimize its work * The limiter will catch peaks, but we want to minimize its work
*/ */
private initializeChannels(): void { 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) { 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); const channel = new PulseChannel(this.audioContext, gain, config.hasSweep);
channel.setDutyCycle(config.defaultDuty); channel.setDutyCycle(config.defaultDuty);
this.pulseChannels.set(config.id, channel); this.pulseChannels.set(config.id, channel);
this.initChannelState(config.id); 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) { 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); const channel = new WaveChannel(this.audioContext, gain, config.preset);
this.waveChannels.set(config.id, channel); this.waveChannels.set(config.id, channel);
this.initChannelState(config.id); 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) { 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); const channel = new NoiseChannel(this.audioContext, gain, config.mode);
this.noiseChannels.set(config.id, channel); this.noiseChannels.set(config.id, channel);
this.initChannelState(config.id); 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. * Create a gain node for a channel and connect to master.
+5 -5
View File
@@ -125,8 +125,8 @@ export class NoiseChannel {
// Create gain for envelope // Create gain for envelope
const gain = this.audioContext.createGain(); const gain = this.audioContext.createGain();
// Calculate gain from velocity // Calculate gain from velocity - keep very quiet
const maxGain = (velocity / 127) * 0.7; // Noise is loud, keep headroom const maxGain = (velocity / 127) * 0.3;
// Noise envelope: instant attack, decay to sustain, release // Noise envelope: instant attack, decay to sustain, release
const attackTime = 0.001; // Nearly instant const attackTime = 0.001; // Nearly instant
@@ -201,7 +201,7 @@ export class NoiseChannel {
source.buffer = buffer; source.buffer = buffer;
const gain = this.audioContext.createGain(); const gain = this.audioContext.createGain();
const maxGain = (velocity / 127) * 0.9; const maxGain = (velocity / 127) * 0.4;
// Kick envelope: instant attack, fast decay // Kick envelope: instant attack, fast decay
gain.gain.setValueAtTime(maxGain, now); gain.gain.setValueAtTime(maxGain, now);
@@ -237,7 +237,7 @@ export class NoiseChannel {
source.buffer = buffer; source.buffer = buffer;
const gain = this.audioContext.createGain(); const gain = this.audioContext.createGain();
const maxGain = (velocity / 127) * 0.8; const maxGain = (velocity / 127) * 0.3;
// Snare envelope: fast attack, medium decay // Snare envelope: fast attack, medium decay
gain.gain.setValueAtTime(0, now); gain.gain.setValueAtTime(0, now);
@@ -279,7 +279,7 @@ export class NoiseChannel {
source.buffer = buffer; source.buffer = buffer;
const gain = this.audioContext.createGain(); 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 // Hihat envelope: instant attack, quick decay
gain.gain.setValueAtTime(maxGain, now); gain.gain.setValueAtTime(maxGain, now);
+2 -2
View File
@@ -38,8 +38,8 @@ export interface ChannelMapperConfig {
} }
const DEFAULT_CONFIG: ChannelMapperConfig = { const DEFAULT_CONFIG: ChannelMapperConfig = {
maxTracks: 8, maxTracks: 8, // Full 8 GB channels
arpeggiateHarmony: true, arpeggiateHarmony: true, // Re-enabled for classic GB sound
leadDuty: 2, // 50% for full sound leadDuty: 2, // 50% for full sound
harmonyDuty: 1, // 25% for thinner, less intrusive sound harmonyDuty: 1, // 25% for thinner, less intrusive sound
}; };
+85 -7
View File
@@ -53,6 +53,13 @@ export class GameBoyPlayer {
private currentPlaybackInfo: PlaybackInfo | null = null; private currentPlaybackInfo: PlaybackInfo | null = null;
private playbackStartTime: number = 0; private playbackStartTime: number = 0;
// Progressive scheduling state
private pendingNotes: ChannelNote[] = [];
private scheduleIndex: number = 0;
private schedulerInterval: ReturnType<typeof setInterval> | 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<GameBoyPlayerConfig> = {}) { constructor(config: Partial<GameBoyPlayerConfig> = {}) {
this.config = { ...DEFAULT_PLAYER_CONFIG, ...config }; this.config = { ...DEFAULT_PLAYER_CONFIG, ...config };
this.apu = new GameBoyAPU(undefined, this.config); this.apu = new GameBoyAPU(undefined, this.config);
@@ -115,13 +122,15 @@ export class GameBoyPlayer {
const startTime = this.apu.getCurrentTime() + 0.1; // Small lookahead const startTime = this.apu.getCurrentTime() + 0.1; // Small lookahead
this.playbackStartTime = startTime; this.playbackStartTime = startTime;
// Schedule all notes // Store notes for progressive scheduling
for (const note of gbNotes) { this.pendingNotes = gbNotes;
this.apu.scheduleNote({ this.scheduleIndex = 0;
...note,
startTime: startTime + note.startTime, // Schedule initial batch
}); this.scheduleNextBatch();
}
// Start the scheduler for progressive note scheduling
this.startScheduler();
this.isPlaying = true; 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 playback.
*/ */
stop(): void { stop(): void {
this.isPlaying = false; this.isPlaying = false;
this.stopScheduler();
this.pendingNotes = [];
this.scheduleIndex = 0;
this.apu.stopAll(); this.apu.stopAll();
console.log('Playback stopped'); console.log('Playback stopped');
} }