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
This commit is contained in:
@@ -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 <mid-60> -t 120 -i <file> -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."
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -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("<I", 36 + len(data)) + b"WAVE"
|
||||
hdr += b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, SAMPLE_RATE,
|
||||
SAMPLE_RATE * 2, 2, 16)
|
||||
hdr += b"data" + struct.pack("<I", len(data))
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(hdr + data)
|
||||
|
||||
|
||||
def main():
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
samples = generate()
|
||||
wav = os.path.join(here, OUT_WAV)
|
||||
write_wav(wav, samples)
|
||||
if "--wav" in sys.argv:
|
||||
print("wrote", wav)
|
||||
return
|
||||
flac = os.path.join(here, OUT_FLAC)
|
||||
# -compression_level 12 is deterministic for a given libFLAC/ffmpeg build;
|
||||
# only the container bytes vary, never the decoded PCM.
|
||||
subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", wav,
|
||||
"-c:a", "flac", "-compression_level", "12", flac],
|
||||
check=True)
|
||||
os.remove(wav)
|
||||
print("wrote", flac)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user