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
This commit is contained in:
zarzet
2026-06-29 06:46:19 +07:00
parent e9171d6f21
commit b2074dfd02
6 changed files with 606 additions and 51 deletions
+66 -7
View File
@@ -5499,6 +5499,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
) async {
final itemsById = {for (final item in allItems) item.id: item};
final sourceFormats = <String>{};
final sourceBitDepths = <int?>[];
final sourceSampleRates = <int?>[];
for (final id in _selectedIds) {
final item = itemsById[id];
if (item == null) continue;
@@ -5508,6 +5510,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
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<QueueTab> {
required List<UnifiedLibraryItem> allItems,
required String targetFormat,
required String bitrate,
LosslessConversionQuality losslessQuality =
const LosslessConversionQuality(),
}) async {
final itemsById = {for (final item in allItems) item.id: item};
final selectedItems = <UnifiedLibraryItem>[];
@@ -5621,7 +5634,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
targetFormat: targetFormat,
bitrate: bitrate,
),
clearAudioSpecs: true,
newBitDepth: convertedBitDepth,
newSampleRate: convertedSampleRate,
clearAudioSpecs: !isLosslessOutput,
);
}
try {
@@ -5890,6 +5943,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
newFilePath: safUri,
targetFormat: targetFormat,
bitrate: bitrate,
bitDepth: convertedBitDepth,
sampleRate: convertedSampleRate,
);
}
@@ -5911,7 +5966,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
newFilePath: newPath,
targetFormat: targetFormat,
bitrate: bitrate,
bitDepth: convertedBitDepth,
sampleRate: convertedSampleRate,
);
}