UNPKG

@polygonjs/polygonjs

Version:

node-based WebGL 3D engine https://polygonjs.com

406 lines (381 loc) 11 kB
/** * imports an audio file * * */ import {TypedAudioNode} from './_Base'; import {NodeParamsConfig, ParamConfig} from '../utils/params/ParamsConfig'; import {AudioBuilder} from '../../../core/audio/AudioBuilder'; import {Player} from 'tone/build/esm/source/buffer/Player'; import {CoreLoaderAudio} from '../../../core/loader/Audio'; import {isBooleanTrue} from '../../../core/Type'; import {BaseNodeType} from '../_Base'; import {Poly} from '../../Poly'; import { AudioPlayerCallbacksManager, OnBeforePlayCallback, OnPlaySuccessCallback, OnPlayErrorCallback, OnStopCallback, PlayerCallbacks, PlayerEventName, CallbackByEventName, } from './../../../core/audio/PlayerCallbacksManager'; import {AudioType} from '../../poly/registers/nodes/types/Audio'; import {EXTENSIONS_BY_NODE_TYPE_BY_CONTEXT} from '../../../core/loader/FileExtensionRegister'; import {NodeContext} from '../../poly/NodeContext'; const EPSILON = 1e-6; const LOOP_OPTIONS = { cook: false, callback: (node: BaseNodeType) => { FileAudioNode.PARAM_CALLBACK_updateLoop(node as FileAudioNode); }, }; class FileAudioParamsConfig extends NodeParamsConfig { /** @param url to fetch the audio file from */ url = ParamConfig.STRING('', { fileBrowse: {extensions: EXTENSIONS_BY_NODE_TYPE_BY_CONTEXT[NodeContext.AUDIO][AudioType.FILE]}, }); /** @param auto start */ autostart = ParamConfig.BOOLEAN(1); /** @param duration */ duration = ParamConfig.FLOAT(-1, { cook: false, editable: false, }); /** @param display currentTime param */ updateCurrentTimeParam = ParamConfig.BOOLEAN(0, { cook: false, callback: (node: BaseNodeType) => { FileAudioNode.PARAM_CALLBACK_updateUpdateCurrentTimeParam(node as FileAudioNode); }, }); /** @param currentTime */ currentTime = ParamConfig.FLOAT(0, { visibleIf: {updateCurrentTimeParam: 1}, range: [0, 100], editable: false, cook: false, }); /** @param loop */ loop = ParamConfig.BOOLEAN(1, { ...LOOP_OPTIONS, }); /** @param useLoopRange */ // useLoopRange = ParamConfig.BOOLEAN(0, { // visibleIf: {loop: 1}, // ...LOOP_OPTIONS, // }); /** @param loop Range */ // loopRange = ParamConfig.VECTOR2([-1, -1], { // visibleIf: {loop: 1, useLoopRange: 1}, // ...LOOP_OPTIONS, // }); /** @param play the audio */ play = ParamConfig.BUTTON(null, { callback: (node: BaseNodeType) => { FileAudioNode.PARAM_CALLBACK_play(node as FileAudioNode); }, hidden: true, }); /** @param stop the audio */ pause = ParamConfig.BUTTON(null, { callback: (node: BaseNodeType) => { FileAudioNode.PARAM_CALLBACK_pause(node as FileAudioNode); }, hidden: true, }); /** @param restart the audio */ restart = ParamConfig.BUTTON(null, { callback: (node: BaseNodeType) => { FileAudioNode.PARAM_CALLBACK_restart(node as FileAudioNode); }, hidden: true, }); /** @param seek 10 seconds back */ seekM10 = ParamConfig.BUTTON(null, { callback: (node: BaseNodeType) => { FileAudioNode.PARAM_CALLBACK_seekOffset(node as FileAudioNode, -10); }, hidden: true, }); /** @param seek 5 seconds back */ seekM5 = ParamConfig.BUTTON(null, { callback: (node: BaseNodeType) => { FileAudioNode.PARAM_CALLBACK_seekOffset(node as FileAudioNode, -5); }, hidden: true, }); /** @param seek 5 seconds forward */ seekP5 = ParamConfig.BUTTON(null, { callback: (node: BaseNodeType) => { FileAudioNode.PARAM_CALLBACK_seekOffset(node as FileAudioNode, +5); }, hidden: true, }); /** @param seek 10 seconds forward */ seekP10 = ParamConfig.BUTTON(null, { callback: (node: BaseNodeType) => { FileAudioNode.PARAM_CALLBACK_seekOffset(node as FileAudioNode, +10); }, hidden: true, }); } const ParamsConfig = new FileAudioParamsConfig(); export class FileAudioNode extends TypedAudioNode<FileAudioParamsConfig> { override paramsConfig = ParamsConfig; static override type() { return AudioType.FILE; } override initializeNode() { this.io.inputs.setCount(0); } override dispose(): void { super.dispose(); if (this._player) { this.pause(); this._player.dispose(); } Poly.blobs.clearBlobsForNode(this); } private _startedAt: number | undefined; private _stoppedAt: number = 0; override async cook(inputContents: AudioBuilder[]) { await this._loadUrl(); this._updateOnTickHook(); if (this._player) { const audioBuilder = new AudioBuilder(); audioBuilder.setSource(this._player); this.setAudioBuilder(audioBuilder); } else { this.cookController.endCook(); } } private _player: Player | undefined; private async _loadUrl(): Promise<Player | void> { try { const loader = new CoreLoaderAudio(this.pv.url, this); const buffer: AudioBuffer = await loader.load(); return new Promise((resolve) => { if (this._player) { this._player.dispose(); } const player = new Player({ url: buffer, loop: isBooleanTrue(this.pv.loop), volume: 0, // no onload event if a buffer is provided instead of a url // onload: () => { // resolve(player); // }, // make sure to have the param loop set to false for the onstop callbacks to be run. onstop: () => { this._runOnStop(player); }, }); this._player = player; this._reset(); this.p.duration.set(buffer.duration); // this.p.loopRange.set([0, buffer.duration]); if (isBooleanTrue(this.pv.autostart)) { this.play(); } AudioPlayerCallbacksManager.registerPlayer(this._player); resolve(this._player); }); } catch (err) { this.states.error.set(`failed to load url '${this.pv.url}'`); return; } } async play() { if (!this._player) { return; } // const offset = this._stoppedAt; // using pv.currentTime is useful when reloading the page // and still starting from where we were const offset = isBooleanTrue(this.pv.updateCurrentTimeParam) ? this.pv.currentTime : this._currentTime(); // always add 2 * EPSILON in the offset, which will not be noticeable // but helps avoiding errors when restarting const sanitizedOffset = Math.max(offset + 2 * EPSILON, 0); try { this._runOnBeforePlay(this._player, sanitizedOffset); this._player.start(0, sanitizedOffset); this._runOnPlaySuccess(this._player); } catch (err) { console.error(err); this._runOnPlayError(this._player, err); } this._startedAt = this._player.now() - this._stoppedAt; this._stoppedAt = 0; } async pause() { if (!this._player) { console.warn('no player'); return; } if (this._player.state != 'started') { console.warn(`player state is not "started", but "${this._player.state}"`); return; } if (this._startedAt != null) { const elapsed = this._player.now() - this._startedAt; this._stoppedAt = elapsed; } this._player.stop(); } private _reset() { this._stoppedAt = 0; this._startedAt = undefined; this.p.currentTime.set(0); } async restart() { if (!this._player) { return; } this._player.seek(0, 0); this._reset(); this.play(); } seekOffset(offset: number) { if (!this._player) { return; } if (this._startedAt == null) { return; } const currentTime = this._currentTime(); this._startedAt -= offset; this._player.seek(currentTime + offset); } /* * UPDATE CURRENT TIME PARAM */ static PARAM_CALLBACK_updateUpdateCurrentTimeParam(node: FileAudioNode) { node._updateCurrentTimeParam(); node._updateOnTickHook(); } private _updateCurrentTimeParam() { if (!this._player) { return; } const currentTime = this._currentTime(); this.p.currentTime.set(currentTime); } private _currentTime() { if (this._stoppedAt) { return this._stoppedAt; } if (this._player && this._startedAt != null) { let current = this._player.now() - this._startedAt; const duration = this.pv.duration; if (current > duration) { current -= duration; } return current; } return 0; } /* * LOOP */ static PARAM_CALLBACK_updateLoop(node: FileAudioNode) { node._updateLoop(); } private _updateLoop() { if (!this._player) { return; } this._player.loop = this.pv.loop; // if (this._player.loop) { // if (isBooleanTrue(this.pv.useLoopRange)) { // this._player.setLoopPoints(this.pv.loopRange.x, this.pv.loopRange.y); // } // } } /* * REGISTER TICK CALLBACK */ private _updateOnTickHook() { if (isBooleanTrue(this.pv.updateCurrentTimeParam)) { this._registerOnTickHook(); } else { this._unRegisterOnTickHook(); } } private async _registerOnTickHook() { if (this.scene().registeredBeforeTickCallbacks().has(this._tickCallbackName())) { return; } this.scene().registerOnBeforeTick(this._tickCallbackName(), this._updateCurrentTimeParam.bind(this)); } private async _unRegisterOnTickHook() { this.scene().unRegisterOnBeforeTick(this._tickCallbackName()); } private _tickCallbackName() { return `audio/File-${this.graphNodeId()}`; } /* * STATIC CALLBACKS */ static PARAM_CALLBACK_seekOffset(node: FileAudioNode, offset: number) { node.seekOffset(offset); } static PARAM_CALLBACK_play(node: FileAudioNode) { node.play(); } static PARAM_CALLBACK_pause(node: FileAudioNode) { node.pause(); } static PARAM_CALLBACK_restart(node: FileAudioNode) { node.restart(); } /* * HOOKS */ private _playerCallbacks: PlayerCallbacks = {}; // onBeforePlay onBeforePlay(callback: OnBeforePlayCallback) { this._on('onBeforePlay', callback); } private _runOnBeforePlay(player: Player, offset: number) { this._playerCallbacks.onBeforePlay?.forEach((callback) => callback(offset)); AudioPlayerCallbacksManager.runOnBeforePlayCallbacks(player, offset); } // onPlaySuccess onPlaySuccess(callback: OnPlaySuccessCallback) { this._on('onPlaySuccess', callback); } private _runOnPlaySuccess(player: Player) { this._playerCallbacks.onPlaySuccess?.forEach((callback) => callback()); AudioPlayerCallbacksManager.runOnPlaySuccessCallbacks(player); } // onPlayError onPlayError(callback: OnPlayErrorCallback) { this._on('onPlayError', callback); } private _runOnPlayError(player: Player, err: unknown) { this._playerCallbacks.onPlayError?.forEach((callback) => callback(err)); AudioPlayerCallbacksManager.runOnPlayErrorCallbacks(player, err); } // onStop onStop(callback: OnStopCallback) { this._on('onStop', callback); } removeOnStop(callback: OnStopCallback) { this._removeCallback('onStop', callback); } private _runOnStop(player: Player) { this._playerCallbacks.onStop?.forEach((callback) => callback()); AudioPlayerCallbacksManager.runOnStopCallbacks(player); } // generic private _on<E extends PlayerEventName>(eventName: E, callback: CallbackByEventName[E]) { this._playerCallbacks[eventName] = this._playerCallbacks[eventName] || (new Set() as any); this._playerCallbacks[eventName]?.add(callback as any); } private _removeCallback<E extends PlayerEventName>(eventName: E, callback: CallbackByEventName[E]) { this._playerCallbacks[eventName]?.delete(callback as any); } }