@@ -2,6 +2,19 @@ import { createPlugin } from '@/utils';
22import { t } from '@/i18n' ;
33import { type MusicPlayer } from '@/types/music-player' ;
44
5+ import type { MenuContext } from '@/types/contexts' ;
6+ import type { MenuTemplate } from '@/menu' ;
7+
8+ export type AudioCompressorPluginConfig = {
9+ enabled : boolean ;
10+ autoTrackGain : boolean ;
11+ maxTrackGainDb : number ;
12+ } ;
13+
14+ const MAX_TRACK_GAIN_CHOICES = [ 6 , 9 , 12 , 15 , 18 , 24 ] as const ;
15+
16+ const dbToLinear = ( db : number ) => Math . pow ( 10 , db / 20 ) ;
17+
518const lazySafeTry = ( ...fns : ( ( ) => void ) [ ] ) => {
619 for ( const fn of fns ) {
720 try {
@@ -10,93 +23,155 @@ const lazySafeTry = (...fns: (() => void)[]) => {
1023 }
1124} ;
1225
13- const createCompressorNode = (
14- audioContext : AudioContext ,
15- ) : DynamicsCompressorNode => {
16- const compressor = audioContext . createDynamicsCompressor ( ) ;
17-
26+ const configureCompressor = ( compressor : DynamicsCompressorNode ) => {
1827 compressor . threshold . value = - 50 ;
1928 compressor . ratio . value = 12 ;
2029 compressor . knee . value = 40 ;
2130 compressor . attack . value = 0 ;
2231 compressor . release . value = 0.25 ;
23-
24- return compressor ;
2532} ;
2633
27- class Storage {
28- lastSource : MediaElementAudioSourceNode | null = null ;
29- lastContext : AudioContext | null = null ;
30- lastCompressor : DynamicsCompressorNode | null = null ;
34+ class Chain {
35+ source : MediaElementAudioSourceNode | null = null ;
36+ context : AudioContext | null = null ;
37+ compressor : DynamicsCompressorNode | null = null ;
38+ trackGain : GainNode | null = null ;
39+
40+ build ( source : MediaElementAudioSourceNode , context : AudioContext ) {
41+ if (
42+ this . source === source &&
43+ this . context === context &&
44+ this . compressor
45+ ) {
46+ return ; // already built
47+ }
3148
32- connected : WeakMap < MediaElementAudioSourceNode , DynamicsCompressorNode > =
33- new WeakMap ( ) ;
49+ this . teardown ( ) ;
3450
35- connectToCompressor = (
36- source : MediaElementAudioSourceNode | null = null ,
37- audioContext : AudioContext | null = null ,
38- compressor : DynamicsCompressorNode | null = null ,
39- ) : boolean => {
40- if ( ! ( source && audioContext && compressor ) ) return false ;
51+ this . source = source ;
52+ this . context = context ;
4153
42- const current = this . connected . get ( source ) ;
43- if ( current === compressor ) return false ;
54+ const compressor = context . createDynamicsCompressor ( ) ;
55+ const trackGain = context . createGain ( ) ;
56+ configureCompressor ( compressor ) ;
57+ trackGain . gain . value = 1 ;
4458
45- this . lastSource = source ;
46- this . lastContext = audioContext ;
47- this . lastCompressor = compressor ;
59+ this . compressor = compressor ;
60+ this . trackGain = trackGain ;
4861
49- if ( current ) {
50- lazySafeTry (
51- ( ) => source . disconnect ( current ) ,
52- ( ) => current . disconnect ( audioContext . destination ) ,
53- ) ;
54- } else {
55- lazySafeTry ( ( ) => source . disconnect ( audioContext . destination ) ) ;
56- }
62+ // Source was previously connected directly to destination by the
63+ // renderer; detach that and route through our chain instead.
64+ lazySafeTry ( ( ) => source . disconnect ( context . destination ) ) ;
5765
58- try {
59- source . connect ( compressor ) ;
60- compressor . connect ( audioContext . destination ) ;
61- this . connected . set ( source , compressor ) ;
62- return true ;
63- } catch ( error ) {
64- console . error ( 'connectToCompressor failed' , error ) ;
65- return false ;
66- }
67- } ;
66+ source . connect ( compressor ) ;
67+ compressor . connect ( trackGain ) ;
68+ trackGain . connect ( context . destination ) ;
69+ }
6870
69- disconnectCompressor = ( ) : boolean => {
70- const source = this . lastSource ;
71- const audioContext = this . lastContext ;
72- if ( ! ( source && audioContext ) ) return false ;
73- const current = this . connected . get ( source ) ;
74- if ( ! current ) return false ;
71+ applyTrackGain ( gainDb : number ) {
72+ if ( ! this . context || ! this . trackGain ) return ;
73+ this . trackGain . gain . linearRampToValueAtTime (
74+ dbToLinear ( gainDb ) ,
75+ this . context . currentTime + 0.1 ,
76+ ) ;
77+ }
7578
79+ teardown ( ) {
80+ const { source, context, compressor } = this ;
81+ if ( source && context && compressor ) {
82+ lazySafeTry (
83+ ( ) => source . disconnect ( compressor ) ,
84+ ( ) => source . connect ( context . destination ) ,
85+ ) ;
86+ }
7687 lazySafeTry (
77- ( ) => source . connect ( audioContext . destination ) ,
78- ( ) => source . disconnect ( current ) ,
79- ( ) => current . disconnect ( audioContext . destination ) ,
88+ ( ) => this . compressor ?. disconnect ( ) ,
89+ ( ) => this . trackGain ?. disconnect ( ) ,
8090 ) ;
81- this . connected . delete ( source ) ;
82- return true ;
83- } ;
91+ this . compressor = null ;
92+ this . trackGain = null ;
93+ // Keep source/context refs so a re-enable can rebuild without waiting
94+ // for the next audio-can-play event.
95+ }
8496}
8597
86- const storage = new Storage ( ) ;
98+ const chain = new Chain ( ) ;
99+
100+ let currentConfig : AudioCompressorPluginConfig = {
101+ enabled : false ,
102+ autoTrackGain : false ,
103+ maxTrackGainDb : 12 ,
104+ } ;
105+
106+ const getContentLoudnessDb = ( ) : number | null => {
107+ try {
108+ const player = document . querySelector ( '#movie_player' ) as
109+ | ( Element & { getPlayerResponse ?: ( ) => unknown } )
110+ | null ;
111+ const response = player ?. getPlayerResponse ?.( ) as
112+ | {
113+ playerConfig ?: {
114+ audioConfig ?: {
115+ loudnessDb ?: number ;
116+ perceptualLoudnessDb ?: number ;
117+ } ;
118+ } ;
119+ }
120+ | undefined ;
121+ const loudnessDb =
122+ response ?. playerConfig ?. audioConfig ?. loudnessDb ??
123+ response ?. playerConfig ?. audioConfig ?. perceptualLoudnessDb ;
124+ return typeof loudnessDb === 'number' ? loudnessDb : null ;
125+ } catch {
126+ return null ;
127+ }
128+ } ;
129+
130+ let pendingRetry : ReturnType < typeof setTimeout > | null = null ;
131+
132+ const cancelPendingRetry = ( ) => {
133+ if ( pendingRetry !== null ) {
134+ clearTimeout ( pendingRetry ) ;
135+ pendingRetry = null ;
136+ }
137+ } ;
138+
139+ const updateTrackGain = ( retriesLeft = 4 ) => {
140+ cancelPendingRetry ( ) ;
141+
142+ if ( ! currentConfig . autoTrackGain ) {
143+ chain . applyTrackGain ( 0 ) ;
144+ return ;
145+ }
146+
147+ const loudnessDb = getContentLoudnessDb ( ) ;
148+ if ( loudnessDb === null ) {
149+ if ( retriesLeft > 0 ) {
150+ // YT may not have populated loudness yet — retry shortly.
151+ pendingRetry = setTimeout ( ( ) => updateTrackGain ( retriesLeft - 1 ) , 400 ) ;
152+ } else {
153+ chain . applyTrackGain ( 0 ) ;
154+ }
155+ return ;
156+ }
157+
158+ // YT's loudnessDb is signed: positive = louder than reference, negative =
159+ // quieter. Compensate quiet tracks; leave loud tracks alone.
160+ const compensation = loudnessDb < 0 ? - loudnessDb : 0 ;
161+ const target = Math . min ( compensation , currentConfig . maxTrackGainDb ) ;
162+ chain . applyTrackGain ( target ) ;
163+ } ;
87164
88165const audioCanPlayHandler = ( {
89166 detail : { audioSource, audioContext } ,
90167} : CustomEvent < Compressor > ) => {
91- storage . connectToCompressor (
92- audioSource ,
93- audioContext ,
94- createCompressorNode ( audioContext ) ,
95- ) ;
168+ cancelPendingRetry ( ) ;
169+ chain . build ( audioSource , audioContext ) ;
170+ updateTrackGain ( ) ;
96171} ;
97172
98173const ensureAudioContextLoad = ( playerApi : MusicPlayer ) => {
99- if ( playerApi . getPlayerState ( ) !== 1 || storage . lastContext ) return ;
174+ if ( playerApi . getPlayerState ( ) !== 1 || chain . context ) return ;
100175
101176 playerApi . loadVideoById (
102177 playerApi . getPlayerResponse ( ) . videoDetails . videoId ,
@@ -108,26 +183,73 @@ const ensureAudioContextLoad = (playerApi: MusicPlayer) => {
108183export default createPlugin ( {
109184 name : ( ) => t ( 'plugins.audio-compressor.name' ) ,
110185 description : ( ) => t ( 'plugins.audio-compressor.description' ) ,
186+ restartNeeded : false ,
187+ config : {
188+ enabled : false ,
189+ autoTrackGain : false ,
190+ maxTrackGainDb : 12 ,
191+ } as AudioCompressorPluginConfig ,
192+
193+ menu : async ( {
194+ getConfig,
195+ setConfig,
196+ } : MenuContext < AudioCompressorPluginConfig > ) : Promise < MenuTemplate > => {
197+ const config = await getConfig ( ) ;
198+
199+ return [
200+ {
201+ label : t ( 'plugins.audio-compressor.menu.auto-track-gain' ) ,
202+ type : 'checkbox' ,
203+ checked : config . autoTrackGain ,
204+ click ( item ) {
205+ setConfig ( { autoTrackGain : item . checked } ) ;
206+ } ,
207+ } ,
208+ {
209+ label : t ( 'plugins.audio-compressor.menu.maximum-gain.label' ) ,
210+ type : 'submenu' ,
211+ submenu : MAX_TRACK_GAIN_CHOICES . map ( ( db ) => ( {
212+ label : `${ db } dB` ,
213+ type : 'radio' as const ,
214+ checked : config . maxTrackGainDb === db ,
215+ click ( ) {
216+ setConfig ( { maxTrackGainDb : db } ) ;
217+ } ,
218+ } ) ) ,
219+ } ,
220+ ] ;
221+ } ,
111222
112223 renderer : {
224+ async start ( { getConfig } ) {
225+ currentConfig = await getConfig ( ) ;
226+ document . addEventListener ( 'peard:audio-can-play' , audioCanPlayHandler , {
227+ passive : true ,
228+ } ) ;
229+ // If the chain was previously built (plugin re-enable), rebuild now
230+ // rather than waiting for the next track change.
231+ if ( chain . source && chain . context ) {
232+ chain . build ( chain . source , chain . context ) ;
233+ updateTrackGain ( ) ;
234+ }
235+ } ,
236+
113237 onPlayerApiReady ( playerApi ) {
114238 ensureAudioContextLoad ( playerApi ) ;
115239 } ,
116240
117- start ( ) {
118- document . addEventListener ( 'peard:audio-can-play' , audioCanPlayHandler , {
119- passive : true ,
120- } ) ;
121- storage . connectToCompressor (
122- storage . lastSource ,
123- storage . lastContext ,
124- storage . lastCompressor ,
125- ) ;
241+ onConfigChange ( newConfig : AudioCompressorPluginConfig ) {
242+ currentConfig = newConfig ;
243+ updateTrackGain ( ) ;
126244 } ,
127245
128246 stop ( ) {
129- document . removeEventListener ( 'peard:audio-can-play' , audioCanPlayHandler ) ;
130- storage . disconnectCompressor ( ) ;
247+ document . removeEventListener (
248+ 'peard:audio-can-play' ,
249+ audioCanPlayHandler ,
250+ ) ;
251+ cancelPendingRetry ( ) ;
252+ chain . teardown ( ) ;
131253 } ,
132254 } ,
133255} ) ;
0 commit comments