From b2074dfd02079e7cb3c74c548a71b8be7fbb42ce Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 29 Jun 2026 06:46:19 +0700 Subject: [PATCH] feat: cap bit depth/sample rate on lossless conversion + WAV/AIFF - LosslessConversionQuality model with bit depth/sample rate caps, applied only when they reduce source quality - FFmpegService probes sample rate and appends codec-specific args (-ar, -sample_fmt, -bits_per_raw_sample) for FLAC/ALAC/WAV/AIFF - Batch + single-track convert sheets expose quality cap options - Persist real converted bit depth/sample rate to history/library DB - track_metadata: recognize and convert to WAV/AIFF targets - convertedAudioQualityLabel reflects actual output quality --- lib/screens/queue_tab.dart | 73 +++++++++- lib/screens/track_metadata_screen.dart | 173 +++++++++++++++++++---- lib/services/ffmpeg_service.dart | 187 +++++++++++++++++++++++-- lib/services/library_database.dart | 6 + lib/utils/audio_conversion_utils.dart | 115 +++++++++++++++ lib/widgets/batch_convert_sheet.dart | 103 +++++++++++++- 6 files changed, 606 insertions(+), 51 deletions(-) diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 37f1faf0..c1ec6576 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -5499,6 +5499,8 @@ class _QueueTabState extends ConsumerState { ) async { final itemsById = {for (final item in allItems) item.id: item}; final sourceFormats = {}; + final sourceBitDepths = []; + final sourceSampleRates = []; for (final id in _selectedIds) { final item = itemsById[id]; if (item == null) continue; @@ -5508,6 +5510,12 @@ class _QueueTabState extends ConsumerState { fileName: item.historyItem?.safFileName, ); if (sourceFormat != null) sourceFormats.add(sourceFormat); + sourceBitDepths.add( + item.historyItem?.bitDepth ?? item.localItem?.bitDepth, + ); + sourceSampleRates.add( + item.historyItem?.sampleRate ?? item.localItem?.sampleRate, + ); } final formats = audioConversionTargetFormats @@ -5546,13 +5554,16 @@ class _QueueTabState extends ConsumerState { formats: formats, title: sheetTitle, confirmLabel: sheetConfirmLabel, - onConvert: (format, bitrate) { + sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths), + sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates), + onConvert: (format, bitrate, losslessQuality) { didStartConversion = true; Navigator.pop(sheetContext); _performBatchConversion( allItems: allItems, targetFormat: format, bitrate: bitrate, + losslessQuality: losslessQuality, ); }, ), @@ -5585,6 +5596,8 @@ class _QueueTabState extends ConsumerState { required List allItems, required String targetFormat, required String bitrate, + LosslessConversionQuality losslessQuality = + const LosslessConversionQuality(), }) async { final itemsById = {for (final item in allItems) item.id: item}; final selectedItems = []; @@ -5621,7 +5634,9 @@ class _QueueTabState extends ConsumerState { builder: (ctx) => AlertDialog( title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( - isLossless + isLossless && losslessQuality.hasCaps + ? 'Convert ${selectedItems.length} tracks to $targetFormat (${losslessQualityLabel(losslessQuality)})?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.' + : isLossless ? context.l10n.selectionBatchConvertConfirmMessageLossless( selectedItems.length, targetFormat, @@ -5650,9 +5665,6 @@ class _QueueTabState extends ConsumerState { int successCount = 0; final total = selectedItems.length; final historyDb = HistoryDatabase.instance; - final newQuality = isLosslessConversionTarget(targetFormat) - ? '${targetFormat.toUpperCase()} Lossless' - : '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; final settings = ref.read(settingsProvider); final shouldEmbedLyrics = settings.embedLyrics && settings.lyricsMode != 'external'; @@ -5733,6 +5745,9 @@ class _QueueTabState extends ConsumerState { coverPath: coverPath, artistTagMode: settings.artistTagMode, deleteOriginal: !isSaf, + sourceBitDepth: + item.historyItem?.bitDepth ?? item.localItem?.bitDepth, + losslessQuality: losslessQuality, ); if (coverPath != null) { @@ -5750,6 +5765,42 @@ class _QueueTabState extends ConsumerState { continue; } + final sourceBitDepth = + item.historyItem?.bitDepth ?? item.localItem?.bitDepth; + final sourceSampleRate = + item.historyItem?.sampleRate ?? item.localItem?.sampleRate; + final isLosslessOutput = isLosslessConversionTarget(targetFormat); + int? convertedBitDepth; + int? convertedSampleRate; + if (isLosslessOutput) { + try { + final convertedMetadata = await PlatformBridge.readFileMetadata( + newPath, + ); + if (convertedMetadata['error'] == null) { + convertedBitDepth = readPositiveAudioInt( + convertedMetadata['bit_depth'], + ); + convertedSampleRate = readPositiveAudioInt( + convertedMetadata['sample_rate'], + ); + } + } catch (_) {} + convertedBitDepth ??= losslessQuality.effectiveBitDepth( + sourceBitDepth, + ); + convertedSampleRate ??= losslessQuality.effectiveSampleRate( + sourceSampleRate, + ); + } + final newQuality = convertedAudioQualityLabel( + targetFormat: targetFormat, + bitrate: bitrate, + losslessQuality: losslessQuality, + actualBitDepth: convertedBitDepth, + actualSampleRate: convertedSampleRate, + ); + if (isSaf && item.historyItem != null) { final hi = item.historyItem!; final treeUri = hi.downloadTreeUri; @@ -5801,7 +5852,9 @@ class _QueueTabState extends ConsumerState { targetFormat: targetFormat, bitrate: bitrate, ), - clearAudioSpecs: true, + newBitDepth: convertedBitDepth, + newSampleRate: convertedSampleRate, + clearAudioSpecs: !isLosslessOutput, ); } try { @@ -5890,6 +5943,8 @@ class _QueueTabState extends ConsumerState { newFilePath: safUri, targetFormat: targetFormat, bitrate: bitrate, + bitDepth: convertedBitDepth, + sampleRate: convertedSampleRate, ); } @@ -5911,7 +5966,9 @@ class _QueueTabState extends ConsumerState { targetFormat: targetFormat, bitrate: bitrate, ), - clearAudioSpecs: true, + newBitDepth: convertedBitDepth, + newSampleRate: convertedSampleRate, + clearAudioSpecs: !isLosslessOutput, ); } else if (item.localItem != null) { await LibraryDatabase.instance.replaceWithConvertedItem( @@ -5919,6 +5976,8 @@ class _QueueTabState extends ConsumerState { newFilePath: newPath, targetFormat: targetFormat, bitrate: bitrate, + bitDepth: convertedBitDepth, + sampleRate: convertedSampleRate, ); } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 0646b197..5ca398f6 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -775,6 +775,7 @@ class _TrackMetadataScreenState extends ConsumerState { } return url; } + String? get _localCoverPath => _isLocalItem ? _localLibraryItem!.coverPath : null; String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId; @@ -1219,9 +1220,7 @@ class _TrackMetadataScreenState extends ConsumerState { } if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(playNext ? 'Playing next' : 'Added to queue'), - ), + SnackBar(content: Text(playNext ? 'Playing next' : 'Added to queue')), ); } @@ -3568,6 +3567,9 @@ class _TrackMetadataScreenState extends ConsumerState { return lower.endsWith('.flac') || lower.endsWith('.m4a') || lower.endsWith('.aac') || + lower.endsWith('.wav') || + lower.endsWith('.aiff') || + lower.endsWith('.aif') || lower.endsWith('.mp3') || lower.endsWith('.opus') || lower.endsWith('.ogg'); @@ -3613,12 +3615,21 @@ class _TrackMetadataScreenState extends ConsumerState { case 'opus': case 'ogg': return 'Opus'; + case 'wav': + case 'wave': + return 'WAV'; + case 'aiff': + case 'aif': + case 'aifc': + return 'AIFF'; } } final lower = cleanFilePath.toLowerCase(); if (lower.endsWith('.flac')) return 'FLAC'; if (lower.endsWith('.m4a')) return 'M4A'; if (lower.endsWith('.aac')) return 'AAC'; + if (lower.endsWith('.wav')) return 'WAV'; + if (lower.endsWith('.aiff') || lower.endsWith('.aif')) return 'AIFF'; if (lower.endsWith('.mp3')) return 'MP3'; if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'Opus'; if (lower.endsWith('.cue')) return 'CUE'; @@ -3701,15 +3712,6 @@ class _TrackMetadataScreenState extends ConsumerState { return mapped; } - String _buildConvertedQualityLabel(String targetFormat, String bitrate) { - final upper = targetFormat.toUpperCase(); - if (isLosslessConversionTarget(targetFormat)) { - return '$upper Lossless'; - } - final normalizedBitrate = bitrate.trim().toLowerCase(); - return '$upper $normalizedBitrate'; - } - String? _extractLossyBitrateLabel(String? quality) { if (quality == null || quality.isEmpty) return null; final match = RegExp( @@ -3808,6 +3810,10 @@ class _TrackMetadataScreenState extends ConsumerState { String selectedBitrate = defaultBitrateForFormat(selectedFormat); bool isLosslessTarget = isLosslessConversionTarget(selectedFormat); + int? selectedMaxBitDepth; + int? selectedMaxSampleRate; + final bitDepthOptions = availableLosslessBitDepthOptions(bitDepth); + final sampleRateOptions = availableLosslessSampleRateOptions(sampleRate); showModalBottomSheet( context: context, @@ -3875,9 +3881,7 @@ class _TrackMetadataScreenState extends ConsumerState { border: Border.all( color: selected ? Colors.transparent - : colorScheme.outlineVariant.withValues( - alpha: 0.6, - ), + : colorScheme.outlineVariant.withValues(alpha: 0.6), ), ), child: Text( @@ -3949,8 +3953,12 @@ class _TrackMetadataScreenState extends ConsumerState { isLosslessTarget = isLosslessConversionTarget(format); if (!isLosslessTarget) { - selectedBitrate = - defaultBitrateForFormat(format); + selectedBitrate = defaultBitrateForFormat( + format, + ); + } else { + selectedMaxBitDepth = null; + selectedMaxSampleRate = null; } }); }, @@ -3974,9 +3982,8 @@ class _TrackMetadataScreenState extends ConsumerState { return choice( label: br, selected: br == selectedBitrate, - onTap: () => setSheetState( - () => selectedBitrate = br, - ), + onTap: () => + setSheetState(() => selectedBitrate = br), ); }).toList(), ), @@ -3984,6 +3991,70 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), + if (isLosslessTarget && bitDepthOptions.isNotEmpty) + card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + sectionLabel('Bit depth'), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + choice( + label: losslessBitDepthLabel(null), + selected: selectedMaxBitDepth == null, + onTap: () => setSheetState( + () => selectedMaxBitDepth = null, + ), + ), + ...bitDepthOptions.map((depth) { + return choice( + label: losslessBitDepthLabel(depth), + selected: depth == selectedMaxBitDepth, + onTap: () => setSheetState( + () => selectedMaxBitDepth = depth, + ), + ); + }), + ], + ), + ], + ), + ), + + if (isLosslessTarget && sampleRateOptions.isNotEmpty) + card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + sectionLabel('Sample rate'), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + choice( + label: losslessSampleRateLabel(null), + selected: selectedMaxSampleRate == null, + onTap: () => setSheetState( + () => selectedMaxSampleRate = null, + ), + ), + ...sampleRateOptions.map((rate) { + return choice( + label: losslessSampleRateLabel(rate), + selected: rate == selectedMaxSampleRate, + onTap: () => setSheetState( + () => selectedMaxSampleRate = rate, + ), + ); + }), + ], + ), + ], + ), + ), + if (isLosslessTarget && isLosslessSource) Container( width: double.infinity, @@ -4008,7 +4079,10 @@ class _TrackMetadataScreenState extends ConsumerState { const SizedBox(width: 8), Expanded( child: Text( - context.l10n.trackConvertLosslessHint, + selectedMaxBitDepth == null && + selectedMaxSampleRate == null + ? context.l10n.trackConvertLosslessHint + : 'Lossless output with ${losslessQualityLabel(LosslessConversionQuality(maxBitDepth: selectedMaxBitDepth, maxSampleRate: selectedMaxSampleRate))} cap', style: Theme.of(context).textTheme.bodySmall ?.copyWith(color: colorScheme.primary), ), @@ -4028,6 +4102,10 @@ class _TrackMetadataScreenState extends ConsumerState { sourceFormat: currentFormat, targetFormat: selectedFormat, bitrate: selectedBitrate, + losslessQuality: LosslessConversionQuality( + maxBitDepth: selectedMaxBitDepth, + maxSampleRate: selectedMaxSampleRate, + ), ); }, icon: const Icon(Icons.swap_horiz), @@ -4039,7 +4117,7 @@ class _TrackMetadataScreenState extends ConsumerState { ), label: Text( isLosslessTarget - ? '$currentFormat → $selectedFormat (Lossless)' + ? '$currentFormat → $selectedFormat (${losslessQualityLabel(LosslessConversionQuality(maxBitDepth: selectedMaxBitDepth, maxSampleRate: selectedMaxSampleRate))})' : '$currentFormat → $selectedFormat @ $selectedBitrate', ), ), @@ -4510,6 +4588,8 @@ class _TrackMetadataScreenState extends ConsumerState { required String sourceFormat, required String targetFormat, required String bitrate, + LosslessConversionQuality losslessQuality = + const LosslessConversionQuality(), }) { final isLossless = isLosslessConversionTarget(targetFormat); showDialog( @@ -4518,7 +4598,9 @@ class _TrackMetadataScreenState extends ConsumerState { return AlertDialog( title: Text(dialogContext.l10n.trackConvertConfirmTitle), content: Text( - isLossless + isLossless && losslessQuality.hasCaps + ? 'Convert $sourceFormat to $targetFormat (${losslessQualityLabel(losslessQuality)})?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.' + : isLossless ? dialogContext.l10n.trackConvertConfirmMessageLossless( sourceFormat, targetFormat, @@ -4540,6 +4622,7 @@ class _TrackMetadataScreenState extends ConsumerState { _performConversion( targetFormat: targetFormat, bitrate: bitrate, + losslessQuality: losslessQuality, ); }, child: Text(dialogContext.l10n.trackConvertFormat), @@ -4553,6 +4636,8 @@ class _TrackMetadataScreenState extends ConsumerState { Future _performConversion({ required String targetFormat, required String bitrate, + LosslessConversionQuality losslessQuality = + const LosslessConversionQuality(), }) async { if (_isConverting) return; setState(() => _isConverting = true); @@ -4626,6 +4711,8 @@ class _TrackMetadataScreenState extends ConsumerState { coverPath: coverPath, artistTagMode: ref.read(settingsProvider).artistTagMode, deleteOriginal: !isSaf, + sourceBitDepth: bitDepth, + losslessQuality: losslessQuality, ); if (coverPath != null) { @@ -4649,7 +4736,33 @@ class _TrackMetadataScreenState extends ConsumerState { return; } - final newQuality = _buildConvertedQualityLabel(targetFormat, bitrate); + final isLosslessOutput = isLosslessConversionTarget(targetFormat); + int? convertedBitDepth; + int? convertedSampleRate; + if (isLosslessOutput) { + try { + final convertedMetadata = await PlatformBridge.readFileMetadata( + newPath, + ); + if (convertedMetadata['error'] == null) { + convertedBitDepth = readPositiveAudioInt( + convertedMetadata['bit_depth'], + ); + convertedSampleRate = readPositiveAudioInt( + convertedMetadata['sample_rate'], + ); + } + } catch (_) {} + convertedBitDepth ??= losslessQuality.effectiveBitDepth(bitDepth); + convertedSampleRate ??= losslessQuality.effectiveSampleRate(sampleRate); + } + final newQuality = convertedAudioQualityLabel( + targetFormat: targetFormat, + bitrate: bitrate, + losslessQuality: losslessQuality, + actualBitDepth: convertedBitDepth, + actualSampleRate: convertedSampleRate, + ); if (isSaf) { String? treeUri; @@ -4771,7 +4884,9 @@ class _TrackMetadataScreenState extends ConsumerState { targetFormat: targetFormat, bitrate: bitrate, ), - clearAudioSpecs: true, + newBitDepth: convertedBitDepth, + newSampleRate: convertedSampleRate, + clearAudioSpecs: !isLosslessOutput, ); await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); } else { @@ -4780,6 +4895,8 @@ class _TrackMetadataScreenState extends ConsumerState { newFilePath: safUri, targetFormat: targetFormat, bitrate: bitrate, + bitDepth: convertedBitDepth, + sampleRate: convertedSampleRate, ); await ref.read(localLibraryProvider.notifier).reloadFromStorage(); } @@ -4803,7 +4920,9 @@ class _TrackMetadataScreenState extends ConsumerState { targetFormat: targetFormat, bitrate: bitrate, ), - clearAudioSpecs: true, + newBitDepth: convertedBitDepth, + newSampleRate: convertedSampleRate, + clearAudioSpecs: !isLosslessOutput, ); await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); } else { @@ -4812,6 +4931,8 @@ class _TrackMetadataScreenState extends ConsumerState { newFilePath: newPath, targetFormat: targetFormat, bitrate: bitrate, + bitDepth: convertedBitDepth, + sampleRate: convertedSampleRate, ); await ref.read(localLibraryProvider.notifier).reloadFromStorage(); } diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 81479119..31a8dd75 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -11,6 +11,7 @@ import 'package:ffmpeg_kit_flutter_new_full/session_state.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/artist_utils.dart'; +import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('FFmpeg'); @@ -111,6 +112,16 @@ class DownloadDecryptionDescriptor { } } +class _ResolvedLosslessConversionQuality { + final int? targetBitDepth; + final int? targetSampleRate; + + const _ResolvedLosslessConversionQuality({ + this.targetBitDepth, + this.targetSampleRate, + }); +} + class FFmpegService { static const int _commandLogPreviewLength = 300; static const Duration _liveTunnelStartupTimeout = Duration(seconds: 8); @@ -306,6 +317,24 @@ class FFmpegService { return null; } + static Future probeSampleRate(String filePath) async { + try { + final session = await FFprobeKit.getMediaInformation(filePath); + final info = session.getMediaInformation(); + if (info == null) return null; + for (final stream in info.getStreams()) { + final props = stream.getAllProperties() ?? const {}; + if (props['codec_type']?.toString() != 'audio') continue; + final value = int.tryParse(props['sample_rate']?.toString() ?? ''); + if (value != null && value > 0) return value; + return null; + } + } catch (e) { + _log.w('Sample rate probe failed for $filePath: $e'); + } + return null; + } + /// Returns `true` when [filePath] starts with the native FLAC magic bytes /// (`fLaC`). Useful to distinguish a real FLAC file from a FLAC-in-MP4 /// container that carries a `.flac` extension or claims codec=flac. @@ -328,6 +357,80 @@ class FFmpegService { } } + static Future<_ResolvedLosslessConversionQuality> _resolveLosslessQuality({ + required String inputPath, + required LosslessConversionQuality quality, + int? sourceBitDepth, + }) async { + final probedBitDepth = + sourceBitDepth ?? + (quality.maxBitDepth != null ? await probeBitDepth(inputPath) : null); + final probedSampleRate = quality.maxSampleRate != null + ? await probeSampleRate(inputPath) + : null; + + int? targetBitDepth; + if (quality.maxBitDepth != null && + (probedBitDepth == null || probedBitDepth > quality.maxBitDepth!)) { + targetBitDepth = quality.maxBitDepth; + } + + int? targetSampleRate; + if (quality.maxSampleRate != null && + (probedSampleRate == null || + probedSampleRate > quality.maxSampleRate!)) { + targetSampleRate = quality.maxSampleRate; + } + + return _ResolvedLosslessConversionQuality( + targetBitDepth: targetBitDepth, + targetSampleRate: targetSampleRate, + ); + } + + static void _appendLosslessCodecQualityArguments( + List arguments, { + required String codec, + int? targetBitDepth, + int? targetSampleRate, + }) { + if (targetSampleRate != null && targetSampleRate > 0) { + arguments + ..add('-ar') + ..add(targetSampleRate.toString()); + } + if (targetBitDepth == null || targetBitDepth <= 0) return; + + if (codec == 'flac') { + if (targetBitDepth <= 16) { + arguments + ..add('-sample_fmt') + ..add('s16'); + } else if (targetBitDepth <= 24) { + arguments + ..add('-sample_fmt') + ..add('s32') + ..add('-bits_per_raw_sample') + ..add('24'); + } + return; + } + + if (codec == 'alac') { + if (targetBitDepth <= 16) { + arguments + ..add('-sample_fmt') + ..add('s16p'); + } else if (targetBitDepth <= 24) { + arguments + ..add('-sample_fmt') + ..add('s32p') + ..add('-bits_per_raw_sample') + ..add('24'); + } + } + } + static Future convertM4aToFlac(String inputPath) async { final outputPath = _buildOutputPath(inputPath, '.flac'); @@ -2048,7 +2151,10 @@ class FFmpegService { } if (metadata != null) { - _appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata)); + _appendMappedMetadataToArguments( + arguments, + _convertToM4aTags(metadata), + ); } // MOV muxer accepts codecs the MP4 muxer rejects (e.g. AC-4). The default @@ -2228,8 +2334,10 @@ class FFmpegService { /// Unified audio format conversion with full metadata + cover preservation. /// Supports: FLAC/M4A/MP3/Opus -> AAC/M4A/MP3/Opus/ALAC/FLAC/WAV/AIFF. - /// ALAC, FLAC, WAV and AIFF targets are lossless (bitrate parameter is ignored). - /// [sourceBitDepth] (when known) preserves 24-bit resolution for WAV/AIFF. + /// ALAC, FLAC, WAV and AIFF targets are lossless codecs (bitrate parameter + /// is ignored). [losslessQuality] can cap bit depth/sample rate, and caps are + /// only applied when they reduce the source quality. + /// [sourceBitDepth] (when known) avoids an extra probe. static Future convertAudioFormat({ required String inputPath, required String targetFormat, @@ -2239,6 +2347,8 @@ class FFmpegService { String artistTagMode = artistTagModeJoined, bool deleteOriginal = true, int? sourceBitDepth, + LosslessConversionQuality losslessQuality = + const LosslessConversionQuality(), }) async { final format = targetFormat.toLowerCase(); if (!const { @@ -2255,11 +2365,21 @@ class FFmpegService { return null; } + final resolvedLosslessQuality = isLosslessConversionTarget(format) + ? await _resolveLosslessQuality( + inputPath: inputPath, + quality: losslessQuality, + sourceBitDepth: sourceBitDepth, + ) + : const _ResolvedLosslessConversionQuality(); + if (format == 'alac') { return _convertToAlac( inputPath: inputPath, metadata: metadata, coverPath: coverPath, + targetBitDepth: resolvedLosslessQuality.targetBitDepth, + targetSampleRate: resolvedLosslessQuality.targetSampleRate, deleteOriginal: deleteOriginal, ); } @@ -2269,6 +2389,8 @@ class FFmpegService { metadata: metadata, coverPath: coverPath, artistTagMode: artistTagMode, + targetBitDepth: resolvedLosslessQuality.targetBitDepth, + targetSampleRate: resolvedLosslessQuality.targetSampleRate, deleteOriginal: deleteOriginal, ); } @@ -2279,6 +2401,8 @@ class FFmpegService { coverPath: coverPath, container: format == 'wav' ? 'wav' : 'aiff', sourceBitDepth: sourceBitDepth, + targetBitDepth: resolvedLosslessQuality.targetBitDepth, + targetSampleRate: resolvedLosslessQuality.targetSampleRate, deleteOriginal: deleteOriginal, ); } @@ -2374,6 +2498,8 @@ class FFmpegService { required String inputPath, required Map metadata, String? coverPath, + int? targetBitDepth, + int? targetSampleRate, bool deleteOriginal = true, }) async { final outputPath = _buildOutputPath(inputPath, '.m4a'); @@ -2407,7 +2533,14 @@ class FFmpegService { } arguments ..add('-c:a') - ..add('alac') + ..add('alac'); + _appendLosslessCodecQualityArguments( + arguments, + codec: 'alac', + targetBitDepth: targetBitDepth, + targetSampleRate: targetSampleRate, + ); + arguments ..add('-map_metadata') ..add('-1'); @@ -2418,7 +2551,9 @@ class FFmpegService { ..add('-y'); _log.i( - 'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC', + 'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC' + '${targetBitDepth != null ? ' $targetBitDepth-bit' : ''}' + '${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}', ); final result = await _executeWithArguments(arguments); @@ -2446,6 +2581,8 @@ class FFmpegService { required Map metadata, String? coverPath, String artistTagMode = artistTagModeJoined, + int? targetBitDepth, + int? targetSampleRate, bool deleteOriginal = true, }) async { final outputPath = _buildOutputPath(inputPath, '.flac'); @@ -2481,7 +2618,14 @@ class FFmpegService { ..add('-c:a') ..add('flac') ..add('-compression_level') - ..add('8') + ..add('8'); + _appendLosslessCodecQualityArguments( + arguments, + codec: 'flac', + targetBitDepth: targetBitDepth, + targetSampleRate: targetSampleRate, + ); + arguments ..add('-map_metadata') ..add('0'); @@ -2496,7 +2640,9 @@ class FFmpegService { ..add('-y'); _log.i( - 'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC', + 'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC' + '${targetBitDepth != null ? ' $targetBitDepth-bit' : ''}' + '${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}', ); final result = await _executeWithArguments(arguments); @@ -2528,11 +2674,13 @@ class FFmpegService { required String container, // 'wav' or 'aiff' String? coverPath, int? sourceBitDepth, + int? targetBitDepth, + int? targetSampleRate, bool deleteOriginal = true, }) async { final isAiff = container == 'aiff'; final outputPath = _buildOutputPath(inputPath, isAiff ? '.aiff' : '.wav'); - var depth = sourceBitDepth; + var depth = targetBitDepth ?? sourceBitDepth; if (depth == null || depth <= 0) { depth = await probeBitDepth(inputPath); } @@ -2542,18 +2690,29 @@ class FFmpegService { : (use24 ? 'pcm_s24le' : 'pcm_s16le'); final arguments = [ - '-v', 'error', '-hide_banner', - '-i', inputPath, - '-map', '0:a', - '-c:a', codec, - '-map_metadata', '-1', + '-v', + 'error', + '-hide_banner', + '-i', + inputPath, + '-map', + '0:a', + '-c:a', + codec, + if (targetSampleRate != null && targetSampleRate > 0) ...[ + '-ar', + targetSampleRate.toString(), + ], + '-map_metadata', + '-1', outputPath, '-y', ]; _log.i( 'Converting ${inputPath.split(Platform.pathSeparator).last} to ' - '${container.toUpperCase()} (${use24 ? 24 : 16}-bit)', + '${container.toUpperCase()} (${use24 ? 24 : 16}-bit' + '${targetSampleRate != null ? ', ${targetSampleRate}Hz' : ''})', ); final result = await _executeWithArguments(arguments); if (!result.success) { diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 34306287..49694c93 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -1735,6 +1735,8 @@ class LibraryDatabase { required String newFilePath, required String targetFormat, required String bitrate, + int? bitDepth, + int? sampleRate, }) async { final db = await database; final stat = await fileStat(newFilePath); @@ -1755,6 +1757,10 @@ class LibraryDatabase { normalizedFormat == 'opus' || normalizedFormat == 'aac') { updated['bitDepth'] = null; + updated['sampleRate'] = null; + } else { + updated['bitDepth'] = bitDepth ?? item.bitDepth; + updated['sampleRate'] = sampleRate ?? item.sampleRate; } await db.transaction((txn) async { diff --git a/lib/utils/audio_conversion_utils.dart b/lib/utils/audio_conversion_utils.dart index 55e1e8bc..c890cd10 100644 --- a/lib/utils/audio_conversion_utils.dart +++ b/lib/utils/audio_conversion_utils.dart @@ -8,6 +8,67 @@ const List audioConversionTargetFormats = [ 'Opus', ]; +const List losslessConversionSampleRateOptions = [ + 192000, + 96000, + 48000, + 44100, +]; + +const List losslessConversionBitDepthOptions = [16, 24]; + +List availableLosslessBitDepthOptions(int? sourceBitDepth) { + if (sourceBitDepth == null || sourceBitDepth <= 0) { + return losslessConversionBitDepthOptions; + } + return losslessConversionBitDepthOptions + .where((depth) => depth < sourceBitDepth) + .toList(); +} + +List availableLosslessSampleRateOptions(int? sourceSampleRate) { + if (sourceSampleRate == null || sourceSampleRate <= 0) { + return losslessConversionSampleRateOptions; + } + return losslessConversionSampleRateOptions + .where((rate) => rate < sourceSampleRate) + .toList(); +} + +int? lowestKnownPositiveInt(Iterable values) { + int? lowest; + for (final value in values) { + if (value == null || value <= 0) continue; + if (lowest == null || value < lowest) { + lowest = value; + } + } + return lowest; +} + +class LosslessConversionQuality { + final int? maxBitDepth; + final int? maxSampleRate; + + const LosslessConversionQuality({this.maxBitDepth, this.maxSampleRate}); + + bool get hasCaps => maxBitDepth != null || maxSampleRate != null; + + int? effectiveBitDepth(int? sourceBitDepth) { + if (maxBitDepth == null) return sourceBitDepth; + if (sourceBitDepth == null || sourceBitDepth <= 0) return maxBitDepth; + return sourceBitDepth > maxBitDepth! ? maxBitDepth : sourceBitDepth; + } + + int? effectiveSampleRate(int? sourceSampleRate) { + if (maxSampleRate == null) return sourceSampleRate; + if (sourceSampleRate == null || sourceSampleRate <= 0) { + return maxSampleRate; + } + return sourceSampleRate > maxSampleRate! ? maxSampleRate : sourceSampleRate; + } +} + bool isLosslessConversionTarget(String targetFormat) { final normalized = targetFormat.trim().toLowerCase(); return normalized == 'alac' || @@ -107,6 +168,60 @@ String? _convertibleAudioFormatLabel(String? rawFormat) { } } +String losslessBitDepthLabel(int? bitDepth) { + return bitDepth == null ? 'Original' : '$bitDepth-bit'; +} + +String losslessSampleRateLabel(int? sampleRate) { + if (sampleRate == null) return 'Original'; + final khz = sampleRate / 1000; + final precision = sampleRate % 1000 == 0 ? 0 : 1; + return '${khz.toStringAsFixed(precision)} kHz'; +} + +String losslessQualityLabel(LosslessConversionQuality quality) { + final parts = []; + if (quality.maxBitDepth != null) { + parts.add(losslessBitDepthLabel(quality.maxBitDepth)); + } + if (quality.maxSampleRate != null) { + parts.add(losslessSampleRateLabel(quality.maxSampleRate)); + } + return parts.isEmpty ? 'Original quality' : parts.join(' / '); +} + +String convertedAudioQualityLabel({ + required String targetFormat, + required String bitrate, + LosslessConversionQuality losslessQuality = const LosslessConversionQuality(), + int? actualBitDepth, + int? actualSampleRate, +}) { + final upper = targetFormat.toUpperCase(); + if (isLosslessConversionTarget(targetFormat)) { + if (actualBitDepth != null && + actualBitDepth > 0 && + actualSampleRate != null && + actualSampleRate > 0) { + return '$upper ${losslessBitDepthLabel(actualBitDepth)}/${losslessSampleRateLabel(actualSampleRate)}'; + } + if (losslessQuality.hasCaps) { + return '$upper ${losslessQualityLabel(losslessQuality)}'; + } + return '$upper Lossless'; + } + return '$upper ${bitrate.trim().toLowerCase()}'; +} + +int? readPositiveAudioInt(Object? value) { + if (value is num) { + final intValue = value.toInt(); + return intValue > 0 ? intValue : null; + } + final parsed = int.tryParse(value?.toString() ?? ''); + return parsed != null && parsed > 0 ? parsed : null; +} + String normalizedConvertedAudioFormat(String targetFormat) { return targetFormat.trim().toLowerCase(); } diff --git a/lib/widgets/batch_convert_sheet.dart b/lib/widgets/batch_convert_sheet.dart index bba723c4..dbace21e 100644 --- a/lib/widgets/batch_convert_sheet.dart +++ b/lib/widgets/batch_convert_sheet.dart @@ -10,7 +10,14 @@ class BatchConvertSheet extends StatefulWidget { final String title; final String? subtitle; final String confirmLabel; - final void Function(String format, String bitrate) onConvert; + final int? sourceBitDepth; + final int? sourceSampleRate; + final void Function( + String format, + String bitrate, + LosslessConversionQuality losslessQuality, + ) + onConvert; const BatchConvertSheet({ super.key, @@ -19,6 +26,8 @@ class BatchConvertSheet extends StatefulWidget { required this.confirmLabel, required this.onConvert, this.subtitle, + this.sourceBitDepth, + this.sourceSampleRate, }); @override @@ -31,6 +40,8 @@ class _BatchConvertSheetState extends State { late String _selectedFormat; late bool _isLosslessTarget; late String _selectedBitrate; + int? _selectedMaxBitDepth; + int? _selectedMaxSampleRate; String _defaultBitrateForFormat(String format) { if (format == 'Opus') return '128k'; @@ -51,6 +62,12 @@ class _BatchConvertSheetState extends State { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final bitDepthOptions = availableLosslessBitDepthOptions( + widget.sourceBitDepth, + ); + final sampleRateOptions = availableLosslessSampleRateOptions( + widget.sourceSampleRate, + ); return SafeArea( child: SingleChildScrollView( @@ -111,6 +128,9 @@ class _BatchConvertSheetState extends State { _selectedBitrate = _defaultBitrateForFormat( format, ); + } else { + _selectedMaxBitDepth = null; + _selectedMaxSampleRate = null; } }); }, @@ -144,6 +164,72 @@ class _BatchConvertSheetState extends State { ), ), + if (_isLosslessTarget && bitDepthOptions.isNotEmpty) + _card( + cs, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel(cs, 'Bit depth'), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _choice( + cs, + label: losslessBitDepthLabel(null), + selected: _selectedMaxBitDepth == null, + onTap: () => + setState(() => _selectedMaxBitDepth = null), + ), + ...bitDepthOptions.map((depth) { + return _choice( + cs, + label: losslessBitDepthLabel(depth), + selected: depth == _selectedMaxBitDepth, + onTap: () => + setState(() => _selectedMaxBitDepth = depth), + ); + }), + ], + ), + ], + ), + ), + + if (_isLosslessTarget && sampleRateOptions.isNotEmpty) + _card( + cs, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel(cs, 'Sample rate'), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _choice( + cs, + label: losslessSampleRateLabel(null), + selected: _selectedMaxSampleRate == null, + onTap: () => + setState(() => _selectedMaxSampleRate = null), + ), + ...sampleRateOptions.map((rate) { + return _choice( + cs, + label: losslessSampleRateLabel(rate), + selected: rate == _selectedMaxSampleRate, + onTap: () => + setState(() => _selectedMaxSampleRate = rate), + ); + }), + ], + ), + ], + ), + ), + if (_isLosslessTarget) Container( width: double.infinity, @@ -162,7 +248,10 @@ class _BatchConvertSheetState extends State { const SizedBox(width: 8), Expanded( child: Text( - context.l10n.trackConvertLosslessHint, + _selectedMaxBitDepth == null && + _selectedMaxSampleRate == null + ? context.l10n.trackConvertLosslessHint + : 'Lossless output with ${losslessQualityLabel(LosslessConversionQuality(maxBitDepth: _selectedMaxBitDepth, maxSampleRate: _selectedMaxSampleRate))} cap', style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: cs.primary), @@ -176,8 +265,14 @@ class _BatchConvertSheetState extends State { SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: () => - widget.onConvert(_selectedFormat, _selectedBitrate), + onPressed: () => widget.onConvert( + _selectedFormat, + _selectedBitrate, + LosslessConversionQuality( + maxBitDepth: _selectedMaxBitDepth, + maxSampleRate: _selectedMaxSampleRate, + ), + ), icon: const Icon(Icons.swap_horiz), style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16),