Gary10 commited on
Commit
0135310
·
verified ·
1 Parent(s): 4376b0e

SONICS detect API (FastAPI wrapper, SpecTTTra-alpha-120s)

Browse files
Files changed (2) hide show
  1. Dockerfile +2 -1
  2. 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 {"ok": True, "model": MODEL_ID, "loaded": model is not None}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: