import {
counter,
decibelToLinear,
sleep,
isFunction,
isString,
} from '@ircam/sc-utils';
import {
GainNode
} from 'isomorphic-web-audio-api';
/**
* Basically wrap 3 different states
* - source
* - script
* - optional shared state defined in script
* @private
*/
const idGenerator = counter();
/**
* The `Player` class represents a full-featured player instance defined as a link
* between a {@link ComoSource} and a {@link ComoScript}.
*
* A player is always instantiated on a given {@ComoNode}, although it si possible to
* create a mirror of a Player on a different node.
*/
class Player {
#como;
#sourceId;
#state;
#source;
#requestedId = null;
#script = null; // the script instance from the plugin
#scriptModule = null; // the JS module as imported from the script
#scriptSharedState = null; // the shared state defined by the script
#scriptContext = null; // Context object passed to all script public interface
#scriptErrored = false; // Wether the script thrown an error during its lifetime
#unsubscribeSource = null; // The subscription to the motion source
#scriptLastState = null;
#unsubscribeSession = null;
// audio
#muteNode;
#volumeNode;
#outputNode;
#sessionSoundbank = null;
/**
* @hideconstructor
* @param {*} como
* @param {*} sourceId
*/
constructor(como, sourceId, id = null) {
// @todo - check arguments
this.#como = como;
this.#sourceId = sourceId;
this.#requestedId = id;
this.#muteNode = new GainNode(this.#como.audioContext);
this.#volumeNode = new GainNode(this.#como.audioContext);
this.#outputNode = new GainNode(this.#como.audioContext);
this.#muteNode
.connect(this.#volumeNode)
.connect(this.#outputNode)
.connect(this.#como.audioContext.destination);
}
/**
* Id of the player
* @type {String}
*/
get id() {
return this.state.get('id');
}
/**
* Id of the como node
* @type {String}
*/
get nodeId() {
return this.state.get('nodeId');
}
/**
* Source of the player
* @type {SharedState}
*/
get source() {
return this.#source;
}
/**
* Underlying state of the player.
* @type {SharedState}
*/
get state() {
return this.#state;
}
/**
* Reconnect output node to destination.
* Allows to have a script running outside a session.
* @private
*/
#reconnectDestination() {
// re-connect
const fadeInTime = this.#como.audioContext.currentTime;
this.#outputNode.connect(this.#como.audioContext.destination);
this.#outputNode.gain.setValueAtTime(0, fadeInTime);
this.#outputNode.gain.linearRampToValueAtTime(1, fadeInTime + 0.01);
}
/** @private */
async init(withState = null) {
if (!this.#como.sourceManager.sourceExists(this.#sourceId)) {
throw new Error(`Cannot execute "createPlayer" on PlayerManager: source with id ${this.#sourceId} does not exists`);
}
this.#source = await this.#como.sourceManager.getSource(this.#sourceId);
// this is the difference between "real" and "duplicated" clients
if (withState) {
this.#state = withState;
this.#state.onDetach(async () => await this.delete());
} else {
let playerId;
if (this.#requestedId === null) {
playerId = `${this.#como.id}-${idGenerator()}`
} else {
if (!isString(this.#requestedId)) {
throw new Error(`Cannot execute "createPlayer" on PlayerManager: requested player id is not a string`);
}
playerId = this.#requestedId;
}
this.#state = await this.#como.stateManager.create(`${this.#como.playerManager.name}:player`, {
id: playerId,
nodeId: this.#como.nodeId,
sourceId: this.source.get('id'),
});
}
this.state.onUpdate(async updates => {
for (let [key, value] of Object.entries(updates)) {
switch (key) {
case 'scriptName': {
await this.setScript(value);
break;
}
case 'sessionId': {
await this.state.set({ sessionLoading: true });
const sessionId = value;
// disconnect from current output, be it a session bus or the destination
const fadeOutTime = this.#como.audioContext.currentTime;
this.#outputNode.gain.setValueAtTime(1, fadeOutTime);
this.#outputNode.gain.linearRampToValueAtTime(0, fadeOutTime + 0.01);
await sleep(0.01);
this.#outputNode.disconnect();
if (this.#unsubscribeSession) {
this.#unsubscribeSession();
}
if (sessionId === null) {
this.#reconnectDestination();
await this.state.set({ scriptName: null, sessionLoading: false });
break; // nothing left to do
}
const session = this.#como.sessionManager.getSession(sessionId);
if (!session) {
console.log(`Cannot attach player ${this.state.get('id')} to session ${sessionId}: session does not exists`);
this.#reconnectDestination();
this.state.set({ scriptName: null, sessionId: null, sessionLoading: false });
break;
}
// load session files
this.#sessionSoundbank = await this.#como.sessionManager.getSessionSoundbank(sessionId);
// load default script
const defaultScript = session.get('defaultScript');
await this.setScript(defaultScript);
const sessionBus = this.#como.sessionManager.getSessionBus(sessionId);
const fadeInTime = this.#como.audioContext.currentTime;
// connect to session bus
this.#outputNode.connect(sessionBus);
this.#outputNode.gain.setValueAtTime(0, fadeInTime);
this.#outputNode.gain.linearRampToValueAtTime(1, fadeInTime + 0.01);
await this.state.set({ sessionLoading: false });
// handle session changes that must propagate to the player script
this.#unsubscribeSession = session.onUpdate(async updates => {
for (let [name, value] of Object.entries(updates)) {
switch (name) {
case 'defaultScript': {
await this.setScript(value);
break;
}
case 'soundbank': {
const sessionId = session.get('uuid');
this.#sessionSoundbank = await this.#como.sessionManager.getSessionSoundbank(sessionId);
await this.#reloadScript();
}
}
}
});
break;
}
case 'mute': {
const gain = value ? 0 : 1;
this.#muteNode.gain.setTargetAtTime(gain, this.#como.audioContext.currentTime, 0.003);
break;
}
case 'volume': {
const gain = decibelToLinear(value);
this.#volumeNode.gain.setTargetAtTime(gain, this.#como.audioContext.currentTime, 0.003);
break;
}
}
}
});
}
/**
* Delete the player
*/
async delete() {
// if the source is not owned we can delete it safely
if (!this.#source.isOwned) {
await this.#source.detach();
}
// Only delete "real" state, not attached ones.
// They must still live in the stateManager collection.
if (this.#state.isOwned) {
await this.#state.delete();
}
}
/**
* Set the script associated to this player
* @param {String} [scriptName=null] - Name of the script. If null just exit
* from current script.
*/
async setScript(scriptName = null) {
if (this.#state.get('scriptName') !== scriptName) {
// Keep state in sync if method is called directly
// Note that this will resolve after the onUpdate execution
await this.#state.set('scriptName', scriptName);
return;
}
if (this.#script) {
await this.#releaseScript();
await this.#script.detach();
}
this.#script = null;
// explicitly set last state to null as we change the script
this.#scriptLastState = null;
if (scriptName == null) {
return;
}
this.#script = await this.#como.scriptManager.attach(scriptName);
this.#script.onUpdate(async updates => {
if ('runtimeError' in updates && updates.runtimeError !== null) {
// silently release the script, we don't want to pack up runtime errors
console.log(updates.runtimeError);
console.log('releasing script');
this.#releaseScript({ silent: true });
return;
}
// make sure the script actually changed to prevent infinite loops on error
if (this.#como.runtime === 'node' && 'nodeBuild' in updates) {
await this.#reloadScript();
}
if (this.#como.runtime === 'browser' && 'browserBuild' in updates) {
await this.#reloadScript();
}
});
return this.#reloadScript();
}
/** @private */
async #releaseScript({ silent = false } = {}) {
// stop listening from the source
if (this.#unsubscribeSource) {
this.#unsubscribeSource();
}
// exit current script
// if no build was found, we may not have any script module
if (this.#scriptModule && isFunction(this.#scriptModule.exit)) {
// if we don't have any script context, this means that something
// failed before enter so no need to exit
try {
await this.#scriptModule.exit(this.#scriptContext);
} catch (err) {
// if the script errored at its initialization, this is likely that
// disconnect will crash too, so we almost ignore this error which may be
// confusing for the user.
if (!silent) {
this.#script.reportRuntimeError(err);
if (this.#scriptErrored) {
console.log('> note that the script errored at its initialized, it is likely that you can ignore this error');
}
}
// Note that we don't want to return at this point
}
}
// fade out and disconnect audio output
if (this.#scriptContext?.outputNode) {
const now = this.#como.audioContext.currentTime;
this.#scriptContext.outputNode.gain.setValueAtTime(1, now);
this.#scriptContext.outputNode.gain.linearRampToValueAtTime(0, now + 0.01);
await sleep(0.01 + 128 / this.#como.audioContext.sampleRate);
this.#scriptContext.outputNode.disconnect();
}
// clean script shared state if any
if (this.#scriptSharedState !== null) {
await this.#scriptSharedState.delete();
const scriptSharedStateClassName = this.state.get('scriptSharedStateClassName');
// clean state
await this.state.set({
scriptSharedStateClassName: null,
scriptSharedStateId: null,
});
// clean class
this.#como.requestRfc(
this.#como.constants.SERVER_ID,
`${this.#como.playerManager.name}:deleteSharedStateClass`,
{ scriptSharedStateClassName }
)
}
this.#scriptModule = null;
this.#scriptSharedState = null;
this.#scriptContext = null;
}
/** @private */
async #reloadScript() {
// keep current shared state values to maintain state after reload
// note that when the script changes, this is explicitly set to null
if (this.#scriptSharedState) {
this.#scriptLastState = {
description: this.#scriptSharedState.getDescription(),
values: this.#scriptSharedState.getValues(),
}
}
// release old version of the script
await this.#releaseScript();
// import script and check API
try {
this.#scriptModule = await this.#script.import();
} catch (err) {
this.#script.reportRuntimeError(err);
this.#scriptErrored = true;
}
// return if no import was found
if (this.#scriptModule === null) {
return;
}
// check script API contract
if (this.#scriptModule.defineSharedState && !isFunction(this.#scriptModule.defineSharedState)) {
this.#script.reportRuntimeError(new Error(`Cannot execute script ${this.#script.name}: Invalid API: 'defineSharedState' is not a function`));
this.#scriptErrored = true;
return;
}
if (this.#scriptModule.enter && !isFunction(this.#scriptModule.enter)) {
this.#script.reportRuntimeError(new Error(`Cannot execute script ${this.#script.name}: Invalid API: 'enter' is not a function`));
this.#scriptErrored = true;
return;
}
if (this.#scriptModule.exit && !isFunction(this.#scriptModule.exit)) {
this.#script.reportRuntimeError(new Error(`Cannot execute script ${this.#script.name}: Invalid API: 'exit' is not a function`));
this.#scriptErrored = true;
return;
}
if (this.#scriptModule.process && !isFunction(this.#scriptModule.process)) {
this.#script.reportRuntimeError(new Error(`Cannot execute script ${this.#script.name}: Invalid API: 'process' is not a function`));
this.#scriptErrored = true;
return;
}
// Handle script shared state
// create shared state for this script if any
if (this.#scriptModule.defineSharedState) {
// 1. validate class description
let {
classDescription,
initValues,
} = await this.#scriptModule.defineSharedState();
try {
this.#como.stateManager.validateClassDescription(classDescription);
} catch (err) {
this.#script.reportRuntimeError(new Error(`Cannot execute script ${this.#script.name}: Invalid shared state definition: ${err.message}`));
this.#scriptErrored = true;
return;
}
// 2. define shared state class
let className;
try {
className = await this.#como.requestRfc(
this.#como.constants.SERVER_ID,
`${this.#como.playerManager.name}:defineSharedStateClass`,
{
scriptName: this.#script.name,
classDescription,
}
);
} catch (err) {
this.#script.reportRuntimeError(err);
this.#scriptErrored = true;
return;
}
// 3. merge init values from last script instance
if (!initValues) {
initValues = {};
// if no init values have been explicitly defined in the script
// try to propagate the values from last state instance to the new one
if (this.#scriptLastState !== null) {
for (let key in classDescription) {
if (key in this.#scriptLastState.values) {
// use value from last state only if default is the same
if (classDescription[key].default === this.#scriptLastState.description[key].default) {
initValues[key] = this.#scriptLastState.values[key];
}
}
}
}
}
// 4. create script shared state
// @todo - Proxy this.#scriptSharedState to report runtime errors
this.#scriptSharedState = await this.#como.stateManager.create(className, initValues);
// override onUpdate to wrap given callbacks in a try catch block
// const originalOnUpdate = this.#scriptSharedState.onUpdate;
// this.#scriptSharedState.onUpdate = (callback, executeListener) => {
// const wrappedCallback = (...args) => {
// try {
// callback(...args);
// } catch (err) {
// this.#script.reportRuntimeError(err);
// this.#scriptErrored = true;
// }
// }
// return originalOnUpdate.call(this.#scriptSharedState, wrappedCallback, executeListener);
// }
// 5. propagate shared state infos
this.#state.set({
scriptSharedStateClassName: this.#scriptSharedState.className,
scriptSharedStateId: this.#scriptSharedState.id,
scriptLoaded: true,
});
}
// Build audio graph for this script instance
const outputNode = new GainNode(this.#como.audioContext, { gain: 0 });
outputNode.connect(this.#muteNode);
// create context object for this script instance
this.#scriptContext = {
output: outputNode,
state: this.#scriptSharedState,
soundbank: this.#sessionSoundbank,
scriptName: this.#script.name,
};
// enter the script
if (isFunction(this.#scriptModule.enter)) {
try {
await this.#scriptModule.enter(this.#scriptContext);
} catch (err) {
console.log(err.message.slice(0, 200));
process.exit(0);
// this.#script.reportRuntimeError(err);
// this.#scriptErrored = true;
// return;
}
}
// subscribe to the player's motion source
this.#unsubscribeSource = this.#source.onUpdate(async updates => {
if ('frame' in updates) {
if (isFunction(this.#scriptModule.process)) {
try {
this.#scriptModule.process(this.#scriptContext, updates.frame);
} catch (err) {
this.#script.reportRuntimeError(err);
this.#scriptErrored = true;
return;
}
}
}
});
// fade in output
const now = this.#como.audioContext.currentTime;
outputNode.gain.setValueAtTime(0, now);
outputNode.gain.linearRampToValueAtTime(1, now + 0.01);
}
}
export default Player;