Fix bass channel - warmer, less synthetic sound

- Use pure triangle wave for bass preset (warmest option)
- Reduce harmonics from 64 to 16 with gentle rolloff
- Add 2kHz low-pass filter to wave channel
- Route all wave sounds through filter for warmth
This commit is contained in:
b1rdmania
2026-01-20 19:50:55 +00:00
parent 42b65b2a30
commit 9311e82538
2 changed files with 29 additions and 22 deletions
+12 -3
View File
@@ -34,6 +34,7 @@ export class WaveChannel {
private volume: WaveVolume = 1; // Default to 100% private volume: WaveVolume = 1; // Default to 100%
private outputNode: GainNode; private outputNode: GainNode;
private currentPreset: WavePreset; private currentPreset: WavePreset;
private lowpassFilter: BiquadFilterNode;
constructor( constructor(
audioContext: AudioContext, audioContext: AudioContext,
@@ -44,6 +45,14 @@ export class WaveChannel {
this.outputNode = outputNode; this.outputNode = outputNode;
this.currentPreset = preset; this.currentPreset = preset;
// Create a low-pass filter to warm up the sound
// GB wave channel had natural rolloff from the DAC
this.lowpassFilter = audioContext.createBiquadFilter();
this.lowpassFilter.type = 'lowpass';
this.lowpassFilter.frequency.value = 2000; // Cut harsh highs
this.lowpassFilter.Q.value = 0.7;
this.lowpassFilter.connect(outputNode);
// Initialize wavetable with preset // Initialize wavetable with preset
this.waveTable = new WaveTable(); this.waveTable = new WaveTable();
this.loadPreset(preset); this.loadPreset(preset);
@@ -149,9 +158,9 @@ export class WaveChannel {
const stopTime = now + duration + releaseTime; const stopTime = now + duration + releaseTime;
gain.gain.linearRampToValueAtTime(0.001, stopTime); gain.gain.linearRampToValueAtTime(0.001, stopTime);
// Connect nodes // Connect nodes through low-pass filter for warmer sound
oscillator.connect(gain); oscillator.connect(gain);
gain.connect(this.outputNode); gain.connect(this.lowpassFilter);
// Schedule playback // Schedule playback
oscillator.start(now); oscillator.start(now);
@@ -211,7 +220,7 @@ export class WaveChannel {
gain.gain.linearRampToValueAtTime(0.001, stopTime); gain.gain.linearRampToValueAtTime(0.001, stopTime);
oscillator.connect(gain); oscillator.connect(gain);
gain.connect(this.outputNode); gain.connect(this.lowpassFilter);
oscillator.start(now); oscillator.start(now);
oscillator.stop(stopTime + 0.01); oscillator.stop(stopTime + 0.01);
+17 -19
View File
@@ -193,21 +193,12 @@ export function generateSquareWave(): Uint8Array {
/** /**
* Generate a bass-optimized waveform. * Generate a bass-optimized waveform.
* Combination of triangle with slight harmonics. * Pure triangle wave - warm, round, and perfect for bass.
* This is the classic Game Boy bass sound.
*/ */
export function generateBassWave(): Uint8Array { export function generateBassWave(): Uint8Array {
const wave = new Uint8Array(WAVE_TABLE_SIZE); // Use triangle wave for bass - warmest and most bass-friendly
return generateTriangleWave();
for (let i = 0; i < WAVE_TABLE_SIZE; i++) {
const position = i / WAVE_TABLE_SIZE;
const angle = position * Math.PI * 2;
// Fundamental + slight 2nd harmonic for warmth
const value = (Math.sin(angle) * 0.8 + Math.sin(angle * 2) * 0.2 + 1) / 2;
wave[i] = Math.floor(value * MAX_SAMPLE_VALUE);
}
return wave;
} }
/** /**
@@ -259,10 +250,15 @@ export type WavePreset = keyof typeof WAVE_PRESETS;
/** /**
* Create a PeriodicWave from a wavetable for use with OscillatorNode. * Create a PeriodicWave from a wavetable for use with OscillatorNode.
* This is more accurate than using AudioBufferSourceNode with playback rate. * This is more accurate than using AudioBufferSourceNode with playback rate.
*
* @param samples - The wavetable samples (0-15 values)
* @param audioContext - The audio context
* @param maxHarmonics - Maximum harmonics (lower = warmer, higher = brighter). Default 16 for GB authenticity.
*/ */
export function createPeriodicWaveFromTable( export function createPeriodicWaveFromTable(
samples: Uint8Array | number[], samples: Uint8Array | number[],
audioContext: BaseAudioContext audioContext: BaseAudioContext,
maxHarmonics: number = 16
): PeriodicWave { ): PeriodicWave {
const n = samples.length; const n = samples.length;
@@ -273,8 +269,9 @@ export function createPeriodicWaveFromTable(
normalized.push((sample / MAX_SAMPLE_VALUE) * 2 - 1); normalized.push((sample / MAX_SAMPLE_VALUE) * 2 - 1);
} }
// Number of harmonics - more harmonics = more accurate representation // Limit harmonics for warmer, more GB-authentic sound
const numHarmonics = 64; // The real GB had limited bandwidth due to its DAC
const numHarmonics = Math.min(maxHarmonics, 32);
// Calculate Fourier coefficients // Calculate Fourier coefficients
const real = new Float32Array(numHarmonics); const real = new Float32Array(numHarmonics);
@@ -295,9 +292,10 @@ export function createPeriodicWaveFromTable(
imagSum -= normalized[i] * Math.sin(angle); imagSum -= normalized[i] * Math.sin(angle);
} }
// Scale by 2/n for proper amplitude // Scale by 2/n for proper amplitude, with harmonic rolloff for warmth
real[k] = (2 * realSum) / n; const rolloff = 1 / (1 + k * 0.1); // Gentle high-frequency rolloff
imag[k] = (2 * imagSum) / n; real[k] = (2 * realSum) / n * rolloff;
imag[k] = (2 * imagSum) / n * rolloff;
} }
return audioContext.createPeriodicWave(real, imag, { return audioContext.createPeriodicWave(real, imag, {