Implements the content-derived spectral-peak signature from JRay-public-server/SPEC.md §3 so a truth file is self-identifying: 120 s window centred on the media midpoint, mono at 11025 Hz, 4096/1024 Hann STFT, 32 log-spaced bins over 300-3000 Hz, one byte per frame (5-bit peak band + 2-bit energy class), base64, `v1:` prefix. Audio decode is a second stream from the FFmpeg libraries the pipeline already links for video; libswresample is added to the existing ffmpeg_libs interface target. The FFT is written out rather than pulled from a library for the same reason the plugin vendors one: the output has to be bit-identical across two languages, so a dependency whose version could change the numerics is a liability. The server spec fixes the geometry but not enough to reproduce a byte stream — Hann periodicity, band aggregation, the energy-class definition, tie-breaking and the base64 alphabet are all unconstrained by it. Those are pinned in audio_signature.hpp and mirrored in the golden fixture, so the plugin can be implemented from the fixture alone. IR-005: tests/fixtures/audio/ carries a deterministic 120 s tone (FLAC — lossless, so identical PCM to the WAV make_fixture.py emits, and 3.5x smaller in git) plus the signature it must produce, the decoded-PCM checksum and the full parameter contract. That directory is the artefact shared with the plugin repo; the PCM checksum is separate from the signature so a codec-level difference is distinguishable from a DSP one. IR-007: media under 120 s emits no signature. Same for a file with no audio stream or one that will not open — UR-9 is an enhancement and must never be able to break a fetch. Verified against an independent Python reference implementation: same bytes. All 32 bands and all 4 energy classes appear in the golden vector, and the window-centring test wraps the fixture in 90 s of silence either side and requires the golden value back. Not wired into the truth-file output yet — that is the schema_version bump under IR-002/IR-003 and is deliberately out of scope here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
115 lines
4.2 KiB
Python
115 lines
4.2 KiB
Python
#!/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()
|