Spaces:
Running
Running
| import os | |
| import tempfile | |
| import librosa | |
| import numpy as np | |
| import torch | |
| from fastapi import FastAPI, File, Header, HTTPException, UploadFile | |
| MODEL_ID = os.environ.get("MODEL_ID", "awsaf49/sonics-spectttra-alpha-120s") | |
| VOICE_MODEL_ID = os.environ.get("VOICE_MODEL_ID", "MattyB95/AST-ASVspoof5-Synthetic-Voice-Detection") | |
| API_KEY = os.environ.get("DETECT_API_KEY", "") | |
| MAX_BYTES = 25 * 1024 * 1024 | |
| MODEL_SR = 16000 | |
| torch.set_num_threads(2) | |
| app = FastAPI(title="SONICS Detect API") | |
| model = None | |
| voice_model = None | |
| voice_extractor = None | |
| def load_model(): | |
| global model, voice_model, voice_extractor | |
| from sonics import HFAudioClassifier | |
| m = HFAudioClassifier.from_pretrained(MODEL_ID) | |
| m.eval() | |
| model = m | |
| try: | |
| from transformers import AutoFeatureExtractor, AutoModelForAudioClassification | |
| voice_extractor = AutoFeatureExtractor.from_pretrained(VOICE_MODEL_ID) | |
| vm = AutoModelForAudioClassification.from_pretrained(VOICE_MODEL_ID) | |
| vm.eval() | |
| voice_model = vm | |
| except Exception: | |
| voice_model = None | |
| voice_extractor = None | |
| def health(): | |
| return { | |
| "ok": True, | |
| "model": MODEL_ID, | |
| "loaded": model is not None, | |
| "voice_model": VOICE_MODEL_ID, | |
| "voice_loaded": voice_model is not None, | |
| } | |
| # Krumhansl-Schmuckler key profiles (major/minor). | |
| KS_MAJOR = 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]) | |
| KS_MINOR = 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]) | |
| PITCHES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] | |
| def estimate_key(y: np.ndarray, sr: int) -> dict: | |
| chroma = librosa.feature.chroma_cqt(y=y, sr=sr).mean(axis=1) | |
| if chroma.sum() <= 0: | |
| return {"key": None, "mode": None, "confidence": None} | |
| scores = [] | |
| for shift in range(12): | |
| rolled = np.roll(chroma, -shift) | |
| for mode, profile in (("major", KS_MAJOR), ("minor", KS_MINOR)): | |
| r = float(np.corrcoef(rolled, profile)[0, 1]) | |
| scores.append((r, PITCHES[shift], mode)) | |
| scores.sort(reverse=True) | |
| best, second = scores[0], scores[1] | |
| confidence = max(0.0, min(1.0, (best[0] - second[0]) * 5 + 0.5)) | |
| return {"key": best[1], "mode": best[2], "confidence": round(confidence, 2)} | |
| def load_upload(audio: UploadFile, sr: int | None = None): | |
| data = audio.file.read() | |
| if not data: | |
| raise HTTPException(status_code=400, detail="Empty file") | |
| if len(data) > MAX_BYTES: | |
| raise HTTPException(status_code=413, detail="File too large (max 25MB)") | |
| suffix = os.path.splitext(audio.filename or "")[1] or ".mp3" | |
| try: | |
| with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: | |
| tmp.write(data) | |
| tmp_path = tmp.name | |
| try: | |
| y, sr_out = librosa.load(tmp_path, sr=sr, mono=True) | |
| finally: | |
| os.unlink(tmp_path) | |
| except HTTPException: | |
| raise | |
| except Exception: | |
| raise HTTPException(status_code=400, detail="Could not decode audio") | |
| return y, sr_out | |
| def analyze(audio: UploadFile = File(...), x_detect_key: str = Header(default="")): | |
| """Music utilities: BPM + musical key + basic facts. No AI model involved.""" | |
| if API_KEY and x_detect_key != API_KEY: | |
| raise HTTPException(status_code=401, detail="Invalid detect key") | |
| y, sr = load_upload(audio, sr=None) | |
| if y.size < sr * 3: | |
| raise HTTPException(status_code=400, detail="Audio too short (min 3s)") | |
| tempo_bpm = None | |
| tempo_alt = None | |
| try: | |
| tempo, beats = librosa.beat.beat_track(y=y, sr=sr, hop_length=512) | |
| t = float(np.atleast_1d(tempo)[0]) if tempo is not None else 0.0 | |
| if t > 0: | |
| tempo_bpm = round(t, 1) | |
| # Common octave error alternative (half/double time). | |
| tempo_alt = round(t * 2, 1) if t < 90 else round(t / 2, 1) | |
| except Exception: | |
| pass | |
| key = estimate_key(y, sr) | |
| return { | |
| "bpm": tempo_bpm, | |
| "bpm_alternative": tempo_alt, | |
| "key": key["key"], | |
| "mode": key["mode"], | |
| "key_confidence": key["confidence"], | |
| "duration_s": round(len(y) / sr, 1), | |
| "sample_rate": int(sr), | |
| } | |
| def detect_voice(audio: UploadFile = File(...), x_detect_key: str = Header(default="")): | |
| """AI voice / speech deepfake detection (AST fine-tuned on ASVspoof 5).""" | |
| if API_KEY and x_detect_key != API_KEY: | |
| raise HTTPException(status_code=401, detail="Invalid detect key") | |
| if voice_model is None or voice_extractor is None: | |
| raise HTTPException(status_code=503, detail="Voice model unavailable") | |
| y, sr = load_upload(audio, sr=16000) | |
| if y.size < sr * 2: | |
| raise HTTPException(status_code=400, detail="Audio too short (min 2s)") | |
| # Score up to three 10s windows (start / middle / end) and average. | |
| win = sr * 10 | |
| starts = [0] | |
| if len(y) > win * 2: | |
| starts.append((len(y) - win) // 2) | |
| if len(y) > win: | |
| starts.append(max(0, len(y) - win)) | |
| probs = [] | |
| id2label = voice_model.config.id2label | |
| for s in dict.fromkeys(starts): | |
| chunk = y[s : s + win] | |
| inputs = voice_extractor(chunk, sampling_rate=sr, return_tensors="pt") | |
| with torch.no_grad(): | |
| logits = voice_model(**inputs).logits | |
| p = torch.softmax(logits, dim=-1)[0] | |
| spoof_idx = next( | |
| (i for i, lbl in id2label.items() if "spoof" in lbl.lower() or "fake" in lbl.lower()), | |
| 1, | |
| ) | |
| probs.append(float(p[int(spoof_idx)])) | |
| ai_prob = float(np.mean(probs)) | |
| return { | |
| "ai_prob": round(ai_prob, 4), | |
| "windows": len(probs), | |
| "duration_s": round(len(y) / sr, 1), | |
| "model": VOICE_MODEL_ID, | |
| } | |
| def compute_signals(y: np.ndarray, sr: int) -> dict: | |
| """Audio-forensic descriptors computed at the file's native sample rate.""" | |
| out: dict = {"native_sample_rate": int(sr)} | |
| try: | |
| S = np.abs(librosa.stft(y, n_fft=2048, hop_length=512)) | |
| freqs = librosa.fft_frequencies(sr=sr, n_fft=2048) | |
| mag = S.mean(axis=1) | |
| power = mag**2 | |
| total = float(power.sum()) or 1e-12 | |
| # Spectral cutoff: frequency below which 99% of energy lives. | |
| cum = np.cumsum(power) | |
| idx = int(np.searchsorted(cum, 0.99 * cum[-1])) | |
| out["spectral_cutoff_hz"] = int(freqs[min(idx, len(freqs) - 1)]) | |
| # Share of energy above 10 kHz (only meaningful when sr allows it). | |
| if sr >= 32000: | |
| out["hf_energy_ratio"] = round(float(power[freqs >= 10000].sum() / total), 5) | |
| else: | |
| out["hf_energy_ratio"] = None | |
| # Dynamic range: dB spread between loud and quiet frames. | |
| rms = librosa.feature.rms(y=y, hop_length=512)[0] | |
| rms = rms[rms > 1e-6] | |
| if rms.size: | |
| out["dynamic_range_db"] = round( | |
| float(20 * np.log10(np.percentile(rms, 95) / max(np.percentile(rms, 10), 1e-9))), 1 | |
| ) | |
| else: | |
| out["dynamic_range_db"] = None | |
| # Spectral flatness: noisiness/synthetic-ness of the average spectrum. | |
| out["spectral_flatness"] = round(float(librosa.feature.spectral_flatness(y=y).mean()), 4) | |
| # Tempo and beat regularity (coefficient of variation of inter-beat intervals). | |
| try: | |
| tempo, beats = librosa.beat.beat_track(y=y, sr=sr, hop_length=512) | |
| tempo_val = float(np.atleast_1d(tempo)[0]) if tempo is not None else 0.0 | |
| out["tempo_bpm"] = round(tempo_val, 1) if tempo_val > 0 else None | |
| if beats is not None and len(beats) > 8: | |
| ibis = np.diff(librosa.frames_to_time(beats, sr=sr, hop_length=512)) | |
| out["tempo_cv"] = round(float(np.std(ibis) / max(np.mean(ibis), 1e-9)), 4) | |
| else: | |
| out["tempo_cv"] = None | |
| except Exception: | |
| out["tempo_bpm"] = None | |
| out["tempo_cv"] = None | |
| except Exception: | |
| pass | |
| return out | |
| def detect(audio: UploadFile = File(...), x_detect_key: str = Header(default="")): | |
| if API_KEY and x_detect_key != API_KEY: | |
| raise HTTPException(status_code=401, detail="Invalid detect key") | |
| if model is None: | |
| raise HTTPException(status_code=503, detail="Model still loading, retry shortly") | |
| data = audio.file.read() | |
| if not data: | |
| raise HTTPException(status_code=400, detail="Empty file") | |
| if len(data) > MAX_BYTES: | |
| raise HTTPException(status_code=413, detail="File too large (max 25MB)") | |
| suffix = os.path.splitext(audio.filename or "")[1] or ".mp3" | |
| try: | |
| with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: | |
| tmp.write(data) | |
| tmp_path = tmp.name | |
| try: | |
| # Native rate for forensic signals (high-frequency artifacts live here). | |
| y_native, sr_native = librosa.load(tmp_path, sr=None, mono=True) | |
| finally: | |
| os.unlink(tmp_path) | |
| except Exception: | |
| raise HTTPException(status_code=400, detail="Could not decode audio") | |
| if y_native.size < sr_native * 3: | |
| raise HTTPException(status_code=400, detail="Audio too short (min 3s)") | |
| signals = compute_signals(y_native, sr_native) | |
| # Model expects 16 kHz; score the middle max_time window (official demo logic). | |
| y = ( | |
| librosa.resample(y_native, orig_sr=sr_native, target_sr=MODEL_SR) | |
| if sr_native != MODEL_SR | |
| else y_native | |
| ) | |
| max_time = model.config.audio.max_time | |
| chunk_samples = int(max_time * MODEL_SR) | |
| total_chunks = len(y) // chunk_samples | |
| middle_idx = total_chunks // 2 | |
| start = middle_idx * chunk_samples | |
| chunk = y[start : start + chunk_samples] | |
| if len(chunk) < chunk_samples: | |
| chunk = np.pad(chunk, (0, chunk_samples - len(chunk))) | |
| with torch.no_grad(): | |
| t = torch.from_numpy(chunk).float().unsqueeze(0) | |
| ai_prob = float(torch.sigmoid(model(t)).cpu().numpy().reshape(-1)[0]) | |
| return { | |
| "ai_prob": round(ai_prob, 4), | |
| "duration_s": round(len(y_native) / sr_native, 1), | |
| "model": MODEL_ID, | |
| "signals": signals, | |
| } | |