Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/i18n/resources/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,12 @@
},
"audio-compressor": {
"description": "Apply compression to audio (lowers the volume of the loudest parts of the signal and raises the volume of the softest parts)",
"menu": {
"auto-track-gain": "Auto track gain (boost quiet tracks)",
"maximum-gain": {
"label": "Maximum gain"
}
},
"name": "Audio Compressor"
},
"auth-proxy-adapter": {
Expand Down
266 changes: 194 additions & 72 deletions src/plugins/audio-compressor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Does inserting an extra GainNode (gain=1) into a Web Audio graph cause any audible difference or measurable latency?

💡 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 -50

Repository: 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.ts

Repository: 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 5

Repository: 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 2

Repository: 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 3

Repository: 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 5

Repository: 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 -100

Repository: 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 10

Repository: 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 20

Repository: 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.ts

Repository: pear-devs/pear-desktop

Length of output: 1841


Consider whether the extra GainNode should be inserted conditionally based on autoTrackGain.

The chain is rebuilt on every audio-can-play event with a new GainNode inserted between the compressor and destination, regardless of whether autoTrackGain is enabled. When autoTrackGain is disabled, applyTrackGain(0) sets the gain to 1, making the node transparent—but the node remains in the audio graph and routing changes from the original (likely direct compressor-to-destination path).

While a GainNode with gain=1 causes no audible difference or measurable latency, this still represents an unconditional topology change. If the intent is to keep the old path identical when auto-track-gain is off, consider conditionally building the chain only when currentConfig.autoTrackGain === true, or restructure to conditionally insert the trackGain node during chain.build() based on the config.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/audio-compressor.ts` around lines 158 - 163, The chain is always
rebuilt with a new GainNode in audioCanPlayHandler (calling
chain.build(audioSource, audioContext)) even when autoTrackGain is disabled;
change the logic so the chain topology only includes the trackGain node when
currentConfig.autoTrackGain is true: either (A) wrap the
chain.build/updateTrackGain calls in audioCanPlayHandler with a conditional on
currentConfig.autoTrackGain so you only create/insert trackGain when enabled, or
(B) modify chain.build(...) to accept a flag or read currentConfig.autoTrackGain
and conditionally create/attach trackGain inside chain.build; ensure
applyTrackGain(0) is only used to set gain when the node exists and
updateTrackGain and any references to trackGain handle the absent node safely.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The always-present GainNode is intentional, it's what enables autoTrackGain to be toggled live (mid-track) without rebuilding the audio chain. The earlier draft of this PR conditionally rebuilt the chain on config change and I hit a class of bugs where toggles only took effect on the first switch (the second toggle would silently no-op due to disconnect/reconnect ordering issues with the source node). Keeping a transparent gain node always in the graph and only mutating its gain.value sidesteps that entirely.

Since (as the web search confirms) a GainNode with gain=1 has zero audible/perf cost, I'd rather keep the topology stable and the toggle behavior reliable than save one node when the feature is off.

Happy to revisit if a maintainer prefers the conditional-insert path though.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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,
Expand All @@ -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();
},
},
});