Add v2 Game Boy sound engine (isolated from v1)
- Authentic DMG-CPU sound chip implementation: - 4 Pulse channels with duty cycle control (12.5%, 25%, 50%, 75%) - 2 Wave channels with 4-bit wavetables - 2 Noise channels with LFSR (7-bit and 15-bit modes) - GameBoy Colorizer effect chain: - Low-pass filter (natural GB rolloff) - Bit-crushing (4-bit DAC simulation) - Sample rate reduction - Saturation and high-pass filter - Presets: DMG, GBC, GBA, Clean - Intelligent MIDI processing: - Track analysis and role detection (bass, lead, drums, etc.) - Automatic channel mapping to GB channels - Chord arpeggiator for polyphony handling - GameBoy Arranger for fuller sound - BitMidi search integration - Completely isolated from v1 (no changes to src/)
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Game Boy APU (Audio Processing Unit) Coordinator
|
||||
*
|
||||
* This is the main audio engine for v2, coordinating 8 channels:
|
||||
* - 4 Pulse channels (p1-p4) with duty cycle control
|
||||
* - 2 Wave channels (w1-w2) with custom wavetables
|
||||
* - 2 Noise channels (n1-n2) with LFSR noise
|
||||
*
|
||||
* This "Super Game Boy" configuration allows handling complex MIDIs
|
||||
* while maintaining authentic GB sound character.
|
||||
*/
|
||||
|
||||
import { PulseChannel } from './PulseChannel';
|
||||
import { WaveChannel } from './WaveChannel';
|
||||
import { NoiseChannel } from './NoiseChannel';
|
||||
import { GameBoyColorizer, type ColorizerConfig } from '../effects/GameBoyColorizer';
|
||||
import {
|
||||
DEFAULT_V2_CONFIG,
|
||||
type ChannelId,
|
||||
type ChannelNote,
|
||||
type ChannelState,
|
||||
type PulseChannelId,
|
||||
type WaveChannelId,
|
||||
type NoiseChannelId,
|
||||
type V2Config,
|
||||
} from '../../types';
|
||||
import type { DutyIndex } from '../synthesis/DutyCycle';
|
||||
import type { WavePreset } from '../synthesis/WaveTable';
|
||||
import type { LFSRMode } from '../synthesis/LFSR';
|
||||
|
||||
/**
|
||||
* Channel configuration for the 8-channel setup
|
||||
*/
|
||||
const CHANNEL_CONFIG = {
|
||||
pulse: [
|
||||
{ id: 'p1' as const, hasSweep: true, defaultDuty: 2 as DutyIndex },
|
||||
{ id: 'p2' as const, hasSweep: true, defaultDuty: 2 as DutyIndex },
|
||||
{ id: 'p3' as const, hasSweep: false, defaultDuty: 1 as DutyIndex },
|
||||
{ id: 'p4' as const, hasSweep: false, defaultDuty: 1 as DutyIndex },
|
||||
],
|
||||
wave: [
|
||||
{ id: 'w1' as const, preset: 'bass' as WavePreset },
|
||||
{ id: 'w2' as const, preset: 'pad' as WavePreset },
|
||||
],
|
||||
noise: [
|
||||
{ id: 'n1' as const, mode: '7bit' as LFSRMode },
|
||||
{ id: 'n2' as const, mode: '15bit' as LFSRMode },
|
||||
],
|
||||
};
|
||||
|
||||
export class GameBoyAPU {
|
||||
private audioContext: AudioContext;
|
||||
private config: V2Config;
|
||||
|
||||
// Master output chain
|
||||
private masterGain: GainNode;
|
||||
private colorizer: GameBoyColorizer;
|
||||
|
||||
// Individual channel instances
|
||||
private pulseChannels: Map<PulseChannelId, PulseChannel> = new Map();
|
||||
private waveChannels: Map<WaveChannelId, WaveChannel> = new Map();
|
||||
private noiseChannels: Map<NoiseChannelId, NoiseChannel> = new Map();
|
||||
|
||||
// Per-channel gain nodes for mixing
|
||||
private channelGains: Map<ChannelId, GainNode> = new Map();
|
||||
|
||||
// Channel state tracking
|
||||
private channelStates: Map<ChannelId, ChannelState> = new Map();
|
||||
|
||||
// Note scheduling stats (no limit - Web Audio handles scheduling)
|
||||
private scheduledNoteCount = 0;
|
||||
|
||||
constructor(audioContext?: AudioContext, config?: Partial<V2Config>) {
|
||||
this.audioContext = audioContext || new AudioContext();
|
||||
this.config = { ...DEFAULT_V2_CONFIG, ...config };
|
||||
|
||||
// Create colorizer with DMG preset for authentic sound
|
||||
this.colorizer = new GameBoyColorizer(
|
||||
this.audioContext,
|
||||
GameBoyColorizer.createPreset('dmg')
|
||||
);
|
||||
|
||||
// Create master gain
|
||||
this.masterGain = this.audioContext.createGain();
|
||||
this.masterGain.gain.value = this.config.masterVolume;
|
||||
|
||||
// Wire: master -> colorizer -> destination
|
||||
this.masterGain.connect(this.colorizer.getInput());
|
||||
this.colorizer.getOutput().connect(this.audioContext.destination);
|
||||
|
||||
// Initialize all channels
|
||||
this.initializeChannels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all 8 channels with their gain nodes.
|
||||
*/
|
||||
private initializeChannels(): void {
|
||||
// Create pulse channels
|
||||
for (const config of CHANNEL_CONFIG.pulse) {
|
||||
const gain = this.createChannelGain(config.id, 0.25);
|
||||
const channel = new PulseChannel(this.audioContext, gain, config.hasSweep);
|
||||
channel.setDutyCycle(config.defaultDuty);
|
||||
this.pulseChannels.set(config.id, channel);
|
||||
this.initChannelState(config.id);
|
||||
}
|
||||
|
||||
// Create wave channels (higher gain for bass)
|
||||
for (const config of CHANNEL_CONFIG.wave) {
|
||||
const gain = this.createChannelGain(config.id, 0.55); // Boosted for bass
|
||||
const channel = new WaveChannel(this.audioContext, gain, config.preset);
|
||||
this.waveChannels.set(config.id, channel);
|
||||
this.initChannelState(config.id);
|
||||
}
|
||||
|
||||
// Create noise channels
|
||||
for (const config of CHANNEL_CONFIG.noise) {
|
||||
const gain = this.createChannelGain(config.id, 0.3);
|
||||
const channel = new NoiseChannel(this.audioContext, gain, config.mode);
|
||||
this.noiseChannels.set(config.id, channel);
|
||||
this.initChannelState(config.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a gain node for a channel and connect to master.
|
||||
*/
|
||||
private createChannelGain(id: ChannelId, defaultGain: number): GainNode {
|
||||
const gain = this.audioContext.createGain();
|
||||
gain.gain.value = defaultGain;
|
||||
gain.connect(this.masterGain);
|
||||
this.channelGains.set(id, gain);
|
||||
return gain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize channel state tracking.
|
||||
*/
|
||||
private initChannelState(id: ChannelId): void {
|
||||
this.channelStates.set(id, {
|
||||
id,
|
||||
isBusy: false,
|
||||
busyUntil: 0,
|
||||
currentGain: this.channelGains.get(id)?.gain.value || 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the AudioContext.
|
||||
*/
|
||||
getAudioContext(): AudioContext {
|
||||
return this.audioContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume audio context if suspended.
|
||||
*/
|
||||
async resume(): Promise<void> {
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
await this.audioContext.resume();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a note on a specific channel.
|
||||
*
|
||||
* Web Audio handles scheduling of future notes efficiently, so we don't
|
||||
* limit the number of scheduled notes. The browser will automatically
|
||||
* manage memory for nodes that have finished playing.
|
||||
*/
|
||||
scheduleNote(note: ChannelNote): void {
|
||||
const { channel, midiNote, startTime, duration, velocity } = note;
|
||||
|
||||
if (channel.startsWith('p')) {
|
||||
this.schedulePulseNote(channel as PulseChannelId, midiNote, duration, velocity, startTime);
|
||||
} else if (channel.startsWith('w')) {
|
||||
this.scheduleWaveNote(channel as WaveChannelId, midiNote, duration, velocity, startTime);
|
||||
} else if (channel.startsWith('n')) {
|
||||
this.scheduleNoiseNote(channel as NoiseChannelId, midiNote, duration, velocity, startTime);
|
||||
}
|
||||
|
||||
// Update channel state
|
||||
this.updateChannelBusy(channel, startTime + duration);
|
||||
this.scheduledNoteCount++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a pulse channel note.
|
||||
*/
|
||||
private schedulePulseNote(
|
||||
channelId: PulseChannelId,
|
||||
midiNote: number,
|
||||
duration: number,
|
||||
velocity: number,
|
||||
startTime: number
|
||||
): void {
|
||||
const channel = this.pulseChannels.get(channelId);
|
||||
if (!channel) return;
|
||||
|
||||
channel.playNote(midiNote, duration, velocity, startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a wave channel note.
|
||||
*/
|
||||
private scheduleWaveNote(
|
||||
channelId: WaveChannelId,
|
||||
midiNote: number,
|
||||
duration: number,
|
||||
velocity: number,
|
||||
startTime: number
|
||||
): void {
|
||||
const channel = this.waveChannels.get(channelId);
|
||||
if (!channel) return;
|
||||
|
||||
channel.playNote(midiNote, duration, velocity, startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a noise channel note.
|
||||
*/
|
||||
private scheduleNoiseNote(
|
||||
channelId: NoiseChannelId,
|
||||
midiNote: number,
|
||||
duration: number,
|
||||
velocity: number,
|
||||
startTime: number
|
||||
): void {
|
||||
const channel = this.noiseChannels.get(channelId);
|
||||
if (!channel) return;
|
||||
|
||||
channel.playNote(midiNote, duration, velocity, startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update channel busy state.
|
||||
*/
|
||||
private updateChannelBusy(channelId: ChannelId, busyUntil: number): void {
|
||||
const state = this.channelStates.get(channelId);
|
||||
if (state) {
|
||||
state.isBusy = true;
|
||||
state.busyUntil = Math.max(state.busyUntil, busyUntil);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a channel is free at a given time.
|
||||
*/
|
||||
isChannelFree(channelId: ChannelId, atTime?: number): boolean {
|
||||
const time = atTime ?? this.audioContext.currentTime;
|
||||
const state = this.channelStates.get(channelId);
|
||||
if (!state) return false;
|
||||
return time >= state.busyUntil;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a free pulse channel.
|
||||
*/
|
||||
findFreePulseChannel(atTime?: number): PulseChannelId | null {
|
||||
const time = atTime ?? this.audioContext.currentTime;
|
||||
for (const id of ['p1', 'p2', 'p3', 'p4'] as PulseChannelId[]) {
|
||||
if (this.isChannelFree(id, time)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a free wave channel.
|
||||
*/
|
||||
findFreeWaveChannel(atTime?: number): WaveChannelId | null {
|
||||
const time = atTime ?? this.audioContext.currentTime;
|
||||
for (const id of ['w1', 'w2'] as WaveChannelId[]) {
|
||||
if (this.isChannelFree(id, time)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a free noise channel.
|
||||
*/
|
||||
findFreeNoiseChannel(atTime?: number): NoiseChannelId | null {
|
||||
const time = atTime ?? this.audioContext.currentTime;
|
||||
for (const id of ['n1', 'n2'] as NoiseChannelId[]) {
|
||||
if (this.isChannelFree(id, time)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set duty cycle for a pulse channel.
|
||||
*/
|
||||
setPulseDuty(channelId: PulseChannelId, duty: DutyIndex): void {
|
||||
const channel = this.pulseChannels.get(channelId);
|
||||
if (channel) {
|
||||
channel.setDutyCycle(duty);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set preset for a wave channel.
|
||||
*/
|
||||
setWavePreset(channelId: WaveChannelId, preset: WavePreset): void {
|
||||
const channel = this.waveChannels.get(channelId);
|
||||
if (channel) {
|
||||
channel.loadPreset(preset);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mode for a noise channel.
|
||||
*/
|
||||
setNoiseMode(channelId: NoiseChannelId, mode: LFSRMode): void {
|
||||
const channel = this.noiseChannels.get(channelId);
|
||||
if (channel) {
|
||||
channel.setMode(mode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set individual channel volume.
|
||||
*/
|
||||
setChannelVolume(channelId: ChannelId, volume: number): void {
|
||||
const gain = this.channelGains.get(channelId);
|
||||
if (gain) {
|
||||
gain.gain.value = Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set master volume.
|
||||
*/
|
||||
setMasterVolume(volume: number): void {
|
||||
this.masterGain.gain.value = Math.max(0, Math.min(1, volume));
|
||||
this.config.masterVolume = volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get master volume.
|
||||
*/
|
||||
getMasterVolume(): number {
|
||||
return this.config.masterVolume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a pulse channel instance.
|
||||
*/
|
||||
getPulseChannel(id: PulseChannelId): PulseChannel | undefined {
|
||||
return this.pulseChannels.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a wave channel instance.
|
||||
*/
|
||||
getWaveChannel(id: WaveChannelId): WaveChannel | undefined {
|
||||
return this.waveChannels.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a noise channel instance.
|
||||
*/
|
||||
getNoiseChannel(id: NoiseChannelId): NoiseChannel | undefined {
|
||||
return this.noiseChannels.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all channel states.
|
||||
*/
|
||||
getChannelStates(): Map<ChannelId, ChannelState> {
|
||||
return new Map(this.channelStates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current time from audio context.
|
||||
*/
|
||||
getCurrentTime(): number {
|
||||
return this.audioContext.currentTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all channel states.
|
||||
*/
|
||||
reset(): void {
|
||||
for (const id of this.channelStates.keys()) {
|
||||
this.initChannelState(id);
|
||||
}
|
||||
this.scheduledNoteCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduled note count.
|
||||
*/
|
||||
getScheduledNoteCount(): number {
|
||||
return this.scheduledNoteCount;
|
||||
}
|
||||
|
||||
// ===== COLORIZER CONTROLS =====
|
||||
|
||||
/**
|
||||
* Get the colorizer instance.
|
||||
*/
|
||||
getColorizer(): GameBoyColorizer {
|
||||
return this.colorizer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set colorizer preset.
|
||||
*/
|
||||
setColorizerPreset(preset: 'dmg' | 'gbc' | 'gba' | 'clean'): void {
|
||||
this.colorizer.setConfig(GameBoyColorizer.createPreset(preset));
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable the colorizer.
|
||||
*/
|
||||
setColorizerEnabled(enabled: boolean): void {
|
||||
this.colorizer.setEnabled(enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize bit crusher (call after user interaction).
|
||||
*/
|
||||
initializeBitCrusher(): void {
|
||||
this.colorizer.initializeBitCrusher();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export default config for convenience
|
||||
export { DEFAULT_V2_CONFIG } from '../../types';
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Game Boy Noise Channel
|
||||
*
|
||||
* Implements the noise channel with:
|
||||
* - LFSR-based pseudo-random noise generation
|
||||
* - 7-bit mode (tonal, metallic) and 15-bit mode (fuller noise)
|
||||
* - GB-accurate frequency calculation
|
||||
* - Envelope control
|
||||
*/
|
||||
|
||||
import { generateNoiseBuffer, type LFSRMode } from '../synthesis/LFSR';
|
||||
import { calculateNoiseFrequency, midiToNoiseParams } from '../synthesis/FrequencyCalc';
|
||||
|
||||
export interface NoiseNoteResult {
|
||||
source: AudioBufferSourceNode;
|
||||
gainNode: GainNode;
|
||||
stopTime: number;
|
||||
}
|
||||
|
||||
// Cache for noise buffers to avoid regenerating
|
||||
interface NoiseBufferCache {
|
||||
buffer: AudioBuffer;
|
||||
frequency: number;
|
||||
mode: LFSRMode;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export class NoiseChannel {
|
||||
private audioContext: AudioContext;
|
||||
private mode: LFSRMode;
|
||||
private outputNode: GainNode;
|
||||
|
||||
// Cache recently used noise buffers
|
||||
private bufferCache: NoiseBufferCache[] = [];
|
||||
private maxCacheSize = 8;
|
||||
|
||||
constructor(
|
||||
audioContext: AudioContext,
|
||||
outputNode: GainNode,
|
||||
mode: LFSRMode = '15bit'
|
||||
) {
|
||||
this.audioContext = audioContext;
|
||||
this.outputNode = outputNode;
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the LFSR mode.
|
||||
* '7bit' = more tonal, metallic sound (good for snares)
|
||||
* '15bit' = fuller noise (good for hihats, white noise effects)
|
||||
*/
|
||||
setMode(mode: LFSRMode): void {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current mode.
|
||||
*/
|
||||
getMode(): LFSRMode {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a noise buffer with the given parameters.
|
||||
*/
|
||||
private getNoiseBuffer(
|
||||
frequency: number,
|
||||
duration: number,
|
||||
mode: LFSRMode
|
||||
): AudioBuffer {
|
||||
// Check cache first
|
||||
const cached = this.bufferCache.find(
|
||||
c => c.frequency === frequency &&
|
||||
c.mode === mode &&
|
||||
c.duration >= duration
|
||||
);
|
||||
|
||||
if (cached) {
|
||||
return cached.buffer;
|
||||
}
|
||||
|
||||
// Generate new buffer
|
||||
const buffer = generateNoiseBuffer(
|
||||
this.audioContext,
|
||||
duration + 0.1, // Extra time for envelope tail
|
||||
frequency,
|
||||
mode
|
||||
);
|
||||
|
||||
// Add to cache
|
||||
this.bufferCache.push({ buffer, frequency, mode, duration });
|
||||
|
||||
// Trim cache if too large
|
||||
while (this.bufferCache.length > this.maxCacheSize) {
|
||||
this.bufferCache.shift();
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play noise with raw frequency control.
|
||||
*
|
||||
* @param duration - Duration in seconds
|
||||
* @param frequency - LFSR clock frequency in Hz
|
||||
* @param velocity - Velocity (0-127)
|
||||
* @param startTime - When to start
|
||||
*/
|
||||
playNoise(
|
||||
duration: number,
|
||||
frequency: number,
|
||||
velocity: number = 100,
|
||||
startTime?: number
|
||||
): NoiseNoteResult {
|
||||
const now = startTime ?? this.audioContext.currentTime;
|
||||
|
||||
// Get or generate noise buffer
|
||||
const buffer = this.getNoiseBuffer(frequency, duration, this.mode);
|
||||
|
||||
// Create source
|
||||
const source = this.audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.loop = false;
|
||||
|
||||
// Create gain for envelope
|
||||
const gain = this.audioContext.createGain();
|
||||
|
||||
// Calculate gain from velocity
|
||||
const maxGain = (velocity / 127) * 0.7; // Noise is loud, keep headroom
|
||||
|
||||
// Noise envelope: instant attack, decay to sustain, release
|
||||
const attackTime = 0.001; // Nearly instant
|
||||
const decayTime = 0.05; // Quick decay
|
||||
const sustainLevel = maxGain * 0.6;
|
||||
const releaseTime = 0.03;
|
||||
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(maxGain, now + attackTime);
|
||||
gain.gain.linearRampToValueAtTime(sustainLevel, now + attackTime + decayTime);
|
||||
|
||||
const releaseStart = now + Math.max(attackTime + decayTime, duration - releaseTime);
|
||||
gain.gain.setValueAtTime(sustainLevel, releaseStart);
|
||||
|
||||
const stopTime = now + duration + releaseTime;
|
||||
gain.gain.linearRampToValueAtTime(0.001, stopTime);
|
||||
|
||||
// Connect
|
||||
source.connect(gain);
|
||||
gain.connect(this.outputNode);
|
||||
|
||||
// Play
|
||||
source.start(now);
|
||||
source.stop(stopTime + 0.01);
|
||||
|
||||
// Auto-cleanup
|
||||
source.onended = () => {
|
||||
try {
|
||||
source.disconnect();
|
||||
gain.disconnect();
|
||||
} catch {
|
||||
// Already disconnected
|
||||
}
|
||||
};
|
||||
|
||||
return { source, gainNode: gain, stopTime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Play noise mapped from a MIDI note.
|
||||
* Lower notes = lower frequency noise (boomy)
|
||||
* Higher notes = higher frequency noise (hissy)
|
||||
*
|
||||
* @param midiNote - MIDI note (affects noise frequency)
|
||||
* @param duration - Duration in seconds
|
||||
* @param velocity - Velocity (0-127)
|
||||
* @param startTime - When to start
|
||||
*/
|
||||
playNote(
|
||||
midiNote: number,
|
||||
duration: number,
|
||||
velocity: number = 100,
|
||||
startTime?: number
|
||||
): NoiseNoteResult {
|
||||
const { divisorCode, clockShift } = midiToNoiseParams(midiNote);
|
||||
const frequency = calculateNoiseFrequency(divisorCode, clockShift);
|
||||
|
||||
return this.playNoise(duration, frequency, velocity, startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a kick drum sound.
|
||||
* Short, low-frequency noise burst.
|
||||
*/
|
||||
playKick(velocity: number = 100, startTime?: number): NoiseNoteResult {
|
||||
const now = startTime ?? this.audioContext.currentTime;
|
||||
|
||||
// Low frequency, short duration, 7-bit for more punch
|
||||
const buffer = this.getNoiseBuffer(500, 0.15, '7bit');
|
||||
|
||||
const source = this.audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
|
||||
const gain = this.audioContext.createGain();
|
||||
const maxGain = (velocity / 127) * 0.9;
|
||||
|
||||
// Kick envelope: instant attack, fast decay
|
||||
gain.gain.setValueAtTime(maxGain, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.1);
|
||||
|
||||
source.connect(gain);
|
||||
gain.connect(this.outputNode);
|
||||
|
||||
source.start(now);
|
||||
source.stop(now + 0.15);
|
||||
|
||||
source.onended = () => {
|
||||
try {
|
||||
source.disconnect();
|
||||
gain.disconnect();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return { source, gainNode: gain, stopTime: now + 0.15 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a snare drum sound.
|
||||
* Mid-frequency noise with some sustain.
|
||||
*/
|
||||
playSnare(velocity: number = 100, startTime?: number): NoiseNoteResult {
|
||||
const now = startTime ?? this.audioContext.currentTime;
|
||||
|
||||
// Mid frequency, 7-bit for metallic character
|
||||
const buffer = this.getNoiseBuffer(2000, 0.2, '7bit');
|
||||
|
||||
const source = this.audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
|
||||
const gain = this.audioContext.createGain();
|
||||
const maxGain = (velocity / 127) * 0.8;
|
||||
|
||||
// Snare envelope: fast attack, medium decay
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(maxGain, now + 0.005);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15);
|
||||
|
||||
source.connect(gain);
|
||||
gain.connect(this.outputNode);
|
||||
|
||||
source.start(now);
|
||||
source.stop(now + 0.2);
|
||||
|
||||
source.onended = () => {
|
||||
try {
|
||||
source.disconnect();
|
||||
gain.disconnect();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return { source, gainNode: gain, stopTime: now + 0.2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a hihat sound.
|
||||
* High-frequency noise, very short.
|
||||
*/
|
||||
playHihat(
|
||||
velocity: number = 100,
|
||||
open: boolean = false,
|
||||
startTime?: number
|
||||
): NoiseNoteResult {
|
||||
const now = startTime ?? this.audioContext.currentTime;
|
||||
|
||||
// High frequency, 15-bit for fuller sound
|
||||
const duration = open ? 0.3 : 0.08;
|
||||
const buffer = this.getNoiseBuffer(8000, duration, '15bit');
|
||||
|
||||
const source = this.audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
|
||||
const gain = this.audioContext.createGain();
|
||||
const maxGain = (velocity / 127) * 0.5; // Hihats are quieter
|
||||
|
||||
// Hihat envelope: instant attack, quick decay
|
||||
gain.gain.setValueAtTime(maxGain, now);
|
||||
|
||||
if (open) {
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.25);
|
||||
} else {
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.05);
|
||||
}
|
||||
|
||||
source.connect(gain);
|
||||
gain.connect(this.outputNode);
|
||||
|
||||
source.start(now);
|
||||
source.stop(now + duration);
|
||||
|
||||
source.onended = () => {
|
||||
try {
|
||||
source.disconnect();
|
||||
gain.disconnect();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return { source, gainNode: gain, stopTime: now + duration };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the buffer cache.
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.bufferCache = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Game Boy Pulse Channel
|
||||
*
|
||||
* Implements a single pulse channel with:
|
||||
* - 4 selectable duty cycles
|
||||
* - GB-accurate frequency calculation
|
||||
* - Simple envelope (fast attack, configurable release)
|
||||
* - Optional sweep capability (for p1/p2)
|
||||
*/
|
||||
|
||||
import { createAllDutyWaves, type DutyIndex } from '../synthesis/DutyCycle';
|
||||
import { calculatePulseFrequency } from '../synthesis/FrequencyCalc';
|
||||
|
||||
export interface PulseNoteResult {
|
||||
oscillator: OscillatorNode;
|
||||
gainNode: GainNode;
|
||||
stopTime: number;
|
||||
}
|
||||
|
||||
export class PulseChannel {
|
||||
private audioContext: AudioContext;
|
||||
private dutyWaves: PeriodicWave[];
|
||||
private currentDuty: DutyIndex = 2; // Default to 50%
|
||||
private hasSweep: boolean;
|
||||
private outputNode: GainNode;
|
||||
|
||||
constructor(
|
||||
audioContext: AudioContext,
|
||||
outputNode: GainNode,
|
||||
hasSweep: boolean = false
|
||||
) {
|
||||
this.audioContext = audioContext;
|
||||
this.outputNode = outputNode;
|
||||
this.hasSweep = hasSweep;
|
||||
|
||||
// Pre-create all duty cycle waveforms
|
||||
this.dutyWaves = createAllDutyWaves(audioContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the duty cycle for subsequent notes.
|
||||
*/
|
||||
setDutyCycle(duty: DutyIndex): void {
|
||||
this.currentDuty = duty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current duty cycle.
|
||||
*/
|
||||
getDutyCycle(): DutyIndex {
|
||||
return this.currentDuty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a note on this channel.
|
||||
*
|
||||
* @param midiNote - MIDI note number (0-127)
|
||||
* @param duration - Note duration in seconds
|
||||
* @param velocity - Note velocity (0-127)
|
||||
* @param startTime - When to start (audioContext.currentTime based)
|
||||
* @returns Objects for manual cleanup if needed
|
||||
*/
|
||||
playNote(
|
||||
midiNote: number,
|
||||
duration: number,
|
||||
velocity: number = 100,
|
||||
startTime?: number
|
||||
): PulseNoteResult {
|
||||
const now = startTime ?? this.audioContext.currentTime;
|
||||
|
||||
// Create oscillator with current duty cycle
|
||||
const osc = this.audioContext.createOscillator();
|
||||
osc.setPeriodicWave(this.dutyWaves[this.currentDuty]);
|
||||
|
||||
// Use GB frequency formula (slightly detuned from standard)
|
||||
const frequency = calculatePulseFrequency(midiNote);
|
||||
osc.frequency.setValueAtTime(frequency, now);
|
||||
|
||||
// Create gain node for envelope
|
||||
const gain = this.audioContext.createGain();
|
||||
|
||||
// Calculate gain from velocity (0-127 → 0-1)
|
||||
const maxGain = (velocity / 127) * 0.8; // Leave headroom
|
||||
|
||||
// GB-style envelope: fast attack, sustain, quick release
|
||||
const attackTime = 0.005; // 5ms attack
|
||||
const releaseTime = 0.02; // 20ms release
|
||||
|
||||
// Envelope automation
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(maxGain, now + attackTime);
|
||||
|
||||
// Hold at max until release
|
||||
const releaseStart = now + Math.max(attackTime, duration - releaseTime);
|
||||
gain.gain.setValueAtTime(maxGain, releaseStart);
|
||||
|
||||
// Release to near-zero (avoid exponentialRamp to 0)
|
||||
const stopTime = now + duration + releaseTime;
|
||||
gain.gain.linearRampToValueAtTime(0.001, stopTime);
|
||||
|
||||
// Connect nodes
|
||||
osc.connect(gain);
|
||||
gain.connect(this.outputNode);
|
||||
|
||||
// Schedule playback
|
||||
osc.start(now);
|
||||
osc.stop(stopTime + 0.01); // Small buffer after release
|
||||
|
||||
// Auto-cleanup when oscillator ends
|
||||
osc.onended = () => {
|
||||
try {
|
||||
osc.disconnect();
|
||||
gain.disconnect();
|
||||
} catch {
|
||||
// Already disconnected
|
||||
}
|
||||
};
|
||||
|
||||
return { oscillator: osc, gainNode: gain, stopTime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a note with a specific duty cycle (doesn't change default).
|
||||
*/
|
||||
playNoteWithDuty(
|
||||
midiNote: number,
|
||||
duration: number,
|
||||
velocity: number,
|
||||
duty: DutyIndex,
|
||||
startTime?: number
|
||||
): PulseNoteResult {
|
||||
const now = startTime ?? this.audioContext.currentTime;
|
||||
|
||||
const osc = this.audioContext.createOscillator();
|
||||
osc.setPeriodicWave(this.dutyWaves[duty]);
|
||||
|
||||
const frequency = calculatePulseFrequency(midiNote);
|
||||
osc.frequency.setValueAtTime(frequency, now);
|
||||
|
||||
const gain = this.audioContext.createGain();
|
||||
const maxGain = (velocity / 127) * 0.8;
|
||||
|
||||
const attackTime = 0.005;
|
||||
const releaseTime = 0.02;
|
||||
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(maxGain, now + attackTime);
|
||||
|
||||
const releaseStart = now + Math.max(attackTime, duration - releaseTime);
|
||||
gain.gain.setValueAtTime(maxGain, releaseStart);
|
||||
|
||||
const stopTime = now + duration + releaseTime;
|
||||
gain.gain.linearRampToValueAtTime(0.001, stopTime);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.outputNode);
|
||||
|
||||
osc.start(now);
|
||||
osc.stop(stopTime + 0.01);
|
||||
|
||||
osc.onended = () => {
|
||||
try {
|
||||
osc.disconnect();
|
||||
gain.disconnect();
|
||||
} catch {
|
||||
// Already disconnected
|
||||
}
|
||||
};
|
||||
|
||||
return { oscillator: osc, gainNode: gain, stopTime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this channel has sweep capability.
|
||||
*/
|
||||
canSweep(): boolean {
|
||||
return this.hasSweep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a note with pitch sweep (if sweep enabled).
|
||||
* Sweep goes from startNote to endNote over the duration.
|
||||
*/
|
||||
playNoteWithSweep(
|
||||
startNote: number,
|
||||
endNote: number,
|
||||
duration: number,
|
||||
velocity: number = 100,
|
||||
startTime?: number
|
||||
): PulseNoteResult | null {
|
||||
if (!this.hasSweep) {
|
||||
console.warn('PulseChannel: Sweep not available on this channel');
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = startTime ?? this.audioContext.currentTime;
|
||||
|
||||
const osc = this.audioContext.createOscillator();
|
||||
osc.setPeriodicWave(this.dutyWaves[this.currentDuty]);
|
||||
|
||||
const startFreq = calculatePulseFrequency(startNote);
|
||||
const endFreq = calculatePulseFrequency(endNote);
|
||||
|
||||
osc.frequency.setValueAtTime(startFreq, now);
|
||||
osc.frequency.linearRampToValueAtTime(endFreq, now + duration);
|
||||
|
||||
const gain = this.audioContext.createGain();
|
||||
const maxGain = (velocity / 127) * 0.8;
|
||||
|
||||
const attackTime = 0.005;
|
||||
const releaseTime = 0.02;
|
||||
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(maxGain, now + attackTime);
|
||||
|
||||
const releaseStart = now + Math.max(attackTime, duration - releaseTime);
|
||||
gain.gain.setValueAtTime(maxGain, releaseStart);
|
||||
|
||||
const stopTime = now + duration + releaseTime;
|
||||
gain.gain.linearRampToValueAtTime(0.001, stopTime);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.outputNode);
|
||||
|
||||
osc.start(now);
|
||||
osc.stop(stopTime + 0.01);
|
||||
|
||||
osc.onended = () => {
|
||||
try {
|
||||
osc.disconnect();
|
||||
gain.disconnect();
|
||||
} catch {
|
||||
// Already disconnected
|
||||
}
|
||||
};
|
||||
|
||||
return { oscillator: osc, gainNode: gain, stopTime };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Game Boy Wave Channel
|
||||
*
|
||||
* Implements the wave channel with:
|
||||
* - 32-sample × 4-bit wavetable
|
||||
* - GB-accurate frequency calculation
|
||||
* - 4-level volume (mute, 100%, 50%, 25%)
|
||||
* - Preset waveforms (bass, pad, lead, etc.)
|
||||
*
|
||||
* Uses OscillatorNode with PeriodicWave for accurate pitch control
|
||||
* (AudioBufferSourceNode playbackRate has issues at low frequencies).
|
||||
*/
|
||||
|
||||
import {
|
||||
WaveTable,
|
||||
WAVE_PRESETS,
|
||||
VOLUME_MULTIPLIERS,
|
||||
createPeriodicWaveFromTable,
|
||||
type WavePreset,
|
||||
type WaveVolume
|
||||
} from '../synthesis/WaveTable';
|
||||
import { calculateWaveFrequency } from '../synthesis/FrequencyCalc';
|
||||
|
||||
export interface WaveNoteResult {
|
||||
oscillator: OscillatorNode;
|
||||
gainNode: GainNode;
|
||||
stopTime: number;
|
||||
}
|
||||
|
||||
export class WaveChannel {
|
||||
private audioContext: AudioContext;
|
||||
private waveTable: WaveTable;
|
||||
private periodicWave: PeriodicWave | null = null;
|
||||
private volume: WaveVolume = 1; // Default to 100%
|
||||
private outputNode: GainNode;
|
||||
private currentPreset: WavePreset;
|
||||
|
||||
constructor(
|
||||
audioContext: AudioContext,
|
||||
outputNode: GainNode,
|
||||
preset: WavePreset = 'bass'
|
||||
) {
|
||||
this.audioContext = audioContext;
|
||||
this.outputNode = outputNode;
|
||||
this.currentPreset = preset;
|
||||
|
||||
// Initialize wavetable with preset
|
||||
this.waveTable = new WaveTable();
|
||||
this.loadPreset(preset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a preset waveform.
|
||||
*/
|
||||
loadPreset(preset: WavePreset): void {
|
||||
this.currentPreset = preset;
|
||||
const waveform = WAVE_PRESETS[preset]();
|
||||
this.waveTable.loadFromBytes(Array.from(waveform));
|
||||
|
||||
// Create PeriodicWave from the wavetable
|
||||
this.periodicWave = createPeriodicWaveFromTable(
|
||||
this.waveTable.getSamples(),
|
||||
this.audioContext
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load custom waveform data (32 samples, 0-15 each).
|
||||
*/
|
||||
loadCustomWaveform(samples: number[]): void {
|
||||
this.waveTable.loadFromBytes(samples);
|
||||
this.periodicWave = createPeriodicWaveFromTable(
|
||||
this.waveTable.getSamples(),
|
||||
this.audioContext
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume level (GB style: 0=mute, 1=100%, 2=50%, 3=25%).
|
||||
*/
|
||||
setVolume(volume: WaveVolume): void {
|
||||
this.volume = volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current volume level.
|
||||
*/
|
||||
getVolume(): WaveVolume {
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current preset name.
|
||||
*/
|
||||
getPreset(): WavePreset {
|
||||
return this.currentPreset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a note on this channel.
|
||||
*
|
||||
* @param midiNote - MIDI note number
|
||||
* @param duration - Note duration in seconds
|
||||
* @param velocity - Note velocity (0-127)
|
||||
* @param startTime - When to start (audioContext.currentTime based)
|
||||
*/
|
||||
playNote(
|
||||
midiNote: number,
|
||||
duration: number,
|
||||
velocity: number = 100,
|
||||
startTime?: number
|
||||
): WaveNoteResult {
|
||||
const now = startTime ?? this.audioContext.currentTime;
|
||||
|
||||
// Ensure we have a PeriodicWave
|
||||
if (!this.periodicWave) {
|
||||
this.periodicWave = createPeriodicWaveFromTable(
|
||||
this.waveTable.getSamples(),
|
||||
this.audioContext
|
||||
);
|
||||
}
|
||||
|
||||
// Create oscillator with the wavetable's PeriodicWave
|
||||
const oscillator = this.audioContext.createOscillator();
|
||||
oscillator.setPeriodicWave(this.periodicWave);
|
||||
|
||||
// Calculate GB frequency and set directly (no playback rate needed!)
|
||||
const frequency = calculateWaveFrequency(midiNote);
|
||||
oscillator.frequency.setValueAtTime(frequency, now);
|
||||
|
||||
// Create gain node for volume control
|
||||
const gain = this.audioContext.createGain();
|
||||
|
||||
// Calculate final gain from velocity and GB volume level
|
||||
const velocityGain = (velocity / 127) * 0.8;
|
||||
const volumeMultiplier = VOLUME_MULTIPLIERS[this.volume];
|
||||
const finalGain = velocityGain * volumeMultiplier;
|
||||
|
||||
// Simple envelope for wave channel
|
||||
const attackTime = 0.002; // Very fast attack
|
||||
const releaseTime = 0.01; // Quick release
|
||||
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(finalGain, now + attackTime);
|
||||
|
||||
const releaseStart = now + Math.max(attackTime, duration - releaseTime);
|
||||
gain.gain.setValueAtTime(finalGain, releaseStart);
|
||||
|
||||
const stopTime = now + duration + releaseTime;
|
||||
gain.gain.linearRampToValueAtTime(0.001, stopTime);
|
||||
|
||||
// Connect nodes
|
||||
oscillator.connect(gain);
|
||||
gain.connect(this.outputNode);
|
||||
|
||||
// Schedule playback
|
||||
oscillator.start(now);
|
||||
oscillator.stop(stopTime + 0.01);
|
||||
|
||||
// Auto-cleanup
|
||||
oscillator.onended = () => {
|
||||
try {
|
||||
oscillator.disconnect();
|
||||
gain.disconnect();
|
||||
} catch {
|
||||
// Already disconnected
|
||||
}
|
||||
};
|
||||
|
||||
return { oscillator, gainNode: gain, stopTime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a note with a specific preset (doesn't change default).
|
||||
*/
|
||||
playNoteWithPreset(
|
||||
midiNote: number,
|
||||
duration: number,
|
||||
velocity: number,
|
||||
preset: WavePreset,
|
||||
startTime?: number
|
||||
): WaveNoteResult {
|
||||
const now = startTime ?? this.audioContext.currentTime;
|
||||
|
||||
// Create temporary PeriodicWave for this preset
|
||||
const waveform = WAVE_PRESETS[preset]();
|
||||
const tempWave = createPeriodicWaveFromTable(waveform, this.audioContext);
|
||||
|
||||
// Create oscillator with the preset's PeriodicWave
|
||||
const oscillator = this.audioContext.createOscillator();
|
||||
oscillator.setPeriodicWave(tempWave);
|
||||
|
||||
const frequency = calculateWaveFrequency(midiNote);
|
||||
oscillator.frequency.setValueAtTime(frequency, now);
|
||||
|
||||
const gain = this.audioContext.createGain();
|
||||
const velocityGain = (velocity / 127) * 0.8;
|
||||
const volumeMultiplier = VOLUME_MULTIPLIERS[this.volume];
|
||||
const finalGain = velocityGain * volumeMultiplier;
|
||||
|
||||
const attackTime = 0.002;
|
||||
const releaseTime = 0.01;
|
||||
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(finalGain, now + attackTime);
|
||||
|
||||
const releaseStart = now + Math.max(attackTime, duration - releaseTime);
|
||||
gain.gain.setValueAtTime(finalGain, releaseStart);
|
||||
|
||||
const stopTime = now + duration + releaseTime;
|
||||
gain.gain.linearRampToValueAtTime(0.001, stopTime);
|
||||
|
||||
oscillator.connect(gain);
|
||||
gain.connect(this.outputNode);
|
||||
|
||||
oscillator.start(now);
|
||||
oscillator.stop(stopTime + 0.01);
|
||||
|
||||
oscillator.onended = () => {
|
||||
try {
|
||||
oscillator.disconnect();
|
||||
gain.disconnect();
|
||||
} catch {
|
||||
// Already disconnected
|
||||
}
|
||||
};
|
||||
|
||||
return { oscillator, gainNode: gain, stopTime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw wavetable samples for visualization.
|
||||
*/
|
||||
getWaveformSamples(): Uint8Array {
|
||||
return this.waveTable.getSamples();
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user