4e34696719
Smooth the loudness contour (~50 ms envelope at preprocessing + decode-time, plus smootherstep render reconstruction); retune wax↔waveform collision to bouncy/sub-unity (no explosion/stuck/jitter); split the bubbles knob into fluid-amount + fluid-viscosity (cohesion via uniform-only smin/wobble); retune scroll/gravity/heat/width ranges; make the colour rotation visible and boost OKLab chroma; the controls bar now holds its layout and hides only its knobs via a Visible parameter.
153 lines
6.2 KiB
C#
153 lines
6.2 KiB
C#
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();
|
|
|
|
// The ~50 ms envelope smoothing intentionally bleeds a little loud energy across the
|
|
// silence/loud boundary, so the silent-half average is no longer ~0 — it sits low but
|
|
// non-zero (the boundary bucket lifts). The contract that matters is preserved: the silent
|
|
// region reads LOW, the loud region reads near peak, and loud dwarfs silent by a wide margin.
|
|
Assert.That(silentAverage, Is.LessThan(0.1), "silent region should still read low (smoothing lifts only the boundary)");
|
|
Assert.That(loudAverage, Is.GreaterThan(0.9), "loud region should read near peak after normalization");
|
|
Assert.That(loudAverage, Is.GreaterThan(silentAverage * 5),
|
|
"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");
|
|
}
|
|
|
|
[Test]
|
|
public void Compute_AlternatingLoudSilentFrames_SmoothsTheSpikeyContour()
|
|
{
|
|
// A signal that alternates full-scale and silent across many short buckets would, without
|
|
// smoothing, produce a sawtooth (high, ~0, high, ~0). The ~50 ms envelope smoothing must round
|
|
// that into a contour whose neighbouring buckets differ far less than the raw alternation would.
|
|
const int frames = 44100; // 1 second
|
|
var pcm = new byte[frames * 2];
|
|
for (var i = 0; i < frames; i++)
|
|
{
|
|
// 100 Hz square: ~441 frames per half-cycle — alternating loud/silent blocks well above
|
|
// the per-bucket duration so an unsmoothed profile would alternate sharply bucket-to-bucket.
|
|
var loud = (i / 441) % 2 == 0;
|
|
WriteInt16(pcm, i * 2, loud ? short.MaxValue : (short)0);
|
|
}
|
|
|
|
// 256 buckets over 1 s = ~3.9 ms/bucket, far finer than the 50 ms time constant → heavy smoothing.
|
|
var profile = _algorithm.Compute(pcm, Channels, SampleRate, BitsPerSample, bucketCount: 256);
|
|
|
|
// Max bucket-to-bucket step in the interior should be small relative to the full [0,1] range —
|
|
// an unsmoothed alternation would show steps near 1.0 between adjacent buckets.
|
|
var maxStep = 0.0;
|
|
for (var i = 1; i < profile.Length; i++)
|
|
{
|
|
maxStep = Math.Max(maxStep, Math.Abs(profile[i] - profile[i - 1]));
|
|
}
|
|
|
|
Assert.That(maxStep, Is.LessThan(0.5),
|
|
"the ~50 ms envelope smoothing must round the loud/silent alternation into a smooth contour");
|
|
}
|
|
|
|
[Test]
|
|
public void Compute_Smoothing_PreservesPeakNormalization()
|
|
{
|
|
// Smoothing runs before peak-normalization, so the loudest bucket must still land at exactly 1.
|
|
const int frames = 8192;
|
|
var pcm = new byte[frames * 2];
|
|
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 still put the loudest smoothed bucket at 1");
|
|
}
|
|
|
|
private static void WriteInt16(byte[] buffer, int offset, short value)
|
|
{
|
|
buffer[offset] = (byte)(value & 0xFF);
|
|
buffer[offset + 1] = (byte)((value >> 8) & 0xFF);
|
|
}
|
|
}
|