diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml index c74fcb0..d4872d2 100644 --- a/.gitea/workflows/test.yaml +++ b/.gitea/workflows/test.yaml @@ -42,6 +42,16 @@ jobs: working-directory: test-${{ github.run_id }} run: dotnet build Jellyfin.Plugin.JRay.sln --configuration Debug --no-restore --no-self-contained /m:1 + # The workflow is named "Test Plugin" and until now only compiled one. A + # test that is built but never run is not evidence, and JR-043 is the case + # that makes it matter: the point of a golden vector shared with the + # extraction repo is that CI fails when the two implementations drift. + # T1 needs no ASP.NET runtime and no FFmpeg — the audio golden check + # regenerates its own fixture PCM. + - name: Run tests + working-directory: test-${{ github.run_id }} + run: dotnet test Jellyfin.Plugin.JRay.sln --configuration Debug --no-restore --no-build + - name: Cleanup if: always() run: rm -rf test-${{ github.run_id }} diff --git a/Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs b/Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs new file mode 100644 index 0000000..69e9be9 --- /dev/null +++ b/Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs @@ -0,0 +1,366 @@ +using System; +using System.Buffers.Binary; +using System.Globalization; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.JRay.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Plugin.JRay.Tests; + +/// +/// JR-042 (the signature is computed exactly per server specification §3) and +/// JR-043 (the golden-vector fixture shared with the extraction repo). +/// +/// The headline claim is cross-repo: the C++ pipeline and this plugin are two +/// independent implementations of one fingerprint, and two fingerprints that +/// differ in any parameter simply do not match. That claim is only worth +/// anything if it is checked, so `fixtures/audio/` holds the same three files +/// the extraction repo holds — byte-identical — and these tests assert against +/// the values recorded in them, never against each other. +/// +/// The binding check needs no FFmpeg. `make_fixture.py` generates the fixture +/// media from plain arithmetic, so UT-038 regenerates that PCM here and proves +/// it is byte-identical to what the pipeline decoded, using the checksums the +/// fixture records. Everything after that is pure DSP, which is what lets this +/// run on any CI host — the extraction repo's counterpart (UT-101) drives the +/// same vector through libavcodec, and both must land on the same string. +/// +/// TRACES: UT-038, UT-039, UT-040, UT-041, UT-042, UT-043, UT-044 | JR-042, JR-043 +/// +public class AudioSignatureTests +{ + private static readonly string FixtureDir = + Path.Combine(AppContext.BaseDirectory, "fixtures", "audio"); + + private static readonly Lazy GoldenDoc = new(() => + JsonDocument.Parse(File.ReadAllText(Path.Combine(FixtureDir, "jray_audio_v1_golden.json")))); + + private static readonly Lazy FixturePcm = new(GenerateFixturePcm); + + private static readonly Lazy FixtureSamples = new(() => + { + var pcm = FixturePcm.Value; + var samples = new float[pcm.Length]; + for (var i = 0; i < pcm.Length; i++) + { + // FFmpeg's native s16 -> flt conversion. 1/32768 is a power of two, + // so this is exact rather than merely close. + samples[i] = pcm[i] / 32768f; + } + + return samples; + }); + + private static JsonElement Golden => GoldenDoc.Value.RootElement; + + private static string GoldenSignature => Golden.GetProperty("signature").GetString()!; + + // UT-038 + [Fact] + public void RegeneratedFixturePcm_MatchesTheRecordedDecodedWindow() + { + // Checked before the signature, and separately from it, so a mismatch + // is diagnosable: if this passes and UT-039 fails, the DSP diverged; if + // this fails, the input did, and the signature comparison would only + // have told you "different" without saying where. + var pcm = FixturePcm.Value; + var decoded = Golden.GetProperty("decoded_window"); + + Assert.Equal(decoded.GetProperty("samples").GetInt32(), pcm.Length); + + var s16 = new byte[pcm.Length * sizeof(short)]; + for (var i = 0; i < pcm.Length; i++) + { + BinaryPrimitives.WriteInt16LittleEndian(s16.AsSpan(i * sizeof(short)), pcm[i]); + } + + Assert.Equal(Hex64(decoded.GetProperty("s16le_fnv1a64").GetString()!), Fnv1a64(s16)); + + var f32 = new byte[FixtureSamples.Value.Length * sizeof(float)]; + for (var i = 0; i < FixtureSamples.Value.Length; i++) + { + BinaryPrimitives.WriteSingleLittleEndian(f32.AsSpan(i * sizeof(float)), FixtureSamples.Value[i]); + } + + Assert.Equal(Hex64(decoded.GetProperty("f32le_fnv1a64").GetString()!), Fnv1a64(f32)); + } + + // UT-039 + [Fact] + public void Signature_OfTheGoldenFixture_MatchesTheRecordedValueExactly() + { + // The cross-repo check. Not "close", not "matches to within a tier" — + // the same string the C++ producer emits for the same audio. + Assert.Equal(GoldenSignature, AudioSignature.FromMonoSamples(FixtureSamples.Value)); + } + + // UT-040 + [Fact] + public void BandTable_MatchesTheRecordedOne_AndTilesTheRangeExactly() + { + // The band-to-FFT-bin table is the part of the construction most likely + // to drift between two implementations — an off-by-one in a ceiling, a + // half-open range read as closed — so it is pinned independently of the + // signature it produces. + var table = AudioSignature.BandFftBins(); + var want = Golden.GetProperty("band_fft_bins"); + + Assert.Equal(want.GetArrayLength(), table.Count); + for (var b = 0; b < table.Count; b++) + { + Assert.Equal(want[b][0].GetInt32(), table[b].Low); + Assert.Equal(want[b][1].GetInt32(), table[b].High); + Assert.True(table[b].High > table[b].Low, $"band {b} is empty"); + if (b > 0) + { + // Contiguous, so the frame energy really is the sum of the band + // sums — no gap, no bin counted twice. + Assert.Equal(table[b - 1].High, table[b].Low); + } + } + } + + // UT-041 + [Fact] + public void Signature_IsWellFormed_PrefixFrameCountAndStructuralBytes() + { + var signature = AudioSignature.FromMonoSamples(FixtureSamples.Value); + Assert.NotNull(signature); + + // JR-045 — the signature carries its own version, separate from + // schema_version, so a future DSP change is detectable rather than + // silently producing signatures that no longer match. + Assert.StartsWith(AudioSignature.VersionPrefix, signature, StringComparison.Ordinal); + + var bytes = Convert.FromBase64String(signature[AudioSignature.VersionPrefix.Length..]); + Assert.Equal(Golden.GetProperty("frame_count").GetInt32(), bytes.Length); + Assert.Equal(AudioSignature.ExpectedFrames, bytes.Length); + + // The server validates this structure on upload: each byte is a 5-bit + // band index plus a 2-bit energy class, so bit 7 is always clear and an + // arbitrary byte is not a valid signature. That is what keeps the field + // from being usable as a payload channel. + var bandsSeen = new bool[AudioSignature.NumBands]; + var classesSeen = new bool[4]; + foreach (var b in bytes) + { + Assert.Equal(0, b & 0x80); + bandsSeen[(b >> 2) & 0x1F] = true; + classesSeen[b & 0x03] = true; + } + + // The fixture is built to exercise the whole output alphabet. If it ever + // stops doing so, the golden vector has become a weaker check than it + // looks — so that property is asserted rather than assumed. + Assert.All(bandsSeen, Assert.True); + Assert.All(classesSeen, Assert.True); + } + + // UT-042 + [Fact] + public void PackFrames_UsesWholeFramesOnly() + { + Assert.Empty(AudioSignature.PackFrames(new float[AudioSignature.FrameSize - 1])); + Assert.Single(AudioSignature.PackFrames(new float[AudioSignature.FrameSize])); + Assert.Single(AudioSignature.PackFrames( + new float[AudioSignature.FrameSize + AudioSignature.HopSize - 1])); + Assert.Equal(2, AudioSignature.PackFrames( + new float[AudioSignature.FrameSize + AudioSignature.HopSize].AsSpan()).Length); + + // A partial frame is not a signature: below one frame there is nothing + // to emit, and emitting a padded frame would be a different fingerprint + // from the pipeline's. + Assert.Null(AudioSignature.FromMonoSamples(new float[AudioSignature.FrameSize - 1])); + + // The full window is 1288 frames — asserted as a constant rather than by + // running the DSP over 1.3M zeros, which is the same claim for free. + Assert.Equal(1323000, AudioSignature.WindowSamples); + Assert.Equal( + 1 + ((AudioSignature.WindowSamples - AudioSignature.FrameSize) / AudioSignature.HopSize), + AudioSignature.ExpectedFrames); + } + + // UT-043 + [Fact] + public async Task Decode_ThroughFfmpeg_ReproducesTheGoldenSignature() + { + // The one test that exercises the real decode — the command line, the + // stream selection, the downmix and resample — rather than the DSP + // alone. It needs an FFmpeg binary, which the plugin gets from Jellyfin + // at run time and which a bare CI container may not have; UT-038 and + // UT-039 are what make the cross-repo claim binding without one. + var ffmpeg = FindFfmpeg(); + if (ffmpeg is null) + { + return; + } + + var signature = await AudioSignatureService.ComputeWithEncoderAsync( + ffmpeg, + Path.Combine(FixtureDir, "jray_audio_v1_tone.flac"), + AudioSignature.WindowSec, + NullLogger.Instance, + CancellationToken.None).ConfigureAwait(true); + + Assert.Equal(GoldenSignature, signature); + } + + // UT-044 + [Fact] + public async Task Decode_TakesTheWindowFromTheCentre_NotTheHead() + { + // Sampling from the centre is the whole reason the construction avoids + // the head and tail — logos and cold opens at one end, credits at the + // other — so it needs its own check. Nothing else here pins the seek: a + // head-anchored window passes every other test in this file. + var ffmpeg = FindFfmpeg(); + if (ffmpeg is null) + { + return; + } + + var pad = 90 * AudioSignature.SampleRate; + var padded = new short[(pad * 2) + FixturePcm.Value.Length]; + FixturePcm.Value.CopyTo(padded, pad); + + var wav = Path.Combine(Path.GetTempPath(), $"jray_audio_centre_{Environment.ProcessId}.wav"); + try + { + WriteWav(wav, padded); + var signature = await AudioSignatureService.ComputeWithEncoderAsync( + ffmpeg, + wav, + padded.Length / (double)AudioSignature.SampleRate, + NullLogger.Instance, + CancellationToken.None).ConfigureAwait(true); + + Assert.Equal(GoldenSignature, signature); + } + finally + { + File.Delete(wav); + } + } + + /// + /// Regenerates the fixture media's PCM, as make_fixture.py defines + /// it: 120 s of tones stepping through all 32 log-bands, amplitudes walking + /// a golden-ratio sequence so all four energy classes appear, over a quiet + /// constant 777 Hz bed so no frame is degenerate. + /// + /// + /// A port, not a re-derivation — the point is that a repo can rebuild the + /// input from scratch and check the result against the recorded checksums, + /// which is what UT-038 does. The closest sample to a quantisation boundary + /// sits 3.7e-7 away from one, so the result does not depend on which libm + /// rounds the sine. + /// + private static short[] GenerateFixturePcm() + { + const int Segment = 32768; + const int BandStride = 7; + const double AmpLogMin = -1.55; + const double AmpLogSpan = 1.53; + const double PhiFrac = 0.6180339887498949; + const double BackgroundHz = 777.0; + const double BackgroundAmp = 0.004; + + var n = (int)Math.Round(AudioSignature.SampleRate * AudioSignature.WindowSec); + var samples = new short[n]; + var twoPi = 2.0 * Math.PI; + var phase = 0.0; + + for (var start = 0; start < n; start += Segment) + { + var s = start / Segment; + var end = Math.Min(n, start + Segment); + var band = (s * BandStride) % AudioSignature.NumBands; + var freq = AudioSignature.BandLoHz * Math.Pow( + AudioSignature.BandHiHz / AudioSignature.BandLoHz, + (band + 0.5) / AudioSignature.NumBands); + var amp = Math.Pow(10.0, AmpLogMin + (AmpLogSpan * ((s * PhiFrac) % 1.0))); + var step = twoPi * freq / AudioSignature.SampleRate; + + for (var k = 0; k < end - start; k++) + { + var i = start + k; + var x = amp * Math.Sin(phase + (step * k)); + x += BackgroundAmp * Math.Sin(twoPi * BackgroundHz * i / AudioSignature.SampleRate); + x = Math.Clamp(x, -1.0, 1.0); + samples[i] = (short)Math.Floor((x * 32767.0) + 0.5); + } + + phase = (phase + (step * (end - start))) % twoPi; + } + + return samples; + } + + private static void WriteWav(string path, short[] samples) + { + var dataBytes = samples.Length * sizeof(short); + using var stream = File.Create(path); + using var writer = new BinaryWriter(stream); + writer.Write("RIFF"u8); + writer.Write(36 + dataBytes); + writer.Write("WAVE"u8); + writer.Write("fmt "u8); + writer.Write(16); // PCM header size + writer.Write((short)1); // PCM + writer.Write((short)1); // mono + writer.Write(AudioSignature.SampleRate); + writer.Write(AudioSignature.SampleRate * sizeof(short)); + writer.Write((short)sizeof(short)); // block align + writer.Write((short)16); // bits per sample + writer.Write("data"u8); + writer.Write(dataBytes); + foreach (var sample in samples) + { + writer.Write(sample); + } + } + + 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; + } + + private static ulong Fnv1a64(ReadOnlySpan data) + { + var hash = 0xcbf29ce484222325UL; + foreach (var b in data) + { + hash ^= b; + hash *= 0x100000001b3UL; + } + + return hash; + } + + private static ulong Hex64(string value) + { + var digits = value.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? value[2..] : value; + return ulong.Parse(digits, NumberStyles.HexNumber, CultureInfo.InvariantCulture); + } +} diff --git a/Jellyfin.Plugin.JRay.Tests/Jellyfin.Plugin.JRay.Tests.csproj b/Jellyfin.Plugin.JRay.Tests/Jellyfin.Plugin.JRay.Tests.csproj index eeeb369..f1c45a8 100644 --- a/Jellyfin.Plugin.JRay.Tests/Jellyfin.Plugin.JRay.Tests.csproj +++ b/Jellyfin.Plugin.JRay.Tests/Jellyfin.Plugin.JRay.Tests.csproj @@ -53,4 +53,16 @@ + + + + PreserveNewest + + + diff --git a/Jellyfin.Plugin.JRay.Tests/fixtures/audio/jray_audio_v1_golden.json b/Jellyfin.Plugin.JRay.Tests/fixtures/audio/jray_audio_v1_golden.json new file mode 100644 index 0000000..9e88847 --- /dev/null +++ b/Jellyfin.Plugin.JRay.Tests/fixtures/audio/jray_audio_v1_golden.json @@ -0,0 +1,194 @@ +{ + "_": "Golden vector for the JRay v1 audio signature (JRay-public-server SPEC.md \u00a73). Shared verbatim between scene-actor-extraction (C++) and the jRay Jellyfin plugin (C#) so the two implementations can be proven bit-identical. IR-004, IR-005, IR-007, IR-008.", + "version": "v1", + "signature": "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeHx8eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh8fHzk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5V1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dycnJycnJycnJycnJycnJycnJycnJycnJycnJycnMPDgwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKytFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRWNjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2Njfn5+fn5+fn5+fn5+fn5+fn5+fn5+fn5+fn5+fn5/GxoZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGTc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3UlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSU1JsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbAoLCwoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCwsLJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSVDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ15eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eX19eeXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXkXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFzExMTExMTExMTExMTExMTExMTExMTExMTExMTExT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09qampqampqampqampqampqampqampqampqampqamsHBwUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyM+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4/PlhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYd3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3ExMRERERERERERERERERERERERERERERERERERERES8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vSkpKSkpKSkpKSkpKSkpKSkpKSkpKSkpKSkpKSkpLS0plZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZQMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0eHh44ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4OFdXV1ZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWV1dXcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXEPDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDyoqKioqKioqKioqKioqKioqKioqKioqKioqKioqKysqRERERERERERERERERERERERERERERERERERERERjY2NjY2NjY2NjYw==", + "frame_count": 1288, + "media": { + "file": "jray_audio_v1_tone.flac", + "generator": "make_fixture.py", + "container": "FLAC (lossless \u2014 decodes to exactly the PCM make_fixture.py emits)", + "duration_sec": 120.0, + "sample_rate": 11025, + "channels": 1, + "sample_format": "s16", + "sha256": "912ecd426cd426dccb37753e0249694227619c701cb9f533502b37da0fbe8096", + "bytes": 585142 + }, + "decoded_window": { + "_": "Checksums of the 120 s centre window after downmix to mono and resample to 11025 Hz, i.e. exactly the stream `ffmpeg -ss -t 120 -i -vn -ac 1 -ar 11025 -f f32le -` produces. Check these first: a mismatch here is a decode problem, not a DSP one.", + "samples": 1323000, + "f32le_fnv1a64": "0x1ef7899cd4d12662", + "s16le_fnv1a64": "0xf824fa56f125c0dc" + }, + "params": { + "window_sec": 120.0, + "window_centre": "runtime/2, i.e. samples from runtime/2 - 60 s; truncated to exactly 1323000 samples", + "min_duration_sec": 120.0, + "min_duration_rule": "IR-007 \u2014 below this emit NO signature and apply no sync offset", + "sample_rate": 11025, + "channels": 1, + "arithmetic": "IEEE-754 double throughout; float32 is not sufficient", + "sample_scale": "s16 * (1/32768), FFmpeg's native s16->flt", + "frame_size": 4096, + "hop_size": 1024, + "frame_count_rule": "1 + (n_samples - 4096) / 1024, integer division; whole frames only", + "window_fn": "Hann, PERIODIC: w[n] = 0.5 * (1 - cos(2*pi*n/4096))", + "transform": "radix-2 DIT complex FFT over the 4096 real samples (imag=0), no normalisation", + "magnitude": "sqrt(re^2 + im^2), linear", + "band_lo_hz": 300.0, + "band_hi_hz": 3000.0, + "num_bands": 32, + "band_edges": "edge[b] = 300 * (3000/300)^(b/32), b = 0..32", + "band_bins": "band b owns FFT bins [k_lo[b], k_lo[b+1]) with k_lo[b] = ceil(edge[b] * 4096 / 11025); see band_fft_bins", + "band_value": "MEAN of the linear magnitudes in the band (not sum, not max)", + "peak_bin": "argmax over the 32 band values; ties resolve to the LOWEST index", + "energy_metric": "E = mean magnitude over all FFT bins 112..1114, i.e. the whole 300-3000 Hz band", + "energy_reference": "upper median of E over all frames: sorted[n/2], no averaging of the two middle values", + "energy_ratio": "r = log10((E + 1e-12) / (E_ref + 1e-12))", + "energy_class_edges": [ + -0.6, + -0.2, + 0.2 + ], + "energy_class": "0 if r < -0.6, 1 if r < -0.2, 2 if r < 0.2, else 3", + "byte_layout": "bit7 = 0 (reserved), bits6..2 = 5-bit band index, bits1..0 = 2-bit energy class; byte = (band << 2) | class", + "base64": "standard alphabet A-Za-z0-9+/ with '=' padding", + "prefix": "v1:" + }, + "band_fft_bins": [ + [ + 112, + 120 + ], + [ + 120, + 129 + ], + [ + 129, + 139 + ], + [ + 139, + 149 + ], + [ + 149, + 160 + ], + [ + 160, + 172 + ], + [ + 172, + 185 + ], + [ + 185, + 199 + ], + [ + 199, + 213 + ], + [ + 213, + 229 + ], + [ + 229, + 246 + ], + [ + 246, + 265 + ], + [ + 265, + 285 + ], + [ + 285, + 306 + ], + [ + 306, + 328 + ], + [ + 328, + 353 + ], + [ + 353, + 379 + ], + [ + 379, + 408 + ], + [ + 408, + 438 + ], + [ + 438, + 471 + ], + [ + 471, + 506 + ], + [ + 506, + 543 + ], + [ + 543, + 584 + ], + [ + 584, + 627 + ], + [ + 627, + 674 + ], + [ + 674, + 724 + ], + [ + 724, + 778 + ], + [ + 778, + 836 + ], + [ + 836, + 899 + ], + [ + 899, + 966 + ], + [ + 966, + 1038 + ], + [ + 1038, + 1115 + ] + ], + "notes": [ + "The server spec fixes the window, rate, STFT geometry, band and the 5+2 bit packing. Everything under params beyond that (Hann periodicity, band aggregation, the energy-class definition, tie-breaking, base64 alphabet) is pinned HERE for v1 \u2014 the spec does not constrain it, and two implementations that guess differently produce non-matching signatures.", + "Decision margins on this fixture: the two strongest bands are within 1.3% on the closest frame, and the closest frame to an energy-class edge is 3.6e-3 away in log10. Both are many orders of magnitude above double-precision FFT differences, so any two correct double- precision implementations agree; a float32 implementation is not guaranteed to.", + "Coverage: all 32 bands and all 4 energy classes appear in the golden signature.", + "Robustness observed on this fixture: identical peak-bin sequence after a stereo/44100 Hz round trip and after AAC 128 kbit/s re-encoding." + ] +} diff --git a/Jellyfin.Plugin.JRay.Tests/fixtures/audio/jray_audio_v1_tone.flac b/Jellyfin.Plugin.JRay.Tests/fixtures/audio/jray_audio_v1_tone.flac new file mode 100644 index 0000000..22c7e6c Binary files /dev/null and b/Jellyfin.Plugin.JRay.Tests/fixtures/audio/jray_audio_v1_tone.flac differ diff --git a/Jellyfin.Plugin.JRay.Tests/fixtures/audio/make_fixture.py b/Jellyfin.Plugin.JRay.Tests/fixtures/audio/make_fixture.py new file mode 100644 index 0000000..7cd1220 --- /dev/null +++ b/Jellyfin.Plugin.JRay.Tests/fixtures/audio/make_fixture.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Regenerate the JRay audio-signature golden fixture. + + python3 make_fixture.py # writes jray_audio_v1_tone.flac here + +This is the *source of truth* for the fixture media: `jray_audio_v1_tone.flac` +is a lossless FLAC encoding of exactly the PCM this script emits, so any repo +that wants to check its own audio-signature implementation against the golden +vector in `jray_audio_v1_golden.json` can regenerate the input from scratch and +confirm it is byte-identical (the golden file records `pcm_fnv1a64`, a hash of +the decoded 16-bit samples). + +Deliberately dependency-free (no numpy) and written in plain arithmetic so it +ports to any language in ~20 lines. + +Signal — 120.000 s, mono, 11025 Hz, 16-bit signed PCM: + + * split into segments of 32768 samples (~2.97 s), 40.4 segments in total; + * segment `s` carries one sine at the geometric centre of log-band + `(s * 7) mod 32` of the 300-3000 Hz band, so all 32 bands are exercised; + * its amplitude walks a golden-ratio low-discrepancy sequence over + [10^-1.55, 10^-0.02] so frame energies spread continuously across ~1.5 + decades and all four energy classes are exercised, without a dense cluster + of frames sitting on a class boundary; + * phase is carried across segment boundaries (no clicks); + * a constant, far quieter 777 Hz tone sits underneath so no frame is + degenerate; + * samples are quantised with floor(x * 32767 + 0.5). + +Why FLAC and not WAV: 120 s of 11025 Hz 16-bit PCM is 2.6 MB and does not +compress in git. FLAC is lossless — FFmpeg decodes it to exactly the PCM +written here — and is ~3.5x smaller. `--wav` writes the uncompressed original +if you want to diff it. +""" +import math +import struct +import subprocess +import sys +import os + +SAMPLE_RATE = 11025 +DURATION_SEC = 120.0 +SEGMENT = 32768 # samples per tone segment +BAND_STRIDE = 7 # coprime with 32 -> visits every band +BAND_LO_HZ = 300.0 +BAND_HI_HZ = 3000.0 +NUM_BANDS = 32 +AMP_LOG_MIN = -1.55 # 10^-1.55 ~= 0.028 +AMP_LOG_SPAN = 1.53 # up to 10^-0.02 ~= 0.955 +PHI_FRAC = 0.6180339887498949 +BG_HZ = 777.0 +BG_AMP = 0.004 + +OUT_FLAC = "jray_audio_v1_tone.flac" +OUT_WAV = "jray_audio_v1_tone.wav" + + +def generate(): + """Return the 120 s signal as a list of int16 sample values.""" + n = int(round(SAMPLE_RATE * DURATION_SEC)) + out = [0] * n + phase = 0.0 + two_pi = 2.0 * math.pi + for start in range(0, n, SEGMENT): + s = start // SEGMENT + end = min(n, start + SEGMENT) + band = (s * BAND_STRIDE) % NUM_BANDS + # geometric centre of log-band `band` + freq = BAND_LO_HZ * (BAND_HI_HZ / BAND_LO_HZ) ** ((band + 0.5) / NUM_BANDS) + amp = 10.0 ** (AMP_LOG_MIN + AMP_LOG_SPAN * ((s * PHI_FRAC) % 1.0)) + step = two_pi * freq / SAMPLE_RATE + for k in range(end - start): + i = start + k + x = amp * math.sin(phase + step * k) + x += BG_AMP * math.sin(two_pi * BG_HZ * i / SAMPLE_RATE) + if x > 1.0: + x = 1.0 + elif x < -1.0: + x = -1.0 + out[i] = int(math.floor(x * 32767.0 + 0.5)) + phase = (phase + step * (end - start)) % two_pi + return out + + +def write_wav(path, samples): + data = struct.pack("<%dh" % len(samples), *samples) + hdr = b"RIFF" + struct.pack("(); + + // Registered concretely: the signature is computed, not yet consumed, so + // there is no second implementation for an interface to abstract over + // and nothing to gain from inventing one (JR-042). + serviceCollection.AddSingleton(); } } diff --git a/Jellyfin.Plugin.JRay/Services/AudioSignature.cs b/Jellyfin.Plugin.JRay/Services/AudioSignature.cs new file mode 100644 index 0000000..4b940fa --- /dev/null +++ b/Jellyfin.Plugin.JRay/Services/AudioSignature.cs @@ -0,0 +1,373 @@ +using System; +using System.Collections.Generic; + +namespace Jellyfin.Plugin.JRay.Services; + +/// +/// The JRay v1 content-derived audio signature: spectral peak bins taken from +/// the centre of a media file, so a truth file is self-identifying. +/// +/// +/// The construction is owned by the public server specification §3 and is +/// implemented a second time, in C++, by the extraction pipeline +/// (src/audio_signature.*, extraction IR-004). The two must +/// agree byte for byte — a signature that differs in any parameter simply +/// does not match, which defeats the entire point of having one. Every constant +/// below is therefore load-bearing, and any change to one is a change to the +/// v1: prefix as well. +/// +/// The server specification's prose is not sufficient to reproduce a byte +/// stream, so the details it leaves open are pinned by the golden fixture +/// shared with the extraction repo (JR-043, +/// fixtures/audio/jray_audio_v1_golden.json), and restated here: +/// +/// +/// Arithmetic is IEEE-754 double throughout. float is not +/// sufficient: the fixture has frames whose two strongest bands are within 1.3% +/// of each other. +/// Samples arrive as FFmpeg's native s16 -> flt conversion, +/// x * (1/32768), widened to double here. +/// Whole frames only: +/// n_frames = 1 + (n_samples - 4096) / 1024, integer division. +/// Hann window, periodic: 0.5 * (1 - cos(2*pi*n/4096)), not +/// the symmetric N-1 variant. +/// Plain radix-2 FFT, no normalisation; magnitude is +/// sqrt(re^2 + im^2). +/// A band's value is the mean of the linear magnitudes in it, so a +/// wide high band is not favoured over a narrow low one. +/// The peak is the argmax over the 32 bands, ties to the lowest +/// index. The specification's log is a monotone squash and so cannot change an +/// argmax; it is applied only where it is observable, in the energy class. +/// +/// +/// The FFT is written out here rather than taken from a library for the same +/// reason the pipeline writes its own: it is a fixed, fully specified transform, +/// and a dependency whose version could change the numerics is a liability when +/// the output has to be identical across two languages. +/// +/// +// TRACES: JR-042 | SR-003 +public static class AudioSignature +{ + /// Sample rate the signature is computed at, in Hz. + public const int SampleRate = 11025; + + /// STFT frame size, in samples. + public const int FrameSize = 4096; + + /// STFT hop size, in samples (~93 ms). + public const int HopSize = 1024; + + /// Number of logarithmically spaced bands. + public const int NumBands = 32; + + /// Low edge of the analysed band, in Hz. + public const double BandLoHz = 300.0; + + /// High edge of the analysed band, in Hz. + public const double BandHiHz = 3000.0; + + /// Length of the analysed window, in seconds. + public const double WindowSec = 120.0; + + /// + /// Length of the analysed window, in samples (120.000 s at 11025 Hz). + /// + /// + /// The decoded window is truncated to exactly this, so the frame count is + /// the same for every input and does not wobble with seek granularity or a + /// resampler tail. + /// + public const int WindowSamples = 1323000; + + /// Frames a full window yields: 1 + (1323000 - 4096) / 1024. + /// + /// The server specification says "~1290" and accepts a tolerance; the exact + /// count follows from the framing rule and is 1288. + /// + public const int ExpectedFrames = 1288; + + /// Guard added to both sides of the energy ratio. + public const double EnergyEps = 1e-12; + + /// + /// The signature's own version prefix, separate from schema_version. + /// + /// + /// A future change to the DSP chain must be detectable rather than + /// silently producing signatures that no longer match (JR-045). + /// + public const string VersionPrefix = "v1:"; + + // Class thresholds on log10(E_frame / E_median). They deliberately straddle + // r = 0 rather than sit on it, so the median frame itself is never on a + // boundary. + private static readonly double[] EnergyClassEdges = [-0.6, -0.2, 0.2]; + + private static readonly (int Low, int High)[] Bands = BuildBandTable(); + private static readonly double[] Window = BuildHannWindow(); + private static readonly int[] BitReversal = BuildBitReversal(); + private static readonly double[][] TwiddleReal = BuildTwiddles(cosine: true); + private static readonly double[][] TwiddleImag = BuildTwiddles(cosine: false); + + /// + /// Gets the half-open FFT bin range [Low, High) owned by each of the + /// 32 log-spaced bands. + /// + /// + /// Exposed so the golden fixture can assert the table itself rather than + /// only the signature it produces: the band table is the part of the + /// construction most likely to drift between two implementations. + /// + /// One range per band, contiguous and non-overlapping. + public static IReadOnlyList<(int Low, int High)> BandFftBins() => Bands; + + /// + /// Packs one byte per whole STFT frame: a 5-bit peak band index and a 2-bit + /// energy class. + /// + /// + /// The byte layout is (band << 2) | class, so bit 7 is always + /// clear and an arbitrary byte is not a valid signature. That structural + /// constraint is what the server validates on upload, and what keeps the + /// field from being usable as a payload channel. + /// + /// Mono samples at , in [-1, 1). + /// One byte per frame; empty when not even one frame fits. + public static byte[] PackFrames(ReadOnlySpan mono) + { + if (mono.Length < FrameSize) + { + return []; + } + + var frames = 1 + ((mono.Length - FrameSize) / HopSize); + var binLow = Bands[0].Low; + var binHigh = Bands[NumBands - 1].High; // exclusive + double binCount = binHigh - binLow; + + var re = new double[FrameSize]; + var im = new double[FrameSize]; + var peak = new byte[frames]; + var energy = new double[frames]; + + for (var f = 0; f < frames; f++) + { + var src = mono.Slice(f * HopSize, FrameSize); + for (var n = 0; n < FrameSize; n++) + { + re[n] = src[n] * Window[n]; + im[n] = 0.0; + } + + Fft(re, im); + + // Per-band mean magnitude. The bands tile 300-3000 Hz with no gaps + // and no overlaps, so the frame's band-limited energy is the sum of + // the band sums — accumulated in band order, because the order of a + // floating-point summation is part of the contract. + var best = -1.0; + var bestBand = 0; + var total = 0.0; + for (var b = 0; b < NumBands; b++) + { + var (low, high) = Bands[b]; + var sum = 0.0; + for (var k = low; k < high; k++) + { + sum += Math.Sqrt((re[k] * re[k]) + (im[k] * im[k])); + } + + total += sum; + var mean = sum / (high - low); + if (mean > best) + { + best = mean; // ties -> lowest index + bestBand = b; + } + } + + peak[f] = (byte)bestBand; + energy[f] = total / binCount; + } + + // The reference is the upper median of the frame energies: an actually + // observed value rather than the average of the two middle ones, so it + // is bit-reproducible. It is also gain-invariant — loudness + // normalisation must not change a signature — and barely moves when the + // window is trimmed. + var sorted = (double[])energy.Clone(); + Array.Sort(sorted); + var reference = sorted[sorted.Length / 2]; + + var packed = new byte[frames]; + for (var f = 0; f < frames; f++) + { + var r = Math.Log10((energy[f] + EnergyEps) / (reference + EnergyEps)); + packed[f] = (byte)(((peak[f] & 0x1F) << 2) | (EnergyClass(r) & 0x03)); + } + + return packed; + } + + /// + /// Computes the full signature string for a decoded centre window. + /// + /// Mono samples at , in [-1, 1). + /// + /// v1: followed by the base64 of , or + /// null when not even one frame fits. + /// + public static string? FromMonoSamples(ReadOnlySpan mono) + { + var packed = PackFrames(mono); + if (packed.Length == 0) + { + return null; + } + + // Standard alphabet with '=' padding, which is what the server and the + // pipeline both emit. + return VersionPrefix + Convert.ToBase64String(packed); + } + + private static int EnergyClass(double ratio) + { + if (ratio < EnergyClassEdges[0]) + { + return 0; + } + + if (ratio < EnergyClassEdges[1]) + { + return 1; + } + + if (ratio < EnergyClassEdges[2]) + { + return 2; + } + + return 3; + } + + // edge[b] = 300 * (3000/300)^(b/32); band b owns FFT bins + // [k_lo[b], k_lo[b+1]) with k_lo[b] = ceil(edge[b] / hz_per_bin). Taking the + // ceiling once, into an integer table, means membership is never decided by + // a float comparison per bin per frame — which is where two implementations + // would otherwise be free to disagree. + private static (int Low, int High)[] BuildBandTable() + { + var hzPerBin = (double)SampleRate / FrameSize; + var edges = new int[NumBands + 1]; + for (var b = 0; b <= NumBands; b++) + { + var hz = BandLoHz * Math.Pow(BandHiHz / BandLoHz, (double)b / NumBands); + edges[b] = (int)Math.Ceiling(hz / hzPerBin); + } + + var table = new (int Low, int High)[NumBands]; + for (var b = 0; b < NumBands; b++) + { + table[b] = (edges[b], edges[b + 1]); + } + + return table; + } + + private static double[] BuildHannWindow() + { + var w = new double[FrameSize]; + for (var n = 0; n < FrameSize; n++) + { + w[n] = 0.5 * (1.0 - Math.Cos(2.0 * Math.PI * n / FrameSize)); + } + + return w; + } + + private static int[] BuildBitReversal() + { + var bits = 0; + while ((1 << bits) < FrameSize) + { + bits++; + } + + var rev = new int[FrameSize]; + for (var i = 0; i < FrameSize; i++) + { + var r = 0; + for (var b = 0; b < bits; b++) + { + if ((i & (1 << b)) != 0) + { + r |= 1 << (bits - 1 - b); + } + } + + rev[i] = r; + } + + return rev; + } + + // Twiddles are precomputed per stage from cos/sin of -2*pi*j/len, so the + // angle is an exactly reproducible double in either language and only the + // library's own rounding of cos/sin (<= 1 ulp) can differ — orders of + // magnitude below the decision margins the golden fixture records. + private static double[][] BuildTwiddles(bool cosine) + { + var stages = new List(); + for (var len = 2; len <= FrameSize; len <<= 1) + { + var half = len / 2; + var stage = new double[half]; + for (var j = 0; j < half; j++) + { + var angle = -2.0 * Math.PI * j / len; + stage[j] = cosine ? Math.Cos(angle) : Math.Sin(angle); + } + + stages.Add(stage); + } + + return [.. stages]; + } + + // Radix-2 decimation-in-time complex FFT, in place, no normalisation. + private static void Fft(double[] re, double[] im) + { + for (var i = 0; i < FrameSize; i++) + { + var j = BitReversal[i]; + if (i < j) + { + (re[i], re[j]) = (re[j], re[i]); + (im[i], im[j]) = (im[j], im[i]); + } + } + + var stage = 0; + for (var len = 2; len <= FrameSize; len <<= 1, stage++) + { + var half = len / 2; + var wr = TwiddleReal[stage]; + var wi = TwiddleImag[stage]; + for (var start = 0; start < FrameSize; start += len) + { + for (var j = 0; j < half; j++) + { + var a = start + j; + var b = a + half; + var tr = (re[b] * wr[j]) - (im[b] * wi[j]); + var ti = (re[b] * wi[j]) + (im[b] * wr[j]); + re[b] = re[a] - tr; + im[b] = im[a] - ti; + re[a] += tr; + im[a] += ti; + } + } + } + } +} diff --git a/Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs b/Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs new file mode 100644 index 0000000..95cc293 --- /dev/null +++ b/Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs @@ -0,0 +1,285 @@ +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; + +/// +/// Computes the v1 audio signature for a media file, decoding its centre window +/// with the FFmpeg binary Jellyfin already ships. +/// +/// +/// No new dependency. FFmpeg performs decode, downmix and resample; the +/// plugin adds only the fixed FFT and bin-peak extraction in +/// . The binary is reached through +/// , so an installation that can +/// transcode can compute signatures, with nothing further to install and no +/// second copy of FFmpeg to keep in step. +/// +/// The pipeline computes the same signature for files it processes locally +/// (extraction IR-004); 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). +/// +/// +/// Every failure degrades to no signature rather than to an error. A +/// signature is an enhancement to cut matching; a missing one costs a tier, and +/// must never be able to break a fetch. +/// +/// +// TRACES: JR-042 | SR-003 +public class AudioSignatureService +{ + private readonly IMediaEncoder _encoder; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Supplies the path of the FFmpeg binary Jellyfin ships. + /// Logger. + public AudioSignatureService(IMediaEncoder encoder, ILogger logger) + { + _encoder = encoder; + _logger = logger; + } + + /// + /// Computes the signature of the 120 s window centred on the media's + /// midpoint. + /// + /// + /// 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. + /// + /// Media shorter than yields + /// null: 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). + /// + /// + /// Path of the media file. + /// The item's runtime, as Jellyfin knows it. + /// Cancellation token. + /// + /// The v1:-prefixed signature, or null for short media, media + /// with no usable audio, and any decode failure. + /// + public Task ComputeAsync( + string path, + double runtimeSeconds, + CancellationToken cancellationToken) + => ComputeWithEncoderAsync(_encoder?.EncoderPath, path, runtimeSeconds, _logger, cancellationToken); + + /// + /// with the FFmpeg binary named explicitly. + /// + /// + /// Internal so the golden-fixture test can drive the real decode path with + /// whatever FFmpeg the machine has, rather than standing up a fake + /// — a stub of a thirty-member interface would + /// be the larger risk of the two, and it is the decode that is under test. + /// + /// Path of the FFmpeg binary to run. + /// Path of the media file. + /// The item's runtime, as Jellyfin knows it. + /// Logger. + /// Cancellation token. + /// The v1:-prefixed signature, or null. + internal static async Task 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; + } + } + + /// + /// Decodes the centre window as mono 32-bit float PCM at 11025 Hz. + /// + /// + /// The stream is truncated to exactly + /// samples, so the frame count is + /// the same for every input rather than wobbling with seek granularity or a + /// resampler tail. + /// + /// No -map is given: FFmpeg's default audio selection is the same + /// "best stream" choice the pipeline makes with + /// av_find_best_stream, and naming 0:a:0 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. + /// + /// + private static async Task 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 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; + } +}