import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; void main() { group('audio level analysis', () { test('reads peak and RMS from the final astats overall summary', () { const logs = ''' [Parsed_astats_0] Channel: 1 [Parsed_astats_0] Peak level dB: -0.847144 [Parsed_astats_0] RMS level dB: -12.935500 [Parsed_astats_0] Channel: 2 [Parsed_astats_0] Peak level dB: -0.861847 [Parsed_astats_0] RMS level dB: -12.752564 [Parsed_astats_0] Overall [Parsed_astats_0] Peak level dB: -0.847144 [Parsed_astats_0] RMS level dB: -12.843069 '''; final summary = parseAudioAstatsSummary(logs); expect(summary, isNotNull); expect(summary!.peakDb, closeTo(-0.847144, 0.000001)); expect(summary.rmsDb, closeTo(-12.843069, 0.000001)); }); test('rejects logs delivered without the final RMS metric', () { const incompleteLogs = ''' [Parsed_astats_0] Overall [Parsed_astats_0] Peak level dB: -0.847144 '''; expect(parseAudioAstatsSummary(incompleteLogs), isNull); }); test('reads per-session metadata without depending on FFmpeg logs', () { const metadata = ''' lavfi.astats.1.Peak_level=-0.847144 lavfi.astats.1.RMS_level=-12.935500 lavfi.astats.1.Peak_count=2.000000 lavfi.astats.2.Peak_level=-0.861847 lavfi.astats.2.RMS_level=-12.752564 lavfi.astats.2.Peak_count=2.000000 lavfi.astats.Overall.Peak_level=-0.847144 lavfi.astats.Overall.RMS_level=-12.843069 lavfi.r128.I=-9.708 lavfi.r128.true_peak=0.907 '''; final summary = parseAudioAnalysisMetadata(metadata); expect(summary, isNotNull); expect(summary!.peakDb, closeTo(-0.847144, 0.000001)); expect(summary.rmsDb, closeTo(-12.843069, 0.000001)); expect(summary.integratedLufs, closeTo(-9.708, 0.000001)); expect(summary.truePeakDb, closeTo(-0.8476, 0.001)); expect(summary.channelStats, hasLength(2)); expect(summary.channelStats.first.channel, 1); expect(summary.channelStats.first.peakCount, 2); }); test('keeps the analyzer independent from process-global log level', () { final arguments = buildAudioMetricsArguments( inputPath: 'source.flac', metadataPath: '/tmp/metrics.txt', durationSeconds: 203.94, ); expect(arguments, isNot(contains('-v'))); expect(arguments, isNot(contains('-loglevel'))); expect(arguments.join(' '), contains('ametadata=print')); expect(arguments.join(' '), contains("aselect='gte(t,201.940)'")); }); test('rejects incomplete per-session metadata instead of using zero', () { const metadata = 'lavfi.astats.Overall.Peak_level=-0.8'; expect(parseAudioAnalysisMetadata(metadata), isNull); }); }); group('audio spectrogram filter', () { test('keeps source rate and uses a full-range float pipeline', () { final filter = buildAudioSpectrogramFilter(); expect(filter, contains('[0:a:0]aformat=sample_fmts=fltp')); expect(filter, contains('s=1600x800')); expect(filter, contains('scale=log')); expect(filter, contains('fscale=lin')); expect(filter, contains('win_func=hann')); expect(filter, contains('drange=120')); expect(filter, contains('limit=0')); expect(filter, isNot(contains('pan='))); expect(filter, isNot(contains('aresample'))); }); test('can render one source channel without app-specific branching', () { final filter = buildAudioSpectrogramFilter(channel: 1); expect(filter, contains('[0:a:0]pan=mono|c0=c1,')); expect(filter, contains('aformat=sample_fmts=fltp')); }); test('processes the complete source without resampling or downmixing', () { final arguments = buildAudioSpectrogramArguments( inputPath: 'source.flac', outputPath: 'spectrum.rgba', ); expect(arguments, containsAllInOrder(['-i', 'source.flac'])); expect(arguments, containsAllInOrder(['-frames:v', '1'])); expect(arguments, containsAllInOrder(['-pix_fmt', 'rgba'])); expect(arguments, isNot(contains('-t'))); expect(arguments, isNot(contains('-ss'))); expect(arguments, isNot(contains('-ar'))); expect(arguments, isNot(contains('-ac'))); expect(arguments, isNot(contains('-loglevel'))); }); }); group('broadband spectral cutoff', () { const width = 200; const height = 800; const nyquist = 96000.0; test('ignores a narrow ultrasonic pilot above a 22 kHz music band', () { final rgba = _blankRgba(width, height); _paintFrequencyBand( rgba, width: width, height: height, maxFrequencyHz: nyquist, lowHz: 0, highHz: 22000, intensity: 220, ); _paintFrequencyBand( rgba, width: width, height: height, maxFrequencyHz: nyquist, lowHz: 53880, highHz: 54120, intensity: 255, ); final cutoff = estimateBroadbandSpectralCutoffHz( rgba: rgba, width: width, height: height, maxFrequencyHz: nyquist, ); expect(cutoff, isNotNull); expect(cutoff!, inInclusiveRange(21000, 23500)); }); test('retains genuine broadband ultrasonic content', () { final rgba = _blankRgba(width, height); _paintFrequencyBand( rgba, width: width, height: height, maxFrequencyHz: nyquist, lowHz: 0, highHz: 48000, intensity: 180, ); final cutoff = estimateBroadbandSpectralCutoffHz( rgba: rgba, width: width, height: height, maxFrequencyHz: nyquist, ); expect(cutoff, isNotNull); expect(cutoff!, inInclusiveRange(47000, 49500)); }); test('does not report an isolated line as a broadband cutoff', () { final rgba = _blankRgba(width, height); _paintFrequencyBand( rgba, width: width, height: height, maxFrequencyHz: nyquist, lowHz: 53880, highHz: 54120, intensity: 255, ); final cutoff = estimateBroadbandSpectralCutoffHz( rgba: rgba, width: width, height: height, maxFrequencyHz: nyquist, ); expect(cutoff, isNull); }); test('rejects invalid or incomplete images', () { expect( estimateBroadbandSpectralCutoffHz( rgba: Uint8List(3), width: 1, height: 1, maxFrequencyHz: nyquist, ), isNull, ); }); }); } Uint8List _blankRgba(int width, int height) { final rgba = Uint8List(width * height * 4); for (var offset = 3; offset < rgba.length; offset += 4) { rgba[offset] = 255; } return rgba; } void _paintFrequencyBand( Uint8List rgba, { required int width, required int height, required double maxFrequencyHz, required double lowHz, required double highHz, required int intensity, }) { for (var y = 0; y < height; y++) { final frequency = (height - y - 0.5) / height * maxFrequencyHz; if (frequency < lowHz || frequency > highHz) continue; for (var x = 0; x < width; x++) { final offset = (y * width + x) * 4; rgba[offset] = intensity; rgba[offset + 1] = intensity; rgba[offset + 2] = intensity; } } }