Files
SpotiFLAC-Mobile/lib/utils/audio_conversion_utils.dart
T
zarzet 5424648158 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.
2026-07-02 01:24:20 +07:00

347 lines
9.2 KiB
Dart

import 'package:spotiflac_android/l10n/app_localizations.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];
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;
}
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;
}
}
class LosslessConversionLabels {
final String original;
final String originalQuality;
final String lossless;
const LosslessConversionLabels({
required this.original,
required this.originalQuality,
required this.lossless,
});
}
extension LosslessConversionLabelsL10n on AppLocalizations {
LosslessConversionLabels get losslessConversionLabels =>
LosslessConversionLabels(
original: trackConvertOriginal,
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}) {
return bitDepth == null ? originalLabel : '$bitDepth-bit';
}
String losslessSampleRateLabel(
int? sampleRate, {
required String originalLabel,
}) {
if (sampleRate == null) return originalLabel;
final khz = sampleRate / 1000;
final precision = sampleRate % 1000 == 0 ? 0 : 1;
return '${khz.toStringAsFixed(precision)} kHz';
}
String losslessQualityLabel(
LosslessConversionQuality quality, {
required String originalLabel,
required String originalQualityLabel,
}) {
final parts = <String>[];
if (quality.maxBitDepth != null) {
parts.add(
losslessBitDepthLabel(quality.maxBitDepth, originalLabel: originalLabel),
);
}
if (quality.maxSampleRate != null) {
parts.add(
losslessSampleRateLabel(
quality.maxSampleRate,
originalLabel: originalLabel,
),
);
}
return parts.isEmpty ? originalQualityLabel : parts.join(' / ');
}
String convertedAudioQualityLabel({
required String targetFormat,
required String bitrate,
required LosslessConversionLabels labels,
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, originalLabel: labels.original)}/${losslessSampleRateLabel(actualSampleRate, originalLabel: labels.original)}';
}
if (losslessQuality.hasCaps) {
return '$upper ${losslessQualityLabel(losslessQuality, originalLabel: labels.original, originalQualityLabel: labels.originalQuality)}';
}
return '$upper ${labels.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;
}