Files
motif/src-v2/audio/apu/NoiseChannel.ts
T
b1rdmania f829e992c7 v2: Fix playback engine with progressive scheduling and gain tuning
- Add progressive note scheduling (2 seconds ahead) to prevent browser overwhelm
- Tune channel gains: pulse 0.12, wave 0.15, noise 0.02
- Reduce per-note noise gains for cleaner drums
- Re-enable arpeggiation for harmony tracks
- Limit to 8 channels max
- Add scheduler cleanup on stop
2026-01-26 00:10:48 +00:00

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 - keep very quiet
const maxGain = (velocity / 127) * 0.3;
// 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.4;
// 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.3;
// 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.2; // Hihats very quiet
// 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 = [];
}
}