Skip to content

audio-compressor plugin: add Auto Track Gain to boost quiet tracks#4440

Open
emilichu wants to merge 1 commit intopear-devs:masterfrom
emilichu:feat/audio-compressor-auto-track-gain
Open

audio-compressor plugin: add Auto Track Gain to boost quiet tracks#4440
emilichu wants to merge 1 commit intopear-devs:masterfrom
emilichu:feat/audio-compressor-auto-track-gain

Conversation

@emilichu
Copy link
Copy Markdown

@emilichu emilichu commented Apr 26, 2026

Summary

The Audio Compressor plugin tames loud peaks but never raises quiet tracks, so playlists with mixed loudness still
need constant manual volume adjustment. This adds an opt-in Auto Track Gain mode that reads YouTube's
playerConfig.audioConfig.loudnessDb per track and applies compensating gain (capped, configurable) to anything below
the reference level.

Closes #3032.

Changes

  • New Chain class wires source → DynamicsCompressor → trackGain → destination once on peard:audio-can-play and
    adjusts trackGain per track instead of swapping nodes.
  • onConfigChange updates the live GainNode instead of rebuilding the chain.
  • Compressor settings are byte-identical to before; the new code path only activates when Auto track gain is
    enabled.
  • Two new menu entries:
    • Auto track gain: checkbox (off by default)
    • Maximum gain: 6 / 9 / 12 / 15 / 18 / 24 dB
  • New i18n keys under plugins.audio-compressor.menu.

Test plan

  • Existing users with Audio Compressor enabled and Auto track gain off see no change.
  • Enabling Auto track gain mid-track lifts quiet tracks within ~100 ms.
  • Disabling it mid-track returns to baseline within ~100 ms.
  • Tracks at or above YouTube reference loudness receive 0 dB extra gain.
  • DevTools shows [audio-compressor] track loudness X.X dB → track gain Y.Y dB on each track change.
  • Switching tracks updates the gain to match the new track's loudness.

Summary by CodeRabbit

  • New Features

    • Enhanced audio compressor with configurable track-gain control
    • Automatic loudness compensation adjusts gain based on detected audio levels
    • New menu options: auto track gain toggle and maximum gain adjustment limits
  • Localization

    • Added English UI labels for the new audio-compressor menu entries

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 26, 2026

📝 Walkthrough

Walkthrough

The audio-compressor plugin now rebuilds audio routing via a dedicated Chain and applies configurable track-gain compensation computed from YouTube loudness metadata. New i18n keys add menu labels for enabling auto track gain and selecting a maximum gain cap.

Changes

Cohort / File(s) Summary
i18n translations
src/i18n/resources/en.json
Added plugins.audio-compressor.menu entries: auto-track-gain label and nested maximum-gain label for the plugin UI.
Audio compressor plugin
src/plugins/audio-compressor.ts
Replaced Storage-based node reuse with a dedicated Chain that rebuilds routing (source → compressor → trackGain → destination). Added AudioCompressorPluginConfig export and menu for autoTrackGain and maxTrackGainDb. On peard:audio-can-play computes/apply track gain from YouTube loudnessDb/perceptualLoudnessDb with retry and clamping, ramps gain updates, and updates renderer lifecycle (start, onConfigChange, stop) and teardown logic.

Sequence Diagram

