//! Shazam-style audio recognition for the DJ-set setlist assistant — the //! research bundle's "broader recognition adapter" (feasibility §3 rung 4, //! plan phase 7), Kyle's pick 2026-08-19: identify a mix's tracks by //! LISTENING, not just by reading its posted tracklist. //! //! The signature algorithm and packet format are reimplemented from the //! publicly documented Shazam fingerprint (Wang 2003; layout and constants //! cross-checked against the open-source SongRec project's description of //! the format). Nothing here links foreign code: plain Rust over the same //! realfft the vocal separator uses. //! //! Posture: in-process, no child processes; ONE request per analyzed //! window against the free discovery endpoint, paced by the caller //! (research §11 — rate limits, timeout, cache belong to the job layer, //! built next). Failure is always soft: a window that cannot be //! recognized is an honest Unknown, never a guess. use realfft::num_complex::Complex; use realfft::RealFftPlanner; /// One recognized window: what Shazam heard and where it claims the window /// sits inside the identified track. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Recognition { pub artist: String, pub title: String, /// Shazam track key — stable id for consensus grouping. pub key: String, /// Offset of the analyzed window within the identified track, seconds, /// when the response carries one (it usually does). pub offset_secs: Option, pub album: Option, pub cover_url: Option, /// The matched RELEASE's ISRC when the response carries one — an /// id-grade identity the review can resolve against the catalog /// exactly, instead of by name. pub isrc: Option, } // ---- signature generation (pure DSP) ---------------------------------------- const FFT_SIZE: usize = 2048; const BINS: usize = FFT_SIZE / 2 + 1; // 1025 const HOP: usize = 128; /// The generator's working window: 12 s at 16 kHz mono, the length the /// service expects a tag to be. pub const WINDOW_SECS: usize = 12; pub const SAMPLE_RATE: usize = 16_000; struct Rings { samples: [i16; FFT_SIZE], samples_at: usize, ffts: Vec<[f32; BINS]>, ffts_at: u8, spread: Vec<[f32; BINS]>, spread_at: u8, passes: u32, peaks: [Vec; 4], } struct Peak { pass: u32, magnitude: u16, corrected_bin: u16, } /// Symmetric Hann of length 2048 (numpy-style, N−1 denominator) — the /// window the reference tables were generated from. fn hann() -> Vec { (0..FFT_SIZE) .map(|i| { 0.5 - 0.5 * (2.0 * std::f32::consts::PI * i as f32 / (FFT_SIZE - 1) as f32).cos() }) .collect() } /// Build the binary signature for ≤ WINDOW_SECS of 16 kHz mono PCM. pub fn signature(pcm: &[f32]) -> Vec { let mut samples: Vec = pcm .iter() .map(|&x| (x * 32768.0).clamp(-32768.0, 32767.0) as i16) .collect(); let want = WINDOW_SECS * SAMPLE_RATE; if samples.len() < want { samples.resize(want, 0); } else { samples.truncate(want); } let window = hann(); let mut planner = RealFftPlanner::::new(); let fft = planner.plan_fft_forward(FFT_SIZE); let mut rings = Rings { samples: [0i16; FFT_SIZE], samples_at: 0, ffts: vec![[0.0; BINS]; 256], ffts_at: 0, spread: vec![[0.0; BINS]; 256], spread_at: 0, passes: 0, peaks: Default::default(), }; let mut re_in = vec![0.0f32; FFT_SIZE]; let mut cx_out = vec![Complex::default(); BINS]; for chunk in samples.chunks_exact(HOP) { // Ring in the new hop, then window the reordered buffer. for (i, &s) in chunk.iter().enumerate() { rings.samples[(rings.samples_at + i) & (FFT_SIZE - 1)] = s; } rings.samples_at = (rings.samples_at + HOP) & (FFT_SIZE - 1); for i in 0..FFT_SIZE { re_in[i] = rings.samples[(i + rings.samples_at) & (FFT_SIZE - 1)] as f32 * window[i]; } let _ = fft.process(&mut re_in, &mut cx_out); { let out = &mut rings.ffts[rings.ffts_at as usize]; for (o, c) in out.iter_mut().zip(cx_out.iter()) { *o = ((c.re * c.re + c.im * c.im) / (1 << 17) as f32).max(1e-10); } } rings.ffts_at = rings.ffts_at.wrapping_add(1); // Frequency- then time-domain peak spreading. { let src = rings.ffts[rings.ffts_at.wrapping_sub(1) as usize]; let dst = &mut rings.spread[rings.spread_at as usize]; dst.copy_from_slice(&src); for i in 0..=BINS - 3 { dst[i] = dst[i].max(dst[i + 1]).max(dst[i + 2]); } let snapshot = *dst; for former in [1u8, 3, 6] { let f = &mut rings.spread[rings.spread_at.wrapping_sub(former) as usize]; for i in 0..BINS { f[i] = f[i].max(snapshot[i]); } } } rings.spread_at = rings.spread_at.wrapping_add(1); rings.passes += 1; if rings.passes >= 46 { recognize_peaks(&mut rings); } } encode(&rings.peaks, want as u32) } fn recognize_peaks(r: &mut Rings) { let f46 = r.ffts[r.ffts_at.wrapping_sub(46) as usize]; let s49 = r.spread[r.spread_at.wrapping_sub(49) as usize]; for bin in 10..=BINS - 11 { if f46[bin] < 1.0 / 64.0 || f46[bin] < s49[bin - 1] { continue; } let mut neighbor_max = 0.0f32; for off in [-10i32, -7, -4, -3, 1, 2, 5, 8] { neighbor_max = neighbor_max.max(s49[(bin as i32 + off) as usize]); } if f46[bin] <= neighbor_max { continue; } let mut other_max = neighbor_max; for off in [ -53i32, -45, 165, 172, 179, 186, 193, 200, 214, 221, 228, 235, 242, 249, ] { let other = &r.spread[((r.spread_at as i32 + off) & 255) as usize]; other_max = other_max.max(other[bin - 1]); } if f46[bin] <= other_max { continue; } let pass = r.passes - 46; let mag = f46[bin].ln().max(1.0 / 64.0) * 1477.3 + 6144.0; let before = f46[bin - 1].ln().max(1.0 / 64.0) * 1477.3 + 6144.0; let after = f46[bin + 1].ln().max(1.0 / 64.0) * 1477.3 + 6144.0; let variation_1 = mag * 2.0 - before - after; if variation_1 < 0.0 { continue; } let variation_2 = (after - before) * 32.0 / variation_1; let corrected = (bin as i32 * 64 + variation_2 as i32) as u16; let hz = corrected as f32 * (SAMPLE_RATE as f32 / 2.0 / 1024.0 / 64.0); let band = match hz as i32 { 250..=519 => 0, 520..=1449 => 1, 1450..=3499 => 2, 3500..=5500 => 3, _ => continue, }; r.peaks[band].push(Peak { pass, magnitude: mag as u16, corrected_bin: corrected, }); } } /// The wire format: 48-byte header (magic, CRC-32 of everything after the /// first 8 bytes, payload size, 16 kHz marker, sample count + 0.24 s), a /// type-length echo, then per-band peak runs with delta-coded pass numbers. fn encode(peaks: &[Vec; 4], number_samples: u32) -> Vec { let mut out: Vec = Vec::with_capacity(4096); let w32 = |out: &mut Vec, v: u32| out.extend_from_slice(&v.to_le_bytes()); let w16 = |out: &mut Vec, v: u16| out.extend_from_slice(&v.to_le_bytes()); w32(&mut out, 0xcafe2580); // magic1 w32(&mut out, 0); // crc32 backfilled w32(&mut out, 0); // size backfilled w32(&mut out, 0x94119c00); // magic2 w32(&mut out, 0); w32(&mut out, 0); w32(&mut out, 0); w32(&mut out, 3u32 << 27); // 16 kHz w32(&mut out, 0); w32(&mut out, 0); w32(&mut out, number_samples + (SAMPLE_RATE as f32 * 0.24) as u32); w32(&mut out, (15 << 19) + 0x40000); w32(&mut out, 0x40000000); w32(&mut out, 0); // size echo backfilled for (band, list) in peaks.iter().enumerate() { if list.is_empty() { continue; } let mut body: Vec = Vec::with_capacity(list.len() * 5); let mut pass = 0u32; for p in list { if p.pass - pass >= 255 { body.push(0xff); body.extend_from_slice(&p.pass.to_le_bytes()); pass = p.pass; } body.push((p.pass - pass) as u8); body.extend_from_slice(&p.magnitude.to_le_bytes()); body.extend_from_slice(&p.corrected_bin.to_le_bytes()); pass = p.pass; } w32(&mut out, 0x60030040 + band as u32); w32(&mut out, body.len() as u32); out.extend_from_slice(&body); while out.len() % 4 != 0 { out.push(0); } } let _ = w16; // (kept for symmetry; u16 writes happen inside the body) let size = (out.len() - 48) as u32; out[8..12].copy_from_slice(&size.to_le_bytes()); out[52..56].copy_from_slice(&size.to_le_bytes()); let mut hasher = crc32fast::Hasher::new(); hasher.update(&out[8..]); let crc = hasher.finalize(); out[4..8].copy_from_slice(&crc.to_le_bytes()); out } // ---- the discovery request ---------------------------------------------------- /// Pseudo-UUID v4 from OS entropy via the stdlib hasher — unique enough for /// the tag ids the endpoint expects, no uuid/rand dependency. fn pseudo_uuid() -> String { use std::collections::hash_map::RandomState; use std::hash::{BuildHasher, Hasher}; let mut parts = [0u64; 2]; for p in parts.iter_mut() { let mut h = RandomState::new().build_hasher(); h.write_u64(std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.subsec_nanos() as u64) .unwrap_or(0)); *p = h.finish(); } let b: Vec = parts.iter().flat_map(|p| p.to_le_bytes()).collect(); format!( "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-4{:01x}{:02x}-a{:01x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", b[0], b[1], b[2], b[3], b[4], b[5], b[6] & 0xf, b[7], b[8] & 0xf, b[9], b[10], b[11], b[12], b[13], b[14], b[15] ) } /// Recognize one ≤12 s window of 16 kHz mono PCM. `Ok(None)` = the service /// answered and heard nothing it knows — an honest Unknown. `Err` = the /// request itself failed (network, rate limit) and the caller may retry /// later. pub async fn recognize_window(pcm16k: &[f32]) -> Result, String> { let sig = signature(pcm16k); use base64::Engine as _; let uri = format!( "data:audio/vnd.shazam.sig;base64,{}", base64::engine::general_purpose::STANDARD.encode(&sig) ); let sample_ms = (pcm16k.len().min(WINDOW_SECS * SAMPLE_RATE) * 1000 / SAMPLE_RATE) as u64; let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); let body = serde_json::json!({ "geolocation": { "altitude": 300, "latitude": 45, "longitude": 2 }, "signature": { "samplems": sample_ms, "timestamp": ts, "uri": uri }, "timestamp": ts, "timezone": "Europe/Paris", }); let url = format!( "https://amp.shazam.com/discovery/v5/en/US/android/-/tag/{}/{}?sync=true&webv3=true&sampling=true&connected=&shazamapiversion=v3&sharehub=true&video=v3", pseudo_uuid().to_uppercase(), pseudo_uuid() ); let client = reqwest::Client::builder() .user_agent("Dalvik/2.1.0 (Linux; U; Android 13; Pixel 7 Build/TQ3A.230901.001)") .timeout(std::time::Duration::from_secs(20)) .build() .map_err(|e| e.to_string())?; let resp = client .post(&url) .header("Content-Language", "en_US") .json(&body) .send() .await .map_err(|e| format!("Shazam did not answer: {e}"))?; if resp.status().as_u16() == 429 { return Err("rate-limited by the recognition service".into()); } let v: serde_json::Value = resp .json() .await .map_err(|e| format!("unreadable recognition response: {e}"))?; let Some(track) = v.get("track") else { return Ok(None); }; let title = track .get("title") .and_then(serde_json::Value::as_str) .unwrap_or("") .to_string(); let artist = track .get("subtitle") .and_then(serde_json::Value::as_str) .unwrap_or("") .to_string(); if title.is_empty() { return Ok(None); } let key = track .get("key") .and_then(serde_json::Value::as_str) .unwrap_or("") .to_string(); let offset_secs = v .get("matches") .and_then(|m| m.get(0)) .and_then(|m| m.get("offset")) .and_then(serde_json::Value::as_f64); // Album rides in the metadata sections when present. let mut album = None; if let Some(sections) = track.get("sections").and_then(serde_json::Value::as_array) { for s in sections { if let Some(meta) = s.get("metadata").and_then(serde_json::Value::as_array) { for m in meta { if m.get("title").and_then(serde_json::Value::as_str) == Some("Album") { album = m .get("text") .and_then(serde_json::Value::as_str) .map(str::to_string); } } } } } let cover_url = track .get("images") .and_then(|i| i.get("coverart")) .and_then(serde_json::Value::as_str) .map(str::to_string); let isrc = track .get("isrc") .and_then(serde_json::Value::as_str) .filter(|s| !s.is_empty()) .map(str::to_string); Ok(Some(Recognition { artist, title, key, offset_secs, album, cover_url, isrc, })) } #[cfg(test)] mod tests { use super::*; /// The packet must round out structurally: magics in place, CRC over /// bytes 8.., size echoed at 8 and 52, bands tagged 0x60030040+n. #[test] fn signature_packet_is_well_formed() { // A tone sweep gives the recognizer real peaks to encode. let pcm: Vec = (0..WINDOW_SECS * SAMPLE_RATE) .map(|i| { let t = i as f32 / SAMPLE_RATE as f32; (2.0 * std::f32::consts::PI * (440.0 + 80.0 * t) * t).sin() * 0.4 }) .collect(); let sig = signature(&pcm); assert!(sig.len() > 64, "packet too small: {}", sig.len()); assert_eq!(&sig[0..4], &0xcafe2580u32.to_le_bytes()); assert_eq!(&sig[12..16], &0x94119c00u32.to_le_bytes()); let size = u32::from_le_bytes(sig[8..12].try_into().unwrap()); assert_eq!(size as usize, sig.len() - 48); assert_eq!(&sig[52..56], &size.to_le_bytes()); let mut h = crc32fast::Hasher::new(); h.update(&sig[8..]); assert_eq!(u32::from_le_bytes(sig[4..8].try_into().unwrap()), h.finalize()); let band_tag = u32::from_le_bytes(sig[56..60].try_into().unwrap()); assert!( (0x60030040..=0x60030043).contains(&band_tag), "first band tag {band_tag:#x}" ); } /// A pure tone at 1 kHz must place its peaks in band 1 (520–1450 Hz). #[test] fn peaks_land_in_the_right_band() { let pcm: Vec = (0..WINDOW_SECS * SAMPLE_RATE) .map(|i| (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / 16000.0).sin() * 0.4) .collect(); let mut samples: Vec = pcm.iter().map(|&x| (x * 32768.0) as i16).collect(); samples.truncate(WINDOW_SECS * SAMPLE_RATE); let sig = signature(&pcm); // Band 1's tag must appear; bands 0/2/3 may be empty or tiny. let mut found_band1 = false; let mut at = 56usize; while at + 8 <= sig.len() { let tag = u32::from_le_bytes(sig[at..at + 4].try_into().unwrap()); let len = u32::from_le_bytes(sig[at + 4..at + 8].try_into().unwrap()) as usize; if tag == 0x60030041 && len > 50 { found_band1 = true; } at += 8 + len + ((4 - len % 4) % 4); } assert!(found_band1, "1 kHz peaks must dominate band 1"); } /// LIVE: recognize a 12 s window of a known library track against the /// real service. Ignored by default — network, unofficial endpoint — /// run explicitly: cargo test -p app shazam_live -- --ignored --nocapture #[test] #[ignore] fn shazam_live_known_track() { let path = std::path::Path::new( "D:\\Music\\SoulSeek Music\\Panda Bear\\2015 - Panda Bear Meets The Grim Reaper\\1_SEQUENTIAL CIRCUITS.flac", ); assert!(path.is_file(), "corpus track missing"); let [l, r] = player::vocals::separation_input_44k1(path).unwrap(); let mono = player::vocals::stem_pcm_16k(&l, &r); // A mid-track window, well into the singing. let start = 60 * SAMPLE_RATE; let window = &mono[start..start + WINDOW_SECS * SAMPLE_RATE]; let rt = tokio::runtime::Runtime::new().unwrap(); let got = rt.block_on(recognize_window(window)).expect("request failed"); let rec = got.expect("service heard nothing — signature likely malformed"); println!( "LIVE RESULT: {} — {} (key {}, offset {:?}, album {:?}, isrc {:?})", rec.artist, rec.title, rec.key, rec.offset_secs, rec.album, rec.isrc ); assert!( rec.title.to_lowercase().contains("sequential"), "wrong track: {} — {}", rec.artist, rec.title ); } }