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
+434
View File
@@ -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';
+315
View File
@@ -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 = [];
}
}
+239
View File
@@ -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 };
}
}
+237
View File
@@ -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();
}
}