333 lines
10 KiB
TypeScript
333 lines
10 KiB
TypeScript
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();
|
|
|
|
let leftChannel = new Float32Array(audioBuffer.leftChannel);
|
|
let rightChannel = new Float32Array(audioBuffer.rightChannel);
|
|
|
|
this.removeDCOffset(leftChannel, rightChannel);
|
|
|
|
const trimmed = this.trimToZeroCrossing(leftChannel, rightChannel, sampleRate);
|
|
leftChannel = trimmed.left;
|
|
rightChannel = trimmed.right;
|
|
|
|
this.applyFadeIn(leftChannel, rightChannel, sampleRate);
|
|
this.applyFadeOut(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 removeDCOffset(leftChannel: Float32Array, rightChannel: Float32Array): void {
|
|
let leftSum = 0;
|
|
let rightSum = 0;
|
|
const length = leftChannel.length;
|
|
|
|
for (let i = 0; i < length; i++) {
|
|
leftSum += leftChannel[i];
|
|
rightSum += rightChannel[i];
|
|
}
|
|
|
|
const leftDC = leftSum / length;
|
|
const rightDC = rightSum / length;
|
|
|
|
for (let i = 0; i < length; i++) {
|
|
leftChannel[i] -= leftDC;
|
|
rightChannel[i] -= rightDC;
|
|
}
|
|
}
|
|
|
|
private trimToZeroCrossing(
|
|
leftChannel: Float32Array,
|
|
rightChannel: Float32Array,
|
|
sampleRate: number
|
|
): { left: Float32Array; right: Float32Array } {
|
|
const maxSearchSamples = Math.min(Math.floor(sampleRate * 0.01), leftChannel.length);
|
|
let trimIndex = 0;
|
|
|
|
for (let i = 1; i < maxSearchSamples; i++) {
|
|
const prevL = leftChannel[i - 1];
|
|
const currL = leftChannel[i];
|
|
const prevR = rightChannel[i - 1];
|
|
const currR = rightChannel[i];
|
|
|
|
if (
|
|
(prevL <= 0 && currL >= 0) || (prevL >= 0 && currL <= 0) ||
|
|
(prevR <= 0 && currR >= 0) || (prevR >= 0 && currR <= 0)
|
|
) {
|
|
trimIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (trimIndex > 0) {
|
|
const newLeft = new Float32Array(leftChannel.length - trimIndex);
|
|
const newRight = new Float32Array(rightChannel.length - trimIndex);
|
|
newLeft.set(leftChannel.subarray(trimIndex));
|
|
newRight.set(rightChannel.subarray(trimIndex));
|
|
return { left: newLeft, right: newRight };
|
|
}
|
|
|
|
return { left: leftChannel, right: rightChannel };
|
|
}
|
|
|
|
private applyFadeIn(
|
|
leftChannel: Float32Array,
|
|
rightChannel: Float32Array,
|
|
sampleRate: number
|
|
): void {
|
|
const fadeInMs = 5;
|
|
const fadeSamples = Math.floor((fadeInMs / 1000) * sampleRate);
|
|
const actualFadeSamples = Math.min(fadeSamples, leftChannel.length);
|
|
|
|
for (let i = 0; i < actualFadeSamples; i++) {
|
|
const phase = i / actualFadeSamples;
|
|
const gain = 0.5 - 0.5 * Math.cos(phase * Math.PI);
|
|
leftChannel[i] *= gain;
|
|
rightChannel[i] *= gain;
|
|
}
|
|
}
|
|
|
|
private applyFadeOut(
|
|
leftChannel: Float32Array,
|
|
rightChannel: Float32Array,
|
|
sampleRate: number
|
|
): void {
|
|
const fadeOutMs = 5;
|
|
const fadeSamples = Math.floor((fadeOutMs / 1000) * sampleRate);
|
|
const actualFadeSamples = Math.min(fadeSamples, leftChannel.length);
|
|
const startSample = leftChannel.length - actualFadeSamples;
|
|
|
|
for (let i = 0; i < actualFadeSamples; i++) {
|
|
const sampleIndex = startSample + i;
|
|
const phase = i / actualFadeSamples;
|
|
const gain = 0.5 + 0.5 * Math.cos(phase * Math.PI);
|
|
leftChannel[sampleIndex] *= gain;
|
|
rightChannel[sampleIndex] *= 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));
|
|
}
|
|
}
|