Spaces:
Running
Running
SONICS detect API (FastAPI wrapper, SpecTTTra-alpha-120s)
Browse files- Dockerfile +2 -1
- app.py +143 -2
Dockerfile
CHANGED
|
@@ -12,11 +12,12 @@ ENV HOME=/home/user \
|
|
| 12 |
WORKDIR /home/user/app
|
| 13 |
|
| 14 |
RUN pip install --no-cache-dir --user torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu && \
|
| 15 |
-
pip install --no-cache-dir --user librosa soundfile numpy fastapi "uvicorn[standard]" python-multipart huggingface_hub timm pandas matplotlib tqdm scikit-learn fvcore && \
|
| 16 |
pip install --no-cache-dir --user --no-deps git+https://github.com/awsaf49/sonics.git
|
| 17 |
|
| 18 |
# Bake model weights into the image so cold starts skip the download.
|
| 19 |
RUN python -c "from sonics import HFAudioClassifier; HFAudioClassifier.from_pretrained('awsaf49/sonics-spectttra-alpha-120s')"
|
|
|
|
| 20 |
|
| 21 |
COPY --chown=user app.py .
|
| 22 |
|
|
|
|
| 12 |
WORKDIR /home/user/app
|
| 13 |
|
| 14 |
RUN pip install --no-cache-dir --user torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu && \
|
| 15 |
+
pip install --no-cache-dir --user librosa soundfile numpy fastapi "uvicorn[standard]" python-multipart huggingface_hub timm pandas matplotlib tqdm scikit-learn fvcore transformers && \
|
| 16 |
pip install --no-cache-dir --user --no-deps git+https://github.com/awsaf49/sonics.git
|
| 17 |
|
| 18 |
# Bake model weights into the image so cold starts skip the download.
|
| 19 |
RUN python -c "from sonics import HFAudioClassifier; HFAudioClassifier.from_pretrained('awsaf49/sonics-spectttra-alpha-120s')"
|
| 20 |
+
RUN python -c "from transformers import AutoFeatureExtractor, AutoModelForAudioClassification; m='MattyB95/AST-ASVspoof5-Synthetic-Voice-Detection'; AutoFeatureExtractor.from_pretrained(m); AutoModelForAudioClassification.from_pretrained(m)"
|
| 21 |
|
| 22 |
COPY --chown=user app.py .
|
| 23 |
|
app.py
CHANGED
|
@@ -7,6 +7,7 @@ import torch
|
|
| 7 |
from fastapi import FastAPI, File, Header, HTTPException, UploadFile
|
| 8 |
|
| 9 |
MODEL_ID = os.environ.get("MODEL_ID", "awsaf49/sonics-spectttra-alpha-120s")
|
|
|
|
| 10 |
API_KEY = os.environ.get("DETECT_API_KEY", "")
|
| 11 |
MAX_BYTES = 25 * 1024 * 1024
|
| 12 |
MODEL_SR = 16000
|
|
@@ -15,21 +16,161 @@ torch.set_num_threads(2)
|
|
| 15 |
|
| 16 |
app = FastAPI(title="SONICS Detect API")
|
| 17 |
model = None
|
|
|
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
@app.on_event("startup")
|
| 21 |
def load_model():
|
| 22 |
-
global model
|
| 23 |
from sonics import HFAudioClassifier
|
| 24 |
|
| 25 |
m = HFAudioClassifier.from_pretrained(MODEL_ID)
|
| 26 |
m.eval()
|
| 27 |
model = m
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
@app.get("/")
|
| 31 |
def health():
|
| 32 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
def compute_signals(y: np.ndarray, sr: int) -> dict:
|
|
|
|
| 7 |
from fastapi import FastAPI, File, Header, HTTPException, UploadFile
|
| 8 |
|
| 9 |
MODEL_ID = os.environ.get("MODEL_ID", "awsaf49/sonics-spectttra-alpha-120s")
|
| 10 |
+
VOICE_MODEL_ID = os.environ.get("VOICE_MODEL_ID", "MattyB95/AST-ASVspoof5-Synthetic-Voice-Detection")
|
| 11 |
API_KEY = os.environ.get("DETECT_API_KEY", "")
|
| 12 |
MAX_BYTES = 25 * 1024 * 1024
|
| 13 |
MODEL_SR = 16000
|
|
|
|
| 16 |
|
| 17 |
app = FastAPI(title="SONICS Detect API")
|
| 18 |
model = None
|
| 19 |
+
voice_model = None
|
| 20 |
+
voice_extractor = None
|
| 21 |
|
| 22 |
|
| 23 |
@app.on_event("startup")
|
| 24 |
def load_model():
|
| 25 |
+
global model, voice_model, voice_extractor
|
| 26 |
from sonics import HFAudioClassifier
|
| 27 |
|
| 28 |
m = HFAudioClassifier.from_pretrained(MODEL_ID)
|
| 29 |
m.eval()
|
| 30 |
model = m
|
| 31 |
|
| 32 |
+
try:
|
| 33 |
+
from transformers import AutoFeatureExtractor, AutoModelForAudioClassification
|
| 34 |
+
|
| 35 |
+
voice_extractor = AutoFeatureExtractor.from_pretrained(VOICE_MODEL_ID)
|
| 36 |
+
vm = AutoModelForAudioClassification.from_pretrained(VOICE_MODEL_ID)
|
| 37 |
+
vm.eval()
|
| 38 |
+
voice_model = vm
|
| 39 |
+
except Exception:
|
| 40 |
+
voice_model = None
|
| 41 |
+
voice_extractor = None
|
| 42 |
+
|
| 43 |
|
| 44 |
@app.get("/")
|
| 45 |
def health():
|
| 46 |
+
return {
|
| 47 |
+
"ok": True,
|
| 48 |
+
"model": MODEL_ID,
|
| 49 |
+
"loaded": model is not None,
|
| 50 |
+
"voice_model": VOICE_MODEL_ID,
|
| 51 |
+
"voice_loaded": voice_model is not None,
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# Krumhansl-Schmuckler key profiles (major/minor).
|
| 56 |
+
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])
|
| 57 |
+
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])
|
| 58 |
+
PITCHES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def estimate_key(y: np.ndarray, sr: int) -> dict:
|
| 62 |
+
chroma = librosa.feature.chroma_cqt(y=y, sr=sr).mean(axis=1)
|
| 63 |
+
if chroma.sum() <= 0:
|
| 64 |
+
return {"key": None, "mode": None, "confidence": None}
|
| 65 |
+
scores = []
|
| 66 |
+
for shift in range(12):
|
| 67 |
+
rolled = np.roll(chroma, -shift)
|
| 68 |
+
for mode, profile in (("major", KS_MAJOR), ("minor", KS_MINOR)):
|
| 69 |
+
r = float(np.corrcoef(rolled, profile)[0, 1])
|
| 70 |
+
scores.append((r, PITCHES[shift], mode))
|
| 71 |
+
scores.sort(reverse=True)
|
| 72 |
+
best, second = scores[0], scores[1]
|
| 73 |
+
confidence = max(0.0, min(1.0, (best[0] - second[0]) * 5 + 0.5))
|
| 74 |
+
return {"key": best[1], "mode": best[2], "confidence": round(confidence, 2)}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def load_upload(audio: UploadFile, sr: int | None = None):
|
| 78 |
+
data = audio.file.read()
|
| 79 |
+
if not data:
|
| 80 |
+
raise HTTPException(status_code=400, detail="Empty file")
|
| 81 |
+
if len(data) > MAX_BYTES:
|
| 82 |
+
raise HTTPException(status_code=413, detail="File too large (max 25MB)")
|
| 83 |
+
suffix = os.path.splitext(audio.filename or "")[1] or ".mp3"
|
| 84 |
+
try:
|
| 85 |
+
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
| 86 |
+
tmp.write(data)
|
| 87 |
+
tmp_path = tmp.name
|
| 88 |
+
try:
|
| 89 |
+
y, sr_out = librosa.load(tmp_path, sr=sr, mono=True)
|
| 90 |
+
finally:
|
| 91 |
+
os.unlink(tmp_path)
|
| 92 |
+
except HTTPException:
|
| 93 |
+
raise
|
| 94 |
+
except Exception:
|
| 95 |
+
raise HTTPException(status_code=400, detail="Could not decode audio")
|
| 96 |
+
return y, sr_out
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@app.post("/analyze")
|
| 100 |
+
def analyze(audio: UploadFile = File(...), x_detect_key: str = Header(default="")):
|
| 101 |
+
"""Music utilities: BPM + musical key + basic facts. No AI model involved."""
|
| 102 |
+
if API_KEY and x_detect_key != API_KEY:
|
| 103 |
+
raise HTTPException(status_code=401, detail="Invalid detect key")
|
| 104 |
+
|
| 105 |
+
y, sr = load_upload(audio, sr=None)
|
| 106 |
+
if y.size < sr * 3:
|
| 107 |
+
raise HTTPException(status_code=400, detail="Audio too short (min 3s)")
|
| 108 |
+
|
| 109 |
+
tempo_bpm = None
|
| 110 |
+
tempo_alt = None
|
| 111 |
+
try:
|
| 112 |
+
tempo, beats = librosa.beat.beat_track(y=y, sr=sr, hop_length=512)
|
| 113 |
+
t = float(np.atleast_1d(tempo)[0]) if tempo is not None else 0.0
|
| 114 |
+
if t > 0:
|
| 115 |
+
tempo_bpm = round(t, 1)
|
| 116 |
+
# Common octave error alternative (half/double time).
|
| 117 |
+
tempo_alt = round(t * 2, 1) if t < 90 else round(t / 2, 1)
|
| 118 |
+
except Exception:
|
| 119 |
+
pass
|
| 120 |
+
|
| 121 |
+
key = estimate_key(y, sr)
|
| 122 |
+
|
| 123 |
+
return {
|
| 124 |
+
"bpm": tempo_bpm,
|
| 125 |
+
"bpm_alternative": tempo_alt,
|
| 126 |
+
"key": key["key"],
|
| 127 |
+
"mode": key["mode"],
|
| 128 |
+
"key_confidence": key["confidence"],
|
| 129 |
+
"duration_s": round(len(y) / sr, 1),
|
| 130 |
+
"sample_rate": int(sr),
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@app.post("/detect-voice")
|
| 135 |
+
def detect_voice(audio: UploadFile = File(...), x_detect_key: str = Header(default="")):
|
| 136 |
+
"""AI voice / speech deepfake detection (AST fine-tuned on ASVspoof 5)."""
|
| 137 |
+
if API_KEY and x_detect_key != API_KEY:
|
| 138 |
+
raise HTTPException(status_code=401, detail="Invalid detect key")
|
| 139 |
+
if voice_model is None or voice_extractor is None:
|
| 140 |
+
raise HTTPException(status_code=503, detail="Voice model unavailable")
|
| 141 |
+
|
| 142 |
+
y, sr = load_upload(audio, sr=16000)
|
| 143 |
+
if y.size < sr * 2:
|
| 144 |
+
raise HTTPException(status_code=400, detail="Audio too short (min 2s)")
|
| 145 |
+
|
| 146 |
+
# Score up to three 10s windows (start / middle / end) and average.
|
| 147 |
+
win = sr * 10
|
| 148 |
+
starts = [0]
|
| 149 |
+
if len(y) > win * 2:
|
| 150 |
+
starts.append((len(y) - win) // 2)
|
| 151 |
+
if len(y) > win:
|
| 152 |
+
starts.append(max(0, len(y) - win))
|
| 153 |
+
probs = []
|
| 154 |
+
id2label = voice_model.config.id2label
|
| 155 |
+
for s in dict.fromkeys(starts):
|
| 156 |
+
chunk = y[s : s + win]
|
| 157 |
+
inputs = voice_extractor(chunk, sampling_rate=sr, return_tensors="pt")
|
| 158 |
+
with torch.no_grad():
|
| 159 |
+
logits = voice_model(**inputs).logits
|
| 160 |
+
p = torch.softmax(logits, dim=-1)[0]
|
| 161 |
+
spoof_idx = next(
|
| 162 |
+
(i for i, lbl in id2label.items() if "spoof" in lbl.lower() or "fake" in lbl.lower()),
|
| 163 |
+
1,
|
| 164 |
+
)
|
| 165 |
+
probs.append(float(p[int(spoof_idx)]))
|
| 166 |
+
|
| 167 |
+
ai_prob = float(np.mean(probs))
|
| 168 |
+
return {
|
| 169 |
+
"ai_prob": round(ai_prob, 4),
|
| 170 |
+
"windows": len(probs),
|
| 171 |
+
"duration_s": round(len(y) / sr, 1),
|
| 172 |
+
"model": VOICE_MODEL_ID,
|
| 173 |
+
}
|
| 174 |
|
| 175 |
|
| 176 |
def compute_signals(y: np.ndarray, sr: int) -> dict:
|