sequenceDiagram
    participant Plugin as Audio-Compressor Plugin
    participant Player as YouTube Player
    participant Chain as Audio Chain
    participant Context as AudioContext

    Player->>Plugin: peard:audio-can-play
    Plugin->>Player: request loudnessDb / perceptualLoudnessDb (with retries)
    Player-->>Plugin: loudness metadata (or null)
    Plugin->>Plugin: compute track gain (clamp to maxTrackGainDb)
    Plugin->>Chain: rebuild routing (disconnect source → destination)
    Chain->>Context: connect source → compressor → trackGain → destination
    Plugin->>Chain: apply linear ramp to trackGain
    Chain-->>Context: adjusted output level
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰
I tweaked the chain with nimble paws,
I chased down quiet, measured flaws,
A gentle ramp, a careful clamp,
Now every track sings round and damp,
Hooray — the playlist hums because! 🎶

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding an Auto Track Gain feature to the audio-compressor plugin to boost quiet tracks, which matches the core functionality implemented in the PR.
Linked Issues check ✅ Passed The PR fully implements the objectives from issue #3032 by reading YouTube's loudnessDb metadata and applying per-track gain adjustments to normalize playback volume across tracks without manual adjustment.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing the Auto Track Gain feature: i18n keys for the new UI, plugin configuration, chain-based audio routing, and per-track loudness compensation logic.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@emilichu emilichu changed the title feat(audio-compressor): add Auto Track Gain to boost quiet tracks audio-compressor plugin: add Auto Track Gain to boost quiet tracks Apr 26, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
src/plugins/audio-compressor.ts (3)

14-14: Consider exporting/aligning MAX_TRACK_GAIN_CHOICES with the config default.

maxTrackGainDb defaults to 12 (line 103, 182), which is a member of MAX_TRACK_GAIN_CHOICES. If a user (or future config migration) sets maxTrackGainDb to a value not in this list, no radio item will be checked in the menu — the user has no visual indication of the current setting. Either narrow the type ((typeof MAX_TRACK_GAIN_CHOICES)[number]) or add a defensive fallback that highlights the closest choice.

-const MAX_TRACK_GAIN_CHOICES = [6, 9, 12, 15, 18, 24] as const;
+const MAX_TRACK_GAIN_CHOICES = [6, 9, 12, 15, 18, 24] as const;
+type MaxTrackGainDb = (typeof MAX_TRACK_GAIN_CHOICES)[number];

…and tighten maxTrackGainDb: MaxTrackGainDb in AudioCompressorPluginConfig.

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

In `@src/plugins/audio-compressor.ts` at line 14, Export and narrow
MAX_TRACK_GAIN_CHOICES and align the config type so maxTrackGainDb is
constrained to those choices: change MAX_TRACK_GAIN_CHOICES to an exported const
and use a union type like (typeof MAX_TRACK_GAIN_CHOICES)[number] for
AudioCompressorPluginConfig.maxTrackGainDb (i.e., tighten the type on
maxTrackGainDb in AudioCompressorPluginConfig). Also add a defensive runtime
fallback wherever the UI builds the radio items (or reads maxTrackGainDb) to map
any unexpected numeric value to the closest entry in MAX_TRACK_GAIN_CHOICES so
at least one radio item is checked and the UI reflects the effective setting.

106-128: Add a clarifying comment explaining the perceptualLoudnessDb fallback.

The fallback from loudnessDb to perceptualLoudnessDb is semantically different: loudnessDb represents standard loudness normalization (LUFS-based), while perceptualLoudnessDb uses a psychoacoustic model weighted for human perception. Both serve loudness normalization but measure different aspects. A brief inline comment explaining why this fallback is acceptable (e.g., "fallback for content without explicit loudness metadata, perceptual loudness provides reasonable estimate") would clarify the choice. No change required if behavior matches PR testing.

🤖 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 106 - 128, Add a brief inline
comment inside getContentLoudnessDb explaining why perceptualLoudnessDb is used
as a fallback for loudnessDb: note that loudnessDb is the LUFS/standard loudness
value while perceptualLoudnessDb is a psychoacoustic estimate and is acceptable
as a fallback for content lacking explicit loudness metadata; place this comment
near the assignment of loudnessDb (the expression using
response?.playerConfig?.audioConfig?.loudnessDb ??
response?.playerConfig?.audioConfig?.perceptualLoudnessDb) so readers understand
the semantic difference and rationale.

71-77: Cancel scheduled gain values before scheduling a new ramp.

