from typing import Any, Dict, Tuple import torch import torch.nn.functional as F import numpy as np from collections import defaultdict, Counter import heapq SAMPLE_RATE = 16000 WINDOW_SIZE = 512 def chunked(iterable, size): """Helper to yield successive chunks of given size from iterable.""" for i in range(0, len(iterable), size): yield iterable[i : i + size] class GPUShazam: def __init__(self, sample_rate=SAMPLE_RATE, n_fft=WINDOW_SIZE, hop_length=None, fan_value=5, min_match_ratio: float = 0.1, device='cuda', kernal_size=5, min_prune_size=500, max_prune_ratio=0.8): """ GPU-based Shazam with optional query‐match threshold. Args: … min_query_matches: only accept a match if vote count ≥ this threshold """ self.sample_rate = sample_rate self.n_fft = n_fft self.hop_length = hop_length or n_fft // 4 self.fan_value = fan_value self.min_match_ratio = min_match_ratio self.window = torch.hann_window(self.n_fft, device=device) self.index = {} self.min_hash_time_delta = 0 self.max_hash_time_delta = 200 self.min_hash_freq_delta = -30 self.max_hash_freq_delta = 30 self.kernal_size = kernal_size self.device = device self.min_prune_size = min_prune_size self.max_prune_ratio = max_prune_ratio self._set = set() def fingerprint(self, waveform, delta_compress: bool = False): """ Compute fingerprints for a single audio waveform. Args: waveform (1D array-like): audio samples Returns: List of (hash, time_offset) tuples. """ # Move signal to GPU sig = torch.tensor(waveform, dtype=torch.float32, device=self.device) # Compute complex STFT on GPU spec = torch.stft(sig, n_fft=self.n_fft, hop_length=self.hop_length, win_length=self.n_fft, window=self.window, center=False, return_complex=True) # Magnitude spectrogram mag = spec.abs() # Local max pooling for peak detection padding = self.kernal_size // 2 max_pooled = F.max_pool2d( mag.unsqueeze(0).unsqueeze(0), kernel_size=(self.kernal_size, self.kernal_size), stride=1, padding=(padding, padding) ) #amp_min = torch.quantile(mag.flatten(), 0.99).item() amp_min = 10 peaks = (mag.unsqueeze(0).unsqueeze(0) == max_pooled) & (mag.unsqueeze(0).unsqueeze(0) > amp_min) peaks = peaks.squeeze().cpu().numpy() freqs, times = np.where(peaks) # after you compute freqs, times: peak_list = list(zip(times, freqs)) if len(peak_list) == 0: return [] peak_list.sort(key=lambda x: x[0]) # sort by time times_sorted, freqs_sorted = zip(*peak_list) # now pair in temporal order hashes = [] for i in range(len(times_sorted)): for j in range(1, self.fan_value): if i + j < len(times_sorted): t1, f1 = times_sorted[i], freqs_sorted[i] t2, f2 = times_sorted[i+j], freqs_sorted[i+j] dt = t2 - t1 df = f2 - f1 if self.min_hash_time_delta <= dt <= self.max_hash_time_delta and self.min_hash_freq_delta <= df <= self.max_hash_freq_delta: h = (f1 << 24) | (f2 << 16) | dt hashes.append((h, t1)) if not delta_compress: return hashes # 3) aggregate into a list of (h, [t1, t2, …]) grouping = defaultdict(list) for h, t in hashes: grouping[h].append(t) return [(h, grouping[h]) for h in grouping] def add_song(self, song_id, waveform): """ Add a song to the index. Args: song_id (Any): unique identifier for the song waveform (1D array-like): audio samples """ fpt= self.fingerprint(waveform, delta_compress=True) if len(fpt)>0: self._set.add(song_id) for h, t in fpt: self.index.setdefault(h, []).append((song_id, t)) def top_candidates(self, batch, top_n): counts : Dict[Tuple[int, int], int] = {} for h, t_query in batch: for song_id, t_ref_list in self.index.get(h, []): # t_ref_list 可能是列表,也可能是单个值 if isinstance(t_ref_list, list): for t_ref in t_ref_list: delta = t_ref - t_query counts.setdefault((song_id, delta), 0) counts[(song_id, delta)] += 1 else: t_ref = t_ref_list delta = t_ref - t_query counts.setdefault((song_id, delta), 0) counts[(song_id, delta)] += 1 counter = Counter(counts) return counter.most_common(top_n) def _prune(self): if len(self._set) < self.min_prune_size: return maximum_cnt = len(self._set) * self.max_prune_ratio for hash in self.index: if len(self.index[hash]) >= maximum_cnt: self.index[hash] = [] def most_common(self, counter_dict, top_n): counter = Counter(counter_dict) return counter.most_common(top_n) def query(self, waveform, top_n=1): """ Returns (best_song_id, best_time_delta, match_ratio). If no match or ratio < min_match_ratio, song_id/time_delta will be None. """ """ 返回匹配度最高的 top_n 个结果列表,每个元素为 (song_id, time_delta, match_ratio)。 Args: waveform: 1D numpy array,待查询的音频片段 top_n : 整数,返回前 top_n 项(如果总候选数不足,也可能少于 top_n)。 返回: List[ (song_id or None, time_delta or None, match_ratio) ] - 如果 match_ratio < self.min_match_ratio,则该项的 song_id/time_delta 均为 None 但仍返回 match_ratio。 """ # 1) 对片段做指纹提取 hashes = list(self.fingerprint(waveform, delta_compress=False)) total_hashes = len(hashes) if total_hashes == 0: # 如果根本没有哈希,就直接返回 top_n 个 (None, None, 0.0) return [(None, None, 0.0)] * top_n # 2) 统计每个 (song_id, Δt) 的匹配数 counts: Dict[(Any, int), int] = {} batch_size = 1000 candidates, total = {}, 0 for batch in chunked(hashes, batch_size): print(f"Processing batches in query, {total + len(batch)}") top_candidates = self.top_candidates(batch, top_n) # 4) 最终拼出结果列表 results, should_stop = [], True for tp, vote_count in top_candidates: original_count = candidates.get(tp, 0) original_ratio = original_count / total if total else 0 new_match_ratio = (original_count + vote_count) / (total + len(batch)) if new_match_ratio <=self.min_match_ratio and abs(new_match_ratio - original_ratio) >= 0.02: should_stop = False candidates[tp] = original_count + vote_count if should_stop: return self.most_common(candidates, top_n), (total + len(batch)) total += len(batch) return self.most_common(candidates, top_n), total