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

268 lines (236 loc) 9.01 kB
import * as Comlink from "comlink"; import ScriptProcessorNodeWorker from "@root/workers/old-spn.worker"; import { logOldSpnMain as log } from "@root/logger"; import { messageEventHandler } from "@root/mains/messages.main"; import { WebkitAudioContext } from "@root/utils"; import { requestMidi } from "@utils/request-midi"; import { requestMicrophoneNode } from "@root/mains/io.utils"; // we reuse the spnWorker // since it handles multiple // audio Contexts via UID. let spnWorker; let proxyPort; let UID = 0; class ScriptProcessorNodeMainThread { constructor({ audioContext, audioContextIsProvided, autoConnect }) { this.autoConnect = autoConnect; this.audioContextIsProvided = audioContextIsProvided; this.audioContext = audioContext; this.currentPlayState = undefined; this.csoundWorkerMain = undefined; // never default these, get it from // csound-worker before starting this.sampleRate = undefined; this.inputsCount = undefined; this.outputsCount = undefined; this.hardwareBufferSize = undefined; this.softwareBufferSize = undefined; this.initIframe = this.initIframe.bind(this); this.initialize = this.initialize.bind(this); this.onPlayStateChange = this.onPlayStateChange.bind(this); this.scriptProcessorNode = true; log("ScriptProcessorNodeMainThread was constructed")(); } async terminateInstance() { delete this.onPlayStateChange; if (window[`__csound_wasm_iframe_parent_${this.contextUid}Node`]) { window[`__csound_wasm_iframe_parent_${this.contextUid}Node`].disconnect(); delete window[`__csound_wasm_iframe_parent_${this.contextUid}Node`].disconnect(); } if (this.audioContext) { if (this.audioContext.state !== "closed") { try { await this.audioContext.close(); } catch (error) {} } delete this.audioContext; } if (proxyPort) { proxyPort[Comlink.releaseProxy](); proxyPort = undefined; } if (this.iFrameElement) { this.iFrameElement.remove(); } spnWorker = undefined; UID = 0; Object.keys(this).forEach((key) => delete this[key]); } async onPlayStateChange(newPlayState) { if (this.currentPlayState === newPlayState) { return; } switch (newPlayState) { case "realtimePerformanceStarted": { log("event received: realtimePerformanceStarted")(); this.currentPlayState = newPlayState; await this.initialize(); if (this.csoundWorkerMain.startPromiz) { // hacky SAB timing fix when starting // eventually, replace this spaghetti with // private/internal event emitters const startPromiz = this.csoundWorkerMain.startPromiz; setTimeout(() => { startPromiz(); }, 0); delete this.csoundWorkerMain.startPromiz; } break; } case "realtimePerformanceEnded": { log("event received: realtimePerformanceEnded")(); if (window[`__csound_wasm_iframe_parent_${this.contextUid}Node`]) { window[`__csound_wasm_iframe_parent_${this.contextUid}Node`].disconnect(); delete window[`__csound_wasm_iframe_parent_${this.contextUid}Node`].disconnect(); } break; } default: { break; } } this.currentPlayState = newPlayState; if ( proxyPort && (newPlayState !== "realtimePerformanceStarted" || newPlayState !== "renderStarted") ) { await proxyPort.setPlayState({ contextUid: this.contextUid, newPlayState, }); } } async initIframe() { // HACK FROM (but it works just fine when adding modern security models) // https://github.com/GoogleChromeLabs/audioworklet-polyfill/blob/274792e5e3d189e04c9496bed24129118539b4b5/src/realm.js#L18-L20 if (typeof window === "undefined" || typeof window.document === "undefined") { throw new TypeError("Can only run SPN in Browser scope"); } const parentScope = window.document; // eslint-disable-next-line unicorn/prevent-abbreviations const iFrameHtml = [ `<!doctype html>`, `<html lang="en">`, `<head>`, `<meta charset="UTF-8">`, `</head>`, `<body>`, `<script type="text/javascript" src="${ScriptProcessorNodeWorker()}"></script>`, `</body>`, ].join("\n"); // eslint-disable-next-line unicorn/prevent-abbreviations const iFrameBlob = new Blob([iFrameHtml], { type: "text/html" }); this.iFrameElement = document.createElement("iframe"); this.iFrameElement.src = URL.createObjectURL(iFrameBlob); this.iFrameElement.sandbox.add("allow-scripts", "allow-same-origin"); this.iFrameElement.style.cssText = "position:absolute;left:0;top:-999px;width:1px;height:1px;"; // appending early to have access to contentWindow // eslint-disable-next-line unicorn/prevent-abbreviations const iFrameOnLoad = new Promise((resolve) => { // eslint-disable-next-line unicorn/prefer-add-event-listener this.iFrameElement.onload = () => { resolve(); }; }); parentScope.body.append(this.iFrameElement); try { await iFrameOnLoad; } catch (error) { console.error(error); } spnWorker = this.iFrameElement.contentWindow; } async initialize() { if (!spnWorker) { await this.initIframe(); if (!spnWorker) { console.error("SPN FATAL: Couldn't create iFrame"); return; } } const contextUid = `audioWorklet${UID}`; this.contextUid = contextUid; UID += 1; if (!proxyPort) { proxyPort = Comlink.wrap(Comlink.windowEndpoint(spnWorker)); } if (!this.audioContext) { if (this.audioContextIsProvided) { console.error(`fatal: the provided AudioContext was undefined`); } this.audioContext = new (WebkitAudioContext())({ sampleRate: this.sampleRate }); } if (this.audioContext.state === "closed") { if (this.audioContextIsProvided) { console.error(`fatal: the provided AudioContext was closed, falling back new AudioContext`); } this.audioContext = new (WebkitAudioContext())({ sampleRate: this.sampleRate }); } if (!this.audioContextIsProvided && this.sampleRate !== this.audioContext.sampleRate) { this.audioContext = new (WebkitAudioContext())({ sampleRate: this.sampleRate }); } // just set it both on parent and iframe // since 1 works on linux and other one on mac // leaking globals indeed spnWorker[contextUid] = this.audioContext; window[`__csound_wasm_iframe_parent_${contextUid}`] = this.audioContext; // const { port1: mainMessagePort, port2: workerMessagePort } = new MessageChannel(); let liveInput; if (this.isRequestingInput) { await new Promise((resolve) => { const microphoneCallback = (stream) => { if (stream) { liveInput = this.audioContext.createMediaStreamSource(stream); } resolve(); }; requestMicrophoneNode(microphoneCallback); }); } await proxyPort.initialize( Comlink.transfer( { contextUid, hardwareBufferSize: 32768, softwareBufferSize: 2048, inputsCount: this.inputsCount, outputsCount: this.outputsCount, sampleRate: this.sampleRate, audioInputPort: this.ipcMessagePorts.audioWorkerAudioInputPort, messagePort: this.ipcMessagePorts.workerMessagePort, requestPort: this.ipcMessagePorts.audioWorkerFrameRequestPort, audioContextIsProvided: this.audioContextIsProvided, autoConnect: this.autoConnect, initialPlayState: this.currentPlayState, }, [ this.ipcMessagePorts.audioWorkerAudioInputPort, this.ipcMessagePorts.workerMessagePort, this.ipcMessagePorts.audioWorkerFrameRequestPort, ], ), ); this.ipcMessagePorts.mainMessagePort.addEventListener("message", messageEventHandler(this)); this.ipcMessagePorts.mainMessagePort.start(); if (this.csoundWorkerMain && this.csoundWorkerMain.publicEvents) { const audioNode = spnWorker[`${contextUid}Node`] || window[`__csound_wasm_iframe_parent_${contextUid}Node`]; audioNode && liveInput && liveInput.connect(audioNode); if ( audioNode && this.csoundWorkerMain && this.csoundWorkerMain.publicEvents && this.csoundWorkerMain.publicEvents.triggerOnAudioNodeCreated ) { this.csoundWorkerMain.publicEvents.triggerOnAudioNodeCreated(audioNode); } } if (this.isRequestingMidi && this.csoundWorkerMain && this.csoundWorkerMain.handleMidiInput) { log("requesting for web-midi connection"); requestMidi({ onMidiMessage: this.csoundWorkerMain.handleMidiInput.bind(this.csoundWorkerMain), }); } } } export default ScriptProcessorNodeMainThread;