Skip to content

fix(audio): apply normalization immediately from YouTube response, reset stale cross-track gain#3905

Draft
kairosci wants to merge 4 commits into
MetrolistGroup:mainfrom
kairosci:fix-audio-normalization-timing
Draft

fix(audio): apply normalization immediately from YouTube response, reset stale cross-track gain#3905
kairosci wants to merge 4 commits into
MetrolistGroup:mainfrom
kairosci:fix-audio-normalization-timing

Conversation

@kairosci
Copy link
Copy Markdown
Contributor

@kairosci kairosci commented Jun 4, 2026

Problem

After PR #3807, transitioning between tracks causes playback to start at full volume — normalization takes ~3 seconds to kick in, even with crossfade disabled.

Cause

Two root causes:

  1. Stale cross-track state retained: In setupAudioNormalization(), when the Room database has no loudness data yet for the new track (first playback), the format == null branch did nothing — keeping the previous track's gain values on the processor. This caused wrong boost/cut to be applied to the new track.

  2. Slow async pipeline: Loudness data fetched from the YouTube response goes through Room→Flow→Combine→coroutine before being applied, adding latency even when the data is already available at stream fetch time.

Solution

  • Reset to neutral on missing data: The format == null branch in setupAudioNormalization() now immediately resets the processor to neutral (enabled=false, gain=null) instead of keeping stale cross-track state. This prevents the previous track's gain from leaking into the new track.

  • Bypass the Room pipeline: New applyNormalizationFromLoudnessData() is called directly from the data source factory right after the YouTube response provides loudness data — applying gain to the VolumeNormalizationAudioProcessor synchronously, before a single audio sample reaches the processor. The Room→Flow→Combine reactive path becomes a secondary convergence mechanism.

Testing

  • assembleFossDebug builds successfully
  • Verified the direct application path is called immediately after format upsert in the data source resolver
  • The reactive combine path remains intact for preference changes and edge cases

Related Issues

Summary by CodeRabbit

  • Bug Fixes

    • Reset normalization to neutral when loudness data is missing to prevent inconsistent playback levels.
    • Apply loudness-based gain only to the currently playing track during crossfades; avoid global updates that cause artifacts.
    • Immediately refresh normalization state after stream loudness is fetched and persisted.
  • Improvements

    • Prefer perceptual loudness when available; otherwise derive measured loudness with an aggressive offset.
    • Thread-safe, in-memory caching of measured loudness, target gain, and enablement for more stable, immediate normalization.
  • Performance

    • Reusable output buffer and simplified gain path reduce audio processing and memory churn when normalization is disabled or gain is neutral.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 4, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 81fe1535-61ba-4f42-aa50-6bd7e9009538

📥 Commits

Reviewing files that changed from the base of the PR and between ae0f217 and da648fa.

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

📝 Walkthrough

Walkthrough

Adds thread-safe normalization processor storage, volatile in-memory loudness/gain caches, a helper to compute/apply normalization from loudness inputs, setup/fetch path updates for missing loudness, and refactors VolumeNormalizationAudioProcessor to reuse an output buffer and simplify gain application.

Changes

Volume normalization from fresh loudness data (MusicService)

Layer / File(s) Summary
Concurrent storage & import
app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt
Replaces HashMap with ConcurrentHashMap for playerNormalizationProcessors and adds the corresponding import.
Volatile cached fields
app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt
Adds @Volatile cached measured loudness (cachedMeasuredLufs, cachedMeasuredLufsMediaId) and cached normalization target gain/enablement fields for cross-thread visibility.
applyNormalizationFromLoudnessData helper
app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt
Adds applyNormalizationFromLoudnessData(mediaId, loudnessDb, perceptualLoudnessDb) which caches measured loudness, computes/clamps target gain when measurable, caches results, and updates/disables processors (respects crossfade/current player behavior).
setupAudioNormalization adjustments
app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt
Prefers cached measured loudness for the current mediaId, falls back to DB fields (applying aggressive offset for raw loudness), preserves cache when format row is missing, and resets normalization to neutral when format exists but loudness is unavailable.
Fetch integration invokes helper
app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt
After persisting FormatEntity loudness fields during stream fetch, calls applyNormalizationFromLoudnessData immediately for that mediaId.

Volume normalization processor refactor

