Skip to content

Commit

Permalink
feat: replay gain (normalize volume)
Browse files Browse the repository at this point in the history
  • Loading branch information
MSOB7YY committed Sep 13, 2024
1 parent a0fc75e commit 0280611
Show file tree
Hide file tree
Showing 15 changed files with 240 additions and 44 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,13 @@ public class FAudioTagger : FlutterPlugin, MethodCallHandler {
} catch (_: Exception) {}
}
}

// -- for extra goofy fields
tag.getFields().forEach {
if (metadata[it.id] == null) {
metadata[it.id] = it.toString()
}
}
} catch (_: Exception) {}

if (extractArtwork) {
Expand Down
5 changes: 5 additions & 0 deletions lib/base/audio_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,10 @@ class NamidaAudioVideoHandler<Q extends Playable> extends BasicAudioHandler<Q> {
Future<void> onItemPlaySelectable(Q pi, Selectable item, int index, bool Function() startPlaying, Function skipItem) async {
final tr = item.track;
videoPlayerInfo.value = null;
if (settings.player.replayGain.value) {
final gain = item.track.toTrackExt().gainData?.calculateGainAsVolume();
_userPlayerVolume = gain ?? 0.75; // save in memory only
}
final isVideo = item is Video;
Lyrics.inst.resetLyrics();
WaveformController.inst.resetWaveform();
Expand Down Expand Up @@ -1620,6 +1624,7 @@ class NamidaAudioVideoHandler<Q extends Playable> extends BasicAudioHandler<Q> {
);

double get _userPlayerVolume => settings.player.volume.value;
set _userPlayerVolume(double val) => settings.player.volume.value = val;

@override
bool get enableCrossFade => settings.player.enableCrossFade.value && currentItem.value is! YoutubeID;
Expand Down
5 changes: 5 additions & 0 deletions lib/class/faudiomodel.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:io';
import 'dart:typed_data';

import 'package:namida/class/replay_gain_data.dart';
import 'package:namida/core/extensions.dart';

class FArtwork {
Expand Down Expand Up @@ -70,6 +71,7 @@ class FTags {
final String? recordLabel;

final double? ratingPercentage;
final ReplayGainData? gainData;

const FTags({
required this.path,
Expand Down Expand Up @@ -99,6 +101,7 @@ class FTags {
this.country,
this.recordLabel,
this.ratingPercentage,
this.gainData,
});

static String? _listToString(List? list) {
Expand Down Expand Up @@ -159,6 +162,7 @@ class FTags {
country: _listToString(map["country"]) ?? map["COUNTRY"],
recordLabel: _listToString(map["recordLabel"]) ?? map["RECORDLABEL"] ?? map["label"] ?? map["LABEL"],
ratingPercentage: ratingUnsignedIntToPercentage(ratingString),
gainData: ReplayGainData.fromAndroidMap(map),
);
}

Expand Down Expand Up @@ -190,6 +194,7 @@ class FTags {
"country": country,
"recordLabel": recordLabel,
"language": language,
"gainData": gainData?.toMap(),
};
}
}
Expand Down
6 changes: 6 additions & 0 deletions lib/class/media_info.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'package:namida/class/replay_gain_data.dart';

class MediaInfo {
final String path;
final List<MIStream>? streams;
Expand Down Expand Up @@ -106,6 +108,7 @@ class MIFormatTags {
final String? lyricist;
final String? compatibleBrands;
final String? mood;
final ReplayGainData? gainData;

const MIFormatTags({
this.date,
Expand Down Expand Up @@ -133,6 +136,7 @@ class MIFormatTags {
this.lyricist,
this.compatibleBrands,
this.mood,
this.gainData,
});

factory MIFormatTags.fromMap(Map<dynamic, dynamic> map) => MIFormatTags(
Expand Down Expand Up @@ -161,6 +165,7 @@ class MIFormatTags {
lyricist: map.getOrLowerCase("LYRICIST"),
compatibleBrands: map.getOrUpperCase("compatible_brands"),
mood: map.getOrUpperCase("mood"),
gainData: ReplayGainData.fromAndroidMap(map),
);

Map<dynamic, dynamic> toMap() => {
Expand Down Expand Up @@ -189,6 +194,7 @@ class MIFormatTags {
"LYRICIST": lyricist,
"compatible_brands": compatibleBrands,
"mood": mood,
"gainData": gainData?.toMap(),
};
}

Expand Down
74 changes: 74 additions & 0 deletions lib/class/replay_gain_data.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import 'dart:math' as math;

class ReplayGainData {
final double? trackGain, albumGain;
final double? trackPeak, albumPeak;
const ReplayGainData({
required this.trackGain,
required this.trackPeak,
required this.albumGain,
required this.albumPeak,
});

double? calculateGainAsVolume({double withRespectiveVolume = 0.75}) {
final gainFinal = trackGain ?? albumGain;
if (gainFinal == null) return null;
final gainLinear = math.pow(10, gainFinal / 20).clamp(0.1, 1.0);
return gainLinear * withRespectiveVolume;
}

static ReplayGainData? fromAndroidMap(Map map) {
double? trackGainDB = ((map['replaygain_track_gain'] ?? map['REPLAYGAIN_TRACK_GAIN']) as String?)?._parseGainValue(); // "-0.515000 dB"
double? albumGainDB = ((map['replaygain_album_gain'] ?? map['REPLAYGAIN_ALBUM_GAIN']) as String?)?._parseGainValue(); // "+0.040000 dB"

trackGainDB ??= ((map['r128_track_gain'] ?? map['R128_TRACK_GAIN']) as String?)?._parseGainValueR128();
albumGainDB ??= ((map['r128_album_gain'] ?? map['R128_ALBUM_GAIN']) as String?)?._parseGainValueR128();

final trackPeak = ((map['replaygain_track_peak'] ?? map['REPLAYGAIN_TRACK_PEAK']) as String?)?._parsePeakValue();
final albumPeak = ((map['replaygain_album_peak'] ?? map['REPLAYGAIN_ALBUM_PEAK']) as String?)?._parsePeakValue();

final data = ReplayGainData(
trackGain: trackGainDB,
trackPeak: trackPeak,
albumGain: albumGainDB,
albumPeak: albumPeak,
);
if (data.trackGain == null && data.trackPeak == null && data.albumGain == null && data.albumPeak == null) return null;
return data;
}

factory ReplayGainData.fromMap(Map<String, dynamic> map) {
return ReplayGainData(
trackGain: map['tg'],
trackPeak: map['tp'],
albumGain: map['ag'],
albumPeak: map['ap'],
);
}

Map<String, dynamic> toMap() {
return <String, dynamic>{
"tg": trackGain,
"tp": trackPeak,
"ag": albumGain,
"ap": albumPeak,
};
}
}

extension _GainParser on String? {
double? _parseGainValueR128() {
final parsed = _parseGainValue();
return parsed == null ? null : (parsed / 256) + 5;
}

double? _parseGainValue() {
var text = this;
return text == null ? null : double.tryParse(text.replaceFirst(RegExp(r'[^\d.-]'), '')) ?? double.tryParse(text.split(' ').first);
}

double? _parsePeakValue() {
var text = this;
return text == null ? null : double.tryParse(text);
}
}
66 changes: 47 additions & 19 deletions lib/class/track.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:intl/intl.dart';

import 'package:namida/class/faudiomodel.dart';
import 'package:namida/class/folder.dart';
import 'package:namida/class/replay_gain_data.dart';
import 'package:namida/class/split_config.dart';
import 'package:namida/class/video.dart';
import 'package:namida/controller/indexer_controller.dart';
Expand Down Expand Up @@ -230,6 +231,7 @@ class TrackExtended {
final double rating;
final String? originalTags;
final List<String> tagsList;
final ReplayGainData? gainData;

final bool isVideo;

Expand Down Expand Up @@ -263,6 +265,7 @@ class TrackExtended {
required this.rating,
required this.originalTags,
required this.tagsList,
required this.gainData,
required this.isVideo,
});

Expand Down Expand Up @@ -328,6 +331,7 @@ class TrackExtended {
json['originalTags'],
config: genresSplitConfig,
),
gainData: json['gainData'] == null ? null : ReplayGainData.fromMap(json['gainData']),
isVideo: json['v'] ?? false,
);
}
Expand Down Expand Up @@ -359,6 +363,7 @@ class TrackExtended {
if (label.isNotEmpty) 'label': label,
if (rating > 0) 'rating': rating,
if (originalTags?.isNotEmpty == true) 'originalTags': originalTags,
if (gainData != null) 'gainData': gainData?.toMap(),
'v': isVideo,
};
}
Expand Down Expand Up @@ -431,6 +436,40 @@ extension TrackExtUtils on TrackExtended {
return tostr;
}

String get audioInfoFormatted {
final trExt = this;
final initial = [
trExt.durationMS.milliSecondsLabel,
trExt.size.fileSizeFormatted,
"${trExt.bitrate} kps",
"${trExt.sampleRate} hz",
].join(' • ');
final gainFormatted = trExt.gainDataFormatted;
if (gainFormatted == null) return initial;
return '$initial\n$gainFormatted';
}

String? get gainDataFormatted {
final gain = gainData;
if (gain == null) return null;
return [
'${gain.trackGain ?? '?'} dB gain',
if (gain.trackPeak != null) '${gain.trackPeak} peak',
if (gain.albumGain != null) '${gain.albumGain} dB gain (album)',
if (gain.albumPeak != null) '${gain.albumPeak} peak (album)',
].join(' • ');
}

String get audioInfoFormattedCompact {
final trExt = this;
return [
trExt.format,
"${trExt.channels} ch",
"${trExt.bitrate} kps",
"${trExt.sampleRate / 1000} khz",
].joinText(separator: ' • ');
}

TrackExtended copyWithTag({
required FTags tag,
int? dateModified,
Expand Down Expand Up @@ -459,6 +498,7 @@ extension TrackExtUtils on TrackExtended {
rating: tag.ratingPercentage ?? rating,
originalTags: tag.tags ?? originalTags,
tagsList: tag.tags != null ? [tag.tags!] : tagsList,
gainData: tag.gainData ?? gainData,

// -- uneditable fields
bitrate: bitrate,
Expand Down Expand Up @@ -504,6 +544,7 @@ extension TrackExtUtils on TrackExtended {
double? rating,
String? originalTags,
List<String>? tagsList,
ReplayGainData? gainData,
bool? isVideo,
}) {
return TrackExtended(
Expand Down Expand Up @@ -536,6 +577,7 @@ extension TrackExtUtils on TrackExtended {
rating: rating ?? this.rating,
originalTags: originalTags ?? this.originalTags,
tagsList: tagsList ?? this.tagsList,
gainData: gainData ?? this.gainData,
isVideo: isVideo ?? this.isVideo,
);
}
Expand Down Expand Up @@ -606,26 +648,12 @@ extension TrackUtils on Track {
String get youtubeLink => toTrackExt().youtubeLink;
String get youtubeID => youtubeLink.getYoutubeID;

String get audioInfoFormatted {
final trExt = toTrackExt();
return [
trExt.durationMS.milliSecondsLabel,
trExt.size.fileSizeFormatted,
"${trExt.bitrate} kps",
"${trExt.sampleRate} hz",
].join(' • ');
}

String get audioInfoFormattedCompact {
final trExt = toTrackExt();
return [
trExt.format,
"${trExt.channels} ch",
"${trExt.bitrate} kps",
"${trExt.sampleRate / 1000} khz",
].join(' • ');
}
String get audioInfoFormatted => toTrackExt().audioInfoFormatted;
String? get gainDataFormatted => toTrackExt().gainDataFormatted;
String get audioInfoFormattedCompact => toTrackExt().audioInfoFormattedCompact;

String get albumIdentifier => toTrackExt().albumIdentifier;
String getAlbumIdentifier(List<AlbumIdentifier> identifiers) => toTrackExt().getAlbumIdentifier(identifiers);

ReplayGainData? get gainData => toTrackExt().gainData;
}
3 changes: 3 additions & 0 deletions lib/controller/indexer_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,7 @@ class Indexer<T extends Track> {
rating: 0.0,
originalTags: null,
tagsList: [],
gainData: null,
isVideo: trackPath.isVideo(),
);
if (!trackInfo.hasError) {
Expand Down Expand Up @@ -562,6 +563,7 @@ class Indexer<T extends Track> {
rating: tags.ratingPercentage,
originalTags: tags.tags,
tagsList: tagsEmbedded,
gainData: tags.gainData,
);

// ----- if the title || artist weren't found in the tag fields
Expand Down Expand Up @@ -1319,6 +1321,7 @@ class Indexer<T extends Track> {
rating: 0.0,
originalTags: tag,
tagsList: tags,
gainData: null,
isVideo: e.data.isVideo(),
);
tracks.add((trext, e.id));
Expand Down
5 changes: 5 additions & 0 deletions lib/controller/settings.player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class _PlayerSettings with SettingsFileWriter {
final displayRemainingDurInsteadOfTotal = false.obs;
final killAfterDismissingApp = KillAppMode.ifNotPlaying.obs;
final lockscreenArtwork = true.obs;
final replayGain = false.obs;

final onInterrupted = <InterruptionType, InterruptionAction>{
InterruptionType.shouldPause: InterruptionAction.pause,
Expand Down Expand Up @@ -70,6 +71,7 @@ class _PlayerSettings with SettingsFileWriter {
RepeatMode? repeatMode,
KillAppMode? killAfterDismissingApp,
bool? lockscreenArtwork,
bool? replayGain,
}) {
if (enableVolumeFadeOnPlayPause != null) this.enableVolumeFadeOnPlayPause.value = enableVolumeFadeOnPlayPause;
if (infiniyQueueOnNextPrevious != null) this.infiniyQueueOnNextPrevious.value = infiniyQueueOnNextPrevious;
Expand Down Expand Up @@ -99,6 +101,7 @@ class _PlayerSettings with SettingsFileWriter {
if (repeatMode != null) this.repeatMode.value = repeatMode;
if (killAfterDismissingApp != null) this.killAfterDismissingApp.value = killAfterDismissingApp;
if (lockscreenArtwork != null) this.lockscreenArtwork.value = lockscreenArtwork;
if (replayGain != null) this.replayGain.value = replayGain;
_writeToStorage();
}

Expand Down Expand Up @@ -140,6 +143,7 @@ class _PlayerSettings with SettingsFileWriter {
displayRemainingDurInsteadOfTotal.value = json['displayRemainingDurInsteadOfTotal'] ?? displayRemainingDurInsteadOfTotal.value;
killAfterDismissingApp.value = KillAppMode.values.getEnum(json['killAfterDismissingApp']) ?? killAfterDismissingApp.value;
lockscreenArtwork.value = json['lockscreenArtwork'] ?? lockscreenArtwork.value;
replayGain.value = json['replayGain'] ?? replayGain.value;
onInterrupted.value = getEnumMap_(
json['onInterrupted'],
InterruptionType.values,
Expand Down Expand Up @@ -181,6 +185,7 @@ class _PlayerSettings with SettingsFileWriter {
'repeatMode': repeatMode.value.name,
'killAfterDismissingApp': killAfterDismissingApp.value.name,
'lockscreenArtwork': lockscreenArtwork.value,
'replayGain': replayGain.value,
'infiniyQueueOnNextPrevious': infiniyQueueOnNextPrevious.value,
'displayRemainingDurInsteadOfTotal': displayRemainingDurInsteadOfTotal.value,
'onInterrupted': onInterrupted.map((key, value) => MapEntry(key.name, value.name)),
Expand Down
1 change: 1 addition & 0 deletions lib/core/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@ const kDummyExtendedTrack = TrackExtended(
rating: 0.0,
originalTags: null,
tagsList: [],
gainData: null,
isVideo: false,
);

Expand Down
Loading

0 comments on commit 0280611

Please sign in to comment.