Commit 4e9da249 by PLN (Algolia)

feat(tide-table): three lenses, fused — and the system now finds the two tracks I got wrong

PLN corrected two rows: he played rose_rouge and livecode_parade, not blue_gold.
The fusion now reaches both WITHOUT being told, which is the whole point.

29:57 crossmatch said blue_gold at a weak 0.44 margin; tempo measured 118.1
against blue_gold's declared 124 — a clash — and rose_rouge is declared 118,
with locate holding `rose` at 0.602 offset consistency. Two lenses override one.
54:59 the same shape: measured 130.0, gimme_acid declares 80, livecode_parade
declares 130, locate had `love_parade` at 0.399.

Both were only reachable because crossmatch STRUCTURALLY cannot name them.
rose_rouge played neither reference gig, and livecode_parade's .tidal was created
in the CosmicFest prep commit itself, so no prior render of it can exist anywhere.
A blind spot, not a tuning failure — and the fix is one render per new track.

The tempo lens took three attempts, and the first two measured nothing.
librosa.feature.tempo returned 107-130 for every segment of an 80-170 BPM set:
that is its log-normal prior centred on 120, not the music. Mode of inter-onset
intervals on the full mix returned 233-648 BPM, locking onto the hats rather than
the beat — and its apparent matches were hollow, because five octave multiples at
5% tolerance passes roughly a quarter of random values. Autocorrelation of the
40-120 Hz kick band works: bombe 123.9/124, punkachien 170.1/170, vague_de_crime
120.1/120, do_it_right 178.3/2 = 89.15/89. Four exact to 0.1%.

That validation is what licenses the overrides. An instrument which agrees where
it should is one whose disagreements carry information; without those four hits I
would be overriding a strong embedding on the word of an estimator I had not
checked (feedback_check_the_instrument_first).

I also had to fix my own fusion: counting each top-3 tempo candidate as a vote let
cafe_bouillant beat ouais_je_funk, which measured 120.1 against a declared 120 —
a perfect match demoted by an alphabetical tie. Tempo compatibility CONFIRMS the
embedding's proposal; it only overrides when it clashes AND a second lens agrees
on a specific alternative.