Layer / File(s) Summary
Reusable output buffer & queueInput simplification
app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt
Adds internal reusable buffer: ByteBuffer, refactors queueInput to copy input bytes when processor disabled or target gain is zero, otherwise apply per-encoding sample scaling with gain; updates getOutput, reset, and replaceOutputBuffer to use the reusable buffer.

Sequence Diagram(s)

sequenceDiagram
  participant StreamFetch
  participant applyNormalizationFromLoudnessData
  participant MusicService
  participant VolumeNormalizationAudioProcessor
  StreamFetch->>applyNormalizationFromLoudnessData: call(mediaId, loudnessDb, perceptualLoudnessDb)
  applyNormalizationFromLoudnessData->>MusicService: cache measuredLufs and mediaId
  applyNormalizationFromLoudnessData->>MusicService: check cachedNormalizationEnabled
  alt normalization disabled
    applyNormalizationFromLoudnessData->>VolumeNormalizationAudioProcessor: disable()
  else normalization enabled & measuredLufs present
    applyNormalizationFromLoudnessData->>applyNormalizationFromLoudnessData: compute & clamp targetGainMb
    applyNormalizationFromLoudnessData->>VolumeNormalizationAudioProcessor: setTargetGain(targetGainMb), setEnabled(true)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~40 minutes

