Files
jRay/Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs
T
dtourolle 17be9bcc4e feat(audio): v1 signature producer, bit-exact with the pipeline
`AudioSignature` is the DSP — band table, periodic Hann, radix-2 FFT,
band-mean peak, energy class, packing — and `AudioSignatureService` the
decode, running the FFmpeg binary `IMediaEncoder.EncoderPath` names. The
plugin gained no dependency.

The server specification's prose does not determine a byte stream, so the
parameters it leaves open are pinned by the fixture shared with the
extraction repo and restated at the top of `AudioSignature`: double
throughout, whole frames only, periodic Hann, unnormalised FFT, band mean
rather than sum, argmax ties to the lowest index.

`fixtures/audio/` holds the extraction repo's three files byte-identically
and the computed signature equals the recorded vector exactly. The binding
check regenerates the fixture PCM from `make_fixture.py`'s arithmetic and
verifies it against the recorded decode checksums, so it runs on a host
with no codec at all and a decode divergence stays distinguishable from a
DSP one; the two tests that drive real FFmpeg self-skip without a binary.

The workflow named "Test Plugin" until now only compiled one. A test that
is built and never run is not evidence, and a golden vector shared across
two repos exists precisely so CI fails when they drift.

TRACES: JR-042, JR-043 | SR-003
2026-07-31 16:24:11 +02:00

286 lines
11 KiB
C#

