SHA256
125 lines
6.1 KiB
C#
125 lines
6.1 KiB
C#
using System;
|
|
using System.IO;
|
|
|
|
namespace ADOFAIRenderer.Util.Audio.Clip;
|
|
|
|
/// <summary>
|
|
/// Detects the VBR/info header frame ("Xing", "Info" or "VBRI") at the start of an MP3 file.
|
|
///
|
|
/// The game decodes MP3 files with MP3Sharp (Assembly-CSharp-firstpass, MP3Sharp.MP3Stream), which has no header-frame handling:
|
|
/// it decodes that frame as one frame of silence and keeps the encoder delay. FFmpeg's MP3 demuxer consumes the header frame
|
|
/// without output. Measured with the clip harness (LAME 192k CBR, LAME VBR, 44.1/48 kHz, with and without ID3v2):
|
|
/// MP3Sharp output is FFmpeg's untrimmed output preceded by one frame of silence when the header frame exists,
|
|
/// and identical in timing when it does not.
|
|
///
|
|
/// Frame layout used here (ISO/IEC 11172-3 and 13818-3 frame header; Xing/Info tag after the Layer III side information,
|
|
/// VBRI tag 32 bytes after the frame header):
|
|
/// - Header: 11 sync bits, version (2 bits: 3 = MPEG-1, 2 = MPEG-2, 0 = MPEG-2.5), layer (2 bits: 1 = III, 2 = II, 3 = I),
|
|
/// protection bit (0 = 16-bit CRC follows), bitrate index, sample rate index, padding, private, channel mode (3 = mono).
|
|
/// - Layer III side information: MPEG-1 32 bytes (17 mono), MPEG-2/2.5 17 bytes (9 mono).
|
|
/// - Samples per frame: Layer I 384, Layer II 1152, Layer III 1152 (MPEG-1) or 576 (MPEG-2/2.5).
|
|
/// - ID3v2 tags before the first frame: "ID3", version (2 bytes), flags (bit 0x10 = 10-byte footer), size (4 bytes, 7 bits each).
|
|
/// </summary>
|
|
internal static class MP3HeaderFrame
|
|
{
|
|
/// <summary>Bytes searched for the first frame sync after the ID3v2 tags.</summary>
|
|
private const int SyncSearchBytes = 64 * 1024;
|
|
|
|
/// <summary>
|
|
/// Frames of silence MP3Sharp outputs for the header frame of <paramref name="path"/>:
|
|
/// the samples per frame of the first frame when it is a Xing/Info/VBRI header frame, otherwise 0.
|
|
/// </summary>
|
|
public static int SilentFrames(string path)
|
|
{
|
|
// The game keeps the song open while it streams it (MP3Sharp.MP3Stream: new FileStream(path, FileMode.Open),
|
|
// read/write access with read sharing), so the file is opened with read/write/delete sharing.
|
|
using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
|
return SilentFrames(file);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <see cref="SilentFrames(string)"/> for a seekable stream positioned anywhere (the stream is read from offset 0).
|
|
/// Only the first valid frame header within <see cref="SyncSearchBytes"/> after the ID3v2 tags is examined.
|
|
/// </summary>
|
|
public static int SilentFrames(Stream stream)
|
|
{
|
|
// Skip consecutive ID3v2 tags: 10-byte header + syncsafe size (+ 10-byte footer when flagged).
|
|
var tag = new byte[10];
|
|
long offset = 0;
|
|
while (true)
|
|
{
|
|
stream.Position = offset;
|
|
if (ReadFully(stream, tag, 10) < 10 || tag[0] != 'I' || tag[1] != 'D' || tag[2] != '3') break;
|
|
var size = (tag[6] & 0x7F) << 21 | (tag[7] & 0x7F) << 14 | (tag[8] & 0x7F) << 7 | (tag[9] & 0x7F);
|
|
offset += 10 + size + ((tag[5] & 0x10) != 0 ? 10 : 0);
|
|
}
|
|
|
|
stream.Position = offset;
|
|
var data = new byte[SyncSearchBytes];
|
|
var length = ReadFully(stream, data, data.Length);
|
|
for (var i = 0; i + 4 <= length; i++)
|
|
{
|
|
if (!TryParseHeader(data, i, out var samplesPerFrame, out var tagOffset)) continue;
|
|
|
|
// Xing/Info after the side information; VBRI at a fixed offset of 32 bytes after the 4-byte header.
|
|
if (HasTag(data, length, i + tagOffset, "Xing") || HasTag(data, length, i + tagOffset, "Info")
|
|
|| HasTag(data, length, i + 4 + 32, "VBRI"))
|
|
return samplesPerFrame;
|
|
return 0;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses the 4-byte frame header at <paramref name="i"/>. False for a missing sync or a reserved version, layer, bitrate
|
|
/// index (15) or sample rate index (3).
|
|
/// </summary>
|
|
/// <param name="data">Bytes to parse.</param>
|
|
/// <param name="i">Offset of the first header byte in <paramref name="data"/>.</param>
|
|
/// <param name="samplesPerFrame">Samples per channel in the frame (384, 576 or 1152).</param>
|
|
/// <param name="tagOffset">Offset of a Xing/Info tag from the header start: 4 + CRC (2) + Layer III side information (0 for Layers I/II).</param>
|
|
private static bool TryParseHeader(byte[] data, int i, out int samplesPerFrame, out int tagOffset)
|
|
{
|
|
samplesPerFrame = 0;
|
|
tagOffset = 0;
|
|
if (data[i] != 0xFF || (data[i + 1] & 0xE0) != 0xE0) return false;
|
|
|
|
var version = (data[i + 1] >> 3) & 0x3;
|
|
var layer = (data[i + 1] >> 1) & 0x3;
|
|
var hasCrc = (data[i + 1] & 0x1) == 0;
|
|
var bitrateIndex = (data[i + 2] >> 4) & 0xF;
|
|
var sampleRateIndex = (data[i + 2] >> 2) & 0x3;
|
|
var mono = ((data[i + 3] >> 6) & 0x3) == 3;
|
|
if (version == 1 || layer == 0 || bitrateIndex == 0xF || sampleRateIndex == 3) return false;
|
|
|
|
var mpeg1 = version == 3;
|
|
samplesPerFrame = layer switch
|
|
{
|
|
3 => 384,
|
|
2 => 1152,
|
|
_ => mpeg1 ? 1152 : 576
|
|
};
|
|
var sideInfo = layer == 1 ? (mpeg1 ? (mono ? 17 : 32) : (mono ? 9 : 17)) : 0;
|
|
tagOffset = 4 + (hasCrc ? 2 : 0) + sideInfo;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>True when the ASCII string <paramref name="tag"/> is at <paramref name="at"/> within the first <paramref name="length"/> bytes.</summary>
|
|
private static bool HasTag(byte[] data, int length, int at, string tag)
|
|
{
|
|
if (at < 0 || at + tag.Length > length) return false;
|
|
for (var k = 0; k < tag.Length; k++)
|
|
if (data[at + k] != tag[k]) return false;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Reads until <paramref name="count"/> bytes or the end of the stream; returns the bytes read.</summary>
|
|
private static int ReadFully(Stream stream, byte[] buffer, int count)
|
|
{
|
|
var total = 0;
|
|
int read;
|
|
while (total < count && (read = stream.Read(buffer, total, count - total)) > 0) total += read;
|
|
return total;
|
|
}
|
|
}
|