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/)
238 lines
6.4 KiB
TypeScript
238 lines
6.4 KiB
TypeScript
/**
|
||
* 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();
|
||
}
|
||
}
|