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:
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user