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:
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Game Boy Noise Channel
|
||||
*
|
||||
* Implements the noise channel with:
|
||||
* - LFSR-based pseudo-random noise generation
|
||||
* - 7-bit mode (tonal, metallic) and 15-bit mode (fuller noise)
|
||||
* - GB-accurate frequency calculation
|
||||
* - Envelope control
|
||||
*/
|
||||
|
||||
import { generateNoiseBuffer, type LFSRMode } from '../synthesis/LFSR';
|
||||
import { calculateNoiseFrequency, midiToNoiseParams } from '../synthesis/FrequencyCalc';
|
||||
|
||||
export interface NoiseNoteResult {
|
||||
source: AudioBufferSourceNode;
|
||||
gainNode: GainNode;
|
||||
stopTime: number;
|
||||
}
|
||||
|
||||
// Cache for noise buffers to avoid regenerating
|
||||
interface NoiseBufferCache {
|
||||
buffer: AudioBuffer;
|
||||
frequency: number;
|
||||
mode: LFSRMode;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export class NoiseChannel {
|
||||
private audioContext: AudioContext;
|
||||
private mode: LFSRMode;
|
||||
private outputNode: GainNode;
|
||||
|
||||
// Cache recently used noise buffers
|
||||
private bufferCache: NoiseBufferCache[] = [];
|
||||
private maxCacheSize = 8;
|
||||
|
||||
constructor(
|
||||
audioContext: AudioContext,
|
||||
outputNode: GainNode,
|
||||
mode: LFSRMode = '15bit'
|
||||
) {
|
||||
this.audioContext = audioContext;
|
||||
this.outputNode = outputNode;
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the LFSR mode.
|
||||
* '7bit' = more tonal, metallic sound (good for snares)
|
||||
* '15bit' = fuller noise (good for hihats, white noise effects)
|
||||
*/
|
||||
setMode(mode: LFSRMode): void {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current mode.
|
||||
*/
|
||||
getMode(): LFSRMode {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a noise buffer with the given parameters.
|
||||
*/
|
||||
private getNoiseBuffer(
|
||||
frequency: number,
|
||||
duration: number,
|
||||
mode: LFSRMode
|
||||
): AudioBuffer {
|
||||
// Check cache first
|
||||
const cached = this.bufferCache.find(
|
||||
c => c.frequency === frequency &&
|
||||
c.mode === mode &&
|
||||
c.duration >= duration
|
||||
);
|
||||
|
||||
if (cached) {
|
||||
return cached.buffer;
|
||||
}
|
||||
|
||||
// Generate new buffer
|
||||
const buffer = generateNoiseBuffer(
|
||||
this.audioContext,
|
||||
duration + 0.1, // Extra time for envelope tail
|
||||
frequency,
|
||||
mode
|
||||
);
|
||||
|
||||
// Add to cache
|
||||
this.bufferCache.push({ buffer, frequency, mode, duration });
|
||||
|
||||
// Trim cache if too large
|
||||
while (this.bufferCache.length > this.maxCacheSize) {
|
||||
this.bufferCache.shift();
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play noise with raw frequency control.
|
||||
*
|
||||
* @param duration - Duration in seconds
|
||||
* @param frequency - LFSR clock frequency in Hz
|
||||
* @param velocity - Velocity (0-127)
|
||||
* @param startTime - When to start
|
||||
*/
|
||||
playNoise(
|
||||
duration: number,
|
||||
frequency: number,
|
||||
velocity: number = 100,
|
||||
startTime?: number
|
||||
): NoiseNoteResult {
|
||||
const now = startTime ?? this.audioContext.currentTime;
|
||||
|
||||
// Get or generate noise buffer
|
||||
const buffer = this.getNoiseBuffer(frequency, duration, this.mode);
|
||||
|
||||
// Create source
|
||||
const source = this.audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.loop = false;
|
||||
|
||||
// Create gain for envelope
|
||||
const gain = this.audioContext.createGain();
|
||||
|
||||
// Calculate gain from velocity
|
||||
const maxGain = (velocity / 127) * 0.7; // Noise is loud, keep headroom
|
||||
|
||||
// Noise envelope: instant attack, decay to sustain, release
|
||||
const attackTime = 0.001; // Nearly instant
|
||||
const decayTime = 0.05; // Quick decay
|
||||
const sustainLevel = maxGain * 0.6;
|
||||
const releaseTime = 0.03;
|
||||
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(maxGain, now + attackTime);
|
||||
gain.gain.linearRampToValueAtTime(sustainLevel, now + attackTime + decayTime);
|
||||
|
||||
const releaseStart = now + Math.max(attackTime + decayTime, duration - releaseTime);
|
||||
gain.gain.setValueAtTime(sustainLevel, releaseStart);
|
||||
|
||||
const stopTime = now + duration + releaseTime;
|
||||
gain.gain.linearRampToValueAtTime(0.001, stopTime);
|
||||
|
||||
// Connect
|
||||
source.connect(gain);
|
||||
gain.connect(this.outputNode);
|
||||
|
||||
// Play
|
||||
source.start(now);
|
||||
source.stop(stopTime + 0.01);
|
||||
|
||||
// Auto-cleanup
|
||||
source.onended = () => {
|
||||
try {
|
||||
source.disconnect();
|
||||
gain.disconnect();
|
||||
} catch {
|
||||
// Already disconnected
|
||||
}
|
||||
};
|
||||
|
||||
return { source, gainNode: gain, stopTime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Play noise mapped from a MIDI note.
|
||||
* Lower notes = lower frequency noise (boomy)
|
||||
* Higher notes = higher frequency noise (hissy)
|
||||
*
|
||||
* @param midiNote - MIDI note (affects noise frequency)
|
||||
* @param duration - Duration in seconds
|
||||
* @param velocity - Velocity (0-127)
|
||||
* @param startTime - When to start
|
||||
*/
|
||||
playNote(
|
||||
midiNote: number,
|
||||
duration: number,
|
||||
velocity: number = 100,
|
||||
startTime?: number
|
||||
): NoiseNoteResult {
|
||||
const { divisorCode, clockShift } = midiToNoiseParams(midiNote);
|
||||
const frequency = calculateNoiseFrequency(divisorCode, clockShift);
|
||||
|
||||
return this.playNoise(duration, frequency, velocity, startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a kick drum sound.
|
||||
* Short, low-frequency noise burst.
|
||||
*/
|
||||
playKick(velocity: number = 100, startTime?: number): NoiseNoteResult {
|
||||
const now = startTime ?? this.audioContext.currentTime;
|
||||
|
||||
// Low frequency, short duration, 7-bit for more punch
|
||||
const buffer = this.getNoiseBuffer(500, 0.15, '7bit');
|
||||
|
||||
const source = this.audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
|
||||
const gain = this.audioContext.createGain();
|
||||
const maxGain = (velocity / 127) * 0.9;
|
||||
|
||||
// Kick envelope: instant attack, fast decay
|
||||
gain.gain.setValueAtTime(maxGain, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.1);
|
||||
|
||||
source.connect(gain);
|
||||
gain.connect(this.outputNode);
|
||||
|
||||
source.start(now);
|
||||
source.stop(now + 0.15);
|
||||
|
||||
source.onended = () => {
|
||||
try {
|
||||
source.disconnect();
|
||||
gain.disconnect();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return { source, gainNode: gain, stopTime: now + 0.15 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a snare drum sound.
|
||||
* Mid-frequency noise with some sustain.
|
||||
*/
|
||||
playSnare(velocity: number = 100, startTime?: number): NoiseNoteResult {
|
||||
const now = startTime ?? this.audioContext.currentTime;
|
||||
|
||||
// Mid frequency, 7-bit for metallic character
|
||||
const buffer = this.getNoiseBuffer(2000, 0.2, '7bit');
|
||||
|
||||
const source = this.audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
|
||||
const gain = this.audioContext.createGain();
|
||||
const maxGain = (velocity / 127) * 0.8;
|
||||
|
||||
// Snare envelope: fast attack, medium decay
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(maxGain, now + 0.005);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15);
|
||||
|
||||
source.connect(gain);
|
||||
gain.connect(this.outputNode);
|
||||
|
||||
source.start(now);
|
||||
source.stop(now + 0.2);
|
||||
|
||||
source.onended = () => {
|
||||
try {
|
||||
source.disconnect();
|
||||
gain.disconnect();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return { source, gainNode: gain, stopTime: now + 0.2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a hihat sound.
|
||||
* High-frequency noise, very short.
|
||||
*/
|
||||
playHihat(
|
||||
velocity: number = 100,
|
||||
open: boolean = false,
|
||||
startTime?: number
|
||||
): NoiseNoteResult {
|
||||
const now = startTime ?? this.audioContext.currentTime;
|
||||
|
||||
// High frequency, 15-bit for fuller sound
|
||||
const duration = open ? 0.3 : 0.08;
|
||||
const buffer = this.getNoiseBuffer(8000, duration, '15bit');
|
||||
|
||||
const source = this.audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
|
||||
const gain = this.audioContext.createGain();
|
||||
const maxGain = (velocity / 127) * 0.5; // Hihats are quieter
|
||||
|
||||
// Hihat envelope: instant attack, quick decay
|
||||
gain.gain.setValueAtTime(maxGain, now);
|
||||
|
||||
if (open) {
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.25);
|
||||
} else {
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.05);
|
||||
}
|
||||
|
||||
source.connect(gain);
|
||||
gain.connect(this.outputNode);
|
||||
|
||||
source.start(now);
|
||||
source.stop(now + duration);
|
||||
|
||||
source.onended = () => {
|
||||
try {
|
||||
source.disconnect();
|
||||
gain.disconnect();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return { source, gainNode: gain, stopTime: now + duration };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the buffer cache.
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.bufferCache = [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user