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; /// /// 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 /// public class AudioSignatureTests { private static readonly string FixtureDir = Path.Combine(AppContext.BaseDirectory, "fixtures", "audio"); private static readonly Lazy GoldenDoc = new(() => JsonDocument.Parse(File.ReadAllText(Path.Combine(FixtureDir, "jray_audio_v1_golden.json")))); private static readonly Lazy FixturePcm = new(GenerateFixturePcm); private static readonly Lazy 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); } } /// /// Regenerates the fixture media's PCM, as make_fixture.py 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. /// /// /// 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. /// 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 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); } }