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
This commit is contained in:
zarzet
2026-07-14 15:18:42 +07:00
parent e37f13275f
commit 9ac10dd6bb
25 changed files with 270 additions and 0 deletions
+26
View File
@@ -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 {
@@ -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")
}
}
+12
View File
@@ -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:
+7
View File
@@ -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';
+7
View File
@@ -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';
+7
View File
@@ -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';
+7
View File
@@ -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';
+7
View File
@@ -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';
+7
View File
@@ -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';
+7
View File
@@ -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';
+7
View File
@@ -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';
+7
View File
@@ -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';
+7
View File
@@ -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';
+7
View File
@@ -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';
+7
View File
@@ -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';
+7
View File
@@ -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';
+7
View File
@@ -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';
+7
View File
@@ -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';
+8
View File
@@ -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"
+6
View File
@@ -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,
+2
View File
@@ -19,6 +19,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> _$AppSettingsToJson(
'artistTagMode': instance.artistTagMode,
'embedLyrics': instance.embedLyrics,
'embedReplayGain': instance.embedReplayGain,
'playbackNormalization': instance.playbackNormalization,
'maxQualityCover': instance.maxQualityCover,
'isFirstLaunch': instance.isFirstLaunch,
'checkForUpdates': instance.checkForUpdates,
+5
View File
@@ -395,6 +395,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setPlaybackNormalization(bool enabled) {
state = state.copyWith(playbackNormalization: enabled);
_saveSettings();
}
void setEmbedMetadata(bool enabled) {
state = state.copyWith(embedMetadata: enabled);
_saveSettings();
+9
View File
@@ -72,6 +72,9 @@ class _MainShellState extends ConsumerState<MainShell>
unknownTitle: l10n.unknownTitle,
unknownArtist: l10n.unknownArtist,
);
setPlaybackNormalizationEnabled(
ref.read(settingsProvider).playbackNormalization,
);
}
@override
@@ -484,6 +487,12 @@ class _MainShellState extends ConsumerState<MainShell>
@override
Widget build(BuildContext context) {
ref.listen(settingsProvider.select((s) => s.playbackNormalization), (
_,
enabled,
) {
setPlaybackNormalizationEnabled(enabled);
});
final queueState = ref.watch(
downloadQueueProvider.select((s) => s.queuedCount),
);
@@ -622,6 +622,15 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
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,
),
],
+75
View File
@@ -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<String, double> _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<double> _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<String?> _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);