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();
}
}
+433
View File
@@ -0,0 +1,433 @@
/**
* Game Boy Arranger
*
* This is the "secret sauce" that makes arbitrary MIDI files sound like
* actual Game Boy music. Professional GB composers used specific techniques
* to make 4 channels sound full - this module applies those techniques
* automatically to sparse MIDI arrangements.
*
* Techniques applied:
* 1. Bass Enhancement - Make bass lines rhythmically active
* 2. Drum Enhancement - Add hi-hats and fill percussion gaps
* 3. Melody Doubling - Add octave harmonies on spare channels
* 4. Counter-Melody Generation - Create interweaving parts
* 5. Gap Filling - Ensure channels stay busy
* 6. Arpeggio Insertion - Turn static chords into motion
*/
import type { ChannelNote, ChannelId, ChannelAssignment, TrackAnalysis } from '../../types';
import type { ArpNote } from '../../types';
export interface ArrangerConfig {
/** Enable bass enhancement */
enhanceBass: boolean;
/** Enable drum/percussion enhancement */
enhanceDrums: boolean;
/** Enable melody doubling */
doubleMelody: boolean;
/** Enable gap filling */
fillGaps: boolean;
/** Minimum gap duration to fill (seconds) */
minGapToFill: number;
/** Target channel utilization (0-1) */
targetUtilization: number;
/** Hi-hat rate (notes per beat) */
hihatRate: number;
/** BPM for timing calculations */
bpm: number;
}
const DEFAULT_CONFIG: ArrangerConfig = {
enhanceBass: true,
enhanceDrums: true,
doubleMelody: true,
fillGaps: true,
minGapToFill: 0.5,
targetUtilization: 0.7,
hihatRate: 1, // Quarter notes (less busy than 8th notes)
bpm: 120,
};
export interface ArrangementResult {
notes: ChannelNote[];
stats: {
originalNotes: number;
addedNotes: number;
channelUtilization: Record<ChannelId, number>;
};
}
export class GameBoyArranger {
private config: ArrangerConfig;
constructor(config: Partial<ArrangerConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Set configuration.
*/
setConfig(config: Partial<ArrangerConfig>): void {
this.config = { ...this.config, ...config };
}
/**
* Set BPM for timing calculations.
*/
setBPM(bpm: number): void {
this.config.bpm = bpm;
}
/**
* Arrange and enhance notes for fuller GB sound.
*/
arrange(
notes: ChannelNote[],
assignments: ChannelAssignment[],
duration: number
): ArrangementResult {
const originalCount = notes.length;
let enhanced = [...notes];
// Group notes by channel
const byChannel = this.groupByChannel(enhanced);
// 1. Enhance bass
if (this.config.enhanceBass) {
const bassChannels = ['w1', 'w2'] as ChannelId[];
for (const channelId of bassChannels) {
if (byChannel.has(channelId)) {
const bassNotes = byChannel.get(channelId)!;
const enhancedBass = this.enhanceBass(bassNotes, duration);
byChannel.set(channelId, enhancedBass);
}
}
}
// 2. Enhance drums
if (this.config.enhanceDrums) {
const noiseChannels = ['n1', 'n2'] as ChannelId[];
for (const channelId of noiseChannels) {
const drumNotes = byChannel.get(channelId) || [];
const enhancedDrums = this.enhanceDrums(drumNotes, duration, channelId);
byChannel.set(channelId, enhancedDrums);
}
}
// 3. Double melody if spare pulse channel available
if (this.config.doubleMelody) {
const pulseChannels = ['p1', 'p2', 'p3', 'p4'] as ChannelId[];
const usedPulse = pulseChannels.filter(c =>
byChannel.has(c) && byChannel.get(c)!.length > 0
);
const sparePulse = pulseChannels.filter(c => !usedPulse.includes(c));
if (sparePulse.length > 0 && usedPulse.length > 0) {
// Find the lead channel (most notes, highest pitch)
const leadChannel = this.findLeadChannel(byChannel, usedPulse);
if (leadChannel && byChannel.get(leadChannel)) {
const doubled = this.doubleMelody(
byChannel.get(leadChannel)!,
sparePulse[0]
);
byChannel.set(sparePulse[0], doubled);
}
}
}
// 4. Fill gaps in all channels
if (this.config.fillGaps) {
for (const [channelId, channelNotes] of byChannel) {
const filled = this.fillGaps(channelNotes, duration, channelId);
byChannel.set(channelId, filled);
}
}
// Flatten back to array
enhanced = [];
for (const channelNotes of byChannel.values()) {
enhanced.push(...channelNotes);
}
// Sort by time
enhanced.sort((a, b) => a.startTime - b.startTime);
// Calculate utilization stats
const utilization = this.calculateUtilization(byChannel, duration);
return {
notes: enhanced,
stats: {
originalNotes: originalCount,
addedNotes: enhanced.length - originalCount,
channelUtilization: utilization,
},
};
}
/**
* Group notes by channel.
*/
private groupByChannel(notes: ChannelNote[]): Map<ChannelId, ChannelNote[]> {
const grouped = new Map<ChannelId, ChannelNote[]>();
for (const note of notes) {
if (!grouped.has(note.channel)) {
grouped.set(note.channel, []);
}
grouped.get(note.channel)!.push(note);
}
// Sort each channel by time
for (const channelNotes of grouped.values()) {
channelNotes.sort((a, b) => a.startTime - b.startTime);
}
return grouped;
}
/**
* Enhance bass to be more rhythmically active.
* GB bass doesn't just play root notes - it has rhythmic variation.
*/
private enhanceBass(notes: ChannelNote[], duration: number): ChannelNote[] {
if (notes.length === 0) return notes;
const enhanced: ChannelNote[] = [];
const beatDuration = 60 / this.config.bpm;
for (const note of notes) {
// Keep original note
enhanced.push(note);
// If note is long, add rhythmic subdivisions
if (note.duration > beatDuration * 1.5) {
// Add a "bounce" note halfway through
const bounceTime = note.startTime + note.duration / 2;
enhanced.push({
...note,
startTime: bounceTime,
duration: Math.min(beatDuration * 0.5, note.duration / 4),
velocity: note.velocity * 0.7,
});
}
// Add octave jump on strong beats occasionally
if (note.duration > beatDuration * 2 && Math.random() > 0.6) {
enhanced.push({
...note,
midiNote: note.midiNote + 12, // Octave up
startTime: note.startTime + beatDuration,
duration: beatDuration * 0.4,
velocity: note.velocity * 0.6,
});
}
}
return enhanced.sort((a, b) => a.startTime - b.startTime);
}
/**
* Enhance drums with hi-hats and fills.
* GB drums are BUSY - hi-hats on every 8th note.
*/
private enhanceDrums(
notes: ChannelNote[],
duration: number,
channelId: ChannelId
): ChannelNote[] {
const enhanced = [...notes];
const beatDuration = 60 / this.config.bpm;
const subdivisionDuration = beatDuration / this.config.hihatRate;
// n2 is for hi-hats (15-bit noise = more "tsss")
// n1 is for kick/snare (7-bit noise = more punchy)
if (channelId === 'n2') {
// Add hi-hats on every subdivision if not already occupied
for (let time = 0; time < duration; time += subdivisionDuration) {
const hasNote = notes.some(n =>
Math.abs(n.startTime - time) < subdivisionDuration * 0.3
);
if (!hasNote) {
enhanced.push({
channel: channelId,
midiNote: 66, // High pitch = high frequency noise
startTime: time,
duration: subdivisionDuration * 0.4,
velocity: 40 + Math.random() * 15, // Quieter, sits back in mix
});
}
}
} else if (channelId === 'n1') {
// Ensure kick and snare on basic beats if sparse
const kickTimes = this.getKickTimes(notes);
const snareTimes = this.getSnareTimes(notes);
// Add kick on beat 1 and 3 if missing
for (let beat = 0; beat < duration / beatDuration; beat++) {
const beatTime = beat * beatDuration;
if (beat % 4 === 0 || beat % 4 === 2) { // Beats 1 and 3
if (!kickTimes.some(t => Math.abs(t - beatTime) < beatDuration * 0.2)) {
enhanced.push({
channel: channelId,
midiNote: 36, // Low pitch = low frequency noise (kick)
startTime: beatTime,
duration: 0.15,
velocity: 100,
});
}
}
if (beat % 4 === 1 || beat % 4 === 3) { // Beats 2 and 4
if (!snareTimes.some(t => Math.abs(t - beatTime) < beatDuration * 0.2)) {
enhanced.push({
channel: channelId,
midiNote: 48, // Mid pitch (snare)
startTime: beatTime,
duration: 0.1,
velocity: 90,
});
}
}
}
}
return enhanced.sort((a, b) => a.startTime - b.startTime);
}
/**
* Get times of kick-like notes.
*/
private getKickTimes(notes: ChannelNote[]): number[] {
return notes
.filter(n => n.midiNote < 45 && n.velocity > 80)
.map(n => n.startTime);
}
/**
* Get times of snare-like notes.
*/
private getSnareTimes(notes: ChannelNote[]): number[] {
return notes
.filter(n => n.midiNote >= 45 && n.midiNote < 55 && n.velocity > 70)
.map(n => n.startTime);
}
/**
* Double the melody an octave up on a spare channel.
*/
private doubleMelody(
leadNotes: ChannelNote[],
targetChannel: ChannelId
): ChannelNote[] {
return leadNotes.map(note => ({
...note,
channel: targetChannel,
midiNote: note.midiNote + 12, // Octave up
velocity: Math.round(note.velocity * 0.5), // Quieter
}));
}
/**
* Find the lead channel (highest average pitch, most notes).
*/
private findLeadChannel(
byChannel: Map<ChannelId, ChannelNote[]>,
candidates: ChannelId[]
): ChannelId | null {
let bestChannel: ChannelId | null = null;
let bestScore = 0;
for (const channelId of candidates) {
const notes = byChannel.get(channelId);
if (!notes || notes.length === 0) continue;
const avgPitch = notes.reduce((sum, n) => sum + n.midiNote, 0) / notes.length;
const noteCount = notes.length;
// Score = high pitch + many notes
const score = avgPitch / 127 * 0.5 + Math.min(1, noteCount / 100) * 0.5;
if (score > bestScore) {
bestScore = score;
bestChannel = channelId;
}
}
return bestChannel;
}
/**
* Fill gaps in a channel with sustain notes or echoes.
*/
private fillGaps(
notes: ChannelNote[],
duration: number,
channelId: ChannelId
): ChannelNote[] {
if (notes.length === 0) return notes;
const enhanced = [...notes];
const minGap = this.config.minGapToFill;
// Find gaps
for (let i = 0; i < notes.length - 1; i++) {
const current = notes[i];
const next = notes[i + 1];
const gapStart = current.startTime + current.duration;
const gapDuration = next.startTime - gapStart;
if (gapDuration > minGap) {
// Add an echo/sustain note in the gap
enhanced.push({
channel: channelId,
midiNote: current.midiNote,
startTime: gapStart + 0.1,
duration: Math.min(gapDuration - 0.2, 0.3),
velocity: Math.round(current.velocity * 0.4), // Quiet echo
});
}
}
// Check gap at the end
const lastNote = notes[notes.length - 1];
const endGap = duration - (lastNote.startTime + lastNote.duration);
if (endGap > minGap * 2) {
enhanced.push({
channel: channelId,
midiNote: lastNote.midiNote,
startTime: lastNote.startTime + lastNote.duration + 0.1,
duration: 0.3,
velocity: Math.round(lastNote.velocity * 0.3),
});
}
return enhanced.sort((a, b) => a.startTime - b.startTime);
}
/**
* Calculate channel utilization (0-1 for each channel).
*/
private calculateUtilization(
byChannel: Map<ChannelId, ChannelNote[]>,
duration: number
): Record<ChannelId, number> {
const utilization: Record<string, number> = {};
for (const [channelId, notes] of byChannel) {
const totalNoteTime = notes.reduce((sum, n) => sum + n.duration, 0);
utilization[channelId] = Math.min(1, totalNoteTime / duration);
}
return utilization as Record<ChannelId, number>;
}
}
+274
View File
@@ -0,0 +1,274 @@
/**
* Game Boy Colorizer
*
* Applies authentic Game Boy audio characteristics to the output:
* - Low-pass filter (GB has ~8kHz natural rolloff)
* - Bit-crushing (4-bit DAC simulation)
* - Sample rate reduction (mimics ~32kHz internal rate)
* - Subtle saturation (hardware non-linearity)
* - Characteristic noise floor
*/
export interface ColorizerConfig {
/** Enable/disable the colorizer */
enabled: boolean;
/** Low-pass filter cutoff (Hz). Real GB is ~8-10kHz */
lowpassFreq: number;
/** Bit depth for crushing (4 = authentic, higher = cleaner) */
bitDepth: number;
/** Sample rate reduction factor (1 = none, 2 = half, etc.) */
sampleRateReduction: number;
/** Saturation amount (0-1) */
saturation: number;
/** High-pass filter to remove DC offset and sub-bass (Hz) */
highpassFreq: number;
}
const DEFAULT_CONFIG: ColorizerConfig = {
enabled: true,
lowpassFreq: 10000, // GB natural rolloff (slightly higher)
bitDepth: 8, // Less aggressive bit crushing (4 was too harsh)
sampleRateReduction: 1, // No sample rate reduction (was causing artifacts)
saturation: 0.2, // Subtle warmth
highpassFreq: 20, // LOW - allow bass through!
};
export class GameBoyColorizer {
private audioContext: AudioContext;
private config: ColorizerConfig;
// Audio nodes
private inputGain: GainNode;
private outputGain: GainNode;
private highpassFilter: BiquadFilterNode;
private lowpassFilter: BiquadFilterNode;
private bitCrusher: AudioWorkletNode | ScriptProcessorNode | null = null;
private waveshaper: WaveShaperNode;
private limiter: DynamicsCompressorNode;
private isInitialized = false;
constructor(audioContext: AudioContext, config: Partial<ColorizerConfig> = {}) {
this.audioContext = audioContext;
this.config = { ...DEFAULT_CONFIG, ...config };
// Create basic nodes
this.inputGain = audioContext.createGain();
this.outputGain = audioContext.createGain();
// High-pass filter (remove DC and sub-bass)
this.highpassFilter = audioContext.createBiquadFilter();
this.highpassFilter.type = 'highpass';
this.highpassFilter.frequency.value = this.config.highpassFreq;
this.highpassFilter.Q.value = 0.7;
// Low-pass filter (GB characteristic rolloff)
this.lowpassFilter = audioContext.createBiquadFilter();
this.lowpassFilter.type = 'lowpass';
this.lowpassFilter.frequency.value = this.config.lowpassFreq;
this.lowpassFilter.Q.value = 0.7;
// Waveshaper for saturation
this.waveshaper = audioContext.createWaveShaper();
this.waveshaper.curve = this.createSaturationCurve(this.config.saturation);
this.waveshaper.oversample = '2x';
// Limiter to prevent clipping
this.limiter = audioContext.createDynamicsCompressor();
this.limiter.threshold.value = -6;
this.limiter.knee.value = 6;
this.limiter.ratio.value = 12;
this.limiter.attack.value = 0.001;
this.limiter.release.value = 0.1;
// Initialize chain (without bit crusher for now)
this.initializeBasicChain();
}
/**
* Initialize the basic audio chain without bit crusher.
*/
private initializeBasicChain(): void {
// Chain: input -> highpass -> lowpass -> waveshaper -> limiter -> output
this.inputGain.connect(this.highpassFilter);
this.highpassFilter.connect(this.lowpassFilter);
this.lowpassFilter.connect(this.waveshaper);
this.waveshaper.connect(this.limiter);
this.limiter.connect(this.outputGain);
this.isInitialized = true;
}
/**
* Initialize with bit crusher using ScriptProcessor (fallback).
* Call this after user interaction for iOS compatibility.
*/
initializeBitCrusher(): void {
if (this.bitCrusher) return;
// Disconnect current chain
this.lowpassFilter.disconnect();
// Create bit crusher using ScriptProcessor (deprecated but widely supported)
const bufferSize = 4096;
const crusher = this.audioContext.createScriptProcessor(bufferSize, 1, 1);
const bitDepth = this.config.bitDepth;
const sampleRateReduction = this.config.sampleRateReduction;
const levels = Math.pow(2, bitDepth);
let lastSample = 0;
let sampleCounter = 0;
crusher.onaudioprocess = (event) => {
const input = event.inputBuffer.getChannelData(0);
const output = event.outputBuffer.getChannelData(0);
for (let i = 0; i < input.length; i++) {
sampleCounter++;
// Sample rate reduction
if (sampleCounter >= sampleRateReduction) {
sampleCounter = 0;
// Bit crushing: quantize to bitDepth levels
const sample = input[i];
lastSample = Math.round(sample * levels) / levels;
}
output[i] = lastSample;
}
};
this.bitCrusher = crusher;
// Reconnect chain with crusher
this.lowpassFilter.connect(crusher as unknown as AudioNode);
(crusher as unknown as AudioNode).connect(this.waveshaper);
}
/**
* Create a saturation curve for the waveshaper.
*/
private createSaturationCurve(amount: number): Float32Array {
const samples = 44100;
const curve = new Float32Array(samples);
const deg = Math.PI / 180;
for (let i = 0; i < samples; i++) {
const x = (i * 2) / samples - 1;
if (amount === 0) {
// No saturation - linear
curve[i] = x;
} else {
// Soft clipping curve
const k = 2 * amount / (1 - amount);
curve[i] = ((1 + k) * x) / (1 + k * Math.abs(x));
}
}
return curve;
}
/**
* Get the input node (connect your audio source to this).
*/
getInput(): GainNode {
return this.inputGain;
}
/**
* Get the output node (connect this to destination or other effects).
*/
getOutput(): GainNode {
return this.outputGain;
}
/**
* Enable/disable the colorizer.
*/
setEnabled(enabled: boolean): void {
this.config.enabled = enabled;
// When disabled, bypass could be implemented
// For now, just set gain to 0 or 1
this.inputGain.gain.value = enabled ? 1 : 0;
}
/**
* Update configuration.
*/
setConfig(config: Partial<ColorizerConfig>): void {
this.config = { ...this.config, ...config };
// Update filter frequencies
this.highpassFilter.frequency.value = this.config.highpassFreq;
this.lowpassFilter.frequency.value = this.config.lowpassFreq;
// Update saturation curve
this.waveshaper.curve = this.createSaturationCurve(this.config.saturation);
}
/**
* Get current configuration.
*/
getConfig(): ColorizerConfig {
return { ...this.config };
}
/**
* Create a preset configuration.
*/
static createPreset(preset: 'dmg' | 'gbc' | 'gba' | 'clean'): Partial<ColorizerConfig> {
switch (preset) {
case 'dmg':
// Original Game Boy - lo-fi but with bass
return {
enabled: true,
lowpassFreq: 8000,
bitDepth: 8, // Less harsh than 4-bit
sampleRateReduction: 1, // No SR reduction (causes artifacts)
saturation: 0.3,
highpassFreq: 30, // Let bass through!
};
case 'gbc':
// Game Boy Color - slightly cleaner
return {
enabled: true,
lowpassFreq: 10000,
bitDepth: 8,
sampleRateReduction: 1,
saturation: 0.2,
highpassFreq: 25,
};
case 'gba':
// Game Boy Advance - cleaner still
return {
enabled: true,
lowpassFreq: 14000,
bitDepth: 12,
sampleRateReduction: 1,
saturation: 0.1,
highpassFreq: 20,
};
case 'clean':
// No processing
return {
enabled: false,
lowpassFreq: 20000,
bitDepth: 16,
sampleRateReduction: 1,
saturation: 0,
highpassFreq: 20,
};
}
}
}
+229
View File
@@ -0,0 +1,229 @@
/**
* Arpeggiator
*
* Converts chords (simultaneous notes) into fast arpeggios.
* This is a classic Game Boy technique to simulate polyphony
* on limited channels.
*/
import type { ArpNote, ArpChord } from '../../types';
export interface ArpeggiatorConfig {
/** Speed of arpeggiation in beats (1/64 = 64th note, 1/32 = 32nd note) */
speed: number;
/** BPM for calculating actual timing */
bpm: number;
/** Pattern: 'up', 'down', 'updown', 'random' */
pattern: 'up' | 'down' | 'updown' | 'random';
/** Minimum number of notes to trigger arpeggiation (2 = any chord) */
minNotes: number;
}
const DEFAULT_CONFIG: ArpeggiatorConfig = {
speed: 1 / 32, // 32nd notes
bpm: 120,
pattern: 'up',
minNotes: 2,
};
export class Arpeggiator {
private config: ArpeggiatorConfig;
constructor(config: Partial<ArpeggiatorConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Update configuration.
*/
setConfig(config: Partial<ArpeggiatorConfig>): void {
this.config = { ...this.config, ...config };
}
/**
* Get current configuration.
*/
getConfig(): ArpeggiatorConfig {
return { ...this.config };
}
/**
* Calculate the duration of one arp step in seconds.
*/
private getStepDuration(): number {
// One beat = 60 / BPM seconds
// speed is in beats (e.g., 1/32 = 32nd note = 1/8 of a beat)
const beatDuration = 60 / this.config.bpm;
return beatDuration * this.config.speed;
}
/**
* Convert an array of notes to arpeggiated output.
* Single notes pass through unchanged.
* Chords are converted to fast arpeggios.
*/
arpeggiate(notes: ArpNote[]): ArpNote[] {
if (notes.length === 0) return [];
// Group notes by time (detect chords)
const chords = this.groupIntoChords(notes);
// Process each chord
const result: ArpNote[] = [];
for (const chord of chords) {
if (chord.notes.length < this.config.minNotes) {
// Not enough notes for a chord, pass through
result.push(...chord.notes);
} else {
// Arpeggiate the chord
result.push(...this.arpeggiateChord(chord));
}
}
// Sort by time
return result.sort((a, b) => a.time - b.time);
}
/**
* Group notes into chords based on timing.
*/
private groupIntoChords(notes: ArpNote[]): ArpChord[] {
const tolerance = 0.02; // 20ms tolerance
const sorted = [...notes].sort((a, b) => a.time - b.time);
const chords: ArpChord[] = [];
let currentChord: ArpChord | null = null;
for (const note of sorted) {
if (!currentChord || note.time - currentChord.startTime > tolerance) {
// Start a new chord
currentChord = {
startTime: note.time,
notes: [note],
};
chords.push(currentChord);
} else {
// Add to current chord
currentChord.notes.push(note);
}
}
return chords;
}
/**
* Arpeggiate a single chord.
*/
private arpeggiateChord(chord: ArpChord): ArpNote[] {
const stepDuration = this.getStepDuration();
// Sort notes by pitch based on pattern
const sortedNotes = this.sortNotesForPattern(chord.notes);
// Calculate how long the original chord should last
const maxDuration = Math.max(...chord.notes.map(n => n.duration));
// Calculate how many complete cycles we can fit
const cycleLength = sortedNotes.length * stepDuration;
const numCycles = Math.max(1, Math.floor(maxDuration / cycleLength));
const result: ArpNote[] = [];
let noteIndex = 0;
let direction = 1; // For updown pattern
// Generate arpeggiated notes
for (let cycle = 0; cycle < numCycles; cycle++) {
for (let i = 0; i < sortedNotes.length; i++) {
const originalNote = sortedNotes[noteIndex];
const time = chord.startTime + (cycle * sortedNotes.length + i) * stepDuration;
// Only add if within original duration
if (time < chord.startTime + maxDuration) {
result.push({
midiNote: originalNote.midiNote,
time,
duration: stepDuration * 0.9, // Slight gap between notes
velocity: originalNote.velocity,
});
}
// Update index based on pattern
if (this.config.pattern === 'updown') {
noteIndex += direction;
if (noteIndex >= sortedNotes.length - 1) {
direction = -1;
noteIndex = sortedNotes.length - 1;
} else if (noteIndex <= 0) {
direction = 1;
noteIndex = 0;
}
} else if (this.config.pattern === 'random') {
noteIndex = Math.floor(Math.random() * sortedNotes.length);
} else {
noteIndex = (noteIndex + 1) % sortedNotes.length;
}
}
}
return result;
}
/**
* Sort notes based on the arpeggio pattern.
*/
private sortNotesForPattern(notes: ArpNote[]): ArpNote[] {
const sorted = [...notes];
switch (this.config.pattern) {
case 'up':
case 'updown':
sorted.sort((a, b) => a.midiNote - b.midiNote);
break;
case 'down':
sorted.sort((a, b) => b.midiNote - a.midiNote);
break;
case 'random':
// Shuffle
for (let i = sorted.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[sorted[i], sorted[j]] = [sorted[j], sorted[i]];
}
break;
}
return sorted;
}
/**
* Check if a set of notes would be arpeggiated.
*/
wouldArpeggiate(notes: ArpNote[]): boolean {
const chords = this.groupIntoChords(notes);
return chords.some(chord => chord.notes.length >= this.config.minNotes);
}
/**
* Set BPM (updates timing calculations).
*/
setBPM(bpm: number): void {
this.config.bpm = Math.max(20, Math.min(300, bpm));
}
/**
* Set arpeggio speed.
*/
setSpeed(speed: number): void {
this.config.speed = speed;
}
/**
* Set arpeggio pattern.
*/
setPattern(pattern: ArpeggiatorConfig['pattern']): void {
this.config.pattern = pattern;
}
}
+364
View File
@@ -0,0 +1,364 @@
/**
* Channel Mapper
*
* Intelligently assigns MIDI tracks to the 8 GB channels based on
* track analysis results. Prioritizes the most important tracks
* and assigns them to the most appropriate channel types.
*/
import { TrackAnalyzer, type MIDITrack } from './TrackAnalyzer';
import type {
ChannelAssignment,
TrackAnalysis,
ChannelId,
PulseChannelId,
WaveChannelId,
NoiseChannelId,
TrackRole
} from '../../types';
import type { DutyIndex } from '../synthesis/DutyCycle';
import type { WavePreset } from '../synthesis/WaveTable';
import type { LFSRMode } from '../synthesis/LFSR';
/**
* Channel mapping configuration
*/
export interface ChannelMapperConfig {
/** Maximum tracks to assign (limits complexity) */
maxTracks: number;
/** Whether to arpeggiate harmony tracks */
arpeggiateHarmony: boolean;
/** Default duty cycle for lead channels */
leadDuty: DutyIndex;
/** Default duty cycle for harmony channels */
harmonyDuty: DutyIndex;
}
const DEFAULT_CONFIG: ChannelMapperConfig = {
maxTracks: 8,
arpeggiateHarmony: true,
leadDuty: 2, // 50% for full sound
harmonyDuty: 1, // 25% for thinner, less intrusive sound
};
/**
* Available channel pools by type
*/
const CHANNEL_POOLS = {
pulse: ['p1', 'p2', 'p3', 'p4'] as PulseChannelId[],
wave: ['w1', 'w2'] as WaveChannelId[],
noise: ['n1', 'n2'] as NoiseChannelId[],
};
export class ChannelMapper {
private analyzer: TrackAnalyzer;
private config: ChannelMapperConfig;
constructor(config: Partial<ChannelMapperConfig> = {}) {
this.analyzer = new TrackAnalyzer();
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Update configuration.
*/
setConfig(config: Partial<ChannelMapperConfig>): void {
this.config = { ...this.config, ...config };
}
/**
* Map MIDI tracks to GB channels.
* Returns an array of channel assignments.
*/
mapTracks(tracks: MIDITrack[]): ChannelAssignment[] {
// Analyze all tracks
const analyses = this.analyzer.analyzeTracks(tracks);
// Filter out empty tracks
const nonEmptyAnalyses = analyses.filter(a => a.noteCount > 0);
// Track used channels
const usedChannels = new Set<ChannelId>();
// Assign channels in priority order
const assignments: ChannelAssignment[] = [];
for (const analysis of nonEmptyAnalyses) {
if (assignments.length >= this.config.maxTracks) break;
const assignment = this.assignChannel(analysis, usedChannels);
if (assignment) {
assignments.push(assignment);
usedChannels.add(assignment.channelId);
}
}
return assignments;
}
/**
* Assign a single track to a channel.
*/
private assignChannel(
analysis: TrackAnalysis,
usedChannels: Set<ChannelId>
): ChannelAssignment | null {
const { role, hasChords } = analysis;
// Route to appropriate channel type based on role
switch (role) {
case 'drums':
return this.assignDrums(analysis, usedChannels);
case 'bass':
return this.assignBass(analysis, usedChannels);
case 'lead':
return this.assignLead(analysis, usedChannels);
case 'harmony':
return this.assignHarmony(analysis, usedChannels, hasChords);
case 'pad':
return this.assignPad(analysis, usedChannels);
case 'fx':
return this.assignFX(analysis, usedChannels);
default:
// Fallback to any available pulse channel
return this.assignToAnyPulse(analysis, usedChannels);
}
}
/**
* Assign drums to noise channels.
*/
private assignDrums(
analysis: TrackAnalysis,
usedChannels: Set<ChannelId>
): ChannelAssignment | null {
// Try noise channels first
const channel = this.findFreeChannel(CHANNEL_POOLS.noise, usedChannels);
if (!channel) return null;
// Use 7-bit for kick/snare, 15-bit for hihats
// Default to 7-bit as it's punchier
const noiseMode: LFSRMode = '7bit';
return {
trackIndex: analysis.trackIndex,
channelId: channel,
shouldArpeggiate: false,
noiseMode,
};
}
/**
* Assign bass to wave channel.
*/
private assignBass(
analysis: TrackAnalysis,
usedChannels: Set<ChannelId>
): ChannelAssignment | null {
// Prefer w1 for bass
if (!usedChannels.has('w1')) {
return {
trackIndex: analysis.trackIndex,
channelId: 'w1',
shouldArpeggiate: false,
wavePreset: 'bass' as WavePreset,
};
}
// Fall back to w2
if (!usedChannels.has('w2')) {
return {
trackIndex: analysis.trackIndex,
channelId: 'w2',
shouldArpeggiate: false,
wavePreset: 'bass' as WavePreset,
};
}
// No wave channels available, try pulse with low duty
const pulseChannel = this.findFreeChannel(CHANNEL_POOLS.pulse, usedChannels);
if (pulseChannel) {
return {
trackIndex: analysis.trackIndex,
channelId: pulseChannel,
shouldArpeggiate: false,
dutyCycle: 2 as DutyIndex, // 50% for fuller bass
};
}
return null;
}
/**
* Assign lead melody to pulse channels.
*/
private assignLead(
analysis: TrackAnalysis,
usedChannels: Set<ChannelId>
): ChannelAssignment | null {
// Prefer p1 or p2 (sweep-capable) for lead
for (const channelId of ['p1', 'p2'] as PulseChannelId[]) {
if (!usedChannels.has(channelId)) {
return {
trackIndex: analysis.trackIndex,
channelId,
shouldArpeggiate: false,
dutyCycle: this.config.leadDuty,
};
}
}
// Fall back to p3/p4
const channel = this.findFreeChannel(['p3', 'p4'] as PulseChannelId[], usedChannels);
if (channel) {
return {
trackIndex: analysis.trackIndex,
channelId: channel,
shouldArpeggiate: false,
dutyCycle: this.config.leadDuty,
};
}
return null;
}
/**
* Assign harmony to pulse channels (with optional arpeggio).
*/
private assignHarmony(
analysis: TrackAnalysis,
usedChannels: Set<ChannelId>,
hasChords: boolean
): ChannelAssignment | null {
// Use p3/p4 for harmony (thinner sound, no sweep)
const channel = this.findFreeChannel(['p3', 'p4', 'p1', 'p2'] as PulseChannelId[], usedChannels);
if (!channel) return null;
return {
trackIndex: analysis.trackIndex,
channelId: channel,
shouldArpeggiate: this.config.arpeggiateHarmony && hasChords,
dutyCycle: this.config.harmonyDuty,
};
}
/**
* Assign pad to wave channel.
*/
private assignPad(
analysis: TrackAnalysis,
usedChannels: Set<ChannelId>
): ChannelAssignment | null {
// Prefer w2 for pads
if (!usedChannels.has('w2')) {
return {
trackIndex: analysis.trackIndex,
channelId: 'w2',
shouldArpeggiate: false,
wavePreset: 'pad' as WavePreset,
};
}
// Fall back to w1
if (!usedChannels.has('w1')) {
return {
trackIndex: analysis.trackIndex,
channelId: 'w1',
shouldArpeggiate: false,
wavePreset: 'pad' as WavePreset,
};
}
// Fall back to pulse
const pulseChannel = this.findFreeChannel(CHANNEL_POOLS.pulse, usedChannels);
if (pulseChannel) {
return {
trackIndex: analysis.trackIndex,
channelId: pulseChannel,
shouldArpeggiate: false,
dutyCycle: 2 as DutyIndex,
};
}
return null;
}
/**
* Assign FX/incidental to any available pulse channel.
*/
private assignFX(
analysis: TrackAnalysis,
usedChannels: Set<ChannelId>
): ChannelAssignment | null {
// FX goes to any available pulse channel
const channel = this.findFreeChannel(CHANNEL_POOLS.pulse, usedChannels);
if (!channel) return null;
return {
trackIndex: analysis.trackIndex,
channelId: channel,
shouldArpeggiate: false,
dutyCycle: 0 as DutyIndex, // 12.5% for thin, effects-like sound
};
}
/**
* Assign to any available pulse channel.
*/
private assignToAnyPulse(
analysis: TrackAnalysis,
usedChannels: Set<ChannelId>
): ChannelAssignment | null {
const channel = this.findFreeChannel(CHANNEL_POOLS.pulse, usedChannels);
if (!channel) return null;
return {
trackIndex: analysis.trackIndex,
channelId: channel,
shouldArpeggiate: false,
dutyCycle: 2 as DutyIndex,
};
}
/**
* Find the first free channel from a pool.
*/
private findFreeChannel<T extends ChannelId>(
pool: T[],
usedChannels: Set<ChannelId>
): T | null {
for (const channel of pool) {
if (!usedChannels.has(channel)) {
return channel;
}
}
return null;
}
/**
* Get the analyzer for external use.
*/
getAnalyzer(): TrackAnalyzer {
return this.analyzer;
}
/**
* Analyze tracks without mapping (useful for UI display).
*/
analyzeTracks(tracks: MIDITrack[]): TrackAnalysis[] {
return this.analyzer.analyzeTracks(tracks);
}
}
+335
View File
@@ -0,0 +1,335 @@
/**
* MIDI Track Analyzer
*
* Analyzes MIDI tracks to determine their musical role and characteristics.
* This information is used by the ChannelMapper to intelligently assign
* tracks to the appropriate GB channels.
*/
import type { TrackAnalysis, TrackRole } from '../../types';
/**
* Raw note data from a MIDI track
*/
export interface MIDINote {
midi: number; // MIDI note number (0-127)
time: number; // Start time in seconds
duration: number; // Duration in seconds
velocity: number; // Velocity (0-127)
}
/**
* Parsed track data from a MIDI file
*/
export interface MIDITrack {
channel: number; // MIDI channel (0-15)
notes: MIDINote[];
name?: string;
}
export class TrackAnalyzer {
/**
* Analyze a single MIDI track and determine its characteristics.
*/
analyzeTrack(track: MIDITrack, trackIndex: number): TrackAnalysis {
const notes = track.notes;
if (notes.length === 0) {
return this.createEmptyAnalysis(trackIndex, track.channel);
}
// Calculate basic statistics
const noteRange = this.calculateNoteRange(notes);
const noteDensity = this.calculateNoteDensity(notes);
const avgVelocity = this.calculateAverageVelocity(notes);
const avgDuration = this.calculateAverageDuration(notes);
const complexity = this.calculateComplexity(notes);
const hasChords = this.detectChords(notes);
// Detect if this is a drums track
const isDrums = track.channel === 9 || this.detectDrums(notes);
const isPercussive = this.detectPercussive(notes);
// Determine the musical role
const role = this.detectRole(notes, noteRange, isDrums, noteDensity, hasChords, avgDuration);
// Calculate priority for channel assignment
const priority = this.calculatePriority(role, noteDensity, avgVelocity, notes.length);
return {
trackIndex,
channel: track.channel,
isDrums,
isPercussive,
noteRange,
noteDensity,
complexity,
hasChords,
avgVelocity,
avgDuration,
noteCount: notes.length,
role,
priority,
};
}
/**
* Analyze multiple tracks and return sorted by priority.
*/
analyzeTracks(tracks: MIDITrack[]): TrackAnalysis[] {
const analyses = tracks.map((track, index) => this.analyzeTrack(track, index));
// Sort by priority (highest first)
return analyses.sort((a, b) => b.priority - a.priority);
}
/**
* Create an empty analysis for a track with no notes.
*/
private createEmptyAnalysis(trackIndex: number, channel: number): TrackAnalysis {
return {
trackIndex,
channel,
isDrums: false,
isPercussive: false,
noteRange: { min: 0, max: 0, avg: 0 },
noteDensity: 0,
complexity: 0,
hasChords: false,
avgVelocity: 0,
avgDuration: 0,
noteCount: 0,
role: 'fx',
priority: 0,
};
}
/**
* Calculate the note range (min, max, average pitch).
*/
private calculateNoteRange(notes: MIDINote[]): { min: number; max: number; avg: number } {
if (notes.length === 0) {
return { min: 0, max: 0, avg: 0 };
}
let min = 127;
let max = 0;
let sum = 0;
for (const note of notes) {
min = Math.min(min, note.midi);
max = Math.max(max, note.midi);
sum += note.midi;
}
return {
min,
max,
avg: sum / notes.length,
};
}
/**
* Calculate note density (notes per second).
*/
private calculateNoteDensity(notes: MIDINote[]): number {
if (notes.length < 2) return 0;
const startTime = notes[0].time;
const endTime = notes[notes.length - 1].time + notes[notes.length - 1].duration;
const duration = endTime - startTime;
if (duration <= 0) return 0;
return notes.length / duration;
}
/**
* Calculate average velocity.
*/
private calculateAverageVelocity(notes: MIDINote[]): number {
if (notes.length === 0) return 0;
const sum = notes.reduce((acc, note) => acc + note.velocity, 0);
return sum / notes.length;
}
/**
* Calculate average note duration.
*/
private calculateAverageDuration(notes: MIDINote[]): number {
if (notes.length === 0) return 0;
const sum = notes.reduce((acc, note) => acc + note.duration, 0);
return sum / notes.length;
}
/**
* Calculate complexity score (0-1).
* Based on pitch variation, rhythm variation, and density.
*/
private calculateComplexity(notes: MIDINote[]): number {
if (notes.length < 2) return 0;
// Pitch variation
const range = this.calculateNoteRange(notes);
const pitchVariation = Math.min(1, (range.max - range.min) / 36); // Normalize to 3 octaves
// Rhythm variation (variance in inter-note timing)
const timeDiffs: number[] = [];
for (let i = 1; i < notes.length; i++) {
timeDiffs.push(notes[i].time - notes[i - 1].time);
}
if (timeDiffs.length === 0) return pitchVariation * 0.5;
const avgTimeDiff = timeDiffs.reduce((a, b) => a + b, 0) / timeDiffs.length;
const timeVariance = timeDiffs.reduce((acc, t) => acc + Math.pow(t - avgTimeDiff, 2), 0) / timeDiffs.length;
const rhythmVariation = Math.min(1, Math.sqrt(timeVariance) / avgTimeDiff);
return (pitchVariation * 0.6 + rhythmVariation * 0.4);
}
/**
* Detect if notes contain chords (multiple simultaneous notes).
*/
private detectChords(notes: MIDINote[]): boolean {
// Group notes by time (10ms tolerance)
const tolerance = 0.01;
const timeSlots = new Map<number, number>();
for (const note of notes) {
const slot = Math.floor(note.time / tolerance);
timeSlots.set(slot, (timeSlots.get(slot) || 0) + 1);
}
// Count how many slots have more than 2 notes
let chordSlots = 0;
for (const count of timeSlots.values()) {
if (count >= 2) chordSlots++;
}
// If more than 10% of time slots have chords, this track has chords
return chordSlots > timeSlots.size * 0.1;
}
/**
* Detect if this is a drums track (based on note patterns, not just channel).
*/
private detectDrums(notes: MIDINote[]): boolean {
if (notes.length < 4) return false;
// Drums typically have:
// 1. Short note durations
// 2. Limited pitch range (clustered around GM drum notes 35-81)
// 3. High velocity variation
const avgDuration = this.calculateAverageDuration(notes);
const range = this.calculateNoteRange(notes);
// Very short notes
const shortNotes = avgDuration < 0.1;
// Limited pitch range around drum notes
const drumPitchRange = range.min >= 35 && range.max <= 81 && (range.max - range.min) < 30;
// High repetition (same notes repeated often)
const pitchCounts = new Map<number, number>();
for (const note of notes) {
pitchCounts.set(note.midi, (pitchCounts.get(note.midi) || 0) + 1);
}
const uniquePitches = pitchCounts.size;
const highRepetition = uniquePitches < 10 && notes.length > 20;
return shortNotes && (drumPitchRange || highRepetition);
}
/**
* Detect if track is percussive (short, rhythmic).
*/
private detectPercussive(notes: MIDINote[]): boolean {
const avgDuration = this.calculateAverageDuration(notes);
return avgDuration < 0.15;
}
/**
* Determine the musical role of the track.
*/
private detectRole(
notes: MIDINote[],
range: { min: number; max: number; avg: number },
isDrums: boolean,
density: number,
hasChords: boolean,
avgDuration: number
): TrackRole {
// Drums are drums
if (isDrums) return 'drums';
// Bass: low average pitch (MIDI 52 = E3, typical bass range)
// Also consider tracks where the max note is low
if (range.avg < 52 || range.max < 55) return 'bass';
// Lead: high pitch with high density
if (range.avg > 58 && density > 2) return 'lead';
// Pad: low density, long notes
if (density < 1.5 && avgDuration > 0.5) return 'pad';
// Harmony: has chords
if (hasChords) return 'harmony';
// FX: very high density
if (density > 8) return 'fx';
// Default to lead for melodic content
return 'lead';
}
/**
* Calculate priority for channel assignment.
* Higher priority = assigned first to best channels.
*/
private calculatePriority(
role: TrackRole,
density: number,
avgVelocity: number,
noteCount: number
): number {
let priority = 50;
// Role-based priority
switch (role) {
case 'drums':
priority += 30;
break;
case 'bass':
priority += 25;
break;
case 'lead':
priority += 20;
break;
case 'harmony':
priority += 15;
break;
case 'pad':
priority += 10;
break;
case 'fx':
priority += 5;
break;
}
// Density bonus (up to 20 points)
priority += Math.min(20, density * 2);
// Velocity bonus (up to 10 points)
priority += (avgVelocity / 127) * 10;
// Note count bonus (logarithmic, up to 10 points)
priority += Math.min(10, Math.log10(noteCount + 1) * 3);
return priority;
}
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Game Boy Duty Cycle Implementation
*
* The GB pulse channels support 4 duty cycle patterns.
* These exact duty ratios give the Game Boy its distinctive sound.
*/
/**
* Duty cycle ratios for the 4 GB patterns
* 12.5% - Very thin, buzzy, laser-like sound
* 25% - Classic chiptune sound, bright and punchy
* 50% - Full square wave
* 75% - Same as 25% but inverted
*/
export const DUTY_RATIOS = [0.125, 0.25, 0.5, 0.75] as const;
export type DutyIndex = 0 | 1 | 2 | 3;
/**
* Creates a PeriodicWave for Web Audio from a duty cycle.
*
* Uses proper Fourier series for pulse wave:
* imag[n] = (2 / (π * n)) * sin(π * n * duty)
*
* This is the mathematically correct way to synthesize pulse waves.
*/
export function createDutyWave(
dutyIndex: DutyIndex,
audioContext: BaseAudioContext
): PeriodicWave {
const dutyRatio = DUTY_RATIOS[dutyIndex];
// More harmonics = sharper edges (but more CPU)
const numHarmonics = 64;
const real = new Float32Array(numHarmonics);
const imag = new Float32Array(numHarmonics);
// DC offset = 0 for centered waveform
real[0] = 0;
imag[0] = 0;
// Fourier series for pulse wave
// https://en.wikipedia.org/wiki/Pulse_wave
for (let n = 1; n < numHarmonics; n++) {
// Pulse wave Fourier coefficient
const coefficient = (2 / (Math.PI * n)) * Math.sin(Math.PI * n * dutyRatio);
imag[n] = coefficient;
real[n] = 0;
}
return audioContext.createPeriodicWave(real, imag, {
disableNormalization: false
});
}
/**
* Pre-creates all 4 duty cycle waveforms for efficient reuse.
*/
export function createAllDutyWaves(
audioContext: BaseAudioContext
): PeriodicWave[] {
return [
createDutyWave(0, audioContext),
createDutyWave(1, audioContext),
createDutyWave(2, audioContext),
createDutyWave(3, audioContext),
];
}
/**
* Returns a human-readable description of each duty cycle.
*/
export function getDutyDescription(dutyIndex: DutyIndex): string {
const descriptions = [
'12.5% - Thin, buzzy',
'25% - Classic chiptune',
'50% - Full square',
'75% - Bright, punchy',
];
return descriptions[dutyIndex];
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Game Boy Frequency Calculations
*
* The GB uses specific frequency formulas based on 11-bit period registers.
* This creates slightly "off" tuning compared to standard A440 tuning,
* which is part of the characteristic GB sound.
*
* Reference: https://gbdev.io/pandocs/Audio_details.html
*/
/**
* GB CPU clock rate used for audio timing
*/
const GB_CLOCK = 4194304; // 4.194304 MHz
/**
* Pulse channel base frequency divider
* Formula: freq = 131072 / (2048 - period)
*/
const PULSE_FREQ_BASE = 131072;
/**
* Wave channel base frequency divider
* Formula: freq = 65536 / (2048 - period)
* (Half the pulse frequency, so wave plays one octave lower for same period)
*/
const WAVE_FREQ_BASE = 65536;
/**
* Maximum period register value (11-bit)
*/
const MAX_PERIOD = 2047;
/**
* Noise channel divisor lookup table
* Used with divisor code (r) in noise frequency calculation
*/
const NOISE_DIVISORS = [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4] as const;
/**
* Convert MIDI note number to standard frequency (A4 = 440Hz)
*/
export function midiToStandardFrequency(midiNote: number): number {
return 440 * Math.pow(2, (midiNote - 69) / 12);
}
/**
* Convert standard frequency to GB pulse period register value.
* Returns clamped 11-bit value (0-2047).
*/
export function frequencyToPulsePeriod(frequency: number): number {
// freq = 131072 / (2048 - period)
// period = 2048 - (131072 / freq)
const period = Math.round(2048 - (PULSE_FREQ_BASE / frequency));
return Math.max(0, Math.min(MAX_PERIOD, period));
}
/**
* Convert GB pulse period register to actual output frequency.
*/
export function pulsePeriodToFrequency(period: number): number {
if (period >= 2048) return 0;
return PULSE_FREQ_BASE / (2048 - period);
}
/**
* Calculate the actual GB frequency for a pulse channel from MIDI note.
*
* This goes: MIDI → standard freq → period register → GB freq
* The register quantization creates the characteristic slight detuning.
*/
export function calculatePulseFrequency(midiNote: number): number {
const standardFreq = midiToStandardFrequency(midiNote);
const period = frequencyToPulsePeriod(standardFreq);
return pulsePeriodToFrequency(period);
}
/**
* Convert standard frequency to GB wave period register value.
*/
export function frequencyToWavePeriod(frequency: number): number {
// freq = 65536 / (2048 - period)
// period = 2048 - (65536 / freq)
const period = Math.round(2048 - (WAVE_FREQ_BASE / frequency));
return Math.max(0, Math.min(MAX_PERIOD, period));
}
/**
* Convert GB wave period register to actual output frequency.
*/
export function wavePeriodToFrequency(period: number): number {
if (period >= 2048) return 0;
return WAVE_FREQ_BASE / (2048 - period);
}
/**
* Calculate the actual GB frequency for a wave channel from MIDI note.
*/
export function calculateWaveFrequency(midiNote: number): number {
const standardFreq = midiToStandardFrequency(midiNote);
const period = frequencyToWavePeriod(standardFreq);
return wavePeriodToFrequency(period);
}
/**
* Calculate noise channel frequency.
*
* @param divisorCode - Divisor code (0-7), selects from NOISE_DIVISORS
* @param clockShift - Clock shift (0-14), higher = lower frequency
* @returns Frequency in Hz
*
* Formula: freq = 524288 / divisor / 2^(shift+1)
*/
export function calculateNoiseFrequency(
divisorCode: number,
clockShift: number
): number {
const divisor = NOISE_DIVISORS[divisorCode % 8];
const shift = Math.max(0, Math.min(14, clockShift));
return 524288 / divisor / Math.pow(2, shift + 1);
}
/**
* Map a MIDI note to noise parameters.
* Lower notes = lower noise frequency (more "boomy")
* Higher notes = higher noise frequency (more "hissy")
*
* This is an approximation since noise isn't truly pitched.
*/
export function midiToNoiseParams(midiNote: number): {
divisorCode: number;
clockShift: number;
} {
// Map MIDI notes 24-96 to noise parameters
// Lower notes get higher shift (lower freq)
// Higher notes get lower shift (higher freq)
const normalized = Math.max(0, Math.min(72, midiNote - 24));
// Map to shift (0-14): high notes = low shift, low notes = high shift
const clockShift = Math.floor(14 - (normalized / 72) * 14);
// Divisor code affects timbre - use middle values for most natural sound
const divisorCode = Math.floor((normalized % 8));
return { divisorCode, clockShift };
}
/**
* Calculate the frequency deviation from standard tuning.
* Useful for testing/verification.
*
* @returns Deviation in cents (100 cents = 1 semitone)
*/
export function getFrequencyDeviation(midiNote: number): number {
const standard = midiToStandardFrequency(midiNote);
const gbFreq = calculatePulseFrequency(midiNote);
// Cents = 1200 * log2(f2/f1)
return 1200 * Math.log2(gbFreq / standard);
}
+181
View File
@@ -0,0 +1,181 @@
/**
* Linear Feedback Shift Register (LFSR) Noise Generator
*
* The Game Boy's noise channel uses a 15-bit LFSR to generate
* pseudo-random noise. It can also operate in 7-bit mode for
* a more tonal, metallic sound.
*
* This is what gives GB noise its characteristic "crunchy" quality
* compared to smooth white noise.
*
* Reference: https://gbdev.io/pandocs/Audio_details.html#noise-channel
*/
export type LFSRMode = '7bit' | '15bit';
/**
* Initial LFSR seed value (all 1s for 15-bit register)
*/
const INITIAL_SEED = 0x7FFF;
/**
* LFSR noise generator that matches Game Boy hardware behavior.
*/
export class LFSR {
private lfsr: number;
private mode: LFSRMode;
constructor(mode: LFSRMode = '15bit') {
this.mode = mode;
this.lfsr = INITIAL_SEED;
}
/**
* Clock the LFSR once and return the output bit.
*
* Algorithm:
* 1. XOR bits 0 and 1 to get new bit
* 2. Output is current bit 0 (before shift)
* 3. Shift register right by 1
* 4. Put XOR result into bit 14
* 5. If 7-bit mode, also put XOR result into bit 6
*
* @returns 0 or 1
*/
clock(): number {
// Output is bit 0 before we modify anything
const output = this.lfsr & 1;
// XOR bits 0 and 1
const bit0 = this.lfsr & 1;
const bit1 = (this.lfsr >> 1) & 1;
const xorResult = bit0 ^ bit1;
// Shift right by 1
this.lfsr >>= 1;
// Set bit 14 to XOR result
this.lfsr |= (xorResult << 14);
// In 7-bit mode, also set bit 6
if (this.mode === '7bit') {
// Clear bit 6 first, then set if needed
this.lfsr &= ~(1 << 6);
this.lfsr |= (xorResult << 6);
}
return output;
}
/**
* Reset LFSR to initial state.
*/
reset(): void {
this.lfsr = INITIAL_SEED;
}
/**
* Set the LFSR mode.
* 7-bit mode produces more tonal, metallic sounds.
* 15-bit mode produces fuller noise.
*/
setMode(mode: LFSRMode): void {
this.mode = mode;
}
/**
* Get current mode.
*/
getMode(): LFSRMode {
return this.mode;
}
/**
* Get current register value (for debugging/visualization).
*/
getValue(): number {
return this.lfsr;
}
/**
* Generate a sequence of n output bits.
* Useful for verification against known GB sequences.
*/
generateSequence(length: number): number[] {
const sequence: number[] = [];
for (let i = 0; i < length; i++) {
sequence.push(this.clock());
}
return sequence;
}
}
/**
* Known first 20 values of 15-bit LFSR starting from 0x7FFF (all 1s).
* The first outputs are just the low bits shifting out.
* Used for verification that our implementation matches GB hardware.
*/
export const LFSR_15BIT_EXPECTED = [
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
0, 0, 0, 0, 0
];
/**
* Verify that our LFSR implementation produces correct output.
*/
export function verifyLFSR(): boolean {
const lfsr = new LFSR('15bit');
const sequence = lfsr.generateSequence(20);
for (let i = 0; i < LFSR_15BIT_EXPECTED.length; i++) {
if (sequence[i] !== LFSR_15BIT_EXPECTED[i]) {
console.error(`LFSR mismatch at index ${i}: got ${sequence[i]}, expected ${LFSR_15BIT_EXPECTED[i]}`);
return false;
}
}
return true;
}
/**
* Generate an audio buffer filled with LFSR noise.
*
* @param audioContext - Web Audio context
* @param duration - Duration in seconds
* @param frequency - Clock frequency of the LFSR
* @param mode - LFSR mode (7bit or 15bit)
* @returns AudioBuffer filled with noise
*/
export function generateNoiseBuffer(
audioContext: BaseAudioContext,
duration: number,
frequency: number,
mode: LFSRMode = '15bit'
): AudioBuffer {
const sampleRate = audioContext.sampleRate;
const bufferLength = Math.ceil(duration * sampleRate);
const buffer = audioContext.createBuffer(1, bufferLength, sampleRate);
const data = buffer.getChannelData(0);
const lfsr = new LFSR(mode);
// How many samples between LFSR clocks
const samplesPerClock = sampleRate / frequency;
let clockAccumulator = 0;
let currentOutput = 0;
for (let i = 0; i < bufferLength; i++) {
// Clock LFSR when accumulator reaches threshold
clockAccumulator += 1;
if (clockAccumulator >= samplesPerClock) {
currentOutput = lfsr.clock();
clockAccumulator -= samplesPerClock;
}
// Convert 0/1 to -1/+1 for audio
data[i] = currentOutput * 2 - 1;
}
return buffer;
}
+306
View File
@@ -0,0 +1,306 @@
/**
* Game Boy Wave Channel Wavetable
*
* The GB wave channel uses a 32-sample wavetable with 4-bit resolution.
* Each sample can be 0-15, giving the characteristic "digital staircase"
* sound quality.
*
* The low resolution creates audible quantization that's part of the
* GB's unique character - smoother than pulse but still distinctly digital.
*
* Reference: https://gbdev.io/pandocs/Audio_details.html#wave-channel
*/
/**
* Number of samples in the wavetable
*/
export const WAVE_TABLE_SIZE = 32;
/**
* Maximum sample value (4-bit = 0-15)
*/
export const MAX_SAMPLE_VALUE = 15;
/**
* GB wave channel volume levels (bit-shift based)
* 0 = mute, 1 = 100%, 2 = 50%, 3 = 25%
*/
export type WaveVolume = 0 | 1 | 2 | 3;
/**
* Volume multipliers matching GB behavior
* GB uses right-shift for volume: 0=mute, 1=>>0, 2=>>1, 3=>>2
*/
export const VOLUME_MULTIPLIERS: Record<WaveVolume, number> = {
0: 0,
1: 1.0,
2: 0.5,
3: 0.25,
};
/**
* Wavetable class for the GB wave channel.
*/
export class WaveTable {
private samples: Uint8Array;
constructor() {
this.samples = new Uint8Array(WAVE_TABLE_SIZE);
// Initialize with silence
this.samples.fill(8); // 8 = center value (no DC offset)
}
/**
* Quantize a float value (0-1) to 4-bit (0-15).
*/
private quantize(value: number): number {
const clamped = Math.max(0, Math.min(1, value));
return Math.floor(clamped * MAX_SAMPLE_VALUE);
}
/**
* Load a waveform from a float array (0-1 range).
* Values are quantized to 4-bit resolution.
*/
loadFromFloats(waveform: number[]): void {
for (let i = 0; i < WAVE_TABLE_SIZE; i++) {
const value = i < waveform.length ? waveform[i] : 0.5;
this.samples[i] = this.quantize(value);
}
}
/**
* Load raw 4-bit samples directly.
*/
loadFromBytes(samples: number[]): void {
for (let i = 0; i < WAVE_TABLE_SIZE; i++) {
const value = i < samples.length ? samples[i] : 8;
this.samples[i] = Math.max(0, Math.min(MAX_SAMPLE_VALUE, Math.floor(value)));
}
}
/**
* Get the raw sample array.
*/
getSamples(): Uint8Array {
return this.samples;
}
/**
* Create a Web Audio buffer from this wavetable.
* The buffer is one cycle of the waveform.
*/
createBuffer(audioContext: BaseAudioContext): AudioBuffer {
const buffer = audioContext.createBuffer(1, WAVE_TABLE_SIZE, audioContext.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < WAVE_TABLE_SIZE; i++) {
// Convert 0-15 to -1 to +1
data[i] = (this.samples[i] / MAX_SAMPLE_VALUE) * 2 - 1;
}
return buffer;
}
/**
* Create an extended buffer for better audio quality.
* Repeats the waveform multiple times to avoid pitch artifacts.
*/
createExtendedBuffer(
audioContext: BaseAudioContext,
repetitions: number = 256
): AudioBuffer {
const totalSamples = WAVE_TABLE_SIZE * repetitions;
const buffer = audioContext.createBuffer(1, totalSamples, audioContext.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < totalSamples; i++) {
const sampleIndex = i % WAVE_TABLE_SIZE;
data[i] = (this.samples[sampleIndex] / MAX_SAMPLE_VALUE) * 2 - 1;
}
return buffer;
}
}
/**
* Generate a triangle wave with 4-bit quantization.
* Classic GB bass sound.
*/
export function generateTriangleWave(): Uint8Array {
const wave = new Uint8Array(WAVE_TABLE_SIZE);
for (let i = 0; i < WAVE_TABLE_SIZE; i++) {
// Triangle: ramp up for first half, down for second half
const position = i / WAVE_TABLE_SIZE;
let value: number;
if (position < 0.5) {
value = position * 2; // 0 to 1
} else {
value = 2 - position * 2; // 1 to 0
}
wave[i] = Math.floor(value * MAX_SAMPLE_VALUE);
}
return wave;
}
/**
* Generate a sawtooth wave with 4-bit quantization.
* Brighter, more aggressive sound.
*/
export function generateSawtoothWave(): Uint8Array {
const wave = new Uint8Array(WAVE_TABLE_SIZE);
for (let i = 0; i < WAVE_TABLE_SIZE; i++) {
wave[i] = Math.floor((i / (WAVE_TABLE_SIZE - 1)) * MAX_SAMPLE_VALUE);
}
return wave;
}
/**
* Generate a sine-ish wave with 4-bit quantization.
* Rounder, softer sound for pads.
*/
export function generateSineWave(): Uint8Array {
const wave = new Uint8Array(WAVE_TABLE_SIZE);
for (let i = 0; i < WAVE_TABLE_SIZE; i++) {
const angle = (i / WAVE_TABLE_SIZE) * Math.PI * 2;
const sine = (Math.sin(angle) + 1) / 2; // Normalize to 0-1
wave[i] = Math.floor(sine * MAX_SAMPLE_VALUE);
}
return wave;
}
/**
* Generate a square wave with 4-bit resolution.
* Sharp, bright sound.
*/
export function generateSquareWave(): Uint8Array {
const wave = new Uint8Array(WAVE_TABLE_SIZE);
for (let i = 0; i < WAVE_TABLE_SIZE; i++) {
wave[i] = i < WAVE_TABLE_SIZE / 2 ? MAX_SAMPLE_VALUE : 0;
}
return wave;
}
/**
* Generate a bass-optimized waveform.
* Combination of triangle with slight harmonics.
*/
export function generateBassWave(): Uint8Array {
const wave = new Uint8Array(WAVE_TABLE_SIZE);
for (let i = 0; i < WAVE_TABLE_SIZE; i++) {
const position = i / WAVE_TABLE_SIZE;
const angle = position * Math.PI * 2;
// Fundamental + slight 2nd harmonic for warmth
const value = (Math.sin(angle) * 0.8 + Math.sin(angle * 2) * 0.2 + 1) / 2;
wave[i] = Math.floor(value * MAX_SAMPLE_VALUE);
}
return wave;
}
/**
* Generate a pad-optimized waveform.
* Softer, rounder character.
*/
export function generatePadWave(): Uint8Array {
// Use sine wave for pads - smoothest option
return generateSineWave();
}
/**
* Generate a lead-optimized waveform.
* Brighter with more harmonics.
*/
export function generateLeadWave(): Uint8Array {
const wave = new Uint8Array(WAVE_TABLE_SIZE);
for (let i = 0; i < WAVE_TABLE_SIZE; i++) {
const position = i / WAVE_TABLE_SIZE;
const angle = position * Math.PI * 2;
// Mix of saw and triangle characteristics
const saw = position;
const tri = position < 0.5 ? position * 2 : 2 - position * 2;
const value = saw * 0.6 + tri * 0.4;
wave[i] = Math.floor(value * MAX_SAMPLE_VALUE);
}
return wave;
}
/**
* Preset wavetables for easy access.
*/
export const WAVE_PRESETS = {
triangle: generateTriangleWave,
sawtooth: generateSawtoothWave,
sine: generateSineWave,
square: generateSquareWave,
bass: generateBassWave,
pad: generatePadWave,
lead: generateLeadWave,
} as const;
export type WavePreset = keyof typeof WAVE_PRESETS;
/**
* Create a PeriodicWave from a wavetable for use with OscillatorNode.
* This is more accurate than using AudioBufferSourceNode with playback rate.
*/
export function createPeriodicWaveFromTable(
samples: Uint8Array | number[],
audioContext: BaseAudioContext
): PeriodicWave {
const n = samples.length;
// Convert samples to normalized audio values (-1 to +1)
const normalized: number[] = [];
for (let i = 0; i < n; i++) {
const sample = typeof samples[i] === 'number' ? samples[i] : 0;
normalized.push((sample / MAX_SAMPLE_VALUE) * 2 - 1);
}
// Number of harmonics - more harmonics = more accurate representation
const numHarmonics = 64;
// Calculate Fourier coefficients
const real = new Float32Array(numHarmonics);
const imag = new Float32Array(numHarmonics);
// DC offset (real[0]) should be 0 for centered waveform
real[0] = 0;
imag[0] = 0;
// Calculate each harmonic using DFT
for (let k = 1; k < numHarmonics; k++) {
let realSum = 0;
let imagSum = 0;
for (let i = 0; i < n; i++) {
const angle = (2 * Math.PI * k * i) / n;
realSum += normalized[i] * Math.cos(angle);
imagSum -= normalized[i] * Math.sin(angle);
}
// Scale by 2/n for proper amplitude
real[k] = (2 * realSum) / n;
imag[k] = (2 * imagSum) / n;
}
return audioContext.createPeriodicWave(real, imag, {
disableNormalization: false
});
}
+267
View File
@@ -0,0 +1,267 @@
/**
* Sound Test for Wario Synth v2
*
* Tests all individual sound generators to verify they work
* and sound authentically Game Boy-like.
*/
import { PulseChannel } from '../apu/PulseChannel';
import { WaveChannel } from '../apu/WaveChannel';
import { NoiseChannel } from '../apu/NoiseChannel';
import { verifyLFSR } from '../synthesis/LFSR';
import { getFrequencyDeviation } from '../synthesis/FrequencyCalc';
import type { DutyIndex } from '../synthesis/DutyCycle';
export interface TestResult {
name: string;
passed: boolean;
message: string;
}
/**
* Run all verification tests (non-audio).
*/
export function runVerificationTests(): TestResult[] {
const results: TestResult[] = [];
// Test LFSR implementation
const lfsrOk = verifyLFSR();
results.push({
name: 'LFSR Sequence',
passed: lfsrOk,
message: lfsrOk ? 'LFSR matches expected GB sequence' : 'LFSR sequence mismatch!'
});
// Test frequency deviation (should be non-zero but small)
const devA4 = getFrequencyDeviation(69); // A4
const devOk = Math.abs(devA4) > 0.01 && Math.abs(devA4) < 10;
results.push({
name: 'Frequency Deviation',
passed: devOk,
message: `A4 deviation: ${devA4.toFixed(2)} cents (expected small non-zero value)`
});
return results;
}
/**
* Create test channels for audio testing.
*/
export function createTestChannels(audioContext: AudioContext) {
// Create master gain
const masterGain = audioContext.createGain();
masterGain.gain.value = 0.5;
masterGain.connect(audioContext.destination);
// Create individual channel gains
const pulseGain = audioContext.createGain();
pulseGain.gain.value = 0.4;
pulseGain.connect(masterGain);
const waveGain = audioContext.createGain();
waveGain.gain.value = 0.5;
waveGain.connect(masterGain);
const noiseGain = audioContext.createGain();
noiseGain.gain.value = 0.4;
noiseGain.connect(masterGain);
return {
pulse: new PulseChannel(audioContext, pulseGain, true),
wave: new WaveChannel(audioContext, waveGain, 'bass'),
noise: new NoiseChannel(audioContext, noiseGain, '15bit'),
masterGain
};
}
/**
* Test all 4 duty cycles on the pulse channel.
*/
export async function testDutyCycles(
pulse: PulseChannel,
audioContext: AudioContext
): Promise<void> {
console.log('Testing duty cycles...');
const duties: DutyIndex[] = [0, 1, 2, 3];
const dutyNames = ['12.5%', '25%', '50%', '75%'];
for (let i = 0; i < duties.length; i++) {
console.log(` Playing duty cycle ${dutyNames[i]}`);
pulse.setDutyCycle(duties[i]);
// Play a short melody
const notes = [60, 64, 67, 72]; // C major arpeggio
const now = audioContext.currentTime;
notes.forEach((note, idx) => {
pulse.playNote(note, 0.15, 100, now + idx * 0.2);
});
// Wait for notes to finish
await sleep(1000);
}
console.log('Duty cycle test complete!');
}
/**
* Test the wave channel with different presets.
*/
export async function testWaveChannel(
wave: WaveChannel,
audioContext: AudioContext
): Promise<void> {
console.log('Testing wave channel...');
const presets = ['bass', 'pad', 'lead', 'triangle', 'sawtooth'] as const;
for (const preset of presets) {
console.log(` Playing preset: ${preset}`);
wave.loadPreset(preset);
// Play a bass line
const notes = [36, 36, 43, 41]; // Low C, C, G, F
const now = audioContext.currentTime;
notes.forEach((note, idx) => {
wave.playNote(note, 0.4, 100, now + idx * 0.5);
});
await sleep(2200);
}
console.log('Wave channel test complete!');
}
/**
* Test the noise channel with different modes.
*/
export async function testNoiseChannel(
noise: NoiseChannel,
audioContext: AudioContext
): Promise<void> {
console.log('Testing noise channel...');
// Test 15-bit mode (fuller noise)
console.log(' Testing 15-bit mode (full noise)');
noise.setMode('15bit');
let now = audioContext.currentTime;
noise.playHihat(80, false, now);
noise.playHihat(60, false, now + 0.25);
noise.playHihat(80, false, now + 0.5);
noise.playHihat(60, true, now + 0.75);
await sleep(1500);
// Test 7-bit mode (metallic)
console.log(' Testing 7-bit mode (metallic)');
noise.setMode('7bit');
now = audioContext.currentTime;
noise.playKick(100, now);
noise.playSnare(90, now + 0.5);
noise.playKick(100, now + 1.0);
noise.playSnare(90, now + 1.5);
await sleep(2200);
// Test different frequencies
console.log(' Testing frequency range');
now = audioContext.currentTime;
for (let i = 0; i < 8; i++) {
noise.playNote(36 + i * 6, 0.2, 80, now + i * 0.25);
}
await sleep(2500);
console.log('Noise channel test complete!');
}
/**
* Play a simple test melody using all channels.
*/
export async function testCombined(
pulse: PulseChannel,
wave: WaveChannel,
noise: NoiseChannel,
audioContext: AudioContext
): Promise<void> {
console.log('Testing combined playback...');
const bpm = 120;
const beatDuration = 60 / bpm;
const now = audioContext.currentTime;
// Set up channels
pulse.setDutyCycle(2); // 50%
wave.loadPreset('bass');
noise.setMode('7bit');
// 4-bar phrase
for (let bar = 0; bar < 4; bar++) {
const barStart = now + bar * 4 * beatDuration;
// Bass line (wave channel) - root notes
const bassNotes = [36, 36, 43, 41]; // C, C, G, F
wave.playNote(bassNotes[bar], beatDuration * 3.5, 90, barStart);
// Melody (pulse channel)
const melodyNotes = [
[60, 64, 67], // Bar 1: C E G
[64, 67, 72], // Bar 2: E G C
[67, 71, 74], // Bar 3: G B D
[65, 69, 72], // Bar 4: F A C
];
melodyNotes[bar].forEach((note, i) => {
pulse.playNote(note, beatDuration * 0.9, 80, barStart + i * beatDuration);
});
// Drums (noise channel)
noise.playKick(100, barStart);
noise.playHihat(60, false, barStart + beatDuration * 0.5);
noise.playSnare(90, barStart + beatDuration);
noise.playHihat(60, false, barStart + beatDuration * 1.5);
noise.playKick(80, barStart + beatDuration * 2);
noise.playHihat(60, false, barStart + beatDuration * 2.5);
noise.playSnare(90, barStart + beatDuration * 3);
noise.playHihat(60, true, barStart + beatDuration * 3.5);
}
// Wait for playback to complete
await sleep(4 * 4 * beatDuration * 1000 + 500);
console.log('Combined test complete!');
}
/**
* Run all audio tests sequentially.
*/
export async function runAllAudioTests(audioContext: AudioContext): Promise<void> {
const { pulse, wave, noise } = createTestChannels(audioContext);
console.log('=== Wario Synth v2 Audio Tests ===\n');
await testDutyCycles(pulse, audioContext);
await sleep(500);
await testWaveChannel(wave, audioContext);
await sleep(500);
await testNoiseChannel(noise, audioContext);
await sleep(500);
await testCombined(pulse, wave, noise, audioContext);
console.log('\n=== All Audio Tests Complete ===');
}
/**
* Helper: sleep for a given duration.
*/
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}