using System;
using System.Buffers.Binary;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Computes the v1 audio signature for a media file, decoding its centre window
/// with the FFmpeg binary Jellyfin already ships.
/// </summary>
/// <remarks>
/// <b>No new dependency.</b> FFmpeg performs decode, downmix and resample; the
/// plugin adds only the fixed FFT and bin-peak extraction in
/// <see cref="AudioSignature"/>. The binary is reached through
/// <see cref="IMediaEncoder.EncoderPath"/>, so an installation that can
/// transcode can compute signatures, with nothing further to install and no
/// second copy of FFmpeg to keep in step.
/// <para>
/// The pipeline computes the same signature for files it processes locally
/// (extraction <c>IR-004</c>); this exists for the files it never sees. Both
/// producers must therefore agree exactly, including on the decode: the command
/// below is the CLI spelling of what the pipeline asks libswresample for — best
/// audio stream, mono, 11025 Hz, 32-bit float — and the golden fixture pins the
/// decoded PCM as well as the signature, so a codec-level divergence is
/// distinguishable from a DSP-level one (JR-043).
/// </para>
/// <para>
/// <b>Every failure degrades to no signature rather than to an error.</b> A
/// signature is an enhancement to cut matching; a missing one costs a tier, and
/// must never be able to break a fetch.
/// </para>
/// </remarks>
// TRACES: JR-042 | SR-003
public class AudioSignatureService
{
private readonly IMediaEncoder _encoder;
private readonly ILogger<AudioSignatureService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="AudioSignatureService"/> class.
/// </summary>
/// <param name="encoder">Supplies the path of the FFmpeg binary Jellyfin ships.</param>
/// <param name="logger">Logger.</param>
public AudioSignatureService(IMediaEncoder encoder, ILogger<AudioSignatureService> logger)
{
_encoder = encoder;
_logger = logger;
}
/// <summary>
/// Computes the signature of the 120 s window centred on the media's
/// midpoint.
/// </summary>
/// <remarks>
/// The centre is used because the head and tail are the least
/// content-specific parts of a release: logos and cold opens at one end,
/// credits at the other.
/// <para>
/// Media shorter than <see cref="AudioSignature.WindowSec"/> yields
/// <c>null</c>: the window underflows, so there is no signature — the
/// identical rule the extraction producer applies, since diverging here
/// would break exactly the short items most likely to be misidentified
/// (JR-044, whose remaining half — applying no sync offset — belongs with
/// signature matching).
/// </para>
/// </remarks>
/// <param name="path">Path of the media file.</param>
/// <param name="runtimeSeconds">The item's runtime, as Jellyfin knows it.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// The <c>v1:</c>-prefixed signature, or <c>null</c> for short media, media
/// with no usable audio, and any decode failure.
/// </returns>
public Task<string?> ComputeAsync(
string path,
double runtimeSeconds,
CancellationToken cancellationToken)
=> ComputeWithEncoderAsync(_encoder?.EncoderPath, path, runtimeSeconds, _logger, cancellationToken);
/// <summary>
/// <see cref="ComputeAsync"/> with the FFmpeg binary named explicitly.
/// </summary>
/// <remarks>
/// Internal so the golden-fixture test can drive the real decode path with
/// whatever FFmpeg the machine has, rather than standing up a fake
/// <see cref="IMediaEncoder"/> — a stub of a thirty-member interface would
/// be the larger risk of the two, and it is the decode that is under test.
/// </remarks>
/// <param name="encoderPath">Path of the FFmpeg binary to run.</param>
/// <param name="path">Path of the media file.</param>
/// <param name="runtimeSeconds">The item's runtime, as Jellyfin knows it.</param>
/// <param name="logger">Logger.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The <c>v1:</c>-prefixed signature, or <c>null</c>.</returns>
internal static async Task<string?> ComputeWithEncoderAsync(
string? encoderPath,
string path,
double runtimeSeconds,
ILogger logger,
CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(path) || runtimeSeconds < AudioSignature.WindowSec)
{
return null;
}
if (string.IsNullOrEmpty(encoderPath))
{
logger.LogDebug("No FFmpeg binary available; skipping the audio signature for {Path}", path);
return null;
}
try
{
var samples = await DecodeCentreWindowAsync(encoderPath, path, runtimeSeconds, logger, cancellationToken)
.ConfigureAwait(false);
if (samples is null)
{
return null;
}
return AudioSignature.FromMonoSamples(samples);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
logger.LogDebug(ex, "Audio signature failed for {Path}; continuing without one", path);
return null;
}
}
/// <summary>
/// Decodes the centre window as mono 32-bit float PCM at 11025 Hz.
/// </summary>
/// <remarks>
/// The stream is truncated to exactly
/// <see cref="AudioSignature.WindowSamples"/> samples, so the frame count is
/// the same for every input rather than wobbling with seek granularity or a
/// resampler tail.
/// <para>
/// No <c>-map</c> is given: FFmpeg's default audio selection is the same
/// "best stream" choice the pipeline makes with
/// <c>av_find_best_stream</c>, and naming <c>0:a:0</c> instead would pick a
/// different track from the pipeline's on any file whose first audio stream
/// is not its main one — a commentary track, say.
/// </para>
/// </remarks>
private static async Task<float[]?> DecodeCentreWindowAsync(
string encoderPath,
string path,
double runtimeSeconds,
ILogger logger,
CancellationToken cancellationToken)
{
var start = (runtimeSeconds / 2.0) - (AudioSignature.WindowSec / 2.0);
if (start < 0.0)
{
start = 0.0;
}
var startArgument = start.ToString("0.000", CultureInfo.InvariantCulture);
var windowArgument = AudioSignature.WindowSec.ToString("0.000", CultureInfo.InvariantCulture);
var rateArgument = AudioSignature.SampleRate.ToString(CultureInfo.InvariantCulture);
var startInfo = new ProcessStartInfo
{
FileName = encoderPath,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = false,
UseShellExecute = false,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-nostdin");
startInfo.ArgumentList.Add("-v");
startInfo.ArgumentList.Add("error");
// Input seeking, so FFmpeg does not decode the whole file to reach the
// middle of it. Accurate by default: it seeks to the preceding keyframe
// and discards the excess, which is what the pipeline does by hand.
startInfo.ArgumentList.Add("-ss");
startInfo.ArgumentList.Add(startArgument);
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(path);
startInfo.ArgumentList.Add("-t");
startInfo.ArgumentList.Add(windowArgument);
startInfo.ArgumentList.Add("-vn");
startInfo.ArgumentList.Add("-sn");
startInfo.ArgumentList.Add("-dn");
startInfo.ArgumentList.Add("-ac");
startInfo.ArgumentList.Add("1");
startInfo.ArgumentList.Add("-ar");
startInfo.ArgumentList.Add(rateArgument);
startInfo.ArgumentList.Add("-f");
startInfo.ArgumentList.Add("f32le");
startInfo.ArgumentList.Add("-");
using var process = new Process { StartInfo = startInfo };
if (!process.Start())
{
return null;
}
try
{
// Read stderr concurrently: it is redirected, so leaving it unread
// would deadlock the moment FFmpeg filled the pipe.
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
var bytes = await ReadWindowAsync(process.StandardOutput.BaseStream, cancellationToken)
.ConfigureAwait(false);
var error = await errorTask.ConfigureAwait(false);
if (bytes.Length < AudioSignature.FrameSize * sizeof(float))
{
// No audio stream, an unreadable file, or a runtime Jellyfin
// knows but the container does not support seeking into.
logger.LogDebug(
"FFmpeg returned {Bytes} bytes of audio for {Path}: {Error}",
bytes.Length,
path,
error);
return null;
}
var samples = new float[bytes.Length / sizeof(float)];
for (var i = 0; i < samples.Length; i++)
{
samples[i] = BinaryPrimitives.ReadSingleLittleEndian(bytes.AsSpan(i * sizeof(float)));
}
return samples;
}
finally
{
if (!process.HasExited)
{
try
{
process.Kill(entireProcessTree: true);
}
catch (InvalidOperationException)
{
// Exited between the check and the kill.
}
}
}
}
private static async Task<byte[]> ReadWindowAsync(Stream stream, CancellationToken cancellationToken)
{
var wanted = AudioSignature.WindowSamples * sizeof(float);
var buffer = new byte[wanted];
var filled = 0;
while (filled < wanted)
{
var read = await stream.ReadAsync(buffer.AsMemory(filled, wanted - filled), cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
break;
}
filled += read;
}
if (filled == wanted)
{
return buffer;
}
var truncated = new byte[filled];
Array.Copy(buffer, truncated, filled);
return truncated;
}
}