mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
refactor(analysis): split models, info card, and spectrogram into part files
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
part of 'audio_analysis_widget.dart';
|
||||
|
||||
// Read-only info card with metric chips for a finished analysis.
|
||||
|
||||
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(
|
||||
elevation: 0,
|
||||
color: settingsGroupColor(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
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: formatClock(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: formatBytes(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 _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 _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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
part of 'audio_analysis_widget.dart';
|
||||
|
||||
// Analysis result models and per-run parameter records.
|
||||
|
||||
class AudioAnalysisData {
|
||||
static const cacheVersion = 5;
|
||||
|
||||
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;
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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 _GeneratedSpectrogram {
|
||||
final ui.Image image;
|
||||
final Uint8List rgba;
|
||||
|
||||
const _GeneratedSpectrogram({required this.image, required this.rgba});
|
||||
}
|
||||
|
||||
class _AudioAnalysisRunResult {
|
||||
final AudioAnalysisData data;
|
||||
final ui.Image spectrogramImage;
|
||||
|
||||
const _AudioAnalysisRunResult({
|
||||
required this.data,
|
||||
required this.spectrogramImage,
|
||||
});
|
||||
}
|
||||
|
||||
class _SpectralCutoffParams {
|
||||
final Uint8List rgba;
|
||||
final int width;
|
||||
final int height;
|
||||
final double maxFrequencyHz;
|
||||
|
||||
const _SpectralCutoffParams({
|
||||
required this.rgba,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.maxFrequencyHz,
|
||||
});
|
||||
}
|
||||
|
||||
double? _estimateBroadbandSpectralCutoffInIsolate(
|
||||
_SpectralCutoffParams params,
|
||||
) {
|
||||
return estimateBroadbandSpectralCutoffHz(
|
||||
rgba: params.rgba,
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
maxFrequencyHz: params.maxFrequencyHz,
|
||||
);
|
||||
}
|
||||
|
||||
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<ChannelAnalysisStats> 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});
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
part of 'audio_analysis_widget.dart';
|
||||
|
||||
// Spectrogram rendering: interactive view, axis painter, palette.
|
||||
|
||||
class _SpectrogramView extends StatelessWidget {
|
||||
final ui.Image image;
|
||||
final int sampleRate;
|
||||
final double maxFreq;
|
||||
final double duration;
|
||||
final int channels;
|
||||
final int selectedChannel;
|
||||
final bool channelLoading;
|
||||
final ValueChanged<int> onChannelChanged;
|
||||
|
||||
const _SpectrogramView({
|
||||
required this.image,
|
||||
required this.sampleRate,
|
||||
required this.maxFreq,
|
||||
required this.duration,
|
||||
required this.channels,
|
||||
required this.selectedChannel,
|
||||
required this.channelLoading,
|
||||
required this.onChannelChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
const labelColor = Color(0xFFB5B5B5);
|
||||
|
||||
return Card(
|
||||
color: Colors.black,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (channels > 1)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 0),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: Text(context.l10n.audioAnalysisChannels),
|
||||
selected: selectedChannel < 0,
|
||||
onSelected: (_) => onChannelChanged(-1),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
for (var channel = 0; channel < channels; channel++) ...[
|
||||
const SizedBox(width: 6),
|
||||
ChoiceChip(
|
||||
label: Text('Ch ${channel + 1}'),
|
||||
selected: selectedChannel == channel,
|
||||
onSelected: (_) => onChannelChanged(channel),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (channelLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(12, 8, 12, 0),
|
||||
child: LinearProgressIndicator(minHeight: 2),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 10, 10, 4),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const leftGutter = 34.0;
|
||||
const bottomGutter = 18.0;
|
||||
final plotWidth = constraints.maxWidth - leftGutter;
|
||||
final plotHeight = plotWidth / 2.0;
|
||||
final totalHeight = plotHeight + bottomGutter;
|
||||
return SizedBox(
|
||||
width: constraints.maxWidth,
|
||||
height: totalHeight,
|
||||
child: CustomPaint(
|
||||
painter: _SpectrogramPainter(
|
||||
image: image,
|
||||
maxFreqHz: maxFreq,
|
||||
durationSec: duration,
|
||||
labelColor: labelColor,
|
||||
gridColor: Colors.white.withValues(alpha: 0.10),
|
||||
),
|
||||
size: Size(constraints.maxWidth, totalHeight),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(40, 0, 10, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'-120 dBFS',
|
||||
style: TextStyle(color: labelColor, fontSize: 10),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
gradient: LinearGradient(colors: _legendColors()),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'0 dBFS',
|
||||
style: TextStyle(color: labelColor, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.25)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${context.l10n.audioAnalysisSampleRate}: $sampleRate Hz',
|
||||
style: const TextStyle(color: labelColor, fontSize: 11),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${context.l10n.audioAnalysisNyquist}: ${(maxFreq / 1000).toStringAsFixed(1)} kHz',
|
||||
style: const TextStyle(color: labelColor, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static List<Color> _legendColors() {
|
||||
return List.generate(20, (i) {
|
||||
final c = _spekColorRGB(i / 19.0);
|
||||
return Color.fromARGB(255, c[0], c[1], c[2]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _SpectrogramPainter extends CustomPainter {
|
||||
final ui.Image image;
|
||||
final double maxFreqHz;
|
||||
final double durationSec;
|
||||
final Color labelColor;
|
||||
final Color gridColor;
|
||||
|
||||
static const double leftGutter = 34;
|
||||
static const double bottomGutter = 18;
|
||||
|
||||
_SpectrogramPainter({
|
||||
required this.image,
|
||||
required this.maxFreqHz,
|
||||
required this.durationSec,
|
||||
required this.labelColor,
|
||||
required this.gridColor,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final plot = Rect.fromLTWH(
|
||||
leftGutter,
|
||||
0,
|
||||
size.width - leftGutter,
|
||||
size.height - bottomGutter,
|
||||
);
|
||||
if (plot.width <= 0 || plot.height <= 0) return;
|
||||
|
||||
canvas.drawImageRect(
|
||||
image,
|
||||
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
|
||||
plot,
|
||||
Paint()..filterQuality = FilterQuality.medium,
|
||||
);
|
||||
|
||||
final gridPaint = Paint()
|
||||
..color = gridColor
|
||||
..strokeWidth = 1;
|
||||
|
||||
// Frequency axis (Y): 0 Hz at the bottom, maxFreq at the top.
|
||||
final maxKHz = maxFreqHz / 1000.0;
|
||||
if (maxKHz > 0) {
|
||||
final stepKHz = _niceStepKHz(maxKHz);
|
||||
for (double fk = 0; fk <= maxKHz + 0.001; fk += stepKHz) {
|
||||
final ratio = (fk * 1000) / maxFreqHz;
|
||||
final y = plot.bottom - ratio * plot.height;
|
||||
canvas.drawLine(Offset(plot.left, y), Offset(plot.right, y), gridPaint);
|
||||
_drawText(
|
||||
canvas,
|
||||
fk == 0 ? '0' : '${fk.toStringAsFixed(0)}k',
|
||||
Offset(plot.left - 5, y),
|
||||
align: _TextAlignV.rightCenter,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (durationSec > 0) {
|
||||
final stepSec = _niceStepSec(durationSec);
|
||||
for (double ts = 0; ts <= durationSec + 0.001; ts += stepSec) {
|
||||
final ratio = ts / durationSec;
|
||||
final x = plot.left + ratio * plot.width;
|
||||
canvas.drawLine(Offset(x, plot.top), Offset(x, plot.bottom), gridPaint);
|
||||
_drawText(
|
||||
canvas,
|
||||
formatClock(ts),
|
||||
Offset(x, plot.bottom + 3),
|
||||
align: _TextAlignV.topCenter,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _drawText(
|
||||
Canvas canvas,
|
||||
String text,
|
||||
Offset anchor, {
|
||||
required _TextAlignV align,
|
||||
}) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(color: labelColor, fontSize: 10),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
double dx = anchor.dx;
|
||||
double dy = anchor.dy;
|
||||
switch (align) {
|
||||
case _TextAlignV.rightCenter:
|
||||
dx = anchor.dx - tp.width;
|
||||
dy = anchor.dy - tp.height / 2;
|
||||
break;
|
||||
case _TextAlignV.topCenter:
|
||||
dx = anchor.dx - tp.width / 2;
|
||||
dy = anchor.dy;
|
||||
break;
|
||||
}
|
||||
tp.paint(canvas, Offset(dx, dy));
|
||||
}
|
||||
|
||||
static double _niceStepKHz(double maxKHz) {
|
||||
const candidates = [1.0, 2.0, 5.0, 10.0, 20.0, 50.0];
|
||||
for (final c in candidates) {
|
||||
if (maxKHz / c <= 6) return c;
|
||||
}
|
||||
return 100.0;
|
||||
}
|
||||
|
||||
static double _niceStepSec(double dur) {
|
||||
const candidates = [5.0, 10.0, 15.0, 30.0, 60.0, 120.0, 300.0, 600.0];
|
||||
for (final c in candidates) {
|
||||
if (dur / c <= 6) return c;
|
||||
}
|
||||
return 1200.0;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _SpectrogramPainter old) =>
|
||||
old.image != image ||
|
||||
old.maxFreqHz != maxFreqHz ||
|
||||
old.durationSec != durationSec;
|
||||
}
|
||||
|
||||
enum _TextAlignV { rightCenter, topCenter }
|
||||
|
||||
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)];
|
||||
}
|
||||
@@ -17,6 +17,10 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
|
||||
part 'audio_analysis_models.dart';
|
||||
part 'audio_analysis_info_card.dart';
|
||||
part 'audio_analysis_spectrogram.dart';
|
||||
|
||||
const int audioSpectrogramWidth = 1600;
|
||||
const int audioSpectrogramHeight = 800;
|
||||
const double audioSpectrogramDynamicRangeDb = 120;
|
||||
@@ -145,188 +149,6 @@ double? estimateBroadbandSpectralCutoffHz({
|
||||
return null;
|
||||
}
|
||||
|
||||
class AudioAnalysisData {
|
||||
static const cacheVersion = 5;
|
||||
|
||||
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;
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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 _GeneratedSpectrogram {
|
||||
final ui.Image image;
|
||||
final Uint8List rgba;
|
||||
|
||||
const _GeneratedSpectrogram({required this.image, required this.rgba});
|
||||
}
|
||||
|
||||
class _AudioAnalysisRunResult {
|
||||
final AudioAnalysisData data;
|
||||
final ui.Image spectrogramImage;
|
||||
|
||||
const _AudioAnalysisRunResult({
|
||||
required this.data,
|
||||
required this.spectrogramImage,
|
||||
});
|
||||
}
|
||||
|
||||
class _SpectralCutoffParams {
|
||||
final Uint8List rgba;
|
||||
final int width;
|
||||
final int height;
|
||||
final double maxFrequencyHz;
|
||||
|
||||
const _SpectralCutoffParams({
|
||||
required this.rgba,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.maxFrequencyHz,
|
||||
});
|
||||
}
|
||||
|
||||
double? _estimateBroadbandSpectralCutoffInIsolate(
|
||||
_SpectralCutoffParams params,
|
||||
) {
|
||||
return estimateBroadbandSpectralCutoffHz(
|
||||
rgba: params.rgba,
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
maxFrequencyHz: params.maxFrequencyHz,
|
||||
);
|
||||
}
|
||||
|
||||
class AudioAnalysisCard extends StatefulWidget {
|
||||
final String filePath;
|
||||
|
||||
@@ -1278,683 +1100,3 @@ 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;
|
||||
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<ChannelAnalysisStats> 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 _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(
|
||||
elevation: 0,
|
||||
color: settingsGroupColor(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
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: formatClock(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: formatBytes(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 _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 _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;
|
||||
final double duration;
|
||||
final int channels;
|
||||
final int selectedChannel;
|
||||
final bool channelLoading;
|
||||
final ValueChanged<int> onChannelChanged;
|
||||
|
||||
const _SpectrogramView({
|
||||
required this.image,
|
||||
required this.sampleRate,
|
||||
required this.maxFreq,
|
||||
required this.duration,
|
||||
required this.channels,
|
||||
required this.selectedChannel,
|
||||
required this.channelLoading,
|
||||
required this.onChannelChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
const labelColor = Color(0xFFB5B5B5);
|
||||
|
||||
return Card(
|
||||
color: Colors.black,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (channels > 1)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 0),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: Text(context.l10n.audioAnalysisChannels),
|
||||
selected: selectedChannel < 0,
|
||||
onSelected: (_) => onChannelChanged(-1),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
for (var channel = 0; channel < channels; channel++) ...[
|
||||
const SizedBox(width: 6),
|
||||
ChoiceChip(
|
||||
label: Text('Ch ${channel + 1}'),
|
||||
selected: selectedChannel == channel,
|
||||
onSelected: (_) => onChannelChanged(channel),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (channelLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(12, 8, 12, 0),
|
||||
child: LinearProgressIndicator(minHeight: 2),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 10, 10, 4),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const leftGutter = 34.0;
|
||||
const bottomGutter = 18.0;
|
||||
final plotWidth = constraints.maxWidth - leftGutter;
|
||||
final plotHeight = plotWidth / 2.0;
|
||||
final totalHeight = plotHeight + bottomGutter;
|
||||
return SizedBox(
|
||||
width: constraints.maxWidth,
|
||||
height: totalHeight,
|
||||
child: CustomPaint(
|
||||
painter: _SpectrogramPainter(
|
||||
image: image,
|
||||
maxFreqHz: maxFreq,
|
||||
durationSec: duration,
|
||||
labelColor: labelColor,
|
||||
gridColor: Colors.white.withValues(alpha: 0.10),
|
||||
),
|
||||
size: Size(constraints.maxWidth, totalHeight),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(40, 0, 10, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'-120 dBFS',
|
||||
style: TextStyle(color: labelColor, fontSize: 10),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
gradient: LinearGradient(colors: _legendColors()),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'0 dBFS',
|
||||
style: TextStyle(color: labelColor, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.25)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${context.l10n.audioAnalysisSampleRate}: $sampleRate Hz',
|
||||
style: const TextStyle(color: labelColor, fontSize: 11),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${context.l10n.audioAnalysisNyquist}: ${(maxFreq / 1000).toStringAsFixed(1)} kHz',
|
||||
style: const TextStyle(color: labelColor, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static List<Color> _legendColors() {
|
||||
return List.generate(20, (i) {
|
||||
final c = _spekColorRGB(i / 19.0);
|
||||
return Color.fromARGB(255, c[0], c[1], c[2]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _SpectrogramPainter extends CustomPainter {
|
||||
final ui.Image image;
|
||||
final double maxFreqHz;
|
||||
final double durationSec;
|
||||
final Color labelColor;
|
||||
final Color gridColor;
|
||||
|
||||
static const double leftGutter = 34;
|
||||
static const double bottomGutter = 18;
|
||||
|
||||
_SpectrogramPainter({
|
||||
required this.image,
|
||||
required this.maxFreqHz,
|
||||
required this.durationSec,
|
||||
required this.labelColor,
|
||||
required this.gridColor,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final plot = Rect.fromLTWH(
|
||||
leftGutter,
|
||||
0,
|
||||
size.width - leftGutter,
|
||||
size.height - bottomGutter,
|
||||
);
|
||||
if (plot.width <= 0 || plot.height <= 0) return;
|
||||
|
||||
canvas.drawImageRect(
|
||||
image,
|
||||
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
|
||||
plot,
|
||||
Paint()..filterQuality = FilterQuality.medium,
|
||||
);
|
||||
|
||||
final gridPaint = Paint()
|
||||
..color = gridColor
|
||||
..strokeWidth = 1;
|
||||
|
||||
// Frequency axis (Y): 0 Hz at the bottom, maxFreq at the top.
|
||||
final maxKHz = maxFreqHz / 1000.0;
|
||||
if (maxKHz > 0) {
|
||||
final stepKHz = _niceStepKHz(maxKHz);
|
||||
for (double fk = 0; fk <= maxKHz + 0.001; fk += stepKHz) {
|
||||
final ratio = (fk * 1000) / maxFreqHz;
|
||||
final y = plot.bottom - ratio * plot.height;
|
||||
canvas.drawLine(Offset(plot.left, y), Offset(plot.right, y), gridPaint);
|
||||
_drawText(
|
||||
canvas,
|
||||
fk == 0 ? '0' : '${fk.toStringAsFixed(0)}k',
|
||||
Offset(plot.left - 5, y),
|
||||
align: _TextAlignV.rightCenter,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (durationSec > 0) {
|
||||
final stepSec = _niceStepSec(durationSec);
|
||||
for (double ts = 0; ts <= durationSec + 0.001; ts += stepSec) {
|
||||
final ratio = ts / durationSec;
|
||||
final x = plot.left + ratio * plot.width;
|
||||
canvas.drawLine(Offset(x, plot.top), Offset(x, plot.bottom), gridPaint);
|
||||
_drawText(
|
||||
canvas,
|
||||
formatClock(ts),
|
||||
Offset(x, plot.bottom + 3),
|
||||
align: _TextAlignV.topCenter,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _drawText(
|
||||
Canvas canvas,
|
||||
String text,
|
||||
Offset anchor, {
|
||||
required _TextAlignV align,
|
||||
}) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(color: labelColor, fontSize: 10),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
double dx = anchor.dx;
|
||||
double dy = anchor.dy;
|
||||
switch (align) {
|
||||
case _TextAlignV.rightCenter:
|
||||
dx = anchor.dx - tp.width;
|
||||
dy = anchor.dy - tp.height / 2;
|
||||
break;
|
||||
case _TextAlignV.topCenter:
|
||||
dx = anchor.dx - tp.width / 2;
|
||||
dy = anchor.dy;
|
||||
break;
|
||||
}
|
||||
tp.paint(canvas, Offset(dx, dy));
|
||||
}
|
||||
|
||||
static double _niceStepKHz(double maxKHz) {
|
||||
const candidates = [1.0, 2.0, 5.0, 10.0, 20.0, 50.0];
|
||||
for (final c in candidates) {
|
||||
if (maxKHz / c <= 6) return c;
|
||||
}
|
||||
return 100.0;
|
||||
}
|
||||
|
||||
static double _niceStepSec(double dur) {
|
||||
const candidates = [5.0, 10.0, 15.0, 30.0, 60.0, 120.0, 300.0, 600.0];
|
||||
for (final c in candidates) {
|
||||
if (dur / c <= 6) return c;
|
||||
}
|
||||
return 1200.0;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _SpectrogramPainter old) =>
|
||||
old.image != image ||
|
||||
old.maxFreqHz != maxFreqHz ||
|
||||
old.durationSec != durationSec;
|
||||
}
|
||||
|
||||
enum _TextAlignV { rightCenter, topCenter }
|
||||
|
||||
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)];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user