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