Files
motif/src-v2/audio/apu/PulseChannel.ts
T
b1rdmania 5e127c3a3e 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/)
2026-01-20 19:36:13 +00:00

240 lines
6.5 KiB
TypeScript

/**
* 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 };
}
}