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.
This commit is contained in:
zarzet
2026-07-01 11:24:12 +07:00
parent dcfd95f276
commit 5424648158
7 changed files with 289 additions and 44 deletions
+5 -1
View File
@@ -1149,13 +1149,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
required String bitrate,
LosslessConversionQuality losslessQuality =
const LosslessConversionQuality(),
LosslessConversionProcessing losslessProcessing =
const LosslessConversionProcessing(),
}) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <DownloadHistoryItem>[];
@@ -1322,6 +1325,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
deleteOriginal: !isSaf,
sourceBitDepth: item.bitDepth,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
if (coverPath != null) {
+5 -1
View File
@@ -1328,13 +1328,14 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
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<LocalAlbumScreen> {
required String bitrate,
LosslessConversionQuality losslessQuality =
const LosslessConversionQuality(),
LosslessConversionProcessing losslessProcessing =
const LosslessConversionProcessing(),
}) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <LocalLibraryItem>[];
@@ -1500,6 +1503,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
deleteOriginal: !isSaf,
sourceBitDepth: item.bitDepth,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
if (coverPath != null) {
+5 -1
View File
@@ -5569,7 +5569,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
targetFormat: format,
bitrate: bitrate,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
},
),
@@ -5611,6 +5612,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
required String bitrate,
LosslessConversionQuality losslessQuality =
const LosslessConversionQuality(),
LosslessConversionProcessing losslessProcessing =
const LosslessConversionProcessing(),
}) async {
final itemsById = {for (final item in allItems) item.id: item};
final selectedItems = <UnifiedLibraryItem>[];
@@ -5770,6 +5773,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
sourceBitDepth:
item.historyItem?.bitDepth ?? item.localItem?.bitDepth,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
if (coverPath != null) {
+71 -6
View File
@@ -3822,6 +3822,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
} else {
selectedMaxBitDepth = null;
selectedMaxSampleRate = null;
selectedDither = 'none';
selectedResampler = 'swr';
}
});
},
@@ -4018,9 +4022,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
),
),
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<TrackMetadataScreen> {
maxBitDepth: selectedMaxBitDepth,
maxSampleRate: selectedMaxSampleRate,
),
losslessProcessing: LosslessConversionProcessing(
dither: selectedDither,
resampler: selectedResampler,
),
);
},
icon: const Icon(Icons.swap_horiz),
@@ -4642,6 +4701,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
required String bitrate,
LosslessConversionQuality losslessQuality =
const LosslessConversionQuality(),
LosslessConversionProcessing losslessProcessing =
const LosslessConversionProcessing(),
}) {
final isLossless = isLosslessConversionTarget(targetFormat);
showDialog<void>(
@@ -4687,6 +4748,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
targetFormat: targetFormat,
bitrate: bitrate,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
},
child: Text(dialogContext.l10n.trackConvertFormat),
@@ -4702,6 +4764,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
deleteOriginal: !isSaf,
sourceBitDepth: bitDepth,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
if (coverPath != null) {
+86 -18
View File
@@ -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<String> 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 = <String>[
'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<String?> 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) {
+53 -13
View File
@@ -19,6 +19,36 @@ const List<int> losslessConversionSampleRateOptions = [
const List<int> losslessConversionBitDepthOptions = [16, 24];
const List<String> losslessDitherOptions = [
'none',
'triangular',
'triangular_hp',
];
const List<String> 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<int> 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 = <String>[];
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}';
}
+64 -4
View File
@@ -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<BatchConvertSheet> {
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<BatchConvertSheet> {
} else {
_selectedMaxBitDepth = null;
_selectedMaxSampleRate = null;
_selectedDither = 'none';
_selectedResampler = 'swr';
}
});
},
@@ -183,8 +188,10 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
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<BatchConvertSheet> {
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<BatchConvertSheet> {
),
),
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<BatchConvertSheet> {
maxBitDepth: _selectedMaxBitDepth,
maxSampleRate: _selectedMaxSampleRate,
),
LosslessConversionProcessing(
dither: _selectedDither,
resampler: _selectedResampler,
),
),
icon: const Icon(Icons.swap_horiz),
style: FilledButton.styleFrom(