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,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
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user