linearRampToValueAtTime appends a new ramp event to the AudioParam's timeline, starting from the value at the previous event's end time. If applyTrackGain is invoked again before the prior 0.1s ramp completes (e.g., rapid track switching or onConfigChange firing during playback), the new ramp will start from a partially-completed value, creating audible artifacts or incorrect target gain. Mixing direct value writes and ramps without canceling scheduled events is error-prone.

♻️ Anchor the current value, then ramp
   applyTrackGain(gainDb: number) {
     if (!this.context || !this.trackGain) return;
+    const now = this.context.currentTime;
+    this.trackGain.gain.cancelScheduledValues(now);
+    this.trackGain.gain.setValueAtTime(this.trackGain.gain.value, now);
     this.trackGain.gain.linearRampToValueAtTime(
       dbToLinear(gainDb),
-      this.context.currentTime + 0.1,
+      now + 0.1,
     );
   }
🤖 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 71 - 77, applyTrackGain
currently appends ramps without clearing prior scheduled events causing ramps to
start from in-flight values; update applyTrackGain to first anchor and cancel
previous schedules on trackGain.gain by calling
cancelScheduledValues(this.context.currentTime) (or cancelAndHoldAtTime where
supported), set the current instantaneous value with
setValueAtTime(dbToLinear(currentDb) or getValueAtTime equivalent) at
this.context.currentTime, then schedule the linearRampToValueAtTime to
dbToLinear(gainDb) at this.context.currentTime + 0.1; use the existing symbols
applyTrackGain, this.trackGain.gain, dbToLinear and this.context.currentTime to
locate and modify the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/plugins/audio-compressor.ts`:
- Around line 130-156: The retry timeouts in updateTrackGain() must be tracked
and cancelled to avoid races and work after teardown: store the timeout id when
calling setTimeout in updateTrackGain (e.g., lastUpdateTrackGainTimer),
clearTimeout(lastUpdateTrackGainTimer) before scheduling a new retry (at the top
of audioCanPlayHandler) and in stop() to cancel pending retries; keep the
retriesLeft logic but ensure the scheduled function uses the stored id
lifecycle. Also gate the console.log behind a debug flag in currentConfig (or
remove it for release) so logs only emit when currentConfig.debug is true.
- Around line 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.

---

Nitpick comments:
In `@src/plugins/audio-compressor.ts`:
- Line 14: Export and narrow MAX_TRACK_GAIN_CHOICES and align the config type so
maxTrackGainDb is constrained to those choices: change MAX_TRACK_GAIN_CHOICES to
an exported const and use a union type like (typeof
MAX_TRACK_GAIN_CHOICES)[number] for AudioCompressorPluginConfig.maxTrackGainDb
(i.e., tighten the type on maxTrackGainDb in AudioCompressorPluginConfig). Also
add a defensive runtime fallback wherever the UI builds the radio items (or
reads maxTrackGainDb) to map any unexpected numeric value to the closest entry
in MAX_TRACK_GAIN_CHOICES so at least one radio item is checked and the UI
reflects the effective setting.
- Around line 106-128: Add a brief inline comment inside getContentLoudnessDb
explaining why perceptualLoudnessDb is used as a fallback for loudnessDb: note
that loudnessDb is the LUFS/standard loudness value while perceptualLoudnessDb
is a psychoacoustic estimate and is acceptable as a fallback for content lacking
explicit loudness metadata; place this comment near the assignment of loudnessDb
(the expression using response?.playerConfig?.audioConfig?.loudnessDb ??
response?.playerConfig?.audioConfig?.perceptualLoudnessDb) so readers understand
the semantic difference and rationale.
- Around line 71-77: applyTrackGain currently appends ramps without clearing
prior scheduled events causing ramps to start from in-flight values; update
applyTrackGain to first anchor and cancel previous schedules on trackGain.gain
by calling cancelScheduledValues(this.context.currentTime) (or
cancelAndHoldAtTime where supported), set the current instantaneous value with
setValueAtTime(dbToLinear(currentDb) or getValueAtTime equivalent) at
this.context.currentTime, then schedule the linearRampToValueAtTime to
dbToLinear(gainDb) at this.context.currentTime + 0.1; use the existing symbols
applyTrackGain, this.trackGain.gain, dbToLinear and this.context.currentTime to
locate and modify the code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5468164e-7988-4516-962b-b1f3bd1e9ce9

📥 Commits

Reviewing files that changed from the base of the PR and between 7b1c574 and 6e19d66.

📒 Files selected for processing (2)
  • src/i18n/resources/en.json
  • src/plugins/audio-compressor.ts

Comment thread src/plugins/audio-compressor.ts
Comment on lines 158 to 163
const audioCanPlayHandler = ({
detail: { audioSource, audioContext },
}: CustomEvent<Compressor>) => {
storage.connectToCompressor(
audioSource,
audioContext,
createCompressorNode(audioContext),
);
chain.build(audioSource, audioContext);
updateTrackGain();
};
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.

Reads YouTube's per-track loudnessDb metadata and applies compensating
gain to tracks quieter than the reference level.

Closes pear-devs#3032
@emilichu emilichu force-pushed the feat/audio-compressor-auto-track-gain branch from 6e19d66 to 57f7fc6 Compare April 26, 2026 23:58
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/plugins/audio-compressor.ts (1)

71-77: Anchor current value before scheduling a fresh ramp.

linearRampToValueAtTime appends an automation event without invalidating any previously scheduled ones. Two ramps scheduled within the 0.1 s window (e.g., a fast onConfigChange followed by an updateTrackGain retry resolving) can produce a piecewise trajectory that overshoots or stalls before reaching the new target. The conventional Web Audio idiom is to cancel future automation and anchor the current value first.

♻️ Suggested fix
   applyTrackGain(gainDb: number) {
     if (!this.context || !this.trackGain) return;
-    this.trackGain.gain.linearRampToValueAtTime(
-      dbToLinear(gainDb),
-      this.context.currentTime + 0.1,
-    );
+    const now = this.context.currentTime;
+    const gain = this.trackGain.gain;
+    gain.cancelScheduledValues(now);
+    gain.setValueAtTime(gain.value, now);
+    gain.linearRampToValueAtTime(dbToLinear(gainDb), now + 0.1);
   }
🤖 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 71 - 77, In applyTrackGain,
before calling this.trackGain.gain.linearRampToValueAtTime, cancel any future
automation and anchor the current value so we don't create overlapping ramps:
call this.trackGain.gain.cancelScheduledValues(this.context.currentTime), set
the gain to the current instantaneous value (e.g.,
this.trackGain.gain.setValueAtTime(this.trackGain.gain.value,
this.context.currentTime)) and then schedule
linearRampToValueAtTime(dbToLinear(gainDb), this.context.currentTime + 0.1);
this uses this.context, this.trackGain and dbToLinear to locate the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/plugins/audio-compressor.ts`:
- Around line 71-77: In applyTrackGain, before calling
this.trackGain.gain.linearRampToValueAtTime, cancel any future automation and
anchor the current value so we don't create overlapping ramps: call
this.trackGain.gain.cancelScheduledValues(this.context.currentTime), set the
gain to the current instantaneous value (e.g.,
this.trackGain.gain.setValueAtTime(this.trackGain.gain.value,
this.context.currentTime)) and then schedule
linearRampToValueAtTime(dbToLinear(gainDb), this.context.currentTime + 0.1);
this uses this.context, this.trackGain and dbToLinear to locate the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 323cffb0-19bf-430a-b0a8-b43bfd49a306

📥 Commits

Reviewing files that changed from the base of the PR and between 6e19d66 and 57f7fc6.

📒 Files selected for processing (2)
  • src/i18n/resources/en.json
  • src/plugins/audio-compressor.ts
✅ Files skipped from review due to trivial changes (1)
  • src/i18n/resources/en.json

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request]: Normalize Song Volume

1 participant