mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-25 01:04:11 +02:00
feat: enhanced audio analysis with loudness, clipping, and spectral cutoff
Audio Analysis Enhancements: - Display codec name and container format - Show decoded sample format (s16, s32, fltp, etc.) - Add LUFS integrated loudness measurement (broadcast standard) - Add true peak measurement (dBTP) - Detect and count clipping samples per channel - Estimate spectral cutoff frequency (helps detect fake upscales) - Show per-channel statistics (Peak, RMS, DR, Clip count) UI Improvements: - MetricChip now handles long text with ellipsis - Constrained max width for better layout Cache version bumped to 4 to force rescan with new metrics.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Семпли';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<ChannelAnalysisStats> 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<dynamic, dynamic>>()
|
||||
.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<String, dynamic> toJson() => {
|
||||
'channel': channel,
|
||||
'peakDb': peakDb,
|
||||
'rmsDb': rmsDb,
|
||||
'dynamicRangeDb': dynamicRangeDb,
|
||||
'peakCount': peakCount,
|
||||
};
|
||||
|
||||
factory ChannelAnalysisStats.fromJson(Map<dynamic, dynamic> 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<Float64List> magnitudes;
|
||||
final int sampleRate;
|
||||
@@ -380,15 +451,25 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
),
|
||||
);
|
||||
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<AudioAnalysisCard> {
|
||||
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<AudioAnalysisCard> {
|
||||
);
|
||||
|
||||
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<AudioAnalysisCard> {
|
||||
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<AudioAnalysisCard> {
|
||||
|
||||
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<AudioAnalysisCard> {
|
||||
);
|
||||
}
|
||||
|
||||
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<dynamic, dynamic> props,
|
||||
required double duration,
|
||||
@@ -578,16 +689,91 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
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<int>(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<ChannelAnalysisStats> _parseChannelStats(String logs) {
|
||||
final stats = <ChannelAnalysisStats>[];
|
||||
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<AudioAnalysisCard> {
|
||||
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<void> _decodeToPCM(
|
||||
String inputPath,
|
||||
String outputPath,
|
||||
@@ -794,6 +992,9 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
|
||||
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<ChannelAnalysisStats> 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 = <String>[];
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user