Skip to content

Commit 6e19d66

Browse files
author
atrupb-Atila
committed
feat(audio-compressor): add Auto Track Gain to boost quiet tracks
Reads YouTube's per-track loudnessDb metadata and applies compensating gain to tracks quieter than the reference level. Closes #3032
1 parent 7b1c574 commit 6e19d66

2 files changed

Lines changed: 191 additions & 72 deletions

File tree

src/i18n/resources/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,12 @@
356356
},
357357
"audio-compressor": {
358358
"description": "Apply compression to audio (lowers the volume of the loudest parts of the signal and raises the volume of the softest parts)",
359+
"menu": {
360+
"auto-track-gain": "Auto track gain (boost quiet tracks)",
361+
"maximum-gain": {
362+
"label": "Maximum gain"
363+
}
364+
},
359365
"name": "Audio Compressor"
360366
},
361367
"auth-proxy-adapter": {

src/plugins/audio-compressor.ts

Lines changed: 185 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@ import { createPlugin } from '@/utils';
22
import { t } from '@/i18n';
33
import { 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+
518
const lazySafeTry = (...fns: (() => void)[]) => {
619
for (const fn of fns) {
720
try {
@@ -10,93 +23,147 @@ 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+
const updateTrackGain = (retriesLeft = 4) => {
131+
if (!currentConfig.autoTrackGain) {
132+
chain.applyTrackGain(0);
133+
return;
134+
}
135+
136+
const loudnessDb = getContentLoudnessDb();
137+
if (loudnessDb === null) {
138+
if (retriesLeft > 0) {
139+
// YT may not have populated loudness yet — retry shortly.
140+
setTimeout(() => updateTrackGain(retriesLeft - 1), 400);
141+
} else {
142+
chain.applyTrackGain(0);
143+
}
144+
return;
145+
}
146+
147+
// YT's loudnessDb is signed: positive = louder than reference, negative =
148+
// quieter. Compensate quiet tracks; leave loud tracks alone.
149+
const compensation = loudnessDb < 0 ? -loudnessDb : 0;
150+
const target = Math.min(compensation, currentConfig.maxTrackGainDb);
151+
chain.applyTrackGain(target);
152+
console.log(
153+
`[audio-compressor] track loudness ${loudnessDb.toFixed(1)} dB → ` +
154+
`track gain ${target.toFixed(1)} dB`,
155+
);
156+
};
87157

88158
const audioCanPlayHandler = ({
89159
detail: { audioSource, audioContext },
90160
}: CustomEvent<Compressor>) => {
91-
storage.connectToCompressor(
92-
audioSource,
93-
audioContext,
94-
createCompressorNode(audioContext),
95-
);
161+
chain.build(audioSource, audioContext);
162+
updateTrackGain();
96163
};
97164

98165
const ensureAudioContextLoad = (playerApi: MusicPlayer) => {
99-
if (playerApi.getPlayerState() !== 1 || storage.lastContext) return;
166+
if (playerApi.getPlayerState() !== 1 || chain.context) return;
100167

101168
playerApi.loadVideoById(
102169
playerApi.getPlayerResponse().videoDetails.videoId,
@@ -108,26 +175,72 @@ const ensureAudioContextLoad = (playerApi: MusicPlayer) => {
108175
export default createPlugin({
109176
name: () => t('plugins.audio-compressor.name'),
110177
description: () => t('plugins.audio-compressor.description'),
178+
restartNeeded: false,
179+
config: {
180+
enabled: false,
181+
autoTrackGain: false,
182+
maxTrackGainDb: 12,
183+
} as AudioCompressorPluginConfig,
184+
185+
menu: async ({
186+
getConfig,
187+
setConfig,
188+
}: MenuContext<AudioCompressorPluginConfig>): Promise<MenuTemplate> => {
189+
const config = await getConfig();
190+
191+
return [
192+
{
193+
label: t('plugins.audio-compressor.menu.auto-track-gain'),
194+
type: 'checkbox',
195+
checked: config.autoTrackGain,
196+
click(item) {
197+
setConfig({ autoTrackGain: item.checked });
198+
},
199+
},
200+
{
201+
label: t('plugins.audio-compressor.menu.maximum-gain.label'),
202+
type: 'submenu',
203+
submenu: MAX_TRACK_GAIN_CHOICES.map((db) => ({
204+
label: `${db} dB`,
205+
type: 'radio' as const,
206+
checked: config.maxTrackGainDb === db,
207+
click() {
208+
setConfig({ maxTrackGainDb: db });
209+
},
210+
})),
211+
},
212+
];
213+
},
111214

112215
renderer: {
216+
async start({ getConfig }) {
217+
currentConfig = await getConfig();
218+
document.addEventListener('peard:audio-can-play', audioCanPlayHandler, {
219+
passive: true,
220+
});
221+
// If the chain was previously built (plugin re-enable), rebuild now
222+
// rather than waiting for the next track change.
223+
if (chain.source && chain.context) {
224+
chain.build(chain.source, chain.context);
225+
updateTrackGain();
226+
}
227+
},
228+
113229
onPlayerApiReady(playerApi) {
114230
ensureAudioContextLoad(playerApi);
115231
},
116232

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-
);
233+
onConfigChange(newConfig: AudioCompressorPluginConfig) {
234+
currentConfig = newConfig;
235+
updateTrackGain();
126236
},
127237

128238
stop() {
129-
document.removeEventListener('peard:audio-can-play', audioCanPlayHandler);
130-
storage.disconnectCompressor();
239+
document.removeEventListener(
240+
'peard:audio-can-play',
241+
audioCanPlayHandler,
242+
);
243+
chain.teardown();
131244
},
132245
},
133246
});

0 commit comments

Comments
 (0)