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:
2026-07-31 16:24:11 +02:00
parent 19aecee646
commit 17be9bcc4e
9 changed files with 1359 additions and 0 deletions
@@ -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);
}
}