diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 0631c921..e2365211 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -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? 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 _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 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 embedMetadataToOpus({ required String opusPath, String? coverPath, diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 734b0c5e..0b216acc 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -455,8 +455,7 @@ class _AudioAnalysisCardState extends State { 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 { 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 { } 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 props, required double duration, @@ -671,6 +677,9 @@ class _AudioAnalysisCardState extends State { inputPath, '-map', '0:a:0', + '-vn', + '-sn', + '-dn', '-af', 'astats=metadata=1:reset=0', '-f', @@ -697,7 +706,6 @@ class _AudioAnalysisCardState extends State { return _LevelMetrics( peakDb: peak, rmsDb: rms, - dynamicRangeDb: _parseLastAstatsValue(section, 'Dynamic range'), clippingSamples: clippingSamples, channelStats: channelStats, ); @@ -714,7 +722,12 @@ class _AudioAnalysisCardState extends State { '-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 { 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 channelStats; const _LevelMetrics({ required this.peakDb, required this.rmsDb, - this.dynamicRangeDb, this.clippingSamples = 0, this.channelStats = const [], });