Possibly related PRs

  • MetrolistGroup/Metrolist#3807: Modifies MusicService normalization pipeline and VolumeNormalizationAudioProcessor integration; closely related.
  • MetrolistGroup/Metrolist#3358: Earlier changes to loudness-level preference and normalization setup that interact with cached target-gain logic.

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 title clearly summarizes the main changes: fixing normalization application timing from YouTube response and resetting stale cross-track gain on track transitions.
Description check ✅ Passed The description comprehensively covers all required template sections: Problem (startup at full volume), Cause (two root causes identified), Solution (specific changes), Testing (build verification and direct path validation), and Related Issues (references #3807 timing regression).
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
Copy link
Copy Markdown
Contributor Author

kairosci commented Jun 4, 2026

@yarikeua @Bec-de-Xorbin It seems to work, can you test and confirm that it works?

@yarikeua
Copy link
Copy Markdown

yarikeua commented Jun 4, 2026

@kairosci I tested your version and now everything works without delays.

!Update
when I tested with tracks from @Bec-de-Xorbin , the problem persisted

@Bec-de-Xorbin
Copy link
Copy Markdown

@yarikeua @Bec-de-Xorbin It seems to work, can you test and confirm that it works?

Thanks for the quick reaction! Unfortunately no, it still takes ~3 seconds for normalization to kick in.

@Bec-de-Xorbin
Copy link
Copy Markdown

@kairosci I tested your version and now everything works without delays.

Hmm. I'm using Metrolist PR 3905 and when this (https://music.youtube.com/watch?v=i97OkCXwotE) ends and this (https://music.youtube.com/watch?v=dKWfA9LCCOg) begins it takes ~3 seconds to lower volume.

@yarikeua
Copy link
Copy Markdown

yarikeua commented Jun 4, 2026

@kairosci There's a slight delay only when changing the normalization settings, specifically the loudness level. Is this how it should be?

@yarikeua
Copy link
Copy Markdown

yarikeua commented Jun 4, 2026

@kairosci I tested your version and now everything works without delays.

Hmm. I'm using Metrolist PR 3905 and when this (https://music.youtube.com/watch?v=i97OkCXwotE) ends and this (https://music.youtube.com/watch?v=dKWfA9LCCOg) begins it takes ~3 seconds to lower volume.

@Bec-de-Xorbin Yes, you are right, when I tested with these tracks, the problem persisted.

@kairosci kairosci force-pushed the fix-audio-normalization-timing branch from 17c1b46 to 36016fe Compare June 4, 2026 18:11
@kairosci
Copy link
Copy Markdown
Contributor Author

kairosci commented Jun 4, 2026

@yarikeua @Bec-de-Xorbin could you take another look? Thanks

@yarikeua
Copy link
Copy Markdown

yarikeua commented Jun 4, 2026

@kairosci
Screenshot_2026-06-04-21-47-53-32_d800bdcb138acf830d1e1a76bd31f651

@Bec-de-Xorbin
Copy link
Copy Markdown

@kairosci Screenshot_2026-06-04-21-47-53-32_d800bdcb138acf830d1e1a76bd31f651

Yes, this. Not working at all.

@kairosci
Copy link
Copy Markdown
Contributor Author

kairosci commented Jun 5, 2026

@yarikeua @Bec-de-Xorbin could you take another look? Thanks

@yarikeua
Copy link
Copy Markdown

yarikeua commented Jun 5, 2026

@kairosci

@yarikeua @Bec-de-Xorbin could you take another look? Thanks

Same. Not working at all.
Screenshot_2026-06-05-18-11-08-07_d800bdcb138acf830d1e1a76bd31f651

@kairosci
Copy link
Copy Markdown
Contributor Author

kairosci commented Jun 5, 2026

do you have log about this?

@yarikeua
Copy link
Copy Markdown

yarikeua commented Jun 5, 2026

do you have log about this?

@kairosci yes
05_06-18-44-39_288.log

@kairosci
Copy link
Copy Markdown
Contributor Author

kairosci commented Jun 5, 2026

thanks

@yarikeua
Copy link
Copy Markdown

yarikeua commented Jun 5, 2026

@kairosci It works now, but just as it did originally, with a delay of 3-5 seconds.

@kairosci
Copy link
Copy Markdown
Contributor Author

kairosci commented Jun 5, 2026

@yarikeua do you have log also about this?

@yarikeua
Copy link
Copy Markdown

yarikeua commented Jun 5, 2026

@yarikeua do you have log also about this?

@kairosci yes
05_06-23-56-16_058.log

@yarikeua
Copy link
Copy Markdown

yarikeua commented Jun 5, 2026

@kairosci Nothing has changed, only the progress bar is now staying at 0:00 while the track is playing.

@kairosci kairosci force-pushed the fix-audio-normalization-timing branch from 6495f2f to 0502262 Compare June 5, 2026 22:08
@yarikeua
Copy link
Copy Markdown

yarikeua commented Jun 5, 2026

@kairosci After a clean install, the progress bar started working again. The delay is still there.

Record_2026-06-06-01-11-40_d800bdcb138acf830d1e1a76bd31f651.mp4

@kairosci kairosci force-pushed the fix-audio-normalization-timing branch from 0502262 to 29d0896 Compare June 5, 2026 22:16
@kairosci
Copy link
Copy Markdown
Contributor Author

kairosci commented Jun 5, 2026

@coderabbitai resume

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 5, 2026

✅ Action performed

Reviews resumed.

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

185-191: 💤 Low value

Remove unused read24Bit function.

This function is now dead code—the 24-bit sample reading logic was inlined directly in the ENCODING_PCM_24BIT branch at lines 104-107.

🧹 Proposed removal
-    private fun read24Bit(buffer: ByteBuffer): Int {
-        val b0 = buffer.get().toInt() and 0xFF
-        val b1 = buffer.get().toInt() and 0xFF
-        val b2 = buffer.get().toInt()
-        return (b2 shl 16) or (b1 shl 8) or b0
-    }
🤖 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 185 - 191, Remove the now-unused helper function read24Bit from
VolumeNormalizationAudioProcessor: locate the private fun read24Bit(buffer:
ByteBuffer) and delete it, and ensure no other references to read24Bit remain
(the 24-bit logic has been inlined in the ENCODING_PCM_24BIT branch), leaving
the class without dead code.
🤖 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 185-191: Remove the now-unused helper function read24Bit from
VolumeNormalizationAudioProcessor: locate the private fun read24Bit(buffer:
ByteBuffer) and delete it, and ensure no other references to read24Bit remain
(the 24-bit logic has been inlined in the ENCODING_PCM_24BIT branch), leaving
the class without dead code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: be58b35b-af80-4a81-86f3-c7671c34b7e1

📥 Commits

Reviewing files that changed from the base of the PR and between 36016fe and 29d0896.

📒 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

@yarikeua
Copy link
Copy Markdown

yarikeua commented Jun 5, 2026

@kairosci same(

Record_2026-06-06-01-39-08_d800bdcb138acf830d1e1a76bd31f651.mp4

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt (1)

2226-2228: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset normalization when the FormatEntity row is missing.

This branch still keeps the previous track's gain alive. onMediaItemTransition() calls setupAudioNormalization() before the new row exists, so the next track can start with stale normalization and, if playback is served from the URL/cache path, keep it until some later async update happens.

Suggested fix
                             isFormatNull -> {
-                                Timber.tag(TAG).d("Loudness row not ready yet; keeping cached normalization state")
+                                Timber.tag(TAG).d("Loudness row not ready yet; resetting normalization to neutral")
+                                cachedNormalizationGainMb = null
+                                cachedNormalizationEnabled = false
+                                playerNormalizationProcessors.values.forEach { it.enabled = false }
                             }
🤖 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/MusicService.kt` around
lines 2226 - 2228, The isFormatNull branch in MusicService.kt should clear/reset
the per-track normalization state instead of keeping the previous track's gain;
update the isFormatNull handling inside setupAudioNormalization() (used by
onMediaItemTransition()) to call the normalization reset path (e.g., invoke
whatever method or logic resets normalization state and related fields that
current/previous track normalization uses) so a missing FormatEntity row does
not leave stale gain applied; ensure you reference and reset the same
variables/flags used by the normal setup path so subsequent async updates can
populate fresh values.
🤖 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/MusicService.kt`:
- Around line 2152-2156: The early return guarded by isCrossfading skips
applying the freshly computed track gain to the incoming player's normalization
processor; instead, always cache the new clampedGain and ensure it is applied to
the processor instance for the player being prepared (or to the secondary
ExoPlayer's processor) even during a crossfade: modify the block around
startCrossfade()/isCrossfading so you do not return before calling
playerNormalizationProcessors.values.forEach (or target the specific processor
for the incoming player) to call setTargetGain(clampedGain) and enable the
processor, or alternatively construct the secondary ExoPlayer with normalization
forced neutral until the track-specific loudness arrives.
- Around line 2143-2158: When measuredLufs is null (i.e. no loudness data),
immediately neutralize normalization: set cachedNormalizationGainMb to 0 and
cachedNormalizationEnabled to false, and if not isCrossfading update every
playerNormalizationProcessors entry by calling setTargetGain(0) and setting
enabled = false; if isCrossfading, still update the cached values but return
early as current code does. Locate this logic in MusicService.kt around the
block using targetLufs, measuredLufs, cachedNormalizationGainMb,
cachedNormalizationEnabled, isCrossfading and playerNormalizationProcessors, and
add an else branch handling the null measuredLufs case with the steps above (use
MIN_GAIN_MB/MAX_GAIN_MB only for the normal path where measuredLufs exists).

---

Outside diff comments:
In `@app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt`:
- Around line 2226-2228: The isFormatNull branch in MusicService.kt should
clear/reset the per-track normalization state instead of keeping the previous
track's gain; update the isFormatNull handling inside setupAudioNormalization()
(used by onMediaItemTransition()) to call the normalization reset path (e.g.,
invoke whatever method or logic resets normalization state and related fields
that current/previous track normalization uses) so a missing FormatEntity row
does not leave stale gain applied; ensure you reference and reset the same
variables/flags used by the normal setup path so subsequent async updates can
populate fresh 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: 637f4ca8-4337-4195-a206-ceeeca4b30cf

📥 Commits

Reviewing files that changed from the base of the PR and between 29d0896 and c918658.

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

Comment thread app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt
Comment thread app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt Outdated
@kairosci
Copy link
Copy Markdown
Contributor Author

kairosci commented Jun 6, 2026

@yarikeua Thanks for several tests, I changed my approach since the previous one wasn't working. Can you check if this one works? Thanks again!

@yarikeua
Copy link
Copy Markdown

yarikeua commented Jun 6, 2026

@yarikeua Thanks for several tests, I changed my approach since the previous one wasn't working. Can you check if this one works? Thanks again!

@kairosci Nothing has changed.

Record_2026-06-06-14-47-52_d800bdcb138acf830d1e1a76bd31f651.mp4

@kairosci
Copy link
Copy Markdown
Contributor Author

kairosci commented Jun 6, 2026

Damn, but isn't the problem with the song itself? Can you try another song?

@yarikeua
Copy link
Copy Markdown

yarikeua commented Jun 6, 2026

Damn, but isn't the problem with the song itself? Can you try another song?

@kairosci Ok. No problem.
As you can hear, the normalization kicks in at 6 seconds on this song.

Record_2026-06-06-15-06-24_d800bdcb138acf830d1e1a76bd31f651.mp4

@kairosci kairosci marked this pull request as draft June 6, 2026 13:34
@kairosci
Copy link
Copy Markdown
Contributor Author

kairosci commented Jun 6, 2026

@yarikeua could you take another look? Thanks again

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.

3 participants