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:
daniel-c-harvey
2026-06-11 05:49:17 -04:00
parent f8186fb7c7
commit 3bb8104967
8 changed files with 725 additions and 30 deletions
+215
View File
@@ -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