#pragma once // ── JRay audio signature, v1 ───────────────────────────────────────────────── // /// TRACES: IR-004, IR-005, IR-007, IR-008 | SR-003 // // A content-derived spectral-peak signature taken from the *centre* of the // media, so a truth file is self-identifying: a consumer can tell whether a // local file is the same cut as the one a manifest describes, and recover the // frame offset when it is the same cut trimmed differently. // // The construction is owned by `JRay-public-server/SPEC.md` §3 and is // reproduced by the JRay Jellyfin plugin in C#. **The two implementations must // agree byte for byte** — a signature that differs in any parameter simply does // not match, which defeats the entire point. Every deviation is therefore a // breaking change and must go through the `v1:` prefix (see kVersionPrefix). // // Server spec §3, restated: // // 1. Decode a 120 s window centred on the midpoint (runtime/2 ± 60 s). // 2. Downmix to mono, resample to 11025 Hz. // 3. STFT: 4096-sample frame, 1024-sample hop, Hann window (~1290 frames). // 4. Per frame, log-magnitude spectrum over 300–3000 Hz. // 5. 32 logarithmically spaced bins; peak bin index + coarse 2-bit energy // class. // 6. Pack one byte per frame; base64-encode. // 7. Prefix `v1:`. // // ── Details the server spec leaves open, pinned here for v1 ────────────────── // // The prose above is not sufficient to reproduce a byte stream, so the choices // below are the contract. They are mirrored in // `tests/fixtures/audio/jray_audio_v1_golden.json`, which is the artefact // shared with the plugin repo (IR-005). // // Arithmetic All DSP in IEEE-754 **double**. float32 is not sufficient: // the golden fixture has frames whose two strongest bands are // within 1.3% of each other, which double resolves identically // everywhere and float32 does not. // Sample scale FFmpeg's native s16→flt conversion, x * (1/32768), then // widened to double. Values in [-1, 1). // Framing Only whole frames: n_frames = 1 + (n_samples - 4096) / 1024, // integer division, 0 when n_samples < 4096. A 120.000 s // window is 1 323 000 samples → **1288 frames**. // ("~1290" in the spec; the server accepts a tolerance.) // Window Hann, **periodic**: w[n] = 0.5 * (1 - cos(2*pi*n/4096)). // Not the symmetric (N-1) variant. // Transform Plain radix-2 decimation-in-time complex FFT over 4096 real // samples (imag = 0), no normalisation. Magnitude is // sqrt(re² + im²). Twiddles from cos/sin of // -2*pi*k/len computed in double. // Band edges edge[b] = 300 * (3000/300)^(b/32), b = 0..32. Band b spans // FFT bins [k_lo[b], k_lo[b+1]) with // k_lo[b] = ceil(edge[b] * 4096 / 11025) — i.e. bins 112..1114 // inclusive, 8 bins in the narrowest band. Precomputed as an // integer table so no float comparison decides membership. // Band value **Mean** of the linear magnitudes in the band. Mean, not // sum, so a wide high band is not favoured over a narrow low // one; magnitude, not power, because it is an energy proxy and // more codec-robust than a single bin's peak. // Peak bin argmax over the 32 band values; ties resolve to the **lowest // index**. The log of step 4 is a monotone squash and so // cannot change an argmax — it is applied only where it is // observable, in the energy class below. // Energy class The spec says "coarse 2-bit energy class" and no more. v1 // defines it as the frame's band-limited energy relative to // the window, which is invariant to gain (loudness // normalisation must not change a signature) and robust to // trimming (the median barely moves): // E_f = mean magnitude over *all* FFT bins 112..1114 // Eref = median over frames of E_f, taken as the upper // median sorted[n/2] — no averaging of the two middle // values, so the reference is always an actual // observed value and is bit-reproducible // r = log10((E_f + 1e-12) / (Eref + 1e-12)) // class = 0 if r < -0.6, 1 if r < -0.2, 2 if r < 0.2, else 3 // The thresholds deliberately straddle r = 0 rather than sit // on it, so the median frame itself is not on a boundary. // Byte layout bit 7 = 0 (reserved), bits 6..2 = 5-bit band index, // bits 1..0 = 2-bit energy class: // byte = (band << 2) | class → always 0..127 // This is the structural constraint the server validates on // upload (§3 "Validation and abuse"). // Base64 Standard alphabet A–Za–z0–9+/ with '=' padding. // // ── Short media (IR-007) ───────────────────────────────────────────────────── // // `runtime/2 ± 60 s` underflows below 120 s, so **no signature is emitted** and // no sync offset is applied downstream. Both producers apply the identical // rule; diverging here would break exactly the short items most likely to be // misidentified. `compute_signature` returns `std::nullopt`. // // The same nullopt is returned for a file with no audio stream, an unopenable // file, or an unknown duration. UR-9 is an enhancement and must never be able // to break a fetch — degradation, not failure. #include #include #include #include #include #include #include namespace sae::audio { // ── Contract constants — changing any of these is a `v1:` bump ─────────────── inline constexpr int kSampleRate = 11025; inline constexpr int kFrameSize = 4096; inline constexpr int kHopSize = 1024; inline constexpr int kNumBands = 32; inline constexpr double kBandLoHz = 300.0; inline constexpr double kBandHiHz = 3000.0; inline constexpr double kWindowSec = 120.0; inline constexpr double kEnergyEps = 1e-12; // Class thresholds on log10(E_frame / E_median); see the header comment. inline constexpr double kEnergyClassEdges[3] = {-0.6, -0.2, 0.2}; // 120.000 s at 11025 Hz. The decoded window is truncated to exactly this so the // frame count does not wobble with seek granularity or resampler tail. inline constexpr std::size_t kWindowSamples = static_cast(kWindowSec * kSampleRate); // 1 323 000 inline constexpr std::size_t kExpectedFrames = 1 + (kWindowSamples - kFrameSize) / kHopSize; // 1288 static_assert(kWindowSamples == 1323000, "120 s at 11025 Hz"); static_assert(kExpectedFrames == 1288, "server spec's ~1290 frames"); /// The version prefix is the signature's own, separate from `schema_version`: /// a future change to the DSP chain must be *detectable* rather than silently /// producing non-matching signatures (IR-008). inline constexpr const char* kVersionPrefix = "v1:"; /// FFT bin range [first, last) for each of the 32 log-spaced bands. /// Computed once from the constants above; exposed so the golden fixture can /// assert the table itself, not merely the signature it produces. const std::array, kNumBands>& band_fft_bins(); /// Decode the centre window of `path` as mono float PCM at 11025 Hz. /// nullopt when the media is shorter than 120 s (IR-007), has no audio stream, /// or cannot be opened. Never throws. std::optional> decode_centre_window(const std::string& path); /// One packed byte per whole STFT frame. Empty when `mono` is shorter than one /// frame. This is the payload that gets base64-encoded. std::vector pack_frames(const std::vector& mono); /// `v1:` + base64(pack_frames(mono)). nullopt when no whole frame fits. std::optional signature_from_mono(const std::vector& mono); /// Decode + sign. The one call the pipeline makes. nullopt per IR-007 and on /// any decode failure — degradation, not failure. std::optional compute_signature(const std::string& path); // ── Small utilities, exposed for the golden-fixture test ──────────────────── std::string base64_encode(const std::uint8_t* data, std::size_t n); /// FNV-1a 64. Used only to pin the *decoded PCM* in the golden fixture, so a /// codec-level difference is distinguishable from a DSP-level one. std::uint64_t fnv1a64(const void* data, std::size_t n); } // namespace sae::audio