mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-15 05:10:28 +02:00
fix: MP3 lyrics embedding via ID3v2.3 USLT frame
FFmpeg doesn't always embed lyrics correctly to MP3 files. This adds manual ID3v2.3 USLT (Unsynchronized Lyrics) frame writing after FFmpeg metadata embedding to ensure lyrics are properly stored. Implementation: - Extract lyrics from metadata (UNSYNCEDLYRICS or LYRICS key) - Build ID3v2.3 compliant USLT frame with UTF-16LE encoding - Insert or replace USLT frame in existing ID3v2.3 tag - Create new ID3v2.3 tag if file has no ID3 header - Skip gracefully for unsupported ID3 versions or flags Also includes minor audio analysis improvements: - Consistent dynamic range calculation (peak - rms) - Filter out 'unknown' and 'n/a' labels - Add -vn -sn -dn flags for more robust stream selection
This commit is contained in:
@@ -1389,6 +1389,7 @@ class FFmpegService {
|
||||
}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3');
|
||||
final lyrics = _extractLyricsForId3(metadata);
|
||||
|
||||
// Try with -c:a copy first (fastest, preserves original codec)
|
||||
var result = await _runMp3Embed(
|
||||
@@ -1401,7 +1402,11 @@ class FFmpegService {
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
return await _finalizeMp3Embed(mp3Path, tempOutput);
|
||||
final embeddedPath = await _finalizeMp3Embed(mp3Path, tempOutput);
|
||||
if (embeddedPath != null && lyrics != null) {
|
||||
await _ensureMp3UnsyncedLyricsFrame(embeddedPath, lyrics);
|
||||
}
|
||||
return embeddedPath;
|
||||
}
|
||||
|
||||
// If copy failed (e.g. AAC/Opus in .mp3 container), re-encode to real MP3
|
||||
@@ -1427,7 +1432,11 @@ class FFmpegService {
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
return await _finalizeMp3Embed(mp3Path, reencodeOutput);
|
||||
final embeddedPath = await _finalizeMp3Embed(mp3Path, reencodeOutput);
|
||||
if (embeddedPath != null && lyrics != null) {
|
||||
await _ensureMp3UnsyncedLyricsFrame(embeddedPath, lyrics);
|
||||
}
|
||||
return embeddedPath;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1539,6 +1548,204 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
static String? _extractLyricsForId3(Map<String, String>? metadata) {
|
||||
if (metadata == null) return null;
|
||||
|
||||
String? fallback;
|
||||
for (final entry in metadata.entries) {
|
||||
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
if (key != 'UNSYNCEDLYRICS' && key != 'LYRICS') continue;
|
||||
|
||||
final value = entry.value;
|
||||
if (value.trim().isEmpty) continue;
|
||||
if (key == 'UNSYNCEDLYRICS') return value;
|
||||
fallback ??= value;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
static Future<void> _ensureMp3UnsyncedLyricsFrame(
|
||||
String mp3Path,
|
||||
String lyrics,
|
||||
) async {
|
||||
try {
|
||||
final file = File(mp3Path);
|
||||
if (!await file.exists()) return;
|
||||
|
||||
final bytes = await file.readAsBytes();
|
||||
final updated = _writeId3v23UnsyncedLyrics(bytes, lyrics);
|
||||
if (updated == null) {
|
||||
_log.w('Skipping MP3 USLT lyrics frame update: unsupported ID3 tag');
|
||||
return;
|
||||
}
|
||||
|
||||
await file.writeAsBytes(updated, flush: true);
|
||||
_log.d('MP3 USLT lyrics frame written (${lyrics.length} chars)');
|
||||
} catch (e) {
|
||||
_log.w('Failed to write MP3 USLT lyrics frame: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Uint8List? _writeId3v23UnsyncedLyrics(Uint8List bytes, String lyrics) {
|
||||
final lyricsFrame = _buildId3v23UnsyncedLyricsFrame(lyrics);
|
||||
|
||||
if (!_hasId3Header(bytes)) {
|
||||
final builder = BytesBuilder(copy: false)
|
||||
..add(_buildId3v23Tag(lyricsFrame))
|
||||
..add(bytes);
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
if (bytes.length < 10 || bytes[3] != 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final flags = bytes[5];
|
||||
const unsupportedFlags = 0x80 | 0x40 | 0x20;
|
||||
if ((flags & unsupportedFlags) != 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final tagSize = _readSynchsafeInt(bytes, 6);
|
||||
if (tagSize == null) return null;
|
||||
|
||||
final tagEnd = 10 + tagSize;
|
||||
if (tagEnd < 10 || tagEnd > bytes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final tagPayload = bytes.sublist(10, tagEnd);
|
||||
final preservedFrames = _removeId3v23Frames(tagPayload, {'USLT'});
|
||||
final newPayload = BytesBuilder(copy: false)
|
||||
..add(preservedFrames)
|
||||
..add(lyricsFrame);
|
||||
|
||||
final newTag = _buildId3v23Tag(newPayload.toBytes());
|
||||
final builder = BytesBuilder(copy: false)
|
||||
..add(newTag)
|
||||
..add(bytes.sublist(tagEnd));
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
static bool _hasId3Header(Uint8List bytes) {
|
||||
return bytes.length >= 10 &&
|
||||
bytes[0] == 0x49 &&
|
||||
bytes[1] == 0x44 &&
|
||||
bytes[2] == 0x33;
|
||||
}
|
||||
|
||||
static Uint8List _removeId3v23Frames(
|
||||
Uint8List tagPayload,
|
||||
Set<String> frameIds,
|
||||
) {
|
||||
final builder = BytesBuilder(copy: false);
|
||||
var offset = 0;
|
||||
|
||||
while (offset + 10 <= tagPayload.length) {
|
||||
final idBytes = tagPayload.sublist(offset, offset + 4);
|
||||
if (idBytes.every((byte) => byte == 0)) break;
|
||||
|
||||
final frameId = ascii.decode(idBytes, allowInvalid: true);
|
||||
if (!RegExp(r'^[A-Z0-9]{4}$').hasMatch(frameId)) break;
|
||||
|
||||
final frameSize = _readUint32(tagPayload, offset + 4);
|
||||
if (frameSize <= 0 || offset + 10 + frameSize > tagPayload.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!frameIds.contains(frameId)) {
|
||||
builder.add(tagPayload.sublist(offset, offset + 10 + frameSize));
|
||||
}
|
||||
|
||||
offset += 10 + frameSize;
|
||||
}
|
||||
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
static Uint8List _buildId3v23Tag(Uint8List payload) {
|
||||
final header = Uint8List(10)
|
||||
..[0] = 0x49
|
||||
..[1] = 0x44
|
||||
..[2] = 0x33
|
||||
..[3] = 3;
|
||||
|
||||
final size = _writeSynchsafeInt(payload.length);
|
||||
header.setRange(6, 10, size);
|
||||
|
||||
final builder = BytesBuilder(copy: false)
|
||||
..add(header)
|
||||
..add(payload);
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
static Uint8List _buildId3v23UnsyncedLyricsFrame(String lyrics) {
|
||||
final payload = BytesBuilder(copy: false)
|
||||
..add(const [0x01, 0x65, 0x6e, 0x67])
|
||||
..add(const [0xff, 0xfe, 0x00, 0x00])
|
||||
..add(_utf16LeWithBom(lyrics));
|
||||
|
||||
return _buildId3v23Frame('USLT', payload.toBytes());
|
||||
}
|
||||
|
||||
static Uint8List _buildId3v23Frame(String frameId, Uint8List payload) {
|
||||
final header = Uint8List(10);
|
||||
header.setRange(0, 4, ascii.encode(frameId));
|
||||
final size = _writeUint32(payload.length);
|
||||
header.setRange(4, 8, size);
|
||||
|
||||
final builder = BytesBuilder(copy: false)
|
||||
..add(header)
|
||||
..add(payload);
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
static Uint8List _utf16LeWithBom(String value) {
|
||||
final bytes = BytesBuilder(copy: false)..add(const [0xff, 0xfe]);
|
||||
for (final codeUnit in value.codeUnits) {
|
||||
bytes.add([codeUnit & 0xff, (codeUnit >> 8) & 0xff]);
|
||||
}
|
||||
return bytes.toBytes();
|
||||
}
|
||||
|
||||
static int? _readSynchsafeInt(Uint8List bytes, int offset) {
|
||||
if (offset + 4 > bytes.length) return null;
|
||||
|
||||
final b0 = bytes[offset];
|
||||
final b1 = bytes[offset + 1];
|
||||
final b2 = bytes[offset + 2];
|
||||
final b3 = bytes[offset + 3];
|
||||
if ((b0 | b1 | b2 | b3) & 0x80 != 0) return null;
|
||||
|
||||
return (b0 << 21) | (b1 << 14) | (b2 << 7) | b3;
|
||||
}
|
||||
|
||||
static Uint8List _writeSynchsafeInt(int value) {
|
||||
return Uint8List.fromList([
|
||||
(value >> 21) & 0x7f,
|
||||
(value >> 14) & 0x7f,
|
||||
(value >> 7) & 0x7f,
|
||||
value & 0x7f,
|
||||
]);
|
||||
}
|
||||
|
||||
static int _readUint32(Uint8List bytes, int offset) {
|
||||
return (bytes[offset] << 24) |
|
||||
(bytes[offset + 1] << 16) |
|
||||
(bytes[offset + 2] << 8) |
|
||||
bytes[offset + 3];
|
||||
}
|
||||
|
||||
static Uint8List _writeUint32(int value) {
|
||||
return Uint8List.fromList([
|
||||
(value >> 24) & 0xff,
|
||||
(value >> 16) & 0xff,
|
||||
(value >> 8) & 0xff,
|
||||
value & 0xff,
|
||||
]);
|
||||
}
|
||||
|
||||
static Future<String?> embedMetadataToOpus({
|
||||
required String opusPath,
|
||||
String? coverPath,
|
||||
|
||||
@@ -455,8 +455,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
final peakAmplitude =
|
||||
levelMetrics?.peakDb ?? spectrumResult.peakAmplitude;
|
||||
final rmsLevel = levelMetrics?.rmsDb ?? spectrumResult.rmsLevel;
|
||||
final dynamicRange =
|
||||
levelMetrics?.dynamicRangeDb ?? (peakAmplitude - rmsLevel);
|
||||
final dynamicRange = peakAmplitude - rmsLevel;
|
||||
final spectralCutoffHz = spectrumResult.spectrum == null
|
||||
? null
|
||||
: await compute(
|
||||
@@ -601,7 +600,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
|
||||
String _formatCodecLabel(String codecName, String codecLongName) {
|
||||
final name = codecName.trim();
|
||||
final longName = codecLongName.trim();
|
||||
final longName = _normalizeAnalysisLabel(codecLongName);
|
||||
if (name.isEmpty) return longName;
|
||||
if (longName.isEmpty || longName.toLowerCase() == name.toLowerCase()) {
|
||||
return name.toUpperCase();
|
||||
@@ -610,12 +609,19 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
}
|
||||
|
||||
String _formatContainerLabel(String formatName, String formatLongName) {
|
||||
final longName = formatLongName.trim();
|
||||
final longName = _normalizeAnalysisLabel(formatLongName);
|
||||
if (longName.isNotEmpty) return longName;
|
||||
final name = formatName.trim();
|
||||
return name.isEmpty ? '' : name.toUpperCase();
|
||||
}
|
||||
|
||||
String _normalizeAnalysisLabel(String value) {
|
||||
final trimmed = value.trim();
|
||||
final lower = trimmed.toLowerCase();
|
||||
if (lower.isEmpty || lower == 'unknown' || lower == 'n/a') return '';
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
int _estimateTotalSamples({
|
||||
required Map<dynamic, dynamic> props,
|
||||
required double duration,
|
||||
@@ -671,6 +677,9 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
inputPath,
|
||||
'-map',
|
||||
'0:a:0',
|
||||
'-vn',
|
||||
'-sn',
|
||||
'-dn',
|
||||
'-af',
|
||||
'astats=metadata=1:reset=0',
|
||||
'-f',
|
||||
@@ -697,7 +706,6 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
return _LevelMetrics(
|
||||
peakDb: peak,
|
||||
rmsDb: rms,
|
||||
dynamicRangeDb: _parseLastAstatsValue(section, 'Dynamic range'),
|
||||
clippingSamples: clippingSamples,
|
||||
channelStats: channelStats,
|
||||
);
|
||||
@@ -714,7 +722,12 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
'-nostats',
|
||||
'-i',
|
||||
inputPath,
|
||||
'-filter_complex',
|
||||
'-map',
|
||||
'0:a:0',
|
||||
'-vn',
|
||||
'-sn',
|
||||
'-dn',
|
||||
'-af',
|
||||
'ebur128=peak=true:framelog=quiet',
|
||||
'-f',
|
||||
'null',
|
||||
@@ -757,12 +770,16 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
final channel = int.tryParse(match.group(1) ?? '') ?? 0;
|
||||
final section = match.group(2) ?? '';
|
||||
if (channel <= 0 || section.trim().isEmpty) continue;
|
||||
final peakDb = _parseLastAstatsValue(section, 'Peak level dB');
|
||||
final rmsDb = _parseLastAstatsValue(section, 'RMS level dB');
|
||||
stats.add(
|
||||
ChannelAnalysisStats(
|
||||
channel: channel,
|
||||
peakDb: _parseLastAstatsValue(section, 'Peak level dB'),
|
||||
rmsDb: _parseLastAstatsValue(section, 'RMS level dB'),
|
||||
dynamicRangeDb: _parseLastAstatsValue(section, 'Dynamic range'),
|
||||
peakDb: peakDb,
|
||||
rmsDb: rmsDb,
|
||||
dynamicRangeDb: peakDb != null && rmsDb != null
|
||||
? peakDb - rmsDb
|
||||
: null,
|
||||
peakCount:
|
||||
_parseLastAstatsInt(section, 'Peak count') ??
|
||||
_parseLastAstatsInt(section, 'Peak count ch') ??
|
||||
@@ -1021,14 +1038,12 @@ class _MediaInfo {
|
||||
class _LevelMetrics {
|
||||
final double peakDb;
|
||||
final double rmsDb;
|
||||
final double? dynamicRangeDb;
|
||||
final int clippingSamples;
|
||||
final List<ChannelAnalysisStats> channelStats;
|
||||
|
||||
const _LevelMetrics({
|
||||
required this.peakDb,
|
||||
required this.rmsDb,
|
||||
this.dynamicRangeDb,
|
||||
this.clippingSamples = 0,
|
||||
this.channelStats = const [],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user