@doc.e.dub/csound-browser
Version:
[](https://www.npmjs.com/package/@csound/browser) [](h
510 lines (457 loc) • 18.3 kB
JavaScript
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;