feat(audio): v1 signature producer, bit-exact with the pipeline
`AudioSignature` is the DSP — band table, periodic Hann, radix-2 FFT, band-mean peak, energy class, packing — and `AudioSignatureService` the decode, running the FFmpeg binary `IMediaEncoder.EncoderPath` names. The plugin gained no dependency. The server specification's prose does not determine a byte stream, so the parameters it leaves open are pinned by the fixture shared with the extraction repo and restated at the top of `AudioSignature`: double throughout, whole frames only, periodic Hann, unnormalised FFT, band mean rather than sum, argmax ties to the lowest index. `fixtures/audio/` holds the extraction repo's three files byte-identically and the computed signature equals the recorded vector exactly. The binding check regenerates the fixture PCM from `make_fixture.py`'s arithmetic and verifies it against the recorded decode checksums, so it runs on a host with no codec at all and a decode divergence stays distinguishable from a DSP one; the two tests that drive real FFmpeg self-skip without a binary. The workflow named "Test Plugin" until now only compiled one. A test that is built and never run is not evidence, and a golden vector shared across two repos exists precisely so CI fails when they drift. TRACES: JR-042, JR-043 | SR-003
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.JRay.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// JR-042 (the signature is computed exactly per server specification §3) and
|
||||
/// JR-043 (the golden-vector fixture shared with the extraction repo).
|
||||
///
|
||||
/// The headline claim is cross-repo: the C++ pipeline and this plugin are two
|
||||
/// independent implementations of one fingerprint, and two fingerprints that
|
||||
/// differ in any parameter simply do not match. That claim is only worth
|
||||
/// anything if it is checked, so `fixtures/audio/` holds the same three files
|
||||
/// the extraction repo holds — byte-identical — and these tests assert against
|
||||
/// the values recorded in them, never against each other.
|
||||
///
|
||||
/// The binding check needs no FFmpeg. `make_fixture.py` generates the fixture
|
||||
/// media from plain arithmetic, so UT-038 regenerates that PCM here and proves
|
||||
/// it is byte-identical to what the pipeline decoded, using the checksums the
|
||||
/// fixture records. Everything after that is pure DSP, which is what lets this
|
||||
/// run on any CI host — the extraction repo's counterpart (UT-101) drives the
|
||||
/// same vector through libavcodec, and both must land on the same string.
|
||||
///
|
||||
/// TRACES: UT-038, UT-039, UT-040, UT-041, UT-042, UT-043, UT-044 | JR-042, JR-043
|
||||
/// </summary>
|
||||
public class AudioSignatureTests
|
||||
{
|
||||
private static readonly string FixtureDir =
|
||||
Path.Combine(AppContext.BaseDirectory, "fixtures", "audio");
|
||||
|
||||
private static readonly Lazy<JsonDocument> GoldenDoc = new(() =>
|
||||
JsonDocument.Parse(File.ReadAllText(Path.Combine(FixtureDir, "jray_audio_v1_golden.json"))));
|
||||
|
||||
private static readonly Lazy<short[]> FixturePcm = new(GenerateFixturePcm);
|
||||
|
||||
private static readonly Lazy<float[]> FixtureSamples = new(() =>
|
||||
{
|
||||
var pcm = FixturePcm.Value;
|
||||
var samples = new float[pcm.Length];
|
||||
for (var i = 0; i < pcm.Length; i++)
|
||||
{
|
||||
// FFmpeg's native s16 -> flt conversion. 1/32768 is a power of two,
|
||||
// so this is exact rather than merely close.
|
||||
samples[i] = pcm[i] / 32768f;
|
||||
}
|
||||
|
||||
return samples;
|
||||
});
|
||||
|
||||
private static JsonElement Golden => GoldenDoc.Value.RootElement;
|
||||
|
||||
private static string GoldenSignature => Golden.GetProperty("signature").GetString()!;
|
||||
|
||||
// UT-038
|
||||
[Fact]
|
||||
public void RegeneratedFixturePcm_MatchesTheRecordedDecodedWindow()
|
||||
{
|
||||
// Checked before the signature, and separately from it, so a mismatch
|
||||
// is diagnosable: if this passes and UT-039 fails, the DSP diverged; if
|
||||
// this fails, the input did, and the signature comparison would only
|
||||
// have told you "different" without saying where.
|
||||
var pcm = FixturePcm.Value;
|
||||
var decoded = Golden.GetProperty("decoded_window");
|
||||
|
||||
Assert.Equal(decoded.GetProperty("samples").GetInt32(), pcm.Length);
|
||||
|
||||
var s16 = new byte[pcm.Length * sizeof(short)];
|
||||
for (var i = 0; i < pcm.Length; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteInt16LittleEndian(s16.AsSpan(i * sizeof(short)), pcm[i]);
|
||||
}
|
||||
|
||||
Assert.Equal(Hex64(decoded.GetProperty("s16le_fnv1a64").GetString()!), Fnv1a64(s16));
|
||||
|
||||
var f32 = new byte[FixtureSamples.Value.Length * sizeof(float)];
|
||||
for (var i = 0; i < FixtureSamples.Value.Length; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteSingleLittleEndian(f32.AsSpan(i * sizeof(float)), FixtureSamples.Value[i]);
|
||||
}
|
||||
|
||||
Assert.Equal(Hex64(decoded.GetProperty("f32le_fnv1a64").GetString()!), Fnv1a64(f32));
|
||||
}
|
||||
|
||||
// UT-039
|
||||
[Fact]
|
||||
public void Signature_OfTheGoldenFixture_MatchesTheRecordedValueExactly()
|
||||
{
|
||||
// The cross-repo check. Not "close", not "matches to within a tier" —
|
||||
// the same string the C++ producer emits for the same audio.
|
||||
Assert.Equal(GoldenSignature, AudioSignature.FromMonoSamples(FixtureSamples.Value));
|
||||
}
|
||||
|
||||
// UT-040
|
||||
[Fact]
|
||||
public void BandTable_MatchesTheRecordedOne_AndTilesTheRangeExactly()
|
||||
{
|
||||
// The band-to-FFT-bin table is the part of the construction most likely
|
||||
// to drift between two implementations — an off-by-one in a ceiling, a
|
||||
// half-open range read as closed — so it is pinned independently of the
|
||||
// signature it produces.
|
||||
var table = AudioSignature.BandFftBins();
|
||||
var want = Golden.GetProperty("band_fft_bins");
|
||||
|
||||
Assert.Equal(want.GetArrayLength(), table.Count);
|
||||
for (var b = 0; b < table.Count; b++)
|
||||
{
|
||||
Assert.Equal(want[b][0].GetInt32(), table[b].Low);
|
||||
Assert.Equal(want[b][1].GetInt32(), table[b].High);
|
||||
Assert.True(table[b].High > table[b].Low, $"band {b} is empty");
|
||||
if (b > 0)
|
||||
{
|
||||
// Contiguous, so the frame energy really is the sum of the band
|
||||
// sums — no gap, no bin counted twice.
|
||||
Assert.Equal(table[b - 1].High, table[b].Low);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UT-041
|
||||
[Fact]
|
||||
public void Signature_IsWellFormed_PrefixFrameCountAndStructuralBytes()
|
||||
{
|
||||
var signature = AudioSignature.FromMonoSamples(FixtureSamples.Value);
|
||||
Assert.NotNull(signature);
|
||||
|
||||
// JR-045 — the signature carries its own version, separate from
|
||||
// schema_version, so a future DSP change is detectable rather than
|
||||
// silently producing signatures that no longer match.
|
||||
Assert.StartsWith(AudioSignature.VersionPrefix, signature, StringComparison.Ordinal);
|
||||
|
||||
var bytes = Convert.FromBase64String(signature[AudioSignature.VersionPrefix.Length..]);
|
||||
Assert.Equal(Golden.GetProperty("frame_count").GetInt32(), bytes.Length);
|
||||
Assert.Equal(AudioSignature.ExpectedFrames, bytes.Length);
|
||||
|
||||
// The server validates this structure on upload: each byte is a 5-bit
|
||||
// band index plus a 2-bit energy class, so bit 7 is always clear and an
|
||||
// arbitrary byte is not a valid signature. That is what keeps the field
|
||||
// from being usable as a payload channel.
|
||||
var bandsSeen = new bool[AudioSignature.NumBands];
|
||||
var classesSeen = new bool[4];
|
||||
foreach (var b in bytes)
|
||||
{
|
||||
Assert.Equal(0, b & 0x80);
|
||||
bandsSeen[(b >> 2) & 0x1F] = true;
|
||||
classesSeen[b & 0x03] = true;
|
||||
}
|
||||
|
||||
// The fixture is built to exercise the whole output alphabet. If it ever
|
||||
// stops doing so, the golden vector has become a weaker check than it
|
||||
// looks — so that property is asserted rather than assumed.
|
||||
Assert.All(bandsSeen, Assert.True);
|
||||
Assert.All(classesSeen, Assert.True);
|
||||
}
|
||||
|
||||
// UT-042
|
||||
[Fact]
|
||||
public void PackFrames_UsesWholeFramesOnly()
|
||||
{
|
||||
Assert.Empty(AudioSignature.PackFrames(new float[AudioSignature.FrameSize - 1]));
|
||||
Assert.Single(AudioSignature.PackFrames(new float[AudioSignature.FrameSize]));
|
||||
Assert.Single(AudioSignature.PackFrames(
|
||||
new float[AudioSignature.FrameSize + AudioSignature.HopSize - 1]));
|
||||
Assert.Equal(2, AudioSignature.PackFrames(
|
||||
new float[AudioSignature.FrameSize + AudioSignature.HopSize].AsSpan()).Length);
|
||||
|
||||
// A partial frame is not a signature: below one frame there is nothing
|
||||
// to emit, and emitting a padded frame would be a different fingerprint
|
||||
// from the pipeline's.
|
||||
Assert.Null(AudioSignature.FromMonoSamples(new float[AudioSignature.FrameSize - 1]));
|
||||
|
||||
// The full window is 1288 frames — asserted as a constant rather than by
|
||||
// running the DSP over 1.3M zeros, which is the same claim for free.
|
||||
Assert.Equal(1323000, AudioSignature.WindowSamples);
|
||||
Assert.Equal(
|
||||
1 + ((AudioSignature.WindowSamples - AudioSignature.FrameSize) / AudioSignature.HopSize),
|
||||
AudioSignature.ExpectedFrames);
|
||||
}
|
||||
|
||||
// UT-043
|
||||
[Fact]
|
||||
public async Task Decode_ThroughFfmpeg_ReproducesTheGoldenSignature()
|
||||
{
|
||||
// The one test that exercises the real decode — the command line, the
|
||||
// stream selection, the downmix and resample — rather than the DSP
|
||||
// alone. It needs an FFmpeg binary, which the plugin gets from Jellyfin
|
||||
// at run time and which a bare CI container may not have; UT-038 and
|
||||
// UT-039 are what make the cross-repo claim binding without one.
|
||||
var ffmpeg = FindFfmpeg();
|
||||
if (ffmpeg is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var signature = await AudioSignatureService.ComputeWithEncoderAsync(
|
||||
ffmpeg,
|
||||
Path.Combine(FixtureDir, "jray_audio_v1_tone.flac"),
|
||||
AudioSignature.WindowSec,
|
||||
NullLogger.Instance,
|
||||
CancellationToken.None).ConfigureAwait(true);
|
||||
|
||||
Assert.Equal(GoldenSignature, signature);
|
||||
}
|
||||
|
||||
// UT-044
|
||||
[Fact]
|
||||
public async Task Decode_TakesTheWindowFromTheCentre_NotTheHead()
|
||||
{
|
||||
// Sampling from the centre is the whole reason the construction avoids
|
||||
// the head and tail — logos and cold opens at one end, credits at the
|
||||
// other — so it needs its own check. Nothing else here pins the seek: a
|
||||
// head-anchored window passes every other test in this file.
|
||||
var ffmpeg = FindFfmpeg();
|
||||
if (ffmpeg is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pad = 90 * AudioSignature.SampleRate;
|
||||
var padded = new short[(pad * 2) + FixturePcm.Value.Length];
|
||||
FixturePcm.Value.CopyTo(padded, pad);
|
||||
|
||||
var wav = Path.Combine(Path.GetTempPath(), $"jray_audio_centre_{Environment.ProcessId}.wav");
|
||||
try
|
||||
{
|
||||
WriteWav(wav, padded);
|
||||
var signature = await AudioSignatureService.ComputeWithEncoderAsync(
|
||||
ffmpeg,
|
||||
wav,
|
||||
padded.Length / (double)AudioSignature.SampleRate,
|
||||
NullLogger.Instance,
|
||||
CancellationToken.None).ConfigureAwait(true);
|
||||
|
||||
Assert.Equal(GoldenSignature, signature);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(wav);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regenerates the fixture media's PCM, as <c>make_fixture.py</c> defines
|
||||
/// it: 120 s of tones stepping through all 32 log-bands, amplitudes walking
|
||||
/// a golden-ratio sequence so all four energy classes appear, over a quiet
|
||||
/// constant 777 Hz bed so no frame is degenerate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A port, not a re-derivation — the point is that a repo can rebuild the
|
||||
/// input from scratch and check the result against the recorded checksums,
|
||||
/// which is what UT-038 does. The closest sample to a quantisation boundary
|
||||
/// sits 3.7e-7 away from one, so the result does not depend on which libm
|
||||
/// rounds the sine.
|
||||
/// </remarks>
|
||||
private static short[] GenerateFixturePcm()
|
||||
{
|
||||
const int Segment = 32768;
|
||||
const int BandStride = 7;
|
||||
const double AmpLogMin = -1.55;
|
||||
const double AmpLogSpan = 1.53;
|
||||
const double PhiFrac = 0.6180339887498949;
|
||||
const double BackgroundHz = 777.0;
|
||||
const double BackgroundAmp = 0.004;
|
||||
|
||||
var n = (int)Math.Round(AudioSignature.SampleRate * AudioSignature.WindowSec);
|
||||
var samples = new short[n];
|
||||
var twoPi = 2.0 * Math.PI;
|
||||
var phase = 0.0;
|
||||
|
||||
for (var start = 0; start < n; start += Segment)
|
||||
{
|
||||
var s = start / Segment;
|
||||
var end = Math.Min(n, start + Segment);
|
||||
var band = (s * BandStride) % AudioSignature.NumBands;
|
||||
var freq = AudioSignature.BandLoHz * Math.Pow(
|
||||
AudioSignature.BandHiHz / AudioSignature.BandLoHz,
|
||||
(band + 0.5) / AudioSignature.NumBands);
|
||||
var amp = Math.Pow(10.0, AmpLogMin + (AmpLogSpan * ((s * PhiFrac) % 1.0)));
|
||||
var step = twoPi * freq / AudioSignature.SampleRate;
|
||||
|
||||
for (var k = 0; k < end - start; k++)
|
||||
{
|
||||
var i = start + k;
|
||||
var x = amp * Math.Sin(phase + (step * k));
|
||||
x += BackgroundAmp * Math.Sin(twoPi * BackgroundHz * i / AudioSignature.SampleRate);
|
||||
x = Math.Clamp(x, -1.0, 1.0);
|
||||
samples[i] = (short)Math.Floor((x * 32767.0) + 0.5);
|
||||
}
|
||||
|
||||
phase = (phase + (step * (end - start))) % twoPi;
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
private static void WriteWav(string path, short[] samples)
|
||||
{
|
||||
var dataBytes = samples.Length * sizeof(short);
|
||||
using var stream = File.Create(path);
|
||||
using var writer = new BinaryWriter(stream);
|
||||
writer.Write("RIFF"u8);
|
||||
writer.Write(36 + dataBytes);
|
||||
writer.Write("WAVE"u8);
|
||||
writer.Write("fmt "u8);
|
||||
writer.Write(16); // PCM header size
|
||||
writer.Write((short)1); // PCM
|
||||
writer.Write((short)1); // mono
|
||||
writer.Write(AudioSignature.SampleRate);
|
||||
writer.Write(AudioSignature.SampleRate * sizeof(short));
|
||||
writer.Write((short)sizeof(short)); // block align
|
||||
writer.Write((short)16); // bits per sample
|
||||
writer.Write("data"u8);
|
||||
writer.Write(dataBytes);
|
||||
foreach (var sample in samples)
|
||||
{
|
||||
writer.Write(sample);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindFfmpeg()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("JRAY_TEST_FFMPEG");
|
||||
if (!string.IsNullOrEmpty(configured))
|
||||
{
|
||||
return File.Exists(configured) ? configured : null;
|
||||
}
|
||||
|
||||
var name = OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg";
|
||||
foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)
|
||||
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var candidate = Path.Combine(dir, name);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ulong Fnv1a64(ReadOnlySpan<byte> data)
|
||||
{
|
||||
var hash = 0xcbf29ce484222325UL;
|
||||
foreach (var b in data)
|
||||
{
|
||||
hash ^= b;
|
||||
hash *= 0x100000001b3UL;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static ulong Hex64(string value)
|
||||
{
|
||||
var digits = value.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? value[2..] : value;
|
||||
return ulong.Parse(digits, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user