From fa57861dbf4e72aec4c1e4b332506890f923914a Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 5 Jun 2026 16:38:02 -0400 Subject: [PATCH] Add server-side waveform loudness profiling on track upload ILoudnessAlgorithm strategy (RmsLoudnessAlgorithm first impl), WaveformProfileService stores quantized byte[] sidecar in new MediaFileVault (profiles vault), wired into UnifiedTrackService.UploadAsync; failure is logged and swallowed. WaveformProfileDto and WaveformProfileOptions in shared projects. --- DeepDrftAPI/Services/UnifiedTrackService.cs | 22 +++ DeepDrftAPI/Startup.cs | 6 + DeepDrftContent/Constants/VaultConstants.cs | 5 + DeepDrftContent/DeepDrftContent.csproj | 1 + .../FileDatabase/Services/MediaVault.cs | 26 ++++ .../Services/MediaVaultFactory.cs | 1 + .../Services/SimpleMediaTypeRegistry.cs | 3 +- DeepDrftContent/Processors/AudioProcessor.cs | 65 ++++++++- .../Processors/ILoudnessAlgorithm.cs | 23 +++ .../Processors/RmsLoudnessAlgorithm.cs | 138 ++++++++++++++++++ .../Processors/WaveformProfileOptions.cs | 11 ++ .../Processors/WaveformProfileService.cs | 123 ++++++++++++++++ DeepDrftModels/DTOs/WaveformProfileDto.cs | 14 ++ DeepDrftTests/MediaVaultFactoryTests.cs | 10 +- DeepDrftTests/RmsLoudnessAlgorithmTests.cs | 98 +++++++++++++ DeepDrftTests/SimpleMediaTypeRegistryTests.cs | 8 +- 16 files changed, 544 insertions(+), 10 deletions(-) create mode 100644 DeepDrftContent/Processors/ILoudnessAlgorithm.cs create mode 100644 DeepDrftContent/Processors/RmsLoudnessAlgorithm.cs create mode 100644 DeepDrftContent/Processors/WaveformProfileOptions.cs create mode 100644 DeepDrftContent/Processors/WaveformProfileService.cs create mode 100644 DeepDrftModels/DTOs/WaveformProfileDto.cs create mode 100644 DeepDrftTests/RmsLoudnessAlgorithmTests.cs diff --git a/DeepDrftAPI/Services/UnifiedTrackService.cs b/DeepDrftAPI/Services/UnifiedTrackService.cs index 260c9eb..6963100 100644 --- a/DeepDrftAPI/Services/UnifiedTrackService.cs +++ b/DeepDrftAPI/Services/UnifiedTrackService.cs @@ -1,5 +1,6 @@ using DeepDrftContent; using DeepDrftContent.Constants; +using DeepDrftContent.Processors; using DeepDrftData; using DeepDrftModels.DTOs; using NetBlocks.Models; @@ -18,17 +19,20 @@ public class UnifiedTrackService private readonly TrackContentService _contentTrackContentService; private readonly ITrackService _sqlTrackService; private readonly FileDb _fileDatabase; + private readonly WaveformProfileService _waveformProfileService; private readonly ILogger _logger; public UnifiedTrackService( TrackContentService contentTrackContentService, ITrackService sqlTrackService, FileDb fileDatabase, + WaveformProfileService waveformProfileService, ILogger logger) { _contentTrackContentService = contentTrackContentService; _sqlTrackService = sqlTrackService; _fileDatabase = fileDatabase; + _waveformProfileService = waveformProfileService; _logger = logger; } @@ -70,9 +74,27 @@ public class UnifiedTrackService return ResultContainer.CreateFailResult($"Track was uploaded but could not be saved: {error}"); } + // Best-effort waveform profile: both stores succeeded, so the upload is a success + // regardless of the profile outcome. A missing profile renders as a flat seekbar on the + // frontend, so a failure here is logged and swallowed — never fails the upload. + await TryStoreWaveformProfileAsync(tempFilePath, unpersisted.EntryKey, ct); + return saveResult; } + private async Task TryStoreWaveformProfileAsync(string tempFilePath, string entryKey, CancellationToken ct) + { + try + { + var wavBytes = await File.ReadAllBytesAsync(tempFilePath, ct); + await _waveformProfileService.ComputeAndStoreAsync(wavBytes, entryKey); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Waveform profile step failed for {EntryKey}; upload unaffected.", entryKey); + } + } + /// /// Delete a track's SQL row, then its vault entry. SQL is the source of truth: a SQL delete /// failure fails the operation (and leaves the vault untouched), but a subsequent vault delete diff --git a/DeepDrftAPI/Startup.cs b/DeepDrftAPI/Startup.cs index 00f657e..e05e64b 100644 --- a/DeepDrftAPI/Startup.cs +++ b/DeepDrftAPI/Startup.cs @@ -19,6 +19,12 @@ namespace DeepDrftAPI builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Waveform loudness profiling (upload-time, off the playback path) + builder.Services.Configure( + builder.Configuration.GetSection(nameof(WaveformProfileOptions))); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + // File Database var fileDatabasePath = CredentialTools.ResolvePathOrThrow("filedatabase", "environment/filedatabase.json"); builder.Configuration.AddJsonFile(fileDatabasePath, optional: false, reloadOnChange: false); diff --git a/DeepDrftContent/Constants/VaultConstants.cs b/DeepDrftContent/Constants/VaultConstants.cs index efd7596..cec5f88 100644 --- a/DeepDrftContent/Constants/VaultConstants.cs +++ b/DeepDrftContent/Constants/VaultConstants.cs @@ -9,4 +9,9 @@ public static class VaultConstants /// Vault name for storing audio tracks /// public const string Tracks = "tracks"; + + /// + /// Vault name for storing waveform loudness profile sidecars, keyed by track EntryKey. + /// + public const string WaveformProfiles = "waveform-profiles"; } \ No newline at end of file diff --git a/DeepDrftContent/DeepDrftContent.csproj b/DeepDrftContent/DeepDrftContent.csproj index f291083..51f7b68 100644 --- a/DeepDrftContent/DeepDrftContent.csproj +++ b/DeepDrftContent/DeepDrftContent.csproj @@ -12,6 +12,7 @@ + diff --git a/DeepDrftContent/FileDatabase/Services/MediaVault.cs b/DeepDrftContent/FileDatabase/Services/MediaVault.cs index b1bf0d6..2ccf294 100644 --- a/DeepDrftContent/FileDatabase/Services/MediaVault.cs +++ b/DeepDrftContent/FileDatabase/Services/MediaVault.cs @@ -219,6 +219,32 @@ public class AudioVault : MediaVault } } +/// +/// Concrete vault for plain entries (vault type +/// ) — bytes plus an extension, no audio/image-specific +/// metadata. Used for sidecar artifacts such as waveform loudness profiles. The base +/// already handles Media-typed storage via the registry; this only +/// provides the concrete factory the Image and Audio vaults also provide. +/// +public class MediaFileVault : MediaVault +{ + private MediaFileVault(string rootPath, VaultIndex index, IndexFactoryService? factoryService = null) + : base(rootPath, index, factoryService) { } + + public static async Task FromAsync(string rootPath, IndexFactoryService? factoryService = null) + { + var factory = factoryService ?? new IndexFactoryService(); + var index = await factory.LoadOrCreateVaultIndexAsync(rootPath, MediaVaultType.Media); + + if (index != null) + { + return new MediaFileVault(rootPath, (VaultIndex)index, factory); + } + + return null; + } +} + /// /// An open read-only stream over a vault entry plus the extension needed to /// resolve its MIME type. Caller owns the stream and must dispose it. diff --git a/DeepDrftContent/FileDatabase/Services/MediaVaultFactory.cs b/DeepDrftContent/FileDatabase/Services/MediaVaultFactory.cs index 66e41f1..eb352af 100644 --- a/DeepDrftContent/FileDatabase/Services/MediaVaultFactory.cs +++ b/DeepDrftContent/FileDatabase/Services/MediaVaultFactory.cs @@ -11,6 +11,7 @@ public static class MediaVaultFactory { return mediaType switch { + MediaVaultType.Media => await MediaFileVault.FromAsync(rootPath, factoryService), MediaVaultType.Image => await ImageVault.FromAsync(rootPath, factoryService), MediaVaultType.Audio => await AudioVault.FromAsync(rootPath, factoryService), _ => null diff --git a/DeepDrftContent/FileDatabase/Services/SimpleMediaTypeRegistry.cs b/DeepDrftContent/FileDatabase/Services/SimpleMediaTypeRegistry.cs index 585592f..515889d 100644 --- a/DeepDrftContent/FileDatabase/Services/SimpleMediaTypeRegistry.cs +++ b/DeepDrftContent/FileDatabase/Services/SimpleMediaTypeRegistry.cs @@ -31,7 +31,8 @@ public class SimpleMediaTypeRegistry : IMediaTypeRegistry dto => MediaBinary.From(dto), binary => new MediaBinaryDto(binary), (key, ext, _) => new MetaData(key, ext), - (binary, meta) => new MediaBinaryParams(binary.Buffer, binary.Size, meta.Extension)); + (binary, meta) => new MediaBinaryParams(binary.Buffer, binary.Size, meta.Extension), + async path => await MediaFileVault.FromAsync(path)); RegisterType( MediaVaultType.Image, diff --git a/DeepDrftContent/Processors/AudioProcessor.cs b/DeepDrftContent/Processors/AudioProcessor.cs index 179520f..af18f73 100644 --- a/DeepDrftContent/Processors/AudioProcessor.cs +++ b/DeepDrftContent/Processors/AudioProcessor.cs @@ -45,6 +45,55 @@ public class AudioProcessor } } + /// + /// Extracts the raw PCM data region and format parameters from a WAV buffer, reusing the + /// same chunk-walk and validation as metadata extraction. Returns null if the buffer is not + /// a valid PCM WAV (callers treat a null as "no profile computable" and continue) — unlike + /// , this does NOT fall back to synthetic defaults, because a + /// loudness profile over fabricated silence would be misleading. + /// + public PcmData? TryExtractPcm(ReadOnlySpan buffer) + { + // Copy the span to an array so the existing array-based parsers can be reused. The PCM + // slice returned is a view over this array (no second copy of the data region). + var bytes = buffer.ToArray(); + + var validation = ValidateWavStructure(bytes); + if (!validation.IsValid) + { + return null; + } + + WavMetadata metadata; + try + { + metadata = ParseWavMetadata(bytes, validation); + ValidateAudioParameters(metadata); + } + catch + { + return null; + } + + // Data bytes begin 8 past the "data" chunk id (4 id + 4 size). Clamp the declared size to + // what is actually present — some encoders write a size that overshoots the file. + var dataStart = validation.DataChunkPos + 8; + if (dataStart > bytes.Length) + { + return null; + } + + var available = bytes.Length - dataStart; + var dataLength = Math.Min(metadata.DataSize, available); + if (dataLength <= 0) + { + return null; + } + + var pcm = new ReadOnlyMemory(bytes, dataStart, dataLength); + return new PcmData(pcm, metadata.Channels, metadata.SampleRate, metadata.BitsPerSample); + } + /// /// Extracts metadata from WAV file buffer with comprehensive validation /// @@ -268,4 +317,18 @@ public class AudioProcessor public int FmtChunkPos { get; set; } public int DataChunkPos { get; set; } } -} \ No newline at end of file +} + +/// +/// The raw PCM sample region of a WAV plus the format parameters needed to interpret it. +/// is a view over the decoded buffer — the data chunk only, header excluded. +/// +/// The PCM sample bytes (interleaved by channel, little-endian). +/// Number of interleaved channels. +/// Samples per second. +/// Bit depth per sample (8, 16, 24, or 32). +public readonly record struct PcmData( + ReadOnlyMemory Pcm, + int Channels, + int SampleRate, + int BitsPerSample); \ No newline at end of file diff --git a/DeepDrftContent/Processors/ILoudnessAlgorithm.cs b/DeepDrftContent/Processors/ILoudnessAlgorithm.cs new file mode 100644 index 0000000..7b2de43 --- /dev/null +++ b/DeepDrftContent/Processors/ILoudnessAlgorithm.cs @@ -0,0 +1,23 @@ +namespace DeepDrftContent.Processors; + +/// +/// Strategy for reducing a stream of PCM samples to a fixed-length, peak-normalized loudness +/// envelope. Swappable so the loudness measure (RMS today, LUFS later) can change without +/// touching WaveformProfileService, the stored wire format, or the frontend renderer. +/// +public interface ILoudnessAlgorithm +{ + /// + /// Computes a peak-normalized loudness profile from raw interleaved PCM. + /// + /// Interleaved, little-endian PCM sample bytes (the WAV data chunk). + /// Number of interleaved channels; averaged to mono per sample. + /// Samples per second (unused by RMS but part of the contract for measures that need it). + /// Bit depth (8 unsigned, 16/24/32 signed) used to decode samples. + /// Number of equal time slices to reduce the signal to. + /// + /// A double[bucketCount], each value in [0, 1], peak-normalized so the loudest bucket + /// is 1. All zeros when the signal is silent (peak is 0) or no samples are present. + /// + double[] Compute(ReadOnlySpan pcmData, int channels, int sampleRate, int bitsPerSample, int bucketCount); +} diff --git a/DeepDrftContent/Processors/RmsLoudnessAlgorithm.cs b/DeepDrftContent/Processors/RmsLoudnessAlgorithm.cs new file mode 100644 index 0000000..7fd9696 --- /dev/null +++ b/DeepDrftContent/Processors/RmsLoudnessAlgorithm.cs @@ -0,0 +1,138 @@ +namespace DeepDrftContent.Processors; + +/// +/// Loudness via root-mean-square amplitude per time bucket. Decodes signed PCM (8-bit unsigned, +/// 16/24/32-bit signed little-endian), averages channels to mono, partitions the frames into +/// equal time slices, takes the RMS of each slice, then peak-normalizes so the loudest bucket is 1. +/// No external audio dependency — operates directly on the WAV data-chunk bytes. +/// +public class RmsLoudnessAlgorithm : ILoudnessAlgorithm +{ + public double[] Compute(ReadOnlySpan pcmData, int channels, int sampleRate, int bitsPerSample, int bucketCount) + { + if (bucketCount <= 0) + { + throw new ArgumentOutOfRangeException(nameof(bucketCount), "Bucket count must be positive."); + } + + var result = new double[bucketCount]; + + if (channels <= 0) + { + return result; + } + + var bytesPerSample = bitsPerSample / 8; + if (bytesPerSample <= 0) + { + return result; + } + + var bytesPerFrame = bytesPerSample * channels; + var frameCount = pcmData.Length / bytesPerFrame; + if (frameCount == 0) + { + return result; + } + + // Sum of squared mono amplitudes and the frame count, per bucket. A frame's bucket is + // determined by its position in the timeline so buckets are equal-duration slices. + var sumSquares = new double[bucketCount]; + var counts = new long[bucketCount]; + + for (var frame = 0; frame < frameCount; frame++) + { + var frameStart = frame * bytesPerFrame; + + double channelSum = 0; + for (var ch = 0; ch < channels; ch++) + { + var sampleStart = frameStart + ch * bytesPerSample; + channelSum += ReadSampleNormalized(pcmData, sampleStart, bitsPerSample); + } + + var mono = channelSum / channels; + + // long math avoids overflow on large files before the divide back into bucket index. + var bucket = (int)((long)frame * bucketCount / frameCount); + if (bucket >= bucketCount) + { + bucket = bucketCount - 1; + } + + sumSquares[bucket] += mono * mono; + counts[bucket]++; + } + + var peak = 0.0; + for (var i = 0; i < bucketCount; i++) + { + if (counts[i] > 0) + { + result[i] = Math.Sqrt(sumSquares[i] / counts[i]); + if (result[i] > peak) + { + peak = result[i]; + } + } + } + + if (peak <= 0) + { + // Silence — return all zeros (Array is already zero-initialized). + Array.Clear(result); + return result; + } + + for (var i = 0; i < bucketCount; i++) + { + result[i] /= peak; + } + + return result; + } + + /// + /// Decodes one PCM sample at to a normalized amplitude in [-1, 1]. + /// 8-bit is unsigned (0..255, centered at 128); 16/24/32-bit are signed little-endian. + /// + private static double ReadSampleNormalized(ReadOnlySpan data, int offset, int bitsPerSample) + { + switch (bitsPerSample) + { + case 8: + // Unsigned, midpoint 128. + return (data[offset] - 128) / 128.0; + + case 16: + { + short sample = (short)(data[offset] | (data[offset + 1] << 8)); + return sample / 32768.0; + } + + case 24: + { + // Sign-extend the 24-bit little-endian value into an int. + int raw = data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16); + if ((raw & 0x800000) != 0) + { + raw |= unchecked((int)0xFF000000); + } + return raw / 8388608.0; + } + + case 32: + { + int sample = data[offset] + | (data[offset + 1] << 8) + | (data[offset + 2] << 16) + | (data[offset + 3] << 24); + return sample / 2147483648.0; + } + + default: + throw new ArgumentOutOfRangeException( + nameof(bitsPerSample), bitsPerSample, "Unsupported PCM bit depth."); + } + } +} diff --git a/DeepDrftContent/Processors/WaveformProfileOptions.cs b/DeepDrftContent/Processors/WaveformProfileOptions.cs new file mode 100644 index 0000000..6f1f929 --- /dev/null +++ b/DeepDrftContent/Processors/WaveformProfileOptions.cs @@ -0,0 +1,11 @@ +namespace DeepDrftContent.Processors; + +/// +/// Configuration for waveform loudness profiling. is the stored +/// resolution — the number of loudness buckets computed and persisted per track, which is also +/// the bar count the frontend WaveformSeeker renders. +/// +public class WaveformProfileOptions +{ + public int BucketCount { get; set; } = 512; +} diff --git a/DeepDrftContent/Processors/WaveformProfileService.cs b/DeepDrftContent/Processors/WaveformProfileService.cs new file mode 100644 index 0000000..6c31e21 --- /dev/null +++ b/DeepDrftContent/Processors/WaveformProfileService.cs @@ -0,0 +1,123 @@ +using DeepDrftContent.Constants; +using DeepDrftContent.FileDatabase.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase; + +namespace DeepDrftContent.Processors; + +/// +/// Computes a track's waveform loudness profile from its WAV bytes and persists it as a sidecar +/// in the vault, keyed by the track's EntryKey. +/// The profile is the upload-time, off-the-playback-path representation the frontend fetches to +/// render the WaveformSeeker. The loudness measure is injected () +/// so it can be swapped without changing storage or the wire format. +/// +public class WaveformProfileService +{ + private const string ProfileExtension = ".wfp"; + + private readonly FileDb _fileDatabase; + private readonly AudioProcessor _audioProcessor; + private readonly ILoudnessAlgorithm _loudnessAlgorithm; + private readonly WaveformProfileOptions _options; + private readonly ILogger _logger; + + public WaveformProfileService( + FileDb fileDatabase, + AudioProcessor audioProcessor, + ILoudnessAlgorithm loudnessAlgorithm, + IOptions options, + ILogger logger) + { + _fileDatabase = fileDatabase; + _audioProcessor = audioProcessor; + _loudnessAlgorithm = loudnessAlgorithm; + _options = options.Value; + _logger = logger; + } + + /// + /// Computes the loudness profile from and stores it under + /// . Returns false (and logs) on any failure — a missing profile + /// is handled gracefully downstream, so callers on the upload path log-and-continue rather + /// than failing the upload. Does not throw for expected failure modes. + /// + public async Task ComputeAndStoreAsync(ReadOnlyMemory wavBytes, string entryKey) + { + try + { + var pcm = _audioProcessor.TryExtractPcm(wavBytes.Span); + if (pcm is null) + { + _logger.LogWarning( + "Waveform profile not computed for {EntryKey}: WAV PCM could not be extracted.", + entryKey); + return false; + } + + var value = pcm.Value; + var profile = _loudnessAlgorithm.Compute( + value.Pcm.Span, + value.Channels, + value.SampleRate, + value.BitsPerSample, + _options.BucketCount); + + var quantized = Quantize(profile); + + await EnsureVaultAsync(); + + var binary = new MediaBinary(new MediaBinaryParams(quantized, quantized.Length, ProfileExtension)); + var stored = await _fileDatabase.RegisterResourceAsync( + VaultConstants.WaveformProfiles, entryKey, binary); + + if (!stored) + { + _logger.LogWarning("Waveform profile vault write failed for {EntryKey}.", entryKey); + return false; + } + + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Waveform profile computation failed for {EntryKey}.", entryKey); + return false; + } + } + + /// + /// Returns the stored quantized profile bytes for a track, or null if no profile is stored + /// (existing tracks predate profiling, and computation may have failed). Each byte is a + /// peak-normalized loudness value in [0, 255]. + /// + public async Task GetProfileAsync(string entryKey) + { + var binary = await _fileDatabase.LoadResourceAsync( + VaultConstants.WaveformProfiles, entryKey); + return binary?.Buffer; + } + + /// + /// Maps each [0, 1] bucket to a [0, 255] byte. 1.0 maps to 255; the multiply-by-255 with a + /// truncating cast keeps every in-range value within a byte without a clamp branch. + /// + private static byte[] Quantize(double[] profile) + { + var bytes = new byte[profile.Length]; + for (var i = 0; i < profile.Length; i++) + { + bytes[i] = (byte)(profile[i] * 255); + } + return bytes; + } + + private async Task EnsureVaultAsync() + { + if (!_fileDatabase.HasVault(VaultConstants.WaveformProfiles)) + { + await _fileDatabase.CreateVaultAsync(VaultConstants.WaveformProfiles, MediaVaultType.Media); + } + } +} diff --git a/DeepDrftModels/DTOs/WaveformProfileDto.cs b/DeepDrftModels/DTOs/WaveformProfileDto.cs new file mode 100644 index 0000000..8fd953f --- /dev/null +++ b/DeepDrftModels/DTOs/WaveformProfileDto.cs @@ -0,0 +1,14 @@ +namespace DeepDrftModels.DTOs; + +/// +/// Wire contract for a stored waveform loudness profile. is the base64 +/// encoding of a byte[BucketCount], each byte a peak-normalized loudness value in +/// [0, 255] (the quantized form of a [0, 1] float). The frontend renders these as bar heights +/// in the WaveformSeeker. A track with no stored profile yields no DTO (the frontend falls +/// back to a flat seekbar), so this type never represents "absent" — only a present profile. +/// +public class WaveformProfileDto +{ + public int BucketCount { get; set; } + public string Data { get; set; } = string.Empty; +} diff --git a/DeepDrftTests/MediaVaultFactoryTests.cs b/DeepDrftTests/MediaVaultFactoryTests.cs index f720fe6..6c7f66b 100644 --- a/DeepDrftTests/MediaVaultFactoryTests.cs +++ b/DeepDrftTests/MediaVaultFactoryTests.cs @@ -71,16 +71,16 @@ public class MediaVaultFactoryTests } [Test] - public async Task From_MediaVaultType_ReturnsNull() + public async Task From_MediaVaultType_CreatesMediaFileVault() { - // Note: MediaVaultType.Media doesn't have a concrete vault implementation - // This tests the factory's handling of unsupported types - + // MediaVaultType.Media resolves to MediaFileVault — the concrete vault for plain + // MediaBinary entries (e.g. waveform loudness profile sidecars). + // Act var vault = await MediaVaultFactory.From(TestDirectory, MediaVaultType.Media); // Assert - Assert.That(vault, Is.Null, "Should return null for unsupported Media vault type"); + AssertVaultCreated(vault, "Media vault creation"); } [Test] diff --git a/DeepDrftTests/RmsLoudnessAlgorithmTests.cs b/DeepDrftTests/RmsLoudnessAlgorithmTests.cs new file mode 100644 index 0000000..e88e87b --- /dev/null +++ b/DeepDrftTests/RmsLoudnessAlgorithmTests.cs @@ -0,0 +1,98 @@ +using DeepDrftContent.Processors; + +namespace DeepDrftTests; + +/// +/// Behavioral tests for the RMS loudness algorithm. The algorithm reduces raw PCM to a +/// fixed-length, peak-normalized loudness envelope; these tests anchor the contract that a loud +/// region reads as high buckets, silence reads as zero, and output is bounded to [0, 1]. +/// +[TestFixture] +public class RmsLoudnessAlgorithmTests +{ + private const int SampleRate = 44100; + private const int Channels = 1; + private const int BitsPerSample = 16; + + private RmsLoudnessAlgorithm _algorithm = null!; + + [SetUp] + public void SetUp() => _algorithm = new RmsLoudnessAlgorithm(); + + [Test] + public void Compute_LoudSecondHalf_ProducesHigherBucketsThanSilentFirstHalf() + { + const int frames = 44100; // 1 second + var pcm = new byte[frames * 2]; // 16-bit mono + + // First half silent (zeros); second half a full-scale square wave. + for (var i = frames / 2; i < frames; i++) + { + WriteInt16(pcm, i * 2, short.MaxValue); + } + + var profile = _algorithm.Compute(pcm, Channels, SampleRate, BitsPerSample, bucketCount: 16); + + var silentAverage = profile.Take(8).Average(); + var loudAverage = profile.Skip(8).Average(); + + Assert.That(silentAverage, Is.LessThan(0.01), "silent region should read near zero"); + Assert.That(loudAverage, Is.GreaterThan(0.9), "loud region should read near peak after normalization"); + Assert.That(loudAverage, Is.GreaterThan(silentAverage * 10), + "loud region must be significantly higher than the silent region"); + } + + [Test] + public void Compute_AllSilence_ReturnsAllZeros() + { + var pcm = new byte[44100 * 2]; // all zeros, 16-bit mono + + var profile = _algorithm.Compute(pcm, Channels, SampleRate, BitsPerSample, bucketCount: 32); + + Assert.That(profile, Has.Length.EqualTo(32)); + Assert.That(profile, Is.All.EqualTo(0.0)); + } + + [Test] + public void Compute_AllValues_AreWithinUnitRangeAndPeakIsOne() + { + const int frames = 8192; + var pcm = new byte[frames * 2]; + + // Ramp amplitude across the signal so buckets differ and exactly one reaches peak. + for (var i = 0; i < frames; i++) + { + var amplitude = (short)(short.MaxValue * ((double)i / frames)); + WriteInt16(pcm, i * 2, amplitude); + } + + var profile = _algorithm.Compute(pcm, Channels, SampleRate, BitsPerSample, bucketCount: 64); + + Assert.That(profile, Is.All.InRange(0.0, 1.0)); + Assert.That(profile.Max(), Is.EqualTo(1.0).Within(1e-9), "peak normalization must put the loudest bucket at 1"); + } + + [Test] + public void Compute_AveragesStereoChannelsToMono() + { + const int frames = 4096; + var pcm = new byte[frames * 2 * 2]; // 16-bit, 2 channels + + // Left at full scale, right at silence — mono average is half scale, non-zero. + for (var i = 0; i < frames; i++) + { + WriteInt16(pcm, i * 4, short.MaxValue); // left + WriteInt16(pcm, i * 4 + 2, 0); // right + } + + var profile = _algorithm.Compute(pcm, channels: 2, SampleRate, BitsPerSample, bucketCount: 8); + + Assert.That(profile.Max(), Is.GreaterThan(0.0), "mixed-channel signal must not read as silence"); + } + + private static void WriteInt16(byte[] buffer, int offset, short value) + { + buffer[offset] = (byte)(value & 0xFF); + buffer[offset + 1] = (byte)((value >> 8) & 0xFF); + } +} diff --git a/DeepDrftTests/SimpleMediaTypeRegistryTests.cs b/DeepDrftTests/SimpleMediaTypeRegistryTests.cs index 1177464..4a44e60 100644 --- a/DeepDrftTests/SimpleMediaTypeRegistryTests.cs +++ b/DeepDrftTests/SimpleMediaTypeRegistryTests.cs @@ -485,13 +485,15 @@ public class SimpleMediaTypeRegistryTests } [Test] - public async Task CreateVaultAsync_MediaVaultType_ReturnsNull() + public async Task CreateVaultAsync_MediaVaultType_CreatesMediaFileVault() { // Act var vault = await Registry.CreateVaultAsync(MediaVaultType.Media, TestDirectory); - // Assert - Assert.That(vault, Is.Null, "Should return null for unsupported Media vault type"); + // Assert — Media now has a concrete vault (MediaFileVault) for plain MediaBinary + // sidecars such as waveform loudness profiles. + Assert.That(vault, Is.Not.Null); + Assert.That(vault, Is.InstanceOf()); } }