Gary10's picture
SONICS detect API (FastAPI wrapper, SpecTTTra-alpha-120s)
0135310 verified
Raw
History Blame Contribute Delete
10.3 kB
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
@app.on_event("startup")
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
@app.get("/")
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
@app.post("/analyze")
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),
}
@app.post("/detect-voice")
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
@app.post("/detect")
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,
}