595 lines
16 KiB
TypeScript
595 lines
16 KiB
TypeScript
import { Csound as Csound6 } from '@csound/browser';
|
|
import { Csound as Csound7 } from 'csound7';
|
|
import type { File } from '../project-system/types';
|
|
import { compileCSD, type CsoundObj } from './types';
|
|
|
|
export interface CsoundEngineOptions {
|
|
onMessage?: (message: string) => void;
|
|
onError?: (error: string) => void;
|
|
onPerformanceEnd?: () => void;
|
|
onAnalyserNodeCreated?: (node: AnalyserNode) => void;
|
|
getProjectFiles?: () => Promise<File[]>;
|
|
useCsound7?: boolean;
|
|
}
|
|
|
|
export interface PerformanceMetrics {
|
|
kperiodCount: number;
|
|
currentTime: number;
|
|
isRunning: boolean;
|
|
}
|
|
|
|
export interface CompilationResult {
|
|
success: boolean;
|
|
errorMessage?: string;
|
|
}
|
|
|
|
export class CsoundEngine {
|
|
private csound: CsoundObj | null = null;
|
|
private initialized = false;
|
|
private compiled = false;
|
|
private running = false;
|
|
private options: CsoundEngineOptions;
|
|
private scopeNode: AnalyserNode | null = null;
|
|
private audioNode: AudioNode | null = null;
|
|
private audioContext: AudioContext | null = null;
|
|
private gainNode: GainNode | null = null;
|
|
private useCsound7: boolean;
|
|
|
|
constructor(options: CsoundEngineOptions = {}) {
|
|
this.options = options;
|
|
this.useCsound7 = options.useCsound7 ?? false;
|
|
}
|
|
|
|
async init(): Promise<void> {
|
|
if (this.initialized) return;
|
|
|
|
await this.ensureCsoundInstance();
|
|
this.initialized = true;
|
|
this.log('Csound ready');
|
|
}
|
|
|
|
private async ensureCsoundInstance(): Promise<void> {
|
|
if (!this.csound) {
|
|
const version = this.useCsound7 ? '7' : '6';
|
|
this.log(`Creating Csound ${version} instance...`);
|
|
const Csound = this.useCsound7 ? Csound7 : Csound6;
|
|
this.csound = await Csound();
|
|
this.setupCallbacks();
|
|
}
|
|
}
|
|
|
|
async restart(): Promise<void> {
|
|
this.log('Restarting Csound...');
|
|
|
|
if (this.running) {
|
|
await this.stop();
|
|
}
|
|
|
|
if (this.csound) {
|
|
try {
|
|
await this.csound.reset();
|
|
} catch (error) {
|
|
this.log('Reset failed, creating new instance...');
|
|
await this.cleanupInstance();
|
|
await this.ensureCsoundInstance();
|
|
}
|
|
} else {
|
|
await this.ensureCsoundInstance();
|
|
}
|
|
|
|
await this.csound!.setOption('-m0');
|
|
await this.csound!.setOption('-d');
|
|
await this.csound!.setOption('-odac');
|
|
await this.csound!.setOption('-+msg_color=false');
|
|
await this.csound!.setOption('--daemon');
|
|
|
|
const ac = await this.csound!.getAudioContext();
|
|
const sampleRate = ac?.sampleRate || 48000;
|
|
|
|
const header = `sr = ${sampleRate}\nksmps = 32\n0dbfs = 1\nnchnls = 2\nnchnls_i = 1`;
|
|
|
|
const compileResult = await this.csound!.compileOrc(header);
|
|
if (compileResult !== 0) {
|
|
throw new Error('Failed to compile base header');
|
|
}
|
|
|
|
this.compiled = true;
|
|
this.log('Csound restarted and ready');
|
|
}
|
|
|
|
/**
|
|
* Sync all project files to Csound's virtual filesystem
|
|
* This enables #include directives to work properly
|
|
*/
|
|
private async syncProjectFilesToFS(): Promise<void> {
|
|
if (!this.csound) {
|
|
this.log('Warning: Cannot sync files - no Csound instance');
|
|
return;
|
|
}
|
|
|
|
if (!this.options.getProjectFiles) {
|
|
this.log('Warning: Cannot sync files - no getProjectFiles callback');
|
|
return;
|
|
}
|
|
|
|
const files = await this.options.getProjectFiles();
|
|
this.log(`Syncing ${files.length} files to virtual FS...`);
|
|
|
|
const encoder = new TextEncoder();
|
|
|
|
for (const file of files) {
|
|
try {
|
|
const content = encoder.encode(file.content);
|
|
await this.csound.fs.writeFile(file.title, content);
|
|
this.log(` - ${file.title}`);
|
|
} catch (error) {
|
|
this.log(`Warning: Failed to write ${file.title}: ${error}`);
|
|
}
|
|
}
|
|
|
|
this.log('File sync complete');
|
|
}
|
|
|
|
private setupCallbacks(): void {
|
|
if (!this.csound) return;
|
|
|
|
this.csound.on('message', (message: string) => {
|
|
this.options.onMessage?.(message);
|
|
});
|
|
|
|
this.csound.on('onAudioNodeCreated', (node: AudioNode) => {
|
|
this.audioNode = node;
|
|
this.audioContext = node.context as AudioContext;
|
|
this.setupGainNode();
|
|
this.log('Audio node created and captured');
|
|
});
|
|
|
|
this.csound.on('realtimePerformanceEnded', async () => {
|
|
this.running = false;
|
|
this.log('Performance complete');
|
|
this.options.onPerformanceEnd?.();
|
|
});
|
|
}
|
|
|
|
async compileOrchestra(orchestra: string): Promise<CompilationResult> {
|
|
if (!this.initialized) {
|
|
throw new Error('Csound not initialized. Call init() first.');
|
|
}
|
|
|
|
if (!this.csound) {
|
|
throw new Error('No Csound instance available');
|
|
}
|
|
|
|
try {
|
|
this.log('Compiling orchestra...');
|
|
const result = await this.csound.compileOrc(orchestra);
|
|
|
|
if (result !== 0) {
|
|
const errorMsg = 'Orchestra compilation failed';
|
|
this.error(errorMsg);
|
|
return { success: false, errorMessage: errorMsg };
|
|
}
|
|
|
|
this.compiled = true;
|
|
this.log('Orchestra compiled successfully');
|
|
return { success: true };
|
|
|
|
} catch (error) {
|
|
const errorMsg = error instanceof Error ? error.message : 'Compilation failed';
|
|
this.error(errorMsg);
|
|
return { success: false, errorMessage: errorMsg };
|
|
}
|
|
}
|
|
|
|
async startPerformance(): Promise<void> {
|
|
if (!this.compiled) {
|
|
throw new Error('No orchestra compiled. Call compileOrchestra() first.');
|
|
}
|
|
|
|
if (this.running) {
|
|
this.log('Already running');
|
|
return;
|
|
}
|
|
|
|
this.log('Starting performance...');
|
|
await this.csound!.start();
|
|
this.running = true;
|
|
this.setupAnalyser();
|
|
this.log('Performance started');
|
|
}
|
|
|
|
async readScore(score: string): Promise<void> {
|
|
if (!this.csound) {
|
|
throw new Error('No Csound instance available');
|
|
}
|
|
|
|
this.log('Reading score...');
|
|
await this.csound.readScore(score);
|
|
}
|
|
|
|
async sendScoreEvent(event: string): Promise<void> {
|
|
if (!this.running) {
|
|
throw new Error('Csound not running. Call startPerformance() first.');
|
|
}
|
|
|
|
await this.csound!.readScore(event);
|
|
}
|
|
|
|
/**
|
|
* Run a .csd file (composition mode)
|
|
* Syncs all files to virtual FS, then uses native CSD compilation
|
|
*/
|
|
async playCSD(filename: string, content?: string): Promise<void> {
|
|
if (!this.initialized) {
|
|
throw new Error('Csound not initialized. Call init() first.');
|
|
}
|
|
|
|
try {
|
|
console.log('[Engine] playCSD() called with filename:', filename);
|
|
console.log('[Engine] Content provided:', !!content);
|
|
if (content) {
|
|
console.log('[Engine] Content preview:', content.substring(0, 100));
|
|
console.log('[Engine] Content length:', content.length);
|
|
}
|
|
|
|
if (this.running) {
|
|
await this.stop();
|
|
}
|
|
|
|
await this.restart();
|
|
this.scopeNode = null;
|
|
|
|
// Sync all files to virtual FS so #includes work
|
|
await this.syncProjectFilesToFS();
|
|
|
|
// If content is provided, write it to the filename
|
|
if (content) {
|
|
const encoder = new TextEncoder();
|
|
const encoded = encoder.encode(content);
|
|
console.log('[Engine] Encoded content length:', encoded.length);
|
|
await this.csound!.fs.writeFile(filename, encoded);
|
|
this.log(`Wrote ${filename} to virtual FS (${encoded.length} bytes)`);
|
|
console.log('[Engine] Successfully wrote to virtual FS');
|
|
|
|
// Verify the file was written correctly
|
|
try {
|
|
const readBack = await this.csound!.fs.readFile(filename);
|
|
const decoder = new TextDecoder();
|
|
const readContent = decoder.decode(readBack);
|
|
console.log('[Engine] Read back from FS:', readContent.substring(0, 100));
|
|
console.log('[Engine] Read back length:', readContent.length);
|
|
console.log('[Engine] Contains closing tag:', readContent.includes('</CsoundSynthesizer>'));
|
|
} catch (e) {
|
|
console.error('[Engine] Failed to read back file:', e);
|
|
}
|
|
}
|
|
|
|
this.log(`Compiling ${filename}...`);
|
|
|
|
// Debug: List files in virtual FS root to verify file exists
|
|
try {
|
|
const files = await this.csound!.fs.readdir('/');
|
|
console.log('[Engine] Files in virtual FS root:', files);
|
|
} catch (e) {
|
|
console.error('[Engine] Failed to list FS:', e);
|
|
}
|
|
|
|
// Use native CSD compilation (Csound handles #includes)
|
|
// Mode 0 = path (read from virtual FS), Mode 1 = text (parse as CSD content)
|
|
console.log('[Engine] Calling compileCSD with filename:', filename, 'mode: 0 (path)');
|
|
const result = await compileCSD(this.csound!, filename, 0);
|
|
console.log('[Engine] compileCSD returned:', result);
|
|
|
|
if (result !== 0) {
|
|
throw new Error(`Failed to compile ${filename}`);
|
|
}
|
|
|
|
this.compiled = true;
|
|
this.log('CSD compiled successfully');
|
|
|
|
// Start performance after successful compilation
|
|
this.log('Starting performance...');
|
|
await this.csound!.start();
|
|
this.running = true;
|
|
this.setupAnalyser();
|
|
this.log('Performance started');
|
|
|
|
} catch (error) {
|
|
this.running = false;
|
|
this.compiled = false;
|
|
const errorMsg = error instanceof Error ? error.message : 'Evaluation failed';
|
|
this.error(errorMsg);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run a .orc file (orchestra only, no score)
|
|
*/
|
|
async playORC(orchestraCode: string): Promise<void> {
|
|
if (!this.initialized) {
|
|
throw new Error('Csound not initialized. Call init() first.');
|
|
}
|
|
|
|
try {
|
|
if (this.running) {
|
|
await this.stop();
|
|
}
|
|
|
|
await this.restart();
|
|
this.scopeNode = null;
|
|
|
|
this.log('Compiling orchestra...');
|
|
|
|
const result = await this.csound!.compileOrc(orchestraCode);
|
|
|
|
if (result !== 0) {
|
|
throw new Error('Failed to compile orchestra');
|
|
}
|
|
|
|
this.compiled = true;
|
|
this.log('Orchestra compiled successfully');
|
|
|
|
// Start performance after successful compilation
|
|
this.log('Starting performance...');
|
|
await this.csound!.start();
|
|
this.running = true;
|
|
this.setupAnalyser();
|
|
this.log('Performance started');
|
|
|
|
} catch (error) {
|
|
this.running = false;
|
|
this.compiled = false;
|
|
const errorMsg = error instanceof Error ? error.message : 'Evaluation failed';
|
|
this.error(errorMsg);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Incremental code evaluation (live coding mode)
|
|
* Requires Csound to be already running
|
|
*/
|
|
async evalCode(code: string): Promise<void> {
|
|
if (!this.csound) {
|
|
throw new Error('No Csound instance available');
|
|
}
|
|
|
|
if (!this.running) {
|
|
throw new Error('Csound not running. Use playCSD() or playORC() first.');
|
|
}
|
|
|
|
this.log('Evaluating code...');
|
|
const result = await this.csound.evalCode(code);
|
|
|
|
if (result !== 0) {
|
|
this.error('Code evaluation failed');
|
|
throw new Error('Code evaluation failed');
|
|
}
|
|
}
|
|
|
|
async setControlChannel(name: string, value: number): Promise<void> {
|
|
if (!this.csound) {
|
|
throw new Error('No Csound instance available');
|
|
}
|
|
|
|
await this.csound.setControlChannel(name, value);
|
|
}
|
|
|
|
async getControlChannel(name: string): Promise<number> {
|
|
if (!this.csound) {
|
|
throw new Error('No Csound instance available');
|
|
}
|
|
|
|
return await this.csound.getControlChannel(name);
|
|
}
|
|
|
|
async setStringChannel(name: string, value: string): Promise<void> {
|
|
if (!this.csound) {
|
|
throw new Error('No Csound instance available');
|
|
}
|
|
|
|
await this.csound.setStringChannel(name, value);
|
|
}
|
|
|
|
async getStringChannel(name: string): Promise<string> {
|
|
if (!this.csound) {
|
|
throw new Error('No Csound instance available');
|
|
}
|
|
|
|
return await this.csound.getStringChannel(name);
|
|
}
|
|
|
|
async getTable(tableNumber: number): Promise<Float32Array> {
|
|
if (!this.csound) {
|
|
throw new Error('No Csound instance available');
|
|
}
|
|
|
|
return await this.csound.getTable(tableNumber);
|
|
}
|
|
|
|
async setTable(tableNumber: number, data: Float32Array | number[]): Promise<void> {
|
|
if (!this.csound) {
|
|
throw new Error('No Csound instance available');
|
|
}
|
|
|
|
const tableData = data instanceof Float32Array ? data : new Float32Array(data);
|
|
await this.csound.setTable(tableNumber, tableData);
|
|
}
|
|
|
|
async getPerformanceMetrics(): Promise<PerformanceMetrics> {
|
|
if (!this.csound) {
|
|
return {
|
|
kperiodCount: 0,
|
|
currentTime: 0,
|
|
isRunning: this.running
|
|
};
|
|
}
|
|
|
|
try {
|
|
const currentTime = await this.csound.getCurrentTimeSamples();
|
|
|
|
return {
|
|
kperiodCount: 0,
|
|
currentTime: currentTime / (await this.csound.getSr()),
|
|
isRunning: this.running
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
kperiodCount: 0,
|
|
currentTime: 0,
|
|
isRunning: this.running
|
|
};
|
|
}
|
|
}
|
|
|
|
async getSampleRate(): Promise<number> {
|
|
if (!this.csound) {
|
|
throw new Error('No Csound instance available');
|
|
}
|
|
return await this.csound.getSr();
|
|
}
|
|
|
|
async getKsmps(): Promise<number> {
|
|
if (!this.csound) {
|
|
throw new Error('No Csound instance available');
|
|
}
|
|
return await this.csound.getKsmps();
|
|
}
|
|
|
|
async getOutputChannelCount(): Promise<number> {
|
|
if (!this.csound) {
|
|
throw new Error('No Csound instance available');
|
|
}
|
|
return await this.csound.getNchnls();
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
if (!this.csound || !this.running) return;
|
|
|
|
try {
|
|
this.log('Stopping performance...');
|
|
await this.csound.stop();
|
|
this.running = false;
|
|
this.log('Stopped');
|
|
} catch (error) {
|
|
const errorMsg = error instanceof Error ? error.message : 'Failed to stop';
|
|
this.error(errorMsg);
|
|
}
|
|
}
|
|
|
|
|
|
private async cleanupInstance(): Promise<void> {
|
|
if (!this.csound) return;
|
|
|
|
try {
|
|
if (this.running) {
|
|
await this.csound.stop();
|
|
}
|
|
await this.csound.cleanup();
|
|
} catch (error) {
|
|
console.error('Cleanup error:', error);
|
|
} finally {
|
|
this.csound = null;
|
|
this.running = false;
|
|
this.compiled = false;
|
|
}
|
|
}
|
|
|
|
isRunning(): boolean {
|
|
return this.running;
|
|
}
|
|
|
|
isInitialized(): boolean {
|
|
return this.initialized;
|
|
}
|
|
|
|
isCompiled(): boolean {
|
|
return this.compiled;
|
|
}
|
|
|
|
getAudioContext(): AudioContext | null {
|
|
return this.audioContext;
|
|
}
|
|
|
|
private setupGainNode(): void {
|
|
if (!this.audioNode || !this.audioContext || this.gainNode) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.gainNode = this.audioContext.createGain();
|
|
this.gainNode.gain.value = 1.0;
|
|
|
|
this.audioNode.disconnect();
|
|
this.audioNode.connect(this.gainNode);
|
|
this.gainNode.connect(this.audioContext.destination);
|
|
|
|
this.log('Gain node created and connected');
|
|
} catch (error) {
|
|
console.error('Failed to setup gain node:', error);
|
|
this.log('Error setting up gain node: ' + error);
|
|
}
|
|
}
|
|
|
|
private setupAnalyser(): void {
|
|
if (!this.audioNode || !this.audioContext) {
|
|
this.log('Warning: Audio node not available yet');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (!this.gainNode) {
|
|
this.setupGainNode();
|
|
}
|
|
|
|
this.scopeNode = this.audioContext.createAnalyser();
|
|
this.scopeNode.fftSize = 2048;
|
|
this.scopeNode.smoothingTimeConstant = 0.3;
|
|
|
|
if (this.gainNode) {
|
|
this.gainNode.disconnect();
|
|
this.gainNode.connect(this.scopeNode);
|
|
this.scopeNode.connect(this.audioContext.destination);
|
|
}
|
|
|
|
this.log('Analyser node created and connected');
|
|
this.options.onAnalyserNodeCreated?.(this.scopeNode);
|
|
} catch (error) {
|
|
console.error('Failed to setup analyser:', error);
|
|
this.log('Error setting up analyser: ' + error);
|
|
}
|
|
}
|
|
|
|
getAnalyserNode(): AnalyserNode | null {
|
|
return this.scopeNode;
|
|
}
|
|
|
|
setVolume(value: number): void {
|
|
if (!this.gainNode) {
|
|
return;
|
|
}
|
|
this.gainNode.gain.value = Math.max(0, Math.min(1, value));
|
|
}
|
|
|
|
getVolume(): number {
|
|
if (!this.gainNode) {
|
|
return 1.0;
|
|
}
|
|
return this.gainNode.gain.value;
|
|
}
|
|
|
|
private log(message: string): void {
|
|
this.options.onMessage?.(message);
|
|
}
|
|
|
|
private error(message: string): void {
|
|
this.options.onError?.(message);
|
|
}
|
|
|
|
async destroy(): Promise<void> {
|
|
await this.cleanupInstance();
|
|
this.initialized = false;
|
|
}
|
|
}
|