Spaces:
Running
Running
| from __future__ import annotations | |
| import subprocess | |
| from pathlib import Path | |
| import numpy as np | |
| PITCH_CLASSES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B") | |
| # Recognized tala / meter cycles keyed by beats-per-cycle. | |
| TALA_SIGNATURES: dict[int, tuple[str, str]] = { | |
| 3: ("Tisra Eka", "3/4"), | |
| 4: ("Common Time", "4/4"), | |
| 5: ("Khanda Chapu", "5/4"), | |
| 6: ("Rupaka", "6/8"), | |
| 7: ("Misra Chapu", "7/8"), | |
| 8: ("Adi Tala", "4/4"), | |
| 9: ("Sankirna Chapu", "9/8"), | |
| 10: ("Jhaptaal", "5/4"), | |
| 12: ("Ektaal", "12/8"), | |
| 14: ("Dhamar", "7/4"), | |
| 16: ("Teentaal", "4/4"), | |
| } | |
| # Krumhansl-Schmuckler key profiles. | |
| _MAJOR_PROFILE = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88], dtype=np.float64) | |
| _MINOR_PROFILE = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17], dtype=np.float64) | |
| def _safe_normalize(x: np.ndarray) -> np.ndarray: | |
| peak = float(np.max(np.abs(x))) if x.size else 0.0 | |
| if peak <= 1e-9: | |
| return x.astype(np.float32) | |
| return (x / peak).astype(np.float32) | |
| def _decode_audio_mono(audio_path: Path, sample_rate: int = 22050) -> np.ndarray: | |
| cmd = [ | |
| "ffmpeg", | |
| "-hide_banner", | |
| "-loglevel", | |
| "error", | |
| "-i", | |
| str(audio_path), | |
| "-f", | |
| "f32le", | |
| "-ac", | |
| "1", | |
| "-ar", | |
| str(sample_rate), | |
| "-", | |
| ] | |
| proc = subprocess.run(cmd, capture_output=True, check=False) | |
| if proc.returncode != 0: | |
| stderr = proc.stderr.decode(errors="replace")[:500] | |
| raise RuntimeError(f"Audio decode failed: {stderr}") | |
| y = np.frombuffer(proc.stdout, dtype=np.float32) | |
| if y.size == 0: | |
| raise RuntimeError("Audio decode returned empty output") | |
| y = np.nan_to_num(y) | |
| return _safe_normalize(y) | |
| def _make_frames(y: np.ndarray, frame_size: int, hop_size: int) -> np.ndarray: | |
| if y.size < frame_size: | |
| y = np.pad(y, (0, frame_size - y.size)) | |
| frame_count = 1 + (y.size - frame_size) // hop_size | |
| if frame_count <= 0: | |
| return np.empty((0, frame_size), dtype=np.float32) | |
| idx = np.arange(frame_size)[None, :] + hop_size * np.arange(frame_count)[:, None] | |
| return y[idx] | |
| def _onset_envelope(y: np.ndarray, frame_size: int = 2048, hop_size: int = 512) -> np.ndarray: | |
| frames = _make_frames(y, frame_size=frame_size, hop_size=hop_size) | |
| if frames.shape[0] < 3: | |
| return np.array([], dtype=np.float32) | |
| window = np.hanning(frame_size).astype(np.float32) | |
| spectrum = np.abs(np.fft.rfft(frames * window, axis=1)) | |
| flux = np.maximum(0.0, np.diff(spectrum, axis=0)).sum(axis=1) | |
| if flux.size == 0: | |
| return flux.astype(np.float32) | |
| flux = flux - float(np.min(flux)) | |
| flux = flux / (float(np.max(flux)) + 1e-9) | |
| # Mild smoothing to reduce spurious peaks. | |
| kernel = np.ones(5, dtype=np.float32) / 5.0 | |
| flux = np.convolve(flux, kernel, mode="same") | |
| return flux.astype(np.float32) | |
| def _tempo_from_autocorrelation(onset: np.ndarray, sample_rate: int, hop_size: int) -> tuple[float | None, float]: | |
| if onset.size < 16: | |
| return None, 0.0 | |
| x = onset - float(np.mean(onset)) | |
| if np.allclose(x, 0.0, atol=1e-9): | |
| return None, 0.0 | |
| ac = np.correlate(x, x, mode="full")[x.size - 1 :] | |
| if ac.size < 4: | |
| return None, 0.0 | |
| bpm_min, bpm_max = 60.0, 200.0 | |
| lag_min = max(1, int(round((60.0 / bpm_max) * sample_rate / hop_size))) | |
| lag_max = min(ac.size - 1, int(round((60.0 / bpm_min) * sample_rate / hop_size))) | |
| if lag_max <= lag_min: | |
| return None, 0.0 | |
| search = ac[lag_min : lag_max + 1] | |
| if search.size < 3: | |
| return None, 0.0 | |
| peak_rel = int(np.argmax(search)) | |
| lag = lag_min + peak_rel | |
| # Check octave-related tempo aliases (half/double-time). | |
| candidates = [lag] | |
| if lag // 2 >= lag_min: | |
| candidates.append(lag // 2) | |
| if lag * 2 <= lag_max: | |
| candidates.append(lag * 2) | |
| if lag * 3 // 2 <= lag_max: | |
| candidates.append(lag * 3 // 2) | |
| candidate_scores: dict[int, float] = {} | |
| for cand in candidates: | |
| score = float(ac[cand]) | |
| if cand != lag: | |
| score *= 0.96 | |
| candidate_scores[cand] = score | |
| best_lag = max(candidate_scores, key=candidate_scores.get) | |
| best_score = candidate_scores[best_lag] | |
| bpm = 60.0 * sample_rate / (best_lag * hop_size) | |
| # Resolve common half-time/double-time ambiguity. | |
| if bpm < 85.0 and best_lag // 2 >= lag_min: | |
| alt_lag = best_lag // 2 | |
| alt_bpm = 60.0 * sample_rate / (alt_lag * hop_size) | |
| if alt_bpm <= bpm_max and float(ac[alt_lag]) >= best_score * 0.88: | |
| best_lag = alt_lag | |
| bpm = alt_bpm | |
| elif bpm > 170.0 and best_lag * 2 <= lag_max: | |
| alt_lag = best_lag * 2 | |
| alt_bpm = 60.0 * sample_rate / (alt_lag * hop_size) | |
| if alt_bpm >= bpm_min and float(ac[alt_lag]) >= best_score * 0.88: | |
| best_lag = alt_lag | |
| bpm = alt_bpm | |
| peak = float(ac[best_lag]) | |
| sorted_peaks = np.sort(search) | |
| second = float(sorted_peaks[-2]) if sorted_peaks.size > 1 else 0.0 | |
| peak_sep = max(0.0, peak - second) / (abs(peak) + 1e-9) | |
| signal_strength = max(0.0, peak / (float(np.mean(search)) + 1e-9) - 1.0) | |
| confidence = float(np.clip(0.65 * peak_sep + 0.35 * (signal_strength / 4.0), 0.0, 1.0)) | |
| if bpm < bpm_min or bpm > bpm_max: | |
| return None, 0.0 | |
| return float(bpm), confidence | |
| def _tempo_from_frequency(onset: np.ndarray, sample_rate: int, hop_size: int) -> tuple[float | None, float]: | |
| if onset.size < 32: | |
| return None, 0.0 | |
| x = onset - float(np.mean(onset)) | |
| if np.allclose(x, 0.0, atol=1e-9): | |
| return None, 0.0 | |
| spec = np.abs(np.fft.rfft(x * np.hanning(x.size))) | |
| freqs = np.fft.rfftfreq(x.size, d=hop_size / sample_rate) | |
| bpms = freqs * 60.0 | |
| mask = (bpms >= 60.0) & (bpms <= 200.0) | |
| if not np.any(mask): | |
| return None, 0.0 | |
| filtered = spec[mask] | |
| filtered_bpms = bpms[mask] | |
| if filtered.size < 3: | |
| return None, 0.0 | |
| peak_idx = int(np.argmax(filtered)) | |
| bpm = float(filtered_bpms[peak_idx]) | |
| sorted_peaks = np.sort(filtered) | |
| second = float(sorted_peaks[-2]) if sorted_peaks.size > 1 else 0.0 | |
| peak = float(filtered[peak_idx]) | |
| confidence = float(np.clip((peak - second) / (peak + 1e-9), 0.0, 1.0)) | |
| return bpm, confidence | |
| def _detect_bpm(y: np.ndarray, sample_rate: int) -> tuple[float | None, float | None]: | |
| hop_size = 512 | |
| onset = _onset_envelope(y, hop_size=hop_size) | |
| if onset.size < 16: | |
| return None, None | |
| bpm_ac, conf_ac = _tempo_from_autocorrelation(onset, sample_rate=sample_rate, hop_size=hop_size) | |
| bpm_fft, conf_fft = _tempo_from_frequency(onset, sample_rate=sample_rate, hop_size=hop_size) | |
| if bpm_ac is None and bpm_fft is None: | |
| return None, None | |
| if bpm_ac is not None and bpm_fft is not None: | |
| diff = abs(bpm_ac - bpm_fft) | |
| if diff <= 4.0: | |
| bpm = (bpm_ac + bpm_fft) / 2.0 | |
| agreement = 1.0 - diff / 4.0 | |
| confidence = 0.5 * (conf_ac + conf_fft) * (0.7 + 0.3 * agreement) | |
| else: | |
| use_ac = conf_ac >= conf_fft | |
| bpm = bpm_ac if use_ac else bpm_fft | |
| confidence = max(conf_ac, conf_fft) * 0.65 | |
| else: | |
| bpm = bpm_ac if bpm_ac is not None else bpm_fft | |
| confidence = conf_ac if bpm_ac is not None else conf_fft | |
| if bpm is None or confidence is None: | |
| return None, None | |
| if confidence < 0.22: | |
| return None, None | |
| return round(float(bpm), 1), round(float(np.clip(confidence, 0.0, 1.0)), 2) | |
| def _chroma_profile(y: np.ndarray, sample_rate: int, frame_size: int = 4096, hop_size: int = 1024) -> np.ndarray: | |
| frames = _make_frames(y, frame_size=frame_size, hop_size=hop_size) | |
| if frames.shape[0] < 3: | |
| return np.zeros(12, dtype=np.float64) | |
| window = np.hanning(frame_size).astype(np.float32) | |
| spectrum = np.abs(np.fft.rfft(frames * window, axis=1)) ** 2 | |
| freqs = np.fft.rfftfreq(frame_size, d=1.0 / sample_rate) | |
| valid = (freqs >= 40.0) & (freqs <= 5000.0) | |
| spectrum = spectrum[:, valid] | |
| freqs = freqs[valid] | |
| if freqs.size == 0: | |
| return np.zeros(12, dtype=np.float64) | |
| midi = np.rint(69.0 + 12.0 * np.log2(freqs / 440.0)).astype(np.int32) | |
| pitch_classes = np.mod(midi, 12) | |
| chroma = np.zeros((12, spectrum.shape[0]), dtype=np.float64) | |
| for pc in range(12): | |
| pc_bins = pitch_classes == pc | |
| if not np.any(pc_bins): | |
| continue | |
| chroma[pc, :] = spectrum[:, pc_bins].sum(axis=1) | |
| # Dynamic range compression and time-average. | |
| chroma = np.sqrt(np.maximum(chroma, 0.0)) | |
| profile = chroma.mean(axis=1) | |
| total = float(profile.sum()) | |
| if total <= 1e-9: | |
| return np.zeros(12, dtype=np.float64) | |
| return profile / total | |
| def _corr(a: np.ndarray, b: np.ndarray) -> float: | |
| if np.allclose(a.std(), 0.0) or np.allclose(b.std(), 0.0): | |
| return 0.0 | |
| return float(np.corrcoef(a, b)[0, 1]) | |
| def _detect_key(y: np.ndarray, sample_rate: int) -> tuple[str | None, str | None, float | None]: | |
| profile = _chroma_profile(y, sample_rate=sample_rate) | |
| if not np.any(profile): | |
| return None, None, None | |
| major = _MAJOR_PROFILE / _MAJOR_PROFILE.sum() | |
| minor = _MINOR_PROFILE / _MINOR_PROFILE.sum() | |
| candidates: list[tuple[float, str, str]] = [] | |
| for i, key in enumerate(PITCH_CLASSES): | |
| maj_score = _corr(profile, np.roll(major, i)) | |
| min_score = _corr(profile, np.roll(minor, i)) | |
| candidates.append((maj_score, key, "major")) | |
| candidates.append((min_score, key, "minor")) | |
| candidates.sort(key=lambda x: x[0], reverse=True) | |
| best_score, best_key, best_scale = candidates[0] | |
| second_score = candidates[1][0] if len(candidates) > 1 else -1.0 | |
| # Confidence blends absolute fit and margin over runner-up. | |
| margin = max(0.0, best_score - second_score) | |
| absolute = max(0.0, (best_score + 1.0) / 2.0) | |
| confidence = float(np.clip(0.45 * absolute + 0.55 * margin, 0.0, 1.0)) | |
| if best_score < 0.18 or confidence < 0.22: | |
| return None, None, None | |
| return best_key, best_scale, round(confidence, 2) | |
| def _find_ac_peaks( | |
| ac: np.ndarray, lag_min: int, lag_max: int, threshold_ratio: float = 0.15 | |
| ) -> list[tuple[int, float]]: | |
| """Return (lag, value) pairs for every local maximum above *threshold_ratio* of the segment max.""" | |
| if lag_max >= ac.size: | |
| lag_max = ac.size - 1 | |
| if lag_max <= lag_min + 1: | |
| return [] | |
| seg = ac[lag_min : lag_max + 1] | |
| peak_val = float(np.max(seg)) | |
| if peak_val <= 0: | |
| return [] | |
| threshold = peak_val * threshold_ratio | |
| peaks: list[tuple[int, float]] = [] | |
| for i in range(1, seg.size - 1): | |
| if seg[i] > seg[i - 1] and seg[i] > seg[i + 1] and seg[i] > threshold: | |
| peaks.append((lag_min + i, float(seg[i]))) | |
| return peaks | |
| def _detect_tala(y: np.ndarray, sample_rate: int) -> dict[str, float | str | None]: | |
| """Detect rhythm pattern covering both Western meters and Indian talas. | |
| Returns dict with keys: bpm, bpm_confidence, tala, time_signature. | |
| """ | |
| hop_size = 512 | |
| onset = _onset_envelope(y, hop_size=hop_size) | |
| empty: dict[str, float | str | None] = { | |
| "bpm": None, "bpm_confidence": None, "tala": None, "time_signature": None, | |
| } | |
| if onset.size < 32: | |
| return empty | |
| x = onset - float(np.mean(onset)) | |
| if np.allclose(x, 0.0, atol=1e-9): | |
| return empty | |
| ac = np.correlate(x, x, mode="full")[x.size - 1 :] | |
| # ---------- Step 1: find the base pulse (akshara / beat) ---------- | |
| # Wide range to cover slow vilambit to fast drut. | |
| pulse_bpm_min, pulse_bpm_max = 50.0, 280.0 | |
| lag_min = max(1, int(round((60.0 / pulse_bpm_max) * sample_rate / hop_size))) | |
| lag_max = min(ac.size - 1, int(round((60.0 / pulse_bpm_min) * sample_rate / hop_size))) | |
| if lag_max <= lag_min: | |
| return empty | |
| peaks = _find_ac_peaks(ac, lag_min, lag_max) | |
| if not peaks: | |
| return empty | |
| strongest_score = max(p[1] for p in peaks) | |
| peaks_by_lag = sorted(peaks, key=lambda p: p[0]) | |
| # Shortest peak that is at least 25 % as strong as the best. | |
| base_lag = peaks_by_lag[0][0] | |
| for lag, score in peaks_by_lag: | |
| if score >= strongest_score * 0.25: | |
| base_lag = lag | |
| break | |
| pulse_bpm = 60.0 * sample_rate / (base_lag * hop_size) | |
| if pulse_bpm < pulse_bpm_min or pulse_bpm > pulse_bpm_max: | |
| return empty | |
| # ---------- Step 2: detect cycle length ---------- | |
| # For each candidate cycle length check if the autocorrelation near | |
| # (base_lag * cycle_len) is strong relative to the base. | |
| base_ac = float(ac[base_lag]) | |
| if base_ac <= 0: | |
| return empty | |
| best_cycle: int | None = None | |
| best_ratio = 0.0 | |
| window = max(2, base_lag // 4) | |
| for cycle_len in sorted(TALA_SIGNATURES.keys()): | |
| expected_lag = base_lag * cycle_len | |
| if expected_lag + window >= ac.size: | |
| continue | |
| lo = max(0, expected_lag - window) | |
| hi = min(ac.size, expected_lag + window + 1) | |
| local_max = float(np.max(ac[lo:hi])) | |
| # Normalize by base_ac so longer lags (which naturally decay) are | |
| # not unfairly penalised. A mild length penalty keeps shorter, | |
| # simpler cycles preferred when evidence is equal. | |
| ratio = (local_max / base_ac) * (1.0 - 0.01 * cycle_len) | |
| if ratio > best_ratio: | |
| best_ratio = ratio | |
| best_cycle = cycle_len | |
| tala: str | None = None | |
| time_sig: str | None = None | |
| if best_cycle is not None and best_ratio > 0.15: | |
| tala, time_sig = TALA_SIGNATURES.get(best_cycle, (None, None)) | |
| # ---------- Step 3: confidence ---------- | |
| seg_mean = float(np.mean(np.abs(ac[lag_min : lag_max + 1]))) | |
| confidence = float(np.clip( | |
| (base_ac - seg_mean) / (base_ac + 1e-9), 0.0, 1.0, | |
| )) | |
| return { | |
| "bpm": round(pulse_bpm, 1), | |
| "bpm_confidence": round(confidence, 2), | |
| "tala": tala, | |
| "time_signature": time_sig, | |
| } | |
| def analyze_waveform(y: np.ndarray, sample_rate: int) -> dict[str, float | str | None]: | |
| if y.size == 0: | |
| return { | |
| "bpm": None, | |
| "bpm_confidence": None, | |
| "musical_key": None, | |
| "key_scale": None, | |
| "key_confidence": None, | |
| } | |
| y = np.nan_to_num(y.astype(np.float32, copy=False)) | |
| y = _safe_normalize(y) | |
| # Avoid intro/outro bias and keep runtime bounded. | |
| max_samples = int(sample_rate * 180) | |
| if y.size > max_samples: | |
| start = (y.size - max_samples) // 2 | |
| y = y[start : start + max_samples] | |
| # Very short segments are not trustworthy. | |
| if y.size < sample_rate * 6: | |
| return { | |
| "bpm": None, | |
| "bpm_confidence": None, | |
| "musical_key": None, | |
| "key_scale": None, | |
| "key_confidence": None, | |
| } | |
| bpm, bpm_conf = _detect_bpm(y, sample_rate=sample_rate) | |
| key, scale, key_conf = _detect_key(y, sample_rate=sample_rate) | |
| tala_result = _detect_tala(y, sample_rate=sample_rate) | |
| # Use tala detector's BPM as fallback when standard detection fails. | |
| if bpm is None and tala_result.get("bpm") is not None: | |
| bpm = tala_result["bpm"] | |
| bpm_conf = tala_result["bpm_confidence"] | |
| return { | |
| "bpm": bpm, | |
| "bpm_confidence": bpm_conf, | |
| "musical_key": key, | |
| "key_scale": scale, | |
| "key_confidence": key_conf, | |
| "tala": tala_result.get("tala"), | |
| "time_signature": tala_result.get("time_signature"), | |
| } | |
| def analyze_audio_file(audio_path: Path, sample_rate: int = 22050) -> dict[str, float | str | None]: | |
| y = _decode_audio_mono(audio_path, sample_rate=sample_rate) | |
| return analyze_waveform(y, sample_rate=sample_rate) | |