diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 87798352..73567689 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5599,6 +5599,24 @@ abstract class AppLocalizations { /// **'Sample Rate'** String get audioAnalysisSampleRate; + /// Audio codec metric label + /// + /// In en, this message translates to: + /// **'Codec'** + String get audioAnalysisCodec; + + /// Audio container metric label + /// + /// In en, this message translates to: + /// **'Container'** + String get audioAnalysisContainer; + + /// Decoded sample format metric label + /// + /// In en, this message translates to: + /// **'Decoded Format'** + String get audioAnalysisDecodedFormat; + /// Bit depth metric label /// /// In en, this message translates to: @@ -5647,6 +5665,42 @@ abstract class AppLocalizations { /// **'RMS'** String get audioAnalysisRms; + /// Integrated loudness metric label + /// + /// In en, this message translates to: + /// **'LUFS'** + String get audioAnalysisLufs; + + /// True peak metric label + /// + /// In en, this message translates to: + /// **'True Peak'** + String get audioAnalysisTruePeak; + + /// Clipping metric label + /// + /// In en, this message translates to: + /// **'Clipping'** + String get audioAnalysisClipping; + + /// Displayed when no clipped samples were detected + /// + /// In en, this message translates to: + /// **'No clipping'** + String get audioAnalysisNoClipping; + + /// Estimated spectral cutoff metric label + /// + /// In en, this message translates to: + /// **'Spectral Cutoff'** + String get audioAnalysisSpectralCutoff; + + /// Per-channel audio analysis section label + /// + /// In en, this message translates to: + /// **'Per-channel Stats'** + String get audioAnalysisChannelStats; + /// Total samples metric label /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 333d25fa..3efc50b1 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3318,6 +3318,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get audioAnalysisSampleRate => 'Sample Rate'; + @override + String get audioAnalysisCodec => 'Codec'; + + @override + String get audioAnalysisContainer => 'Container'; + + @override + String get audioAnalysisDecodedFormat => 'Decoded Format'; + @override String get audioAnalysisBitDepth => 'Bit-Tiefe'; @@ -3342,6 +3351,24 @@ class AppLocalizationsDe extends AppLocalizations { @override String get audioAnalysisRms => 'RMS'; + @override + String get audioAnalysisLufs => 'LUFS'; + + @override + String get audioAnalysisTruePeak => 'True Peak'; + + @override + String get audioAnalysisClipping => 'Clipping'; + + @override + String get audioAnalysisNoClipping => 'No clipping'; + + @override + String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + + @override + String get audioAnalysisChannelStats => 'Per-channel Stats'; + @override String get audioAnalysisSamples => 'Proben'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 78029ae6..e4a64198 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3283,6 +3283,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get audioAnalysisSampleRate => 'Sample Rate'; + @override + String get audioAnalysisCodec => 'Codec'; + + @override + String get audioAnalysisContainer => 'Container'; + + @override + String get audioAnalysisDecodedFormat => 'Decoded Format'; + @override String get audioAnalysisBitDepth => 'Bit Depth'; @@ -3307,6 +3316,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get audioAnalysisRms => 'RMS'; + @override + String get audioAnalysisLufs => 'LUFS'; + + @override + String get audioAnalysisTruePeak => 'True Peak'; + + @override + String get audioAnalysisClipping => 'Clipping'; + + @override + String get audioAnalysisNoClipping => 'No clipping'; + + @override + String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + + @override + String get audioAnalysisChannelStats => 'Per-channel Stats'; + @override String get audioAnalysisSamples => 'Samples'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 3fbcb736..0058f0a2 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -3283,6 +3283,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get audioAnalysisSampleRate => 'Sample Rate'; + @override + String get audioAnalysisCodec => 'Codec'; + + @override + String get audioAnalysisContainer => 'Container'; + + @override + String get audioAnalysisDecodedFormat => 'Decoded Format'; + @override String get audioAnalysisBitDepth => 'Bit Depth'; @@ -3307,6 +3316,24 @@ class AppLocalizationsEs extends AppLocalizations { @override String get audioAnalysisRms => 'RMS'; + @override + String get audioAnalysisLufs => 'LUFS'; + + @override + String get audioAnalysisTruePeak => 'True Peak'; + + @override + String get audioAnalysisClipping => 'Clipping'; + + @override + String get audioAnalysisNoClipping => 'No clipping'; + + @override + String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + + @override + String get audioAnalysisChannelStats => 'Per-channel Stats'; + @override String get audioAnalysisSamples => 'Samples'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index effea27e..395133ce 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -3287,6 +3287,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get audioAnalysisSampleRate => 'Sample Rate'; + @override + String get audioAnalysisCodec => 'Codec'; + + @override + String get audioAnalysisContainer => 'Container'; + + @override + String get audioAnalysisDecodedFormat => 'Decoded Format'; + @override String get audioAnalysisBitDepth => 'Bit Depth'; @@ -3311,6 +3320,24 @@ class AppLocalizationsFr extends AppLocalizations { @override String get audioAnalysisRms => 'RMS'; + @override + String get audioAnalysisLufs => 'LUFS'; + + @override + String get audioAnalysisTruePeak => 'True Peak'; + + @override + String get audioAnalysisClipping => 'Clipping'; + + @override + String get audioAnalysisNoClipping => 'No clipping'; + + @override + String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + + @override + String get audioAnalysisChannelStats => 'Per-channel Stats'; + @override String get audioAnalysisSamples => 'Samples'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 04c23bcf..aa97580d 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -3284,6 +3284,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String get audioAnalysisSampleRate => 'Sample Rate'; + @override + String get audioAnalysisCodec => 'Codec'; + + @override + String get audioAnalysisContainer => 'Container'; + + @override + String get audioAnalysisDecodedFormat => 'Decoded Format'; + @override String get audioAnalysisBitDepth => 'Bit Depth'; @@ -3308,6 +3317,24 @@ class AppLocalizationsHi extends AppLocalizations { @override String get audioAnalysisRms => 'RMS'; + @override + String get audioAnalysisLufs => 'LUFS'; + + @override + String get audioAnalysisTruePeak => 'True Peak'; + + @override + String get audioAnalysisClipping => 'Clipping'; + + @override + String get audioAnalysisNoClipping => 'No clipping'; + + @override + String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + + @override + String get audioAnalysisChannelStats => 'Per-channel Stats'; + @override String get audioAnalysisSamples => 'Samples'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index de626afc..e1fffedc 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -3293,6 +3293,15 @@ class AppLocalizationsId extends AppLocalizations { @override String get audioAnalysisSampleRate => 'Sample Rate'; + @override + String get audioAnalysisCodec => 'Codec'; + + @override + String get audioAnalysisContainer => 'Container'; + + @override + String get audioAnalysisDecodedFormat => 'Decoded Format'; + @override String get audioAnalysisBitDepth => 'Bit Depth'; @@ -3317,6 +3326,24 @@ class AppLocalizationsId extends AppLocalizations { @override String get audioAnalysisRms => 'RMS'; + @override + String get audioAnalysisLufs => 'LUFS'; + + @override + String get audioAnalysisTruePeak => 'True Peak'; + + @override + String get audioAnalysisClipping => 'Clipping'; + + @override + String get audioAnalysisNoClipping => 'No clipping'; + + @override + String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + + @override + String get audioAnalysisChannelStats => 'Per-channel Stats'; + @override String get audioAnalysisSamples => 'Samples'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 37734fa9..db483e80 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -3271,6 +3271,15 @@ class AppLocalizationsJa extends AppLocalizations { @override String get audioAnalysisSampleRate => 'Sample Rate'; + @override + String get audioAnalysisCodec => 'Codec'; + + @override + String get audioAnalysisContainer => 'Container'; + + @override + String get audioAnalysisDecodedFormat => 'Decoded Format'; + @override String get audioAnalysisBitDepth => 'Bit Depth'; @@ -3295,6 +3304,24 @@ class AppLocalizationsJa extends AppLocalizations { @override String get audioAnalysisRms => 'RMS'; + @override + String get audioAnalysisLufs => 'LUFS'; + + @override + String get audioAnalysisTruePeak => 'True Peak'; + + @override + String get audioAnalysisClipping => 'Clipping'; + + @override + String get audioAnalysisNoClipping => 'No clipping'; + + @override + String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + + @override + String get audioAnalysisChannelStats => 'Per-channel Stats'; + @override String get audioAnalysisSamples => 'Samples'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 6d746a7e..4e545514 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -3264,6 +3264,15 @@ class AppLocalizationsKo extends AppLocalizations { @override String get audioAnalysisSampleRate => 'Sample Rate'; + @override + String get audioAnalysisCodec => 'Codec'; + + @override + String get audioAnalysisContainer => 'Container'; + + @override + String get audioAnalysisDecodedFormat => 'Decoded Format'; + @override String get audioAnalysisBitDepth => 'Bit Depth'; @@ -3288,6 +3297,24 @@ class AppLocalizationsKo extends AppLocalizations { @override String get audioAnalysisRms => 'RMS'; + @override + String get audioAnalysisLufs => 'LUFS'; + + @override + String get audioAnalysisTruePeak => 'True Peak'; + + @override + String get audioAnalysisClipping => 'Clipping'; + + @override + String get audioAnalysisNoClipping => 'No clipping'; + + @override + String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + + @override + String get audioAnalysisChannelStats => 'Per-channel Stats'; + @override String get audioAnalysisSamples => 'Samples'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index aed926bb..fa1a4f56 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -3284,6 +3284,15 @@ class AppLocalizationsNl extends AppLocalizations { @override String get audioAnalysisSampleRate => 'Sample Rate'; + @override + String get audioAnalysisCodec => 'Codec'; + + @override + String get audioAnalysisContainer => 'Container'; + + @override + String get audioAnalysisDecodedFormat => 'Decoded Format'; + @override String get audioAnalysisBitDepth => 'Bit Depth'; @@ -3308,6 +3317,24 @@ class AppLocalizationsNl extends AppLocalizations { @override String get audioAnalysisRms => 'RMS'; + @override + String get audioAnalysisLufs => 'LUFS'; + + @override + String get audioAnalysisTruePeak => 'True Peak'; + + @override + String get audioAnalysisClipping => 'Clipping'; + + @override + String get audioAnalysisNoClipping => 'No clipping'; + + @override + String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + + @override + String get audioAnalysisChannelStats => 'Per-channel Stats'; + @override String get audioAnalysisSamples => 'Samples'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 6d8adfd1..d72b1e65 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -3283,6 +3283,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get audioAnalysisSampleRate => 'Sample Rate'; + @override + String get audioAnalysisCodec => 'Codec'; + + @override + String get audioAnalysisContainer => 'Container'; + + @override + String get audioAnalysisDecodedFormat => 'Decoded Format'; + @override String get audioAnalysisBitDepth => 'Bit Depth'; @@ -3307,6 +3316,24 @@ class AppLocalizationsPt extends AppLocalizations { @override String get audioAnalysisRms => 'RMS'; + @override + String get audioAnalysisLufs => 'LUFS'; + + @override + String get audioAnalysisTruePeak => 'True Peak'; + + @override + String get audioAnalysisClipping => 'Clipping'; + + @override + String get audioAnalysisNoClipping => 'No clipping'; + + @override + String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + + @override + String get audioAnalysisChannelStats => 'Per-channel Stats'; + @override String get audioAnalysisSamples => 'Samples'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index b547559d..de3162cb 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3343,6 +3343,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get audioAnalysisSampleRate => 'Sample Rate'; + @override + String get audioAnalysisCodec => 'Codec'; + + @override + String get audioAnalysisContainer => 'Container'; + + @override + String get audioAnalysisDecodedFormat => 'Decoded Format'; + @override String get audioAnalysisBitDepth => 'Bit Depth'; @@ -3367,6 +3376,24 @@ class AppLocalizationsRu extends AppLocalizations { @override String get audioAnalysisRms => 'RMS'; + @override + String get audioAnalysisLufs => 'LUFS'; + + @override + String get audioAnalysisTruePeak => 'True Peak'; + + @override + String get audioAnalysisClipping => 'Clipping'; + + @override + String get audioAnalysisNoClipping => 'No clipping'; + + @override + String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + + @override + String get audioAnalysisChannelStats => 'Per-channel Stats'; + @override String get audioAnalysisSamples => 'Samples'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 43347a49..d13dec01 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -3310,6 +3310,15 @@ class AppLocalizationsTr extends AppLocalizations { @override String get audioAnalysisSampleRate => 'Sample Rate'; + @override + String get audioAnalysisCodec => 'Codec'; + + @override + String get audioAnalysisContainer => 'Container'; + + @override + String get audioAnalysisDecodedFormat => 'Decoded Format'; + @override String get audioAnalysisBitDepth => 'Bit Depth'; @@ -3334,6 +3343,24 @@ class AppLocalizationsTr extends AppLocalizations { @override String get audioAnalysisRms => 'RMS'; + @override + String get audioAnalysisLufs => 'LUFS'; + + @override + String get audioAnalysisTruePeak => 'True Peak'; + + @override + String get audioAnalysisClipping => 'Clipping'; + + @override + String get audioAnalysisNoClipping => 'No clipping'; + + @override + String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + + @override + String get audioAnalysisChannelStats => 'Per-channel Stats'; + @override String get audioAnalysisSamples => 'Samples'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 15e5963d..dab8c235 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -3339,6 +3339,15 @@ class AppLocalizationsUk extends AppLocalizations { @override String get audioAnalysisSampleRate => 'Частота дискретизації'; + @override + String get audioAnalysisCodec => 'Codec'; + + @override + String get audioAnalysisContainer => 'Container'; + + @override + String get audioAnalysisDecodedFormat => 'Decoded Format'; + @override String get audioAnalysisBitDepth => 'Глибина бітів'; @@ -3363,6 +3372,24 @@ class AppLocalizationsUk extends AppLocalizations { @override String get audioAnalysisRms => 'RMS'; + @override + String get audioAnalysisLufs => 'LUFS'; + + @override + String get audioAnalysisTruePeak => 'True Peak'; + + @override + String get audioAnalysisClipping => 'Clipping'; + + @override + String get audioAnalysisNoClipping => 'No clipping'; + + @override + String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + + @override + String get audioAnalysisChannelStats => 'Per-channel Stats'; + @override String get audioAnalysisSamples => 'Семпли'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 0c7afb65..3e126804 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -3283,6 +3283,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get audioAnalysisSampleRate => 'Sample Rate'; + @override + String get audioAnalysisCodec => 'Codec'; + + @override + String get audioAnalysisContainer => 'Container'; + + @override + String get audioAnalysisDecodedFormat => 'Decoded Format'; + @override String get audioAnalysisBitDepth => 'Bit Depth'; @@ -3307,6 +3316,24 @@ class AppLocalizationsZh extends AppLocalizations { @override String get audioAnalysisRms => 'RMS'; + @override + String get audioAnalysisLufs => 'LUFS'; + + @override + String get audioAnalysisTruePeak => 'True Peak'; + + @override + String get audioAnalysisClipping => 'Clipping'; + + @override + String get audioAnalysisNoClipping => 'No clipping'; + + @override + String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + + @override + String get audioAnalysisChannelStats => 'Per-channel Stats'; + @override String get audioAnalysisSamples => 'Samples'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 4d040dd3..b7d52bd8 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -4272,6 +4272,18 @@ "@audioAnalysisSampleRate": { "description": "Sample rate metric label" }, + "audioAnalysisCodec": "Codec", + "@audioAnalysisCodec": { + "description": "Audio codec metric label" + }, + "audioAnalysisContainer": "Container", + "@audioAnalysisContainer": { + "description": "Audio container metric label" + }, + "audioAnalysisDecodedFormat": "Decoded Format", + "@audioAnalysisDecodedFormat": { + "description": "Decoded sample format metric label" + }, "audioAnalysisBitDepth": "Bit Depth", "@audioAnalysisBitDepth": { "description": "Bit depth metric label" @@ -4304,6 +4316,30 @@ "@audioAnalysisRms": { "description": "RMS level metric label" }, + "audioAnalysisLufs": "LUFS", + "@audioAnalysisLufs": { + "description": "Integrated loudness metric label" + }, + "audioAnalysisTruePeak": "True Peak", + "@audioAnalysisTruePeak": { + "description": "True peak metric label" + }, + "audioAnalysisClipping": "Clipping", + "@audioAnalysisClipping": { + "description": "Clipping metric label" + }, + "audioAnalysisNoClipping": "No clipping", + "@audioAnalysisNoClipping": { + "description": "Displayed when no clipped samples were detected" + }, + "audioAnalysisSpectralCutoff": "Spectral Cutoff", + "@audioAnalysisSpectralCutoff": { + "description": "Estimated spectral cutoff metric label" + }, + "audioAnalysisChannelStats": "Per-channel Stats", + "@audioAnalysisChannelStats": { + "description": "Per-channel audio analysis section label" + }, "audioAnalysisSamples": "Samples", "@audioAnalysisSamples": { "description": "Total samples metric label" diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index ebdd3129..734b0c5e 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -15,10 +15,13 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; class AudioAnalysisData { - static const cacheVersion = 3; + static const cacheVersion = 4; final String filePath; final int fileSize; + final String codec; + final String container; + final String decodedSampleFormat; final int sampleRate; final int channels; final String channelLayout; @@ -29,12 +32,20 @@ class AudioAnalysisData { final double dynamicRange; final double peakAmplitude; final double rmsLevel; + final double? integratedLufs; + final double? truePeakDb; + final int clippingSamples; + final double? spectralCutoffHz; + final List channelStats; final int totalSamples; final SpectrogramData? spectrum; const AudioAnalysisData({ required this.filePath, required this.fileSize, + this.codec = '', + this.container = '', + this.decodedSampleFormat = '', required this.sampleRate, required this.channels, this.channelLayout = '', @@ -45,6 +56,11 @@ class AudioAnalysisData { required this.dynamicRange, required this.peakAmplitude, required this.rmsLevel, + this.integratedLufs, + this.truePeakDb, + this.clippingSamples = 0, + this.spectralCutoffHz, + this.channelStats = const [], required this.totalSamples, this.spectrum, }); @@ -53,6 +69,9 @@ class AudioAnalysisData { 'filePath': filePath, 'cacheVersion': cacheVersion, 'fileSize': fileSize, + 'codec': codec, + 'container': container, + 'decodedSampleFormat': decodedSampleFormat, 'sampleRate': sampleRate, 'channels': channels, 'channelLayout': channelLayout, @@ -63,6 +82,11 @@ class AudioAnalysisData { 'dynamicRange': dynamicRange, 'peakAmplitude': peakAmplitude, 'rmsLevel': rmsLevel, + 'integratedLufs': integratedLufs, + 'truePeakDb': truePeakDb, + 'clippingSamples': clippingSamples, + 'spectralCutoffHz': spectralCutoffHz, + 'channelStats': channelStats.map((stats) => stats.toJson()).toList(), 'totalSamples': totalSamples, }; @@ -70,6 +94,9 @@ class AudioAnalysisData { return AudioAnalysisData( filePath: json['filePath'] as String, fileSize: json['fileSize'] as int, + codec: json['codec']?.toString() ?? '', + container: json['container']?.toString() ?? '', + decodedSampleFormat: json['decodedSampleFormat']?.toString() ?? '', sampleRate: json['sampleRate'] as int, channels: json['channels'] as int, channelLayout: json['channelLayout']?.toString() ?? '', @@ -80,11 +107,55 @@ class AudioAnalysisData { dynamicRange: (json['dynamicRange'] as num).toDouble(), peakAmplitude: (json['peakAmplitude'] as num).toDouble(), rmsLevel: (json['rmsLevel'] as num).toDouble(), + integratedLufs: (json['integratedLufs'] as num?)?.toDouble(), + truePeakDb: (json['truePeakDb'] as num?)?.toDouble(), + clippingSamples: (json['clippingSamples'] as num?)?.toInt() ?? 0, + spectralCutoffHz: (json['spectralCutoffHz'] as num?)?.toDouble(), + channelStats: + (json['channelStats'] as List?) + ?.whereType>() + .map((item) => ChannelAnalysisStats.fromJson(item)) + .toList() ?? + const [], totalSamples: json['totalSamples'] as int, ); } } +class ChannelAnalysisStats { + final int channel; + final double? peakDb; + final double? rmsDb; + final double? dynamicRangeDb; + final int peakCount; + + const ChannelAnalysisStats({ + required this.channel, + this.peakDb, + this.rmsDb, + this.dynamicRangeDb, + this.peakCount = 0, + }); + + Map toJson() => { + 'channel': channel, + 'peakDb': peakDb, + 'rmsDb': rmsDb, + 'dynamicRangeDb': dynamicRangeDb, + 'peakCount': peakCount, + }; + + factory ChannelAnalysisStats.fromJson(Map json) { + return ChannelAnalysisStats( + channel: (json['channel'] as num?)?.toInt() ?? 0, + peakDb: (json['peakDb'] as num?)?.toDouble(), + rmsDb: (json['rmsDb'] as num?)?.toDouble(), + dynamicRangeDb: (json['dynamicRangeDb'] as num?)?.toDouble(), + peakCount: (json['peakCount'] as num?)?.toInt() ?? 0, + ); + } +} + class SpectrogramData { final List magnitudes; final int sampleRate; @@ -380,15 +451,25 @@ class _AudioAnalysisCardState extends State { ), ); final levelMetrics = await _runFullStreamLevelAnalysis(workingPath); + final loudnessMetrics = await _runLoudnessAnalysis(workingPath); final peakAmplitude = levelMetrics?.peakDb ?? spectrumResult.peakAmplitude; final rmsLevel = levelMetrics?.rmsDb ?? spectrumResult.rmsLevel; final dynamicRange = levelMetrics?.dynamicRangeDb ?? (peakAmplitude - rmsLevel); + final spectralCutoffHz = spectrumResult.spectrum == null + ? null + : await compute( + _estimateSpectralCutoffHz, + spectrumResult.spectrum!, + ); return AudioAnalysisData( filePath: filePath, fileSize: info.fileSize, + codec: info.codec, + container: info.container, + decodedSampleFormat: info.decodedSampleFormat, sampleRate: info.sampleRate, channels: info.channels, channelLayout: info.channelLayout, @@ -401,6 +482,11 @@ class _AudioAnalysisCardState extends State { dynamicRange: dynamicRange, peakAmplitude: peakAmplitude, rmsLevel: rmsLevel, + integratedLufs: loudnessMetrics?.integratedLufs, + truePeakDb: loudnessMetrics?.truePeakDb, + clippingSamples: levelMetrics?.clippingSamples ?? 0, + spectralCutoffHz: spectralCutoffHz, + channelStats: levelMetrics?.channelStats ?? const [], totalSamples: info.totalSamples, spectrum: spectrumResult.spectrum, ); @@ -439,6 +525,12 @@ class _AudioAnalysisCardState extends State { ); final props = audioStream.getAllProperties() ?? {}; + final infoProps = info.getAllProperties() ?? {}; + final codecName = props['codec_name']?.toString().toLowerCase() ?? ''; + final codecLongName = props['codec_long_name']?.toString() ?? ''; + final decodedSampleFormat = props['sample_fmt']?.toString() ?? ''; + final formatName = infoProps['format_name']?.toString() ?? ''; + final formatLongName = infoProps['format_long_name']?.toString() ?? ''; final sampleRate = int.tryParse(props['sample_rate']?.toString() ?? '') ?? 0; final channels = int.tryParse(props['channels']?.toString() ?? '') ?? 0; @@ -460,7 +552,6 @@ class _AudioAnalysisCardState extends State { containerBitrate ?? (duration > 0 && fileSize > 0 ? (fileSize * 8 / duration).round() : 0); - final codecName = props['codec_name']?.toString().toLowerCase() ?? ''; final canReportStoredBitDepth = _codecHasStoredBitDepth(codecName); int bitsPerSample = 0; @@ -490,6 +581,9 @@ class _AudioAnalysisCardState extends State { return _MediaInfo( fileSize: fileSize, + codec: _formatCodecLabel(codecName, codecLongName), + container: _formatContainerLabel(formatName, formatLongName), + decodedSampleFormat: decodedSampleFormat, sampleRate: sampleRate, channels: channels, channelLayout: channelLayout, @@ -505,6 +599,23 @@ class _AudioAnalysisCardState extends State { ); } + String _formatCodecLabel(String codecName, String codecLongName) { + final name = codecName.trim(); + final longName = codecLongName.trim(); + if (name.isEmpty) return longName; + if (longName.isEmpty || longName.toLowerCase() == name.toLowerCase()) { + return name.toUpperCase(); + } + return '${name.toUpperCase()} ($longName)'; + } + + String _formatContainerLabel(String formatName, String formatLongName) { + final longName = formatLongName.trim(); + if (longName.isNotEmpty) return longName; + final name = formatName.trim(); + return name.isEmpty ? '' : name.toUpperCase(); + } + int _estimateTotalSamples({ required Map props, required double duration, @@ -578,16 +689,91 @@ class _AudioAnalysisCardState extends State { final peak = _parseLastAstatsValue(section, 'Peak level dB'); final rms = _parseLastAstatsValue(section, 'RMS level dB'); if (peak == null || rms == null) return null; + final channelStats = _parseChannelStats(logs); + final clippingSamples = channelStats.fold(0, (sum, stats) { + if (stats.peakDb == null || stats.peakDb! < -0.1) return sum; + return sum + stats.peakCount; + }); return _LevelMetrics( peakDb: peak, rmsDb: rms, dynamicRangeDb: _parseLastAstatsValue(section, 'Dynamic range'), + clippingSamples: clippingSamples, + channelStats: channelStats, ); } finally { await FFmpegKitConfig.setLogLevel(Level.avLogError); } } + Future<_LoudnessMetrics?> _runLoudnessAnalysis(String inputPath) async { + await FFmpegKitConfig.setLogLevel(Level.avLogInfo); + try { + final session = await FFmpegKit.executeWithArguments([ + '-hide_banner', + '-nostats', + '-i', + inputPath, + '-filter_complex', + 'ebur128=peak=true:framelog=quiet', + '-f', + 'null', + '-', + ]); + + final logs = await session.getLogsAsString(); + final integratedMatches = RegExp( + r'I:\s+(-?\d+\.?\d*)\s+LUFS', + ).allMatches(logs); + final integrated = integratedMatches.isEmpty + ? null + : double.tryParse(integratedMatches.last.group(1) ?? ''); + + double? truePeak; + for (final match in RegExp( + r'Peak:\s+(-?\d+\.?\d*)\s+dBFS', + ).allMatches(logs)) { + final value = double.tryParse(match.group(1) ?? ''); + if (value != null && (truePeak == null || value > truePeak)) { + truePeak = value; + } + } + + if (integrated == null && truePeak == null) return null; + return _LoudnessMetrics(integratedLufs: integrated, truePeakDb: truePeak); + } finally { + await FFmpegKitConfig.setLogLevel(Level.avLogError); + } + } + + List _parseChannelStats(String logs) { + final stats = []; + final channelMatches = RegExp( + r'Channel:\s*(\d+)([\s\S]*?)(?=Channel:\s*\d+|Overall|$)', + caseSensitive: false, + ).allMatches(logs); + + for (final match in channelMatches) { + final channel = int.tryParse(match.group(1) ?? '') ?? 0; + final section = match.group(2) ?? ''; + if (channel <= 0 || section.trim().isEmpty) continue; + stats.add( + ChannelAnalysisStats( + channel: channel, + peakDb: _parseLastAstatsValue(section, 'Peak level dB'), + rmsDb: _parseLastAstatsValue(section, 'RMS level dB'), + dynamicRangeDb: _parseLastAstatsValue(section, 'Dynamic range'), + peakCount: + _parseLastAstatsInt(section, 'Peak count') ?? + _parseLastAstatsInt(section, 'Peak count ch') ?? + 0, + ), + ); + } + + return stats; + } + double? _parseLastAstatsValue(String text, String label) { final matches = RegExp( '${RegExp.escape(label)}:\\s*([-+]?\\d+(?:\\.\\d+)?)', @@ -603,6 +789,18 @@ class _AudioAnalysisCardState extends State { return value; } + int? _parseLastAstatsInt(String text, String label) { + final matches = RegExp( + '${RegExp.escape(label)}:\\s*(\\d+)', + caseSensitive: false, + ).allMatches(text); + int? value; + for (final match in matches) { + value = int.tryParse(match.group(1) ?? '') ?? value; + } + return value; + } + Future _decodeToPCM( String inputPath, String outputPath, @@ -794,6 +992,9 @@ class _AudioAnalysisCardState extends State { class _MediaInfo { final int fileSize; + final String codec; + final String container; + final String decodedSampleFormat; final int sampleRate; final int channels; final String channelLayout; @@ -804,6 +1005,9 @@ class _MediaInfo { const _MediaInfo({ required this.fileSize, + required this.codec, + required this.container, + required this.decodedSampleFormat, required this.sampleRate, required this.channels, required this.channelLayout, @@ -818,14 +1022,25 @@ class _LevelMetrics { final double peakDb; final double rmsDb; final double? dynamicRangeDb; + final int clippingSamples; + final List channelStats; const _LevelMetrics({ required this.peakDb, required this.rmsDb, this.dynamicRangeDb, + this.clippingSamples = 0, + this.channelStats = const [], }); } +class _LoudnessMetrics { + final double? integratedLufs; + final double? truePeakDb; + + const _LoudnessMetrics({this.integratedLufs, this.truePeakDb}); +} + class _AnalysisParams { final Uint8List pcmBytes; final int sampleRate; @@ -938,6 +1153,40 @@ SpectrogramData _computeSpectrum(Float64List samples, int sampleRate) { ); } +double? _estimateSpectralCutoffHz(SpectrogramData spectrum) { + if (spectrum.magnitudes.isEmpty || spectrum.freqBins <= 0) return null; + + final averages = Float64List(spectrum.freqBins); + for (final slice in spectrum.magnitudes) { + final limit = math.min(slice.length, spectrum.freqBins); + for (int i = 0; i < limit; i++) { + averages[i] += slice[i]; + } + } + + var peak = -double.infinity; + final startBin = math.max( + 1, + (20 / spectrum.maxFreq * spectrum.freqBins).floor(), + ); + for (int i = startBin; i < averages.length; i++) { + averages[i] /= spectrum.magnitudes.length; + if (averages[i] > peak) peak = averages[i]; + } + if (!peak.isFinite) return null; + + final threshold = peak - 60.0; + var cutoffBin = 0; + for (int i = averages.length - 1; i >= startBin; i--) { + if (averages[i] >= threshold) { + cutoffBin = i; + break; + } + } + if (cutoffBin <= 0) return null; + return cutoffBin / spectrum.freqBins * spectrum.maxFreq; +} + /// Cooley-Tukey radix-2 FFT. Returns interleaved [re, im, re, im, ...]. Float64List _fft(Float64List realInput) { final n = realInput.length; @@ -1048,6 +1297,20 @@ class _AudioInfoCard extends StatelessWidget { spacing: 16, runSpacing: 8, children: [ + if (data.codec.isNotEmpty) + _MetricChip( + icon: Icons.memory, + label: context.l10n.audioAnalysisCodec, + value: data.codec, + cs: cs, + ), + if (data.container.isNotEmpty) + _MetricChip( + icon: Icons.inventory_2_outlined, + label: context.l10n.audioAnalysisContainer, + value: data.container, + cs: cs, + ), _MetricChip( icon: Icons.graphic_eq, label: context.l10n.audioAnalysisSampleRate, @@ -1060,6 +1323,13 @@ class _AudioInfoCard extends StatelessWidget { value: data.bitDepth, cs: cs, ), + if (data.decodedSampleFormat.isNotEmpty) + _MetricChip( + icon: Icons.data_object, + label: context.l10n.audioAnalysisDecodedFormat, + value: data.decodedSampleFormat, + cs: cs, + ), if (data.bitrate > 0) _MetricChip( icon: Icons.speed, @@ -1119,6 +1389,33 @@ class _AudioInfoCard extends StatelessWidget { value: '${data.rmsLevel.toStringAsFixed(2)} dB', cs: cs, ), + if (data.integratedLufs != null) + _MetricChip( + icon: Icons.volume_up_outlined, + label: context.l10n.audioAnalysisLufs, + value: '${data.integratedLufs!.toStringAsFixed(1)} LUFS', + cs: cs, + ), + if (data.truePeakDb != null) + _MetricChip( + icon: Icons.warning_amber_outlined, + label: context.l10n.audioAnalysisTruePeak, + value: '${data.truePeakDb!.toStringAsFixed(2)} dBTP', + cs: cs, + ), + _MetricChip( + icon: Icons.report_gmailerrorred_outlined, + label: context.l10n.audioAnalysisClipping, + value: _formatClipping(context, data.clippingSamples), + cs: cs, + ), + if (data.spectralCutoffHz != null) + _MetricChip( + icon: Icons.filter_alt_outlined, + label: context.l10n.audioAnalysisSpectralCutoff, + value: _formatFrequency(data.spectralCutoffHz!), + cs: cs, + ), _MetricChip( icon: Icons.numbers, label: context.l10n.audioAnalysisSamples, @@ -1127,6 +1424,32 @@ class _AudioInfoCard extends StatelessWidget { ), ], ), + if (data.channelStats.length > 1) ...[ + const SizedBox(height: 8), + Divider(color: cs.outlineVariant), + const SizedBox(height: 8), + Text( + context.l10n.audioAnalysisChannelStats, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 8, + children: data.channelStats.map((stats) { + return _MetricChip( + icon: Icons.surround_sound, + label: 'Ch ${stats.channel}', + value: _formatChannelStats(stats), + cs: cs, + ); + }).toList(), + ), + ], ], ), ), @@ -1157,6 +1480,11 @@ class _AudioInfoCard extends StatelessWidget { return '${size.toStringAsFixed(1)} ${units[i]}'; } + String _formatFrequency(double hz) { + if (hz >= 1000) return '${(hz / 1000).toStringAsFixed(1)} kHz'; + return '${hz.round()} Hz'; + } + String _formatBitrate(int bitsPerSecond) { if (bitsPerSecond >= 1000000) { return '${(bitsPerSecond / 1000000).toStringAsFixed(2)} Mbps'; @@ -1164,6 +1492,28 @@ class _AudioInfoCard extends StatelessWidget { return '${(bitsPerSecond / 1000).round()} kbps'; } + String _formatClipping(BuildContext context, int samples) { + if (samples <= 0) return context.l10n.audioAnalysisNoClipping; + return _formatNumber(samples); + } + + String _formatChannelStats(ChannelAnalysisStats stats) { + final parts = []; + if (stats.peakDb != null) { + parts.add('P ${stats.peakDb!.toStringAsFixed(1)}'); + } + if (stats.rmsDb != null) { + parts.add('R ${stats.rmsDb!.toStringAsFixed(1)}'); + } + if (stats.dynamicRangeDb != null) { + parts.add('DR ${stats.dynamicRangeDb!.toStringAsFixed(1)}'); + } + if (stats.peakCount > 0 && (stats.peakDb ?? -100) >= -0.1) { + parts.add('Clip ${_formatNumber(stats.peakCount)}'); + } + return parts.isEmpty ? 'N/A' : parts.join(' / '); + } + String _formatNumber(int n) { if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M'; if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}K'; @@ -1186,24 +1536,30 @@ class _MetricChip extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: cs.onSurfaceVariant), - const SizedBox(width: 4), - Text( - '$label: ', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), - ), - Text( - value, - style: TextStyle( - color: cs.onSurface, - fontSize: 12, - fontWeight: FontWeight.w600, + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: cs.onSurfaceVariant), + const SizedBox(width: 4), + Text( + '$label: ', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), ), - ), - ], + Flexible( + child: Text( + value, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), ); } } diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 4d929acb..8f4b396b 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -428,23 +428,20 @@ void main() { }); group('audio conversion utils', () { - test('detects Dolby formats from stored scan format before file extension', () { - expect( - convertibleAudioSourceFormat( - storedFormat: 'eac3', - filePath: 'content://media/song.m4a', - ), - 'EAC3', - ); - expect( - convertibleAudioSourceFormat(fileName: 'Song.ac-3'), - 'AC3', - ); - expect( - convertibleAudioSourceFormat(storedFormat: 'ac4'), - 'AC4', - ); - }); + test( + 'detects Dolby formats from stored scan format before file extension', + () { + expect( + convertibleAudioSourceFormat( + storedFormat: 'eac3', + filePath: 'content://media/song.m4a', + ), + 'EAC3', + ); + expect(convertibleAudioSourceFormat(fileName: 'Song.ac-3'), 'AC3'); + expect(convertibleAudioSourceFormat(storedFormat: 'ac4'), 'AC4'); + }, + ); test('allows Dolby sources only for lossy batch conversion targets', () { expect(