diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index 3b9617c..f1e7882 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -166,11 +166,12 @@ public class TrackController : ControllerBase return Ok(status); } - // POST api/track/upload: raw WAV in (multipart/form-data) + metadata → persisted TrackDto out. - // Used by the CMS upload flow on DeepDrftManager; that host proxies the upload here so it never - // touches the vault disk path or SQL directly. UnifiedTrackService owns the two-database write. + // POST api/track/upload: raw audio in (multipart/form-data) + metadata → persisted TrackDto out. + // Accepts .wav, .mp3, and .flac. Used by the CMS upload flow on DeepDrftManager; that host + // proxies the upload here so it never touches the vault disk path or SQL directly. + // UnifiedTrackService owns the two-database write. // - // RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: WAV uploads can be tens to hundreds + // RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: audio uploads can be tens to hundreds // of MB and the framework defaults (~28 MB) reject them outright. The IFormFile path streams // the body to a temp file once Kestrel surfaces it, so the limit is the per-request ceiling, // not a buffered allocation. @@ -179,7 +180,7 @@ public class TrackController : ControllerBase [RequestSizeLimit(1_073_741_824)] [RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)] public async Task> UploadTrack( - [FromForm] IFormFile? wav, + [FromForm] IFormFile? audioFile, [FromForm] string? trackName, [FromForm] string? artist, [FromForm] string? album, @@ -190,11 +191,11 @@ public class TrackController : ControllerBase CancellationToken cancellationToken) { _logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, fileName={FileName}, size={Size}", - trackName, artist, originalFileName, wav?.Length); + trackName, artist, originalFileName, audioFile?.Length); - if (wav is null || wav.Length == 0) + if (audioFile is null || audioFile.Length == 0) { - return BadRequest("WAV file is required"); + return BadRequest("Audio file is required"); } if (string.IsNullOrWhiteSpace(trackName)) @@ -207,9 +208,10 @@ public class TrackController : ControllerBase return BadRequest("artist is required"); } - if (!string.Equals(Path.GetExtension(wav.FileName), ".wav", StringComparison.OrdinalIgnoreCase)) + var uploadExtension = Path.GetExtension(audioFile.FileName).ToLowerInvariant(); + if (uploadExtension is not (".wav" or ".mp3" or ".flac")) { - return BadRequest("Uploaded file must have a .wav extension"); + return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension"); } DateOnly? parsedReleaseDate = null; @@ -222,16 +224,17 @@ public class TrackController : ControllerBase parsedReleaseDate = parsed; } - // AudioProcessor.ProcessWavFileAsync requires a path ending in .wav and reads from disk. - // Path.GetTempFileName() yields .tmp, which fails that check — generate our own .wav path. - var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".wav"); + // The processor router selects by extension and reads from disk, so the temp file must carry + // the upload's real extension. Path.GetTempFileName() yields .tmp, which the router rejects — + // generate our own path preserving the validated .wav/.mp3/.flac extension. + var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension); try { await using (var tempStream = new FileStream( tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: true)) - await using (var uploadStream = wav.OpenReadStream()) + await using (var uploadStream = audioFile.OpenReadStream()) { await uploadStream.CopyToAsync(tempStream, cancellationToken); } @@ -249,7 +252,7 @@ public class TrackController : ControllerBase if (!result.Success || result.Value is null) { - var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store WAV"; + var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store audio"; _logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error); return StatusCode(500, error); } diff --git a/DeepDrftAPI/Services/UnifiedTrackService.cs b/DeepDrftAPI/Services/UnifiedTrackService.cs index 796105c..5e4b3ac 100644 --- a/DeepDrftAPI/Services/UnifiedTrackService.cs +++ b/DeepDrftAPI/Services/UnifiedTrackService.cs @@ -37,9 +37,10 @@ public class UnifiedTrackService } /// - /// Process a WAV into the vault, then persist its metadata to SQL. On success the returned - /// DTO carries the SQL-assigned Id. If the vault write succeeds but the SQL persist fails, - /// the audio is orphaned under EntryKey — logged loudly so it is recoverable manually. + /// Process a supported audio file (.wav, .mp3, .flac) into the vault, then persist its metadata + /// to SQL. On success the returned DTO carries the SQL-assigned Id. If the vault write succeeds + /// but the SQL persist fails, the audio is orphaned under EntryKey — logged loudly so it is + /// recoverable manually. /// public async Task> UploadAsync( string tempFilePath, @@ -52,7 +53,7 @@ public class UnifiedTrackService string? originalFileName, CancellationToken ct) { - var unpersisted = await _contentTrackContentService.AddTrackFromWavAsync( + var unpersisted = await _contentTrackContentService.AddTrackAsync( tempFilePath, trackName, artist, album, genre, releaseDate, originalFileName: originalFileName); if (unpersisted is null) diff --git a/DeepDrftAPI/Startup.cs b/DeepDrftAPI/Startup.cs index 852f062..11f11b0 100644 --- a/DeepDrftAPI/Startup.cs +++ b/DeepDrftAPI/Startup.cs @@ -15,6 +15,9 @@ namespace DeepDrftAPI { // Audio services builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Image services diff --git a/DeepDrftContent/Processors/AudioProcessorRouter.cs b/DeepDrftContent/Processors/AudioProcessorRouter.cs new file mode 100644 index 0000000..e1242a7 --- /dev/null +++ b/DeepDrftContent/Processors/AudioProcessorRouter.cs @@ -0,0 +1,42 @@ +using DeepDrftContent.FileDatabase.Models; + +namespace DeepDrftContent.Processors; + +/// +/// Dispatches an audio file to the correct format processor by extension. The single seam through +/// which processes uploads, so callers depend on one abstraction +/// rather than three concrete processors. +/// +public class AudioProcessorRouter +{ + private readonly AudioProcessor _wavProcessor; + private readonly Mp3AudioProcessor _mp3Processor; + private readonly FlacAudioProcessor _flacProcessor; + + public AudioProcessorRouter( + AudioProcessor wavProcessor, + Mp3AudioProcessor mp3Processor, + FlacAudioProcessor flacProcessor) + { + _wavProcessor = wavProcessor; + _mp3Processor = mp3Processor; + _flacProcessor = flacProcessor; + } + + /// + /// Processes with the processor matching its extension, returning an + /// carrying the stored bytes and extracted metadata. Throws + /// for unsupported extensions. + /// + public async Task ProcessAudioFileAsync(string filePath) + { + var ext = Path.GetExtension(filePath).ToLowerInvariant(); + return ext switch + { + ".wav" => await _wavProcessor.ProcessWavFileAsync(filePath), + ".mp3" => await _mp3Processor.ProcessMp3FileAsync(filePath), + ".flac" => await _flacProcessor.ProcessFlacFileAsync(filePath), + _ => throw new ArgumentException($"Unsupported audio format: {ext}", nameof(filePath)), + }; + } +} diff --git a/DeepDrftContent/Processors/FlacAudioProcessor.cs b/DeepDrftContent/Processors/FlacAudioProcessor.cs new file mode 100644 index 0000000..23ffe71 --- /dev/null +++ b/DeepDrftContent/Processors/FlacAudioProcessor.cs @@ -0,0 +1,104 @@ +using DeepDrftContent.FileDatabase.Models; + +namespace DeepDrftContent.Processors; + +/// +/// Extracts metadata from a FLAC file and wraps its unmodified bytes in an +/// tagged .flac. No transcoding — the vault stores the original +/// stream; duration and average bitrate come from the mandatory STREAMINFO metadata block. +/// +public class FlacAudioProcessor +{ + private const double FallbackDuration = 180.0; + private const int FallbackBitrate = 1411; + + public async Task ProcessFlacFileAsync(string filePath) + { + if (!File.Exists(filePath)) + { + throw new FileNotFoundException($"FLAC file not found: {filePath}"); + } + + if (!Path.GetExtension(filePath).Equals(".flac", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("File must be a FLAC file", nameof(filePath)); + } + + var buffer = await File.ReadAllBytesAsync(filePath); + var meta = ExtractFlacMetadata(buffer); + + var parameters = new AudioBinaryParams( + Buffer: buffer, + Size: buffer.Length, + Extension: ".flac", + Duration: meta.Duration, + Bitrate: meta.Bitrate); + + return new AudioBinary(parameters); + } + + /// + /// Validates the fLaC magic and the leading STREAMINFO block, then computes duration from + /// total-samples / sample-rate and average bitrate from file size. On any parse failure, logs a + /// warning and returns synthetic defaults — never throws. + /// + private static FlacMetadata ExtractFlacMetadata(byte[] buffer) + { + try + { + // Magic (4) + metadata block header (4) + STREAMINFO data (34) = 42 bytes minimum. + if (buffer.Length < 42) + { + throw new InvalidDataException("File too short for FLAC STREAMINFO"); + } + + if (buffer[0] != 'f' || buffer[1] != 'L' || buffer[2] != 'a' || buffer[3] != 'C') + { + throw new InvalidDataException("Invalid fLaC magic"); + } + + // Metadata block header at offset 4: bits 6-0 of byte 0 are the block type (0 = STREAMINFO). + var blockType = buffer[4] & 0x7F; + if (blockType != 0) + { + throw new InvalidDataException($"First metadata block is not STREAMINFO (type {blockType})"); + } + + // STREAMINFO data begins at offset 8. Layout (bit-packed, big-endian): + // 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 4 bits of byte 13: bits per sample - 1 + // low nibble of byte 13 + bytes 14-17: total samples (36 bits) + var d = 8; + var sampleRate = (buffer[d + 10] << 12) | (buffer[d + 11] << 4) | (buffer[d + 12] >> 4); + var totalSamples = ((long)(buffer[d + 13] & 0x0F) << 32) + | ((long)buffer[d + 14] << 24) + | ((long)buffer[d + 15] << 16) + | ((long)buffer[d + 16] << 8) + | buffer[d + 17]; + + if (sampleRate <= 0) + { + throw new InvalidDataException("Invalid FLAC sample rate"); + } + + var duration = (double)totalSamples / sampleRate; + var bitrate = duration > 0 + ? (int)(buffer.LongLength * 8L / (duration * 1000)) + : FallbackBitrate; + + return new FlacMetadata { Duration = duration, Bitrate = bitrate }; + } + catch (Exception ex) + { + Console.WriteLine($"Warning: FLAC parsing failed, using defaults: {ex.Message}"); + return new FlacMetadata { Duration = FallbackDuration, Bitrate = FallbackBitrate }; + } + } + + private sealed class FlacMetadata + { + public double Duration { get; init; } + public int Bitrate { get; init; } + } +} diff --git a/DeepDrftContent/Processors/Mp3AudioProcessor.cs b/DeepDrftContent/Processors/Mp3AudioProcessor.cs new file mode 100644 index 0000000..d8f1296 --- /dev/null +++ b/DeepDrftContent/Processors/Mp3AudioProcessor.cs @@ -0,0 +1,311 @@ +using DeepDrftContent.FileDatabase.Models; + +namespace DeepDrftContent.Processors; + +/// +/// Extracts metadata from an MP3 file and wraps its unmodified bytes in an +/// tagged .mp3. No transcoding — the vault stores the original +/// stream; only duration/bitrate metadata are computed from the first MPEG frame header (plus a +/// Xing/VBRI tag when present for accurate VBR duration). +/// +public class Mp3AudioProcessor +{ + // MPEG1 Layer III bitrate table (kbps), indexed by the 4-bit bitrate index. 0 = free, 15 = bad. + private static readonly int[] Mpeg1Layer3Bitrates = + [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320]; + + private static readonly int[] Mpeg1SampleRates = [44100, 48000, 32000]; + private static readonly int[] Mpeg2SampleRates = [22050, 24000, 16000]; + private static readonly int[] Mpeg25SampleRates = [11025, 12000, 8000]; + + private const double FallbackDuration = 180.0; + private const int FallbackBitrate = 320; + + public async Task ProcessMp3FileAsync(string filePath) + { + if (!File.Exists(filePath)) + { + throw new FileNotFoundException($"MP3 file not found: {filePath}"); + } + + if (!Path.GetExtension(filePath).Equals(".mp3", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("File must be an MP3 file", nameof(filePath)); + } + + var buffer = await File.ReadAllBytesAsync(filePath); + var meta = ExtractMp3Metadata(buffer); + + var parameters = new AudioBinaryParams( + Buffer: buffer, + Size: buffer.Length, + Extension: ".mp3", + Duration: meta.Duration, + Bitrate: meta.Bitrate); + + return new AudioBinary(parameters); + } + + /// + /// Parses the first valid MPEG frame (after any ID3v2 tag) and any Xing/VBRI tag inside it. + /// On any parse failure, logs a warning and returns synthetic defaults — never throws. + /// + private static Mp3Metadata ExtractMp3Metadata(byte[] buffer) + { + try + { + var frameStart = FindFirstFrame(buffer); + if (frameStart < 0) + { + throw new InvalidDataException("No valid MPEG frame sync found"); + } + + var header = DecodeFrameHeader(buffer, frameStart); + var duration = ComputeDuration(buffer, frameStart, header); + + return new Mp3Metadata { Duration = duration, Bitrate = header.BitrateKbps }; + } + catch (Exception ex) + { + Console.WriteLine($"Warning: MP3 parsing failed, using defaults: {ex.Message}"); + return new Mp3Metadata { Duration = FallbackDuration, Bitrate = FallbackBitrate }; + } + } + + /// + /// Returns the offset of the first valid MPEG frame, skipping a leading ID3v2 tag if present. + /// Scans for a 0xFF / 0xE0-syncword pair and fully validates the 4-byte header before accepting. + /// + private static int FindFirstFrame(byte[] buffer) + { + var start = SkipId3v2(buffer); + + for (int i = start; i < buffer.Length - 4; i++) + { + if (buffer[i] != 0xFF || (buffer[i + 1] & 0xE0) != 0xE0) + { + continue; + } + + if (IsValidFrameHeader(buffer, i)) + { + return i; + } + } + + return -1; + } + + /// + /// Returns the byte offset just past an ID3v2 tag, or 0 if none. The tag size is a syncsafe + /// big-endian uint28 at bytes 6–9 (each byte's MSB is 0). A footer (flag bit 4 of byte 5) adds 10. + /// + private static int SkipId3v2(byte[] buffer) + { + if (buffer.Length < 10 || buffer[0] != 'I' || buffer[1] != 'D' || buffer[2] != '3') + { + return 0; + } + + var size = (buffer[6] << 21) | (buffer[7] << 14) | (buffer[8] << 7) | buffer[9]; + var skip = 10 + size; + if ((buffer[5] & 0x10) != 0) + { + skip += 10; // footer present + } + + return skip <= buffer.Length ? skip : 0; + } + + /// + /// Fully validates a candidate 4-byte frame header: layer must be III, and version, bitrate + /// index, and sample-rate index must all be non-reserved (rejects free bitrate, bad index 0xF, + /// and reserved sample rate 3). + /// + private static bool IsValidFrameHeader(byte[] buffer, int pos) + { + var b1 = buffer[pos + 1]; + var b2 = buffer[pos + 2]; + + var versionBits = (b1 >> 3) & 0x03; + if (versionBits == 1) // 1 = reserved + { + return false; + } + + var layerBits = (b1 >> 1) & 0x03; + if (layerBits != 1) // 1 = Layer III; this processor handles Layer III only + { + return false; + } + + var bitrateIndex = (b2 >> 4) & 0x0F; + if (bitrateIndex == 0 || bitrateIndex == 0x0F) // 0 = free, 0xF = bad + { + return false; + } + + var sampleRateIndex = (b2 >> 2) & 0x03; + if (sampleRateIndex == 3) // reserved + { + return false; + } + + return true; + } + + private static FrameHeader DecodeFrameHeader(byte[] buffer, int pos) + { + var b1 = buffer[pos + 1]; + var b2 = buffer[pos + 2]; + var b3 = buffer[pos + 3]; + + var versionBits = (b1 >> 3) & 0x03; + var version = versionBits switch + { + 3 => MpegVersion.Mpeg1, + 2 => MpegVersion.Mpeg2, + _ => MpegVersion.Mpeg25, // 0 = MPEG2.5 + }; + + var bitrateIndex = (b2 >> 4) & 0x0F; + var bitrateKbps = Mpeg1Layer3Bitrates[bitrateIndex]; + + var sampleRateIndex = (b2 >> 2) & 0x03; + var sampleRate = version switch + { + MpegVersion.Mpeg1 => Mpeg1SampleRates[sampleRateIndex], + MpegVersion.Mpeg2 => Mpeg2SampleRates[sampleRateIndex], + _ => Mpeg25SampleRates[sampleRateIndex], + }; + + var paddingBit = (b2 >> 1) & 0x01; + var channelMode = (b3 >> 6) & 0x03; + var channels = channelMode == 3 ? 1 : 2; + var samplesPerFrame = version == MpegVersion.Mpeg1 ? 1152 : 576; + + var frameSize = (int)Math.Floor(144.0 * (bitrateKbps * 1000) / sampleRate) + paddingBit; + + return new FrameHeader + { + Version = version, + BitrateKbps = bitrateKbps, + SampleRate = sampleRate, + Channels = channels, + SamplesPerFrame = samplesPerFrame, + FrameSize = frameSize, + }; + } + + /// + /// Computes duration from a Xing/Info or VBRI tag (accurate for VBR) when present; otherwise + /// falls back to the CBR estimate fileSize / (bitrate_kbps * 125). Guards divide-by-zero. + /// + private static double ComputeDuration(byte[] buffer, int frameStart, FrameHeader header) + { + var xingFrames = ReadXingFrameCount(buffer, frameStart, header); + if (xingFrames > 0 && header.SampleRate > 0) + { + return (double)xingFrames * header.SamplesPerFrame / header.SampleRate; + } + + var vbriFrames = ReadVbriFrameCount(buffer, frameStart); + if (vbriFrames > 0 && header.SampleRate > 0) + { + return (double)vbriFrames * header.SamplesPerFrame / header.SampleRate; + } + + // CBR fallback: bitrate_kbps * 1000 / 8 bytes per second = bitrate_kbps * 125. + var bytesPerSecond = header.BitrateKbps * 125; + return bytesPerSecond > 0 ? (double)buffer.Length / bytesPerSecond : FallbackDuration; + } + + /// + /// Reads the Xing/Info VBR total-frame count from the side-information region of the first frame, + /// or 0 if no Xing tag or no frame-count flag. Side-info offset depends on version and channels. + /// + private static int ReadXingFrameCount(byte[] buffer, int frameStart, FrameHeader header) + { + var sideInfoSize = header.Version == MpegVersion.Mpeg1 + ? (header.Channels == 1 ? 17 : 32) + : (header.Channels == 1 ? 9 : 17); + + var tagPos = frameStart + 4 + sideInfoSize; + if (tagPos + 12 > buffer.Length) + { + return 0; + } + + if (!MatchesAscii(buffer, tagPos, "Xing") && !MatchesAscii(buffer, tagPos, "Info")) + { + return 0; + } + + var flags = ReadUInt32BigEndian(buffer, tagPos + 4); + if ((flags & 0x01) == 0) // bit 0 = frame-count present + { + return 0; + } + + return (int)ReadUInt32BigEndian(buffer, tagPos + 8); + } + + /// + /// Reads the Fraunhofer VBRI total-frame count. The VBRI tag sits at a fixed offset 32 past the + /// frame header (frameStart + 4 + 32); the frame count is a big-endian uint32 at tag offset 14. + /// + private static int ReadVbriFrameCount(byte[] buffer, int frameStart) + { + var tagPos = frameStart + 4 + 32; + if (tagPos + 18 > buffer.Length) + { + return 0; + } + + if (!MatchesAscii(buffer, tagPos, "VBRI")) + { + return 0; + } + + return (int)ReadUInt32BigEndian(buffer, tagPos + 14); + } + + private static bool MatchesAscii(byte[] buffer, int pos, string tag) + { + for (int i = 0; i < tag.Length; i++) + { + if (buffer[pos + i] != (byte)tag[i]) + { + return false; + } + } + + return true; + } + + private static uint ReadUInt32BigEndian(byte[] buffer, int pos) => + ((uint)buffer[pos] << 24) | ((uint)buffer[pos + 1] << 16) | ((uint)buffer[pos + 2] << 8) | buffer[pos + 3]; + + private enum MpegVersion + { + Mpeg1, + Mpeg2, + Mpeg25, + } + + private sealed class FrameHeader + { + public MpegVersion Version { get; init; } + public int BitrateKbps { get; init; } + public int SampleRate { get; init; } + public int Channels { get; init; } + public int SamplesPerFrame { get; init; } + public int FrameSize { get; init; } + } + + private sealed class Mp3Metadata + { + public double Duration { get; init; } + public int Bitrate { get; init; } + } +} diff --git a/DeepDrftContent/TrackContentService.cs b/DeepDrftContent/TrackContentService.cs index 6eaf7a2..52d2dde 100644 --- a/DeepDrftContent/TrackContentService.cs +++ b/DeepDrftContent/TrackContentService.cs @@ -12,18 +12,20 @@ namespace DeepDrftContent; public class TrackContentService { private readonly FileDatabase.Services.FileDatabase _fileDatabase; - private readonly AudioProcessor _audioProcessor; + private readonly AudioProcessorRouter _audioProcessorRouter; - public TrackContentService(FileDatabase.Services.FileDatabase fileDatabase, AudioProcessor audioProcessor) + public TrackContentService(FileDatabase.Services.FileDatabase fileDatabase, AudioProcessorRouter audioProcessorRouter) { _fileDatabase = fileDatabase; - _audioProcessor = audioProcessor; + _audioProcessorRouter = audioProcessorRouter; } /// - /// Adds a new track from a WAV file to both databases + /// Adds a new track from a supported audio file (.wav, .mp3, .flac) to both databases. The + /// router selects the processor by extension; original bytes are stored for mp3/flac (no + /// transcoding), while EXTENSIBLE WAVs are normalized to standard PCM at storage time. /// - /// Path to the WAV file + /// Path to the audio file /// Name of the track /// Artist name /// Optional album name @@ -31,8 +33,8 @@ public class TrackContentService /// Optional release date /// Optional original browser filename captured at upload time /// The track entity with generated ID and media path - public async Task AddTrackFromWavAsync( - string wavFilePath, + public async Task AddTrackAsync( + string audioFilePath, string trackName, string artist, string? album = null, @@ -42,11 +44,11 @@ public class TrackContentService { try { - // Process the WAV file - var audioBinary = await _audioProcessor.ProcessWavFileAsync(wavFilePath); + // Process the audio file (routed by extension) + var audioBinary = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath); if (audioBinary == null) { - throw new InvalidOperationException("Failed to process WAV file"); + throw new InvalidOperationException("Failed to process audio file"); } // Generate a unique track ID @@ -81,11 +83,25 @@ public class TrackContentService } catch (Exception ex) when (ex is not OperationCanceledException) { - Console.WriteLine($"TrackContentService.AddTrackFromWavAsync failed: {ex.Message}"); + Console.WriteLine($"TrackContentService.AddTrackAsync failed: {ex.Message}"); return null; } } + /// + /// Backward-compatible shim — delegates to . The router accepts WAV + /// alongside MP3 and FLAC, so this carries no WAV-specific logic of its own. + /// + public Task AddTrackFromWavAsync( + string wavFilePath, + string trackName, + string artist, + string? album = null, + string? genre = null, + DateOnly? releaseDate = null, + string? originalFileName = null) => + AddTrackAsync(wavFilePath, trackName, artist, album, genre, releaseDate, originalFileName); + /// /// Retrieves audio binary from FileDatabase /// diff --git a/DeepDrftTests/AudioProcessorTests.cs b/DeepDrftTests/AudioProcessorTests.cs index 04cce45..9c66bed 100644 --- a/DeepDrftTests/AudioProcessorTests.cs +++ b/DeepDrftTests/AudioProcessorTests.cs @@ -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 -------------------------------------------------------------------------------- + /// + /// 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. + /// + 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; + } + + /// + /// Synthesises a minimal FLAC buffer: fLaC magic, a STREAMINFO metadata block header, a + /// 34-byte STREAMINFO body carrying the sample rate, channel count, bit depth, and total sample + /// count, plus 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. + /// + 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 WriteAudioAsync(byte[] bytes, string extension) + { + var path = Path.Combine(_testDir, Guid.NewGuid() + extension); + await File.WriteAllBytesAsync(path, bytes); + return path; + } + /// /// 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