Add v2 Game Boy sound engine (isolated from v1)

- Authentic DMG-CPU sound chip implementation:
  - 4 Pulse channels with duty cycle control (12.5%, 25%, 50%, 75%)
  - 2 Wave channels with 4-bit wavetables
  - 2 Noise channels with LFSR (7-bit and 15-bit modes)

- GameBoy Colorizer effect chain:
  - Low-pass filter (natural GB rolloff)
  - Bit-crushing (4-bit DAC simulation)
  - Sample rate reduction
  - Saturation and high-pass filter
  - Presets: DMG, GBC, GBA, Clean

- Intelligent MIDI processing:
  - Track analysis and role detection (bass, lead, drums, etc.)
  - Automatic channel mapping to GB channels
  - Chord arpeggiator for polyphony handling
  - GameBoy Arranger for fuller sound

- BitMidi search integration
- Completely isolated from v1 (no changes to src/)
This commit is contained in:
b1rdmania
2026-01-20 19:36:13 +00:00
parent 63bd71202a
commit 5e127c3a3e
21 changed files with 6789 additions and 0 deletions
+237
View File
@@ -0,0 +1,237 @@
/**
* Game Boy Wave Channel
*
* Implements the wave channel with:
* - 32-sample × 4-bit wavetable
* - GB-accurate frequency calculation
* - 4-level volume (mute, 100%, 50%, 25%)
* - Preset waveforms (bass, pad, lead, etc.)
*
* Uses OscillatorNode with PeriodicWave for accurate pitch control
* (AudioBufferSourceNode playbackRate has issues at low frequencies).
*/
import {
WaveTable,
WAVE_PRESETS,
VOLUME_MULTIPLIERS,
createPeriodicWaveFromTable,
type WavePreset,
type WaveVolume
} from '../synthesis/WaveTable';
import { calculateWaveFrequency } from '../synthesis/FrequencyCalc';
export interface WaveNoteResult {
oscillator: OscillatorNode;
gainNode: GainNode;
stopTime: number;
}
export class WaveChannel {
private audioContext: AudioContext;
private waveTable: WaveTable;
private periodicWave: PeriodicWave | null = null;
private volume: WaveVolume = 1; // Default to 100%
private outputNode: GainNode;
private currentPreset: WavePreset;
constructor(
audioContext: AudioContext,
outputNode: GainNode,
preset: WavePreset = 'bass'
) {
this.audioContext = audioContext;
this.outputNode = outputNode;
this.currentPreset = preset;
// Initialize wavetable with preset
this.waveTable = new WaveTable();
this.loadPreset(preset);
}
/**
* Load a preset waveform.
*/
loadPreset(preset: WavePreset): void {
this.currentPreset = preset;
const waveform = WAVE_PRESETS[preset]();
this.waveTable.loadFromBytes(Array.from(waveform));
// Create PeriodicWave from the wavetable
this.periodicWave = createPeriodicWaveFromTable(
this.waveTable.getSamples(),
this.audioContext
);
}
/**
* Load custom waveform data (32 samples, 0-15 each).
*/
loadCustomWaveform(samples: number[]): void {
this.waveTable.loadFromBytes(samples);
this.periodicWave = createPeriodicWaveFromTable(
this.waveTable.getSamples(),
this.audioContext
);
}
/**
* Set volume level (GB style: 0=mute, 1=100%, 2=50%, 3=25%).
*/
setVolume(volume: WaveVolume): void {
this.volume = volume;
}
/**
* Get current volume level.
*/
getVolume(): WaveVolume {
return this.volume;
}
/**
* Get current preset name.
*/
getPreset(): WavePreset {
return this.currentPreset;
}
/**
* Play a note on this channel.
*
* @param midiNote - MIDI note number
* @param duration - Note duration in seconds
* @param velocity - Note velocity (0-127)
* @param startTime - When to start (audioContext.currentTime based)
*/
playNote(
midiNote: number,
duration: number,
velocity: number = 100,
startTime?: number
): WaveNoteResult {
const now = startTime ?? this.audioContext.currentTime;
// Ensure we have a PeriodicWave
if (!this.periodicWave) {
this.periodicWave = createPeriodicWaveFromTable(
this.waveTable.getSamples(),
this.audioContext
);
}
// Create oscillator with the wavetable's PeriodicWave
const oscillator = this.audioContext.createOscillator();
oscillator.setPeriodicWave(this.periodicWave);
// Calculate GB frequency and set directly (no playback rate needed!)
const frequency = calculateWaveFrequency(midiNote);
oscillator.frequency.setValueAtTime(frequency, now);
// Create gain node for volume control
const gain = this.audioContext.createGain();
// Calculate final gain from velocity and GB volume level
const velocityGain = (velocity / 127) * 0.8;
const volumeMultiplier = VOLUME_MULTIPLIERS[this.volume];
const finalGain = velocityGain * volumeMultiplier;
// Simple envelope for wave channel
const attackTime = 0.002; // Very fast attack
const releaseTime = 0.01; // Quick release
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(finalGain, now + attackTime);
const releaseStart = now + Math.max(attackTime, duration - releaseTime);
gain.gain.setValueAtTime(finalGain, releaseStart);
const stopTime = now + duration + releaseTime;
gain.gain.linearRampToValueAtTime(0.001, stopTime);
// Connect nodes
oscillator.connect(gain);
gain.connect(this.outputNode);
// Schedule playback
oscillator.start(now);
oscillator.stop(stopTime + 0.01);
// Auto-cleanup
oscillator.onended = () => {
try {
oscillator.disconnect();
gain.disconnect();
} catch {
// Already disconnected
}
};
return { oscillator, gainNode: gain, stopTime };
}
/**
* Play a note with a specific preset (doesn't change default).
*/
playNoteWithPreset(
midiNote: number,
duration: number,
velocity: number,
preset: WavePreset,
startTime?: number
): WaveNoteResult {
const now = startTime ?? this.audioContext.currentTime;
// Create temporary PeriodicWave for this preset
const waveform = WAVE_PRESETS[preset]();
const tempWave = createPeriodicWaveFromTable(waveform, this.audioContext);
// Create oscillator with the preset's PeriodicWave
const oscillator = this.audioContext.createOscillator();
oscillator.setPeriodicWave(tempWave);
const frequency = calculateWaveFrequency(midiNote);
oscillator.frequency.setValueAtTime(frequency, now);
const gain = this.audioContext.createGain();
const velocityGain = (velocity / 127) * 0.8;
const volumeMultiplier = VOLUME_MULTIPLIERS[this.volume];
const finalGain = velocityGain * volumeMultiplier;
const attackTime = 0.002;
const releaseTime = 0.01;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(finalGain, now + attackTime);
const releaseStart = now + Math.max(attackTime, duration - releaseTime);
gain.gain.setValueAtTime(finalGain, releaseStart);
const stopTime = now + duration + releaseTime;
gain.gain.linearRampToValueAtTime(0.001, stopTime);
oscillator.connect(gain);
gain.connect(this.outputNode);
oscillator.start(now);
oscillator.stop(stopTime + 0.01);
oscillator.onended = () => {
try {
oscillator.disconnect();
gain.disconnect();
} catch {
// Already disconnected
}
};
return { oscillator, gainNode: gain, stopTime };
}
/**
* Get the raw wavetable samples for visualization.
*/
getWaveformSamples(): Uint8Array {
return this.waveTable.getSamples();
}
}