Rthur2003 commited on
Commit
29d1ce6
·
1 Parent(s): 7b35ef2

feat: add audio feature extraction module for AI music detection

Browse files
Files changed (1) hide show
  1. app/services/feature_extractor.py +426 -0
app/services/feature_extractor.py ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Real audio feature extraction for AI music detection.
3
+
4
+ Extracts spectral, temporal, and harmonic features from audio
5
+ using librosa. These features are used to distinguish AI-generated
6
+ music from human-composed music.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Optional, Union
15
+
16
+ import numpy as np
17
+ import librosa
18
+
19
+ from .logging_config import get_logger
20
+
21
+ logger = get_logger(__name__)
22
+
23
+ # ── Constants ────────────────────────────────────────────────────────────
24
+ _TARGET_SR = 22050 # Standard sample rate for analysis
25
+ _DURATION_LIMIT = 120.0 # Analyze max 2 minutes
26
+ _N_MFCC = 13
27
+ _N_MELS = 128
28
+ _HOP_LENGTH = 512
29
+ _N_FFT = 2048
30
+
31
+
32
+ @dataclass
33
+ class AudioFeatures:
34
+ """Extracted audio features with normalized scores (0.0 – 1.0)."""
35
+
36
+ spectral_regularity: float
37
+ temporal_patterns: float
38
+ harmonic_structure: float
39
+
40
+ # Raw metrics for downstream consumers
41
+ duration_sec: float
42
+ sample_rate: int
43
+ rms_energy: float
44
+ tempo_bpm: float
45
+ tempo_stability: float # std of inter-beat intervals
46
+ spectral_centroid_mean: float
47
+ spectral_centroid_std: float
48
+ spectral_flatness_mean: float
49
+ mfcc_variance: float # mean variance across MFCC bands
50
+ chroma_entropy: float # entropy of chroma distribution
51
+ harmonic_ratio: float # harmonic / (harmonic + percussive)
52
+ zero_crossing_rate: float
53
+
54
+
55
+ @dataclass
56
+ class AudioMeta:
57
+ """Basic metadata about the audio file."""
58
+
59
+ duration_sec: float
60
+ sample_rate: int
61
+ format: str
62
+ channels: int
63
+
64
+
65
+ def extract_features(
66
+ source: Union[Path, bytes, io.BytesIO],
67
+ *,
68
+ sr: Optional[int] = None,
69
+ ) -> AudioFeatures:
70
+ """
71
+ Extract all analysis features from an audio source.
72
+
73
+ Args:
74
+ source: File path, raw bytes, or BytesIO of the audio.
75
+ sr: Force a specific sample rate (default: _TARGET_SR).
76
+
77
+ Returns:
78
+ AudioFeatures with normalized scores and raw metrics.
79
+ """
80
+ target_sr = sr or _TARGET_SR
81
+
82
+ y, actual_sr = _load_audio(source, target_sr)
83
+ duration_sec = float(len(y) / actual_sr)
84
+
85
+ logger.info(
86
+ f"Feature extraction: {duration_sec:.1f}s audio @ {actual_sr}Hz "
87
+ f"({len(y)} samples)"
88
+ )
89
+
90
+ # ── Core feature groups ──────────────────────────────────────────
91
+ spectral = _extract_spectral(y, actual_sr)
92
+ temporal = _extract_temporal(y, actual_sr)
93
+ harmonic = _extract_harmonic(y, actual_sr)
94
+
95
+ # ── Composite scores (0.0 = very human, 1.0 = very AI-like) ─────
96
+ spectral_score = _score_spectral_regularity(spectral)
97
+ temporal_score = _score_temporal_patterns(temporal)
98
+ harmonic_score = _score_harmonic_structure(harmonic)
99
+
100
+ return AudioFeatures(
101
+ spectral_regularity=spectral_score,
102
+ temporal_patterns=temporal_score,
103
+ harmonic_structure=harmonic_score,
104
+ duration_sec=duration_sec,
105
+ sample_rate=actual_sr,
106
+ rms_energy=spectral["rms_mean"],
107
+ tempo_bpm=temporal["tempo_bpm"],
108
+ tempo_stability=temporal["tempo_stability"],
109
+ spectral_centroid_mean=spectral["centroid_mean"],
110
+ spectral_centroid_std=spectral["centroid_std"],
111
+ spectral_flatness_mean=spectral["flatness_mean"],
112
+ mfcc_variance=spectral["mfcc_variance"],
113
+ chroma_entropy=harmonic["chroma_entropy"],
114
+ harmonic_ratio=harmonic["harmonic_ratio"],
115
+ zero_crossing_rate=temporal["zcr_mean"],
116
+ )
117
+
118
+
119
+ def extract_meta(source: Union[Path, bytes, io.BytesIO]) -> AudioMeta:
120
+ """Quick metadata extraction without full feature analysis."""
121
+ y, sr = _load_audio(source, _TARGET_SR)
122
+ fmt = "wav"
123
+ channels = 1
124
+ if isinstance(source, Path):
125
+ fmt = source.suffix.lstrip(".") or "wav"
126
+ # librosa always returns mono; detect original channels via soundfile
127
+ try:
128
+ import soundfile as sf
129
+ info = sf.info(str(source))
130
+ channels = info.channels
131
+ except Exception:
132
+ pass
133
+
134
+ return AudioMeta(
135
+ duration_sec=float(len(y) / sr),
136
+ sample_rate=sr,
137
+ format=fmt,
138
+ channels=channels,
139
+ )
140
+
141
+
142
+ # ═══════════════════════════════════════════════════════════════════════
143
+ # PRIVATE — Audio loading
144
+ # ═══════════════════════════════════════════════════════════════════════
145
+
146
+ def _load_audio(
147
+ source: Union[Path, bytes, io.BytesIO],
148
+ target_sr: int,
149
+ ) -> tuple[np.ndarray, int]:
150
+ """Load audio from any source, mono, resampled, duration-limited."""
151
+ if isinstance(source, bytes):
152
+ source = io.BytesIO(source)
153
+
154
+ y, sr = librosa.load(
155
+ source if isinstance(source, (str, Path, io.BytesIO)) else str(source),
156
+ sr=target_sr,
157
+ mono=True,
158
+ duration=_DURATION_LIMIT,
159
+ )
160
+
161
+ # Guard against silent / corrupt files
162
+ if len(y) < sr:
163
+ raise ValueError("Audio too short for analysis (< 1 second)")
164
+
165
+ if np.max(np.abs(y)) < 1e-6:
166
+ raise ValueError("Audio is silent")
167
+
168
+ return y, sr
169
+
170
+
171
+ # ═══════════════════════════════════════════════════════════════════════
172
+ # PRIVATE — Spectral features
173
+ # ═══════════════════════════════════════════════════════════════════════
174
+
175
+ def _extract_spectral(y: np.ndarray, sr: int) -> dict:
176
+ """Spectral domain features."""
177
+ # Spectral centroid — "brightness"
178
+ centroid = librosa.feature.spectral_centroid(
179
+ y=y, sr=sr, hop_length=_HOP_LENGTH
180
+ )[0]
181
+
182
+ # Spectral flatness — how noise-like vs tonal
183
+ flatness = librosa.feature.spectral_flatness(
184
+ y=y, hop_length=_HOP_LENGTH
185
+ )[0]
186
+
187
+ # Spectral bandwidth
188
+ bandwidth = librosa.feature.spectral_bandwidth(
189
+ y=y, sr=sr, hop_length=_HOP_LENGTH
190
+ )[0]
191
+
192
+ # Spectral contrast — valley-to-peak in frequency bands
193
+ contrast = librosa.feature.spectral_contrast(
194
+ y=y, sr=sr, hop_length=_HOP_LENGTH, n_bands=6
195
+ )
196
+
197
+ # MFCCs — timbre fingerprint
198
+ mfcc = librosa.feature.mfcc(
199
+ y=y, sr=sr, n_mfcc=_N_MFCC, hop_length=_HOP_LENGTH
200
+ )
201
+
202
+ # RMS energy
203
+ rms = librosa.feature.rms(y=y, hop_length=_HOP_LENGTH)[0]
204
+
205
+ # Mel spectrogram statistics
206
+ mel = librosa.feature.melspectrogram(
207
+ y=y, sr=sr, n_mels=_N_MELS, hop_length=_HOP_LENGTH
208
+ )
209
+ mel_db = librosa.power_to_db(mel, ref=np.max)
210
+
211
+ return {
212
+ "centroid_mean": float(np.mean(centroid)),
213
+ "centroid_std": float(np.std(centroid)),
214
+ "flatness_mean": float(np.mean(flatness)),
215
+ "flatness_std": float(np.std(flatness)),
216
+ "bandwidth_mean": float(np.mean(bandwidth)),
217
+ "bandwidth_std": float(np.std(bandwidth)),
218
+ "contrast_mean": float(np.mean(contrast)),
219
+ "contrast_std": float(np.std(contrast)),
220
+ "mfcc_variance": float(np.mean(np.var(mfcc, axis=1))),
221
+ "mfcc_delta_var": float(np.mean(np.var(
222
+ librosa.feature.delta(mfcc), axis=1
223
+ ))),
224
+ "rms_mean": float(np.mean(rms)),
225
+ "rms_std": float(np.std(rms)),
226
+ "mel_flatness": float(np.mean(np.std(mel_db, axis=0))),
227
+ }
228
+
229
+
230
+ # ═══════════════════════════════════════════════════════════════════════
231
+ # PRIVATE — Temporal features
232
+ # ═══════════════════════════════════════════════════════════════════════
233
+
234
+ def _extract_temporal(y: np.ndarray, sr: int) -> dict:
235
+ """Time-domain and rhythm features."""
236
+ # Tempo and beat tracking
237
+ tempo, beat_frames = librosa.beat.beat_track(
238
+ y=y, sr=sr, hop_length=_HOP_LENGTH
239
+ )
240
+ tempo_bpm = float(np.atleast_1d(tempo)[0])
241
+
242
+ # Beat timing stability
243
+ beat_times = librosa.frames_to_time(beat_frames, sr=sr, hop_length=_HOP_LENGTH)
244
+ if len(beat_times) > 2:
245
+ ibi = np.diff(beat_times) # inter-beat intervals
246
+ tempo_stability = float(np.std(ibi))
247
+ tempo_cv = float(np.std(ibi) / np.mean(ibi)) if np.mean(ibi) > 0 else 0.0
248
+ else:
249
+ tempo_stability = 0.0
250
+ tempo_cv = 0.0
251
+
252
+ # Onset strength — rhythmic energy
253
+ onset_env = librosa.onset.onset_strength(
254
+ y=y, sr=sr, hop_length=_HOP_LENGTH
255
+ )
256
+ onset_std = float(np.std(onset_env))
257
+ onset_mean = float(np.mean(onset_env))
258
+
259
+ # Zero-crossing rate — rough texture indicator
260
+ zcr = librosa.feature.zero_crossing_rate(y, hop_length=_HOP_LENGTH)[0]
261
+
262
+ # RMS energy dynamics — how much volume varies
263
+ rms = librosa.feature.rms(y=y, hop_length=_HOP_LENGTH)[0]
264
+ rms_dynamic_range = float(np.max(rms) - np.min(rms)) if len(rms) > 0 else 0.0
265
+
266
+ return {
267
+ "tempo_bpm": tempo_bpm,
268
+ "tempo_stability": tempo_stability,
269
+ "tempo_cv": tempo_cv,
270
+ "onset_std": onset_std,
271
+ "onset_mean": onset_mean,
272
+ "zcr_mean": float(np.mean(zcr)),
273
+ "zcr_std": float(np.std(zcr)),
274
+ "rms_dynamic_range": rms_dynamic_range,
275
+ "beat_count": len(beat_frames),
276
+ }
277
+
278
+
279
+ # ════════════════════════════════��══════════════════════════════════════
280
+ # PRIVATE — Harmonic features
281
+ # ═══════════════════════════════════════════════════════════════════════
282
+
283
+ def _extract_harmonic(y: np.ndarray, sr: int) -> dict:
284
+ """Harmonic and tonal features."""
285
+ # Harmonic-percussive separation
286
+ y_harmonic, y_percussive = librosa.effects.hpss(y)
287
+ harmonic_energy = float(np.sum(y_harmonic ** 2))
288
+ total_energy = float(np.sum(y ** 2))
289
+ harmonic_ratio = harmonic_energy / total_energy if total_energy > 0 else 0.5
290
+
291
+ # Chroma features — pitch class distribution
292
+ chroma = librosa.feature.chroma_stft(
293
+ y=y, sr=sr, hop_length=_HOP_LENGTH, n_chroma=12
294
+ )
295
+
296
+ # Chroma entropy — how spread the pitch classes are
297
+ chroma_mean = np.mean(chroma, axis=1)
298
+ chroma_mean = chroma_mean / (np.sum(chroma_mean) + 1e-10)
299
+ chroma_entropy = float(-np.sum(chroma_mean * np.log2(chroma_mean + 1e-10)))
300
+
301
+ # Chroma standard deviation — how stable pitch classes are over time
302
+ chroma_std = float(np.mean(np.std(chroma, axis=1)))
303
+
304
+ # Tonnetz — tonal centroid features (harmonic relationships)
305
+ tonnetz = librosa.feature.tonnetz(y=y_harmonic, sr=sr)
306
+ tonnetz_std = float(np.mean(np.std(tonnetz, axis=1)))
307
+
308
+ # Chroma transition matrix — how often pitch classes change
309
+ chroma_binary = (chroma > np.median(chroma)).astype(float)
310
+ chroma_diff = np.diff(chroma_binary, axis=1)
311
+ chroma_transition_rate = float(np.mean(np.abs(chroma_diff)))
312
+
313
+ return {
314
+ "harmonic_ratio": harmonic_ratio,
315
+ "chroma_entropy": chroma_entropy,
316
+ "chroma_std": chroma_std,
317
+ "tonnetz_std": tonnetz_std,
318
+ "chroma_transition_rate": chroma_transition_rate,
319
+ }
320
+
321
+
322
+ # ═══════════════════════════════════════════════════════════════════════
323
+ # PRIVATE — Composite scoring
324
+ # ═══════════════════════════════════════════════════════════════════════
325
+
326
+ def _sigmoid(x: float, midpoint: float = 0.0, steepness: float = 1.0) -> float:
327
+ """Sigmoid normalization to [0, 1]."""
328
+ z = steepness * (x - midpoint)
329
+ z = max(-20.0, min(20.0, z)) # clamp for numerical stability
330
+ return 1.0 / (1.0 + np.exp(-z))
331
+
332
+
333
+ def _score_spectral_regularity(spectral: dict) -> float:
334
+ """
335
+ Score how "regular" (AI-like) the spectral content is.
336
+
337
+ AI music tends to have:
338
+ - Lower spectral centroid variance (more uniform brightness)
339
+ - Lower MFCC variance (more consistent timbre)
340
+ - Higher spectral flatness (more even frequency distribution)
341
+ - Lower mel spectrogram variance over time
342
+ """
343
+ # Low centroid std → high regularity → more AI-like
344
+ centroid_score = 1.0 - _sigmoid(spectral["centroid_std"], midpoint=800, steepness=0.003)
345
+
346
+ # Low MFCC variance → consistent timbre → more AI-like
347
+ mfcc_score = 1.0 - _sigmoid(spectral["mfcc_variance"], midpoint=50, steepness=0.03)
348
+
349
+ # High flatness → noise-like distribution → more AI-like
350
+ flatness_score = _sigmoid(spectral["flatness_mean"], midpoint=0.02, steepness=40)
351
+
352
+ # Low mel variance → uniform spectral energy → more AI-like
353
+ mel_score = 1.0 - _sigmoid(spectral["mel_flatness"], midpoint=10, steepness=0.1)
354
+
355
+ score = (
356
+ centroid_score * 0.3
357
+ + mfcc_score * 0.3
358
+ + flatness_score * 0.2
359
+ + mel_score * 0.2
360
+ )
361
+ return round(max(0.0, min(0.99, score)), 3)
362
+
363
+
364
+ def _score_temporal_patterns(temporal: dict) -> float:
365
+ """
366
+ Score how "metronomic" (AI-like) the temporal patterns are.
367
+
368
+ AI music tends to have:
369
+ - Very low tempo variability (coefficient of variation)
370
+ - Consistent onset strength (less dynamic)
371
+ - Lower RMS dynamic range
372
+ """
373
+ # Low tempo CV → metronomic → more AI-like
374
+ # Human musicians: CV ~0.05-0.15, AI: CV ~0.01-0.04
375
+ tempo_score = 1.0 - _sigmoid(temporal["tempo_cv"], midpoint=0.06, steepness=30)
376
+
377
+ # Low onset std → flat dynamics → more AI-like
378
+ onset_score = 1.0 - _sigmoid(temporal["onset_std"], midpoint=1.5, steepness=1.0)
379
+
380
+ # Low dynamic range → compressed → more AI-like
381
+ dynamic_score = 1.0 - _sigmoid(temporal["rms_dynamic_range"], midpoint=0.15, steepness=8)
382
+
383
+ # Low ZCR variance → uniform texture → more AI-like
384
+ zcr_score = 1.0 - _sigmoid(temporal["zcr_std"], midpoint=0.03, steepness=30)
385
+
386
+ score = (
387
+ tempo_score * 0.35
388
+ + onset_score * 0.25
389
+ + dynamic_score * 0.2
390
+ + zcr_score * 0.2
391
+ )
392
+ return round(max(0.0, min(0.99, score)), 3)
393
+
394
+
395
+ def _score_harmonic_structure(harmonic: dict) -> float:
396
+ """
397
+ Score how "predictable" (AI-like) the harmonic content is.
398
+
399
+ AI music tends to have:
400
+ - Lower chroma entropy (fewer distinct pitch classes used)
401
+ - Lower chroma transition rate (less harmonic movement)
402
+ - Lower tonnetz variability (simpler tonal relationships)
403
+ - Higher harmonic ratio (cleaner separation)
404
+ """
405
+ # Low chroma entropy → fewer pitch classes → more AI-like
406
+ # Max entropy for 12 pitch classes = log2(12) ≈ 3.58
407
+ entropy_score = 1.0 - _sigmoid(harmonic["chroma_entropy"], midpoint=3.2, steepness=3)
408
+
409
+ # Low transition rate → less harmonic movement → more AI-like
410
+ transition_score = 1.0 - _sigmoid(
411
+ harmonic["chroma_transition_rate"], midpoint=0.15, steepness=8
412
+ )
413
+
414
+ # Low tonnetz std → simpler relationships → more AI-like
415
+ tonnetz_score = 1.0 - _sigmoid(harmonic["tonnetz_std"], midpoint=0.15, steepness=8)
416
+
417
+ # High harmonic ratio → too clean → more AI-like
418
+ hr_score = _sigmoid(harmonic["harmonic_ratio"], midpoint=0.6, steepness=5)
419
+
420
+ score = (
421
+ entropy_score * 0.3
422
+ + transition_score * 0.25
423
+ + tonnetz_score * 0.25
424
+ + hr_score * 0.2
425
+ )
426
+ return round(max(0.0, min(0.99, score)), 3)