feat(audio): store, serve and match the v1 audio signature
Completes UR-009. The register recorded the signature as stored; it was not. `ingest` validated `cut.audio_signature` and wrote NULL, so every served manifest came back without one — which also meant the plugin's own alignment (jRay JR-047, Done) had nothing to align against and could never run. That is the failure mode a status field is least able to catch: every validation test passed and the feature delivered nothing. Now stored, coarse-indexed and served back byte-identically, with a held manifest adopting an incoming signature it lacked (§9a). `audio`-tier matching runs on every read endpoint via an `audio_signature` parameter, and `POST /manifests/search` answers the unknown-providence case with a runtime prefilter, a bounded scan and honest truncation reporting. Three rules §3 did not previously state, now normative: - **±1 frame of slack in the score.** scene-actor-extraction VR-014 measured the exact-frame rule demoting 27 of 40 correctly aligned releases to `loose`, because the two windows are cut on their own file's frame grid and those grids do not coincide. With ±1 frame all 40 reach `audio` (worst 0.906) and the strongest false match is unmoved at 0.16. - **The offset has two terms.** Both windows are anchored at their own file's runtime/2, so the slide alone is wrong by half the runtime difference on every shifted release. A signature without a runtime therefore cannot align, and is refused by name rather than answered at a lower tier. - **A signature verdict is final**, including its refusals. Falling back to the runtime tier after the audio declined would let a coincidence overturn direct evidence, inverting the ordering the tier table exists to state. The slide precomputes each frame's neighbourhood as a 32-bit bin set rather than re-deriving it across 1201 slides — 3.3 ms to 1.1 ms per candidate, with a test asserting exact equivalence to the rule written the obvious way. The 1000-candidate search cap follows from that measurement as a ~1.1 s ceiling per request, not a round number. jRay's matcher still implements the pre-slack rule and will label some alignments `loose` that this server calls `audio`. Nothing misaligns — JR-047 makes the local answer win — but that register now carries the follow-up. TRACES: UR-008, UR-009 | SR-003
This commit is contained in:
@@ -459,9 +459,10 @@ Two signatures are compared by sliding one against the other and taking the
|
||||
best score:
|
||||
|
||||
```
|
||||
for offset in -600 .. +600 frames: # ±56 s
|
||||
score(offset) = fraction of overlapping frames whose peak bin matches
|
||||
best = argmax score
|
||||
for slide in -600 .. +600 frames: # ±56 s
|
||||
score(slide) = fraction of overlapping frames whose peak bin matches
|
||||
the reference within ±1 frame
|
||||
best = argmax score # smallest |slide| wins a tie
|
||||
```
|
||||
|
||||
| Result | Interpretation |
|
||||
@@ -483,6 +484,72 @@ Speed-differing releases (PAL 4% speed-up) are **not** handled by a constant
|
||||
offset and are correctly rejected by the score threshold; a scale-and-offset
|
||||
search is possible later but is out of scope.
|
||||
|
||||
##### Normative v1 matching parameters
|
||||
|
||||
As with the construction, prose alone leaves choices that decide whether two
|
||||
implementations agree. These are pinned for v1:
|
||||
|
||||
| Parameter | v1 value |
|
||||
|---|---|
|
||||
| Slide range | ±600 frames, inclusive |
|
||||
| Score tolerance | A frame agrees if the reference carries the same peak bin within **±1 frame** |
|
||||
| Tie-break | Smallest \|slide\| |
|
||||
| Minimum overlap | **64 frames** — an alignment thinner than this does not score at all |
|
||||
| Field compared | Peak bin only; the 2-bit energy class is not scored |
|
||||
|
||||
**The ±1 frame tolerance is a correction, not a loosening.** The rule was an
|
||||
exact frame match until `scene-actor-extraction` VR-014 measured it on real
|
||||
film audio: over 40 random in-cap offsets every alignment was recovered to the
|
||||
nearest frame, but **27 of the 40 scored below 0.85 and were demoted to
|
||||
`loose`** — not because the content disagreed but because the two windows are
|
||||
cut on their own file's frame grid and those grids do not coincide. The exact
|
||||
rule therefore scored how nearly two grids happened to line up. With ±1 frame
|
||||
all 40 reach `audio` (worst 0.906), the strongest false match is unmoved at
|
||||
0.16, and the offset costs 81 ms of a 500 ms budget. The gap that makes the
|
||||
thresholds mean anything is untouched; what changed is that a correctly aligned
|
||||
release now clears them.
|
||||
|
||||
**Minimum overlap is not from the construction.** Without a floor the extreme
|
||||
slides compare a handful of frames, where a chance agreement scores 1.0 and
|
||||
beats the true alignment. It never binds on the real case — two full-length
|
||||
signatures still overlap by 688 frames at ±600 — but two implementations that
|
||||
chose different floors would disagree at the edges, so it is fixed here.
|
||||
|
||||
##### The offset has two terms
|
||||
|
||||
This is the easiest thing in the feature to get wrong, and the pseudocode above
|
||||
gives only half of it. Both signatures are cut from **their own file's centre**,
|
||||
`runtime/2 ± 60 s`, so when the runtimes differ the two windows do not begin at
|
||||
the same point in the content:
|
||||
|
||||
```
|
||||
offset = (local_runtime - manifest_runtime) / 2 - slide × 1024/11025
|
||||
└──── window-anchor difference ────┘ └───── recovered ─────┘
|
||||
```
|
||||
|
||||
A release carrying 40 s of extra head material takes 20 s from each term. Using
|
||||
the slide alone is wrong by half the runtime difference on **every** shifted
|
||||
release — which is every release this feature exists for.
|
||||
|
||||
It follows that **a signature alone cannot produce an alignment**: the anchor
|
||||
term needs both runtimes. A caller that supplies a signature without a runtime
|
||||
is refused by name rather than answered at a lower tier, which would look like a
|
||||
match its own signature had failed to improve.
|
||||
|
||||
##### A signature verdict is final
|
||||
|
||||
When both sides carry a signature, the audio comparison decides — including
|
||||
when it declines. There is no fallback to the runtime tiers after a `score <
|
||||
0.60`, because `audio` outranks `runtime` precisely for being content-derived,
|
||||
and letting a runtime coincidence overturn direct evidence would invert the
|
||||
ordering the tier table states. The separation measured in VR-014 — true
|
||||
matches at 0.906 and above, the strongest false one at 0.16 — is what makes
|
||||
that safe rather than brave.
|
||||
|
||||
A signature on only one side is a different case entirely and falls through to
|
||||
the runtime tiers, since coverage accumulates gradually and most stored
|
||||
manifests carry none.
|
||||
|
||||
#### Revised tier table
|
||||
|
||||
| Tier | Signal | Confidence |
|
||||
@@ -503,25 +570,57 @@ With no TMDB id at all, a client can search by signature alone:
|
||||
|
||||
```
|
||||
POST /manifests/search
|
||||
{ "audio_signature": "base64…", "runtime_sec": 6420.5 }
|
||||
{ "audio_signature": "v1:…", "runtime_sec": 6420.5 }
|
||||
```
|
||||
|
||||
Both fields are required. `runtime_sec` drives the prefilter and supplies the
|
||||
offset's window-anchor term, so a search without it could rank candidates but
|
||||
could not align to them.
|
||||
|
||||
The server returns candidate matches with scores, offsets and title identity —
|
||||
letting JRay identify an unidentified file *and* align to it in one step.
|
||||
letting JRay identify an unidentified file *and* align to it in one step:
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{ "manifest_id": "01HZ...", "match": "audio", "score": 0.97, "offset_sec": -20.0,
|
||||
"identity": { "type": "movie", "tmdb_id": "504172",
|
||||
"title": "Road to Bali", "year": 1952 } }
|
||||
],
|
||||
"candidates_scored": 412,
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
Results are best-first and capped at 10. Title identity is the half of the
|
||||
answer `GET /manifests/exists` cannot give, since a caller with no id has
|
||||
nothing to probe it with — and it stays cut-level like every other tier, so it
|
||||
says which *work* this is and never which copy.
|
||||
|
||||
**This endpoint is a scaling problem, not a correctness one.** A naive
|
||||
implementation compares against every stored signature. Mitigations:
|
||||
|
||||
- Prefilter by runtime (±90 s) before scoring, which eliminates almost
|
||||
everything.
|
||||
- Index a **coarse hash** of the signature (e.g. the peak-bin sequence of
|
||||
every 16th frame) for candidate generation, with full sliding comparison
|
||||
only on candidates.
|
||||
everything. Necessarily wider than the ±30 s `loose` tier: here a large
|
||||
runtime difference is the premise rather than a disqualification.
|
||||
- Index a **coarse hash** of the signature — the peak-bin sequence of every
|
||||
16th frame — for candidate generation, with full sliding comparison only on
|
||||
candidates. Subsampling destroys the alignment for any non-zero slide, so
|
||||
the coarse key settles the *re-encode* case cheaply and orders the scan; the
|
||||
runtime prefilter is what carries the shifted releases.
|
||||
- Bound the scan. A full slide costs ~1.1 ms per candidate, so this server
|
||||
scores at most 1000 of them — a ~1.1 s ceiling on one request — and reports
|
||||
`candidates_scored` alongside `truncated`. A truncated scan presented as a
|
||||
complete one turns "no match" into a claim the server cannot support, and
|
||||
truncation is the signal that the corpus has outgrown a linear scan.
|
||||
- Rate-limit hard (§5): this is the most expensive read endpoint and the most
|
||||
attractive to abuse.
|
||||
|
||||
Because it is expensive, `POST /manifests/search` is **optional for a server
|
||||
to implement**; `GET /federation/capabilities` advertises support.
|
||||
to implement**; `GET /federation/capabilities` advertises support, alongside
|
||||
`audio_tier_matching` for the read endpoints, which is not optional — matching
|
||||
costs one slide against the candidates a title already narrows to, which is
|
||||
nothing like a corpus-wide search.
|
||||
|
||||
#### Validation and abuse
|
||||
|
||||
@@ -559,6 +658,12 @@ plugin-side computation first, let signatures accumulate, then enable
|
||||
`audio`-tier matching and the search endpoint once coverage is useful. Nothing
|
||||
above needs to land at once.
|
||||
|
||||
That sequence is now complete: the plugin computes signatures (`jRay` JR-042),
|
||||
the server stores and serves them, and both `audio`-tier matching and
|
||||
`POST /manifests/search` are live. The optionality survives it — a manifest
|
||||
without a signature is matched by the runtime tiers exactly as before, and
|
||||
**nothing here may be allowed to break a fetch** (UR-009 is an enhancement).
|
||||
|
||||
#### Computing the signature in the JRay plugin
|
||||
|
||||
The plugin is the right place for this, and it is the *only* place that covers
|
||||
@@ -670,7 +775,12 @@ title *and* at what cut-match tier, without transferring the payload.
|
||||
|
||||
Query parameters are the same identity + cut parameters as the fetch
|
||||
endpoints: `tmdb_id` / `imdb_id` (or `series_tmdb_id` + `season` + `episode`),
|
||||
plus optional `runtime_sec`.
|
||||
plus optional `runtime_sec` and `audio_signature`.
|
||||
|
||||
`audio_signature` is what lets the `audio` tier fire here at all. This endpoint
|
||||
transfers no payload, so a client cannot align locally from its answer — the
|
||||
server-side comparison is the only way a sweep can learn that a manifest is not
|
||||
merely present but *aligned*. It requires `runtime_sec` alongside it (§3).
|
||||
|
||||
```json
|
||||
{ "exists": true, "match": "runtime", "manifest_id": "01HZ...", "actor_count": 34 }
|
||||
@@ -701,24 +811,46 @@ returning results positionally. This exists specifically so the rate limit in
|
||||
because the payload does not fit a query string; it is a read and requires no
|
||||
token.
|
||||
|
||||
### `GET /manifests/movie?tmdb_id=&imdb_id=&runtime_sec=`
|
||||
### `GET /manifests/movie?tmdb_id=&imdb_id=&runtime_sec=&audio_signature=`
|
||||
|
||||
Returns the best-matching Jmanifest, or `404` if none clears `loose`.
|
||||
|
||||
```json
|
||||
{ "match": "runtime", "manifest": { "...": "..." } }
|
||||
{ "match": "runtime", "offset_sec": 0.0, "manifest": { "...": "..." } }
|
||||
```
|
||||
|
||||
`offset_sec` is always present and is non-zero only on the `audio` route — the
|
||||
runtime tiers know that two cuts are close, never by how much they are
|
||||
displaced. The served manifest carries `cut.audio_signature` when the server
|
||||
holds one, which is what a client aligning locally compares against.
|
||||
|
||||
`audio_signature` rides in the query string: ~2.3 KB encoded, comfortably inside
|
||||
any request-line limit. Unlike the withdrawn `video_hash` it is not a file-level
|
||||
signal, so accepting it on a read does not make the read an oracle for which
|
||||
*copy* a caller holds (§3, PR-005).
|
||||
|
||||
### `GET /manifests/series/{series_tmdb_id}?season=`
|
||||
|
||||
Returns a series bundle (§2). `season` optional; omitted means all seasons.
|
||||
Episode-level cut matching is done client-side against the returned bundle,
|
||||
since a client pulling a whole series already knows its own runtimes.
|
||||
|
||||
### `GET /manifests/episode?series_tmdb_id=&season=&episode=&runtime_sec=`
|
||||
### `GET /manifests/episode?series_tmdb_id=&season=&episode=&runtime_sec=&audio_signature=`
|
||||
|
||||
Single-episode equivalent of the movie endpoint.
|
||||
|
||||
### `POST /manifests/search` — UR-009
|
||||
|
||||
Identify a file of unknown providence by its audio signature, and align to it.
|
||||
See §3 "Unknown-providence search" for the body, the response and the cost.
|
||||
|
||||
- `200` — results, possibly empty. An empty list is an answer, not an error
|
||||
- `400` — the signature is malformed, or fails the same structural rules an
|
||||
upload would (§6)
|
||||
- `404` — this server does not offer search; `GET /federation/capabilities`
|
||||
says so up front
|
||||
- `429` — rate limited (§5)
|
||||
|
||||
### `POST /manifests`
|
||||
|
||||
Contribute a manifest. Body is a Jmanifest. Requires an API token (§5).
|
||||
|
||||
Reference in New Issue
Block a user