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,434 @@
|
||||
/**
|
||||
* Game Boy APU (Audio Processing Unit) Coordinator
|
||||
*
|
||||
* This is the main audio engine for v2, coordinating 8 channels:
|
||||
* - 4 Pulse channels (p1-p4) with duty cycle control
|
||||
* - 2 Wave channels (w1-w2) with custom wavetables
|
||||
* - 2 Noise channels (n1-n2) with LFSR noise
|
||||
*
|
||||
* This "Super Game Boy" configuration allows handling complex MIDIs
|
||||
* while maintaining authentic GB sound character.
|
||||
*/
|
||||
|
||||
import { PulseChannel } from './PulseChannel';
|
||||
import { WaveChannel } from './WaveChannel';
|
||||
import { NoiseChannel } from './NoiseChannel';
|
||||
import { GameBoyColorizer, type ColorizerConfig } from '../effects/GameBoyColorizer';
|
||||
import {
|
||||
DEFAULT_V2_CONFIG,
|
||||
type ChannelId,
|
||||
type ChannelNote,
|
||||
type ChannelState,
|
||||
type PulseChannelId,
|
||||
type WaveChannelId,
|
||||
type NoiseChannelId,
|
||||
type V2Config,
|
||||
} from '../../types';
|
||||
import type { DutyIndex } from '../synthesis/DutyCycle';
|
||||
import type { WavePreset } from '../synthesis/WaveTable';
|
||||
import type { LFSRMode } from '../synthesis/LFSR';
|
||||
|
||||
/**
|
||||
* Channel configuration for the 8-channel setup
|
||||
*/
|
||||
const CHANNEL_CONFIG = {
|
||||
pulse: [
|
||||
{ id: 'p1' as const, hasSweep: true, defaultDuty: 2 as DutyIndex },
|
||||
{ id: 'p2' as const, hasSweep: true, defaultDuty: 2 as DutyIndex },
|
||||
{ id: 'p3' as const, hasSweep: false, defaultDuty: 1 as DutyIndex },
|
||||
{ id: 'p4' as const, hasSweep: false, defaultDuty: 1 as DutyIndex },
|
||||
],
|
||||
wave: [
|
||||
{ id: 'w1' as const, preset: 'bass' as WavePreset },
|
||||
{ id: 'w2' as const, preset: 'pad' as WavePreset },
|
||||
],
|
||||
noise: [
|
||||
{ id: 'n1' as const, mode: '7bit' as LFSRMode },
|
||||
{ id: 'n2' as const, mode: '15bit' as LFSRMode },
|
||||
],
|
||||
};
|
||||
|
||||
export class GameBoyAPU {
|
||||
private audioContext: AudioContext;
|
||||
private config: V2Config;
|
||||
|
||||
// Master output chain
|
||||
private masterGain: GainNode;
|
||||
private colorizer: GameBoyColorizer;
|
||||
|
||||
// Individual channel instances
|
||||
private pulseChannels: Map<PulseChannelId, PulseChannel> = new Map();
|
||||
private waveChannels: Map<WaveChannelId, WaveChannel> = new Map();
|
||||
private noiseChannels: Map<NoiseChannelId, NoiseChannel> = new Map();
|
||||
|
||||
// Per-channel gain nodes for mixing
|
||||
private channelGains: Map<ChannelId, GainNode> = new Map();
|
||||
|
||||
// Channel state tracking
|
||||
private channelStates: Map<ChannelId, ChannelState> = new Map();
|
||||
|
||||
// Note scheduling stats (no limit - Web Audio handles scheduling)
|
||||
private scheduledNoteCount = 0;
|
||||
|
||||
constructor(audioContext?: AudioContext, config?: Partial<V2Config>) {
|
||||
this.audioContext = audioContext || new AudioContext();
|
||||
this.config = { ...DEFAULT_V2_CONFIG, ...config };
|
||||
|
||||
// Create colorizer with DMG preset for authentic sound
|
||||
this.colorizer = new GameBoyColorizer(
|
||||
this.audioContext,
|
||||
GameBoyColorizer.createPreset('dmg')
|
||||
);
|
||||
|
||||
// Create master gain
|
||||
this.masterGain = this.audioContext.createGain();
|
||||
this.masterGain.gain.value = this.config.masterVolume;
|
||||
|
||||
// Wire: master -> colorizer -> destination
|
||||
this.masterGain.connect(this.colorizer.getInput());
|
||||
this.colorizer.getOutput().connect(this.audioContext.destination);
|
||||
|
||||
// Initialize all channels
|
||||
this.initializeChannels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all 8 channels with their gain nodes.
|
||||
*/
|
||||
private initializeChannels(): void {
|
||||
// Create pulse channels
|
||||
for (const config of CHANNEL_CONFIG.pulse) {
|
||||
const gain = this.createChannelGain(config.id, 0.25);
|
||||
const channel = new PulseChannel(this.audioContext, gain, config.hasSweep);
|
||||
channel.setDutyCycle(config.defaultDuty);
|
||||
this.pulseChannels.set(config.id, channel);
|
||||
this.initChannelState(config.id);
|
||||
}
|
||||
|
||||
// Create wave channels (higher gain for bass)
|
||||
for (const config of CHANNEL_CONFIG.wave) {
|
||||
const gain = this.createChannelGain(config.id, 0.55); // Boosted for bass
|
||||
const channel = new WaveChannel(this.audioContext, gain, config.preset);
|
||||
this.waveChannels.set(config.id, channel);
|
||||
this.initChannelState(config.id);
|
||||
}
|
||||
|
||||
// Create noise channels
|
||||
for (const config of CHANNEL_CONFIG.noise) {
|
||||
const gain = this.createChannelGain(config.id, 0.3);
|
||||
const channel = new NoiseChannel(this.audioContext, gain, config.mode);
|
||||
this.noiseChannels.set(config.id, channel);
|
||||
this.initChannelState(config.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a gain node for a channel and connect to master.
|
||||
*/
|
||||
private createChannelGain(id: ChannelId, defaultGain: number): GainNode {
|
||||
const gain = this.audioContext.createGain();
|
||||
gain.gain.value = defaultGain;
|
||||
gain.connect(this.masterGain);
|
||||
this.channelGains.set(id, gain);
|
||||
return gain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize channel state tracking.
|
||||
*/
|
||||
private initChannelState(id: ChannelId): void {
|
||||
this.channelStates.set(id, {
|
||||
id,
|
||||
isBusy: false,
|
||||
busyUntil: 0,
|
||||
currentGain: this.channelGains.get(id)?.gain.value || 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the AudioContext.
|
||||
*/
|
||||
getAudioContext(): AudioContext {
|
||||
return this.audioContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume audio context if suspended.
|
||||
*/
|
||||
async resume(): Promise<void> {
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
await this.audioContext.resume();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a note on a specific channel.
|
||||
*
|
||||
* Web Audio handles scheduling of future notes efficiently, so we don't
|
||||
* limit the number of scheduled notes. The browser will automatically
|
||||
* manage memory for nodes that have finished playing.
|
||||
*/
|
||||
scheduleNote(note: ChannelNote): void {
|
||||
const { channel, midiNote, startTime, duration, velocity } = note;
|
||||
|
||||
if (channel.startsWith('p')) {
|
||||
this.schedulePulseNote(channel as PulseChannelId, midiNote, duration, velocity, startTime);
|
||||
} else if (channel.startsWith('w')) {
|
||||
this.scheduleWaveNote(channel as WaveChannelId, midiNote, duration, velocity, startTime);
|
||||
} else if (channel.startsWith('n')) {
|
||||
this.scheduleNoiseNote(channel as NoiseChannelId, midiNote, duration, velocity, startTime);
|
||||
}
|
||||
|
||||
// Update channel state
|
||||
this.updateChannelBusy(channel, startTime + duration);
|
||||
this.scheduledNoteCount++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a pulse channel note.
|
||||
*/
|
||||
private schedulePulseNote(
|
||||
channelId: PulseChannelId,
|
||||
midiNote: number,
|
||||
duration: number,
|
||||
velocity: number,
|
||||
startTime: number
|
||||
): void {
|
||||
const channel = this.pulseChannels.get(channelId);
|
||||
if (!channel) return;
|
||||
|
||||
channel.playNote(midiNote, duration, velocity, startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a wave channel note.
|
||||
*/
|
||||
private scheduleWaveNote(
|
||||
channelId: WaveChannelId,
|
||||
midiNote: number,
|
||||
duration: number,
|
||||
velocity: number,
|
||||
startTime: number
|
||||
): void {
|
||||
const channel = this.waveChannels.get(channelId);
|
||||
if (!channel) return;
|
||||
|
||||
channel.playNote(midiNote, duration, velocity, startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a noise channel note.
|
||||
*/
|
||||
private scheduleNoiseNote(
|
||||
channelId: NoiseChannelId,
|
||||
midiNote: number,
|
||||
duration: number,
|
||||
velocity: number,
|
||||
startTime: number
|
||||
): void {
|
||||
const channel = this.noiseChannels.get(channelId);
|
||||
if (!channel) return;
|
||||
|
||||
channel.playNote(midiNote, duration, velocity, startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update channel busy state.
|
||||
*/
|
||||
private updateChannelBusy(channelId: ChannelId, busyUntil: number): void {
|
||||
const state = this.channelStates.get(channelId);
|
||||
if (state) {
|
||||
state.isBusy = true;
|
||||
state.busyUntil = Math.max(state.busyUntil, busyUntil);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a channel is free at a given time.
|
||||
*/
|
||||
isChannelFree(channelId: ChannelId, atTime?: number): boolean {
|
||||
const time = atTime ?? this.audioContext.currentTime;
|
||||
const state = this.channelStates.get(channelId);
|
||||
if (!state) return false;
|
||||
return time >= state.busyUntil;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a free pulse channel.
|
||||
*/
|
||||
findFreePulseChannel(atTime?: number): PulseChannelId | null {
|
||||
const time = atTime ?? this.audioContext.currentTime;
|
||||
for (const id of ['p1', 'p2', 'p3', 'p4'] as PulseChannelId[]) {
|
||||
if (this.isChannelFree(id, time)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a free wave channel.
|
||||
*/
|
||||
findFreeWaveChannel(atTime?: number): WaveChannelId | null {
|
||||
const time = atTime ?? this.audioContext.currentTime;
|
||||
for (const id of ['w1', 'w2'] as WaveChannelId[]) {
|
||||
if (this.isChannelFree(id, time)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a free noise channel.
|
||||
*/
|
||||
findFreeNoiseChannel(atTime?: number): NoiseChannelId | null {
|
||||
const time = atTime ?? this.audioContext.currentTime;
|
||||
for (const id of ['n1', 'n2'] as NoiseChannelId[]) {
|
||||
if (this.isChannelFree(id, time)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set duty cycle for a pulse channel.
|
||||
*/
|
||||
setPulseDuty(channelId: PulseChannelId, duty: DutyIndex): void {
|
||||
const channel = this.pulseChannels.get(channelId);
|
||||
if (channel) {
|
||||
channel.setDutyCycle(duty);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set preset for a wave channel.
|
||||
*/
|
||||
setWavePreset(channelId: WaveChannelId, preset: WavePreset): void {
|
||||
const channel = this.waveChannels.get(channelId);
|
||||
if (channel) {
|
||||
channel.loadPreset(preset);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mode for a noise channel.
|
||||
*/
|
||||
setNoiseMode(channelId: NoiseChannelId, mode: LFSRMode): void {
|
||||
const channel = this.noiseChannels.get(channelId);
|
||||
if (channel) {
|
||||
channel.setMode(mode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set individual channel volume.
|
||||
*/
|
||||
setChannelVolume(channelId: ChannelId, volume: number): void {
|
||||
const gain = this.channelGains.get(channelId);
|
||||
if (gain) {
|
||||
gain.gain.value = Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set master volume.
|
||||
*/
|
||||
setMasterVolume(volume: number): void {
|
||||
this.masterGain.gain.value = Math.max(0, Math.min(1, volume));
|
||||
this.config.masterVolume = volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get master volume.
|
||||
*/
|
||||
getMasterVolume(): number {
|
||||
return this.config.masterVolume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a pulse channel instance.
|
||||
*/
|
||||
getPulseChannel(id: PulseChannelId): PulseChannel | undefined {
|
||||
return this.pulseChannels.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a wave channel instance.
|
||||
*/
|
||||
getWaveChannel(id: WaveChannelId): WaveChannel | undefined {
|
||||
return this.waveChannels.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a noise channel instance.
|
||||
*/
|
||||
getNoiseChannel(id: NoiseChannelId): NoiseChannel | undefined {
|
||||
return this.noiseChannels.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all channel states.
|
||||
*/
|
||||
getChannelStates(): Map<ChannelId, ChannelState> {
|
||||
return new Map(this.channelStates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current time from audio context.
|
||||
*/
|
||||
getCurrentTime(): number {
|
||||
return this.audioContext.currentTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all channel states.
|
||||
*/
|
||||
reset(): void {
|
||||
for (const id of this.channelStates.keys()) {
|
||||
this.initChannelState(id);
|
||||
}
|
||||
this.scheduledNoteCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduled note count.
|
||||
*/
|
||||
getScheduledNoteCount(): number {
|
||||
return this.scheduledNoteCount;
|
||||
}
|
||||
|
||||
// ===== COLORIZER CONTROLS =====
|
||||
|
||||
/**
|
||||
* Get the colorizer instance.
|
||||
*/
|
||||
getColorizer(): GameBoyColorizer {
|
||||
return this.colorizer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set colorizer preset.
|
||||
*/
|
||||
setColorizerPreset(preset: 'dmg' | 'gbc' | 'gba' | 'clean'): void {
|
||||
this.colorizer.setConfig(GameBoyColorizer.createPreset(preset));
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable the colorizer.
|
||||
*/
|
||||
setColorizerEnabled(enabled: boolean): void {
|
||||
this.colorizer.setEnabled(enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize bit crusher (call after user interaction).
|
||||
*/
|
||||
initializeBitCrusher(): void {
|
||||
this.colorizer.initializeBitCrusher();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export default config for convenience
|
||||
export { DEFAULT_V2_CONFIG } from '../../types';
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Game Boy Pulse Channel
|
||||
*
|
||||
* Implements a single pulse channel with:
|
||||
* - 4 selectable duty cycles
|
||||
* - GB-accurate frequency calculation
|
||||
* - Simple envelope (fast attack, configurable release)
|
||||
* - Optional sweep capability (for p1/p2)
|
||||
*/
|
||||
|
||||
import { createAllDutyWaves, type DutyIndex } from '../synthesis/DutyCycle';
|
||||
import { calculatePulseFrequency } from '../synthesis/FrequencyCalc';
|
||||
|
||||
export interface PulseNoteResult {
|
||||
oscillator: OscillatorNode;
|
||||
gainNode: GainNode;
|
||||
stopTime: number;
|
||||
}
|
||||
|
||||
export class PulseChannel {
|
||||
private audioContext: AudioContext;
|
||||
private dutyWaves: PeriodicWave[];
|
||||
private currentDuty: DutyIndex = 2; // Default to 50%
|
||||
private hasSweep: boolean;
|
||||
private outputNode: GainNode;
|
||||
|
||||
constructor(
|
||||
audioContext: AudioContext,
|
||||
outputNode: GainNode,
|
||||
hasSweep: boolean = false
|
||||
) {
|
||||
this.audioContext = audioContext;
|
||||
this.outputNode = outputNode;
|
||||
this.hasSweep = hasSweep;
|
||||
|
||||
// Pre-create all duty cycle waveforms
|
||||
this.dutyWaves = createAllDutyWaves(audioContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the duty cycle for subsequent notes.
|
||||
*/
|
||||
setDutyCycle(duty: DutyIndex): void {
|
||||
this.currentDuty = duty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current duty cycle.
|
||||
*/
|
||||
getDutyCycle(): DutyIndex {
|
||||
return this.currentDuty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a note on this channel.
|
||||
*
|
||||
* @param midiNote - MIDI note number (0-127)
|
||||
* @param duration - Note duration in seconds
|
||||
* @param velocity - Note velocity (0-127)
|
||||
* @param startTime - When to start (audioContext.currentTime based)
|
||||
* @returns Objects for manual cleanup if needed
|
||||
*/
|
||||
playNote(
|
||||
midiNote: number,
|
||||
duration: number,
|
||||
velocity: number = 100,
|
||||
startTime?: number
|
||||
): PulseNoteResult {
|
||||
const now = startTime ?? this.audioContext.currentTime;
|
||||
|
||||
// Create oscillator with current duty cycle
|
||||
const osc = this.audioContext.createOscillator();
|
||||
osc.setPeriodicWave(this.dutyWaves[this.currentDuty]);
|
||||
|
||||
// Use GB frequency formula (slightly detuned from standard)
|
||||
const frequency = calculatePulseFrequency(midiNote);
|
||||
osc.frequency.setValueAtTime(frequency, now);
|
||||
|
||||
// Create gain node for envelope
|
||||
const gain = this.audioContext.createGain();
|
||||
|
||||
// Calculate gain from velocity (0-127 → 0-1)
|
||||
const maxGain = (velocity / 127) * 0.8; // Leave headroom
|
||||
|
||||
// GB-style envelope: fast attack, sustain, quick release
|
||||
const attackTime = 0.005; // 5ms attack
|
||||
const releaseTime = 0.02; // 20ms release
|
||||
|
||||
// Envelope automation
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(maxGain, now + attackTime);
|
||||
|
||||
// Hold at max until release
|
||||
const releaseStart = now + Math.max(attackTime, duration - releaseTime);
|
||||
gain.gain.setValueAtTime(maxGain, releaseStart);
|
||||
|
||||
// Release to near-zero (avoid exponentialRamp to 0)
|
||||
const stopTime = now + duration + releaseTime;
|
||||
gain.gain.linearRampToValueAtTime(0.001, stopTime);
|
||||
|
||||
// Connect nodes
|
||||
osc.connect(gain);
|
||||
gain.connect(this.outputNode);
|
||||
|
||||
// Schedule playback
|
||||
osc.start(now);
|
||||
osc.stop(stopTime + 0.01); // Small buffer after release
|
||||
|
||||
// Auto-cleanup when oscillator ends
|
||||
osc.onended = () => {
|
||||
try {
|
||||
osc.disconnect();
|
||||
gain.disconnect();
|
||||
} catch {
|
||||
// Already disconnected
|
||||
}
|
||||
};
|
||||
|
||||
return { oscillator: osc, gainNode: gain, stopTime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a note with a specific duty cycle (doesn't change default).
|
||||
*/
|
||||
playNoteWithDuty(
|
||||
midiNote: number,
|
||||
duration: number,
|
||||
velocity: number,
|
||||
duty: DutyIndex,
|
||||
startTime?: number
|
||||
): PulseNoteResult {
|
||||
const now = startTime ?? this.audioContext.currentTime;
|
||||
|
||||
const osc = this.audioContext.createOscillator();
|
||||
osc.setPeriodicWave(this.dutyWaves[duty]);
|
||||
|
||||
const frequency = calculatePulseFrequency(midiNote);
|
||||
osc.frequency.setValueAtTime(frequency, now);
|
||||
|
||||
const gain = this.audioContext.createGain();
|
||||
const maxGain = (velocity / 127) * 0.8;
|
||||
|
||||
const attackTime = 0.005;
|
||||
const releaseTime = 0.02;
|
||||
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(maxGain, now + attackTime);
|
||||
|
||||
const releaseStart = now + Math.max(attackTime, duration - releaseTime);
|
||||
gain.gain.setValueAtTime(maxGain, releaseStart);
|
||||
|
||||
const stopTime = now + duration + releaseTime;
|
||||
gain.gain.linearRampToValueAtTime(0.001, stopTime);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.outputNode);
|
||||
|
||||
osc.start(now);
|
||||
osc.stop(stopTime + 0.01);
|
||||
|
||||
osc.onended = () => {
|
||||
try {
|
||||
osc.disconnect();
|
||||
gain.disconnect();
|
||||
} catch {
|
||||
// Already disconnected
|
||||
}
|
||||
};
|
||||
|
||||
return { oscillator: osc, gainNode: gain, stopTime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this channel has sweep capability.
|
||||
*/
|
||||
canSweep(): boolean {
|
||||
return this.hasSweep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a note with pitch sweep (if sweep enabled).
|
||||
* Sweep goes from startNote to endNote over the duration.
|
||||
*/
|
||||
playNoteWithSweep(
|
||||
startNote: number,
|
||||
endNote: number,
|
||||
duration: number,
|
||||
velocity: number = 100,
|
||||
startTime?: number
|
||||
): PulseNoteResult | null {
|
||||
if (!this.hasSweep) {
|
||||
console.warn('PulseChannel: Sweep not available on this channel');
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = startTime ?? this.audioContext.currentTime;
|
||||
|
||||
const osc = this.audioContext.createOscillator();
|
||||
osc.setPeriodicWave(this.dutyWaves[this.currentDuty]);
|
||||
|
||||
const startFreq = calculatePulseFrequency(startNote);
|
||||
const endFreq = calculatePulseFrequency(endNote);
|
||||
|
||||
osc.frequency.setValueAtTime(startFreq, now);
|
||||
osc.frequency.linearRampToValueAtTime(endFreq, now + duration);
|
||||
|
||||
const gain = this.audioContext.createGain();
|
||||
const maxGain = (velocity / 127) * 0.8;
|
||||
|
||||
const attackTime = 0.005;
|
||||
const releaseTime = 0.02;
|
||||
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(maxGain, now + attackTime);
|
||||
|
||||
const releaseStart = now + Math.max(attackTime, duration - releaseTime);
|
||||
gain.gain.setValueAtTime(maxGain, releaseStart);
|
||||
|
||||
const stopTime = now + duration + releaseTime;
|
||||
gain.gain.linearRampToValueAtTime(0.001, stopTime);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.outputNode);
|
||||
|
||||
osc.start(now);
|
||||
osc.stop(stopTime + 0.01);
|
||||
|
||||
osc.onended = () => {
|
||||
try {
|
||||
osc.disconnect();
|
||||
gain.disconnect();
|
||||
} catch {
|
||||
// Already disconnected
|
||||
}
|
||||
};
|
||||
|
||||
return { oscillator: osc, gainNode: gain, stopTime };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user