fix(analysis): guard unsupported AC-4 codec

This commit is contained in:
zarzet
2026-08-01 15:02:20 +07:00
parent dc58353c07
commit 15b1b757fd
4 changed files with 170 additions and 12 deletions
+4 -1
View File
@@ -61,7 +61,10 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
if (_fileExists) ...[
const SizedBox(height: 16),
AudioAnalysisCard(filePath: _filePath),
AudioAnalysisCard(
filePath: _filePath,
codecHint: _storedAudioFormat,
),
],
const SizedBox(height: 24),
+2
View File
@@ -191,6 +191,7 @@ double? _estimateEffectiveSpectralCutoffInIsolate(
class _MediaInfo {
final int fileSize;
final String codecName;
final String codec;
final String container;
final String decodedSampleFormat;
@@ -204,6 +205,7 @@ class _MediaInfo {
const _MediaInfo({
required this.fileSize,
required this.codecName,
required this.codec,
required this.container,
required this.decodedSampleFormat,
+146 -11
View File
@@ -37,6 +37,29 @@ String formatAudioAnalysisSpectralCutoff(
return '${cutoffHz.round()} Hz';
}
final RegExp _ac4CodecTokenPattern = RegExp(
r'(^|[^a-z0-9])ac[\s_-]?4($|[^a-z0-9])',
caseSensitive: false,
);
/// Returns whether the bundled FFmpeg decoder can analyze this audio codec.
/// Container formats such as MP4 are intentionally not used here because the
/// same container may hold supported AAC/ALAC/E-AC-3 or unsupported AC-4.
bool isAudioAnalysisCodecSupported(String? codecName) {
final value = codecName?.trim() ?? '';
if (value.isEmpty) return true;
return !_ac4CodecTokenPattern.hasMatch(value);
}
String? unsupportedAudioAnalysisCodecLabel(String? codecName) =>
isAudioAnalysisCodecSupported(codecName) ? null : 'AC-4';
class _UnsupportedAudioAnalysisCodecException implements Exception {
final String codecLabel;
const _UnsupportedAudioAnalysisCodecException(this.codecLabel);
}
String _buildShowspectrumOptions({required int width, required String color}) {
return 'showspectrumpic='
's=${width}x$audioSpectrogramHeight:'
@@ -442,8 +465,9 @@ double _spectralPercentile(
class AudioAnalysisCard extends StatefulWidget {
final String filePath;
final String? codecHint;
const AudioAnalysisCard({super.key, required this.filePath});
const AudioAnalysisCard({super.key, required this.filePath, this.codecHint});
@override
State<AudioAnalysisCard> createState() => _AudioAnalysisCardState();
@@ -458,6 +482,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
int _spectrogramChannel = -1;
bool _spectrogramChannelLoading = false;
int _spectrogramRequestId = 0;
String? _unsupportedCodec;
static const _supportedExtensions = {
'.flac',
@@ -487,8 +512,37 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
@override
void initState() {
super.initState();
_unsupportedCodec = unsupportedAudioAnalysisCodecLabel(widget.codecHint);
if (_isSupported) {
_tryLoadFromCache();
if (_unsupportedCodec == null) {
_tryLoadFromCache();
} else {
_checkingCache = false;
}
}
}
@override
void didUpdateWidget(covariant AudioAnalysisCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.filePath == widget.filePath &&
oldWidget.codecHint == widget.codecHint) {
return;
}
_spectrogramRequestId++;
_spectrogramImage?.dispose();
_spectrogramImage = null;
_spectrogramChannel = -1;
_spectrogramChannelLoading = false;
_data = null;
_error = null;
_analyzing = false;
_unsupportedCodec = unsupportedAudioAnalysisCodecLabel(widget.codecHint);
_checkingCache = _isSupported && _unsupportedCodec == null;
if (_checkingCache) {
unawaited(_tryLoadFromCache());
}
}
@@ -500,19 +554,37 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
}
Future<void> _tryLoadFromCache() async {
final expectedPath = widget.filePath;
final requestId = _spectrogramRequestId;
bool isCurrentRequest() =>
mounted &&
widget.filePath == expectedPath &&
requestId == _spectrogramRequestId;
try {
final cached = await _loadFromCache(widget.filePath);
if (cached != null && mounted) {
final cached = await _loadFromCache(expectedPath);
if (cached != null && isCurrentRequest()) {
final unsupported = unsupportedAudioAnalysisCodecLabel(cached.codec);
if (unsupported != null) {
await _clearCache(expectedPath);
if (isCurrentRequest()) {
setState(() {
_unsupportedCodec = unsupported;
_checkingCache = false;
});
}
return;
}
setState(() {
_data = cached;
_checkingCache = false;
});
var image = await _loadSpectrogramFromCache(
widget.filePath,
expectedPath,
channel: _spectrogramChannel,
);
image ??= await _generateAndCacheSpectrogram();
if (mounted) {
image ??= await _generateAndCacheSpectrogram(filePath: expectedPath);
if (isCurrentRequest()) {
setState(() {
_spectrogramImage?.dispose();
_spectrogramImage = image;
@@ -523,18 +595,19 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
return;
}
} catch (_) {}
if (mounted) {
if (isCurrentRequest()) {
setState(() => _checkingCache = false);
}
}
Future<ui.Image> _generateAndCacheSpectrogram() async {
Future<ui.Image> _generateAndCacheSpectrogram({String? filePath}) async {
final sourcePath = filePath ?? widget.filePath;
final artifact = await _generateSpectrogramForFile(
widget.filePath,
sourcePath,
channel: _spectrogramChannel,
);
await _saveSpectrogramToCache(
widget.filePath,
sourcePath,
artifact.image,
channel: _spectrogramChannel,
);
@@ -568,6 +641,10 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
ui.Image? image;
if (cached != null) {
final unsupported = unsupportedAudioAnalysisCodecLabel(cached.codec);
if (unsupported != null) {
throw _UnsupportedAudioAnalysisCodecException(unsupported);
}
data = cached;
image = await _loadSpectrogramFromCache(
widget.filePath,
@@ -597,6 +674,14 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
} else {
image.dispose();
}
} on _UnsupportedAudioAnalysisCodecException catch (e) {
if (mounted) {
setState(() {
_unsupportedCodec = e.codecLabel;
_error = null;
_analyzing = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
@@ -742,6 +827,10 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
try {
final info = await _getMediaInfo(workingPath);
final unsupported = unsupportedAudioAnalysisCodecLabel(info.codecName);
if (unsupported != null) {
throw _UnsupportedAudioAnalysisCodecException(unsupported);
}
_GeneratedSpectrogram? spectrogram;
try {
spectrogram = await _generateSpectrogram(
@@ -1047,6 +1136,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
return _MediaInfo(
fileSize: fileSize,
codecName: codecName,
codec: _formatCodecLabel(codecName, codecLongName),
container: _formatContainerLabel(formatName, formatLongName),
decodedSampleFormat: decodedSampleFormat,
@@ -1243,6 +1333,51 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
if (_checkingCache) return const SizedBox.shrink();
if (_unsupportedCodec != null) {
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(20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline, color: cs.primary, size: 24),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.audioAnalysisTitle,
style: TextStyle(
color: cs.onSurface,
fontWeight: FontWeight.w600,
fontSize: 15,
),
),
const SizedBox(height: 4),
Text(
'${l10n.snackbarUnsupportedAudioFormat}: '
'$_unsupportedCodec',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
],
),
),
],
),
),
);
}
if (_analyzing) {
final isRescan = _data != null || _spectrogramImage != null;
return Card(
+18
View File
@@ -4,6 +4,24 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
void main() {
group('audio analysis codec support', () {
test('rejects AC-4 aliases unsupported by the bundled decoder', () {
expect(isAudioAnalysisCodecSupported('ac4'), isFalse);
expect(isAudioAnalysisCodecSupported('AC-4'), isFalse);
expect(isAudioAnalysisCodecSupported('Dolby Atmos (AC_4)'), isFalse);
expect(unsupportedAudioAnalysisCodecLabel('AC-4'), 'AC-4');
});
test('keeps supported codecs inside MP4 analyzable', () {
expect(isAudioAnalysisCodecSupported('aac'), isTrue);
expect(isAudioAnalysisCodecSupported('alac'), isTrue);
expect(isAudioAnalysisCodecSupported('eac3'), isTrue);
expect(isAudioAnalysisCodecSupported('E-AC-3'), isTrue);
expect(isAudioAnalysisCodecSupported('flac'), isTrue);
expect(isAudioAnalysisCodecSupported(null), isTrue);
});
});
group('audio level analysis', () {
test('reads peak and RMS from the final astats overall summary', () {
const logs = '''