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 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 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 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((item) => ChannelAnalysisStats.fromJson(item)) .toList() ?? const [], totalSamples: json['totalSamples'] as int, ); } } class ChannelAnalysisStats { final int channel; final double? peakDb; final double? rmsDb; final double? dynamicRangeDb; final int peakCount; const ChannelAnalysisStats({ required this.channel, this.peakDb, this.rmsDb, this.dynamicRangeDb, this.peakCount = 0, }); Map toJson() => { 'channel': channel, 'peakDb': peakDb, 'rmsDb': rmsDb, 'dynamicRangeDb': dynamicRangeDb, 'peakCount': peakCount, }; factory ChannelAnalysisStats.fromJson(Map json) { return ChannelAnalysisStats( channel: (json['channel'] as num?)?.toInt() ?? 0, peakDb: (json['peakDb'] as num?)?.toDouble(), rmsDb: (json['rmsDb'] as num?)?.toDouble(), dynamicRangeDb: (json['dynamicRangeDb'] as num?)?.toDouble(), peakCount: (json['peakCount'] as num?)?.toInt() ?? 0, ); } } class SpectrogramData { final List magnitudes; final int sampleRate; 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 createState() => _AudioAnalysisCardState(); } class _AudioAnalysisCardState extends State { 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 _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 _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 _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 _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 _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.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 _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 _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 _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.decodeImageFromList(bytes, completer.complete); return completer.future; } catch (_) { return null; } } Future _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 = 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 = _normalizeAnalysisLabel(codecLongName); 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 = _normalizeAnalysisLabel(formatLongName); if (longName.isNotEmpty) return longName; final name = formatName.trim(); return name.isEmpty ? '' : name.toUpperCase(); } String _normalizeAnalysisLabel(String value) { final trimmed = value.trim(); final lower = trimmed.toLowerCase(); if (lower.isEmpty || lower == 'unknown' || lower == 'n/a') return ''; return trimmed; } int _estimateTotalSamples({ required Map 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', '-vn', '-sn', '-dn', '-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(0, (sum, stats) { if (stats.peakDb == null || stats.peakDb! < -0.1) return sum; return sum + stats.peakCount; }); return _LevelMetrics( peakDb: peak, rmsDb: rms, 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, '-map', '0:a:0', '-vn', '-sn', '-dn', '-af', 'ebur128=peak=true:framelog=quiet', '-f', 'null', '-', ]); final logs = await session.getLogsAsString(); final integratedMatches = RegExp( r'I:\s+(-?\d+\.?\d*)\s+LUFS', ).allMatches(logs); final integrated = integratedMatches.isEmpty ? null : double.tryParse(integratedMatches.last.group(1) ?? ''); double? truePeak; for (final match in RegExp( r'Peak:\s+(-?\d+\.?\d*)\s+dBFS', ).allMatches(logs)) { final value = double.tryParse(match.group(1) ?? ''); if (value != null && (truePeak == null || value > truePeak)) { truePeak = value; } } if (integrated == null && truePeak == null) return null; return _LoudnessMetrics(integratedLufs: integrated, truePeakDb: truePeak); } finally { await FFmpegKitConfig.setLogLevel(Level.avLogError); } } List _parseChannelStats(String logs) { final stats = []; final channelMatches = RegExp( r'Channel:\s*(\d+)([\s\S]*?)(?=Channel:\s*\d+|Overall|$)', caseSensitive: false, ).allMatches(logs); for (final match in channelMatches) { final channel = int.tryParse(match.group(1) ?? '') ?? 0; final section = match.group(2) ?? ''; if (channel <= 0 || section.trim().isEmpty) continue; final peakDb = _parseLastAstatsValue(section, 'Peak level dB'); final rmsDb = _parseLastAstatsValue(section, 'RMS level dB'); stats.add( ChannelAnalysisStats( channel: channel, peakDb: peakDb, rmsDb: rmsDb, dynamicRangeDb: peakDb != null && rmsDb != null ? peakDb - rmsDb : null, 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 _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 _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.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 int clippingSamples; final List channelStats; const _LevelMetrics({ required this.peakDb, required this.rmsDb, 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 = []; 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 = []; 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 _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)]; }