5e127c3a3e
- 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/)
316 lines
8.2 KiB
TypeScript
316 lines
8.2 KiB
TypeScript
/**
|
|
* 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 = [];
|
|
}
|
|
}
|