- Add Book.IsSpatial property and add it to search index - Read audio format of actual output files and store it in UserDefinedItem. Now works with MP3s. - Store last downloaded audio file version - Add IsSpatial, file version, and Audio Format to library exports and to template tags. Updated docs. - Add last downloaded audio file version and format info to the Last Downloaded tab - Migrated the DB - Update AAXClean with some bug fixes - Fixed error converting xHE-AAC audio files to mp3 when splitting by chapter (or trimming the audible branding from the beginning of the file) - Improve mp3 ID# tags support. Chapter titles are now preserved. - Add support for reading EC-3 and AC-4 audio format metadata
243 lines
6.8 KiB
C#
243 lines
6.8 KiB
C#
using AAXClean;
|
|
using DataLayer;
|
|
using FileManager;
|
|
using Mpeg4Lib.Boxes;
|
|
using Mpeg4Lib.Util;
|
|
using NAudio.Lame.ID3;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
|
|
#nullable enable
|
|
namespace AaxDecrypter;
|
|
|
|
/// <summary> Read audio codec, bitrate, sample rate, and channel count from MP4 and MP3 audio files. </summary>
|
|
internal static class AudioFormatDecoder
|
|
{
|
|
public static AudioFormat FromMpeg4(string filename)
|
|
{
|
|
using var fileStream = File.Open(filename, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
return FromMpeg4(new Mp4File(fileStream));
|
|
}
|
|
|
|
public static AudioFormat FromMpeg4(Mp4File mp4File)
|
|
{
|
|
Codec codec;
|
|
if (mp4File.AudioSampleEntry.Dac4 is not null)
|
|
{
|
|
codec = Codec.AC_4;
|
|
}
|
|
else if (mp4File.AudioSampleEntry.Dec3 is not null)
|
|
{
|
|
codec = Codec.EC_3;
|
|
}
|
|
else if (mp4File.AudioSampleEntry.Esds is EsdsBox esds)
|
|
{
|
|
var objectType = esds.ES_Descriptor.DecoderConfig.AudioSpecificConfig.AudioObjectType;
|
|
codec
|
|
= objectType == 2 ? Codec.AAC_LC
|
|
: objectType == 42 ? Codec.xHE_AAC
|
|
: Codec.Unknown;
|
|
}
|
|
else
|
|
return AudioFormat.Default;
|
|
|
|
var bitrate = (int)Math.Round(mp4File.AverageBitrate / 1024d);
|
|
|
|
return new AudioFormat(codec, bitrate, mp4File.TimeScale, mp4File.AudioChannels);
|
|
}
|
|
|
|
public static AudioFormat FromMpeg3(LongPath mp3Filename)
|
|
{
|
|
using var mp3File = File.Open(mp3Filename, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
if (Id3Header.Create(mp3File) is Id3Header id3header)
|
|
id3header.SeekForwardToPosition(mp3File, mp3File.Position + id3header.Size);
|
|
else
|
|
{
|
|
Serilog.Log.Logger.Debug("File appears not to have ID3 tags.");
|
|
mp3File.Position = 0;
|
|
}
|
|
|
|
if (!SeekToFirstKeyFrame(mp3File))
|
|
{
|
|
Serilog.Log.Logger.Warning("Invalid frame sync read from file at end of ID3 tag.");
|
|
return AudioFormat.Default;
|
|
}
|
|
|
|
var mpegSize = mp3File.Length - mp3File.Position;
|
|
if (mpegSize < 64)
|
|
{
|
|
Serilog.Log.Logger.Warning("Remaining file length is too short to contain any mp3 frames. {@File}", mp3Filename);
|
|
return AudioFormat.Default;
|
|
}
|
|
|
|
#region read first mp3 frame header
|
|
//https://www.codeproject.com/Articles/8295/MPEG-Audio-Frame-Header#VBRIHeader
|
|
var reader = new BitReader(mp3File.ReadBlock(4));
|
|
reader.Position = 11; //Skip frame header magic bits
|
|
var versionId = (Version)reader.Read(2);
|
|
var layerDesc = (Layer)reader.Read(2);
|
|
|
|
if (layerDesc is not Layer.Layer_3)
|
|
{
|
|
Serilog.Log.Logger.Warning("Could not read mp3 data from {@layerVersion} file.", layerDesc.ToString());
|
|
return AudioFormat.Default;
|
|
}
|
|
|
|
if (versionId is Version.Reserved)
|
|
{
|
|
Serilog.Log.Logger.Warning("Mp3 data data cannot be read from a file with version = 'Reserved'");
|
|
return AudioFormat.Default;
|
|
}
|
|
|
|
var protectionBit = reader.ReadBool();
|
|
var bitrateIndex = reader.Read(4);
|
|
var freqIndex = reader.Read(2);
|
|
_ = reader.ReadBool(); //Padding bit
|
|
_ = reader.ReadBool(); //Private bit
|
|
var channelMode = reader.Read(2);
|
|
_ = reader.Read(2); //Mode extension
|
|
_ = reader.ReadBool(); //Copyright
|
|
_ = reader.ReadBool(); //Original
|
|
_ = reader.Read(2); //Emphasis
|
|
#endregion
|
|
|
|
//Read the sample rate,and channels from the first frame's header.
|
|
var sampleRate = Mp3SampleRateIndex[versionId][freqIndex];
|
|
var channelCount = channelMode == 3 ? 1 : 2;
|
|
|
|
//Try to read variable bitrate info from the first frame.
|
|
//Revert to fixed bitrate from frame header if not found.
|
|
var bitrate
|
|
= TryReadXingBitrate(out var br) ? br
|
|
: TryReadVbriBitrate(out br) ? br
|
|
: Mp3BitrateIndex[versionId][bitrateIndex];
|
|
|
|
return new AudioFormat(Codec.Mp3, bitrate, sampleRate, channelCount);
|
|
|
|
#region Variable bitrate header readers
|
|
bool TryReadXingBitrate(out int bitrate)
|
|
{
|
|
const int XingHeader = 0x58696e67;
|
|
const int InfoHeader = 0x496e666f;
|
|
|
|
var sideInfoSize = GetSideInfo(channelCount == 2, versionId) + (protectionBit ? 0 : 2);
|
|
mp3File.Position += sideInfoSize;
|
|
|
|
if (mp3File.ReadUInt32BE() is XingHeader or InfoHeader)
|
|
{
|
|
//Xing or Info header (common)
|
|
var flags = mp3File.ReadUInt32BE();
|
|
bool hasFramesField = (flags & 1) == 1;
|
|
bool hasBytesField = (flags & 2) == 2;
|
|
|
|
if (hasFramesField)
|
|
{
|
|
var numFrames = mp3File.ReadUInt32BE();
|
|
if (hasBytesField)
|
|
{
|
|
mpegSize = mp3File.ReadUInt32BE();
|
|
}
|
|
|
|
var samplesPerFrame = GetSamplesPerFrame(sampleRate);
|
|
var duration = samplesPerFrame * numFrames / sampleRate;
|
|
bitrate = (short)(mpegSize / duration / 1024 * 8);
|
|
return true;
|
|
}
|
|
}
|
|
else
|
|
mp3File.Position -= sideInfoSize + 4;
|
|
|
|
bitrate = 0;
|
|
return false;
|
|
}
|
|
|
|
bool TryReadVbriBitrate(out int bitrate)
|
|
{
|
|
const int VBRIHeader = 0x56425249;
|
|
|
|
mp3File.Position += 32;
|
|
|
|
if (mp3File.ReadUInt32BE() is VBRIHeader)
|
|
{
|
|
//VBRI header (rare)
|
|
_ = mp3File.ReadBlock(6);
|
|
mpegSize = mp3File.ReadUInt32BE();
|
|
var numFrames = mp3File.ReadUInt32BE();
|
|
|
|
var samplesPerFrame = GetSamplesPerFrame(sampleRate);
|
|
var duration = samplesPerFrame * numFrames / sampleRate;
|
|
bitrate = (short)(mpegSize / duration / 1024 * 8);
|
|
return true;
|
|
}
|
|
bitrate = 0;
|
|
return false;
|
|
}
|
|
#endregion
|
|
}
|
|
|
|
#region MP3 frame decoding helpers
|
|
private static bool SeekToFirstKeyFrame(Stream file)
|
|
{
|
|
//Frame headers begin with first 11 bits set.
|
|
const int MaxSeekBytes = 4096;
|
|
var maxPosition = Math.Min(file.Length, file.Position + MaxSeekBytes) - 2;
|
|
|
|
while (file.Position < maxPosition)
|
|
{
|
|
if (file.ReadByte() == 0xff)
|
|
{
|
|
if ((file.ReadByte() & 0xe0) == 0xe0)
|
|
{
|
|
file.Position -= 2;
|
|
return true;
|
|
}
|
|
file.Position--;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private enum Version
|
|
{
|
|
Version_2_5,
|
|
Reserved,
|
|
Version_2,
|
|
Version_1
|
|
}
|
|
|
|
private enum Layer
|
|
{
|
|
Reserved,
|
|
Layer_3,
|
|
Layer_2,
|
|
Layer_1
|
|
}
|
|
|
|
private static double GetSamplesPerFrame(int sampleRate) => sampleRate >= 32000 ? 1152 : 576;
|
|
|
|
private static byte GetSideInfo(bool stereo, Version version) => (stereo, version) switch
|
|
{
|
|
(true, Version.Version_1) => 32,
|
|
(true, Version.Version_2 or Version.Version_2_5) => 17,
|
|
(false, Version.Version_1) => 17,
|
|
(false, Version.Version_2 or Version.Version_2_5) => 9,
|
|
_ => 0,
|
|
};
|
|
|
|
private static readonly Dictionary<Version, ushort[]> Mp3SampleRateIndex = new()
|
|
{
|
|
{ Version.Version_2_5, [11025, 12000, 8000] },
|
|
{ Version.Version_2, [22050, 24000, 16000] },
|
|
{ Version.Version_1, [44100, 48000, 32000] },
|
|
};
|
|
|
|
private static readonly Dictionary<Version, short[]> Mp3BitrateIndex = new()
|
|
{
|
|
{ Version.Version_2_5, [-1, 8,16,24,32,40,48,56, 64, 80, 96,112,128,144,160,-1]},
|
|
{ Version.Version_2, [-1, 8,16,24,32,40,48,56, 64, 80, 96,112,128,144,160,-1]},
|
|
{ Version.Version_1, [-1,32,40,48,56,64,80,96,112,128,160,192,224,256,320,-1]}
|
|
};
|
|
#endregion
|
|
}
|