feat(audio): add MP3 and FLAC upload support via format-routed processors
AudioProcessorRouter dispatches by extension; vault stores original bytes with correct MIME type.
This commit is contained in:
@@ -159,8 +159,223 @@ public class AudioProcessorTests
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
|
||||
// -- MP3 ------------------------------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public async Task Mp3_CbrMetadata_ParsedCorrectly()
|
||||
{
|
||||
var path = await WriteAudioAsync(BuildMinimalMp3(bitrateKbps: 128, sampleRate: 44100, stereo: true), ".mp3");
|
||||
|
||||
var audio = await new Mp3AudioProcessor().ProcessMp3FileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
Assert.That(audio!.Extension, Is.EqualTo(".mp3"));
|
||||
Assert.That(audio.Duration, Is.GreaterThan(0.0));
|
||||
Assert.That(audio.Bitrate, Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Mp3_VbrWithXingHeader_DurationFromXing()
|
||||
{
|
||||
const int frameCount = 1000;
|
||||
const int sampleRate = 44100;
|
||||
var path = await WriteAudioAsync(
|
||||
BuildMinimalMp3(bitrateKbps: 128, sampleRate: sampleRate, stereo: true, addXingHeader: true, xingFrameCount: frameCount),
|
||||
".mp3");
|
||||
|
||||
var audio = await new Mp3AudioProcessor().ProcessMp3FileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
var expected = (double)frameCount * 1152 / sampleRate;
|
||||
Assert.That(audio!.Duration, Is.EqualTo(expected).Within(0.001), "VBR duration must come from the Xing frame count");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Mp3_InvalidFile_FallsBackToDefaults()
|
||||
{
|
||||
// A standard PCM WAV has no MPEG frame sync — the parser must fall back to defaults.
|
||||
var path = await WriteAudioAsync(BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatPcm), ".mp3");
|
||||
|
||||
var audio = await new Mp3AudioProcessor().ProcessMp3FileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
Assert.That(audio!.Duration, Is.EqualTo(180.0), "Unparseable MP3 must fall back to default duration");
|
||||
}
|
||||
|
||||
// -- FLAC -----------------------------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public async Task Flac_StreaminfoMetadata_ParsedCorrectly()
|
||||
{
|
||||
var path = await WriteAudioAsync(
|
||||
BuildMinimalFlac(sampleRate: 44100, channels: 2, bitsPerSample: 24, totalSamples: 441000),
|
||||
".flac");
|
||||
|
||||
var audio = await new FlacAudioProcessor().ProcessFlacFileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
Assert.That(audio!.Extension, Is.EqualTo(".flac"));
|
||||
Assert.That(audio.Duration, Is.EqualTo(10.0).Within(0.01), "441000 samples / 44100 Hz = 10 s");
|
||||
Assert.That(audio.Bitrate, Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Flac_InvalidFile_FallsBackToDefaults()
|
||||
{
|
||||
// A standard PCM WAV lacks the fLaC magic — the parser must fall back to defaults.
|
||||
var path = await WriteAudioAsync(BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatPcm), ".flac");
|
||||
|
||||
var audio = await new FlacAudioProcessor().ProcessFlacFileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
Assert.That(audio!.Duration, Is.EqualTo(180.0), "Unparseable FLAC must fall back to default duration");
|
||||
}
|
||||
|
||||
// -- Router ---------------------------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public async Task Router_DispatchesByExtension()
|
||||
{
|
||||
var router = new AudioProcessorRouter(new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor());
|
||||
|
||||
var wavPath = await WriteAudioAsync(BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatPcm), ".wav");
|
||||
var mp3Path = await WriteAudioAsync(BuildMinimalMp3(), ".mp3");
|
||||
var flacPath = await WriteAudioAsync(BuildMinimalFlac(), ".flac");
|
||||
|
||||
var wav = await router.ProcessAudioFileAsync(wavPath);
|
||||
var mp3 = await router.ProcessAudioFileAsync(mp3Path);
|
||||
var flac = await router.ProcessAudioFileAsync(flacPath);
|
||||
|
||||
Assert.That(wav, Is.Not.Null);
|
||||
Assert.That(wav!.Extension, Is.EqualTo(".wav"));
|
||||
Assert.That(mp3, Is.Not.Null);
|
||||
Assert.That(mp3!.Extension, Is.EqualTo(".mp3"));
|
||||
Assert.That(flac, Is.Not.Null);
|
||||
Assert.That(flac!.Extension, Is.EqualTo(".flac"));
|
||||
}
|
||||
|
||||
// -- helpers --------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Synthesises a minimal valid MPEG1 Layer III CBR MP3 buffer: one frame header plus enough body
|
||||
/// bytes for the frame, with an optional Xing VBR header in the side-information region. The body
|
||||
/// is zero-filled (silence) — only the header and any Xing tag are meaningful to the parser.
|
||||
/// </summary>
|
||||
private static byte[] BuildMinimalMp3(
|
||||
int bitrateKbps = 128,
|
||||
int sampleRate = 44100,
|
||||
bool stereo = true,
|
||||
bool addXingHeader = false,
|
||||
int xingFrameCount = 0)
|
||||
{
|
||||
// MPEG1 Layer III bitrate index for the kbps table [0,32,40,48,56,64,80,96,112,128,160,192,224,256,320].
|
||||
var bitrateIndex = Array.IndexOf(
|
||||
new[] { 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320 }, bitrateKbps);
|
||||
var sampleRateIndex = Array.IndexOf(new[] { 44100, 48000, 32000 }, sampleRate);
|
||||
|
||||
// Byte 1: 1111 1011 = sync(111) version(11=MPEG1) layer(01=Layer III) protection(1=no CRC).
|
||||
const byte b1 = 0xFB;
|
||||
// Byte 2: bitrate index (4) | sample-rate index (2) | padding (1=0) | private (1=0).
|
||||
var b2 = (byte)((bitrateIndex << 4) | (sampleRateIndex << 2));
|
||||
// Byte 3: channel mode (2) | mode ext (2) | copyright (1) | original (1) | emphasis (2).
|
||||
// 00 = stereo, 11 = mono.
|
||||
var b3 = (byte)(stereo ? 0x00 : 0xC0);
|
||||
|
||||
var frameSize = (int)Math.Floor(144.0 * (bitrateKbps * 1000) / sampleRate);
|
||||
|
||||
// Side-info size for MPEG1: 32 bytes stereo, 17 bytes mono. Xing tag sits just past it.
|
||||
var sideInfoSize = stereo ? 32 : 17;
|
||||
var bufferSize = Math.Max(frameSize, 4 + sideInfoSize + 12);
|
||||
var buffer = new byte[bufferSize];
|
||||
|
||||
buffer[0] = 0xFF;
|
||||
buffer[1] = b1;
|
||||
buffer[2] = b2;
|
||||
buffer[3] = b3;
|
||||
|
||||
if (addXingHeader)
|
||||
{
|
||||
var tagPos = 4 + sideInfoSize;
|
||||
buffer[tagPos] = (byte)'X';
|
||||
buffer[tagPos + 1] = (byte)'i';
|
||||
buffer[tagPos + 2] = (byte)'n';
|
||||
buffer[tagPos + 3] = (byte)'g';
|
||||
// Flags: bit 0 set = frame count present.
|
||||
buffer[tagPos + 7] = 0x01;
|
||||
// Frame count: big-endian uint32 at tag offset 8.
|
||||
buffer[tagPos + 8] = (byte)((xingFrameCount >> 24) & 0xFF);
|
||||
buffer[tagPos + 9] = (byte)((xingFrameCount >> 16) & 0xFF);
|
||||
buffer[tagPos + 10] = (byte)((xingFrameCount >> 8) & 0xFF);
|
||||
buffer[tagPos + 11] = (byte)(xingFrameCount & 0xFF);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synthesises a minimal FLAC buffer: <c>fLaC</c> magic, a STREAMINFO metadata block header, a
|
||||
/// 34-byte STREAMINFO body carrying the sample rate, channel count, bit depth, and total sample
|
||||
/// count, plus <paramref name="audioDataBytes"/> of trailing zero bytes standing in for the encoded
|
||||
/// audio frames. The trailing bytes only affect the average-bitrate computation (file size); the
|
||||
/// parser ignores their content. Other STREAMINFO fields are left zero — the parser ignores them.
|
||||
/// </summary>
|
||||
private static byte[] BuildMinimalFlac(
|
||||
int sampleRate = 44100,
|
||||
int channels = 2,
|
||||
int bitsPerSample = 24,
|
||||
long totalSamples = 441000,
|
||||
int audioDataBytes = 256_000)
|
||||
{
|
||||
var buffer = new byte[4 + 4 + 34 + audioDataBytes];
|
||||
|
||||
// Magic.
|
||||
buffer[0] = (byte)'f';
|
||||
buffer[1] = (byte)'L';
|
||||
buffer[2] = (byte)'a';
|
||||
buffer[3] = (byte)'C';
|
||||
|
||||
// Metadata block header: byte 0 = is_last(1) | block type(7=0 STREAMINFO); bytes 1-3 = length 34.
|
||||
buffer[4] = 0x80; // last block, type 0
|
||||
buffer[5] = 0x00;
|
||||
buffer[6] = 0x00;
|
||||
buffer[7] = 34;
|
||||
|
||||
// STREAMINFO body begins at offset 8. We only set the bit-packed fields the parser reads:
|
||||
// bytes 10-12 + top nibble of 13: sample rate (20 bits)
|
||||
// bits 3-1 of byte 12: channels - 1
|
||||
// bit 0 of byte 12 + top nibble of 13: bits per sample - 1
|
||||
// low nibble of byte 13 + bytes 14-17: total samples (36 bits)
|
||||
var d = 8;
|
||||
|
||||
// 20-bit sample rate split across bytes 10, 11, and the top nibble of 12.
|
||||
buffer[d + 10] = (byte)((sampleRate >> 12) & 0xFF);
|
||||
buffer[d + 11] = (byte)((sampleRate >> 4) & 0xFF);
|
||||
|
||||
var bps = bitsPerSample - 1; // 5 bits: bit 0 of byte 12 + top 4 bits of byte 13
|
||||
var ch = channels - 1; // 3 bits: bits 3-1 of byte 12
|
||||
|
||||
// Byte 12: [sampleRate low nibble (4)] [channels-1 (3)] [bps-1 high bit (1)].
|
||||
buffer[d + 12] = (byte)(((sampleRate & 0x0F) << 4) | ((ch & 0x07) << 1) | ((bps >> 4) & 0x01));
|
||||
|
||||
// Byte 13: [bps-1 low 4 bits (4)] [total samples bits 35-32 (4)].
|
||||
buffer[d + 13] = (byte)(((bps & 0x0F) << 4) | (int)((totalSamples >> 32) & 0x0F));
|
||||
|
||||
// Bytes 14-17: total samples low 32 bits, big-endian.
|
||||
buffer[d + 14] = (byte)((totalSamples >> 24) & 0xFF);
|
||||
buffer[d + 15] = (byte)((totalSamples >> 16) & 0xFF);
|
||||
buffer[d + 16] = (byte)((totalSamples >> 8) & 0xFF);
|
||||
buffer[d + 17] = (byte)(totalSamples & 0xFF);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private async Task<string> WriteAudioAsync(byte[] bytes, string extension)
|
||||
{
|
||||
var path = Path.Combine(_testDir, Guid.NewGuid() + extension);
|
||||
await File.WriteAllBytesAsync(path, bytes);
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synthesises a minimal valid WAV buffer. For EXTENSIBLE (audioFormat=0xFFFE) the fmt chunk is
|
||||
/// 40 bytes and includes cbSize, wValidBitsPerSample, channel mask, and the SubFormat GUID. For
|
||||
|
||||
Reference in New Issue
Block a user