From f4f7e697e1a867b4d8d6ac67ee04b472e6f48617 Mon Sep 17 00:00:00 2001 From: SrGooglo Date: Thu, 27 Apr 2023 21:55:40 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Support=20for=20audio=20processor?= =?UTF-8?q?=20nodes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/cores/player/{index.jsx => index.js} | 132 +++++++++--------- .../app/src/cores/player/processorNode.js | 123 ++++++++++++++++ .../player/processors/compressorNode/index.js | 55 ++++++++ .../cores/player/processors/gainNode/index.js | 37 +++++ packages/app/src/cores/player/storage.js | 25 ++++ 5 files changed, 309 insertions(+), 63 deletions(-) rename packages/app/src/cores/player/{index.jsx => index.js} (90%) create mode 100644 packages/app/src/cores/player/processorNode.js create mode 100644 packages/app/src/cores/player/processors/compressorNode/index.js create mode 100644 packages/app/src/cores/player/processors/gainNode/index.js create mode 100644 packages/app/src/cores/player/storage.js diff --git a/packages/app/src/cores/player/index.jsx b/packages/app/src/cores/player/index.js similarity index 90% rename from packages/app/src/cores/player/index.jsx rename to packages/app/src/cores/player/index.js index 16d22b48..df50607e 100755 --- a/packages/app/src/cores/player/index.jsx +++ b/packages/app/src/cores/player/index.js @@ -1,39 +1,16 @@ import React from "react" import Core from "evite/src/core" import { Observable } from "object-observer" -import store from "store" +import AudioPlayerStorage from "./storage" import { FastAverageColor } from "fast-average-color" -// import { createRealTimeBpmProcessor } from "realtime-bpm-analyzer" - import EmbbededMediaPlayer from "components/EmbbededMediaPlayer" import BackgroundMediaPlayer from "components/BackgroundMediaPlayer" import { DOMWindow } from "components/RenderWindow" -class AudioPlayerStorage { - static storeKey = "audioPlayer" - - static get(key) { - const data = store.get(AudioPlayerStorage.storeKey) - - if (data) { - return data[key] - } - - return null - } - - static set(key, value) { - const data = store.get(AudioPlayerStorage.storeKey) ?? {} - - data[key] = value - - store.set(AudioPlayerStorage.storeKey, data) - - return data - } -} +import GainProcessorNode from "./processors/gainNode" +import CompressorProcessorNode from "./processors/compressorNode" // TODO: Check if source playing is a stream. Also handle if it's a stream resuming after a pause will seek to the last position export default class Player extends Core { @@ -52,7 +29,10 @@ export default class Player extends Core { audioQueueHistory = [] audioQueue = [] - audioProcessors = [] + audioProcessors = [ + new GainProcessorNode(this), + new CompressorProcessorNode(this), + ] currentAudioInstance = null @@ -83,6 +63,25 @@ export default class Player extends Core { volume: this.volume.bind(this), start: this.start.bind(this), startPlaylist: this.startPlaylist.bind(this), + attachProcessor: function (name) { + + }.bind(this), + dettachProcessor: async function (name) { + // find the processor by refName + const processor = this.currentAudioInstance.attachedProcessors.find((_processor) => { + return _processor.constructor.refName === name + }) + + if (!processor) { + throw new Error("Processor not found") + } + + if (typeof processor._detach !== "function") { + throw new Error("Processor does not support detach") + } + + return this.currentAudioInstance = await processor._detach(this.currentAudioInstance) + }.bind(this), playback: { mode: function (mode) { if (mode) { @@ -157,6 +156,34 @@ export default class Player extends Core { } async onInitialize() { + // initialize all audio processors + for await (const processor of this.audioProcessors) { + console.log(`Initializing audio processor ${processor.constructor.name}`, processor) + + if (typeof processor._init === "function") { + try { + await processor._init(this.audioContext) + } catch (error) { + console.error(`Failed to initialize audio processor ${processor.constructor.name} >`, error) + continue + } + } + + // check if processor has exposed public methods + if (processor.exposeToPublic) { + Object.entries(processor.exposeToPublic).forEach(([key, value]) => { + const refName = processor.constructor.refName + + if (typeof this.public[refName] === "undefined") { + // by default create a empty object + this.public[refName] = {} + } + + this.public[refName][key] = value + }) + } + } + Observable.observe(this.state, (changes) => { changes.forEach((change) => { if (change.type === "update") { @@ -291,7 +318,7 @@ export default class Player extends Core { id: "mediaPlayer" }) - this.currentDomWindow.render() + this.currentDomWindow.render(React.createElement(EmbbededMediaPlayer)) } detachPlayerComponent() { @@ -413,7 +440,8 @@ export default class Player extends Core { track: null, gainNode: null, crossfadeInterval: null, - crossfading: false + crossfading: false, + attachedProcessors: [], } instanceObj.audioElement.signal = instanceObj.abortController.signal @@ -540,37 +568,13 @@ export default class Player extends Core { //this.enqueueLoadBuffer(instanceObj.audioElement) - //await this.instanciateRealtimeAnalyzerNode() - + // create media element source as first node instanceObj.track = this.audioContext.createMediaElementSource(instanceObj.audioElement) - instanceObj.gainNode = this.audioContext.createGain() - - instanceObj.gainNode.gain.value = this.state.audioVolume - - const processorsList = [ - instanceObj.gainNode, - ...this.audioProcessors, - ] - - let lastProcessor = null - - processorsList.forEach((processor) => { - if (lastProcessor) { - lastProcessor.connect(processor) - } else { - instanceObj.track.connect(processor) - } - - lastProcessor = processor - }) - - lastProcessor.connect(this.audioContext.destination) - return instanceObj } - play(instance, params = {}) { + async play(instance, params = {}) { if (typeof instance === "number") { instance = this.audioQueue[instance] } @@ -590,6 +594,16 @@ export default class Player extends Core { this.currentAudioInstance = instance this.state.currentAudioManifest = instance.manifest + for await (const [index, processor] of this.audioProcessors.entries()) { + if (typeof processor._attach !== "function") { + console.error(`Processor ${processor.constructor.refName} not support attach`) + + continue + } + + this.currentAudioInstance = await processor._attach(this.currentAudioInstance, index) + } + // set time to 0 this.currentAudioInstance.audioElement.currentTime = 0 @@ -603,14 +617,6 @@ export default class Player extends Core { this.currentAudioInstance.gainNode.gain.value = this.state.audioVolume } - if (this.realtimeAnalyzerNode) { - const filter = this.audioContext.createBiquadFilter() - - filter.type = "lowpass" - - this.currentAudioInstance.track.connect(filter).connect(this.realtimeAnalyzerNode) - } - instance.audioElement.muted = this.state.audioMuted instance.audioElement.load() diff --git a/packages/app/src/cores/player/processorNode.js b/packages/app/src/cores/player/processorNode.js new file mode 100644 index 00000000..346a023b --- /dev/null +++ b/packages/app/src/cores/player/processorNode.js @@ -0,0 +1,123 @@ +export default class ProcessorNode { + constructor(PlayerCore) { + if (!PlayerCore) { + throw new Error("PlayerCore is required") + } + + this.PlayerCore = PlayerCore + this.audioContext = PlayerCore.audioContext + } + + async _init() { + // check if has init method + if (typeof this.init === "function") { + await this.init(this.audioContext) + } + + // check if has declared bus events + if (typeof this.busEvents === "object") { + Object.entries(this.busEvents).forEach((event, fn) => { + app.eventBus.on(event, fn) + }) + } + + return this + } + + _attach(instance, index) { + if (typeof instance !== "object") { + instance = this.PlayerCore.currentAudioInstance + } + + // check if has dependsOnSettings + if (Array.isArray(this.constructor.dependsOnSettings)) { + // check if the instance has the settings + if (!this.constructor.dependsOnSettings.every((setting) => app.cores.settings.get(setting))) { + console.warn(`Skipping attachment for [${this.constructor.refName ?? this.constructor.name}] node, cause is not passing the settings dependecy > ${this.constructor.dependsOnSettings.join(", ")}`) + + return instance + } + } + + // if index is not defined, attach to the last node + if (!index) { + index = instance.attachedProcessors.length + } + + const prevNode = instance.attachedProcessors[index - 1] + const nextNode = instance.attachedProcessors[index + 1] + + // first check if has prevNode and if is connected to the destination + if (prevNode && prevNode.processor.numberOfOutputs > 0) { + // if has outputs, disconnect from the next node + prevNode.processor.disconnect() + } + + if (prevNode) { + prevNode.processor.connect(this.processor) + } else { + // it means that this is the first node, so connect to the source + instance.track.connect(this.processor) + } + + // now, connect the processor to the next node + if (nextNode) { + this.processor.connect(nextNode.processor) + } else { + // it means that this is the last node, so connect to the destination + this.processor.connect(this.audioContext.destination) + } + + // add to the attachedProcessors + instance.attachedProcessors.splice(index, 0, this) + + if (typeof this.mutateInstance === "function") { + instance = this.mutateInstance(instance) + } + + return instance + } + + _detach(instance) { + if (typeof instance !== "object") { + instance = this.PlayerCore.currentAudioInstance + } + + // find index of the node within the attachedProcessors serching for matching refName + const index = instance.attachedProcessors.findIndex((node) => { + return node.constructor.refName === this.constructor.refName + }) + + if (index === -1) { + console.warn(`Node [${this.constructor.refName ?? this.constructor.name}] is not attached to the given instance`) + + return instance + } + + // retrieve the previous and next nodes + const prevNode = instance.attachedProcessors[index - 1] + const nextNode = instance.attachedProcessors[index + 1] + + // check if has previous node and if has outputs + if (prevNode && prevNode.processor.numberOfOutputs > 0) { + // if has outputs, disconnect from the previous node + prevNode.processor.disconnect() + } + + // disconnect + this.processor.disconnect() + + // now, connect the previous node to the next node + if (prevNode && nextNode) { + prevNode.processor.connect(nextNode.processor) + } else { + // it means that this is the last node, so connect to the destination + prevNode.processor.connect(this.audioContext.destination) + } + + // remove from the attachedProcessors + instance.attachedProcessors.splice(index, 1) + + return instance + } +} \ No newline at end of file diff --git a/packages/app/src/cores/player/processors/compressorNode/index.js b/packages/app/src/cores/player/processors/compressorNode/index.js new file mode 100644 index 00000000..a610640c --- /dev/null +++ b/packages/app/src/cores/player/processors/compressorNode/index.js @@ -0,0 +1,55 @@ +import AudioPlayerStorage from "../../storage" +import ProcessorNode from "../../processorNode" + +export default class CompressorProcessorNode extends ProcessorNode { + static refName = "compressor" + static dependsOnSettings = ["player.compressor"] + static defaultCompressorValues = { + threshold: -50, + knee: 40, + ratio: 12, + attack: 0.003, + release: 0.25, + } + + state = { + compressorValues: AudioPlayerStorage.get("compressor") ?? CompressorProcessorNode.defaultCompressorValues, + } + + exposeToPublic = { + modifyValues: function (values) { + this.state.compressorValues = { + ...this.state.compressorValues, + ...values, + } + + AudioPlayerStorage.set("compressor", this.state.compressorValues) + + this.applyValues() + }.bind(this), + resetDefaultValues: function () { + this.exposeToPublic.modifyValues(CompressorProcessorNode.defaultCompressorValues) + + return this.state.compressorValues + }.bind(this), + detach: this._detach.bind(this), + attach: this._attach.bind(this), + values: this.state.compressorValues, + } + + async init(AudioContext) { + if (!AudioContext) { + throw new Error("AudioContext is required") + } + + this.processor = AudioContext.createDynamicsCompressor() + + this.applyValues() + } + + applyValues() { + Object.keys(this.state.compressorValues).forEach((key) => { + this.processor[key].value = this.state.compressorValues[key] + }) + } +} \ No newline at end of file diff --git a/packages/app/src/cores/player/processors/gainNode/index.js b/packages/app/src/cores/player/processors/gainNode/index.js new file mode 100644 index 00000000..d446b65f --- /dev/null +++ b/packages/app/src/cores/player/processors/gainNode/index.js @@ -0,0 +1,37 @@ +import AudioPlayerStorage from "../../storage" +import ProcessorNode from "../../processorNode" + +export default class GainProcessorNode extends ProcessorNode { + static refName = "gain" + + static lock = true + + static defaultValues = { + volume: 0.3, + } + + state = { + volume: AudioPlayerStorage.get("volume") ?? GainProcessorNode.defaultValues, + } + + async init() { + if (!this.audioContext) { + throw new Error("audioContext is required") + } + + this.processor = this.audioContext.createGain() + + // set the default values + this.processor.gain.value = parseFloat(this.state.volume) + } + + mutateInstance(instance) { + if (!instance) { + throw new Error("instance is required") + } + + instance.gainNode = this.processor + + return instance + } +} \ No newline at end of file diff --git a/packages/app/src/cores/player/storage.js b/packages/app/src/cores/player/storage.js new file mode 100644 index 00000000..b21a1d5f --- /dev/null +++ b/packages/app/src/cores/player/storage.js @@ -0,0 +1,25 @@ +import store from "store" + +export default class AudioPlayerStorage { + static storeKey = "audioPlayer" + + static get(key) { + const data = store.get(AudioPlayerStorage.storeKey) + + if (data) { + return data[key] + } + + return null + } + + static set(key, value) { + const data = store.get(AudioPlayerStorage.storeKey) ?? {} + + data[key] = value + + store.set(AudioPlayerStorage.storeKey, data) + + return data + } +} \ No newline at end of file