This commit is contained in:
2025-10-10 23:13:57 +02:00
commit 58d1424adb
25 changed files with 1958 additions and 0 deletions

View File

@ -0,0 +1,10 @@
// Synthesis engines generate audio buffers with given parameters
// The duration parameter should be used to scale time-based parameters (envelopes, LFOs, etc.)
// Time-based parameters should be stored as ratios (0-1) and scaled by duration during generation
// Engines must generate stereo output: [leftChannel, rightChannel]
export interface SynthEngine<T = any> {
name: string;
generate(params: T, sampleRate: number, duration: number): [Float32Array, Float32Array];
randomParams(): T;
mutateParams(params: T, mutationAmount?: number): T;
}

View File

@ -0,0 +1,123 @@
import type { SynthEngine } from './SynthEngine';
export interface TwoOpFMParams {
carrierFreq: number;
modRatio: number;
modIndex: number;
attack: number; // 0-1, ratio of total duration
decay: number; // 0-1, ratio of total duration
sustain: number; // 0-1, amplitude level
release: number; // 0-1, ratio of total duration
vibratoRate: number; // Hz
vibratoDepth: number; // 0-1, pitch modulation depth
stereoWidth: number; // 0-1, amount of stereo separation
}
export class TwoOpFM implements SynthEngine<TwoOpFMParams> {
name = '2-OP FM';
generate(params: TwoOpFMParams, sampleRate: number, duration: number): [Float32Array, Float32Array] {
const numSamples = Math.floor(sampleRate * duration);
const leftBuffer = new Float32Array(numSamples);
const rightBuffer = new Float32Array(numSamples);
const TAU = Math.PI * 2;
const detune = 1 + (params.stereoWidth * 0.002);
const leftFreq = params.carrierFreq / detune;
const rightFreq = params.carrierFreq * detune;
const modulatorFreq = params.carrierFreq * params.modRatio;
let carrierPhaseL = 0;
let carrierPhaseR = Math.PI * params.stereoWidth * 0.1;
let modulatorPhaseL = 0;
let modulatorPhaseR = 0;
let vibratoPhaseL = 0;
let vibratoPhaseR = Math.PI * params.stereoWidth * 0.3;
for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate;
const envelope = this.calculateEnvelope(t, duration, params);
const vibratoL = Math.sin(vibratoPhaseL) * params.vibratoDepth;
const vibratoR = Math.sin(vibratoPhaseR) * params.vibratoDepth;
const carrierFreqL = leftFreq * (1 + vibratoL);
const carrierFreqR = rightFreq * (1 + vibratoR);
const modulatorL = Math.sin(modulatorPhaseL);
const modulatorR = Math.sin(modulatorPhaseR);
const carrierL = Math.sin(carrierPhaseL + params.modIndex * modulatorL);
const carrierR = Math.sin(carrierPhaseR + params.modIndex * modulatorR);
leftBuffer[i] = carrierL * envelope;
rightBuffer[i] = carrierR * envelope;
carrierPhaseL += (TAU * carrierFreqL) / sampleRate;
carrierPhaseR += (TAU * carrierFreqR) / sampleRate;
modulatorPhaseL += (TAU * modulatorFreq) / sampleRate;
modulatorPhaseR += (TAU * modulatorFreq) / sampleRate;
vibratoPhaseL += (TAU * params.vibratoRate) / sampleRate;
vibratoPhaseR += (TAU * params.vibratoRate) / sampleRate;
}
return [leftBuffer, rightBuffer];
}
private calculateEnvelope(t: number, duration: number, params: TwoOpFMParams): number {
const attackTime = params.attack * duration;
const decayTime = params.decay * duration;
const releaseTime = params.release * duration;
const sustainStart = attackTime + decayTime;
const releaseStart = duration - releaseTime;
if (t < attackTime) {
return t / attackTime;
} else if (t < sustainStart) {
const decayProgress = (t - attackTime) / decayTime;
return 1 - decayProgress * (1 - params.sustain);
} else if (t < releaseStart) {
return params.sustain;
} else {
const releaseProgress = (t - releaseStart) / releaseTime;
return params.sustain * (1 - releaseProgress);
}
}
randomParams(): TwoOpFMParams {
return {
carrierFreq: this.randomRange(100, 800),
modRatio: this.randomRange(0.5, 8),
modIndex: this.randomRange(0, 10),
attack: this.randomRange(0.01, 0.15),
decay: this.randomRange(0.05, 0.2),
sustain: this.randomRange(0.3, 0.9),
release: this.randomRange(0.1, 0.4),
vibratoRate: this.randomRange(3, 8),
vibratoDepth: this.randomRange(0, 0.03),
stereoWidth: this.randomRange(0.3, 0.8),
};
}
mutateParams(params: TwoOpFMParams, mutationAmount: number = 0.15): TwoOpFMParams {
return {
carrierFreq: this.mutateValue(params.carrierFreq, mutationAmount, 50, 1000),
modRatio: this.mutateValue(params.modRatio, mutationAmount, 0.25, 10),
modIndex: this.mutateValue(params.modIndex, mutationAmount, 0, 15),
attack: this.mutateValue(params.attack, mutationAmount, 0.001, 0.3),
decay: this.mutateValue(params.decay, mutationAmount, 0.01, 0.4),
sustain: this.mutateValue(params.sustain, mutationAmount, 0.1, 1.0),
release: this.mutateValue(params.release, mutationAmount, 0.05, 0.6),
vibratoRate: this.mutateValue(params.vibratoRate, mutationAmount, 2, 12),
vibratoDepth: this.mutateValue(params.vibratoDepth, mutationAmount, 0, 0.05),
stereoWidth: this.mutateValue(params.stereoWidth, mutationAmount, 0.0, 1.0),
};
}
private randomRange(min: number, max: number): number {
return min + Math.random() * (max - min);
}
private mutateValue(value: number, amount: number, min: number, max: number): number {
const variation = value * amount * (Math.random() * 2 - 1);
return Math.max(min, Math.min(max, value + variation));
}
}

