mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-02 11:05:38 +02:00
b2074dfd02
- 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
259 lines
6.9 KiB
Dart
259 lines
6.9 KiB
Dart
const List<String> audioConversionTargetFormats = [
|
|
'ALAC',
|
|
'FLAC',
|
|
'WAV',
|
|
'AIFF',
|
|
'AAC',
|
|
'MP3',
|
|
'Opus',
|
|
];
|
|
|
|
const List<int> losslessConversionSampleRateOptions = [
|
|
192000,
|
|
96000,
|
|
48000,
|
|
44100,
|
|
];
|
|
|
|
const List<int> losslessConversionBitDepthOptions = [16, 24];
|
|
|
|
List<int> availableLosslessBitDepthOptions(int? sourceBitDepth) {
|
|
if (sourceBitDepth == null || sourceBitDepth <= 0) {
|
|
return losslessConversionBitDepthOptions;
|
|
}
|
|
return losslessConversionBitDepthOptions
|
|
.where((depth) => depth < sourceBitDepth)
|
|
.toList();
|
|
}
|
|
|
|
List<int> availableLosslessSampleRateOptions(int? sourceSampleRate) {
|
|
if (sourceSampleRate == null || sourceSampleRate <= 0) {
|
|
return losslessConversionSampleRateOptions;
|
|
}
|
|
return losslessConversionSampleRateOptions
|
|
.where((rate) => rate < sourceSampleRate)
|
|
.toList();
|
|
}
|
|
|
|
int? lowestKnownPositiveInt(Iterable<int?> 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' ||
|
|
normalized == 'flac' ||
|
|
normalized == 'wav' ||
|
|
normalized == 'aiff' ||
|
|
normalized == 'aif';
|
|
}
|
|
|
|
bool isLosslessConversionSource(String sourceFormat) {
|
|
switch (sourceFormat.trim().toUpperCase()) {
|
|
case 'FLAC':
|
|
case 'ALAC':
|
|
case 'M4A':
|
|
case 'WAV':
|
|
case 'AIFF':
|
|
case 'AIF':
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
bool canConvertAudioFormat({
|
|
required String sourceFormat,
|
|
required String targetFormat,
|
|
}) {
|
|
if (sourceFormat.trim().toUpperCase() == targetFormat.trim().toUpperCase()) {
|
|
return false;
|
|
}
|
|
if (isLosslessConversionTarget(targetFormat) &&
|
|
!isLosslessConversionSource(sourceFormat)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
String? convertibleAudioSourceFormat({
|
|
String? storedFormat,
|
|
String? filePath,
|
|
String? fileName,
|
|
}) {
|
|
final fromStored = _convertibleAudioFormatLabel(storedFormat);
|
|
if (fromStored != null) return fromStored;
|
|
|
|
final name = (fileName != null && fileName.trim().isNotEmpty)
|
|
? fileName
|
|
: filePath;
|
|
if (name == null || name.trim().isEmpty) return null;
|
|
|
|
final normalizedName = name.trim().toLowerCase();
|
|
final dotIndex = normalizedName.lastIndexOf('.');
|
|
if (dotIndex < 0 || dotIndex == normalizedName.length - 1) {
|
|
return null;
|
|
}
|
|
return _convertibleAudioFormatLabel(normalizedName.substring(dotIndex + 1));
|
|
}
|
|
|
|
String? _convertibleAudioFormatLabel(String? rawFormat) {
|
|
final format = rawFormat?.trim().toLowerCase();
|
|
if (format == null || format.isEmpty) return null;
|
|
|
|
switch (format) {
|
|
case 'flac':
|
|
return 'FLAC';
|
|
case 'alac':
|
|
return 'ALAC';
|
|
case 'wav':
|
|
case 'wave':
|
|
return 'WAV';
|
|
case 'aiff':
|
|
case 'aif':
|
|
case 'aifc':
|
|
return 'AIFF';
|
|
case 'm4a':
|
|
case 'mp4':
|
|
return 'M4A';
|
|
case 'aac':
|
|
case 'mp4a':
|
|
return 'AAC';
|
|
case 'mp3':
|
|
return 'MP3';
|
|
case 'opus':
|
|
case 'ogg':
|
|
return 'Opus';
|
|
case 'eac3':
|
|
case 'ec-3':
|
|
return 'EAC3';
|
|
case 'ac3':
|
|
case 'ac-3':
|
|
return 'AC3';
|
|
case 'ac4':
|
|
case 'ac-4':
|
|
return 'AC4';
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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 = <String>[];
|
|
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();
|
|
}
|
|
|
|
/// Returns the output file extension (with dot) and MIME type for a conversion
|
|
/// target format. Used when creating the converted file via SAF so WAV/AIFF and
|
|
/// the other formats get the correct extension + MIME.
|
|
({String ext, String mime}) convertTargetExtAndMime(String targetFormat) {
|
|
switch (targetFormat.trim().toLowerCase()) {
|
|
case 'opus':
|
|
return (ext: '.opus', mime: 'audio/opus');
|
|
case 'alac':
|
|
case 'aac':
|
|
return (ext: '.m4a', mime: 'audio/mp4');
|
|
case 'flac':
|
|
return (ext: '.flac', mime: 'audio/flac');
|
|
case 'wav':
|
|
return (ext: '.wav', mime: 'audio/wav');
|
|
case 'aiff':
|
|
case 'aif':
|
|
return (ext: '.aiff', mime: 'audio/aiff');
|
|
default:
|
|
return (ext: '.mp3', mime: 'audio/mpeg');
|
|
}
|
|
}
|
|
|
|
int? convertedAudioBitrateKbps({
|
|
required String targetFormat,
|
|
required String bitrate,
|
|
}) {
|
|
if (isLosslessConversionTarget(targetFormat)) return null;
|
|
final match = RegExp(r'(\d+)').firstMatch(bitrate);
|
|
return match != null ? int.tryParse(match.group(1)!) : null;
|
|
}
|