mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-05-31 19:05:05 +07:00
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.
1749 lines
51 KiB
Dart
1749 lines
51 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
import 'dart:ui' as ui;
|
|
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart';
|
|
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart';
|
|
import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart';
|
|
import 'package:ffmpeg_kit_flutter_new_full/level.dart';
|
|
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
|
|
class AudioAnalysisData {
|
|
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;
|
|
final int bitsPerSample;
|
|
final double duration;
|
|
final int bitrate;
|
|
final String bitDepth;
|
|
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 = '',
|
|
required this.bitsPerSample,
|
|
required this.duration,
|
|
required this.bitrate,
|
|
required this.bitDepth,
|
|
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,
|
|
});
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'filePath': filePath,
|
|
'cacheVersion': cacheVersion,
|
|
'fileSize': fileSize,
|
|
'codec': codec,
|
|
'container': container,
|
|
'decodedSampleFormat': decodedSampleFormat,
|
|
'sampleRate': sampleRate,
|
|
'channels': channels,
|
|
'channelLayout': channelLayout,
|
|
'bitsPerSample': bitsPerSample,
|
|
'duration': duration,
|
|
'bitrate': bitrate,
|
|
'bitDepth': bitDepth,
|
|
'dynamicRange': dynamicRange,
|
|
'peakAmplitude': peakAmplitude,
|
|
'rmsLevel': rmsLevel,
|
|
'integratedLufs': integratedLufs,
|
|
'truePeakDb': truePeakDb,
|
|
'clippingSamples': clippingSamples,
|
|
'spectralCutoffHz': spectralCutoffHz,
|
|
'channelStats': channelStats.map((stats) => stats.toJson()).toList(),
|
|
'totalSamples': totalSamples,
|
|
};
|
|
|
|
factory AudioAnalysisData.fromJson(Map<String, dynamic> json) {
|
|
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() ?? '',
|
|
bitsPerSample: json['bitsPerSample'] as int,
|
|
duration: (json['duration'] as num).toDouble(),
|
|
bitrate: json['bitrate'] as int,
|
|
bitDepth: json['bitDepth'] as String,
|
|
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;
|
|
final int freqBins;
|
|
final double duration;
|
|
final double maxFreq;
|
|
final int sliceCount;
|
|
|
|
const SpectrogramData({
|
|
required this.magnitudes,
|
|
required this.sampleRate,
|
|
required this.freqBins,
|
|
required this.duration,
|
|
required this.maxFreq,
|
|
required this.sliceCount,
|
|
});
|
|
}
|
|
|
|
class AudioAnalysisCard extends StatefulWidget {
|
|
final String filePath;
|
|
|
|
const AudioAnalysisCard({super.key, required this.filePath});
|
|
|
|
@override
|
|
State<AudioAnalysisCard> createState() => _AudioAnalysisCardState();
|
|
}
|
|
|
|
class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
|
AudioAnalysisData? _data;
|
|
bool _analyzing = false;
|
|
bool _checkingCache = true;
|
|
String? _error;
|
|
ui.Image? _spectrogramImage;
|
|
|
|
static const _supportedExtensions = {
|
|
'.flac',
|
|
'.mp3',
|
|
'.m4a',
|
|
'.mp4',
|
|
'.aac',
|
|
'.ac3',
|
|
'.eac3',
|
|
'.opus',
|
|
'.ogg',
|
|
'.wav',
|
|
'.wma',
|
|
'.mka',
|
|
'.wv',
|
|
'.ape',
|
|
'.tta',
|
|
'.aif',
|
|
'.aiff',
|
|
};
|
|
|
|
bool get _isSupported {
|
|
final lower = widget.filePath.toLowerCase();
|
|
return _supportedExtensions.any((ext) => lower.endsWith(ext));
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
if (_isSupported) {
|
|
_tryLoadFromCache();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_spectrogramImage?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _tryLoadFromCache() async {
|
|
try {
|
|
final cached = await _loadFromCache(widget.filePath);
|
|
if (cached != null && mounted) {
|
|
setState(() {
|
|
_data = cached;
|
|
_checkingCache = false;
|
|
});
|
|
final image = await _loadSpectrogramFromCache(widget.filePath);
|
|
if (image != null && mounted) {
|
|
setState(() {
|
|
_spectrogramImage?.dispose();
|
|
_spectrogramImage = image;
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
} catch (_) {}
|
|
if (mounted) {
|
|
setState(() => _checkingCache = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _analyze({bool forceRefresh = false}) async {
|
|
if (_analyzing) return;
|
|
setState(() {
|
|
_analyzing = true;
|
|
_error = null;
|
|
if (forceRefresh) {
|
|
_spectrogramImage?.dispose();
|
|
_spectrogramImage = null;
|
|
_data = null;
|
|
}
|
|
});
|
|
|
|
try {
|
|
if (forceRefresh) {
|
|
await _clearCache(widget.filePath);
|
|
}
|
|
|
|
final cached = forceRefresh
|
|
? null
|
|
: await _loadFromCache(widget.filePath);
|
|
AudioAnalysisData data;
|
|
bool fromCache = false;
|
|
|
|
if (cached != null) {
|
|
data = cached;
|
|
fromCache = true;
|
|
} else {
|
|
data = await _runAnalysis(widget.filePath);
|
|
_saveToCache(widget.filePath, data);
|
|
}
|
|
|
|
ui.Image? image;
|
|
if (fromCache) {
|
|
image = await _loadSpectrogramFromCache(widget.filePath);
|
|
}
|
|
if (image == null &&
|
|
data.spectrum != null &&
|
|
data.spectrum!.sliceCount > 0) {
|
|
image = await _renderSpectrogramToImage(data.spectrum!);
|
|
_saveSpectrogramToCache(widget.filePath, image);
|
|
}
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_data = data;
|
|
_spectrogramImage?.dispose();
|
|
_spectrogramImage = image;
|
|
_analyzing = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_error = e.toString();
|
|
_analyzing = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
static Future<void> _clearCache(String filePath) async {
|
|
try {
|
|
final dir = await _cacheDir();
|
|
final key = _cacheKey(filePath);
|
|
final jsonFile = File('${dir.path}/$key.json');
|
|
final imageFile = File('${dir.path}/$key.png');
|
|
if (await jsonFile.exists()) {
|
|
await jsonFile.delete();
|
|
}
|
|
if (await imageFile.exists()) {
|
|
await imageFile.delete();
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
static String _cacheKey(String filePath) {
|
|
var hash = 0xcbf29ce484222325;
|
|
for (final byte in utf8.encode(filePath)) {
|
|
hash ^= byte;
|
|
hash = (hash * 0x100000001b3) & 0x7FFFFFFFFFFFFFFF;
|
|
}
|
|
return hash.toRadixString(16);
|
|
}
|
|
|
|
static Future<Directory> _cacheDir() async {
|
|
final appSupport = await getApplicationSupportDirectory();
|
|
final dir = Directory('${appSupport.path}/audio_analysis_cache');
|
|
if (!await dir.exists()) {
|
|
await dir.create(recursive: true);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
static Future<AudioAnalysisData?> _loadFromCache(String filePath) async {
|
|
try {
|
|
final dir = await _cacheDir();
|
|
final key = _cacheKey(filePath);
|
|
final file = File('${dir.path}/$key.json');
|
|
if (!await file.exists()) return null;
|
|
|
|
final json = Map<String, dynamic>.from(
|
|
jsonDecode(await file.readAsString()) as Map,
|
|
);
|
|
if (json['cacheVersion'] != AudioAnalysisData.cacheVersion) {
|
|
return null;
|
|
}
|
|
final cachedSize = json['fileSize'] as int;
|
|
|
|
if (!filePath.startsWith('content://')) {
|
|
final currentSize = await File(filePath).length();
|
|
if (currentSize != cachedSize) return null;
|
|
} else {
|
|
final stat = await PlatformBridge.safStat(filePath);
|
|
final currentSize = (stat['size'] as num?)?.toInt() ?? 0;
|
|
if (currentSize > 0 && currentSize != cachedSize) return null;
|
|
}
|
|
|
|
return AudioAnalysisData.fromJson(json);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static Future<void> _saveToCache(
|
|
String filePath,
|
|
AudioAnalysisData data,
|
|
) async {
|
|
try {
|
|
final dir = await _cacheDir();
|
|
final key = _cacheKey(filePath);
|
|
final file = File('${dir.path}/$key.json');
|
|
await file.writeAsString(jsonEncode(data.toJson()));
|
|
} catch (_) {}
|
|
}
|
|
|
|
static Future<void> _saveSpectrogramToCache(
|
|
String filePath,
|
|
ui.Image image,
|
|
) async {
|
|
try {
|
|
final dir = await _cacheDir();
|
|
final key = _cacheKey(filePath);
|
|
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
|
if (byteData != null) {
|
|
final file = File('${dir.path}/$key.png');
|
|
await file.writeAsBytes(byteData.buffer.asUint8List());
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
static Future<ui.Image?> _loadSpectrogramFromCache(String filePath) async {
|
|
try {
|
|
final dir = await _cacheDir();
|
|
final key = _cacheKey(filePath);
|
|
final file = File('${dir.path}/$key.png');
|
|
if (!await file.exists()) return null;
|
|
|
|
final bytes = await file.readAsBytes();
|
|
final completer = Completer<ui.Image>();
|
|
ui.decodeImageFromList(bytes, completer.complete);
|
|
return completer.future;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<AudioAnalysisData> _runAnalysis(String filePath) async {
|
|
await FFmpegKitConfig.setLogLevel(Level.avLogError);
|
|
|
|
String workingPath = filePath;
|
|
String? tempCopy;
|
|
if (filePath.startsWith('content://')) {
|
|
tempCopy = await PlatformBridge.copyContentUriToTemp(filePath);
|
|
if (tempCopy == null) {
|
|
throw Exception('Failed to copy SAF file for analysis');
|
|
}
|
|
workingPath = tempCopy;
|
|
}
|
|
|
|
try {
|
|
final info = await _getMediaInfo(workingPath);
|
|
|
|
final tempDir = await getTemporaryDirectory();
|
|
final pcmPath =
|
|
'${tempDir.path}/analysis_pcm_${DateTime.now().millisecondsSinceEpoch}.raw';
|
|
|
|
try {
|
|
await _decodeToPCM(workingPath, pcmPath, info.sampleRate);
|
|
|
|
final pcmBytes = await File(pcmPath).readAsBytes();
|
|
final spectrumResult = await compute(
|
|
_analyzeInIsolate,
|
|
_AnalysisParams(
|
|
pcmBytes: pcmBytes,
|
|
sampleRate: info.sampleRate,
|
|
bitsPerSample: info.bitsPerSample,
|
|
),
|
|
);
|
|
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,
|
|
bitsPerSample: info.bitsPerSample,
|
|
duration: info.duration,
|
|
bitrate: info.bitrate,
|
|
bitDepth: info.bitsPerSample > 0
|
|
? '${info.bitsPerSample}-bit'
|
|
: 'N/A',
|
|
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,
|
|
);
|
|
} finally {
|
|
try {
|
|
await File(pcmPath).delete();
|
|
} catch (_) {}
|
|
}
|
|
} finally {
|
|
if (tempCopy != null) {
|
|
try {
|
|
await File(tempCopy).delete();
|
|
} catch (_) {}
|
|
}
|
|
await FFmpegKitConfig.setLogLevel(Level.avLogInfo);
|
|
}
|
|
}
|
|
|
|
Future<_MediaInfo> _getMediaInfo(String filePath) async {
|
|
final session = await FFprobeKit.getMediaInformation(filePath);
|
|
final info = session.getMediaInformation();
|
|
|
|
if (info == null) {
|
|
throw Exception('Failed to get media information');
|
|
}
|
|
|
|
int fileSize = 0;
|
|
try {
|
|
fileSize = await File(filePath).length();
|
|
} catch (_) {}
|
|
|
|
final streams = info.getStreams();
|
|
final audioStream = streams.firstWhere(
|
|
(s) => s.getAllProperties()?['codec_type'] == 'audio',
|
|
orElse: () => throw Exception('No audio stream found'),
|
|
);
|
|
|
|
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;
|
|
final channelLayout =
|
|
props['channel_layout']?.toString() ??
|
|
props['ch_layout']?.toString() ??
|
|
'';
|
|
final streamDuration = double.tryParse(props['duration']?.toString() ?? '');
|
|
final containerDuration = double.tryParse(info.getDuration() ?? '');
|
|
final duration =
|
|
(streamDuration != null && streamDuration > 0
|
|
? streamDuration
|
|
: containerDuration) ??
|
|
0;
|
|
final streamBitrate = int.tryParse(props['bit_rate']?.toString() ?? '');
|
|
final containerBitrate = int.tryParse(info.getBitrate() ?? '');
|
|
final bitrate =
|
|
streamBitrate ??
|
|
containerBitrate ??
|
|
(duration > 0 && fileSize > 0 ? (fileSize * 8 / duration).round() : 0);
|
|
|
|
final canReportStoredBitDepth = _codecHasStoredBitDepth(codecName);
|
|
|
|
int bitsPerSample = 0;
|
|
if (canReportStoredBitDepth) {
|
|
bitsPerSample =
|
|
int.tryParse(props['bits_per_raw_sample']?.toString() ?? '') ?? 0;
|
|
if (bitsPerSample == 0) {
|
|
bitsPerSample =
|
|
int.tryParse(props['bits_per_sample']?.toString() ?? '') ?? 0;
|
|
}
|
|
}
|
|
|
|
if (bitsPerSample == 0 && canReportStoredBitDepth) {
|
|
final sampleFmt = props['sample_fmt']?.toString() ?? '';
|
|
if (sampleFmt.contains('16') ||
|
|
sampleFmt == 's16' ||
|
|
sampleFmt == 's16p') {
|
|
bitsPerSample = 16;
|
|
} else if (sampleFmt.contains('32') ||
|
|
sampleFmt == 'flt' ||
|
|
sampleFmt == 'fltp') {
|
|
bitsPerSample = 32;
|
|
} else if (sampleFmt.contains('24') || sampleFmt == 's24') {
|
|
bitsPerSample = 24;
|
|
}
|
|
}
|
|
|
|
return _MediaInfo(
|
|
fileSize: fileSize,
|
|
codec: _formatCodecLabel(codecName, codecLongName),
|
|
container: _formatContainerLabel(formatName, formatLongName),
|
|
decodedSampleFormat: decodedSampleFormat,
|
|
sampleRate: sampleRate,
|
|
channels: channels,
|
|
channelLayout: channelLayout,
|
|
bitsPerSample: bitsPerSample,
|
|
duration: duration,
|
|
bitrate: bitrate,
|
|
totalSamples: _estimateTotalSamples(
|
|
props: props,
|
|
duration: duration,
|
|
sampleRate: sampleRate,
|
|
channels: channels,
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
required int sampleRate,
|
|
required int channels,
|
|
}) {
|
|
final nbSamples = int.tryParse(props['nb_samples']?.toString() ?? '');
|
|
if (nbSamples != null && nbSamples > 0) {
|
|
return nbSamples;
|
|
}
|
|
|
|
final durationTs = int.tryParse(props['duration_ts']?.toString() ?? '');
|
|
final timeBase = props['time_base']?.toString() ?? '';
|
|
if (durationTs != null && durationTs > 0 && timeBase.contains('/')) {
|
|
final parts = timeBase.split('/');
|
|
final numerator = double.tryParse(parts[0]);
|
|
final denominator = double.tryParse(parts[1]);
|
|
if (numerator != null &&
|
|
numerator > 0 &&
|
|
denominator != null &&
|
|
denominator > 0 &&
|
|
sampleRate > 0) {
|
|
final seconds = durationTs * numerator / denominator;
|
|
return (seconds * sampleRate).round();
|
|
}
|
|
}
|
|
|
|
if (duration > 0 && sampleRate > 0) {
|
|
return (duration * sampleRate).round();
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
bool _codecHasStoredBitDepth(String codecName) {
|
|
if (codecName.isEmpty) return false;
|
|
return codecName == 'flac' ||
|
|
codecName == 'alac' ||
|
|
codecName == 'wavpack' ||
|
|
codecName == 'ape' ||
|
|
codecName == 'tta' ||
|
|
codecName.startsWith('pcm_');
|
|
}
|
|
|
|
Future<_LevelMetrics?> _runFullStreamLevelAnalysis(String inputPath) async {
|
|
await FFmpegKitConfig.setLogLevel(Level.avLogInfo);
|
|
try {
|
|
final session = await FFmpegKit.executeWithArguments([
|
|
'-v',
|
|
'info',
|
|
'-hide_banner',
|
|
'-nostats',
|
|
'-i',
|
|
inputPath,
|
|
'-map',
|
|
'0:a:0',
|
|
'-af',
|
|
'astats=metadata=1:reset=0',
|
|
'-f',
|
|
'null',
|
|
'-',
|
|
]);
|
|
|
|
final returnCode = await session.getReturnCode();
|
|
if (!ReturnCode.isSuccess(returnCode)) {
|
|
return null;
|
|
}
|
|
|
|
final logs = await session.getLogsAsString();
|
|
final overallMatch = RegExp(r'Overall([\s\S]*)').firstMatch(logs);
|
|
final section = overallMatch?.group(1) ?? logs;
|
|
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+)?)',
|
|
caseSensitive: false,
|
|
).allMatches(text);
|
|
double? value;
|
|
for (final match in matches) {
|
|
final parsed = double.tryParse(match.group(1) ?? '');
|
|
if (parsed != null && parsed.isFinite) {
|
|
value = parsed;
|
|
}
|
|
}
|
|
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,
|
|
int sampleRate,
|
|
) async {
|
|
final maxDuration = sampleRate > 0 ? (10000000 / sampleRate) : 300;
|
|
|
|
final session = await FFmpegKit.executeWithArguments([
|
|
'-loglevel',
|
|
'error',
|
|
'-i',
|
|
inputPath,
|
|
'-t',
|
|
maxDuration.toStringAsFixed(1),
|
|
'-ac',
|
|
'1',
|
|
'-ar',
|
|
sampleRate.toString(),
|
|
'-f',
|
|
's16le',
|
|
'-acodec',
|
|
'pcm_s16le',
|
|
'-y',
|
|
outputPath,
|
|
]);
|
|
|
|
final returnCode = await session.getReturnCode();
|
|
if (!ReturnCode.isSuccess(returnCode)) {
|
|
final logs = await session.getLogsAsString();
|
|
throw Exception('FFmpeg decode failed: $logs');
|
|
}
|
|
}
|
|
|
|
Future<ui.Image> _renderSpectrogramToImage(SpectrogramData spectrum) async {
|
|
const imgWidth = 800;
|
|
const imgHeight = 400;
|
|
|
|
final pixels = await compute(
|
|
_renderSpectrogramPixels,
|
|
_SpectrogramRenderParams(
|
|
spectrum: spectrum,
|
|
width: imgWidth,
|
|
height: imgHeight,
|
|
),
|
|
);
|
|
|
|
final completer = Completer<ui.Image>();
|
|
ui.decodeImageFromPixels(
|
|
pixels,
|
|
imgWidth,
|
|
imgHeight,
|
|
ui.PixelFormat.rgba8888,
|
|
completer.complete,
|
|
);
|
|
return completer.future;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!_isSupported) return const SizedBox.shrink();
|
|
|
|
final cs = Theme.of(context).colorScheme;
|
|
final l10n = context.l10n;
|
|
|
|
if (_checkingCache) return const SizedBox.shrink();
|
|
|
|
if (_analyzing) {
|
|
final isRescan = _data != null || _spectrogramImage != null;
|
|
return Card(
|
|
color: cs.surfaceContainerLow,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CircularProgressIndicator(strokeWidth: 2.5),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
isRescan
|
|
? l10n.audioAnalysisRescanning
|
|
: l10n.audioAnalysisAnalyzing,
|
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_error != null) {
|
|
return Card(
|
|
color: cs.errorContainer,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.error_outline, color: cs.onErrorContainer),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
_error!,
|
|
style: TextStyle(color: cs.onErrorContainer, fontSize: 13),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh, size: 20),
|
|
tooltip: l10n.audioAnalysisRescan,
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
|
color: cs.onErrorContainer,
|
|
onPressed: () => _analyze(forceRefresh: true),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_data == null) {
|
|
return Card(
|
|
color: cs.surfaceContainerLow,
|
|
child: InkWell(
|
|
onTap: _analyze,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.analytics_outlined, color: cs.primary, size: 28),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.audioAnalysisTitle,
|
|
style: TextStyle(
|
|
color: cs.onSurface,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 15,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
l10n.audioAnalysisDescription,
|
|
style: TextStyle(
|
|
color: cs.onSurfaceVariant,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(Icons.chevron_right, color: cs.onSurfaceVariant),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final data = _data!;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_AudioInfoCard(
|
|
data: data,
|
|
onRescan: () => _analyze(forceRefresh: true),
|
|
),
|
|
if (_spectrogramImage != null) ...[
|
|
const SizedBox(height: 12),
|
|
_SpectrogramView(
|
|
image: _spectrogramImage!,
|
|
sampleRate: data.sampleRate,
|
|
maxFreq: data.spectrum?.maxFreq ?? data.sampleRate / 2,
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MediaInfo {
|
|
final int fileSize;
|
|
final String codec;
|
|
final String container;
|
|
final String decodedSampleFormat;
|
|
final int sampleRate;
|
|
final int channels;
|
|
final String channelLayout;
|
|
final int bitsPerSample;
|
|
final double duration;
|
|
final int bitrate;
|
|
final int totalSamples;
|
|
|
|
const _MediaInfo({
|
|
required this.fileSize,
|
|
required this.codec,
|
|
required this.container,
|
|
required this.decodedSampleFormat,
|
|
required this.sampleRate,
|
|
required this.channels,
|
|
required this.channelLayout,
|
|
required this.bitsPerSample,
|
|
required this.duration,
|
|
required this.bitrate,
|
|
required this.totalSamples,
|
|
});
|
|
}
|
|
|
|
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;
|
|
final int bitsPerSample;
|
|
|
|
const _AnalysisParams({
|
|
required this.pcmBytes,
|
|
required this.sampleRate,
|
|
required this.bitsPerSample,
|
|
});
|
|
}
|
|
|
|
class _AnalysisResult {
|
|
final double dynamicRange;
|
|
final double peakAmplitude;
|
|
final double rmsLevel;
|
|
final int totalSamples;
|
|
final SpectrogramData? spectrum;
|
|
|
|
const _AnalysisResult({
|
|
required this.dynamicRange,
|
|
required this.peakAmplitude,
|
|
required this.rmsLevel,
|
|
required this.totalSamples,
|
|
this.spectrum,
|
|
});
|
|
}
|
|
|
|
_AnalysisResult _analyzeInIsolate(_AnalysisParams params) {
|
|
final byteData = ByteData.sublistView(params.pcmBytes);
|
|
final sampleCount = params.pcmBytes.length ~/ 2;
|
|
final samples = Float64List(sampleCount);
|
|
|
|
for (int i = 0; i < sampleCount; i++) {
|
|
final raw = byteData.getInt16(i * 2, Endian.little);
|
|
samples[i] = raw / 32768.0;
|
|
}
|
|
|
|
double peak = 0;
|
|
double sumSquares = 0;
|
|
for (int i = 0; i < samples.length; i++) {
|
|
final abs = samples[i].abs();
|
|
if (abs > peak) peak = abs;
|
|
sumSquares += samples[i] * samples[i];
|
|
}
|
|
|
|
final peakDB = peak > 0 ? 20.0 * math.log(peak) / math.ln10 : -100.0;
|
|
final rms = math.sqrt(sumSquares / samples.length);
|
|
final rmsDB = rms > 0 ? 20.0 * math.log(rms) / math.ln10 : -100.0;
|
|
|
|
SpectrogramData? spectrum;
|
|
if (samples.length >= 8192) {
|
|
spectrum = _computeSpectrum(samples, params.sampleRate);
|
|
}
|
|
|
|
return _AnalysisResult(
|
|
dynamicRange: peakDB - rmsDB,
|
|
peakAmplitude: peakDB,
|
|
rmsLevel: rmsDB,
|
|
totalSamples: sampleCount,
|
|
spectrum: spectrum,
|
|
);
|
|
}
|
|
|
|
SpectrogramData _computeSpectrum(Float64List samples, int sampleRate) {
|
|
const fftSize = 8192;
|
|
const numSlices = 300;
|
|
const freqBins = fftSize ~/ 2;
|
|
|
|
final duration = samples.length / sampleRate;
|
|
var samplesPerSlice = samples.length ~/ numSlices;
|
|
var actualSlices = numSlices;
|
|
if (samplesPerSlice < fftSize) {
|
|
samplesPerSlice = fftSize;
|
|
actualSlices = samples.length ~/ fftSize;
|
|
}
|
|
|
|
final magnitudes = <Float64List>[];
|
|
|
|
for (int i = 0; i < actualSlices; i++) {
|
|
final start = i * samplesPerSlice;
|
|
if (start + fftSize > samples.length) break;
|
|
|
|
final windowed = Float64List(fftSize);
|
|
for (int j = 0; j < fftSize; j++) {
|
|
final w = 0.5 * (1.0 - math.cos(2.0 * math.pi * j / (fftSize - 1)));
|
|
windowed[j] = samples[start + j] * w;
|
|
}
|
|
|
|
final spectrum = _fft(windowed);
|
|
|
|
final mags = Float64List(freqBins);
|
|
for (int j = 0; j < freqBins; j++) {
|
|
final re = spectrum[j * 2];
|
|
final im = spectrum[j * 2 + 1];
|
|
var mag = math.sqrt(re * re + im * im);
|
|
if (mag < 1e-10) mag = 1e-10;
|
|
mags[j] = 20.0 * math.log(mag) / math.ln10;
|
|
}
|
|
magnitudes.add(mags);
|
|
}
|
|
|
|
return SpectrogramData(
|
|
magnitudes: magnitudes,
|
|
sampleRate: sampleRate,
|
|
freqBins: freqBins,
|
|
duration: duration,
|
|
maxFreq: sampleRate / 2.0,
|
|
sliceCount: magnitudes.length,
|
|
);
|
|
}
|
|
|
|
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;
|
|
final data = Float64List(n * 2);
|
|
for (int i = 0; i < n; i++) {
|
|
data[i * 2] = realInput[i];
|
|
}
|
|
|
|
int j = 0;
|
|
for (int i = 0; i < n; i++) {
|
|
if (i < j) {
|
|
final tr = data[i * 2];
|
|
final ti = data[i * 2 + 1];
|
|
data[i * 2] = data[j * 2];
|
|
data[i * 2 + 1] = data[j * 2 + 1];
|
|
data[j * 2] = tr;
|
|
data[j * 2 + 1] = ti;
|
|
}
|
|
int m = n >> 1;
|
|
while (m >= 1 && j >= m) {
|
|
j -= m;
|
|
m >>= 1;
|
|
}
|
|
j += m;
|
|
}
|
|
|
|
for (int size = 2; size <= n; size <<= 1) {
|
|
final halfSize = size >> 1;
|
|
final angle = -2.0 * math.pi / size;
|
|
final wRe = math.cos(angle);
|
|
final wIm = math.sin(angle);
|
|
|
|
for (int i = 0; i < n; i += size) {
|
|
double curRe = 1.0;
|
|
double curIm = 0.0;
|
|
|
|
for (int k = 0; k < halfSize; k++) {
|
|
final evenIdx = (i + k) * 2;
|
|
final oddIdx = (i + k + halfSize) * 2;
|
|
|
|
final tRe = curRe * data[oddIdx] - curIm * data[oddIdx + 1];
|
|
final tIm = curRe * data[oddIdx + 1] + curIm * data[oddIdx];
|
|
|
|
data[oddIdx] = data[evenIdx] - tRe;
|
|
data[oddIdx + 1] = data[evenIdx + 1] - tIm;
|
|
data[evenIdx] += tRe;
|
|
data[evenIdx + 1] += tIm;
|
|
|
|
final newRe = curRe * wRe - curIm * wIm;
|
|
curIm = curRe * wIm + curIm * wRe;
|
|
curRe = newRe;
|
|
}
|
|
}
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
class _AudioInfoCard extends StatelessWidget {
|
|
final AudioAnalysisData data;
|
|
final VoidCallback? onRescan;
|
|
|
|
const _AudioInfoCard({required this.data, this.onRescan});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
final nyquist = data.sampleRate / 2;
|
|
|
|
return Card(
|
|
color: cs.surfaceContainerLow,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.analytics_outlined, color: cs.primary, size: 20),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
context.l10n.audioAnalysisTitle,
|
|
style: TextStyle(
|
|
color: cs.onSurface,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
),
|
|
if (onRescan != null)
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh, size: 20),
|
|
tooltip: context.l10n.audioAnalysisRescan,
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(
|
|
minWidth: 32,
|
|
minHeight: 32,
|
|
),
|
|
color: cs.onSurfaceVariant,
|
|
onPressed: onRescan,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Wrap(
|
|
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,
|
|
value: '${(data.sampleRate / 1000).toStringAsFixed(1)} kHz',
|
|
cs: cs,
|
|
),
|
|
_MetricChip(
|
|
icon: Icons.audio_file,
|
|
label: context.l10n.audioAnalysisBitDepth,
|
|
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,
|
|
label: context.l10n.trackConvertBitrate,
|
|
value: _formatBitrate(data.bitrate),
|
|
cs: cs,
|
|
),
|
|
_MetricChip(
|
|
icon: Icons.surround_sound,
|
|
label: context.l10n.audioAnalysisChannels,
|
|
value: _formatChannels(context, data),
|
|
cs: cs,
|
|
),
|
|
_MetricChip(
|
|
icon: Icons.timer_outlined,
|
|
label: context.l10n.audioAnalysisDuration,
|
|
value: _formatDuration(data.duration),
|
|
cs: cs,
|
|
),
|
|
_MetricChip(
|
|
icon: Icons.multiline_chart,
|
|
label: context.l10n.audioAnalysisNyquist,
|
|
value: '${(nyquist / 1000).toStringAsFixed(1)} kHz',
|
|
cs: cs,
|
|
),
|
|
if (data.fileSize > 0)
|
|
_MetricChip(
|
|
icon: Icons.storage,
|
|
label: context.l10n.audioAnalysisFileSize,
|
|
value: _formatFileSize(data.fileSize),
|
|
cs: cs,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Divider(color: cs.outlineVariant),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 16,
|
|
runSpacing: 8,
|
|
children: [
|
|
_MetricChip(
|
|
icon: Icons.trending_up,
|
|
label: context.l10n.audioAnalysisDynamicRange,
|
|
value: '${data.dynamicRange.toStringAsFixed(2)} dB',
|
|
cs: cs,
|
|
),
|
|
_MetricChip(
|
|
icon: Icons.show_chart,
|
|
label: context.l10n.audioAnalysisPeak,
|
|
value: '${data.peakAmplitude.toStringAsFixed(2)} dB',
|
|
cs: cs,
|
|
),
|
|
_MetricChip(
|
|
icon: Icons.equalizer,
|
|
label: context.l10n.audioAnalysisRms,
|
|
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,
|
|
value: _formatNumber(data.totalSamples),
|
|
cs: cs,
|
|
),
|
|
],
|
|
),
|
|
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(),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatDuration(double seconds) {
|
|
final mins = seconds ~/ 60;
|
|
final secs = (seconds % 60).floor();
|
|
return '$mins:${secs.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
String _formatChannels(BuildContext context, AudioAnalysisData data) {
|
|
final layout = data.channelLayout.trim();
|
|
if (layout.isNotEmpty && layout != 'unknown') {
|
|
return data.channels > 0 ? '${data.channels} ($layout)' : layout;
|
|
}
|
|
if (data.channels == 2) return context.l10n.audioAnalysisStereo;
|
|
if (data.channels == 1) return context.l10n.audioAnalysisMono;
|
|
return data.channels > 0 ? '${data.channels}' : 'N/A';
|
|
}
|
|
|
|
String _formatFileSize(int bytes) {
|
|
if (bytes == 0) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB'];
|
|
final i = (math.log(bytes) / math.log(1024)).floor();
|
|
final size = bytes / math.pow(1024, i);
|
|
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';
|
|
}
|
|
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';
|
|
return n.toString();
|
|
}
|
|
}
|
|
|
|
class _MetricChip extends StatelessWidget {
|
|
final IconData icon;
|
|
final String label;
|
|
final String value;
|
|
final ColorScheme cs;
|
|
|
|
const _MetricChip({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.value,
|
|
required this.cs,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SpectrogramView extends StatelessWidget {
|
|
final ui.Image image;
|
|
final int sampleRate;
|
|
final double maxFreq;
|
|
|
|
const _SpectrogramView({
|
|
required this.image,
|
|
required this.sampleRate,
|
|
required this.maxFreq,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
|
|
return Card(
|
|
color: Colors.black,
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
AspectRatio(
|
|
aspectRatio: 2.0,
|
|
child: CustomPaint(
|
|
painter: _ImagePainter(image),
|
|
size: Size.infinite,
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
'${context.l10n.audioAnalysisSampleRate}: $sampleRate Hz',
|
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
'${context.l10n.audioAnalysisNyquist}: ${(maxFreq / 1000).toStringAsFixed(1)} kHz',
|
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ImagePainter extends CustomPainter {
|
|
final ui.Image image;
|
|
_ImagePainter(this.image);
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
paintImage(
|
|
canvas: canvas,
|
|
rect: Offset.zero & size,
|
|
image: image,
|
|
fit: BoxFit.contain,
|
|
filterQuality: FilterQuality.medium,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _ImagePainter old) => old.image != image;
|
|
}
|
|
|
|
class _SpectrogramRenderParams {
|
|
final SpectrogramData spectrum;
|
|
final int width;
|
|
final int height;
|
|
|
|
const _SpectrogramRenderParams({
|
|
required this.spectrum,
|
|
required this.width,
|
|
required this.height,
|
|
});
|
|
}
|
|
|
|
Uint8List _renderSpectrogramPixels(_SpectrogramRenderParams params) {
|
|
final w = params.width;
|
|
final h = params.height;
|
|
final spectrum = params.spectrum;
|
|
final pixels = Uint8List(w * h * 4);
|
|
|
|
for (int i = 3; i < pixels.length; i += 4) {
|
|
pixels[i] = 255;
|
|
}
|
|
|
|
final slices = spectrum.magnitudes;
|
|
if (slices.isEmpty) return pixels;
|
|
|
|
final freqBins = spectrum.freqBins;
|
|
|
|
double minDB = 0;
|
|
double maxDB = -200;
|
|
for (final slice in slices) {
|
|
for (int i = 0; i < slice.length; i++) {
|
|
final db = slice[i];
|
|
if (db > maxDB) maxDB = db;
|
|
if (db < minDB && db > -200) minDB = db;
|
|
}
|
|
}
|
|
minDB = math.max(minDB, maxDB - 90);
|
|
final dbRange = maxDB - minDB;
|
|
if (dbRange <= 0) return pixels;
|
|
|
|
for (int px = 0; px < w; px++) {
|
|
final t = (px / w * slices.length).floor().clamp(0, slices.length - 1);
|
|
final slice = slices[t];
|
|
|
|
for (int py = 0; py < h; py++) {
|
|
final freqRatio = 1.0 - (py / h);
|
|
final f = (freqRatio * freqBins).floor().clamp(0, freqBins - 1);
|
|
if (f >= slice.length) continue;
|
|
|
|
final db = slice[f];
|
|
final intensity = ((db - minDB) / dbRange).clamp(0.0, 1.0);
|
|
final color = _spekColorRGB(intensity);
|
|
|
|
final offset = (py * w + px) * 4;
|
|
pixels[offset] = color[0];
|
|
pixels[offset + 1] = color[1];
|
|
pixels[offset + 2] = color[2];
|
|
pixels[offset + 3] = 255;
|
|
}
|
|
}
|
|
|
|
return pixels;
|
|
}
|
|
|
|
List<int> _spekColorRGB(double intensity) {
|
|
int r, g, b;
|
|
if (intensity < 0.08) {
|
|
final t = intensity / 0.08;
|
|
r = 0;
|
|
g = 0;
|
|
b = (t * 80).floor();
|
|
} else if (intensity < 0.18) {
|
|
final t = (intensity - 0.08) / 0.10;
|
|
r = (t * 50).floor();
|
|
g = (t * 30).floor();
|
|
b = (80 + t * 175).floor();
|
|
} else if (intensity < 0.28) {
|
|
final t = (intensity - 0.18) / 0.10;
|
|
r = (50 + t * 150).floor();
|
|
g = (30 - t * 30).floor();
|
|
b = (255 - t * 55).floor();
|
|
} else if (intensity < 0.40) {
|
|
final t = (intensity - 0.28) / 0.12;
|
|
r = (200 + t * 55).floor();
|
|
g = 0;
|
|
b = (200 - t * 200).floor();
|
|
} else if (intensity < 0.52) {
|
|
final t = (intensity - 0.40) / 0.12;
|
|
r = 255;
|
|
g = (t * 100).floor();
|
|
b = 0;
|
|
} else if (intensity < 0.65) {
|
|
final t = (intensity - 0.52) / 0.13;
|
|
r = 255;
|
|
g = (100 + t * 80).floor();
|
|
b = 0;
|
|
} else if (intensity < 0.78) {
|
|
final t = (intensity - 0.65) / 0.13;
|
|
r = 255;
|
|
g = (180 + t * 55).floor();
|
|
b = (t * 30).floor();
|
|
} else if (intensity < 0.90) {
|
|
final t = (intensity - 0.78) / 0.12;
|
|
r = 255;
|
|
g = (235 + t * 20).floor();
|
|
b = (30 + t * 100).floor();
|
|
} else {
|
|
final t = (intensity - 0.90) / 0.10;
|
|
r = 255;
|
|
g = 255;
|
|
b = (130 + t * 125).floor();
|
|
}
|
|
return [r.clamp(0, 255), g.clamp(0, 255), b.clamp(0, 255)];
|
|
}
|