feat(audio): signature reader, offset recovery, and version refusal

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
This commit is contained in:
2026-07-31 16:24:23 +02:00
parent 17be9bcc4e
commit c863fe85f5
3 changed files with 547 additions and 0 deletions
@@ -0,0 +1,70 @@
using System;
using Jellyfin.Plugin.JRay.Configuration;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// The outcome of comparing a local audio signature against a remote one.
/// </summary>
/// <remarks>
/// 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".
/// </remarks>
public readonly struct AudioSignatureMatch : IEquatable<AudioSignatureMatch>
{
/// <summary>
/// Gets the fraction of overlapping frames whose peak band agreed, in [0, 1].
/// </summary>
public double Score { get; init; }
/// <summary>
/// Gets the best-scoring alignment, in frames, between the two analysis
/// windows.
/// </summary>
/// <remarks>
/// Positive means the local window lags the remote one. This is a
/// window-relative quantity and is <b>not</b> the offset to apply to
/// timings — see <see cref="OffsetSec"/>, which additionally accounts for
/// the two windows being anchored at different points in their files.
/// </remarks>
public int OffsetFrames { get; init; }
/// <summary>
/// Gets the seconds to add to every remote window to bring it into the local
/// file's timebase.
/// </summary>
/// <remarks>
/// This is the quantity JR-030 applies at store time, and the one
/// <see cref="ManifestConverter.ToTruthFile"/> takes.
/// </remarks>
public double OffsetSec { get; init; }
/// <summary>Gets the tier this score earns.</summary>
public MatchTier Tier { get; init; }
/// <summary>Compares two matches for equality.</summary>
/// <param name="left">Left operand.</param>
/// <param name="right">Right operand.</param>
/// <returns><c>true</c> when the two are equal.</returns>
public static bool operator ==(AudioSignatureMatch left, AudioSignatureMatch right) => left.Equals(right);
/// <summary>Compares two matches for inequality.</summary>
/// <param name="left">Left operand.</param>
/// <param name="right">Right operand.</param>
/// <returns><c>true</c> when the two differ.</returns>
public static bool operator !=(AudioSignatureMatch left, AudioSignatureMatch right) => !left.Equals(right);
/// <inheritdoc/>
public bool Equals(AudioSignatureMatch other)
=> Score.Equals(other.Score)
&& OffsetFrames == other.OffsetFrames
&& OffsetSec.Equals(other.OffsetSec)
&& Tier == other.Tier;
/// <inheritdoc/>
public override bool Equals(object? obj) => obj is AudioSignatureMatch other && Equals(other);
/// <inheritdoc/>
public override int GetHashCode() => HashCode.Combine(Score, OffsetFrames, OffsetSec, Tier);
}
@@ -0,0 +1,207 @@
using System;
using Jellyfin.Plugin.JRay.Configuration;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Reads v1 audio signatures and aligns two of them, recovering the time offset
/// between differently trimmed releases of one cut.
/// </summary>
/// <remarks>
/// The <b>consumer</b> half of the signature feature: <see cref="AudioSignature"/>
/// 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.
/// <para>
/// 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 <b>peak band</b> 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.
/// </para>
/// </remarks>
// TRACES: JR-044, JR-045 | SR-003
public static class AudioSignatureMatcher
{
/// <summary>Widest alignment searched, in frames (±56 s).</summary>
/// <remarks>
/// 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.
/// </remarks>
public const int MaxOffsetFrames = 600;
/// <summary>Score at or above which two signatures are the same cut.</summary>
public const double AudioThreshold = 0.85;
/// <summary>Score at or above which two signatures are possibly the same cut.</summary>
public const double LooseThreshold = 0.60;
/// <summary>
/// Fewest overlapping frames an alignment must have before its score counts.
/// </summary>
/// <remarks>
/// <b>Not from the specification</b> — 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.
/// </remarks>
public const int MinOverlapFrames = 64;
/// <summary>Gets the duration one STFT frame advances, in seconds.</summary>
public static double FrameSeconds => (double)AudioSignature.HopSize / AudioSignature.SampleRate;
/// <summary>
/// Parses a signature string into its per-frame bytes, refusing anything that
/// is not a well-formed <c>v1:</c> signature.
/// </summary>
/// <remarks>
/// JR-045. An unknown prefix is <b>refused, not parsed</b>: a <c>v2:</c>
/// 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
/// <c>schema_version</c>.
/// <para>
/// 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.
/// </para>
/// </remarks>
/// <param name="signature">A signature string, or <c>null</c>.</param>
/// <returns>The frame bytes, or <c>null</c> if this is not a v1 signature.</returns>
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<byte> 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;
}
/// <summary>
/// Aligns a local signature against a remote one and reports the tier and
/// offset it earns.
/// </summary>
/// <remarks>
/// Returns <c>null</c> — no match, and therefore <b>no offset</b> — 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 <see cref="LooseThreshold"/>.
/// <para>
/// 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.
/// </para>
/// </remarks>
/// <param name="localSignature">Signature computed from the local file.</param>
/// <param name="remoteSignature">Signature carried by the fetched manifest.</param>
/// <param name="localRuntimeSec">Runtime of the local file, in seconds.</param>
/// <param name="remoteRuntimeSec">Runtime the manifest records, in seconds.</param>
/// <returns>The match, or <c>null</c> when the two do not align.</returns>
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,
};
}
}