Merge branch 'p1.2-w1-t1-format-processors' into dev
# Conflicts: # DeepDrftAPI/Controllers/TrackController.cs
This commit is contained in:
@@ -167,11 +167,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.
|
||||
@@ -180,7 +181,7 @@ public class TrackController : ControllerBase
|
||||
[RequestSizeLimit(1_073_741_824)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)]
|
||||
public async Task<ActionResult<DeepDrftModels.DTOs.TrackDto>> UploadTrack(
|
||||
[FromForm] IFormFile? wav,
|
||||
[FromForm] IFormFile? audioFile,
|
||||
[FromForm] string? trackName,
|
||||
[FromForm] string? artist,
|
||||
[FromForm] string? album,
|
||||
@@ -193,11 +194,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))
|
||||
@@ -210,9 +211,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;
|
||||
@@ -241,16 +243,17 @@ public class TrackController : ControllerBase
|
||||
}
|
||||
var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1;
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -270,7 +273,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);
|
||||
}
|
||||
|
||||
@@ -38,9 +38,10 @@ public class UnifiedTrackService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<ResultContainer<TrackDto>> UploadAsync(
|
||||
string tempFilePath,
|
||||
@@ -55,7 +56,7 @@ public class UnifiedTrackService
|
||||
int trackNumber,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var unpersisted = await _contentTrackContentService.AddTrackFromWavAsync(
|
||||
var unpersisted = await _contentTrackContentService.AddTrackAsync(
|
||||
tempFilePath, trackName, artist, album, genre, releaseDate, originalFileName: originalFileName);
|
||||
|
||||
if (unpersisted is null)
|
||||
|
||||
@@ -15,6 +15,9 @@ namespace DeepDrftAPI
|
||||
{
|
||||
// Audio services
|
||||
builder.Services.AddSingleton<AudioProcessor>();
|
||||
builder.Services.AddSingleton<Mp3AudioProcessor>();
|
||||
builder.Services.AddSingleton<FlacAudioProcessor>();
|
||||
builder.Services.AddSingleton<AudioProcessorRouter>();
|
||||
builder.Services.AddSingleton<TrackContentService>();
|
||||
|
||||
// Image services
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches an audio file to the correct format processor by extension. The single seam through
|
||||
/// which <see cref="TrackContentService"/> processes uploads, so callers depend on one abstraction
|
||||
/// rather than three concrete processors.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes <paramref name="filePath"/> with the processor matching its extension, returning an
|
||||
/// <see cref="AudioBinary"/> carrying the stored bytes and extracted metadata. Throws
|
||||
/// <see cref="ArgumentException"/> for unsupported extensions.
|
||||
/// </summary>
|
||||
public async Task<AudioBinary?> 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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts metadata from a FLAC file and wraps its <b>unmodified</b> bytes in an
|
||||
/// <see cref="AudioBinary"/> tagged <c>.flac</c>. No transcoding — the vault stores the original
|
||||
/// stream; duration and average bitrate come from the mandatory STREAMINFO metadata block.
|
||||
/// </summary>
|
||||
public class FlacAudioProcessor
|
||||
{
|
||||
private const double FallbackDuration = 180.0;
|
||||
private const int FallbackBitrate = 1411;
|
||||
|
||||
public async Task<AudioBinary?> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the <c>fLaC</c> 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts metadata from an MP3 file and wraps its <b>unmodified</b> bytes in an
|
||||
/// <see cref="AudioBinary"/> tagged <c>.mp3</c>. 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).
|
||||
/// </summary>
|
||||
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];
|
||||
|
||||
// MPEG2/2.5 Layer III bitrate table (kbps), indexed by 4-bit bitrate index. 0 = free, 15 = bad.
|
||||
private static readonly int[] Mpeg2Layer3Bitrates =
|
||||
[0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160];
|
||||
|
||||
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<AudioBinary?> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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 bitrateTable = version == MpegVersion.Mpeg1 ? Mpeg1Layer3Bitrates : Mpeg2Layer3Bitrates;
|
||||
var bitrateKbps = bitrateTable[bitrateIndex];
|
||||
|
||||
var sampleRateIndex = (b2 >> 2) & 0x03;
|
||||
var sampleRate = version switch
|
||||
{
|
||||
MpegVersion.Mpeg1 => Mpeg1SampleRates[sampleRateIndex],
|
||||
MpegVersion.Mpeg2 => Mpeg2SampleRates[sampleRateIndex],
|
||||
_ => Mpeg25SampleRates[sampleRateIndex],
|
||||
};
|
||||
|
||||
var channelMode = (b3 >> 6) & 0x03;
|
||||
var channels = channelMode == 3 ? 1 : 2;
|
||||
var samplesPerFrame = version == MpegVersion.Mpeg1 ? 1152 : 576;
|
||||
|
||||
return new FrameHeader
|
||||
{
|
||||
Version = version,
|
||||
BitrateKbps = bitrateKbps,
|
||||
SampleRate = sampleRate,
|
||||
Channels = channels,
|
||||
SamplesPerFrame = samplesPerFrame,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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.
|
||||
// Exclude the ID3v2 tag bytes (everything before frameStart) from the estimate.
|
||||
var bytesPerSecond = header.BitrateKbps * 125;
|
||||
return bytesPerSecond > 0 ? (double)(buffer.Length - frameStart) / bytesPerSecond : FallbackDuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
private sealed class Mp3Metadata
|
||||
{
|
||||
public double Duration { get; init; }
|
||||
public int Bitrate { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="wavFilePath">Path to the WAV file</param>
|
||||
/// <param name="audioFilePath">Path to the audio file</param>
|
||||
/// <param name="trackName">Name of the track</param>
|
||||
/// <param name="artist">Artist name</param>
|
||||
/// <param name="album">Optional album name</param>
|
||||
@@ -31,8 +33,8 @@ public class TrackContentService
|
||||
/// <param name="releaseDate">Optional release date</param>
|
||||
/// <param name="originalFileName">Optional original browser filename captured at upload time</param>
|
||||
/// <returns>The track entity with generated ID and media path</returns>
|
||||
public async Task<TrackEntity?> AddTrackFromWavAsync(
|
||||
string wavFilePath,
|
||||
public async Task<TrackEntity?> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backward-compatible shim — delegates to <see cref="AddTrackAsync"/>. The router accepts WAV
|
||||
/// alongside MP3 and FLAC, so this carries no WAV-specific logic of its own.
|
||||
/// </summary>
|
||||
public Task<TrackEntity?> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves audio binary from FileDatabase
|
||||
/// </summary>
|
||||
|
||||
@@ -159,8 +159,232 @@ public class AudioProcessorTests
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
|
||||
// -- MP3 ------------------------------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public async Task Mp3_CbrMetadata_ParsedCorrectly()
|
||||
{
|
||||
// BuildMinimalMp3: bitrateKbps=128, sampleRate=44100, stereo=true, no Xing tag, no ID3 tag.
|
||||
// frameSize = floor(144 * 128000 / 44100) = 417 bytes; bufferSize = max(417, 48) = 417.
|
||||
// CBR duration = (bufferLength - frameStart) / (bitrateKbps * 125) = 417 / 16000 ≈ 0.0261 s.
|
||||
const int bitrateKbps = 128;
|
||||
const int sampleRate = 44100;
|
||||
var frameSize = (int)Math.Floor(144.0 * (bitrateKbps * 1000) / sampleRate); // 417
|
||||
var bufferSize = Math.Max(frameSize, 4 + 32 + 12); // max(417, 48) = 417
|
||||
var expectedDuration = (double)bufferSize / (bitrateKbps * 125); // frameStart = 0 (no ID3)
|
||||
|
||||
var path = await WriteAudioAsync(BuildMinimalMp3(bitrateKbps: bitrateKbps, sampleRate: sampleRate, 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.Bitrate, Is.EqualTo(bitrateKbps));
|
||||
Assert.That(audio.Duration, Is.EqualTo(expectedDuration).Within(0.01));
|
||||
}
|
||||
|
||||
[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