From 9ac10dd6bbf58dece952e0570d9416e051737f2b Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 14 Jul 2026 15:18:42 +0700 Subject: [PATCH] feat(player): ReplayGain/R128 volume normalization for the built-in player Reads REPLAYGAIN_TRACK_GAIN (album gain fallback) from FLAC/MP3/M4A tags and converts Opus R128_TRACK_GAIN/R128_ALBUM_GAIN (Q7.8, -23 LUFS) to the same ReplayGain dB representation on the Go side, then attenuates the internal player's volume per track. Off by default; toggle lives under Library > Playback. Opus header output gain is left to the decoder, which already applies it. Closes #465 --- go_backend/audio_metadata.go | 26 +++++++ go_backend/audio_metadata_supplement_test.go | 13 ++++ lib/l10n/app_localizations.dart | 12 +++ lib/l10n/app_localizations_ar.dart | 7 ++ lib/l10n/app_localizations_de.dart | 7 ++ lib/l10n/app_localizations_en.dart | 7 ++ lib/l10n/app_localizations_es.dart | 7 ++ lib/l10n/app_localizations_fr.dart | 7 ++ lib/l10n/app_localizations_hi.dart | 7 ++ lib/l10n/app_localizations_id.dart | 7 ++ lib/l10n/app_localizations_ja.dart | 7 ++ lib/l10n/app_localizations_ko.dart | 7 ++ lib/l10n/app_localizations_nl.dart | 7 ++ lib/l10n/app_localizations_pt.dart | 7 ++ lib/l10n/app_localizations_ru.dart | 7 ++ lib/l10n/app_localizations_tr.dart | 7 ++ lib/l10n/app_localizations_uk.dart | 7 ++ lib/l10n/app_localizations_zh.dart | 7 ++ lib/l10n/arb/app_en.arb | 8 ++ lib/models/settings.dart | 6 ++ lib/models/settings.g.dart | 2 + lib/providers/settings_provider.dart | 5 ++ lib/screens/main_shell.dart | 9 +++ .../settings/library_settings_page.dart | 9 +++ lib/services/music_player_service.dart | 75 +++++++++++++++++++ 25 files changed, 270 insertions(+) diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index 9a8a7140..9cb0c1c9 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -1079,6 +1079,21 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) { metadata.ReplayGainAlbumGain = value case "REPLAYGAIN_ALBUM_PEAK": metadata.ReplayGainAlbumPeak = value + // Opus gain tags (RFC 7845): Q7.8 fixed point on the R128 -23 LUFS + // reference. Exposed as ReplayGain 2 dB (-18 LUFS reference) so + // consumers see one representation; explicit REPLAYGAIN_* wins. + case "R128_TRACK_GAIN": + if metadata.ReplayGainTrackGain == "" { + if db, ok := r128ToReplayGainDb(value); ok { + metadata.ReplayGainTrackGain = db + } + } + case "R128_ALBUM_GAIN": + if metadata.ReplayGainAlbumGain == "" { + if db, ok := r128ToReplayGainDb(value); ok { + metadata.ReplayGainAlbumGain = db + } + } } } @@ -1090,6 +1105,17 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) { } } +// r128ToReplayGainDb converts an R128_*_GAIN value (integer, 1/256 dB steps, +// -23 LUFS reference) to a ReplayGain 2 dB string (-18 LUFS reference): +// rg = q/256 + 5. Inverse of the writer's replayGainDbToR128. +func r128ToReplayGainDb(raw string) (string, bool) { + q, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil { + return "", false + } + return fmt.Sprintf("%.2f dB", float64(q)/256.0+5.0), true +} + func GetOggQuality(filePath string) (*OggQuality, error) { file, err := os.Open(filePath) if err != nil { diff --git a/go_backend/audio_metadata_supplement_test.go b/go_backend/audio_metadata_supplement_test.go index 6a87ec15..eb83b07d 100644 --- a/go_backend/audio_metadata_supplement_test.go +++ b/go_backend/audio_metadata_supplement_test.go @@ -509,3 +509,16 @@ func buildTestFLACPictureBlock(image []byte, mime string) []byte { picture.Write(image) return picture.Bytes() } + +func TestR128ToReplayGainDb(t *testing.T) { + // -1280/256 = -5 dB on the R128 (-23 LUFS) scale -> 0 dB ReplayGain (-18). + if db, ok := r128ToReplayGainDb("-1280"); !ok || db != "0.00 dB" { + t.Fatalf("got %q ok=%v", db, ok) + } + if db, ok := r128ToReplayGainDb(" -2944 "); !ok || db != "-6.50 dB" { + t.Fatalf("got %q ok=%v", db, ok) + } + if _, ok := r128ToReplayGainDb("abc"); ok { + t.Fatal("expected failure for non-numeric input") + } +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6976d1db..d516be6f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3926,6 +3926,18 @@ abstract class AppLocalizations { /// **'Cover art extracted from local music files. Will re-extract on next scan.'** String get cacheLibraryCoverDesc; + /// Toggle title for ReplayGain playback normalization in the built-in player + /// + /// In en, this message translates to: + /// **'Volume normalization'** + String get libraryPlaybackNormalization; + + /// Subtitle explaining playback volume normalization + /// + /// In en, this message translates to: + /// **'Even out loudness between tracks using their ReplayGain or R128 tags, when present'** + String get libraryPlaybackNormalizationSubtitle; + /// Cache item title for saved audio analysis results /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 3fdcb6c4..864db241 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -2192,6 +2192,13 @@ class AppLocalizationsAr extends AppLocalizations { String get cacheLibraryCoverDesc => 'Cover art extracted from local music files. Will re-extract on next scan.'; + @override + String get libraryPlaybackNormalization => 'Volume normalization'; + + @override + String get libraryPlaybackNormalizationSubtitle => + 'Even out loudness between tracks using their ReplayGain or R128 tags, when present'; + @override String get cacheAudioAnalysis => 'Audio analysis cache'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 70f498c9..e35f1532 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2220,6 +2220,13 @@ class AppLocalizationsDe extends AppLocalizations { String get cacheLibraryCoverDesc => 'Cover aus lokalen Musikdateien extrahiert. Wird beim nächsten Scannen neu extrahiert.'; + @override + String get libraryPlaybackNormalization => 'Volume normalization'; + + @override + String get libraryPlaybackNormalizationSubtitle => + 'Even out loudness between tracks using their ReplayGain or R128 tags, when present'; + @override String get cacheAudioAnalysis => 'Audio analysis cache'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b660e3dd..1e6e5a30 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2192,6 +2192,13 @@ class AppLocalizationsEn extends AppLocalizations { String get cacheLibraryCoverDesc => 'Cover art extracted from local music files. Will re-extract on next scan.'; + @override + String get libraryPlaybackNormalization => 'Volume normalization'; + + @override + String get libraryPlaybackNormalizationSubtitle => + 'Even out loudness between tracks using their ReplayGain or R128 tags, when present'; + @override String get cacheAudioAnalysis => 'Audio analysis cache'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index e5f76503..c67415b4 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2192,6 +2192,13 @@ class AppLocalizationsEs extends AppLocalizations { String get cacheLibraryCoverDesc => 'Cover art extracted from local music files. Will re-extract on next scan.'; + @override + String get libraryPlaybackNormalization => 'Volume normalization'; + + @override + String get libraryPlaybackNormalizationSubtitle => + 'Even out loudness between tracks using their ReplayGain or R128 tags, when present'; + @override String get cacheAudioAnalysis => 'Audio analysis cache'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index dc952f71..1872863d 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2250,6 +2250,13 @@ class AppLocalizationsFr extends AppLocalizations { String get cacheLibraryCoverDesc => 'Pochettes extraites des fichiers musicaux locaux. Elles seront extraites à nouveau lors de la prochaine analyse.'; + @override + String get libraryPlaybackNormalization => 'Volume normalization'; + + @override + String get libraryPlaybackNormalizationSubtitle => + 'Even out loudness between tracks using their ReplayGain or R128 tags, when present'; + @override String get cacheAudioAnalysis => 'Audio analysis cache'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 583f2855..1577a306 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2192,6 +2192,13 @@ class AppLocalizationsHi extends AppLocalizations { String get cacheLibraryCoverDesc => 'Cover art extracted from local music files. Will re-extract on next scan.'; + @override + String get libraryPlaybackNormalization => 'Volume normalization'; + + @override + String get libraryPlaybackNormalizationSubtitle => + 'Even out loudness between tracks using their ReplayGain or R128 tags, when present'; + @override String get cacheAudioAnalysis => 'Audio analysis cache'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 7cb64bec..a59a3569 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2201,6 +2201,13 @@ class AppLocalizationsId extends AppLocalizations { String get cacheLibraryCoverDesc => 'Cover art extracted from local music files. Will re-extract on next scan.'; + @override + String get libraryPlaybackNormalization => 'Volume normalization'; + + @override + String get libraryPlaybackNormalizationSubtitle => + 'Even out loudness between tracks using their ReplayGain or R128 tags, when present'; + @override String get cacheAudioAnalysis => 'Audio analysis cache'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index df945731..bf349724 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2181,6 +2181,13 @@ class AppLocalizationsJa extends AppLocalizations { String get cacheLibraryCoverDesc => 'Cover art extracted from local music files. Will re-extract on next scan.'; + @override + String get libraryPlaybackNormalization => 'Volume normalization'; + + @override + String get libraryPlaybackNormalizationSubtitle => + 'Even out loudness between tracks using their ReplayGain or R128 tags, when present'; + @override String get cacheAudioAnalysis => 'Audio analysis cache'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 5130c19c..a62e4b6a 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2130,6 +2130,13 @@ class AppLocalizationsKo extends AppLocalizations { String get cacheLibraryCoverDesc => '로컬 음악 파일에서 표지 이미지를 추출하였습니다. 다음 스캔 시 다시 추출합니다'; + @override + String get libraryPlaybackNormalization => 'Volume normalization'; + + @override + String get libraryPlaybackNormalizationSubtitle => + 'Even out loudness between tracks using their ReplayGain or R128 tags, when present'; + @override String get cacheAudioAnalysis => 'Audio analysis cache'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 9d8c8f8f..fac46bd0 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2192,6 +2192,13 @@ class AppLocalizationsNl extends AppLocalizations { String get cacheLibraryCoverDesc => 'Cover art extracted from local music files. Will re-extract on next scan.'; + @override + String get libraryPlaybackNormalization => 'Volume normalization'; + + @override + String get libraryPlaybackNormalizationSubtitle => + 'Even out loudness between tracks using their ReplayGain or R128 tags, when present'; + @override String get cacheAudioAnalysis => 'Audio analysis cache'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 44871469..1fb90dc1 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2192,6 +2192,13 @@ class AppLocalizationsPt extends AppLocalizations { String get cacheLibraryCoverDesc => 'Cover art extracted from local music files. Will re-extract on next scan.'; + @override + String get libraryPlaybackNormalization => 'Volume normalization'; + + @override + String get libraryPlaybackNormalizationSubtitle => + 'Even out loudness between tracks using their ReplayGain or R128 tags, when present'; + @override String get cacheAudioAnalysis => 'Audio analysis cache'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 0260c4c4..0da15e45 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2221,6 +2221,13 @@ class AppLocalizationsRu extends AppLocalizations { String get cacheLibraryCoverDesc => 'Обложка извлечена из локальных музыкальных файлов. Будет повторно извлечено при следующем сканировании.'; + @override + String get libraryPlaybackNormalization => 'Volume normalization'; + + @override + String get libraryPlaybackNormalizationSubtitle => + 'Even out loudness between tracks using their ReplayGain or R128 tags, when present'; + @override String get cacheAudioAnalysis => 'Audio analysis cache'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 008d15fd..29e352ca 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2218,6 +2218,13 @@ class AppLocalizationsTr extends AppLocalizations { String get cacheLibraryCoverDesc => 'Kapak resmi yerel müzik dosyalarından çıkarıldı. Bir sonraki taramada yeniden çıkarılacaktır.'; + @override + String get libraryPlaybackNormalization => 'Volume normalization'; + + @override + String get libraryPlaybackNormalizationSubtitle => + 'Even out loudness between tracks using their ReplayGain or R128 tags, when present'; + @override String get cacheAudioAnalysis => 'Audio analysis cache'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 85b0a8ed..8b5591d3 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -2225,6 +2225,13 @@ class AppLocalizationsUk extends AppLocalizations { String get cacheLibraryCoverDesc => 'Обкладинку витягнуто з локальних музичних файлів. Буде повторно витягнуто під час наступного сканування.'; + @override + String get libraryPlaybackNormalization => 'Volume normalization'; + + @override + String get libraryPlaybackNormalizationSubtitle => + 'Even out loudness between tracks using their ReplayGain or R128 tags, when present'; + @override String get cacheAudioAnalysis => 'Audio analysis cache'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 565ad57f..9581450b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2192,6 +2192,13 @@ class AppLocalizationsZh extends AppLocalizations { String get cacheLibraryCoverDesc => 'Cover art extracted from local music files. Will re-extract on next scan.'; + @override + String get libraryPlaybackNormalization => 'Volume normalization'; + + @override + String get libraryPlaybackNormalizationSubtitle => + 'Even out loudness between tracks using their ReplayGain or R128 tags, when present'; + @override String get cacheAudioAnalysis => 'Audio analysis cache'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index d13fad53..cfda0e95 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2868,6 +2868,14 @@ "@cacheLibraryCoverDesc": { "description": "Description of what library cover cache contains" }, + "libraryPlaybackNormalization": "Volume normalization", + "@libraryPlaybackNormalization": { + "description": "Toggle title for ReplayGain playback normalization in the built-in player" + }, + "libraryPlaybackNormalizationSubtitle": "Even out loudness between tracks using their ReplayGain or R128 tags, when present", + "@libraryPlaybackNormalizationSubtitle": { + "description": "Subtitle explaining playback volume normalization" + }, "cacheAudioAnalysis": "Audio analysis cache", "@cacheAudioAnalysis": { "description": "Cache item title for saved audio analysis results" diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 9dc18a00..0a409a0a 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -20,6 +20,8 @@ class AppSettings { artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats final bool embedLyrics; final bool embedReplayGain; + // Apply ReplayGain/R128 tags as volume normalization in the built-in player. + final bool playbackNormalization; final bool maxQualityCover; final bool isFirstLaunch; final bool checkForUpdates; @@ -112,6 +114,7 @@ class AppSettings { this.artistTagMode = artistTagModeJoined, this.embedLyrics = true, this.embedReplayGain = false, + this.playbackNormalization = false, this.maxQualityCover = true, this.isFirstLaunch = true, this.checkForUpdates = true, @@ -179,6 +182,7 @@ class AppSettings { String? artistTagMode, bool? embedLyrics, bool? embedReplayGain, + bool? playbackNormalization, bool? maxQualityCover, bool? isFirstLaunch, bool? checkForUpdates, @@ -249,6 +253,8 @@ class AppSettings { artistTagMode: artistTagMode ?? this.artistTagMode, embedLyrics: embedLyrics ?? this.embedLyrics, embedReplayGain: embedReplayGain ?? this.embedReplayGain, + playbackNormalization: + playbackNormalization ?? this.playbackNormalization, maxQualityCover: maxQualityCover ?? this.maxQualityCover, isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, checkForUpdates: checkForUpdates ?? this.checkForUpdates, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index c405cd80..2ea7f751 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -19,6 +19,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( artistTagMode: json['artistTagMode'] as String? ?? artistTagModeJoined, embedLyrics: json['embedLyrics'] as bool? ?? true, embedReplayGain: json['embedReplayGain'] as bool? ?? false, + playbackNormalization: json['playbackNormalization'] as bool? ?? false, maxQualityCover: json['maxQualityCover'] as bool? ?? true, isFirstLaunch: json['isFirstLaunch'] as bool? ?? true, checkForUpdates: json['checkForUpdates'] as bool? ?? true, @@ -105,6 +106,7 @@ Map _$AppSettingsToJson( 'artistTagMode': instance.artistTagMode, 'embedLyrics': instance.embedLyrics, 'embedReplayGain': instance.embedReplayGain, + 'playbackNormalization': instance.playbackNormalization, 'maxQualityCover': instance.maxQualityCover, 'isFirstLaunch': instance.isFirstLaunch, 'checkForUpdates': instance.checkForUpdates, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index c82f27a9..db214d88 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -395,6 +395,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setPlaybackNormalization(bool enabled) { + state = state.copyWith(playbackNormalization: enabled); + _saveSettings(); + } + void setEmbedMetadata(bool enabled) { state = state.copyWith(embedMetadata: enabled); _saveSettings(); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index e0e6b782..bfbb09b2 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -72,6 +72,9 @@ class _MainShellState extends ConsumerState unknownTitle: l10n.unknownTitle, unknownArtist: l10n.unknownArtist, ); + setPlaybackNormalizationEnabled( + ref.read(settingsProvider).playbackNormalization, + ); } @override @@ -484,6 +487,12 @@ class _MainShellState extends ConsumerState @override Widget build(BuildContext context) { + ref.listen(settingsProvider.select((s) => s.playbackNormalization), ( + _, + enabled, + ) { + setPlaybackNormalizationEnabled(enabled); + }); final queueState = ref.watch( downloadQueueProvider.select((s) => s.queuedCount), ); diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 59c6491a..cbb6a146 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -622,6 +622,15 @@ class _LibrarySettingsPageState extends ConsumerState { onTap: () => ref .read(settingsProvider.notifier) .setPlayerMode('internal'), + ), + SettingsSwitchItem( + icon: Icons.graphic_eq, + title: context.l10n.libraryPlaybackNormalization, + subtitle: context.l10n.libraryPlaybackNormalizationSubtitle, + value: settings.playbackNormalization, + onChanged: (v) => ref + .read(settingsProvider.notifier) + .setPlaybackNormalization(v), showDivider: false, ), ], diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index 8f467277..88adc62b 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -21,6 +21,17 @@ void updateMusicPlayerStrings({ _playbackUnknownArtist = unknownArtist; } +bool _playbackNormalizationEnabled = false; +MusicPlayerHandler? _activeMusicPlayerHandler; + +/// Enables/disables ReplayGain volume normalization and re-applies it to the +/// track currently playing. +void setPlaybackNormalizationEnabled(bool enabled) { + if (_playbackNormalizationEnabled == enabled) return; + _playbackNormalizationEnabled = enabled; + _activeMusicPlayerHandler?.reapplyNormalization(); +} + final AudioContext _musicAudioContext = AudioContext( android: const AudioContextAndroid( audioFocus: AndroidAudioFocus.none, @@ -100,6 +111,7 @@ class MusicPlayerHandler extends BaseAudioHandler static const int _maxResolvedPathCacheEntries = 64; MusicPlayerHandler() { + _activeMusicPlayerHandler = this; _init(); } @@ -285,6 +297,64 @@ class MusicPlayerHandler extends BaseAudioHandler playbackState.add(playbackState.value.copyWith(updatePosition: position)); } + // ReplayGain normalization: resolved path -> volume multiplier. + final Map _normalizationVolumeCache = {}; + + /// Volume multiplier from the file's ReplayGain/R128 tags (track gain, + /// album gain fallback; Opus R128 tags are converted to ReplayGain dB by + /// the Go reader). 1.0 when disabled, untagged, or unreadable. Positive + /// gains clamp at 1.0 — setVolume can only attenuate. + Future _normalizationVolumeFor(String path) async { + if (!_playbackNormalizationEnabled) return 1.0; + final cached = _normalizationVolumeCache[path]; + if (cached != null) return cached; + + var volume = 1.0; + try { + final metadata = await PlatformBridge.readFileMetadata(path); + final gainDb = + _parseGainDb(metadata['replaygain_track_gain']) ?? + _parseGainDb(metadata['replaygain_album_gain']); + if (gainDb != null) { + volume = pow(10.0, gainDb / 20.0).toDouble().clamp(0.0, 1.0); + } + } catch (e) { + _log.w('Failed to read gain tags for normalization: $e'); + } + if (_normalizationVolumeCache.length > 128) { + _normalizationVolumeCache.clear(); + } + _normalizationVolumeCache[path] = volume; + return volume; + } + + static double? _parseGainDb(Object? raw) { + final text = raw?.toString(); + if (text == null || text.isEmpty) return null; + final match = RegExp(r'-?\d+(\.\d+)?').firstMatch(text); + return match == null ? null : double.tryParse(match.group(0)!); + } + + /// Re-applies normalization to the playing track when the setting flips. + void reapplyNormalization() { + final index = _index; + if (index < 0 || index >= _media.length) return; + unawaited(() async { + final media = _media[index]; + final resolved = media.isContentUri + ? _resolvedPathCache[media.source] + : media.source; + if (resolved == null) return; + final volume = await _normalizationVolumeFor(resolved); + if (_index != index) return; + try { + await _player.setVolume(volume); + } catch (e) { + _log.w('Failed to apply normalization volume: $e'); + } + }()); + } + Future _resolveSource(PlayableMedia media) async { if (!media.isContentUri) return media.source; @@ -444,9 +514,14 @@ class MusicPlayerHandler extends BaseAudioHandler _switchingTrack = true; try { + // Set before play() so the track never starts at the wrong loudness; + // always set (1.0 when disabled/untagged) so a previous track's + // attenuation can't leak into the next one. + final normalizationVolume = await _normalizationVolumeFor(resolved); await _player.setAudioContext(_musicAudioContext); await _activateAudioSession(); await _player.stop(); + await _player.setVolume(normalizationVolume); await _player.play(DeviceFileSource(resolved)); mediaItem.add(media.toMediaItem(resolvedSource: resolved)); _broadcastPosition(Duration.zero, force: true);