Rthur2003 commited on
Commit
0e90a82
·
1 Parent(s): 9b8e571

feat: add FST client for AI music detection using HuggingFace API

Browse files
Files changed (1) hide show
  1. app/services/fst_client.py +266 -0
app/services/fst_client.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FST (Fusion Segment Transformer) external API client (Layer 3).
3
+
4
+ Calls the HuggingFace Space ``mippia/AI-Music-Detection-FST``
5
+ Gradio API for high-accuracy AI music detection.
6
+
7
+ FST uses MERT + beat-aware segmentation and reports 99.99%
8
+ accuracy on benchmark datasets. We treat it as a strong
9
+ external signal in the score-fusion pipeline.
10
+
11
+ Gracefully returns unavailable result on timeout or error.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import io
17
+ import tempfile
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Optional, Union
21
+
22
+ from .logging_config import get_logger
23
+
24
+ logger = get_logger(__name__)
25
+
26
+ # HuggingFace Space endpoint
27
+ FST_SPACE_ID = "mippia/AI-Music-Detection-FST"
28
+ FST_API_URL = f"https://{FST_SPACE_ID.replace('/', '-')}.hf.space"
29
+
30
+ # Timeouts
31
+ FST_CONNECT_TIMEOUT = 10.0 # seconds
32
+ FST_PREDICT_TIMEOUT = 120.0 # seconds (model inference can be slow)
33
+
34
+
35
+ @dataclass
36
+ class FSTResult:
37
+ """Result from FST external service."""
38
+
39
+ available: bool
40
+ is_ai: bool = False
41
+ confidence: float = 0.5
42
+ label: str = "unknown"
43
+ raw_scores: Optional[dict] = None
44
+ error: Optional[str] = None
45
+
46
+
47
+ class FSTClientService:
48
+ """
49
+ Client for FST AI Music Detection HuggingFace Space.
50
+
51
+ Uses the Gradio Client API to submit audio and receive
52
+ predictions. Falls back gracefully if the space is
53
+ sleeping, overloaded, or unreachable.
54
+ """
55
+
56
+ def __init__(self) -> None:
57
+ self._client = None
58
+ self._available: Optional[bool] = None
59
+
60
+ def _ensure_client(self) -> bool:
61
+ """Lazy-initialize Gradio client."""
62
+ if self._available is not None:
63
+ return self._available
64
+
65
+ try:
66
+ from gradio_client import Client
67
+ self._client = Client(
68
+ FST_SPACE_ID,
69
+ hf_token=None, # Public space
70
+ )
71
+ self._available = True
72
+ logger.info(f"FST client connected: {FST_SPACE_ID}")
73
+ return True
74
+ except ImportError:
75
+ logger.warning(
76
+ "gradio_client not installed — FST layer disabled"
77
+ )
78
+ self._available = False
79
+ return False
80
+ except Exception as e:
81
+ logger.warning(f"FST client init failed: {e}")
82
+ self._available = False
83
+ return False
84
+
85
+ async def predict(
86
+ self,
87
+ source: Union[Path, bytes, io.BytesIO],
88
+ ) -> FSTResult:
89
+ """
90
+ Submit audio to FST Space for AI detection.
91
+
92
+ Args:
93
+ source: Audio file path, raw bytes, or BytesIO.
94
+
95
+ Returns:
96
+ FSTResult with detection outcome.
97
+ """
98
+ if not self._ensure_client():
99
+ return FSTResult(
100
+ available=False,
101
+ error="fst_client_unavailable",
102
+ )
103
+
104
+ try:
105
+ audio_path = self._to_file_path(source)
106
+ return await self._call_api(audio_path)
107
+ except Exception as e:
108
+ logger.warning(f"FST prediction failed: {e}")
109
+ return FSTResult(
110
+ available=False,
111
+ error=str(e),
112
+ )
113
+
114
+ async def _call_api(self, audio_path: Path) -> FSTResult:
115
+ """Call the FST Gradio API."""
116
+ import asyncio
117
+
118
+ try:
119
+ # Run synchronous Gradio client in executor
120
+ loop = asyncio.get_event_loop()
121
+ result = await asyncio.wait_for(
122
+ loop.run_in_executor(
123
+ None,
124
+ self._sync_predict,
125
+ audio_path,
126
+ ),
127
+ timeout=FST_PREDICT_TIMEOUT,
128
+ )
129
+ return result
130
+
131
+ except asyncio.TimeoutError:
132
+ logger.warning(
133
+ f"FST prediction timed out after "
134
+ f"{FST_PREDICT_TIMEOUT}s"
135
+ )
136
+ return FSTResult(
137
+ available=False,
138
+ error="fst_timeout",
139
+ )
140
+
141
+ def _sync_predict(self, audio_path: Path) -> FSTResult:
142
+ """Synchronous Gradio predict call."""
143
+ try:
144
+ result = self._client.predict(
145
+ str(audio_path),
146
+ api_name="/predict",
147
+ )
148
+
149
+ # Parse Gradio response
150
+ # FST typically returns label + confidence dict
151
+ return self._parse_response(result)
152
+
153
+ except Exception as e:
154
+ logger.warning(f"FST sync predict error: {e}")
155
+ return FSTResult(
156
+ available=False,
157
+ error=str(e),
158
+ )
159
+
160
+ def _parse_response(self, response) -> FSTResult:
161
+ """
162
+ Parse FST Gradio API response.
163
+
164
+ FST response format varies — handle multiple formats:
165
+ 1. Dict with 'label' and 'confidences'
166
+ 2. String label with confidence
167
+ 3. Raw dict with scores
168
+ """
169
+ try:
170
+ if isinstance(response, dict):
171
+ return self._parse_dict_response(response)
172
+ elif isinstance(response, str):
173
+ return self._parse_string_response(response)
174
+ elif isinstance(response, (list, tuple)):
175
+ # First element is usually the classification
176
+ if len(response) > 0:
177
+ return self._parse_response(response[0])
178
+ else:
179
+ logger.warning(
180
+ f"Unexpected FST response type: "
181
+ f"{type(response)}"
182
+ )
183
+ return FSTResult(
184
+ available=True,
185
+ label="parse_error",
186
+ error=f"unexpected_type: {type(response).__name__}",
187
+ )
188
+ except Exception as e:
189
+ logger.warning(f"FST response parse error: {e}")
190
+ return FSTResult(
191
+ available=False,
192
+ error=f"parse_error: {e}",
193
+ )
194
+
195
+ def _parse_dict_response(self, data: dict) -> FSTResult:
196
+ """Parse dict-style response."""
197
+ # Format: {"label": "AI", "confidences": [{"label": "AI", "confidence": 0.99}, ...]}
198
+ label = data.get("label", "unknown")
199
+ confidences = data.get("confidences", [])
200
+
201
+ is_ai = "ai" in label.lower() or "fake" in label.lower()
202
+
203
+ confidence = 0.5
204
+ if confidences and isinstance(confidences, list):
205
+ for item in confidences:
206
+ if isinstance(item, dict):
207
+ item_label = item.get("label", "")
208
+ if "ai" in item_label.lower() or "fake" in item_label.lower():
209
+ confidence = float(
210
+ item.get("confidence", 0.5)
211
+ )
212
+ break
213
+
214
+ # If no AI confidence found, use first confidence
215
+ if confidence == 0.5 and confidences:
216
+ first = confidences[0]
217
+ if isinstance(first, dict):
218
+ confidence = float(
219
+ first.get("confidence", 0.5)
220
+ )
221
+ if not is_ai:
222
+ confidence = 1.0 - confidence
223
+
224
+ return FSTResult(
225
+ available=True,
226
+ is_ai=is_ai,
227
+ confidence=round(
228
+ max(0.01, min(0.99, confidence)), 4
229
+ ),
230
+ label=label,
231
+ raw_scores=data,
232
+ )
233
+
234
+ def _parse_string_response(self, text: str) -> FSTResult:
235
+ """Parse string-style response."""
236
+ lower = text.lower().strip()
237
+ is_ai = any(
238
+ kw in lower
239
+ for kw in ("ai", "fake", "generated", "synthetic")
240
+ )
241
+ # Conservative confidence for string-only responses
242
+ confidence = 0.75 if is_ai else 0.25
243
+
244
+ return FSTResult(
245
+ available=True,
246
+ is_ai=is_ai,
247
+ confidence=confidence,
248
+ label=text.strip(),
249
+ )
250
+
251
+ @staticmethod
252
+ def _to_file_path(
253
+ source: Union[Path, bytes, io.BytesIO],
254
+ ) -> Path:
255
+ """Convert source to a file path for Gradio upload."""
256
+ if isinstance(source, Path):
257
+ return source
258
+ if isinstance(source, bytes):
259
+ source = io.BytesIO(source)
260
+ tmp = tempfile.NamedTemporaryFile(
261
+ suffix=".wav", delete=False,
262
+ )
263
+ tmp.write(source.read())
264
+ tmp.flush()
265
+ tmp.close()
266
+ return Path(tmp.name)