From c863fe85f5f17cda122e3255cb8f4ccc1e1f36b3 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 31 Jul 2026 16:24:23 +0200 Subject: [PATCH] feat(audio): signature reader, offset recovery, and version refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the consumer halves of JR-044 and JR-045, which were blocked on there being no reader at all. `AudioSignatureMatcher` implements the specification's slide — ±600 frames, scoring the fraction of overlapping frames whose peak band agrees — and returns the tier and offset. JR-045: `TryParseFrames` refuses any prefix but `v1:`. A `v2:` signature from a future producer describes a DSP chain this build does not implement, so scoring it as v1 would be a confident wrong answer where declining is a correct one — the item drops to the runtime tier, which is the entire reason the prefix is separate from `schema_version`. JR-044: a runtime under 120 s yields no match and therefore no offset, read off the runtime rather than inferred from a missing string, because the runtime is what both producers test. The boundary is asserted on one file at 119.999 s and 120.000 s, so a null cannot be blamed on the decode. The offset has two terms, which is easy to miss: the recovered slide, and the difference between where the two windows are anchored, since both are centred on their own file's midpoint. A release carrying 40 s of extra head material recovers 20 s from each. One parameter is not from the specification and is marked as such in the code: an alignment must overlap by at least 64 frames before its score counts, or the extreme offsets compare a handful of frames where a chance agreement scores 1.0 and beats the true alignment. TRACES: JR-044, JR-045 | SR-003 --- .../AudioSignatureMatcherTests.cs | 270 ++++++++++++++++++ .../Services/AudioSignatureMatch.cs | 70 +++++ .../Services/AudioSignatureMatcher.cs | 207 ++++++++++++++ 3 files changed, 547 insertions(+) create mode 100644 Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs create mode 100644 Jellyfin.Plugin.JRay/Services/AudioSignatureMatch.cs create mode 100644 Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs diff --git a/Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs b/Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs new file mode 100644 index 0000000..919a7d9 --- /dev/null +++ b/Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs @@ -0,0 +1,270 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.JRay.Configuration; +using Jellyfin.Plugin.JRay.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Plugin.JRay.Tests; + +/// +/// JR-044 (media shorter than 120 s emits no signature and takes no sync offset) +/// and JR-045 (the `v1:` prefix is emitted *and honoured*). +/// +/// Both requirements have a producer half and a consumer half. The producer +/// halves live with and were closed by +/// UT-038 … UT-044; these are the consumer halves, which needed a reader — +/// — before they could be closed at all. +/// +/// The distinction that matters throughout: **refusing is a correct answer, and +/// a silently wrong one is not.** An unreadable or out-of-version signature must +/// drop the item to the runtime tier, never score as if it were understood. +/// +/// TRACES: UT-045, UT-046, UT-047, UT-048, UT-049, UT-050, UT-051, UT-052 | JR-044, JR-045 +/// +public class AudioSignatureMatcherTests +{ + private static readonly string FixtureDir = + Path.Combine(AppContext.BaseDirectory, "fixtures", "audio"); + + private static readonly Lazy GoldenSignature = new(() => + JsonDocument.Parse(File.ReadAllText(Path.Combine(FixtureDir, "jray_audio_v1_golden.json"))) + .RootElement.GetProperty("signature").GetString()!); + + private static readonly Lazy GoldenFrames = new(() => + AudioSignatureMatcher.TryParseFrames(GoldenSignature.Value)!); + + // A feature-length runtime, so the window-anchor term is exercised at a + // realistic scale rather than at the 120 s boundary. + private const double FeatureRuntime = 7200.0; + + // UT-045 — JR-044, the producer boundary. + [Fact] + public async Task ShortMedia_YieldsNoSignature_AndExactly120sDoes() + { + // Decisive because it is the *same file* either side of the boundary: + // only the runtime differs, so a null cannot be blamed on the decode. + // The fixture is exactly 120.000 s, which is the boundary itself. + var fixture = Path.Combine(FixtureDir, "jray_audio_v1_tone.flac"); + + // Below the window: refused before the encoder is ever consulted, which + // is what the deliberately invalid path proves. + Assert.Null(await AudioSignatureService.ComputeWithEncoderAsync( + "/nonexistent/ffmpeg", + fixture, + AudioSignature.WindowSec - 0.001, + NullLogger.Instance, + CancellationToken.None).ConfigureAwait(true)); + + var ffmpeg = FindFfmpeg(); + if (ffmpeg is null) + { + return; + } + + Assert.Null(await AudioSignatureService.ComputeWithEncoderAsync( + ffmpeg, + fixture, + AudioSignature.WindowSec - 0.001, + NullLogger.Instance, + CancellationToken.None).ConfigureAwait(true)); + + // Exactly at the boundary the window fits, so a signature is emitted. + // Both producers must agree here or they diverge on precisely the short + // items most likely to be misidentified. + Assert.Equal( + GoldenSignature.Value, + await AudioSignatureService.ComputeWithEncoderAsync( + ffmpeg, + fixture, + AudioSignature.WindowSec, + NullLogger.Instance, + CancellationToken.None).ConfigureAwait(true)); + } + + // UT-046 — JR-044, the consumer half. + [Fact] + public void ShortMedia_TakesNoOffset_EvenWithTwoValidSignatures() + { + var signature = GoldenSignature.Value; + + // Two identical, perfectly valid signatures — the strongest possible + // match — still yield nothing when either side is under the window. The + // rule is checked on the runtime, not inferred from a missing string, + // because the runtime is what both producers test. + Assert.Null(AudioSignatureMatcher.Compare( + signature, signature, AudioSignature.WindowSec - 0.001, FeatureRuntime)); + Assert.Null(AudioSignatureMatcher.Compare( + signature, signature, FeatureRuntime, AudioSignature.WindowSec - 0.001)); + + // At exactly the boundary it matches, so the refusal above is the + // threshold and not a blanket refusal. + Assert.NotNull(AudioSignatureMatcher.Compare( + signature, signature, AudioSignature.WindowSec, AudioSignature.WindowSec)); + } + + // UT-047 — JR-045, the requirement's whole point. + [Fact] + public void UnknownVersionPrefix_IsRefused_NotParsed() + { + // A v2 signature from a future producer, whose payload is byte-identical + // to a valid v1 one. Parsing it as v1 would yield a confident, plausible, + // wrong score; refusing drops the item to the runtime tier, which is + // correct. This is the entire reason the prefix is separate from + // schema_version. + var payload = GoldenSignature.Value[AudioSignature.VersionPrefix.Length..]; + + Assert.Null(AudioSignatureMatcher.TryParseFrames("v2:" + payload)); + Assert.Null(AudioSignatureMatcher.TryParseFrames("v10:" + payload)); + Assert.Null(AudioSignatureMatcher.TryParseFrames(payload)); // no prefix at all + Assert.Null(AudioSignatureMatcher.TryParseFrames("V1:" + payload)); // case is not cosmetic + + // And it must be refused by the matcher too, not merely by the parser — + // a v2 signature produces no match and therefore no offset. + Assert.Null(AudioSignatureMatcher.Compare( + GoldenSignature.Value, "v2:" + payload, FeatureRuntime, FeatureRuntime)); + Assert.Null(AudioSignatureMatcher.Compare( + "v2:" + payload, GoldenSignature.Value, FeatureRuntime, FeatureRuntime)); + } + + // UT-048 — JR-045, the accepting side. + [Fact] + public void V1Signature_ParsesToExactlyTheProducedFrames() + { + // The reader is the inverse of the producer, checked against the golden + // vector rather than against the producer's own output, so the two are + // pinned to the fixture and not merely to each other. + var frames = AudioSignatureMatcher.TryParseFrames(GoldenSignature.Value); + + Assert.NotNull(frames); + Assert.Equal(AudioSignature.ExpectedFrames, frames!.Length); + Assert.Equal(GoldenSignature.Value, AudioSignature.VersionPrefix + Convert.ToBase64String(frames)); + } + + // UT-049 — JR-045, structural refusal. + [Fact] + public void MalformedSignatures_AreRefused_WithoutThrowing() + { + Assert.Null(AudioSignatureMatcher.TryParseFrames(null)); + Assert.Null(AudioSignatureMatcher.TryParseFrames(string.Empty)); + Assert.Null(AudioSignatureMatcher.TryParseFrames("v1:")); // empty payload + Assert.Null(AudioSignatureMatcher.TryParseFrames("v1:not!base64!")); + + // Bit 7 is reserved by the packing — a byte with it set is not a frame. + // The server refuses this on upload; the client must not accept what the + // server would have rejected. + Assert.Null(AudioSignatureMatcher.TryParseFrames( + AudioSignature.VersionPrefix + Convert.ToBase64String(new byte[] { 0x04, 0x80, 0x08 }))); + } + + // UT-050 — the aligned case. + [Fact] + public void IdenticalSignatures_ScorePerfectly_AtZeroOffset() + { + var match = AudioSignatureMatcher.Compare( + GoldenSignature.Value, GoldenSignature.Value, FeatureRuntime, FeatureRuntime); + + Assert.NotNull(match); + Assert.Equal(1.0, match!.Value.Score); + Assert.Equal(0, match.Value.OffsetFrames); + Assert.Equal(0.0, match.Value.OffsetSec); + Assert.Equal(MatchTier.Audio, match.Value.Tier); + } + + // UT-051 — the case the feature exists for. + [Fact] + public void AShiftedRelease_RecoversTheOffset_RatherThanFailingToMatch() + { + // A release trimmed differently from the one the manifest was built on: + // the same cut, sampled at a different point. Before offset recovery this + // failed the runtime tier outright; the recovered shift is what makes one + // stored manifest serve every trim. + const int Shift = 100; + const int Span = 1000; + const int RemoteStart = 144; + + var source = GoldenFrames.Value; + var remote = Signature(source, RemoteStart, Span); + var local = Signature(source, RemoteStart - Shift, Span); + + var match = AudioSignatureMatcher.Compare(local, remote, FeatureRuntime, FeatureRuntime); + + Assert.NotNull(match); + Assert.Equal(1.0, match!.Value.Score); + Assert.Equal(Shift, match.Value.OffsetFrames); + Assert.Equal(MatchTier.Audio, match.Value.Tier); + + // Equal runtimes, so the window-anchor term vanishes and the offset is + // purely the recovered slide. + Assert.Equal(Shift * AudioSignatureMatcher.FrameSeconds, match.Value.OffsetSec, 9); + } + + // UT-052 — the rejecting case, and the anchor term. + [Fact] + public void UnrelatedContent_DoesNotMatch_AndRuntimeSkewShiftsTheOffset() + { + // Two independent band sequences agree about 1 frame in 32, far below the + // loose floor. A matcher that returned its argmax regardless would hand + // back a confident alignment for unrelated films. + Assert.Null(AudioSignatureMatcher.Compare( + GoldenSignature.Value, PseudoRandomSignature(1288, seed: 12345), FeatureRuntime, FeatureRuntime)); + + // Both windows are centred on their own file's midpoint, so when the + // runtimes differ the windows start at different absolute times and that + // difference is part of the offset. Without this term the offset would be + // wrong by half the runtime difference on every shifted release. + var skew = 40.0; + var match = AudioSignatureMatcher.Compare( + GoldenSignature.Value, + GoldenSignature.Value, + FeatureRuntime + skew, + FeatureRuntime); + + Assert.NotNull(match); + Assert.Equal(0, match!.Value.OffsetFrames); + Assert.Equal(skew / 2.0, match.Value.OffsetSec, 9); + } + + private static string Signature(byte[] source, int start, int count) + => AudioSignature.VersionPrefix + Convert.ToBase64String(source, start, count); + + private static string PseudoRandomSignature(int frames, int seed) + { + var bytes = new byte[frames]; + var state = (uint)seed; + for (var i = 0; i < frames; i++) + { + // Deterministic LCG — a fixed sequence, so a failure here is + // reproducible rather than flaky. + state = (state * 1664525u) + 1013904223u; + bytes[i] = (byte)((((state >> 16) % AudioSignature.NumBands) << 2) | ((state >> 8) & 0x03)); + } + + return AudioSignature.VersionPrefix + Convert.ToBase64String(bytes); + } + + 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; + } +} diff --git a/Jellyfin.Plugin.JRay/Services/AudioSignatureMatch.cs b/Jellyfin.Plugin.JRay/Services/AudioSignatureMatch.cs new file mode 100644 index 0000000..400725e --- /dev/null +++ b/Jellyfin.Plugin.JRay/Services/AudioSignatureMatch.cs @@ -0,0 +1,70 @@ +using System; +using Jellyfin.Plugin.JRay.Configuration; + +namespace Jellyfin.Plugin.JRay.Services; + +/// +/// The outcome of comparing a local audio signature against a remote one. +/// +/// +/// Produced only when the two signatures actually align; a comparison that +/// reaches no tier yields no result at all rather than a zero-scored one, so a +/// caller cannot mistake "did not match" for "matched at the bottom". +/// +public readonly struct AudioSignatureMatch : IEquatable +{ + /// + /// Gets the fraction of overlapping frames whose peak band agreed, in [0, 1]. + /// + public double Score { get; init; } + + /// + /// Gets the best-scoring alignment, in frames, between the two analysis + /// windows. + /// + /// + /// Positive means the local window lags the remote one. This is a + /// window-relative quantity and is not the offset to apply to + /// timings — see , which additionally accounts for + /// the two windows being anchored at different points in their files. + /// + public int OffsetFrames { get; init; } + + /// + /// Gets the seconds to add to every remote window to bring it into the local + /// file's timebase. + /// + /// + /// This is the quantity JR-030 applies at store time, and the one + /// takes. + /// + public double OffsetSec { get; init; } + + /// Gets the tier this score earns. + public MatchTier Tier { get; init; } + + /// Compares two matches for equality. + /// Left operand. + /// Right operand. + /// true when the two are equal. + public static bool operator ==(AudioSignatureMatch left, AudioSignatureMatch right) => left.Equals(right); + + /// Compares two matches for inequality. + /// Left operand. + /// Right operand. + /// true when the two differ. + public static bool operator !=(AudioSignatureMatch left, AudioSignatureMatch right) => !left.Equals(right); + + /// + public bool Equals(AudioSignatureMatch other) + => Score.Equals(other.Score) + && OffsetFrames == other.OffsetFrames + && OffsetSec.Equals(other.OffsetSec) + && Tier == other.Tier; + + /// + public override bool Equals(object? obj) => obj is AudioSignatureMatch other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(Score, OffsetFrames, OffsetSec, Tier); +} diff --git a/Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs b/Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs new file mode 100644 index 0000000..34d37fb --- /dev/null +++ b/Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs @@ -0,0 +1,207 @@ +using System; +using Jellyfin.Plugin.JRay.Configuration; + +namespace Jellyfin.Plugin.JRay.Services; + +/// +/// Reads v1 audio signatures and aligns two of them, recovering the time offset +/// between differently trimmed releases of one cut. +/// +/// +/// The consumer half of the signature feature: +/// produces, this reads. Matching belongs to the plugin rather than the server +/// because the offset is applied client-side (JR-030) and manifests are never +/// rewritten — one stored manifest serves every trim of the same cut. +/// +/// The construction is public server specification §3 "Matching and offset +/// recovery": slide one signature against the other over ±600 frames, score the +/// fraction of overlapping frames whose peak band agrees, and take the +/// argmax. The energy class is deliberately not scored — it is the coarser and +/// less re-encoding-stable of the two fields, and the specification's rule names +/// the peak bin alone. +/// +/// +// TRACES: JR-044, JR-045 | SR-003 +public static class AudioSignatureMatcher +{ + /// Widest alignment searched, in frames (±56 s). + /// + /// Covers realistic trim differences. A release that differs in *speed* + /// (a PAL 4% speed-up) is not a constant offset and is correctly rejected by + /// the score threshold rather than mis-aligned by this search. + /// + public const int MaxOffsetFrames = 600; + + /// Score at or above which two signatures are the same cut. + public const double AudioThreshold = 0.85; + + /// Score at or above which two signatures are possibly the same cut. + public const double LooseThreshold = 0.60; + + /// + /// Fewest overlapping frames an alignment must have before its score counts. + /// + /// + /// Not from the specification — a guard this implementation adds. The + /// slide is defined over overlapping frames, and without a floor the extreme + /// offsets compare a handful of frames, where a chance agreement scores 1.0 + /// and beats the true alignment. 64 frames is ~6 s. It only ever excludes + /// alignments near the ±600 limit: two full-length signatures overlap by 688 + /// frames even at the widest offset, so this never binds on the case the + /// feature exists for. + /// + public const int MinOverlapFrames = 64; + + /// Gets the duration one STFT frame advances, in seconds. + public static double FrameSeconds => (double)AudioSignature.HopSize / AudioSignature.SampleRate; + + /// + /// Parses a signature string into its per-frame bytes, refusing anything that + /// is not a well-formed v1: signature. + /// + /// + /// JR-045. An unknown prefix is refused, not parsed: a v2: + /// signature from a future producer describes a DSP chain this build does not + /// implement, so scoring it as v1 would silently produce a wrong answer where + /// declining produces a correct one — the item simply falls to the runtime + /// tier. That is the entire purpose of the prefix being separate from + /// schema_version. + /// + /// Structure is checked too, matching what the server validates on upload: + /// every byte is a 5-bit band index and a 2-bit energy class, so bit 7 is + /// always clear. + /// + /// + /// A signature string, or null. + /// The frame bytes, or null if this is not a v1 signature. + public static byte[]? TryParseFrames(string? signature) + { + if (string.IsNullOrEmpty(signature) + || !signature.StartsWith(AudioSignature.VersionPrefix, StringComparison.Ordinal)) + { + return null; + } + + var payload = signature[AudioSignature.VersionPrefix.Length..]; + Span decoded = new byte[((payload.Length / 4) + 1) * 3]; + if (!Convert.TryFromBase64String(payload, decoded, out var written) || written == 0) + { + return null; + } + + var frames = decoded[..written].ToArray(); + foreach (var frame in frames) + { + if ((frame & 0x80) != 0) + { + return null; + } + } + + return frames; + } + + /// + /// Aligns a local signature against a remote one and reports the tier and + /// offset it earns. + /// + /// + /// Returns null — no match, and therefore no offset — when + /// either signature is absent or is not a v1 signature (JR-045), when either + /// item is shorter than the analysis window (JR-044), or when the best + /// alignment scores below . + /// + /// The short-media rule is checked on the runtimes rather than inferred from + /// a missing signature, because the two producers must apply the identical + /// rule and the runtime is what both of them test. An item under 120 s falls + /// back to the runtime tier, which is adequate: a sub-two-minute item is + /// rarely the ambiguous-providence case the signature exists to solve. + /// + /// + /// Signature computed from the local file. + /// Signature carried by the fetched manifest. + /// Runtime of the local file, in seconds. + /// Runtime the manifest records, in seconds. + /// The match, or null when the two do not align. + public static AudioSignatureMatch? Compare( + string? localSignature, + string? remoteSignature, + double localRuntimeSec, + double remoteRuntimeSec) + { + // JR-044 — below the window there is no signature to trust and no offset + // to apply, whatever the strings happen to contain. + if (localRuntimeSec < AudioSignature.WindowSec || remoteRuntimeSec < AudioSignature.WindowSec) + { + return null; + } + + var local = TryParseFrames(localSignature); + var remote = TryParseFrames(remoteSignature); + if (local is null || remote is null) + { + return null; + } + + var bestScore = -1.0; + var bestOffset = 0; + var found = false; + + for (var d = -MaxOffsetFrames; d <= MaxOffsetFrames; d++) + { + // i indexes the remote signature; the local frame it is compared + // against is i + d, which must fall inside the local signature. + var first = Math.Max(0, -d); + var last = Math.Min(remote.Length, local.Length - d); + var overlap = last - first; + if (overlap < MinOverlapFrames) + { + continue; + } + + var agreed = 0; + for (var i = first; i < last; i++) + { + if (((local[i + d] >> 2) & 0x1F) == ((remote[i] >> 2) & 0x1F)) + { + agreed++; + } + } + + var score = (double)agreed / overlap; + + // Ties go to the alignment closest to zero: when a signature is + // degenerate enough that several offsets score alike, "not shifted" + // is the reading that does least damage, and the choice must be + // deterministic rather than an artefact of iteration order. + if (!found || score > bestScore || (score == bestScore && Math.Abs(d) < Math.Abs(bestOffset))) + { + bestScore = score; + bestOffset = d; + found = true; + } + } + + if (!found || bestScore < LooseThreshold) + { + return null; + } + + // Both windows are centred on their own file's midpoint, so an offset + // between the two windows is only part of the answer: the windows start + // at different absolute times whenever the runtimes differ, and that + // difference is the rest of it. Runtimes are >= WindowSec here, so + // neither start clamps at zero — the same condition under which + // AudioSignatureService does not clamp either. + var localStart = (localRuntimeSec / 2.0) - (AudioSignature.WindowSec / 2.0); + var remoteStart = (remoteRuntimeSec / 2.0) - (AudioSignature.WindowSec / 2.0); + + return new AudioSignatureMatch + { + Score = bestScore, + OffsetFrames = bestOffset, + OffsetSec = (localStart - remoteStart) + (bestOffset * FrameSeconds), + Tier = bestScore >= AudioThreshold ? MatchTier.Audio : MatchTier.Loose, + }; + } +}