`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
367 lines
14 KiB
C#
367 lines
14 KiB
C#
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);
|
|
}
|
|
}
|