From 542464815800fefd7dfa0f84673dbafbab45f18f Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 1 Jul 2026 11:24:12 +0700 Subject: [PATCH] feat(audio): add dither and resampler options for lossless conversion Let users choose FFmpeg dithering when reducing bit depth and SoXr or SWR resampling when changing sample rate across single-track and batch lossless conversion flows. --- lib/screens/downloaded_album_screen.dart | 6 +- lib/screens/local_album_screen.dart | 6 +- lib/screens/queue_tab.dart | 6 +- lib/screens/track_metadata_screen.dart | 77 +++++++++++++++-- lib/services/ffmpeg_service.dart | 104 +++++++++++++++++++---- lib/utils/audio_conversion_utils.dart | 66 +++++++++++--- lib/widgets/batch_convert_sheet.dart | 68 ++++++++++++++- 7 files changed, 289 insertions(+), 44 deletions(-) diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 2654e23e..26ab66e9 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -1149,13 +1149,14 @@ class _DownloadedAlbumScreenState extends ConsumerState { confirmLabel: sheetConfirmLabel, sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths), sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates), - onConvert: (format, bitrate, losslessQuality) { + onConvert: (format, bitrate, losslessQuality, losslessProcessing) { Navigator.pop(sheetContext); _performBatchConversion( allTracks: allTracks, targetFormat: format, bitrate: bitrate, losslessQuality: losslessQuality, + losslessProcessing: losslessProcessing, ); }, ), @@ -1168,6 +1169,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { required String bitrate, LosslessConversionQuality losslessQuality = const LosslessConversionQuality(), + LosslessConversionProcessing losslessProcessing = + const LosslessConversionProcessing(), }) async { final tracksById = {for (final t in allTracks) t.id: t}; final selected = []; @@ -1322,6 +1325,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { deleteOriginal: !isSaf, sourceBitDepth: item.bitDepth, losslessQuality: losslessQuality, + losslessProcessing: losslessProcessing, ); if (coverPath != null) { diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index d0f2bce7..b558679c 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -1328,13 +1328,14 @@ class _LocalAlbumScreenState extends ConsumerState { confirmLabel: sheetConfirmLabel, sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths), sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates), - onConvert: (format, bitrate, losslessQuality) { + onConvert: (format, bitrate, losslessQuality, losslessProcessing) { Navigator.pop(sheetContext); _performBatchConversion( allTracks: allTracks, targetFormat: format, bitrate: bitrate, losslessQuality: losslessQuality, + losslessProcessing: losslessProcessing, ); }, ), @@ -1347,6 +1348,8 @@ class _LocalAlbumScreenState extends ConsumerState { required String bitrate, LosslessConversionQuality losslessQuality = const LosslessConversionQuality(), + LosslessConversionProcessing losslessProcessing = + const LosslessConversionProcessing(), }) async { final tracksById = {for (final t in allTracks) t.id: t}; final selected = []; @@ -1500,6 +1503,7 @@ class _LocalAlbumScreenState extends ConsumerState { deleteOriginal: !isSaf, sourceBitDepth: item.bitDepth, losslessQuality: losslessQuality, + losslessProcessing: losslessProcessing, ); if (coverPath != null) { diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 473c88a0..6a16eb34 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -5569,7 +5569,7 @@ class _QueueTabState extends ConsumerState { confirmLabel: sheetConfirmLabel, sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths), sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates), - onConvert: (format, bitrate, losslessQuality) { + onConvert: (format, bitrate, losslessQuality, losslessProcessing) { didStartConversion = true; Navigator.pop(sheetContext); _performBatchConversion( @@ -5577,6 +5577,7 @@ class _QueueTabState extends ConsumerState { targetFormat: format, bitrate: bitrate, losslessQuality: losslessQuality, + losslessProcessing: losslessProcessing, ); }, ), @@ -5611,6 +5612,8 @@ class _QueueTabState extends ConsumerState { required String bitrate, LosslessConversionQuality losslessQuality = const LosslessConversionQuality(), + LosslessConversionProcessing losslessProcessing = + const LosslessConversionProcessing(), }) async { final itemsById = {for (final item in allItems) item.id: item}; final selectedItems = []; @@ -5770,6 +5773,7 @@ class _QueueTabState extends ConsumerState { sourceBitDepth: item.historyItem?.bitDepth ?? item.localItem?.bitDepth, losslessQuality: losslessQuality, + losslessProcessing: losslessProcessing, ); if (coverPath != null) { diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 619cabaa..b3f87e73 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -3822,6 +3822,8 @@ class _TrackMetadataScreenState extends ConsumerState { bool isLosslessTarget = isLosslessConversionTarget(selectedFormat); int? selectedMaxBitDepth; int? selectedMaxSampleRate; + String selectedDither = 'none'; + String selectedResampler = 'swr'; final bitDepthOptions = availableLosslessBitDepthOptions(bitDepth); final sampleRateOptions = availableLosslessSampleRateOptions(sampleRate); @@ -3970,6 +3972,8 @@ class _TrackMetadataScreenState extends ConsumerState { } else { selectedMaxBitDepth = null; selectedMaxSampleRate = null; + selectedDither = 'none'; + selectedResampler = 'swr'; } }); }, @@ -4018,9 +4022,10 @@ class _TrackMetadataScreenState extends ConsumerState { originalLabel: labels.original, ), selected: selectedMaxBitDepth == null, - onTap: () => setSheetState( - () => selectedMaxBitDepth = null, - ), + onTap: () => setSheetState(() { + selectedMaxBitDepth = null; + selectedDither = 'none'; + }), ), ...bitDepthOptions.map((depth) { return choice( @@ -4056,9 +4061,10 @@ class _TrackMetadataScreenState extends ConsumerState { originalLabel: labels.original, ), selected: selectedMaxSampleRate == null, - onTap: () => setSheetState( - () => selectedMaxSampleRate = null, - ), + onTap: () => setSheetState(() { + selectedMaxSampleRate = null; + selectedResampler = 'swr'; + }), ), ...sampleRateOptions.map((rate) { return choice( @@ -4078,6 +4084,55 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), + if (isLosslessTarget && selectedMaxBitDepth != null) + card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + sectionLabel(context.l10n.trackConvertDithering), + Wrap( + spacing: 8, + runSpacing: 8, + children: losslessDitherOptions.map((mode) { + return choice( + label: context.l10n.losslessDitherOptionLabel( + mode, + ), + selected: mode == selectedDither, + onTap: () => setSheetState( + () => selectedDither = mode, + ), + ); + }).toList(), + ), + ], + ), + ), + + if (isLosslessTarget && selectedMaxSampleRate != null) + card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + sectionLabel(context.l10n.trackConvertResampler), + Wrap( + spacing: 8, + runSpacing: 8, + children: losslessResamplerOptions.map((mode) { + return choice( + label: context.l10n + .losslessResamplerOptionLabel(mode), + selected: mode == selectedResampler, + onTap: () => setSheetState( + () => selectedResampler = mode, + ), + ); + }).toList(), + ), + ], + ), + ), + if (isLosslessTarget && isLosslessSource) Container( width: double.infinity, @@ -4142,6 +4197,10 @@ class _TrackMetadataScreenState extends ConsumerState { maxBitDepth: selectedMaxBitDepth, maxSampleRate: selectedMaxSampleRate, ), + losslessProcessing: LosslessConversionProcessing( + dither: selectedDither, + resampler: selectedResampler, + ), ); }, icon: const Icon(Icons.swap_horiz), @@ -4642,6 +4701,8 @@ class _TrackMetadataScreenState extends ConsumerState { required String bitrate, LosslessConversionQuality losslessQuality = const LosslessConversionQuality(), + LosslessConversionProcessing losslessProcessing = + const LosslessConversionProcessing(), }) { final isLossless = isLosslessConversionTarget(targetFormat); showDialog( @@ -4687,6 +4748,7 @@ class _TrackMetadataScreenState extends ConsumerState { targetFormat: targetFormat, bitrate: bitrate, losslessQuality: losslessQuality, + losslessProcessing: losslessProcessing, ); }, child: Text(dialogContext.l10n.trackConvertFormat), @@ -4702,6 +4764,8 @@ class _TrackMetadataScreenState extends ConsumerState { required String bitrate, LosslessConversionQuality losslessQuality = const LosslessConversionQuality(), + LosslessConversionProcessing losslessProcessing = + const LosslessConversionProcessing(), }) async { if (_isConverting) return; setState(() => _isConverting = true); @@ -4778,6 +4842,7 @@ class _TrackMetadataScreenState extends ConsumerState { deleteOriginal: !isSaf, sourceBitDepth: bitDepth, losslessQuality: losslessQuality, + losslessProcessing: losslessProcessing, ); if (coverPath != null) { diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 31a8dd75..dab629c6 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -393,12 +393,19 @@ class FFmpegService { required String codec, int? targetBitDepth, int? targetSampleRate, + LosslessConversionProcessing processing = + const LosslessConversionProcessing(), }) { - if (targetSampleRate != null && targetSampleRate > 0) { - arguments - ..add('-ar') - ..add(targetSampleRate.toString()); - } + final sampleFmt = _losslessOutputSampleFormat( + codec: codec, + targetBitDepth: targetBitDepth, + ); + _appendLosslessAresampleFilter( + arguments, + targetSampleRate: targetSampleRate, + outputSampleFormat: sampleFmt, + processing: processing, + ); if (targetBitDepth == null || targetBitDepth <= 0) return; if (codec == 'flac') { @@ -431,6 +438,48 @@ class FFmpegService { } } + static String? _losslessOutputSampleFormat({ + required String codec, + int? targetBitDepth, + }) { + if (targetBitDepth == null || targetBitDepth <= 0) return null; + + if (codec == 'flac') { + return targetBitDepth <= 16 ? 's16' : 's32'; + } + if (codec == 'alac') { + return targetBitDepth <= 16 ? 's16p' : 's32p'; + } + if (codec == 'pcm') { + return targetBitDepth <= 16 ? 's16' : 's32'; + } + return null; + } + + static void _appendLosslessAresampleFilter( + List arguments, { + int? targetSampleRate, + String? outputSampleFormat, + LosslessConversionProcessing processing = + const LosslessConversionProcessing(), + }) { + final hasSampleRate = targetSampleRate != null && targetSampleRate > 0; + final hasSampleFormat = + outputSampleFormat != null && outputSampleFormat.trim().isNotEmpty; + if (!hasSampleRate && !hasSampleFormat && !processing.hasDither) return; + + final options = [ + 'resampler=${processing.normalizedResampler}', + if (hasSampleRate) 'osr=$targetSampleRate', + if (hasSampleFormat) 'osf=${outputSampleFormat.trim()}', + if (processing.hasDither) 'dither_method=${processing.normalizedDither}', + ]; + + arguments + ..add('-af') + ..add('aresample=${options.join(':')}'); + } + static Future convertM4aToFlac(String inputPath) async { final outputPath = _buildOutputPath(inputPath, '.flac'); @@ -2349,6 +2398,8 @@ class FFmpegService { int? sourceBitDepth, LosslessConversionQuality losslessQuality = const LosslessConversionQuality(), + LosslessConversionProcessing losslessProcessing = + const LosslessConversionProcessing(), }) async { final format = targetFormat.toLowerCase(); if (!const { @@ -2380,6 +2431,7 @@ class FFmpegService { coverPath: coverPath, targetBitDepth: resolvedLosslessQuality.targetBitDepth, targetSampleRate: resolvedLosslessQuality.targetSampleRate, + processing: losslessProcessing, deleteOriginal: deleteOriginal, ); } @@ -2391,6 +2443,7 @@ class FFmpegService { artistTagMode: artistTagMode, targetBitDepth: resolvedLosslessQuality.targetBitDepth, targetSampleRate: resolvedLosslessQuality.targetSampleRate, + processing: losslessProcessing, deleteOriginal: deleteOriginal, ); } @@ -2403,6 +2456,7 @@ class FFmpegService { sourceBitDepth: sourceBitDepth, targetBitDepth: resolvedLosslessQuality.targetBitDepth, targetSampleRate: resolvedLosslessQuality.targetSampleRate, + processing: losslessProcessing, deleteOriginal: deleteOriginal, ); } @@ -2500,6 +2554,8 @@ class FFmpegService { String? coverPath, int? targetBitDepth, int? targetSampleRate, + LosslessConversionProcessing processing = + const LosslessConversionProcessing(), bool deleteOriginal = true, }) async { final outputPath = _buildOutputPath(inputPath, '.m4a'); @@ -2539,6 +2595,7 @@ class FFmpegService { codec: 'alac', targetBitDepth: targetBitDepth, targetSampleRate: targetSampleRate, + processing: processing, ); arguments ..add('-map_metadata') @@ -2553,7 +2610,9 @@ class FFmpegService { _log.i( 'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC' '${targetBitDepth != null ? ' $targetBitDepth-bit' : ''}' - '${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}', + '${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}' + '${processing.hasDither ? ' dither=${processing.normalizedDither}' : ''}' + '${processing.normalizedResampler != 'swr' ? ' resampler=${processing.normalizedResampler}' : ''}', ); final result = await _executeWithArguments(arguments); @@ -2583,6 +2642,8 @@ class FFmpegService { String artistTagMode = artistTagModeJoined, int? targetBitDepth, int? targetSampleRate, + LosslessConversionProcessing processing = + const LosslessConversionProcessing(), bool deleteOriginal = true, }) async { final outputPath = _buildOutputPath(inputPath, '.flac'); @@ -2624,6 +2685,7 @@ class FFmpegService { codec: 'flac', targetBitDepth: targetBitDepth, targetSampleRate: targetSampleRate, + processing: processing, ); arguments ..add('-map_metadata') @@ -2642,7 +2704,9 @@ class FFmpegService { _log.i( 'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC' '${targetBitDepth != null ? ' $targetBitDepth-bit' : ''}' - '${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}', + '${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}' + '${processing.hasDither ? ' dither=${processing.normalizedDither}' : ''}' + '${processing.normalizedResampler != 'swr' ? ' resampler=${processing.normalizedResampler}' : ''}', ); final result = await _executeWithArguments(arguments); @@ -2676,6 +2740,8 @@ class FFmpegService { int? sourceBitDepth, int? targetBitDepth, int? targetSampleRate, + LosslessConversionProcessing processing = + const LosslessConversionProcessing(), bool deleteOriginal = true, }) async { final isAiff = container == 'aiff'; @@ -2697,22 +2763,24 @@ class FFmpegService { inputPath, '-map', '0:a', - '-c:a', - codec, - if (targetSampleRate != null && targetSampleRate > 0) ...[ - '-ar', - targetSampleRate.toString(), - ], - '-map_metadata', - '-1', - outputPath, - '-y', ]; + _appendLosslessAresampleFilter( + arguments, + targetSampleRate: targetSampleRate, + outputSampleFormat: _losslessOutputSampleFormat( + codec: 'pcm', + targetBitDepth: targetBitDepth, + ), + processing: processing, + ); + arguments.addAll(['-c:a', codec, '-map_metadata', '-1', outputPath, '-y']); _log.i( 'Converting ${inputPath.split(Platform.pathSeparator).last} to ' '${container.toUpperCase()} (${use24 ? 24 : 16}-bit' - '${targetSampleRate != null ? ', ${targetSampleRate}Hz' : ''})', + '${targetSampleRate != null ? ', ${targetSampleRate}Hz' : ''}' + '${processing.hasDither ? ', dither=${processing.normalizedDither}' : ''}' + '${processing.normalizedResampler != 'swr' ? ', resampler=${processing.normalizedResampler}' : ''})', ); final result = await _executeWithArguments(arguments); if (!result.success) { diff --git a/lib/utils/audio_conversion_utils.dart b/lib/utils/audio_conversion_utils.dart index 91edbe0d..e352225c 100644 --- a/lib/utils/audio_conversion_utils.dart +++ b/lib/utils/audio_conversion_utils.dart @@ -19,6 +19,36 @@ const List losslessConversionSampleRateOptions = [ const List losslessConversionBitDepthOptions = [16, 24]; +const List losslessDitherOptions = [ + 'none', + 'triangular', + 'triangular_hp', +]; + +const List losslessResamplerOptions = ['swr', 'soxr']; + +class LosslessConversionProcessing { + final String dither; + final String resampler; + + const LosslessConversionProcessing({ + this.dither = 'none', + this.resampler = 'swr', + }); + + String get normalizedDither { + final normalized = dither.trim().toLowerCase(); + return losslessDitherOptions.contains(normalized) ? normalized : 'none'; + } + + String get normalizedResampler { + final normalized = resampler.trim().toLowerCase(); + return losslessResamplerOptions.contains(normalized) ? normalized : 'swr'; + } + + bool get hasDither => normalizedDither != 'none'; +} + List availableLosslessBitDepthOptions(int? sourceBitDepth) { if (sourceBitDepth == null || sourceBitDepth <= 0) { return losslessConversionBitDepthOptions; @@ -189,12 +219,29 @@ extension LosslessConversionLabelsL10n on AppLocalizations { originalQuality: trackConvertOriginalQuality, lossless: trackConvertLosslessSuffix, ); + + String losslessDitherOptionLabel(String dither) { + switch (dither.trim().toLowerCase()) { + case 'triangular': + return trackConvertDitherTriangular; + case 'triangular_hp': + return trackConvertDitherTriangularHp; + default: + return trackConvertDitherNone; + } + } + + String losslessResamplerOptionLabel(String resampler) { + switch (resampler.trim().toLowerCase()) { + case 'soxr': + return trackConvertResamplerSoxr; + default: + return trackConvertResamplerSwr; + } + } } -String losslessBitDepthLabel( - int? bitDepth, { - required String originalLabel, -}) { +String losslessBitDepthLabel(int? bitDepth, {required String originalLabel}) { return bitDepth == null ? originalLabel : '$bitDepth-bit'; } @@ -216,10 +263,7 @@ String losslessQualityLabel( final parts = []; if (quality.maxBitDepth != null) { parts.add( - losslessBitDepthLabel( - quality.maxBitDepth, - originalLabel: originalLabel, - ), + losslessBitDepthLabel(quality.maxBitDepth, originalLabel: originalLabel), ); } if (quality.maxSampleRate != null) { @@ -250,11 +294,7 @@ String convertedAudioQualityLabel({ return '$upper ${losslessBitDepthLabel(actualBitDepth, originalLabel: labels.original)}/${losslessSampleRateLabel(actualSampleRate, originalLabel: labels.original)}'; } if (losslessQuality.hasCaps) { - return '$upper ${losslessQualityLabel( - losslessQuality, - originalLabel: labels.original, - originalQualityLabel: labels.originalQuality, - )}'; + return '$upper ${losslessQualityLabel(losslessQuality, originalLabel: labels.original, originalQualityLabel: labels.originalQuality)}'; } return '$upper ${labels.lossless}'; } diff --git a/lib/widgets/batch_convert_sheet.dart b/lib/widgets/batch_convert_sheet.dart index 43496b6e..d91feda6 100644 --- a/lib/widgets/batch_convert_sheet.dart +++ b/lib/widgets/batch_convert_sheet.dart @@ -16,6 +16,7 @@ class BatchConvertSheet extends StatefulWidget { String format, String bitrate, LosslessConversionQuality losslessQuality, + LosslessConversionProcessing losslessProcessing, ) onConvert; @@ -42,6 +43,8 @@ class _BatchConvertSheetState extends State { late String _selectedBitrate; int? _selectedMaxBitDepth; int? _selectedMaxSampleRate; + String _selectedDither = 'none'; + String _selectedResampler = 'swr'; String _defaultBitrateForFormat(String format) { if (format == 'Opus') return '128k'; @@ -132,6 +135,8 @@ class _BatchConvertSheetState extends State { } else { _selectedMaxBitDepth = null; _selectedMaxSampleRate = null; + _selectedDither = 'none'; + _selectedResampler = 'swr'; } }); }, @@ -183,8 +188,10 @@ class _BatchConvertSheetState extends State { originalLabel: labels.original, ), selected: _selectedMaxBitDepth == null, - onTap: () => - setState(() => _selectedMaxBitDepth = null), + onTap: () => setState(() { + _selectedMaxBitDepth = null; + _selectedDither = 'none'; + }), ), ...bitDepthOptions.map((depth) { return _choice( @@ -222,8 +229,10 @@ class _BatchConvertSheetState extends State { originalLabel: labels.original, ), selected: _selectedMaxSampleRate == null, - onTap: () => - setState(() => _selectedMaxSampleRate = null), + onTap: () => setState(() { + _selectedMaxSampleRate = null; + _selectedResampler = 'swr'; + }), ), ...sampleRateOptions.map((rate) { return _choice( @@ -243,6 +252,53 @@ class _BatchConvertSheetState extends State { ), ), + if (_isLosslessTarget && _selectedMaxBitDepth != null) + _card( + cs, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel(cs, context.l10n.trackConvertDithering), + Wrap( + spacing: 8, + runSpacing: 8, + children: losslessDitherOptions.map((mode) { + return _choice( + cs, + label: context.l10n.losslessDitherOptionLabel(mode), + selected: mode == _selectedDither, + onTap: () => setState(() => _selectedDither = mode), + ); + }).toList(), + ), + ], + ), + ), + + if (_isLosslessTarget && _selectedMaxSampleRate != null) + _card( + cs, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel(cs, context.l10n.trackConvertResampler), + Wrap( + spacing: 8, + runSpacing: 8, + children: losslessResamplerOptions.map((mode) { + return _choice( + cs, + label: context.l10n.losslessResamplerOptionLabel(mode), + selected: mode == _selectedResampler, + onTap: () => + setState(() => _selectedResampler = mode), + ); + }).toList(), + ), + ], + ), + ), + if (_isLosslessTarget) Container( width: double.infinity, @@ -294,6 +350,10 @@ class _BatchConvertSheetState extends State { maxBitDepth: _selectedMaxBitDepth, maxSampleRate: _selectedMaxSampleRate, ), + LosslessConversionProcessing( + dither: _selectedDither, + resampler: _selectedResampler, + ), ), icon: const Icon(Icons.swap_horiz), style: FilledButton.styleFrom(