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
+82
View File
@@ -0,0 +1,82 @@
/**
* Game Boy Duty Cycle Implementation
*
* The GB pulse channels support 4 duty cycle patterns.
* These exact duty ratios give the Game Boy its distinctive sound.
*/
/**
* Duty cycle ratios for the 4 GB patterns
* 12.5% - Very thin, buzzy, laser-like sound
* 25% - Classic chiptune sound, bright and punchy
* 50% - Full square wave
* 75% - Same as 25% but inverted
*/
export const DUTY_RATIOS = [0.125, 0.25, 0.5, 0.75] as const;
export type DutyIndex = 0 | 1 | 2 | 3;
/**
* Creates a PeriodicWave for Web Audio from a duty cycle.
*
* Uses proper Fourier series for pulse wave:
* imag[n] = (2 / (π * n)) * sin(π * n * duty)
*
* This is the mathematically correct way to synthesize pulse waves.
*/
export function createDutyWave(
dutyIndex: DutyIndex,
audioContext: BaseAudioContext
): PeriodicWave {
const dutyRatio = DUTY_RATIOS[dutyIndex];
// More harmonics = sharper edges (but more CPU)
const numHarmonics = 64;
const real = new Float32Array(numHarmonics);
const imag = new Float32Array(numHarmonics);
// DC offset = 0 for centered waveform
real[0] = 0;
imag[0] = 0;
// Fourier series for pulse wave
// https://en.wikipedia.org/wiki/Pulse_wave
for (let n = 1; n < numHarmonics; n++) {
// Pulse wave Fourier coefficient
const coefficient = (2 / (Math.PI * n)) * Math.sin(Math.PI * n * dutyRatio);
imag[n] = coefficient;
real[n] = 0;
}
return audioContext.createPeriodicWave(real, imag, {
disableNormalization: false
});
}
/**
* Pre-creates all 4 duty cycle waveforms for efficient reuse.
*/
export function createAllDutyWaves(
audioContext: BaseAudioContext
): PeriodicWave[] {
return [
createDutyWave(0, audioContext),
createDutyWave(1, audioContext),
createDutyWave(2, audioContext),
createDutyWave(3, audioContext),
];
}
/**
* Returns a human-readable description of each duty cycle.
*/
export function getDutyDescription(dutyIndex: DutyIndex): string {
const descriptions = [
'12.5% - Thin, buzzy',
'25% - Classic chiptune',
'50% - Full square',
'75% - Bright, punchy',
];
return descriptions[dutyIndex];
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Game Boy Frequency Calculations
*
* The GB uses specific frequency formulas based on 11-bit period registers.
* This creates slightly "off" tuning compared to standard A440 tuning,
* which is part of the characteristic GB sound.
*
* Reference: https://gbdev.io/pandocs/Audio_details.html
*/
/**
* GB CPU clock rate used for audio timing
*/
const GB_CLOCK = 4194304; // 4.194304 MHz
/**
* Pulse channel base frequency divider
* Formula: freq = 131072 / (2048 - period)
*/
const PULSE_FREQ_BASE = 131072;
/**
* Wave channel base frequency divider
* Formula: freq = 65536 / (2048 - period)
* (Half the pulse frequency, so wave plays one octave lower for same period)
*/
const WAVE_FREQ_BASE = 65536;
/**
* Maximum period register value (11-bit)
*/
const MAX_PERIOD = 2047;
/**
* Noise channel divisor lookup table
* Used with divisor code (r) in noise frequency calculation
*/
const NOISE_DIVISORS = [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4] as const;
/**
* Convert MIDI note number to standard frequency (A4 = 440Hz)
*/
export function midiToStandardFrequency(midiNote: number): number {
return 440 * Math.pow(2, (midiNote - 69) / 12);
}
/**
* Convert standard frequency to GB pulse period register value.
* Returns clamped 11-bit value (0-2047).
*/
export function frequencyToPulsePeriod(frequency: number): number {
// freq = 131072 / (2048 - period)
// period = 2048 - (131072 / freq)
const period = Math.round(2048 - (PULSE_FREQ_BASE / frequency));
return Math.max(0, Math.min(MAX_PERIOD, period));
}
/**
* Convert GB pulse period register to actual output frequency.
*/
export function pulsePeriodToFrequency(period: number): number {
if (period >= 2048) return 0;
return PULSE_FREQ_BASE / (2048 - period);
}
/**
* Calculate the actual GB frequency for a pulse channel from MIDI note.
*
* This goes: MIDI → standard freq → period register → GB freq
* The register quantization creates the characteristic slight detuning.
*/
export function calculatePulseFrequency(midiNote: number): number {
const standardFreq = midiToStandardFrequency(midiNote);
const period = frequencyToPulsePeriod(standardFreq);
return pulsePeriodToFrequency(period);
}
/**
* Convert standard frequency to GB wave period register value.
*/
export function frequencyToWavePeriod(frequency: number): number {
// freq = 65536 / (2048 - period)
// period = 2048 - (65536 / freq)
const period = Math.round(2048 - (WAVE_FREQ_BASE / frequency));
return Math.max(0, Math.min(MAX_PERIOD, period));
}
/**
* Convert GB wave period register to actual output frequency.
*/
export function wavePeriodToFrequency(period: number): number {
if (period >= 2048) return 0;
return WAVE_FREQ_BASE / (2048 - period);
}
/**
* Calculate the actual GB frequency for a wave channel from MIDI note.
*/
export function calculateWaveFrequency(midiNote: number): number {
const standardFreq = midiToStandardFrequency(midiNote);
const period = frequencyToWavePeriod(standardFreq);
return wavePeriodToFrequency(period);
}
/**
* Calculate noise channel frequency.
*
* @param divisorCode - Divisor code (0-7), selects from NOISE_DIVISORS
* @param clockShift - Clock shift (0-14), higher = lower frequency
* @returns Frequency in Hz
*
* Formula: freq = 524288 / divisor / 2^(shift+1)
*/
export function calculateNoiseFrequency(
divisorCode: number,
clockShift: number
): number {
const divisor = NOISE_DIVISORS[divisorCode % 8];
const shift = Math.max(0, Math.min(14, clockShift));
return 524288 / divisor / Math.pow(2, shift + 1);
}
/**
* Map a MIDI note to noise parameters.
* Lower notes = lower noise frequency (more "boomy")
* Higher notes = higher noise frequency (more "hissy")
*
* This is an approximation since noise isn't truly pitched.
*/
export function midiToNoiseParams(midiNote: number): {
divisorCode: number;
clockShift: number;
} {
// Map MIDI notes 24-96 to noise parameters
// Lower notes get higher shift (lower freq)
// Higher notes get lower shift (higher freq)
const normalized = Math.max(0, Math.min(72, midiNote - 24));
// Map to shift (0-14): high notes = low shift, low notes = high shift
const clockShift = Math.floor(14 - (normalized / 72) * 14);
// Divisor code affects timbre - use middle values for most natural sound
const divisorCode = Math.floor((normalized % 8));
return { divisorCode, clockShift };
}
/**
* Calculate the frequency deviation from standard tuning.
* Useful for testing/verification.
*
* @returns Deviation in cents (100 cents = 1 semitone)
*/
export function getFrequencyDeviation(midiNote: number): number {
const standard = midiToStandardFrequency(midiNote);
const gbFreq = calculatePulseFrequency(midiNote);
// Cents = 1200 * log2(f2/f1)
return 1200 * Math.log2(gbFreq / standard);
}
+181
View File
@@ -0,0 +1,181 @@
/**
* Linear Feedback Shift Register (LFSR) Noise Generator
*
* The Game Boy's noise channel uses a 15-bit LFSR to generate
* pseudo-random noise. It can also operate in 7-bit mode for
* a more tonal, metallic sound.
*
* This is what gives GB noise its characteristic "crunchy" quality
* compared to smooth white noise.
*
* Reference: https://gbdev.io/pandocs/Audio_details.html#noise-channel
*/
export type LFSRMode = '7bit' | '15bit';
/**
* Initial LFSR seed value (all 1s for 15-bit register)
*/
const INITIAL_SEED = 0x7FFF;
/**
* LFSR noise generator that matches Game Boy hardware behavior.
*/
export class LFSR {
private lfsr: number;
private mode: LFSRMode;
constructor(mode: LFSRMode = '15bit') {
this.mode = mode;
this.lfsr = INITIAL_SEED;
}
/**
* Clock the LFSR once and return the output bit.
*
* Algorithm:
* 1. XOR bits 0 and 1 to get new bit
* 2. Output is current bit 0 (before shift)
* 3. Shift register right by 1
* 4. Put XOR result into bit 14
* 5. If 7-bit mode, also put XOR result into bit 6
*
* @returns 0 or 1
*/
clock(): number {
// Output is bit 0 before we modify anything
const output = this.lfsr & 1;
// XOR bits 0 and 1
const bit0 = this.lfsr & 1;
const bit1 = (this.lfsr >> 1) & 1;
const xorResult = bit0 ^ bit1;
// Shift right by 1
this.lfsr >>= 1;
// Set bit 14 to XOR result
this.lfsr |= (xorResult << 14);
// In 7-bit mode, also set bit 6
if (this.mode === '7bit') {
// Clear bit 6 first, then set if needed
this.lfsr &= ~(1 << 6);
this.lfsr |= (xorResult << 6);
}
return output;
}
/**
* Reset LFSR to initial state.
*/
reset(): void {
this.lfsr = INITIAL_SEED;
}
/**
* Set the LFSR mode.
* 7-bit mode produces more tonal, metallic sounds.
* 15-bit mode produces fuller noise.
*/
setMode(mode: LFSRMode): void {
this.mode = mode;
}
/**
* Get current mode.
*/
getMode(): LFSRMode {
return this.mode;
}
/**
* Get current register value (for debugging/visualization).
*/
getValue(): number {
return this.lfsr;
}
/**
* Generate a sequence of n output bits.
* Useful for verification against known GB sequences.
*/
generateSequence(length: number): number[] {
const sequence: number[] = [];
for (let i = 0; i < length; i++) {
sequence.push(this.clock());
}
return sequence;
}
}
/**
* Known first 20 values of 15-bit LFSR starting from 0x7FFF (all 1s).
* The first outputs are just the low bits shifting out.
* Used for verification that our implementation matches GB hardware.
*/
export const LFSR_15BIT_EXPECTED = [
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
0, 0, 0, 0, 0
];
/**
* Verify that our LFSR implementation produces correct output.
*/
export function verifyLFSR(): boolean {
const lfsr = new LFSR('15bit');
const sequence = lfsr.generateSequence(20);
for (let i = 0; i < LFSR_15BIT_EXPECTED.length; i++) {
if (sequence[i] !== LFSR_15BIT_EXPECTED[i]) {
console.error(`LFSR mismatch at index ${i}: got ${sequence[i]}, expected ${LFSR_15BIT_EXPECTED[i]}`);
return false;
}
}
return true;
}
/**
* Generate an audio buffer filled with LFSR noise.
*
* @param audioContext - Web Audio context
* @param duration - Duration in seconds
* @param frequency - Clock frequency of the LFSR
* @param mode - LFSR mode (7bit or 15bit)
* @returns AudioBuffer filled with noise
*/
export function generateNoiseBuffer(
audioContext: BaseAudioContext,
duration: number,
frequency: number,
mode: LFSRMode = '15bit'
): AudioBuffer {
const sampleRate = audioContext.sampleRate;
const bufferLength = Math.ceil(duration * sampleRate);
const buffer = audioContext.createBuffer(1, bufferLength, sampleRate);
const data = buffer.getChannelData(0);
const lfsr = new LFSR(mode);
// How many samples between LFSR clocks
const samplesPerClock = sampleRate / frequency;
let clockAccumulator = 0;
let currentOutput = 0;
for (let i = 0; i < bufferLength; i++) {
// Clock LFSR when accumulator reaches threshold
clockAccumulator += 1;
if (clockAccumulator >= samplesPerClock) {
currentOutput = lfsr.clock();
clockAccumulator -= samplesPerClock;
}
// Convert 0/1 to -1/+1 for audio
data[i] = currentOutput * 2 - 1;
}
return buffer;
}
+306
View File
@@ -0,0 +1,306 @@
/**
* Game Boy Wave Channel Wavetable
*
* The GB wave channel uses a 32-sample wavetable with 4-bit resolution.
* Each sample can be 0-15, giving the characteristic "digital staircase"
* sound quality.
*
* The low resolution creates audible quantization that's part of the
* GB's unique character - smoother than pulse but still distinctly digital.
*
* Reference: https://gbdev.io/pandocs/Audio_details.html#wave-channel
*/
/**
* Number of samples in the wavetable
*/
export const WAVE_TABLE_SIZE = 32;
/**
* Maximum sample value (4-bit = 0-15)
*/
export const MAX_SAMPLE_VALUE = 15;
/**
* GB wave channel volume levels (bit-shift based)
* 0 = mute, 1 = 100%, 2 = 50%, 3 = 25%
*/
export type WaveVolume = 0 | 1 | 2 | 3;
/**
* Volume multipliers matching GB behavior
* GB uses right-shift for volume: 0=mute, 1=>>0, 2=>>1, 3=>>2
*/
export const VOLUME_MULTIPLIERS: Record<WaveVolume, number> = {
0: 0,
1: 1.0,
2: 0.5,
3: 0.25,
};
/**
* Wavetable class for the GB wave channel.
*/
export class WaveTable {
private samples: Uint8Array;
constructor() {
this.samples = new Uint8Array(WAVE_TABLE_SIZE);
// Initialize with silence
this.samples.fill(8); // 8 = center value (no DC offset)
}
/**
* Quantize a float value (0-1) to 4-bit (0-15).
*/
private quantize(value: number): number {
const clamped = Math.max(0, Math.min(1, value));
return Math.floor(clamped * MAX_SAMPLE_VALUE);
}
/**
* Load a waveform from a float array (0-1 range).
* Values are quantized to 4-bit resolution.
*/
loadFromFloats(waveform: number[]): void {
for (let i = 0; i < WAVE_TABLE_SIZE; i++) {
const value = i < waveform.length ? waveform[i] : 0.5;
this.samples[i] = this.quantize(value);
}
}
/**
* Load raw 4-bit samples directly.
*/
loadFromBytes(samples: number[]): void {
for (let i = 0; i < WAVE_TABLE_SIZE; i++) {
const value = i < samples.length ? samples[i] : 8;
this.samples[i] = Math.max(0, Math.min(MAX_SAMPLE_VALUE, Math.floor(value)));
}
}
/**
* Get the raw sample array.
*/
getSamples(): Uint8Array {
return this.samples;
}
/**
* Create a Web Audio buffer from this wavetable.
* The buffer is one cycle of the waveform.
*/
createBuffer(audioContext: BaseAudioContext): AudioBuffer {
const buffer = audioContext.createBuffer(1, WAVE_TABLE_SIZE, audioContext.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < WAVE_TABLE_SIZE; i++) {
// Convert 0-15 to -1 to +1
data[i] = (this.samples[i] / MAX_SAMPLE_VALUE) * 2 - 1;
}
return buffer;
}
/**
* Create an extended buffer for better audio quality.
* Repeats the waveform multiple times to avoid pitch artifacts.
*/
createExtendedBuffer(
audioContext: BaseAudioContext,
repetitions: number = 256
): AudioBuffer {
const totalSamples = WAVE_TABLE_SIZE * repetitions;
const buffer = audioContext.createBuffer(1, totalSamples, audioContext.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < totalSamples; i++) {
const sampleIndex = i % WAVE_TABLE_SIZE;
data[i] = (this.samples[sampleIndex] / MAX_SAMPLE_VALUE) * 2 - 1;
}
return buffer;
}
}
/**
* Generate a triangle wave with 4-bit quantization.
* Classic GB bass sound.
*/
export function generateTriangleWave(): Uint8Array {
const wave = new Uint8Array(WAVE_TABLE_SIZE);
for (let i = 0; i < WAVE_TABLE_SIZE; i++) {
// Triangle: ramp up for first half, down for second half
const position = i / WAVE_TABLE_SIZE;
let value: number;
if (position < 0.5) {
value = position * 2; // 0 to 1
} else {
value = 2 - position * 2; // 1 to 0
}
wave[i] = Math.floor(value * MAX_SAMPLE_VALUE);
}
return wave;
}
/**
* Generate a sawtooth wave with 4-bit quantization.
* Brighter, more aggressive sound.
*/
export function generateSawtoothWave(): Uint8Array {
const wave = new Uint8Array(WAVE_TABLE_SIZE);
for (let i = 0; i < WAVE_TABLE_SIZE; i++) {
wave[i] = Math.floor((i / (WAVE_TABLE_SIZE - 1)) * MAX_SAMPLE_VALUE);
}
return wave;
}
/**
* Generate a sine-ish wave with 4-bit quantization.
* Rounder, softer sound for pads.
*/
export function generateSineWave(): Uint8Array {
const wave = new Uint8Array(WAVE_TABLE_SIZE);
for (let i = 0; i < WAVE_TABLE_SIZE; i++) {
const angle = (i / WAVE_TABLE_SIZE) * Math.PI * 2;
const sine = (Math.sin(angle) + 1) / 2; // Normalize to 0-1
wave[i] = Math.floor(sine * MAX_SAMPLE_VALUE);
}
return wave;
}
/**
* Generate a square wave with 4-bit resolution.
* Sharp, bright sound.
*/
export function generateSquareWave(): Uint8Array {
const wave = new Uint8Array(WAVE_TABLE_SIZE);
for (let i = 0; i < WAVE_TABLE_SIZE; i++) {
wave[i] = i < WAVE_TABLE_SIZE / 2 ? MAX_SAMPLE_VALUE : 0;
}
return wave;
}
/**
* Generate a bass-optimized waveform.
* Combination of triangle with slight harmonics.
*/
export function generateBassWave(): Uint8Array {
const wave = new Uint8Array(WAVE_TABLE_SIZE);
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;
}
/**
* Generate a pad-optimized waveform.
* Softer, rounder character.
*/
export function generatePadWave(): Uint8Array {
// Use sine wave for pads - smoothest option
return generateSineWave();
}
/**
* Generate a lead-optimized waveform.
* Brighter with more harmonics.
*/
export function generateLeadWave(): Uint8Array {
const wave = new Uint8Array(WAVE_TABLE_SIZE);
for (let i = 0; i < WAVE_TABLE_SIZE; i++) {
const position = i / WAVE_TABLE_SIZE;
const angle = position * Math.PI * 2;
// Mix of saw and triangle characteristics
const saw = position;
const tri = position < 0.5 ? position * 2 : 2 - position * 2;
const value = saw * 0.6 + tri * 0.4;
wave[i] = Math.floor(value * MAX_SAMPLE_VALUE);
}
return wave;
}
/**
* Preset wavetables for easy access.
*/
export const WAVE_PRESETS = {
triangle: generateTriangleWave,
sawtooth: generateSawtoothWave,
sine: generateSineWave,
square: generateSquareWave,
bass: generateBassWave,
pad: generatePadWave,
lead: generateLeadWave,
} as const;
export type WavePreset = keyof typeof WAVE_PRESETS;
/**
* Create a PeriodicWave from a wavetable for use with OscillatorNode.
* This is more accurate than using AudioBufferSourceNode with playback rate.
*/
export function createPeriodicWaveFromTable(
samples: Uint8Array | number[],
audioContext: BaseAudioContext
): PeriodicWave {
const n = samples.length;
// Convert samples to normalized audio values (-1 to +1)
const normalized: number[] = [];
for (let i = 0; i < n; i++) {
const sample = typeof samples[i] === 'number' ? samples[i] : 0;
normalized.push((sample / MAX_SAMPLE_VALUE) * 2 - 1);
}
// Number of harmonics - more harmonics = more accurate representation
const numHarmonics = 64;
// Calculate Fourier coefficients
const real = new Float32Array(numHarmonics);
const imag = new Float32Array(numHarmonics);
// DC offset (real[0]) should be 0 for centered waveform
real[0] = 0;
imag[0] = 0;
// Calculate each harmonic using DFT
for (let k = 1; k < numHarmonics; k++) {
let realSum = 0;
let imagSum = 0;
for (let i = 0; i < n; i++) {
const angle = (2 * Math.PI * k * i) / n;
realSum += normalized[i] * Math.cos(angle);
imagSum -= normalized[i] * Math.sin(angle);
}
// Scale by 2/n for proper amplitude
real[k] = (2 * realSum) / n;
imag[k] = (2 * imagSum) / n;
}
return audioContext.createPeriodicWave(real, imag, {
disableNormalization: false
});
}