CSound based engine

This commit is contained in:
2025-10-13 11:51:20 +02:00
parent 179c52facc
commit 51e7c44c93
7 changed files with 978 additions and 5 deletions

View File

@ -185,11 +185,11 @@
pitchLockEnabled = !pitchLockEnabled;
}
function regenerateBuffer() {
async function regenerateBuffer() {
if (!currentParams) return;
const sampleRate = audioService.getSampleRate();
const data = engine.generate(currentParams, sampleRate, duration);
const data = await engine.generate(currentParams, sampleRate, duration, pitchLock);
currentBuffer = audioService.createAudioBuffer(data);
audioService.play(currentBuffer);
}

View File

@ -0,0 +1,253 @@
import { Csound } from '@csound/browser';
import type { SynthEngine, PitchLock } from './SynthEngine';
export interface CsoundParameter {
channelName: string;
value: number;
}
export abstract class CsoundEngine<T = any> implements SynthEngine<T> {
abstract getName(): string;
abstract getDescription(): string;
abstract getType(): 'generative' | 'sample' | 'input';
protected abstract getOrchestra(): string;
protected abstract getParametersForCsound(params: T): CsoundParameter[];
abstract randomParams(pitchLock?: PitchLock): T;
abstract mutateParams(params: T, mutationAmount?: number, pitchLock?: PitchLock): T;
async generate(
params: T,
sampleRate: number,
duration: number,
pitchLock?: PitchLock
): Promise<[Float32Array, Float32Array]> {
const orchestra = this.getOrchestra();
const csoundParams = this.getParametersForCsound(params);
const outputFile = '/output.wav';
const csd = this.buildCSD(orchestra, duration, sampleRate, csoundParams, outputFile);
try {
const csound = await Csound();
if (!csound) {
throw new Error('Failed to initialize Csound');
}
await csound.compileCSD(csd);
await csound.start();
await csound.perform();
await csound.cleanup();
const wavData = await csound.fs.readFile(outputFile);
const audioBuffer = await this.parseWavManually(wavData, sampleRate);
await csound.terminateInstance();
const leftChannel = new Float32Array(audioBuffer.leftChannel);
const rightChannel = new Float32Array(audioBuffer.rightChannel);
// Apply short fade-in to prevent click at start
this.applyFadeIn(leftChannel, rightChannel, sampleRate);
const peak = this.findPeak(leftChannel, rightChannel);
if (peak > 0.001) {
const normalizeGain = 0.85 / peak;
this.applyGain(leftChannel, rightChannel, normalizeGain);
}
return [leftChannel, rightChannel];
} catch (error) {
console.error('Csound generation failed:', error);
const numSamples = Math.floor(sampleRate * duration);
return [new Float32Array(numSamples), new Float32Array(numSamples)];
}
}
private parseWavManually(
wavData: Uint8Array,
expectedSampleRate: number
): { leftChannel: Float32Array; rightChannel: Float32Array } {
const view = new DataView(wavData.buffer);
// Check RIFF header
const riff = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3));
if (riff !== 'RIFF') {
throw new Error('Invalid WAV file: no RIFF header');
}
// Check WAVE format
const wave = String.fromCharCode(view.getUint8(8), view.getUint8(9), view.getUint8(10), view.getUint8(11));
if (wave !== 'WAVE') {
throw new Error('Invalid WAV file: no WAVE format');
}
// Find fmt chunk
let offset = 12;
while (offset < wavData.length) {
const chunkId = String.fromCharCode(
view.getUint8(offset),
view.getUint8(offset + 1),
view.getUint8(offset + 2),
view.getUint8(offset + 3)
);
const chunkSize = view.getUint32(offset + 4, true);
if (chunkId === 'fmt ') {
const audioFormat = view.getUint16(offset + 8, true);
const numChannels = view.getUint16(offset + 10, true);
const sampleRate = view.getUint32(offset + 12, true);
const bitsPerSample = view.getUint16(offset + 22, true);
// Find data chunk
let dataOffset = offset + 8 + chunkSize;
while (dataOffset < wavData.length) {
const dataChunkId = String.fromCharCode(
view.getUint8(dataOffset),
view.getUint8(dataOffset + 1),
view.getUint8(dataOffset + 2),
view.getUint8(dataOffset + 3)
);
const dataChunkSize = view.getUint32(dataOffset + 4, true);
if (dataChunkId === 'data') {
const bytesPerSample = bitsPerSample / 8;
const numSamples = Math.floor(dataChunkSize / bytesPerSample / numChannels);
const leftChannel = new Float32Array(numSamples);
const rightChannel = new Float32Array(numSamples);
let audioDataOffset = dataOffset + 8;
if (bitsPerSample === 16) {
// 16-bit PCM
for (let i = 0; i < numSamples; i++) {
const leftSample = view.getInt16(audioDataOffset, true);
leftChannel[i] = leftSample / 32768.0;
audioDataOffset += 2;
if (numChannels > 1) {
const rightSample = view.getInt16(audioDataOffset, true);
rightChannel[i] = rightSample / 32768.0;
audioDataOffset += 2;
} else {
rightChannel[i] = leftChannel[i];
}
}
} else if (bitsPerSample === 32 && audioFormat === 3) {
// 32-bit float
for (let i = 0; i < numSamples; i++) {
leftChannel[i] = view.getFloat32(audioDataOffset, true);
audioDataOffset += 4;
if (numChannels > 1) {
rightChannel[i] = view.getFloat32(audioDataOffset, true);
audioDataOffset += 4;
} else {
rightChannel[i] = leftChannel[i];
}
}
} else {
throw new Error(`Unsupported WAV format: ${bitsPerSample}-bit, format ${audioFormat}`);
}
return { leftChannel, rightChannel };
}
dataOffset += 8 + dataChunkSize;
}
throw new Error('No data chunk found in WAV file');
}
offset += 8 + chunkSize;
}
throw new Error('No fmt chunk found in WAV file');
}
private buildCSD(
orchestra: string,
duration: number,
sampleRate: number,
parameters: CsoundParameter[],
outputFile: string
): string {
const paramInit = parameters
.map(p => `chnset ${p.value}, "${p.channelName}"`)
.join('\n');
return `<CsoundSynthesizer>
<CsOptions>
-W -d -m0 -o ${outputFile}
</CsOptions>
<CsInstruments>
sr = ${sampleRate}
ksmps = 64
nchnls = 2
0dbfs = 1.0
${paramInit}
${orchestra}
</CsInstruments>
<CsScore>
i 1 0 ${duration}
e
</CsScore>
</CsoundSynthesizer>`;
}
private findPeak(leftChannel: Float32Array, rightChannel: Float32Array): number {
let peak = 0;
for (let i = 0; i < leftChannel.length; i++) {
peak = Math.max(peak, Math.abs(leftChannel[i]), Math.abs(rightChannel[i]));
}
return peak;
}
private applyGain(
leftChannel: Float32Array,
rightChannel: Float32Array,
gain: number
): void {
for (let i = 0; i < leftChannel.length; i++) {
leftChannel[i] *= gain;
rightChannel[i] *= gain;
}
}
private applyFadeIn(
leftChannel: Float32Array,
rightChannel: Float32Array,
sampleRate: number
): void {
const fadeInMs = 5; // 5ms fade-in to prevent clicks
const fadeSamples = Math.floor((fadeInMs / 1000) * sampleRate);
const actualFadeSamples = Math.min(fadeSamples, leftChannel.length);
for (let i = 0; i < actualFadeSamples; i++) {
const gain = i / actualFadeSamples;
leftChannel[i] *= gain;
rightChannel[i] *= gain;
}
}
protected randomRange(min: number, max: number): number {
return min + Math.random() * (max - min);
}
protected randomInt(min: number, max: number): number {
return Math.floor(this.randomRange(min, max + 1));
}
protected randomChoice<U>(choices: readonly U[]): U {
return choices[Math.floor(Math.random() * choices.length)];
}
protected 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,338 @@
import { CsoundEngine, type CsoundParameter } from './CsoundEngine';
import type { PitchLock } from './SynthEngine';
enum Waveform {
Sine = 0,
Saw = 1,
Square = 2,
Triangle = 3,
}
interface OscillatorParams {
waveform: Waveform;
ratio: number;
level: number;
attack: number;
decay: number;
sustain: number;
release: number;
}
interface FilterParams {
cutoff: number;
resonance: number;
envAmount: number;
attack: number;
decay: number;
sustain: number;
release: number;
}
export interface SubtractiveThreeOscParams {
baseFreq: number;
osc1: OscillatorParams;
osc2: OscillatorParams;
osc3: OscillatorParams;
filter: FilterParams;
stereoWidth: number;
}
export class SubtractiveThreeOsc extends CsoundEngine<SubtractiveThreeOscParams> {
getName(): string {
return 'Subtractive 3-OSC';
}
getDescription(): string {
return 'Three-oscillator subtractive synthesis with resonant filter';
}
getType() {
return 'generative' as const;
}
protected getOrchestra(): string {
return `
instr 1
; Get base frequency
ibasefreq chnget "basefreq"
istereo chnget "stereowidth"
; Oscillator 1 parameters
iosc1wave chnget "osc1_wave"
iosc1ratio chnget "osc1_ratio"
iosc1level chnget "osc1_level"
iosc1attack chnget "osc1_attack"
iosc1decay chnget "osc1_decay"
iosc1sustain chnget "osc1_sustain"
iosc1release chnget "osc1_release"
; Oscillator 2 parameters
iosc2wave chnget "osc2_wave"
iosc2ratio chnget "osc2_ratio"
iosc2level chnget "osc2_level"
iosc2attack chnget "osc2_attack"
iosc2decay chnget "osc2_decay"
iosc2sustain chnget "osc2_sustain"
iosc2release chnget "osc2_release"
; Oscillator 3 parameters
iosc3wave chnget "osc3_wave"
iosc3ratio chnget "osc3_ratio"
iosc3level chnget "osc3_level"
iosc3attack chnget "osc3_attack"
iosc3decay chnget "osc3_decay"
iosc3sustain chnget "osc3_sustain"
iosc3release chnget "osc3_release"
; Filter parameters
ifiltcutoff chnget "filt_cutoff"
ifiltres chnget "filt_resonance"
ifiltenvamt chnget "filt_envamt"
ifiltattack chnget "filt_attack"
ifiltdecay chnget "filt_decay"
ifiltsustain chnget "filt_sustain"
ifiltrelease chnget "filt_release"
idur = p3
; Convert ratios to time values
iosc1att = iosc1attack * idur
iosc1dec = iosc1decay * idur
iosc1rel = iosc1release * idur
iosc2att = iosc2attack * idur
iosc2dec = iosc2decay * idur
iosc2rel = iosc2release * idur
iosc3att = iosc3attack * idur
iosc3dec = iosc3decay * idur
iosc3rel = iosc3release * idur
ifiltatt = ifiltattack * idur
ifiltdec = ifiltdecay * idur
ifiltrel = ifiltrelease * idur
; Stereo detuning
idetune = 1 + (istereo * 0.001)
ifreqL = ibasefreq / idetune
ifreqR = ibasefreq * idetune
; Oscillator 1 envelopes
kenv1 madsr iosc1att, iosc1dec, iosc1sustain, iosc1rel
; Oscillator 1 - Left
if iosc1wave == 0 then
aosc1L oscili kenv1 * iosc1level, ifreqL * iosc1ratio
elseif iosc1wave == 1 then
aosc1L vco2 kenv1 * iosc1level, ifreqL * iosc1ratio, 0
elseif iosc1wave == 2 then
aosc1L vco2 kenv1 * iosc1level, ifreqL * iosc1ratio, 10
else
aosc1L vco2 kenv1 * iosc1level, ifreqL * iosc1ratio, 12
endif
; Oscillator 1 - Right
if iosc1wave == 0 then
aosc1R oscili kenv1 * iosc1level, ifreqR * iosc1ratio
elseif iosc1wave == 1 then
aosc1R vco2 kenv1 * iosc1level, ifreqR * iosc1ratio, 0
elseif iosc1wave == 2 then
aosc1R vco2 kenv1 * iosc1level, ifreqR * iosc1ratio, 10
else
aosc1R vco2 kenv1 * iosc1level, ifreqR * iosc1ratio, 12
endif
; Oscillator 2 envelopes
kenv2 madsr iosc2att, iosc2dec, iosc2sustain, iosc2rel
; Oscillator 2 - Left
if iosc2wave == 0 then
aosc2L oscili kenv2 * iosc2level, ifreqL * iosc2ratio
elseif iosc2wave == 1 then
aosc2L vco2 kenv2 * iosc2level, ifreqL * iosc2ratio, 0
elseif iosc2wave == 2 then
aosc2L vco2 kenv2 * iosc2level, ifreqL * iosc2ratio, 10
else
aosc2L vco2 kenv2 * iosc2level, ifreqL * iosc2ratio, 12
endif
; Oscillator 2 - Right
if iosc2wave == 0 then
aosc2R oscili kenv2 * iosc2level, ifreqR * iosc2ratio
elseif iosc2wave == 1 then
aosc2R vco2 kenv2 * iosc2level, ifreqR * iosc2ratio, 0
elseif iosc2wave == 2 then
aosc2R vco2 kenv2 * iosc2level, ifreqR * iosc2ratio, 10
else
aosc2R vco2 kenv2 * iosc2level, ifreqR * iosc2ratio, 12
endif
; Oscillator 3 envelopes
kenv3 madsr iosc3att, iosc3dec, iosc3sustain, iosc3rel
; Oscillator 3 - Left
if iosc3wave == 0 then
aosc3L oscili kenv3 * iosc3level, ifreqL * iosc3ratio
elseif iosc3wave == 1 then
aosc3L vco2 kenv3 * iosc3level, ifreqL * iosc3ratio, 0
elseif iosc3wave == 2 then
aosc3L vco2 kenv3 * iosc3level, ifreqL * iosc3ratio, 10
else
aosc3L vco2 kenv3 * iosc3level, ifreqL * iosc3ratio, 12
endif
; Oscillator 3 - Right
if iosc3wave == 0 then
aosc3R oscili kenv3 * iosc3level, ifreqR * iosc3ratio
elseif iosc3wave == 1 then
aosc3R vco2 kenv3 * iosc3level, ifreqR * iosc3ratio, 0
elseif iosc3wave == 2 then
aosc3R vco2 kenv3 * iosc3level, ifreqR * iosc3ratio, 10
else
aosc3R vco2 kenv3 * iosc3level, ifreqR * iosc3ratio, 12
endif
; Mix oscillators
amixL = aosc1L + aosc2L + aosc3L
amixR = aosc1R + aosc2R + aosc3R
; Filter envelope
kfiltenv madsr ifiltatt, ifiltdec, ifiltsustain, ifiltrel
kcutoff = ifiltcutoff + (kfiltenv * ifiltenvamt * 10000)
kcutoff = limit(kcutoff, 20, 20000)
; Apply moogladder filter
afiltL moogladder amixL, kcutoff, ifiltres
afiltR moogladder amixR, kcutoff, ifiltres
outs afiltL, afiltR
endin
`;
}
protected getParametersForCsound(params: SubtractiveThreeOscParams): CsoundParameter[] {
return [
{ channelName: 'basefreq', value: params.baseFreq },
{ channelName: 'stereowidth', value: params.stereoWidth },
{ channelName: 'osc1_wave', value: params.osc1.waveform },
{ channelName: 'osc1_ratio', value: params.osc1.ratio },
{ channelName: 'osc1_level', value: params.osc1.level },
{ channelName: 'osc1_attack', value: params.osc1.attack },
{ channelName: 'osc1_decay', value: params.osc1.decay },
{ channelName: 'osc1_sustain', value: params.osc1.sustain },
{ channelName: 'osc1_release', value: params.osc1.release },
{ channelName: 'osc2_wave', value: params.osc2.waveform },
{ channelName: 'osc2_ratio', value: params.osc2.ratio },
{ channelName: 'osc2_level', value: params.osc2.level },
{ channelName: 'osc2_attack', value: params.osc2.attack },
{ channelName: 'osc2_decay', value: params.osc2.decay },
{ channelName: 'osc2_sustain', value: params.osc2.sustain },
{ channelName: 'osc2_release', value: params.osc2.release },
{ channelName: 'osc3_wave', value: params.osc3.waveform },
{ channelName: 'osc3_ratio', value: params.osc3.ratio },
{ channelName: 'osc3_level', value: params.osc3.level },
{ channelName: 'osc3_attack', value: params.osc3.attack },
{ channelName: 'osc3_decay', value: params.osc3.decay },
{ channelName: 'osc3_sustain', value: params.osc3.sustain },
{ channelName: 'osc3_release', value: params.osc3.release },
{ channelName: 'filt_cutoff', value: params.filter.cutoff },
{ channelName: 'filt_resonance', value: params.filter.resonance },
{ channelName: 'filt_envamt', value: params.filter.envAmount },
{ channelName: 'filt_attack', value: params.filter.attack },
{ channelName: 'filt_decay', value: params.filter.decay },
{ channelName: 'filt_sustain', value: params.filter.sustain },
{ channelName: 'filt_release', value: params.filter.release },
];
}
randomParams(pitchLock?: PitchLock): SubtractiveThreeOscParams {
let baseFreq: number;
if (pitchLock?.enabled) {
baseFreq = pitchLock.frequency;
} else {
const baseFreqChoices = [55, 82.4, 110, 146.8, 220, 293.7, 440];
baseFreq = this.randomChoice(baseFreqChoices) * this.randomRange(0.98, 1.02);
}
const harmonicRatios = [0.5, 1, 2, 3, 4];
const detuneRatios = [0.99, 1.0, 1.01, 1.02, 0.98];
return {
baseFreq,
osc1: this.randomOscillator(harmonicRatios),
osc2: this.randomOscillator(detuneRatios),
osc3: this.randomOscillator(harmonicRatios),
filter: this.randomFilter(),
stereoWidth: this.randomRange(0.2, 0.8),
};
}
private randomOscillator(ratios: number[]): OscillatorParams {
return {
waveform: this.randomInt(0, 3) as Waveform,
ratio: this.randomChoice(ratios),
level: this.randomRange(0.2, 0.5),
attack: this.randomRange(0.001, 0.15),
decay: this.randomRange(0.02, 0.25),
sustain: this.randomRange(0.3, 0.8),
release: this.randomRange(0.05, 0.4),
};
}
private randomFilter(): FilterParams {
return {
cutoff: this.randomRange(200, 5000),
resonance: this.randomRange(0.1, 0.8),
envAmount: this.randomRange(0.2, 1.2),
attack: this.randomRange(0.001, 0.15),
decay: this.randomRange(0.05, 0.3),
sustain: this.randomRange(0.2, 0.7),
release: this.randomRange(0.05, 0.4),
};
}
mutateParams(
params: SubtractiveThreeOscParams,
mutationAmount: number = 0.15,
pitchLock?: PitchLock
): SubtractiveThreeOscParams {
const baseFreq = pitchLock?.enabled ? pitchLock.frequency : params.baseFreq;
return {
baseFreq,
osc1: this.mutateOscillator(params.osc1, mutationAmount),
osc2: this.mutateOscillator(params.osc2, mutationAmount),
osc3: this.mutateOscillator(params.osc3, mutationAmount),
filter: this.mutateFilter(params.filter, mutationAmount),
stereoWidth: this.mutateValue(params.stereoWidth, mutationAmount, 0, 1),
};
}
private mutateOscillator(osc: OscillatorParams, amount: number): OscillatorParams {
return {
waveform: Math.random() < 0.1 ? (this.randomInt(0, 3) as Waveform) : osc.waveform,
ratio: Math.random() < 0.1 ? this.randomChoice([0.5, 0.98, 0.99, 1, 1.01, 1.02, 2, 3, 4]) : osc.ratio,
level: this.mutateValue(osc.level, amount, 0.1, 0.7),
attack: this.mutateValue(osc.attack, amount, 0.001, 0.3),
decay: this.mutateValue(osc.decay, amount, 0.01, 0.4),
sustain: this.mutateValue(osc.sustain, amount, 0.1, 0.9),
release: this.mutateValue(osc.release, amount, 0.02, 0.6),
};
}
private mutateFilter(filter: FilterParams, amount: number): FilterParams {
return {
cutoff: this.mutateValue(filter.cutoff, amount, 100, 8000),
resonance: this.mutateValue(filter.resonance, amount, 0, 0.95),
envAmount: this.mutateValue(filter.envAmount, amount, 0, 1.5),
attack: this.mutateValue(filter.attack, amount, 0.001, 0.3),
decay: this.mutateValue(filter.decay, amount, 0.01, 0.4),
sustain: this.mutateValue(filter.sustain, amount, 0.1, 0.9),
release: this.mutateValue(filter.release, amount, 0.02, 0.6),
};
}
}

View File

@ -15,7 +15,7 @@ export interface SynthEngine<T = any> {
getName(): string;
getDescription(): string;
getType(): EngineType;
generate(params: T, sampleRate: number, duration: number, pitchLock?: PitchLock): [Float32Array, Float32Array];
generate(params: T, sampleRate: number, duration: number, pitchLock?: PitchLock): [Float32Array, Float32Array] | Promise<[Float32Array, Float32Array]>;
randomParams(pitchLock?: PitchLock): T;
mutateParams(params: T, mutationAmount?: number, pitchLock?: PitchLock): T;
}

View File

@ -16,6 +16,7 @@ import { BassDrum } from './BassDrum';
import { HiHat } from './HiHat';
import { ParticleNoise } from './ParticleNoise';
import { DustNoise } from './DustNoise';
import { SubtractiveThreeOsc } from './SubtractiveThreeOsc';
export const engines: SynthEngine[] = [
new Sample(),
@ -35,4 +36,5 @@ export const engines: SynthEngine[] = [
new AdditiveEngine(),
new ParticleNoise(),
new DustNoise(),
new SubtractiveThreeOsc(),
];