UNPKG

@doc.e.dub/csound-browser

Version:

[![npm (scoped with tag)](https://shields.shivering-isles.com/npm/v/@csound/browser/latest)](https://www.npmjs.com/package/@csound/browser) [![GitHub Workflow Status](https://shields.shivering-isles.com/github/workflow/status/csound/csound/csound_wasm)](h

510 lines (457 loc) 18.3 kB
import * as Comlink from "comlink"; import { api as API } from "@root/libcsound"; import { messageEventHandler, IPCMessagePorts } from "@root/mains/messages.main"; import SABWorker from "@root/workers/sab.worker"; import { AUDIO_STATE, MAX_CHANNELS, RING_BUFFER_SIZE, MIDI_BUFFER_PAYLOAD_SIZE, MIDI_BUFFER_SIZE, initialSharedState, } from "@root/constants"; import { logSABMain as log } from "@root/logger"; import { isEmpty } from "ramda"; import { csoundApiRename, fetchPlugins, makeProxyCallback, stopableStates } from "@root/utils"; import { EventPromises } from "@utils/event-promises"; import { PublicEventAPI } from "@root/events"; import { clearFsLastmods, persistentFilesystem, syncPersistentStorage, } from "@root/filesystem/persistent-fs"; class SharedArrayBufferMainThread { constructor({ audioContext, audioWorker, wasmDataURI, audioContextIsProvided, inputChannelCount, outputChannelCount, }) { this.hasSharedArrayBuffer = true; this.ipcMessagePorts = new IPCMessagePorts(); this.eventPromises = new EventPromises(); this.publicEvents = new PublicEventAPI(this); audioWorker.ipcMessagePorts = this.ipcMessagePorts; this.audioContextIsProvided = audioContextIsProvided; this.audioWorker = audioWorker; this.csoundInstance = undefined; this.wasmDataURI = wasmDataURI; this.currentPlayState = undefined; this.currentDerivedPlayState = "stop"; this.exportApi = {}; this.callbackId = 0; this.callbackBuffer = {}; this.audioStateBuffer = new SharedArrayBuffer( initialSharedState.length * Int32Array.BYTES_PER_ELEMENT, ); this.audioStatePointer = new Int32Array(this.audioStateBuffer); if (audioContextIsProvided) { Atomics.store(this.audioStatePointer, AUDIO_STATE.SAMPLE_RATE, audioContext.sampleRate); } if (inputChannelCount) { Atomics.store(this.audioStatePointer, AUDIO_STATE.NCHNLS_I, inputChannelCount); } if (outputChannelCount) { Atomics.store(this.audioStatePointer, AUDIO_STATE.NCHNLS, outputChannelCount); } this.audioStreamIn = new SharedArrayBuffer( MAX_CHANNELS * RING_BUFFER_SIZE * Float64Array.BYTES_PER_ELEMENT, ); this.audioStreamOut = new SharedArrayBuffer( MAX_CHANNELS * RING_BUFFER_SIZE * Float64Array.BYTES_PER_ELEMENT, ); this.midiBufferSAB = new SharedArrayBuffer( MIDI_BUFFER_SIZE * MIDI_BUFFER_PAYLOAD_SIZE * Int32Array.BYTES_PER_ELEMENT, ); this.midiBuffer = new Int32Array(this.midiBufferSAB); this.onPlayStateChange = this.onPlayStateChange.bind(this); log(`SharedArrayBufferMainThread got constructed`)(); } async terminateInstance() { if (this.csoundWorker) { this.csoundWorker.terminate(); delete this.csoundWorker; } if (this.audioWorker && this.audioWorker.terminateInstance) { await this.audioWorker.terminateInstance(); delete this.audioWorker.terminateInstance; } if (this.proxyPort) { this.proxyPort[Comlink.releaseProxy](); delete this.proxyPort; } if (this.publicEvents) { this.publicEvents.terminateInstance(); } Object.keys(this.exportApi).forEach((key) => delete this.exportApi[key]); Object.keys(this).forEach((key) => delete this[key]); } get api() { return this.exportApi; } handleMidiInput({ data: [status, data1, data2] }) { const currentQueueLength = Atomics.load( this.audioStatePointer, AUDIO_STATE.AVAIL_RTMIDI_EVENTS, ); const rtmidiBufferIndex = Atomics.load(this.audioStatePointer, AUDIO_STATE.RTMIDI_INDEX); const nextIndex = (currentQueueLength * MIDI_BUFFER_PAYLOAD_SIZE + rtmidiBufferIndex) % MIDI_BUFFER_SIZE; Atomics.store(this.midiBuffer, nextIndex, status); Atomics.store(this.midiBuffer, nextIndex + 1, data1); Atomics.store(this.midiBuffer, nextIndex + 2, data2); Atomics.add(this.audioStatePointer, AUDIO_STATE.AVAIL_RTMIDI_EVENTS, 1); } async csoundPause() { if ( Atomics.load(this.audioStatePointer, AUDIO_STATE.IS_PAUSED) !== 1 && Atomics.load(this.audioStatePointer, AUDIO_STATE.STOP) !== 1 && Atomics.load(this.audioStatePointer, AUDIO_STATE.IS_PERFORMING) === 1 ) { Atomics.store(this.audioStatePointer, AUDIO_STATE.IS_PAUSED, 1); this.onPlayStateChange("realtimePerformancePaused"); } } async csoundResume() { if ( Atomics.load(this.audioStatePointer, AUDIO_STATE.IS_PAUSED) === 1 && Atomics.load(this.audioStatePointer, AUDIO_STATE.STOP) !== 1 && Atomics.load(this.audioStatePointer, AUDIO_STATE.IS_PERFORMING) === 1 ) { Atomics.store(this.audioStatePointer, AUDIO_STATE.IS_PAUSED, 0); Atomics.notify(this.audioStatePointer, AUDIO_STATE.IS_PAUSED); this.onPlayStateChange("realtimePerformanceResumed"); } } async onPlayStateChange(newPlayState) { this.currentPlayState = newPlayState; if (!this.publicEvents || !newPlayState) { // prevent late timers from calling terminated fn return; } switch (newPlayState) { case "realtimePerformanceStarted": { log( `event: realtimePerformanceStarted received,` + ` proceeding to call prepareRealtimePerformance`, )(); await this.prepareRealtimePerformance(); this.publicEvents.triggerRealtimePerformanceStarted(this); break; } case "realtimePerformanceEnded": { this.eventPromises.createStopPromise(); syncPersistentStorage(await this.getWorkerFs()); clearFsLastmods(); // flush out events sent during the time which the worker was stopping Object.values(this.callbackBuffer).forEach(({ argumentz, apiKey, resolveCallback }) => this.proxyPort.callUncloned(apiKey, argumentz).then(resolveCallback), ); this.callbackBuffer = {}; log(`event: realtimePerformanceEnded received, beginning cleanup`)(); // re-initialize SAB initialSharedState.forEach((value, index) => { Atomics.store(this.audioStatePointer, index, value); }); break; } case "realtimePerformancePaused": { this.publicEvents.triggerRealtimePerformancePaused(this); break; } case "realtimePerformanceResumed": { this.publicEvents.triggerRealtimePerformanceResumed(this); break; } case "renderStarted": { this.publicEvents.triggerRenderStarted(this); break; } case "renderEnded": { syncPersistentStorage(await this.getWorkerFs()); this.publicEvents.triggerRenderEnded(this); clearFsLastmods(); log(`event: renderEnded received, beginning cleanup`)(); break; } default: { break; } } // forward the message from worker to the audioWorker try { await this.audioWorker.onPlayStateChange(newPlayState); } catch (error) { console.error(error); } } async prepareRealtimePerformance() { log(`prepareRealtimePerformance`)(); const outputsCount = Atomics.load(this.audioStatePointer, AUDIO_STATE.NCHNLS); const inputCount = Atomics.load(this.audioStatePointer, AUDIO_STATE.NCHNLS_I); this.audioWorker.isRequestingInput = Atomics.load( this.audioStatePointer, AUDIO_STATE.IS_REQUESTING_MIC, ); this.audioWorker.isRequestingMidi = Atomics.load( this.audioStatePointer, AUDIO_STATE.IS_REQUESTING_RTMIDI, ); const ksmps = Atomics.load(this.audioStatePointer, AUDIO_STATE.KSMPS); const sampleRate = Atomics.load(this.audioStatePointer, AUDIO_STATE.SAMPLE_RATE); this.audioWorker.ksmps = ksmps; this.audioWorker.sampleRate = sampleRate; this.audioWorker.inputCount = inputCount; this.audioWorker.outputsCount = outputsCount; } async initialize({ withPlugins }) { if (withPlugins && !isEmpty(withPlugins)) { withPlugins = await fetchPlugins(withPlugins); } log(`initialization: instantiate the SABWorker Thread`)(); clearFsLastmods(); const csoundWorker = new Worker(SABWorker()); this.csoundWorker = csoundWorker; const audioStateBuffer = this.audioStateBuffer; const audioStatePointer = this.audioStatePointer; const audioStreamIn = this.audioStreamIn; const audioStreamOut = this.audioStreamOut; const midiBuffer = this.midiBuffer; log(`providing the audioWorker a pointer to SABMain's instance`)(); this.audioWorker.csoundWorkerMain = this; // both audio worker and csound worker use 1 handler // simplifies flow of data (csound main.worker is always first to receive) log(`adding message eventListeners for mainMessagePort and mainMessagePortAudio`)(); this.ipcMessagePorts.mainMessagePort.addEventListener("message", messageEventHandler(this)); this.ipcMessagePorts.mainMessagePort.start(); this.ipcMessagePorts.mainMessagePortAudio.addEventListener( "message", messageEventHandler(this), ); this.ipcMessagePorts.mainMessagePortAudio.start(); log(`(postMessage) making a message channel from SABMain to SABWorker via workerMessagePort`)(); this.ipcMessagePorts.sabMainCallbackReply.addEventListener("message", (event) => { if (event.data === "poll") { this.ipcMessagePorts && this.ipcMessagePorts.sabMainCallbackReply.postMessage( Object.keys(this.callbackBuffer).map((id) => ({ id, apiKey: this.callbackBuffer[id].apiKey, argumentz: this.callbackBuffer[id].argumentz, })), ); } else if (event.data === "releaseStop") { this.onPlayStateChange( this.currentPlayState === "renderStarted" ? "renderEnded" : "realtimePerformanceEnded", ); this.eventPromises.releaseStopPromises(); this.publicEvents.triggerRealtimePerformanceEnded(this); } else { event.data.forEach(({ id, answer }) => { this.callbackBuffer[id].resolveCallback(answer); delete this.callbackBuffer[id]; }); } }); this.ipcMessagePorts.sabMainCallbackReply.start(); const proxyPort = Comlink.wrap(csoundWorker); this.proxyPort = proxyPort; const csoundInstance = await proxyPort.initialize( Comlink.transfer( { wasmDataURI: this.wasmDataURI, messagePort: this.ipcMessagePorts.workerMessagePort, callbackPort: this.ipcMessagePorts.sabWorkerCallbackReply, withPlugins, }, [this.ipcMessagePorts.workerMessagePort, this.ipcMessagePorts.sabWorkerCallbackReply], ), ); this.csoundInstance = csoundInstance; this.ipcMessagePorts.mainMessagePort.start(); this.ipcMessagePorts.mainMessagePortAudio.start(); log(`A proxy port from SABMain to SABWorker established`)(); this.exportApi.pause = this.csoundPause.bind(this); this.exportApi.resume = this.csoundResume.bind(this); this.exportApi.terminateInstance = this.terminateInstance.bind(this); this.exportApi.fs = persistentFilesystem; // sync/getWorkerFs is only for internal usage this.getWorkerFs = makeProxyCallback( proxyPort, csoundInstance, "getWorkerFs", this.currentPlayState, ); this.getWorkerFs = this.getWorkerFs.bind(this); this.exportApi.enableAudioInput = () => console.warn( `enableAudioInput was ignored: please use -iadc option before calling start with useWorker=true`, ); this.exportApi.getNode = async () => { const maybeNode = this.audioWorker.audioWorkletNode; if (maybeNode) { return maybeNode; } else { const node = await new Promise((resolve) => { this.exportApi.once("onAudioNodeCreated", resolve); }); return node; } }; this.exportApi.getAudioContext = async () => this.audioWorker.audioContext; this.exportApi = this.publicEvents.decorateAPI(this.exportApi); // the default message listener this.exportApi.addListener("message", console.log); for (const apiKey of Object.keys(API)) { const proxyCallback = makeProxyCallback( proxyPort, csoundInstance, apiKey, this.currentPlayState, ); const reference = API[apiKey]; switch (apiKey) { case "csoundCreate": { break; } case "csoundStart": { const csoundStart = async function () { if (!csoundInstance || typeof csoundInstance !== "number") { console.error("starting csound failed because csound instance wasn't created"); return -1; } this.eventPromises.createStartPromise(); const startResult = await proxyCallback({ audioStateBuffer, audioStreamIn, audioStreamOut, midiBuffer, csound: csoundInstance, }); await this.eventPromises.waitForStart(); this.ipcMessagePorts && this.ipcMessagePorts.sabMainCallbackReply.postMessage({ unlock: true }); return startResult; }; csoundStart.toString = () => reference.toString(); this.exportApi.start = csoundStart.bind(this); break; } case "csoundStop": { const csoundStop = async () => { log( [ "Checking if it's safe to call stop:", stopableStates.has(this.currentPlayState), "currentPlayState is", this.currentPlayState, ].join("\n"), )(); if (this.eventPromises.isWaitingToStop()) { log("already waiting to stop, doing nothing")(); return -1; } else if (stopableStates.has(this.currentPlayState)) { log("Marking SAB's state to STOP")(); this.eventPromises.createStopPromise(); Atomics.store(this.audioStatePointer, AUDIO_STATE.STOP, 1); log("Marking that performance is not running anymore (stops the audio too)")(); Atomics.store(this.audioStatePointer, AUDIO_STATE.IS_PERFORMING, 0); // A potential case where the thread is locked because of pause if (this.currentPlayState === "realtimePerformancePaused") { Atomics.store(this.audioStatePointer, AUDIO_STATE.IS_PAUSED, 0); Atomics.notify(this.audioStatePointer, AUDIO_STATE.IS_PAUSED); } if (this.currentPlayState !== "renderStarted") { !Atomics.compareExchange(this.audioStatePointer, AUDIO_STATE.CSOUND_LOCK, 0, 1) && Atomics.notify(this.audioStatePointer, AUDIO_STATE.CSOUND_LOCK); } await this.eventPromises.waitForStop(); return 0; } else { return -1; } }; this.exportApi.stop = csoundStop.bind(this); csoundStop.toString = () => reference.toString(); break; } case "csoundReset": { const csoundReset = async () => { // no start = noReset if (!this.currentPlayState) { return; } if (stopableStates.has(this.currentPlayState)) { await this.exportApi.stop(); } else if (this.eventPromises.isWaitingToStop()) { await this.eventPromises.waitForStop(); } this.ipcMessagePorts.restartAudioWorkerPorts(); if (!this.audioContextIsProvided) { await this.audioWorker.terminateInstance(); delete this.audioWorker.audioContext; } const resetResult = await proxyCallback([]); return resetResult; }; this.exportApi.reset = csoundReset.bind(this); csoundReset.toString = () => reference.toString(); break; } case "csoundPushMidiMessage": { const midiMessage = async (status = 0, data1 = 0, data2 = 0) => { this.handleMidiInput({ data: [status, data1, data2] }); }; this.exportApi.midiMessage = midiMessage.bind(this); midiMessage.toString = () => reference.toString(); break; } default: { // avoiding deadlock by sending the IPC callback // while thread is unlocked const bufferWrappedCallback = async (...arguments_) => { if ( this.currentPlayState === "realtimePerformanceStarted" || this.currentPlayState === "renderStarted" || this.eventPromises.isWaitingToStart() // startPromiz indicates that startup is in progress // and any events send before it's resolved are swallowed ) { const callbackId = this.callbackId; this.callbackId += 1; const returnPromise = new Promise((resolve, reject) => { const timeout = setTimeout( () => reject( new Error(`Worker timed out so ${csoundApiRename(apiKey)}() wasn't called!`), ), 10000, ); const resolveCallback = (answer) => { clearTimeout(timeout); resolve(answer); }; this.callbackBuffer[callbackId] = { resolveCallback, apiKey, argumentz: [csoundInstance, ...arguments_], }; }); Atomics.compareExchange(audioStatePointer, AUDIO_STATE.HAS_PENDING_CALLBACKS, 0, 1); return await returnPromise; } else { return await proxyCallback.apply(undefined, arguments_); } }; bufferWrappedCallback.toString = () => reference.toString(); this.exportApi[csoundApiRename(apiKey)] = bufferWrappedCallback; break; } } } log(`PUBLIC API Generated and stored`)(); } } export default SharedArrayBufferMainThread;