Skip to content

Commit 57f7fc6

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 57f7fc6

2 files changed

Lines changed: 200 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: 194 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,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

88165
const 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

98173
const 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) => {
108183
export 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

Comments
 (0)