Migrating from Spotify Audio Features: a Field-by-Field Threshold Guide
You swapped the host from api.spotify.com to api.freqblog.com. Your GET /v1/audio-features/{id} handler still parses the response unchanged, the unit tests pass. Then your downstream if features.danceability > 0.7 recommender starts surfacing weird tracks. Your acousticness > 0.5 "find the acoustic versions" rule fires on basically everything. Your liveness > 0.8 "skip the live recordings" filter rejects the whole catalog.
Welcome. The migration is byte-compatible at the shape level. It's directional at the value level. Spotify built their audio features by training ML classifiers on a large in-house labelled corpus; we compute ours from signal analysis (librosa for the descriptors, Essentia for BPM and key). Same names, different beasts.
This is the field-by-field guide to re-tuning your thresholds, with real distribution data from our pre-analysed catalog. We'll be honest about which fields work well, which ones are shifted but usable, and which ones currently shouldn't be relied on at all.
If you haven't done the migration yet: start with Spotify Audio Features Is Dead. Here's What to Use Instead in 2026 for the landscape and the host-swap code. This post is the next step — making sure your numbers behave once the calls are flowing.
How the numbers are made (and why they differ)
Spotify's path was model inference. Each field was the output of a classifier trained on a labelled corpus — what "danceable" or "acoustic" meant was set by their labelling team, and the model returned a calibrated probability-like number on [0, 1]. The classifiers themselves were never public, but their training-data behaviour shaped the distributions you got used to.
FreqBlog's path is signal analysis. Each descriptor is a deterministic function of the audio's spectral and temporal statistics, applied to a 30-second window of the preview:
acousticness— fromspectral_flatness(low flatness = tonal = "acoustic")danceability— weighted blend of a Gaussian BPM curve around 120, beat confidence, and inter-beat regularityinstrumentalness— inverse of the variance of MFCCs 1–5 (those bands carry vocal formants)liveness— ratio of 5th-percentile to 95th-percentile RMS (live recordings have an elevated noise floor)speechiness— zero-crossing rate divided by a speech thresholdvalence— weighted blend of mode (major/minor) + tempo + spectral brightness + RMSenergy— normalised mean RMS
BPM and key are different: those come from Essentia's RhythmExtractor2013 (multifeature method, with a confidence score) and KeyExtractor. Essentia is a mature C++ DSP library; in our experience these two are broadly comparable to Spotify's models in accuracy — sometimes better, as we'll see.
The upshot: if a Spotify field was "ML on labels," our number is "what one specific signal statistic happens to look like." Both can be useful in production — they're just not the same statistic, and the distributions don't line up.
TL;DR threshold map
Catalog medians are over ~8,000 sampled tracks from our pre-analysed catalog as of May 2026.
| Field | Spotify (typical) | FreqBlog catalog median | What to do |
|---|---|---|---|
bpm / tempo | 60–200 | 118.7 | Drop-in. Use bpm_alt for half-time fixes (see below). |
key (0–11 / −1) | 0–11 or −1 | same scale | Drop-in — returned as key_int in our response. |
mode (0/1) | 0 or 1 | same | Drop-in. |
energy | ~0.5–0.7 for pop | 0.73 | Distribution is shifted up; nudge thresholds ~0.05–0.1 higher. |
valence | centred ~0.5 | 0.48 | Closest to Spotify's distribution. Minimal re-tune. |
danceability | centred ~0.5–0.6 | 0.74 | Shifted up. Old 0.65 ≈ ours 0.80. |
acousticness | 0.05–0.3 pop; > 0.7 acoustic | 0.98 | Do not use as "is acoustic." Discriminates near 0.99+. |
instrumentalness | bimodal (0 or 1) | 0.23, narrow | Don't use as a vocal-detection classifier; treat as a continuous shape signal. |
liveness | 0.05–0.15 studio; > 0.7 live | 1.00 (saturated) | Not currently diagnostic. Avoid. |
speechiness | 0.03–0.07 music; > 0.66 spoken | 0.45 | Shifted up ~5–10×. Old 0.66 ≈ ours > 0.85. |
loudness (dB) | −7 to −10 mastered pop | −14 | 3–6 dB lower. 30s window vs full-track integrated, dBFS vs BS.1770. Use relatively, not absolutely. |
time_signature | mostly 4 | 4 in practice | Not currently diagnostic. |
Field by field
bpm / tempo — drop-in, plus a documented improvement
Catalog median 118.7 BPM, P95 162. Comparable scale and accuracy to Spotify. The one wrinkle both APIs share is the half-time / double-time detection error: Essentia (and Spotify's old model) sometimes locks onto half the beat grid for upbeat pop — "Blinding Lights" is the canonical example, where Essentia returns ~85 BPM but the perceived BPM is ~171. We expose an extra field bpm_alt on the rich /lookup response that surfaces the corrected value when our heuristic suggests the detection locked on the wrong level. See Half-Time vs Double-Time BPM Detection: How We Fixed Spotify's Known Accuracy Gap for the algorithm. Net: tempo behaves the same; bpm_alt is bonus, opt-in.
key (string), key_int, mode — drop-in
Essentia's KeyExtractor is in the same accuracy league as the DJ-software incumbents (see Why DJ Platforms Disagree on Key 60% of the Time). The Spotify-shaped /v1/audio-features/{id} route returns key as the integer 0–11 (−1 if undetected) and mode as 0/1 — identical semantics. The rich /lookup response additionally returns the human-readable key ("C#-Minor") plus camelot + open_key notation for harmonic mixing. No re-tune needed.
energy — comparable, slightly higher in our distribution
We compute energy as normalised mean RMS over the analysis window, clipped to [0, 1]. Catalog median 0.73, mean 0.69. Spotify's typical pop sat around 0.5–0.7. If you had thresholds like energy > 0.6 = "high energy", expect more matches under our distribution; nudge it to ~0.7.
valence — closest to Spotify's distribution
Valence is the field where our signal-analysis approximation tracks Spotify's behaviour most closely. Our model: 0.65 if major key else 0.35, weighted with tempo, spectral brightness, and RMS. Catalog median 0.48 (Spotify centred ~0.5). The mode dependence is the biggest single driver in both schemes — major-key tracks score higher, minor lower. Minimal re-tune needed.
danceability — shifted up
Our recipe: a Gaussian curve over BPM peaking at 120, beat-confidence, and inter-beat regularity. Catalog median 0.74 vs Spotify's ~0.55. The reason: beat regularity is very high for nearly all commercial music (compressed mastering produces clean ticks), so the regularity term saturates and pushes the score up. Old danceability > 0.65 filter ≈ ours > 0.80. The relative ordering within a genre is still informative; the absolute scale is shifted.
acousticness — rescaled on 27 July 2026; your Spotify thresholds now transfer
Updated 27 July 2026 — this section previously told you to raise your threshold to ~0.99. Do not do that any more. What this post originally described as a quirk was a bug: the constant we scaled spectral flatness against was far too large (real music sits near 0.003, not the 0.15 we divided by), so 98% of the catalogue came back at roughly 0.98. That is fixed. If you re-tuned your thresholds upward on the old advice, revert them.
Our model is still spectral flatness — low flatness means strong tonal/harmonic peaks — but the result is now mapped onto the same distribution as Spotify's own acousticness, calibrated against a 114,000-track reference set. Catalogue percentiles moved from p25 0.9365 / p50 0.9718 / p75 0.9900 to p25 0.017 / p50 0.169 / p75 0.598, which is Spotify's shape to three decimal places. Mean absolute error against Spotify fell from 0.74 to 0.27. A Spotify threshold like acousticness > 0.7 now selects a comparable slice of your library instead of ~99% of it.
The honest caveat is unchanged in kind, only in degree: this is a spectral measure, not a trained classifier. Rescaling fixes the scale, not the underlying agreement — rank correlation with Spotify is about 0.34, so a highly tonal synth pad can still read as fairly acoustic. Use it to sort and filter across a library, and for confident acoustic-vs-electric tagging on an individual track still prefer instrumentalness + energy + genre together. (The R² figure quoted later in this post, 0.07, was measured on the old saturated scale.)
instrumentalness — signal proxy, not a vocal classifier
Spotify's instrumentalness was a vocal-presence classifier with a notably bimodal output: vocal music near 0, instrumental near 1. Ours is 1 − clip(mean_std(MFCC[1:5]) / 25, 0, 1), which measures whether the formant-band MFCCs are stable (instrumental) or varying (vocal). Catalog median 0.23, P75 0.35. It's a soft, continuous signal — not a confident "is this vocal" yes/no. Don't use instrumentalness > 0.5 as a vocal filter; it will mostly return false. Use it as a relative ordering signal within a genre instead.
liveness — currently saturated
Honest call-out: our liveness formula is clip(noise_floor_ratio × 6.0, 0, 1) where noise_floor_ratio is the 5th-percentile RMS divided by the 95th-percentile RMS over the window. The intuition is that live recordings have an elevated noise floor (audience ambience). The problem: modern mastered music has heavy dynamic-range compression, which compresses the RMS percentiles together — and the ×6 multiplier saturates at 1.0 almost immediately. Catalog median 1.0; the field is not currently useful for distinguishing live recordings.
If your Spotify code used liveness > 0.8 to filter out live recordings, that filter will reject your entire catalog under FreqBlog. Until we ship a better live-detector, drop the filter or use track-name pattern matching for now ("(Live)", "Live at", etc.) — we already do that to keep live recordings out of the catalog wherever the source labels them.
speechiness — shifted up roughly an order of magnitude
Spotify's speechiness sat around 0.03–0.07 for music and crossed 0.66 for spoken word / talk podcasts. Ours is clip(zero_crossing_rate / 0.12, 0, 1). Typical music ZCR is 0.03–0.07, which lands us at 0.25–0.6 — an order of magnitude higher than Spotify's. Catalog median 0.45.
To reproduce Spotify's "is this spoken word" rule, you probably want our threshold around > 0.85 rather than > 0.66. The relative ordering is preserved (rap and spoken intros do score higher than melodic singing), the absolute scale isn't.
loudness — numerically lower, relatively comparable, or use ?calibrate=spotify
Catalog median −14 dB; Spotify's mastered-pop typical range was −7 to −10 dB. Two reasons for the offset: (1) we measure dBFS amplitude on a 30-second analysis window, not the BS.1770 integrated full-track loudness Spotify reported; (2) the window is shorter so loud passages are diluted. The relative ordering ("this track is louder than that one") is preserved; the absolute numbers shift down by 3–6 dB. If you were using loudness > −8 as a "well-mastered modern release" filter, try > −12 under FreqBlog.
Or use the calibrated mode. GET /v1/audio-features/{id}?calibrate=spotify applies a linear regression fitted on a 5,724-track overlap between our catalog and the public maharshipandya/spotify-tracks-dataset:
loudness_spotify_calibrated = 0.557 × loudness_freqblog + 0.960
Regression metrics on the 5,724-track sample: R² = 0.43, RMSE = 3.0 dB (down from 8.5 dB raw — 64% improvement). Worked example: Adele "Hello" returns raw loudness = −10.96; calibrated returns −5.14 (cached Spotify value for the same recording: −6.13). The calibrated number won't match Spotify's exactly — same physical quantity measured by different methods on different audio windows — but it's much closer to the scale your existing code is tuned for. Default (omit ?calibrate=) returns raw FreqBlog values so existing integrations stay byte-identical.
Other audio-feature fields don't ship calibration in v1: their heuristic-vs-Spotify-ML methodology gap is too wide to fit a regression honestly. Per-field R² on the same 5,724-track sample: energy 0.28, danceability 0.18, valence 0.005, acousticness 0.07, instrumentalness 0.003, liveness 0.004, speechiness 0.014. See the next section for why — the short version is: when R² is near zero, a linear regression doesn't reveal a calibration, it just averages noise into a number that looks like Spotify's but doesn't correlate with what Spotify would have said.
Why most fields don't ship calibration (the methodology gap)
The honest summary: when R² is near zero, a linear regression doesn't reveal a calibration — it just averages noise into a number that looks like Spotify's but doesn't actually correlate with what Spotify would have said.
Two different things are being measured
Our pipeline computes audio features as deterministic formulas over spectral measurements of a 30-second preview. Here's valence end-to-end (the actual production code, simplified):
valence = (0.65 if major_key else 0.35) * 0.40
+ clip((bpm - 60) / 120, 0, 1) * 0.20
+ clip(spectral_centroid / (sr*0.3), 0, 1) * 0.20
+ clip(rms_mean / 0.3, 0, 1) * 0.20
That's the whole function. Pure DSP. Same input → same output, forever. The same shape (weighted sum of bounded spectral statistics) holds for danceability, acousticness, instrumentalness, liveness, speechiness — different inputs, same family of functions.
Spotify's pipeline trained ML classifiers on labelled data: human-tagged tracks, user playlist co-occurrence ("what tracks do people put on 'happy' playlists vs 'sad' playlists"), genre tags, listening behaviour, the lot. Hidden architecture, trained on perception rather than signal properties, updated over years with continuous training.
These are different things being measured, not different measurements of the same thing.
Concrete example: why valence's R² is 0.005
Johnny Cash's cover of "Hurt":
- Major key (sad lyrics over major chord progression) → our
scale_base = 0.65 - ~91 BPM, mid-range tempo → mid
tempo_valence - Acoustic guitar, mid-bright → moderate
brightness - Soft dynamics → low
rms_norm - Our valence ≈ 0.40 ("moderately positive")
- Spotify's valence ≈ 0.04 ("definitively sad")
Spotify "knew" Hurt was sad because it sits next to other sad songs in everyone's "introspective" playlists. We can't see that from a 30-second spectrogram. There's no signal in our inputs that distinguishes "sad cover in a major key" from "happy song in a major key."
Run that mismatch across 5,724 tracks and you get R² = 0.005 — essentially no relationship.
What a "calibration" would actually do at R² = 0.005
A linear fit y = 0.197 · x + 0.406 says "for every 1.0 increase in our valence, Spotify's valence increases by 0.197 on average." With R² 0.005, the "on average" is doing almost all the work — the average track's calibrated value lands near Spotify's mean (~0.49), regardless of what our value was.
Shipping that as ?calibrate=spotify would mean:
- Hurt (our 0.40): calibrated → 0.485
- "Happy" by Pharrell (our 0.78): calibrated → 0.560
- A customer filtering "happiest tracks" by
valence > 0.55gets neither, lands on random middle-of-distribution tracks instead - The customer thinks they're filtering on Spotify's emotion signal. They're filtering on a number with no actual signal in it.
That's the dishonest part. The raw 0.40 vs 0.78 ordering at least carries some information — major key + tempo + brightness rank-order Hurt and Happy correctly for those acoustic properties. The calibrated 0.485 vs 0.560 carries less: compressed toward the mean by the low slope, the ordering becomes less reliable because the noise dominates the signal.
The decision rule
The R² ≥ 0.30 floor for shipping a calibration is roughly: the regression has to explain more variance than it adds noise. Below that, raw values + the per-field threshold guidance above gives you more information than a regression-laundered number.
Loudness clears the floor (R² 0.43) because it's measuring the same physical quantity as Spotify — just on a different reference (dBFS vs BS.1770) over a different window (30s vs full track). The calibration recovers ~64% of that systematic offset honestly.
For the other fields, getting better numbers means changing the methodology, not calibrating the existing one. Concretely: a TensorFlow vocal classifier for instrumentalness, a learned valence model trained on labelled emotion data, a crowd-noise/applause detector for liveness. Those would be new code paths — not constants applied on top of the heuristic. If your migration depends critically on one of those fields, write — concrete demand is what drives what we work on next.
time_signature — currently always 4
Honest call-out: we ship Essentia's Meter estimator with a fallback to 4 when fewer than four beats are detected in the 30s window. In practice the fallback fires often enough that ~100% of the catalog returns 4. We could expose detected 3/4 and 6/8 if the upstream estimator agreed more often, but right now the field isn't a reliable diagnostic. Don't filter on time_signature; treat it as informational.
What we get right (and where we improve on Spotify)
- BPM & key — Essentia is mature DSP. Comparable accuracy to Spotify's models on a typical commercial track.
bpm_altis a documented improvement: it corrects Spotify's known half-time detection bug for tracks like "Blinding Lights" (Spotify said 85; perceived is 171). - Camelot + Open Key — first-class fields, ready for harmonic-mixing tools without you having to write the lookup table yourself.
- Latency — cache hits return in ~25–40 ms with keep-alive (p95 under 100 ms), with no OAuth dance. A single
X-Api-Keyheader is the whole auth flow. - Coverage tier 2 — when a track isn't in our pre-analysed catalog, we fall back through AcousticBrainz / Million Song Dataset / FMA (8M+ total tracks via MBID and name lookup). Beyond that, our intelligent backfill ingests the track from the iTunes preview in 30 s – 2 min and emails you when it's ready. Spotify's API never had a long-tail backfill story; if their model hadn't seen a track, you got nothing.
- Identifier flexibility —
/v1/audio-features/{id}accepts an ISRC (with or without hyphens), a track name via/lookup?track=…&artist=…, and — for tracks we've already mapped to a Spotify ID (a minority of the catalog, currently under 1%) — a raw Spotify track ID, aspotify:track:…URI, or anopen.spotify.com/track/…URL. The raw Spotify ID is not a universal reverse lookup: we can only resolve an ID we've already mapped, and we can't turn an unknown Spotify ID into a track server-side (Spotify's ownGET /v1/tracks/{id}now returns 403 without a Premium app). For reliable coverage, identify by name (/lookup?track=…&artist=…) or by ISRC. If your own Spotify integration already gives you the ISRC (external_ids.isrc), pass that straight to us — that's the one-liner.
The honest call-outs
To save you re-reading the warning boxes:
acousticness— not an "acoustic recording" detector. Discriminates near 0.99.instrumentalness— signal-shape proxy from MFCC variance, not a vocal classifier. Don't trust> 0.5asis_instrumental.liveness— saturates at 1.0 for compressed modern masters. Not currently useful for filtering live recordings.time_signature— almost always returns 4 in practice. Treat as informational, not as a filter.
These are honest limitations of the current signal-analysis approach. If your migration depends critically on any of them, write to [email protected] with the use case — concrete demand is what drives what we work on next. (For example: if enough customers care about a real liveness classifier, we'd plug in a TensorFlow-based vocal/ambience model. Without demand we keep the surface area honest.)
A practical migration checklist
- Swap the host:
api.spotify.com/v1/audio-features/{id}→api.freqblog.com/v1/audio-features/{id}(auth becomesX-Api-Key). For reliable coverage, key your lookups by ISRC or by name (/lookup?track=…&artist=…) rather than a raw Spotify ID — a raw Spotify ID only resolves for the minority of tracks we've already mapped to one. The name path searches the full catalog and queues an on-demand fetch + analysis on a miss, so tracks we don't have yet still get ingested and returned shortly. - Run your existing call against a few known tracks, log the response side-by-side with your stored Spotify values from before deprecation if you have them.
- Walk the table above: bump
danceability/energythresholds slightly up, pushacousticnessandspeechinessthresholds way up, droplivenessandtime_signaturefrom any filters. - Replace any
instrumentalness > 0.5"is instrumental" rule with a combination signal (e.g. low energy + high acousticness + lyrics-absent metadata). - For batches:
GET /v1/audio-features?ids=id1,id2,…takes up to 100 comma-separated identifiers and returns the Spotify-shaped{"audio_features": […]}. You’re charged one quota request per ID that returns data — IDs we can’t resolve (an unmapped Spotify ID, or an ISRC with no match) come backnullfor free, exactly like the single-ID route’s404. So a batch of raw Spotify IDs only bills for the ones we actually have.
Try the drop-in — free tier, no card
Get an API key in seconds; the first 1,000 lookups per month are free.
Get API Key →Further reading
- Spotify Audio Features Is Dead. Here's What to Use Instead in 2026. — the landscape and the host-swap walkthrough
- Half-Time vs Double-Time BPM Detection: How We Fixed Spotify's Known Accuracy Gap
- Why DJ Platforms Disagree on Key 60% of the Time
- Camelot Wheel for Developers: Harmonic Mixing Without the Music Theory PhD
- Spotify's original (now-deprecated) audio-features reference