-
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,205 @@ 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 | ||
| } | ||
|
|
||
| this.teardown(); | ||
|
|
||
| this.source = source; | ||
| this.context = context; | ||
|
|
||
| const compressor = context.createDynamicsCompressor(); | ||
| const trackGain = context.createGain(); | ||
| configureCompressor(compressor); | ||
| trackGain.gain.value = 1; | ||
|
|
||
| connected: WeakMap<MediaElementAudioSourceNode, DynamicsCompressorNode> = | ||
| new WeakMap(); | ||
| this.compressor = compressor; | ||
| this.trackGain = trackGain; | ||
|
|
||
| connectToCompressor = ( | ||
| source: MediaElementAudioSourceNode | null = null, | ||
| audioContext: AudioContext | null = null, | ||
| compressor: DynamicsCompressorNode | null = null, | ||
| ): boolean => { | ||
| if (!(source && audioContext && compressor)) return false; | ||
| // Source was previously connected directly to destination by the | ||
| // renderer; detach that and route through our chain instead. | ||
| lazySafeTry(() => source.disconnect(context.destination)); | ||
|
|
||
| const current = this.connected.get(source); | ||
| if (current === compressor) return false; | ||
| source.connect(compressor); | ||
| compressor.connect(trackGain); | ||
| trackGain.connect(context.destination); | ||
| } | ||
|
|
||
| this.lastSource = source; | ||
| this.lastContext = audioContext; | ||
| this.lastCompressor = compressor; | ||
| applyTrackGain(gainDb: number) { | ||
| if (!this.context || !this.trackGain) return; | ||
| this.trackGain.gain.linearRampToValueAtTime( | ||
| dbToLinear(gainDb), | ||
| this.context.currentTime + 0.1, | ||
| ); | ||
| } | ||
|
|
||
| if (current) { | ||
| teardown() { | ||
| const { source, context, compressor } = this; | ||
| if (source && context && compressor) { | ||
| lazySafeTry( | ||
| () => source.disconnect(current), | ||
| () => current.disconnect(audioContext.destination), | ||
| () => source.disconnect(compressor), | ||
| () => source.connect(context.destination), | ||
| ); | ||
| } else { | ||
| lazySafeTry(() => source.disconnect(audioContext.destination)); | ||
| } | ||
| lazySafeTry( | ||
| () => this.compressor?.disconnect(), | ||
| () => this.trackGain?.disconnect(), | ||
| ); | ||
| 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. | ||
| } | ||
| } | ||
|
|
||
| try { | ||
| source.connect(compressor); | ||
| compressor.connect(audioContext.destination); | ||
| this.connected.set(source, compressor); | ||
| return true; | ||
| } catch (error) { | ||
| console.error('connectToCompressor failed', error); | ||
| return false; | ||
| 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; | ||
| } | ||
|
|
||
| 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; | ||
| // 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); | ||
| }; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| lazySafeTry( | ||
| () => source.connect(audioContext.destination), | ||
| () => source.disconnect(current), | ||
| () => current.disconnect(audioContext.destination), | ||
| ); | ||
| this.connected.delete(source); | ||
| return true; | ||
| }; | ||
| } | ||
| const sourceMediaElement = ( | ||
| source: MediaElementAudioSourceNode | null, | ||
| ): HTMLVideoElement | null => | ||
| ((source as | ||
| | (MediaElementAudioSourceNode & { mediaElement?: HTMLMediaElement }) | ||
| | null | ||
| )?.mediaElement as HTMLVideoElement | undefined) ?? null; | ||
|
|
||
| let videoSwapObserver: MutationObserver | null = null; | ||
|
|
||
| const stopWatchingForVideoSwap = () => { | ||
| if (videoSwapObserver) { | ||
| videoSwapObserver.disconnect(); | ||
| videoSwapObserver = null; | ||
| } | ||
| }; | ||
|
|
||
| const storage = new Storage(); | ||
| const handleVideoSwap = (newEl: HTMLVideoElement) => { | ||
| if (!chain.context) return; | ||
| let newSource: MediaElementAudioSourceNode; | ||
| try { | ||
| newSource = chain.context.createMediaElementSource(newEl); | ||
| } catch { | ||
| return; | ||
| } | ||
| chain.build(newSource, chain.context); | ||
| updateTrackGain(); | ||
| watchForVideoSwap(); | ||
| }; | ||
|
|
||
| const watchForVideoSwap = () => { | ||
| stopWatchingForVideoSwap(); | ||
| const currentEl = sourceMediaElement(chain.source); | ||
| if (!currentEl) return; | ||
| // YT swaps the <video> element on certain seek operations (notably seek | ||
| // near end then back to start). Our source is permanently bound to the old | ||
| // element; the new one would play straight to the OS, bypassing the chain. | ||
| // Detect the swap and rebind. | ||
| const target = | ||
| (document.querySelector('#movie_player') as HTMLElement | null) ?? | ||
| document.body; | ||
| videoSwapObserver = new MutationObserver(() => { | ||
| const newEl = document.querySelector<HTMLVideoElement>('video'); | ||
| if (!newEl || newEl === currentEl) return; | ||
| handleVideoSwap(newEl); | ||
| }); | ||
| videoSwapObserver.observe(target, { childList: true, subtree: true }); | ||
| }; | ||
|
|
||
| const audioCanPlayHandler = ({ | ||
| detail: { audioSource, audioContext }, | ||
| }: CustomEvent<Compressor>) => { | ||
| storage.connectToCompressor( | ||
| audioSource, | ||
| audioContext, | ||
| createCompressorNode(audioContext), | ||
| ); | ||
| cancelPendingRetry(); | ||
| chain.build(audioSource, audioContext); | ||
| updateTrackGain(); | ||
| watchForVideoSwap(); | ||
| }; | ||
|
Comment on lines
214
to
221
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 +233,84 @@ 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 }) { | ||
| // Register synchronously so we never miss an event during the await. | ||
| document.addEventListener('peard:audio-can-play', audioCanPlayHandler, { | ||
| passive: true, | ||
| }); | ||
| currentConfig = await getConfig(); | ||
| // If the chain was previously built (plugin re-enable), rebuild now | ||
| // rather than waiting for the next track change. | ||
| if (chain.source && chain.context) { | ||
| // YT may have swapped the <video> element while we were disabled | ||
| // (observer was off). Detect a stale cached source and rebind to | ||
| // the live element rather than rebuilding onto a dead one. | ||
| const currentVideo = document.querySelector<HTMLVideoElement>('video'); | ||
| if (currentVideo && sourceMediaElement(chain.source) !== currentVideo) { | ||
| handleVideoSwap(currentVideo); | ||
| } else { | ||
| chain.build(chain.source, chain.context); | ||
| updateTrackGain(); | ||
| watchForVideoSwap(); | ||
| } | ||
| } | ||
| }, | ||
|
|
||
| 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, | ||
| ); | ||
| stopWatchingForVideoSwap(); | ||
| cancelPendingRetry(); | ||
| chain.teardown(); | ||
| }, | ||
| }, | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.