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.
This commit is contained in:
@@ -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<MediaFileVault>(vault, "Media vault creation");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using DeepDrftContent.Processors;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// 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].
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -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<MediaFileVault>());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user