-
Notifications
You must be signed in to change notification settings - Fork 1.8k
audio-compressor plugin: add Auto Track Gain to boost quiet tracks #4440
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,19 @@ import { createPlugin } from '@/utils'; | |
| import { t } from '@/i18n'; | ||
| import { type MusicPlayer } from '@/types/music-player'; | ||
|
|
||
| import type { MenuContext } from '@/types/contexts'; | ||
| import type { MenuTemplate } from '@/menu'; | ||
|
|
||
| export type AudioCompressorPluginConfig = { | ||
| enabled: boolean; | ||
| autoTrackGain: boolean; | ||
| maxTrackGainDb: number; | ||
| }; | ||
|
|
||
| const MAX_TRACK_GAIN_CHOICES = [6, 9, 12, 15, 18, 24] as const; | ||
|
|
||
| const dbToLinear = (db: number) => Math.pow(10, db / 20); | ||
|
|
||
| const lazySafeTry = (...fns: (() => void)[]) => { | ||
| for (const fn of fns) { | ||
| try { | ||
|
|
@@ -10,93 +23,155 @@ const lazySafeTry = (...fns: (() => void)[]) => { | |
| } | ||
| }; | ||
|
|
||
| const createCompressorNode = ( | ||
| audioContext: AudioContext, | ||
| ): DynamicsCompressorNode => { | ||
| const compressor = audioContext.createDynamicsCompressor(); | ||
|
|
||
| const configureCompressor = (compressor: DynamicsCompressorNode) => { | ||
| compressor.threshold.value = -50; | ||
| compressor.ratio.value = 12; | ||
| compressor.knee.value = 40; | ||
| compressor.attack.value = 0; | ||
| compressor.release.value = 0.25; | ||
|
|
||
| return compressor; | ||
| }; | ||
|
|
||
| class Storage { | ||
| lastSource: MediaElementAudioSourceNode | null = null; | ||
| lastContext: AudioContext | null = null; | ||
| lastCompressor: DynamicsCompressorNode | null = null; | ||
| class Chain { | ||
| source: MediaElementAudioSourceNode | null = null; | ||
| context: AudioContext | null = null; | ||
| compressor: DynamicsCompressorNode | null = null; | ||
| trackGain: GainNode | null = null; | ||
|
|
||
| build(source: MediaElementAudioSourceNode, context: AudioContext) { | ||
| if ( | ||
| this.source === source && | ||
| this.context === context && | ||
| this.compressor | ||
| ) { | ||
| return; // already built | ||
| } | ||
|
|
||
| connected: WeakMap<MediaElementAudioSourceNode, DynamicsCompressorNode> = | ||
| new WeakMap(); | ||
| this.teardown(); | ||
|
|
||
| connectToCompressor = ( | ||
| source: MediaElementAudioSourceNode | null = null, | ||
| audioContext: AudioContext | null = null, | ||
| compressor: DynamicsCompressorNode | null = null, | ||
| ): boolean => { | ||
| if (!(source && audioContext && compressor)) return false; | ||
| this.source = source; | ||
| this.context = context; | ||
|
|
||
| const current = this.connected.get(source); | ||
| if (current === compressor) return false; | ||
| const compressor = context.createDynamicsCompressor(); | ||
| const trackGain = context.createGain(); | ||
| configureCompressor(compressor); | ||
| trackGain.gain.value = 1; | ||
|
|
||
| this.lastSource = source; | ||
| this.lastContext = audioContext; | ||
| this.lastCompressor = compressor; | ||
| this.compressor = compressor; | ||
| this.trackGain = trackGain; | ||
|
|
||
| if (current) { | ||
| lazySafeTry( | ||
| () => source.disconnect(current), | ||
| () => current.disconnect(audioContext.destination), | ||
| ); | ||
| } else { | ||
| lazySafeTry(() => source.disconnect(audioContext.destination)); | ||
| } | ||
| // Source was previously connected directly to destination by the | ||
| // renderer; detach that and route through our chain instead. | ||
| lazySafeTry(() => source.disconnect(context.destination)); | ||
|
|
||
| try { | ||
| source.connect(compressor); | ||
| compressor.connect(audioContext.destination); | ||
| this.connected.set(source, compressor); | ||
| return true; | ||
| } catch (error) { | ||
| console.error('connectToCompressor failed', error); | ||
| return false; | ||
| } | ||
| }; | ||
| source.connect(compressor); | ||
| compressor.connect(trackGain); | ||
| trackGain.connect(context.destination); | ||
| } | ||
|
|
||
| disconnectCompressor = (): boolean => { | ||
| const source = this.lastSource; | ||
| const audioContext = this.lastContext; | ||
| if (!(source && audioContext)) return false; | ||
| const current = this.connected.get(source); | ||
| if (!current) return false; | ||
| applyTrackGain(gainDb: number) { | ||
| if (!this.context || !this.trackGain) return; | ||
| this.trackGain.gain.linearRampToValueAtTime( | ||
| dbToLinear(gainDb), | ||
| this.context.currentTime + 0.1, | ||
| ); | ||
| } | ||
|
|
||
| teardown() { | ||
| const { source, context, compressor } = this; | ||
| if (source && context && compressor) { | ||
| lazySafeTry( | ||
| () => source.disconnect(compressor), | ||
| () => source.connect(context.destination), | ||
| ); | ||
| } | ||
| lazySafeTry( | ||
| () => source.connect(audioContext.destination), | ||
| () => source.disconnect(current), | ||
| () => current.disconnect(audioContext.destination), | ||
| () => this.compressor?.disconnect(), | ||
| () => this.trackGain?.disconnect(), | ||
| ); | ||
| this.connected.delete(source); | ||
| return true; | ||
| }; | ||
| this.compressor = null; | ||
| this.trackGain = null; | ||
| // Keep source/context refs so a re-enable can rebuild without waiting | ||
| // for the next audio-can-play event. | ||
| } | ||
| } | ||
|
|
||
| const storage = new Storage(); | ||
| const chain = new Chain(); | ||
|
|
||
| let currentConfig: AudioCompressorPluginConfig = { | ||
| enabled: false, | ||
| autoTrackGain: false, | ||
| maxTrackGainDb: 12, | ||
| }; | ||
|
|
||
| const getContentLoudnessDb = (): number | null => { | ||
| try { | ||
| const player = document.querySelector('#movie_player') as | ||
| | (Element & { getPlayerResponse?: () => unknown }) | ||
| | null; | ||
| const response = player?.getPlayerResponse?.() as | ||
| | { | ||
| playerConfig?: { | ||
| audioConfig?: { | ||
| loudnessDb?: number; | ||
| perceptualLoudnessDb?: number; | ||
| }; | ||
| }; | ||
| } | ||
| | undefined; | ||
| const loudnessDb = | ||
| response?.playerConfig?.audioConfig?.loudnessDb ?? | ||
| response?.playerConfig?.audioConfig?.perceptualLoudnessDb; | ||
| return typeof loudnessDb === 'number' ? loudnessDb : null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| }; | ||
|
|
||
| let pendingRetry: ReturnType<typeof setTimeout> | null = null; | ||
|
|
||
| const cancelPendingRetry = () => { | ||
| if (pendingRetry !== null) { | ||
| clearTimeout(pendingRetry); | ||
| pendingRetry = null; | ||
| } | ||
| }; | ||
|
|
||
| const updateTrackGain = (retriesLeft = 4) => { | ||
| cancelPendingRetry(); | ||
|
|
||
| if (!currentConfig.autoTrackGain) { | ||
| chain.applyTrackGain(0); | ||
| return; | ||
| } | ||
|
|
||
| const loudnessDb = getContentLoudnessDb(); | ||
| if (loudnessDb === null) { | ||
| if (retriesLeft > 0) { | ||
| // YT may not have populated loudness yet — retry shortly. | ||
| pendingRetry = setTimeout(() => updateTrackGain(retriesLeft - 1), 400); | ||
| } else { | ||
| chain.applyTrackGain(0); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| // YT's loudnessDb is signed: positive = louder than reference, negative = | ||
| // quieter. Compensate quiet tracks; leave loud tracks alone. | ||
| const compensation = loudnessDb < 0 ? -loudnessDb : 0; | ||
| const target = Math.min(compensation, currentConfig.maxTrackGainDb); | ||
| chain.applyTrackGain(target); | ||
| }; | ||
|
|
||
| const audioCanPlayHandler = ({ | ||
| detail: { audioSource, audioContext }, | ||
| }: CustomEvent<Compressor>) => { | ||
| storage.connectToCompressor( | ||
| audioSource, | ||
| audioContext, | ||
| createCompressorNode(audioContext), | ||
| ); | ||
| cancelPendingRetry(); | ||
| chain.build(audioSource, audioContext); | ||
| updateTrackGain(); | ||
| }; | ||
|
Comment on lines
165
to
171
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: No, inserting an extra GainNode with gain=1 into a Web Audio graph does not cause any audible difference and adds no measurable latency. Citations:
🏁 Script executed: # First, let's look at the structure of the audio-compressor.ts file around the mentioned lines
cat -n src/plugins/audio-compressor.ts | head -180 | tail -50Repository: pear-devs/pear-desktop Length of output: 1911 🏁 Script executed: # Also get the full context of the handler and related code
wc -l src/plugins/audio-compressor.tsRepository: pear-devs/pear-desktop Length of output: 102 🏁 Script executed: # Search for chain.build implementation and autoTrackGain usage
rg "chain\.build" src/plugins/audio-compressor.ts -B 2 -A 5Repository: pear-devs/pear-desktop Length of output: 562 🏁 Script executed: # Search for autoTrackGain references to understand how it gates behavior
rg "autoTrackGain" src/plugins/audio-compressor.ts -B 2 -A 2Repository: pear-devs/pear-desktop Length of output: 788 🏁 Script executed: # Look for onConfigChange handler to understand config change handling
rg "onConfigChange|currentConfig" src/plugins/audio-compressor.ts -B 1 -A 3Repository: pear-devs/pear-desktop Length of output: 886 🏁 Script executed: # Find where chain is defined
rg "const chain|let chain|var chain|export.*chain" src/plugins/audio-compressor.ts -B 2 -A 5Repository: pear-devs/pear-desktop Length of output: 212 🏁 Script executed: # Look for the chain implementation - it might be in a separate file or defined in this file
rg "chain\s*=" src/plugins/audio-compressor.ts -B 2 -A 20 | head -100Repository: pear-devs/pear-desktop Length of output: 637 🏁 Script executed: # Search for chain object definition - might be createChain or similar
rg "createChain|new.*Chain|function.*chain" src/plugins/audio-compressor.ts -B 2 -A 10Repository: pear-devs/pear-desktop Length of output: 338 🏁 Script executed: # Find the Chain class definition - it's likely imported or in this file
rg "class Chain|import.*Chain" src/plugins/audio-compressor.ts -B 2 -A 20Repository: pear-devs/pear-desktop Length of output: 615 🏁 Script executed: # If Chain is defined in another file, find it
fd "chain" -t f src/Repository: pear-devs/pear-desktop Length of output: 48 🏁 Script executed: # Search for Chain in the entire plugins directory
rg "class Chain" src/plugins/Repository: pear-devs/pear-desktop Length of output: 112 🏁 Script executed: # Get the full Chain class definition including the build method
cat -n src/plugins/audio-compressor.ts | sed -n '1,246p' | grep -A 100 "class Chain"Repository: pear-devs/pear-desktop Length of output: 3521 🏁 Script executed: # Or more specifically, get lines from the Chain class
sed -n '/^class Chain/,/^}/p' src/plugins/audio-compressor.tsRepository: pear-devs/pear-desktop Length of output: 1841 Consider whether the extra The chain is rebuilt on every While a 🤖 Prompt for AI Agents
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The always-present Since (as the web search confirms) a Happy to revisit if a maintainer prefers the conditional-insert path though. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only users with a collaborator, contributor, member, or owner role can interact with CodeRabbit. |
||
|
|
||
| const ensureAudioContextLoad = (playerApi: MusicPlayer) => { | ||
| if (playerApi.getPlayerState() !== 1 || storage.lastContext) return; | ||
| if (playerApi.getPlayerState() !== 1 || chain.context) return; | ||
|
|
||
| playerApi.loadVideoById( | ||
| playerApi.getPlayerResponse().videoDetails.videoId, | ||
|
|
@@ -108,26 +183,73 @@ const ensureAudioContextLoad = (playerApi: MusicPlayer) => { | |
| export default createPlugin({ | ||
| name: () => t('plugins.audio-compressor.name'), | ||
| description: () => t('plugins.audio-compressor.description'), | ||
| restartNeeded: false, | ||
| config: { | ||
| enabled: false, | ||
| autoTrackGain: false, | ||
| maxTrackGainDb: 12, | ||
| } as AudioCompressorPluginConfig, | ||
|
|
||
| menu: async ({ | ||
| getConfig, | ||
| setConfig, | ||
| }: MenuContext<AudioCompressorPluginConfig>): Promise<MenuTemplate> => { | ||
| const config = await getConfig(); | ||
|
|
||
| return [ | ||
| { | ||
| label: t('plugins.audio-compressor.menu.auto-track-gain'), | ||
| type: 'checkbox', | ||
| checked: config.autoTrackGain, | ||
| click(item) { | ||
| setConfig({ autoTrackGain: item.checked }); | ||
| }, | ||
| }, | ||
| { | ||
| label: t('plugins.audio-compressor.menu.maximum-gain.label'), | ||
| type: 'submenu', | ||
| submenu: MAX_TRACK_GAIN_CHOICES.map((db) => ({ | ||
| label: `${db} dB`, | ||
| type: 'radio' as const, | ||
| checked: config.maxTrackGainDb === db, | ||
| click() { | ||
| setConfig({ maxTrackGainDb: db }); | ||
| }, | ||
| })), | ||
| }, | ||
| ]; | ||
| }, | ||
|
|
||
| renderer: { | ||
| async start({ getConfig }) { | ||
| currentConfig = await getConfig(); | ||
| document.addEventListener('peard:audio-can-play', audioCanPlayHandler, { | ||
| passive: true, | ||
| }); | ||
| // If the chain was previously built (plugin re-enable), rebuild now | ||
| // rather than waiting for the next track change. | ||
| if (chain.source && chain.context) { | ||
| chain.build(chain.source, chain.context); | ||
| updateTrackGain(); | ||
| } | ||
| }, | ||
|
|
||
| onPlayerApiReady(playerApi) { | ||
| ensureAudioContextLoad(playerApi); | ||
| }, | ||
|
|
||
| start() { | ||
| document.addEventListener('peard:audio-can-play', audioCanPlayHandler, { | ||
| passive: true, | ||
| }); | ||
| storage.connectToCompressor( | ||
| storage.lastSource, | ||
| storage.lastContext, | ||
| storage.lastCompressor, | ||
| ); | ||
| onConfigChange(newConfig: AudioCompressorPluginConfig) { | ||
| currentConfig = newConfig; | ||
| updateTrackGain(); | ||
| }, | ||
|
|
||
| stop() { | ||
| document.removeEventListener('peard:audio-can-play', audioCanPlayHandler); | ||
| storage.disconnectCompressor(); | ||
| document.removeEventListener( | ||
| 'peard:audio-can-play', | ||
| audioCanPlayHandler, | ||
| ); | ||
| cancelPendingRetry(); | ||
| chain.teardown(); | ||
| }, | ||
| }, | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.