Skip to content

fix(audio): custom volume normalization to resolve muffled audio#3807

Open
kairosci wants to merge 6 commits into
MetrolistGroup:mainfrom
kairosci:fix/audio-normalization
Open

fix(audio): custom volume normalization to resolve muffled audio#3807
kairosci wants to merge 6 commits into
MetrolistGroup:mainfrom
kairosci:fix/audio-normalization

Conversation

@kairosci
Copy link
Copy Markdown
Contributor

@kairosci kairosci commented May 25, 2026

Problem

Audio frequently sounds muffled.

Cause

The use of Android's native LoudnessEnhancer for volume normalization triggers the hardware DSP on many devices, applying aggressive dynamic range compression that squashes high frequencies and muffles the audio even when simply normalizing.

Solution

  • Replaced LoudnessEnhancer with a custom VolumeNormalizationAudioProcessor.
  • The new processor applies pure linear gain directly to 16-bit PCM audio samples inside the ExoPlayer chain.
  • Bypassed the Android OS audio effects pipeline completely to preserve pristine audio quality.
  • Removed all LoudnessEnhancer session management logic.

Testing

  • Successfully compiled assembleFossDebug.
  • Verified audio normalization path bypasses LoudnessEnhancer and correctly handles gain mathematically.

Related Issues

  • N/A

Summary by CodeRabbit

  • New Features

    • Per-track volume normalization applied immediately from cached settings and updated asynchronously for smoother playback.
    • Normalization now runs earlier in the audio pipeline, supports common PCM/float encodings, and offers enable/disable plus configurable target gain.
  • Bug Fixes

    • Safer error handling and simplified audio-session tracking: failures now disable normalization without disrupting playback.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 25, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Audio loudness normalization is migrated from Android's LoudnessEnhancer to a new VolumeNormalizationAudioProcessor. MusicService instantiates and wires the processor, applies cached gain immediately, computes per-track target gain asynchronously, and simplifies audio-effect session tracking and renderer wiring.

Changes

Loudness Normalization Processor Replacement

Layer / File(s) Summary
VolumeNormalizationAudioProcessor implementation
app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt
New AudioProcessor handling format/config, synchronized setTargetGain(gainMb) (millibel→linear), enabled flag, multi-encoding sample processing (PCM16/24/32, float), output buffering, EOS/flush/reset, and 24-bit helpers.
MusicService field and initialization updates
app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt (range_25f2c19db0ef, range_edcc7a53d9a6, range_e22fa3a65ba2)
Replaces LoudnessEnhancer import/field with volumeNormalizationProcessor; updates initialization and preference watcher to call setupAudioNormalization() and applyCachedAudioNormalizationNow().
MusicService cached normalization and async setup
app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt (range_d078060637db, range_aa578201aa62)
Adds applyCachedAudioNormalizationNow() to apply cached enabled/gain and setupAudioNormalization() to compute/clamp per-track target gain, update cached flags, set processor target gain/enabled state, and disable processor on non-cancellation exceptions.
MusicService session management and renderer wiring
app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt (range_81ec1c1206c0, range_4b70878e0ee9, range_0f853ca045b5, range_e74d7532bfa3)
openAudioEffectSession()/closeAudioEffectSession() now track open-session state and broadcast audio-effect control intents without requiring LoudnessEnhancer; call sites updated and volumeNormalizationProcessor inserted into DefaultAudioSink processor chain before equalizer and silence processors.
sequenceDiagram
  participant MusicService
  participant VolumeNormalizationAudioProcessor
  participant DefaultAudioSink
  MusicService->>VolumeNormalizationAudioProcessor: configure(format)
  MusicService->>VolumeNormalizationAudioProcessor: setTargetGain(gainMb)
  DefaultAudioSink->>VolumeNormalizationAudioProcessor: queueInput(buffer)
  alt enabled && targetGainMb != 0
    VolumeNormalizationAudioProcessor->>DefaultAudioSink: output processed buffer (gain applied)
  else disabled or zero gain
    VolumeNormalizationAudioProcessor->>DefaultAudioSink: output passthrough buffer
  end
  DefaultAudioSink->>VolumeNormalizationAudioProcessor: queueEndOfStream()
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • nyxiereal
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title clearly and concisely describes the main change: replacing the native LoudnessEnhancer with a custom audio processor to fix muffled audio.
Description check ✅ Passed The pull request description follows the template structure with all required sections completed: Problem, Cause, Solution, Testing, and Related Issues are all present and adequately filled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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.

@kairosci kairosci marked this pull request as draft May 25, 2026 20:20
Copy link
Copy Markdown
Contributor

@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 (2)
app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt (2)

92-98: 💤 Low value

Simplify redundant buffer identity check.

The condition outputBuffer === inputBuffer compares the class field outputBuffer with the method parameter inputBuffer. This will rarely (if ever) be true and adds confusion. The EMPTY_BUFFER check is sufficient.