View File

@ -0,0 +1,97 @@
const DEFAULT_SAMPLE_RATE = 44100;
export class AudioService {
private context: AudioContext | null = null;
private currentSource: AudioBufferSourceNode | null = null;
private gainNode: GainNode | null = null;
private startTime = 0;
private isPlaying = false;
private onPlaybackUpdate: ((position: number) => void) | null = null;
private animationFrameId: number | null = null;
private getContext(): AudioContext {
if (!this.context) {
this.context = new AudioContext({ sampleRate: DEFAULT_SAMPLE_RATE });
this.gainNode = this.context.createGain();
this.gainNode.connect(this.context.destination);
}
return this.context;
}
getSampleRate(): number {
return DEFAULT_SAMPLE_RATE;
}
setVolume(volume: number): void {
if (this.gainNode) {
this.gainNode.gain.value = Math.max(0, Math.min(1, volume));
}
}
setPlaybackUpdateCallback(callback: ((position: number) => void) | null): void {
this.onPlaybackUpdate = callback;
}
createAudioBuffer(stereoData: [Float32Array, Float32Array]): AudioBuffer {
const ctx = this.getContext();
const [leftChannel, rightChannel] = stereoData;
const buffer = ctx.createBuffer(2, leftChannel.length, DEFAULT_SAMPLE_RATE);
buffer.copyToChannel(leftChannel, 0);
buffer.copyToChannel(rightChannel, 1);
return buffer;
}
play(buffer: AudioBuffer): void {
this.stop();
const ctx = this.getContext();
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(this.gainNode!);
this.startTime = ctx.currentTime;
this.isPlaying = true;
source.onended = () => {
this.isPlaying = false;
if (this.onPlaybackUpdate) {
this.onPlaybackUpdate(0);
}
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
};
source.start();
this.currentSource = source;
this.updatePlaybackPosition();
}
private updatePlaybackPosition(): void {
if (!this.isPlaying || !this.context || !this.onPlaybackUpdate) {
return;
}
const elapsed = this.context.currentTime - this.startTime;
this.onPlaybackUpdate(elapsed);
this.animationFrameId = requestAnimationFrame(() => this.updatePlaybackPosition());
}
stop(): void {
if (this.currentSource) {
try {
this.currentSource.stop();
} catch {
// Already stopped
}
this.currentSource = null;
}
this.isPlaying = false;
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
}
}

View File

@ -0,0 +1,82 @@
const WAV_HEADER_SIZE = 44;
const IEEE_FLOAT_FORMAT = 3;
const BIT_DEPTH = 32;
export function encodeWAV(buffer: AudioBuffer): ArrayBuffer {
const numChannels = buffer.numberOfChannels;
const sampleRate = buffer.sampleRate;
const bytesPerSample = BIT_DEPTH / 8;
const blockAlign = numChannels * bytesPerSample;
const channelData: Float32Array[] = [];
for (let i = 0; i < numChannels; i++) {
const data = new Float32Array(buffer.length);
buffer.copyFromChannel(data, i);
channelData.push(data);
}
const dataLength = buffer.length * numChannels * bytesPerSample;
const bufferLength = WAV_HEADER_SIZE + dataLength;
const arrayBuffer = new ArrayBuffer(bufferLength);
const view = new DataView(arrayBuffer);
writeWAVHeader(view, numChannels, sampleRate, blockAlign, dataLength);
writePCMData(view, channelData, WAV_HEADER_SIZE);
return arrayBuffer;
}
function writeWAVHeader(
view: DataView,
numChannels: number,
sampleRate: number,
blockAlign: number,
dataLength: number
): void {
const writeString = (offset: number, string: string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
writeString(0, 'RIFF');
view.setUint32(4, 36 + dataLength, true);
writeString(8, 'WAVE');
writeString(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, IEEE_FLOAT_FORMAT, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * blockAlign, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, BIT_DEPTH, true);
writeString(36, 'data');
view.setUint32(40, dataLength, true);
}
function writePCMData(view: DataView, channelData: Float32Array[], startOffset: number): void {
const numChannels = channelData.length;
const numSamples = channelData[0].length;
let offset = startOffset;
for (let i = 0; i < numSamples; i++) {
for (let channel = 0; channel < numChannels; channel++) {
const sample = Math.max(-1, Math.min(1, channelData[channel][i]));
view.setFloat32(offset, sample, true);
offset += 4;
}
}
}
export function downloadWAV(buffer: AudioBuffer, filename: string = 'sound.wav'): void {
const wav = encodeWAV(buffer);
const blob = new Blob([wav], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}