From 5ee7f733cf1f357cbf468c67577d6ccabe97d718 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 27 Jul 2026 00:30:25 +0700 Subject: [PATCH] refactor(analysis): split models, info card, and spectrogram into part files --- lib/widgets/audio_analysis_info_card.dart | 313 +++++++ lib/widgets/audio_analysis_models.dart | 234 ++++++ lib/widgets/audio_analysis_spectrogram.dart | 324 ++++++++ lib/widgets/audio_analysis_widget.dart | 866 +------------------- 4 files changed, 875 insertions(+), 862 deletions(-) create mode 100644 lib/widgets/audio_analysis_info_card.dart create mode 100644 lib/widgets/audio_analysis_models.dart create mode 100644 lib/widgets/audio_analysis_spectrogram.dart diff --git a/lib/widgets/audio_analysis_info_card.dart b/lib/widgets/audio_analysis_info_card.dart new file mode 100644 index 00000000..1b9b372a --- /dev/null +++ b/lib/widgets/audio_analysis_info_card.dart @@ -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 = []; + 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, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/audio_analysis_models.dart b/lib/widgets/audio_analysis_models.dart new file mode 100644 index 00000000..83444fa0 --- /dev/null +++ b/lib/widgets/audio_analysis_models.dart @@ -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 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 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 _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 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}); +} diff --git a/lib/widgets/audio_analysis_spectrogram.dart b/lib/widgets/audio_analysis_spectrogram.dart new file mode 100644 index 00000000..3991a56b --- /dev/null +++ b/lib/widgets/audio_analysis_spectrogram.dart @@ -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 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 _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 _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)]; +} diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 3db06b48..e2bf7b9a 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -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 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 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 _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 { ); } } - -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 _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 = []; - 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 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 _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 _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)]; -}