♻️ Proposed simplification
-        if (outputBuffer === EMPTY_BUFFER || outputBuffer === inputBuffer) {
-            outputBuffer = ByteBuffer.allocateDirect(inputSize).order(ByteOrder.nativeOrder())
-        } else if (outputBuffer.capacity() < inputSize) {
+        if (outputBuffer === EMPTY_BUFFER || outputBuffer.capacity() < inputSize) {
             outputBuffer = ByteBuffer.allocateDirect(inputSize).order(ByteOrder.nativeOrder())
         } else {
             outputBuffer.clear()
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt`
around lines 92 - 98, Remove the redundant identity comparison between the field
outputBuffer and the parameter inputBuffer in the buffer allocation logic inside
VolumeNormalizationAudioProcessor (the block that currently tests outputBuffer
=== EMPTY_BUFFER || outputBuffer === inputBuffer). Change the first condition to
only check outputBuffer === EMPTY_BUFFER (keep the subsequent capacity check and
clear behavior unchanged) so the code reads: if (outputBuffer === EMPTY_BUFFER)
{ allocate... } else if (outputBuffer.capacity() < inputSize) {...} else {
outputBuffer.clear() }.

24-37: ⚡ Quick win

Cross-thread visibility: linearGain and enabled should be @Volatile.

setTargetGain() is synchronized and called from the main thread, while queueInput() runs on the audio rendering thread and reads linearGain and enabled without synchronization. Without @Volatile, the audio thread may see stale values.

♻️ Proposed fix to ensure cross-thread visibility
-    var enabled = false
+    `@Volatile`
+    var enabled = false
         set(value) {
             if (field != value) {
                 field = value
                 Timber.tag(TAG).d("Normalization processor enabled: $value")
             }
         }

-    private var inputBuffer: ByteBuffer = EMPTY_BUFFER
     private var outputBuffer: ByteBuffer = EMPTY_BUFFER
     private var inputEnded = false

     private var targetGainMb: Int = 0
-    private var linearGain: Double = 1.0
+    `@Volatile`
+    private var linearGain: Double = 1.0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt`
around lines 24 - 37, The fields accessed across threads (enabled and
linearGain) need the `@Volatile` modifier to guarantee visibility between the main
thread (setTargetGain()) and the audio rendering thread (queueInput()); update
the declarations of var enabled and var linearGain to be annotated with
`@Volatile` so reads in queueInput() always see the latest values written by
setTargetGain().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt`:
- Around line 92-98: Remove the redundant identity comparison between the field
outputBuffer and the parameter inputBuffer in the buffer allocation logic inside
VolumeNormalizationAudioProcessor (the block that currently tests outputBuffer
=== EMPTY_BUFFER || outputBuffer === inputBuffer). Change the first condition to
only check outputBuffer === EMPTY_BUFFER (keep the subsequent capacity check and
clear behavior unchanged) so the code reads: if (outputBuffer === EMPTY_BUFFER)
{ allocate... } else if (outputBuffer.capacity() < inputSize) {...} else {
outputBuffer.clear() }.
- Around line 24-37: The fields accessed across threads (enabled and linearGain)
need the `@Volatile` modifier to guarantee visibility between the main thread
(setTargetGain()) and the audio rendering thread (queueInput()); update the
declarations of var enabled and var linearGain to be annotated with `@Volatile` so
reads in queueInput() always see the latest values written by setTargetGain().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 79921901-695b-43e3-8141-f95305e0bb17

📥 Commits

Reviewing files that changed from the base of the PR and between d952e61 and d97e39a.

📒 Files selected for processing (2)
  • app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt
  • app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt

@kairosci kairosci force-pushed the fix/audio-normalization branch from d97e39a to c56c8ba Compare May 25, 2026 20:33
@kairosci kairosci marked this pull request as ready for review May 26, 2026 18:11
Copy link
Copy Markdown
Contributor

@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 (2)
app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt (2)

33-34: 💤 Low value

Thread visibility gap between setTargetGain and queueInput.

setTargetGain is @Synchronized, but queueInput reads targetGainMb and linearGain without synchronization. Since ExoPlayer calls these methods from different threads (playback vs. audio rendering), there's no happens-before guarantee that writes in setTargetGain are visible to queueInput.

In practice, stale values for a few audio samples are likely inaudible, but for correctness consider marking linearGain as @Volatile:

♻️ Proposed fix
-    private var linearGain: Double = 1.0
+    `@Volatile`
+    private var linearGain: Double = 1.0

Also applies to: 41-48, 96-98

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt`
around lines 33 - 34, set the shared fields used by different threads to be
volatile so writes in setTargetGain are visible to queueInput: annotate
linearGain (and targetGainMb) with Kotlin's `@Volatile` and keep setTargetGain
synchronized; this ensures updates to linearGain and targetGainMb made in
setTargetGain are seen by queueInput and other audio-rendering threads (refer to
setTargetGain, queueInput, targetGainMb, linearGain).

11-39: 💤 Low value

Unused field inputBuffer at line 29.

The inputBuffer field is initialized but never read—it's only assigned to in reset(). The parameter inputBuffer in queueInput shadows this field, which adds confusion. Consider removing this unused field.

♻️ Proposed fix
-    private var inputBuffer: ByteBuffer = EMPTY_BUFFER
     private var outputBuffer: ByteBuffer = EMPTY_BUFFER
     private var inputEnded = false

And in reset():

     override fun reset() {
         `@Suppress`("DEPRECATION")
         flush()
-        inputBuffer = EMPTY_BUFFER
         sampleRate = 0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt`
around lines 11 - 39, The private field inputBuffer is never read and is
shadowed by the parameter in queueInput; remove the unused field declaration
(private var inputBuffer) and update reset() and any other places that assigned
to it so they operate on the local/parameter buffers (e.g., queueInput and reset
methods should only manipulate the parameter/local buffers and outputBuffer),
ensuring no remaining references to inputBuffer remain and that queueInput's
parameter name is not shadowing any removed member.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt`:
- Around line 33-34: set the shared fields used by different threads to be
volatile so writes in setTargetGain are visible to queueInput: annotate
linearGain (and targetGainMb) with Kotlin's `@Volatile` and keep setTargetGain
synchronized; this ensures updates to linearGain and targetGainMb made in
setTargetGain are seen by queueInput and other audio-rendering threads (refer to
setTargetGain, queueInput, targetGainMb, linearGain).
- Around line 11-39: The private field inputBuffer is never read and is shadowed
by the parameter in queueInput; remove the unused field declaration (private var
inputBuffer) and update reset() and any other places that assigned to it so they
operate on the local/parameter buffers (e.g., queueInput and reset methods
should only manipulate the parameter/local buffers and outputBuffer), ensuring
no remaining references to inputBuffer remain and that queueInput's parameter
name is not shadowing any removed member.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fe454c35-efc6-44c7-84f4-77aeaa86afe0

📥 Commits

Reviewing files that changed from the base of the PR and between d97e39a and c56c8ba.

📒 Files selected for processing (2)
  • app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt
  • app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt

Copy link
Copy Markdown
Contributor

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt`:
- Around line 33-37: queueInput can observe targetGainMb and linearGain
mid-update because setTargetGain writes them in two steps; fix by introducing an
atomic snapshot of the gain state and use that single snapshot in queueInput and
setTargetGain. Concretely, create a small immutable holder (e.g., GainState with
targetGainMb and linearGain) and replace the two `@Volatile` fields with one
AtomicReference<GainState> (or a single `@Volatile` GainState), update both values
by swapping the GainState in setTargetGain, and read the snapshot once at the
start of queueInput (referencing VolumeNormalizationAudioProcessor, queueInput,
setTargetGain, targetGainMb, linearGain) so buffer processing always uses a
consistent pair of values.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8a49d079-f505-420b-ae60-8b2830c16aa2

📥 Commits

Reviewing files that changed from the base of the PR and between c56c8ba and cc3fd22.

📒 Files selected for processing (1)
  • app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt

@kairosci
Copy link
Copy Markdown
Contributor Author

TESTED: There is a difference, and you can really feel.

Copy link
Copy Markdown
Contributor

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt`:
- Around line 57-63: VolumeNormalizationAudioProcessor currently
advertises/passes through 24/32/float PCM which breaks downstream processors
that only accept C.ENCODING_PCM_16BIT; change
VolumeNormalizationAudioProcessor.configure() so it only outputs
C.ENCODING_PCM_16BIT (set bytesPerSample = 2) and either reject non-16-bit
inputs with AudioProcessor.UnhandledAudioFormatException or perform an explicit
conversion in the sample processing path (the code around lines handling
bytesPerSample and the sample processing loop in
VolumeNormalizationAudioProcessor.process()/configure(), referenced as
bytesPerSample switch and sample processing block) to convert 24/32/float inputs
to 16-bit PCM before returning the configured output format so
CustomEqualizerAudioProcessor and SilenceDetectorAudioProcessor receive
C.ENCODING_PCM_16BIT.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7168a3eb-2fbe-463e-9643-f156f448cc5e

📥 Commits

Reviewing files that changed from the base of the PR and between 0352b10 and d871363.

📒 Files selected for processing (1)
  • app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 27, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@kairosci
Copy link
Copy Markdown
Contributor Author

@nyxiereal ready to review.

Copy link
Copy Markdown
Contributor

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt`:
- Around line 102-125: In VolumeNormalizationAudioProcessor, the 24/32-bit
branches currently apply gain to full-width samples then clamp to 16-bit,
causing widespread clipping; instead downshift the raw sample into 16-bit range
first (use read24Bit(...) shr 8 for C.ENCODING_PCM_24BIT and
inputBuffer.getInt() shr 16 for C.ENCODING_PCM_32BIT), then apply gain to that
downshifted value (use gain.linearGain), coerceIn(-32768, 32767) and toShort for
output; when applyGain is false just use the downshifted value converted to
Short. Ensure you reference read24Bit, gain.linearGain, applyGain, and the two
C.ENCODING_PCM_* branches when making the change.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4ecb02d5-b02c-4944-8605-836bac7149a2

📥 Commits

Reviewing files that changed from the base of the PR and between 0352b10 and b2a7a71.

📒 Files selected for processing (1)
  • app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt

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.

1 participant