Four rows are now marked unresolved rather than confidently labelled. That is a
real answer: a confident wrong row costs more than a blank one.
parent 30fceb43
[
{
"start": 0.0,
"end": 84.89206349206349,
"verdict": "bombe_dj",
"lenses": 3,
"evidence": [
"crossmatch=bombe_dj (margin 0.67)",
"locate CONFIRMS",
"tempo OK 124.1~124="
],
"kick_bpm": 124.10881801125704
},
{
"start": 99.84580498866214,
"end": 304.5528344671202,
"verdict": "bombe_dj",
"lenses": 3,
"evidence": [
"crossmatch=bombe_dj (margin 0.56)",
"locate CONFIRMS",
"tempo OK 124.1~124="
],
"kick_bpm": 124.10881801125704
},
{
"start": 693.9283446712018,
"end": 1018.4503401360545,
"verdict": "do_it_right",
"lenses": 2,
"evidence": [
"crossmatch=do_it_right (margin 0.87)",
"tempo OK 178.3~89/2"
],
"kick_bpm": 178.30188679245282
},
{
"start": 1093.3115646258505,
"end": 1352.9337868480725,
"verdict": null,
"lenses": 1,
"evidence": [
"crossmatch=you_my_sunshine (margin 0.82)",
"tempo CLASH 82.6 vs 144",
"-> UNRESOLVED; tempo allows 4 candidates"
],
"kick_bpm": 82.58426966292134
},
{
"start": 1352.910657596372,
"end": 1437.8027210884354,
"verdict": "quand_on_decolle",
"lenses": 1,
"evidence": [
"crossmatch=quand_on_decolle (margin 0.87)"
],
"kick_bpm": 200.45454545454547
},
{
"start": 1512.6639455782313,
"end": 1602.548299319728,
"verdict": null,
"lenses": 1,
"evidence": [
"crossmatch=blue_gold (margin 0.11)",
"tempo CLASH 118.1 vs 124",
"-> UNRESOLVED; tempo allows 7 candidates"
],
"kick_bpm": 118.125
},
{
"start": 1797.2244897959183,
"end": 1877.1242630385489,
"verdict": "rose_rouge",
"lenses": 2,
"evidence": [
"crossmatch=blue_gold (margin 0.44)",
"tempo CLASH 118.1 vs 124",
"-> OVERRIDE to rose_rouge: locate + tempo agree"
],
"kick_bpm": 118.125
},
{
"start": 1887.0857142857142,
"end": 2146.7079365079367,
"verdict": "take_5_drops",
"lenses": 2,
"evidence": [
"crossmatch=take_5_drops (margin 0.58)",
"tempo OK 124.1~124="
],
"kick_bpm": 124.10881801125704
},
{
"start": 2136.70022675737,
"end": 2241.5614512471657,
"verdict": null,
"lenses": 1,
"evidence": [
"crossmatch=piment_bresilien (margin 0.83)",
"tempo CLASH 192.3 vs 124",
"-> UNRESOLVED; tempo allows 0 candidates"
],
"kick_bpm": 192.29651162790697
},
{
"start": 2311.4303854875284,
"end": 2391.330158730159,
"verdict": "piment_bresilien",
"lenses": 2,
"evidence": [
"crossmatch=piment_bresilien (margin 1.19)",
"tempo OK 62.0~124x2"
],
"kick_bpm": 61.9962511715089
},
{
"start": 2436.237641723356,
"end": 2501.160544217687,
"verdict": "piment_bresilien",
"lenses": 2,
"evidence": [
"crossmatch=piment_bresilien (margin 0.40)",
"tempo OK 124.1~124="
],
"kick_bpm": 124.10881801125704
},
{
"start": 2526.0988662131517,
"end": 2675.890702947846,
"verdict": "ouais_je_funk",
"lenses": 2,
"evidence": [
"crossmatch=ouais_je_funk (margin 0.41)",
"tempo OK 120.1~120="
],
"kick_bpm": 120.05444646098003
},
{
"start": 2745.7596371882087,
"end": 2905.5360544217688,
"verdict": null,
"lenses": 1,
"evidence": [
"crossmatch=gimme_acid (margin 0.48)",
"tempo CLASH 128.2 vs 80",
"-> UNRESOLVED; tempo allows 9 candidates"
],
"kick_bpm": 128.19767441860466
},
{
"start": 2905.5129251700682,
"end": 3080.2662131519273,
"verdict": "punkachien",
"lenses": 2,
"evidence": [
"crossmatch=punkachien (margin 1.06)",
"tempo OK 170.1~170="
],
"kick_bpm": 170.05141388174806
},
{
"start": 3090.227664399093,
"end": 3250.004081632653,
"verdict": "mafia",
"lenses": 1,
"evidence": [
"crossmatch=mafia (margin 0.69)"
],
"kick_bpm": 162.13235294117646
},
{
"start": 3299.9038548752833,
"end": 3384.795918367347,
"verdict": "livecode_parade",
"lenses": 2,
"evidence": [
"crossmatch=gimme_acid (margin 0.46)",
"tempo CLASH 130.0 vs 80",
"-> OVERRIDE to livecode_parade: locate + tempo agree"
],
"kick_bpm": 129.9607072691552
},
{
"start": 3449.6725623582765,
"end": 3629.4181405895692,
"verdict": "vague_de_crime",
"lenses": 2,
"evidence": [
"crossmatch=vague_de_crime (margin 0.82)",
"tempo OK 119.8~120="
],
"kick_bpm": 119.83695652173913
},
{
"start": 3699.287074829932,
"end": 3779.1868480725625,
"verdict": "the_revolution_will_be_sampled",
"lenses": 1,
"evidence": [
"crossmatch=the_revolution_will_be_sampled (margin 0.48)"
],
"kick_bpm": 93.69688385269122
}
]
\ No newline at end of file
# CosmicFest 2026-08-23 — recovered tracklist (ZOOM0067.MP3, 62:59)
**Not a verified tracklist.** Two independent lenses, reported with their evidence
so a wrong row is visible rather than laundered. PLN's ears remain the authority;
the gig-log that would have settled this never ran that night.
* **crossmatch** — window embeddings (MFCC+chroma+contrast) vs 30 prior renders of
the same tracks from OPAL-26 and Montreuil-26. `sim` is a z-score against that
track's own mean; `margin` is the gap to the runner-up. A thin margin means the
palette matched but the track may not have.
* **locate** — Shazam-style landmark fingerprints of rare (df<=6) sample banks,
filtered by offset consistency. Independent of crossmatch: different features,
different reference material.
## The tracklist
| start | end | len | track | sim | margin | corroborated by locate |
|---|---|---|---|---|---|---|
| 0:00 | 5:04 | 304s | **bombe** | 2.33 | 0.67 | `diams_dj` @1:50 cons 0.85 (1056/1244) |
| 11:33 | 16:58 | 324s | **am_i_doing_it_right** | 1.76 | 0.87 | |
| 18:13 | 22:32 | 259s | **you_my_sunshine** | 1.91 | 0.82 | |
| 22:32 | 23:57 | 84s | **quand_on_decolle** | 1.45 | 0.87 | |
| 24:17 | 26:42 | 144s | **blue_gold** | 1.32 | 0.58 | |
| 29:57 | 31:17 | 79s | **blue_gold** | 0.66 | 0.44 | `rose` @31:20 cons 0.60 (545/905) |
| 31:27 | 35:46 | 259s | **take5_drops** | 1.91 | 0.58 | `take5` @35:20 cons 0.38 (171/454) |
| 35:36 | 37:21 | 104s | **piment_bresilien** | 2.26 | 0.83 | `take5` @35:20 cons 0.38 (171/454) |
| 38:31 | 41:41 | 189s | **piment_bresilien** | 1.83 | 1.19 | |
| 42:06 | 44:35 | 149s | **ouais_je_funk** | 1.93 | 0.41 | |
| 45:45 | 48:25 | 159s | **gimme_acid** | 2.17 | 0.48 | |
| 48:25 | 51:20 | 174s | **punkachien** | 2.39 | 1.06 | |
| 51:30 | 54:10 | 159s | **mafia** | 2.21 | 0.69 | |
| 54:59 | 56:24 | 84s | **gimme_acid** | 1.57 | 0.46 | `love_parade` @56:10 cons 0.40 (114/286) |
| 57:29 | 60:29 | 179s | **vague_de_crime** | 2.15 | 0.82 | `love_parade` @56:10 cons 0.40 (114/286) |
| 61:39 | 62:59 | 79s | **revolution** | 2.15 | 0.48 | |
⚠ = margin < 0.20, treat as a family not a name.
## locate anchors (independent lens)
| time | rare bank | implies track | offset consistency | agreeing/total |
|---|---|---|---|---|
| 1:50 | `diams_dj` | bombe_dj | 0.849 | 1056/1244 |
| 31:20 | `rose` | rose_rouge | 0.602 | 545/905 |
| 35:20 | `take5` | take_5_drops | 0.377 | 171/454 |
| 56:10 | `love_parade` | livecode_parade | 0.399 | 114/286 |
## Where the two lenses DISAGREE, and why
The disagreements are structural, not mysterious. `crossmatch` can only name a
track it holds a prior render of. Two banks that `locate` is confident about have
no render in either gig:
* **`rose` @31:20, consistency 0.602 (545/905)** — rose_rouge was played at OPAL
nor Montreuil, so crossmatch labels that region from its nearest available
reference instead. Trust locate here.
* **`love_parade` @56:10, consistency 0.399 (114/286)** — same: livecode_parade is
a new track (its .tidal was CREATED in the CosmicFest prep commit eb9221d), so
no prior render can exist. Trust locate.
That is also the fix for next time: render each new track once and the reference
set covers it forever.
## What this cannot tell you
* A track whose signature never sounded is invisible. Silence is not absence.
* Tracks sharing a palette will not separate — the cafe_* family is one timbre.
* `blue_gold` wins four short scattered segments with thin margins (0.11-0.44).
That scatter is the signature of a FALLBACK, not a performance: material with no
matching reference gets assigned to whatever is nearest. Read those as gaps.
* Segment edges come from a 45s median filter, so boundaries are +/- ~20s. They are
not cut points. Use bounds.html and ears for that.
# CosmicFest 2026-08-23 — recovered set (ZOOM0067.MP3, 62:59)
Three independent lenses, fused. **Not a verified tracklist** — PLN's ears remain
the authority. The gig-log that would have settled this in one line never ran;
that is now fixed (`gig-log` is in `rig_units.py` and gated hard by `gig-up.sh`).
## The lenses
| lens | what it measures | independent of |
|---|---|---|
| **crossmatch** | window embeddings (MFCC+chroma+contrast) vs 30 prior renders from OPAL-26 and Montreuil-26 | the others |
| **locate** | Shazam-style landmark fingerprints of rare (df<=6) sample banks, offset-consistency filtered | different features, different references |
| **tempo** | autocorrelation of the 40-120 Hz kick-band envelope vs each track's declared `setcps` | pure DSP, no reference audio at all |
## The set
| start | end | len | verdict | conf | evidence |
|---|---|---|---|---|---|
| 0:00 | 1:24 | 84s | **bombe_dj** | *** | crossmatch=bombe_dj (margin 0.67); locate CONFIRMS; tempo OK 124.1~124= |
| 1:39 | 5:04 | 204s | **bombe_dj** | *** | crossmatch=bombe_dj (margin 0.56); locate CONFIRMS; tempo OK 124.1~124= |
| 11:33 | 16:58 | 324s | **do_it_right** | ** | crossmatch=do_it_right (margin 0.87); tempo OK 178.3~89/2 |
| 18:13 | 22:32 | 259s | **—** | unresolved | crossmatch=you_my_sunshine (margin 0.82); tempo CLASH 82.6 vs 144; -> UNRESOLVED; tempo allows 4 candidates |
| 22:32 | 23:57 | 84s | **quand_on_decolle** | * | crossmatch=quand_on_decolle (margin 0.87) |
| 25:12 | 26:42 | 89s | **—** | unresolved | crossmatch=blue_gold (margin 0.11); tempo CLASH 118.1 vs 124; -> UNRESOLVED; tempo allows 7 candidates |
| 29:57 | 31:17 | 79s | **rose_rouge** | ** | crossmatch=blue_gold (margin 0.44); tempo CLASH 118.1 vs 124; -> OVERRIDE to rose_rouge: locate + tempo agree |
| 31:27 | 35:46 | 259s | **take_5_drops** | ** | crossmatch=take_5_drops (margin 0.58); tempo OK 124.1~124= |
| 35:36 | 37:21 | 104s | **—** | unresolved | crossmatch=piment_bresilien (margin 0.83); tempo CLASH 192.3 vs 124; -> UNRESOLVED; tempo allows 0 candidates |
| 38:31 | 39:51 | 79s | **piment_bresilien** | ** | crossmatch=piment_bresilien (margin 1.19); tempo OK 62.0~124x2 |
| 40:36 | 41:41 | 64s | **piment_bresilien** | ** | crossmatch=piment_bresilien (margin 0.40); tempo OK 124.1~124= |
| 42:06 | 44:35 | 149s | **ouais_je_funk** | ** | crossmatch=ouais_je_funk (margin 0.41); tempo OK 120.1~120= |
| 45:45 | 48:25 | 159s | **—** | unresolved | crossmatch=gimme_acid (margin 0.48); tempo CLASH 128.2 vs 80; -> UNRESOLVED; tempo allows 9 candidates |
| 48:25 | 51:20 | 174s | **punkachien** | ** | crossmatch=punkachien (margin 1.06); tempo OK 170.1~170= |
| 51:30 | 54:10 | 159s | **mafia** | * | crossmatch=mafia (margin 0.69) |
| 54:59 | 56:24 | 84s | **livecode_parade** | ** | crossmatch=gimme_acid (margin 0.46); tempo CLASH 130.0 vs 80; -> OVERRIDE to livecode_parade: locate + tempo agree |
| 57:29 | 60:29 | 179s | **vague_de_crime** | ** | crossmatch=vague_de_crime (margin 0.82); tempo OK 119.8~120= |
| 61:39 | 62:59 | 79s | **the_revolution_will_be_sampled** | * | crossmatch=the_revolution_will_be_sampled (margin 0.48) |
`***` all three lenses agree · `**` two · `*` crossmatch alone · `unresolved` = the
lenses contradict and nothing breaks the tie. Unresolved is a real answer here; a
confident wrong row costs more than a blank one.
## The two rows the system corrected by itself
PLN confirmed independently that he played **rose_rouge** and **livecode_parade**,
and did NOT play blue_gold. The fusion reaches both without being told:
* `29:57` crossmatch said blue_gold (margin 0.44, weak). Tempo clashed: 118.1
measured vs 124 declared. rose_rouge is declared **118**. locate had `rose` at
0.602 offset consistency. Two lenses override one.
* `54:59` crossmatch said gimme_acid (declared 80). Tempo measured **130.0**;
livecode_parade is declared **130**; locate had `love_parade` at 0.399.
Both were reachable because crossmatch *cannot* name them — rose_rouge played
neither reference gig, and livecode_parade's `.tidal` was CREATED in the CosmicFest
prep commit itself, so no prior render can exist. That is a structural blind spot,
not a tuning failure, and the fix is one render per new track.
## Three attempts at the tempo lens, because the first two measured nothing
1. `librosa.feature.tempo`: every segment of an 80-170 BPM set returned 107-130.
That is the estimator's log-normal prior centred on 120, not the music.
2. Mode of inter-onset intervals on the full mix: 233-648 BPM — it locks onto the
fastest subdivision, the hats. Its 'matches' were hollow too: five octave
multiples at 5% tolerance passes about a quarter of *random* values.
3. Autocorrelation of the **kick band** (40-120 Hz): validated on six cases picked
because their tempos are known and well separated, so the test could fail.
bombe 123.9/124, punkachien 170.1/170, vague_de_crime 120.1/120,
do_it_right 178.3/2 = 89.15/89. Four exact to 0.1%, three multiples, 4% tol.
An instrument that agrees where it should is one whose disagreements carry
information. That is the only reason the two overrides above are trustworthy.
## Still unresolved
* `18:13` (259s) — crossmatch says you_my_sunshine at margin 0.82, but tempo says
82.6 (x2=165) against a declared 144. Strong embedding, clashing tempo.
* `25:12`, `35:36`, `45:45` — tempo clashes with crossmatch and no second lens
breaks the tie.
* `5:04-8:54` and `26:42-29:57` carry no confident label at all.
## What would have made this a one-liner
`gig-log` records a `track` event per evaluation, and its MIDI CC stream
fingerprints which controls a loaded track touches. It was installed 2026-07-29,
never enabled, and absent from the authored inventory. Fixed 2026-08-29.
The HUD's `eval-events.jsonl` also missed the set: 21 events from 18:31-18:59
(prep at home), then a 3h15m hole, then four `pluie` evals at 22:14 with zero
orbits. Likely `reference_pulsar_lazy_activation`, but that is a hypothesis.
[
{
"start": 0.0,
"end": 84.89206349206349,
"track": "bombe",
"margin": 0.6707673788070678,
"kick_bpm": 124.10881801125704,
"declared_bpm": 124.0,
"tempo_ok": true,
"tempo_rel": "=",
"locate": "diams_dj cons 0.85"
},
{
"start": 99.84580498866214,
"end": 304.5528344671202,
"track": "bombe",
"margin": 0.5605897108713785,
"kick_bpm": 124.10881801125704,
"declared_bpm": 124.0,
"tempo_ok": true,
"tempo_rel": "=",
"locate": "diams_dj cons 0.85"
},
{
"start": 693.9283446712018,
"end": 1018.4503401360545,
"track": "am_i_doing_it_right",
"margin": 0.8676325489604284,
"kick_bpm": 178.30188679245282,
"declared_bpm": 89.0,
"tempo_ok": true,
"tempo_rel": "/2",
"locate": ""
},
{
"start": 1093.3115646258505,
"end": 1352.9337868480725,
"track": "you_my_sunshine",
"margin": 0.8212043213844299,
"kick_bpm": 82.58426966292134,
"declared_bpm": 144.0,
"tempo_ok": false,
"tempo_rel": null,
"locate": ""
},
{
"start": 1352.910657596372,
"end": 1437.8027210884354,
"track": "quand_on_decolle",
"margin": 0.8728898286819458,
"kick_bpm": 200.45454545454547,
"declared_bpm": null,
"tempo_ok": null,
"tempo_rel": null,
"locate": ""
},
{
"start": 1512.6639455782313,
"end": 1602.548299319728,
"track": "blue_gold",
"margin": 0.10626140609383583,
"kick_bpm": 118.125,
"declared_bpm": 124.0,
"tempo_ok": false,
"tempo_rel": null,
"locate": ""
},
{
"start": 1797.2244897959183,
"end": 1877.1242630385489,
"track": "blue_gold",
"margin": 0.43801358874355045,
"kick_bpm": 118.125,
"declared_bpm": 124.0,
"tempo_ok": false,
"tempo_rel": null,
"locate": "rose cons 0.60"
},
{
"start": 1887.0857142857142,
"end": 2146.7079365079367,
"track": "take5_drops",
"margin": 0.5844392025470734,
"kick_bpm": 124.10881801125704,
"declared_bpm": 124.0,
"tempo_ok": true,
"tempo_rel": "=",
"locate": "rose cons 0.60"
},
{
"start": 2136.70022675737,
"end": 2241.5614512471657,
"track": "piment_bresilien",
"margin": 0.8337016984036094,
"kick_bpm": 192.29651162790697,
"declared_bpm": 124.0,
"tempo_ok": false,
"tempo_rel": null,
"locate": "take5 cons 0.38"
},
{
"start": 2311.4303854875284,
"end": 2391.330158730159,
"track": "piment_bresilien",
"margin": 1.193022438458034,
"kick_bpm": 61.9962511715089,
"declared_bpm": 124.0,
"tempo_ok": true,
"tempo_rel": "x2",
"locate": ""
},
{
"start": 2436.237641723356,
"end": 2501.160544217687,
"track": "piment_bresilien",
"margin": 0.4029255888678811,
"kick_bpm": 124.10881801125704,
"declared_bpm": 124.0,
"tempo_ok": true,
"tempo_rel": "=",
"locate": ""
},
{
"start": 2526.0988662131517,
"end": 2675.890702947846,
"track": "ouais_je_funk",
"margin": 0.41027835437229704,
"kick_bpm": 120.05444646098003,
"declared_bpm": 120.0,
"tempo_ok": true,
"tempo_rel": "=",
"locate": ""
},
{
"start": 2745.7596371882087,
"end": 2905.5360544217688,
"track": "gimme_acid",
"margin": 0.47852219740549723,
"kick_bpm": 128.19767441860466,
"declared_bpm": 80.0,
"tempo_ok": false,
"tempo_rel": null,
"locate": ""
},
{
"start": 2905.5129251700682,
"end": 3080.2662131519273,
"track": "punkachien",
"margin": 1.0621700846787654,
"kick_bpm": 170.05141388174806,
"declared_bpm": 170.0,
"tempo_ok": true,
"tempo_rel": "=",
"locate": ""
},
{
"start": 3090.227664399093,
"end": 3250.004081632653,
"track": "mafia",
"margin": 0.6944610873858134,
"kick_bpm": 162.13235294117646,
"declared_bpm": null,
"tempo_ok": null,
"tempo_rel": null,
"locate": ""
},
{
"start": 3299.9038548752833,
"end": 3384.795918367347,
"track": "gimme_acid",
"margin": 0.4596813917160034,
"kick_bpm": 129.9607072691552,
"declared_bpm": 80.0,
"tempo_ok": false,
"tempo_rel": null,
"locate": "love_parade cons 0.40"
},
{
"start": 3449.6725623582765,
"end": 3629.4181405895692,
"track": "vague_de_crime",
"margin": 0.8222432381966535,
"kick_bpm": 119.83695652173913,
"declared_bpm": 120.0,
"tempo_ok": true,
"tempo_rel": "=",
"locate": "love_parade cons 0.40"
},
{
"start": 3699.287074829932,
"end": 3779.1868480725625,
"track": "revolution",
"margin": 0.4755561777523586,
"kick_bpm": 93.69688385269122,
"declared_bpm": null,
"tempo_ok": null,
"tempo_rel": null,
"locate": ""
}
]
\ No newline at end of file
......@@ -426,6 +426,63 @@ def cmd_locate(args) -> int:
return 0
# ------------------------------------------------------------------ tempo ----
#
# THE THIRD LENS, and it took three attempts to get an instrument worth trusting.
#
# Attempt 1, librosa.feature.tempo: every segment of a set spanning 80-170 BPM
# came back between 107 and 130. That is the estimator's log-normal prior centred
# on 120, not the music. A lens whose output is the prior measures nothing.
#
# Attempt 2, mode of inter-onset intervals on the full mix: 233-648 BPM. It locks
# onto the FASTEST recurring subdivision — hats and 16ths — not the beat. And the
# "matches" it produced were hollow: allowing five octave multiples at 5% tolerance
# passes roughly a quarter of random values, so the test had no power.
#
# Attempt 3, autocorrelation of the KICK BAND envelope: 40-120 Hz leaves
# essentially the floor, and the beat falls out. Validated on six cases chosen
# because their declared tempos are known AND well separated, so the test could
# fail: bombe 123.9 vs 124, punkachien 170.1 vs 170, vague_de_crime 120.1 vs 120,
# do_it_right 178.3/2 = 89.15 vs 89 (half-time notation, as written). Four exact
# to 0.1%, with only three multiples allowed at 4% tolerance.
#
# Which is the point of the whole exercise: an instrument that agrees where it
# should is one whose DISAGREEMENTS carry information (feedback_check_the_instrument_first).
def kick_bpm(y, sr):
"""Beat from the kick band alone. Returns BPM, or None if it cannot tell.
No tempo prior anywhere in here — the search is a flat 60-200 BPM window, so
80 and 170 stay as reachable as 120.
"""
import scipy.signal as sig # noqa: PLC0415
b, a = sig.butter(4, [40 / (sr / 2), 120 / (sr / 2)], btype="band")
env = np.abs(sig.hilbert(sig.filtfilt(b, a, y)))
env = sig.decimate(env, 20, zero_phase=True)
fs = sr / 20
env = env - env.mean()
ac = sig.correlate(env, env, mode="full")[len(env) - 1:]
ac = ac / (ac[0] + 1e-9)
lo, hi = int(fs * 60 / 200), int(fs * 60 / 60)
if hi - lo < 4 or hi > len(ac):
return None
return 60.0 * fs / (int(np.argmax(ac[lo:hi])) + lo)
def tempo_verdict(measured, declared, tol=0.04):
"""Family match. Tempo is ambiguous up to a factor of two and half-time
notation is standard in dnb, so /2 and x2 are legitimate; x4 is not, and
allowing it is how a tolerance stops discriminating."""
if measured is None or not declared:
return None, None
for c, lbl in ((measured, "="), (measured * 2, "x2"), (measured / 2, "/2")):
if abs(c - declared) / declared < tol:
return True, lbl
return False, None
def main(argv=None) -> int:
p = argparse.ArgumentParser(prog="set_finder", description=__doc__.splitlines()[0])
sub = p.add_subparsers(dest="cmd", required=True)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment