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, }; } }