BitCheck Dev Claude Opus 4.7 commited on
Commit
67ebc6b
·
1 Parent(s): f445cbc

feat: add exact 26-feature audio extraction pipeline

Browse files

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

bitcheck-audio-service/app/services/audio_feature_extractor.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from dataclasses import dataclass, field
3
+ from typing import Optional
4
+
5
+ import numpy as np
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ FEATURE_NAMES = [
10
+ "chroma_stft",
11
+ "rms",
12
+ "spectral_centroid",
13
+ "spectral_bandwidth",
14
+ "rolloff",
15
+ "zero_crossing_rate",
16
+ "mfcc1",
17
+ "mfcc2",
18
+ "mfcc3",
19
+ "mfcc4",
20
+ "mfcc5",
21
+ "mfcc6",
22
+ "mfcc7",
23
+ "mfcc8",
24
+ "mfcc9",
25
+ "mfcc10",
26
+ "mfcc11",
27
+ "mfcc12",
28
+ "mfcc13",
29
+ "mfcc14",
30
+ "mfcc15",
31
+ "mfcc16",
32
+ "mfcc17",
33
+ "mfcc18",
34
+ "mfcc19",
35
+ "mfcc20",
36
+ ]
37
+
38
+
39
+ @dataclass
40
+ class AudioFeatureResult:
41
+ checked: bool = True
42
+ features_found: bool = False
43
+ feature_count: int = 0
44
+ feature_names: list = field(default_factory=lambda: FEATURE_NAMES)
45
+ features: Optional[dict] = None
46
+ warnings: list = field(default_factory=list)
47
+ error: str = ""
48
+
49
+
50
+ def extract_features(
51
+ audio_path: str,
52
+ sr: int = 22050,
53
+ return_features: bool = False,
54
+ ) -> AudioFeatureResult:
55
+ """Extract the exact 26 features expected by the BitCheck Random Forest model."""
56
+ warnings: list = []
57
+
58
+ # --- Load normalized audio ---
59
+ try:
60
+ import librosa
61
+ y, _sr = librosa.load(audio_path, sr=sr, mono=True)
62
+ except Exception as e:
63
+ return AudioFeatureResult(
64
+ checked=True,
65
+ features_found=False,
66
+ error=f"Could not load audio for feature extraction: {e}",
67
+ warnings=[f"librosa load failed: {e}"],
68
+ )
69
+
70
+ if len(y) < sr * 0.01:
71
+ warnings.append("Audio is extremely short for reliable feature extraction.")
72
+
73
+ # --- Compute features ---
74
+ sanitized_count = 0
75
+
76
+ def _safe_mean(values: np.ndarray, name: str) -> float:
77
+ nonlocal sanitized_count
78
+ m = np.nanmean(values)
79
+ if np.isnan(m) or np.isinf(m):
80
+ sanitized_count += 1
81
+ logger.warning("Feature %s was NaN/inf, replacing with 0", name)
82
+ return 0.0
83
+ return float(m)
84
+
85
+ try:
86
+ chroma = librosa.feature.chroma_stft(y=y, sr=_sr)
87
+ chroma_mean = _safe_mean(chroma, "chroma_stft")
88
+
89
+ rms = librosa.feature.rms(y=y)
90
+ rms_mean = _safe_mean(rms, "rms")
91
+
92
+ centroid = librosa.feature.spectral_centroid(y=y, sr=_sr)
93
+ centroid_mean = _safe_mean(centroid, "spectral_centroid")
94
+
95
+ bandwidth = librosa.feature.spectral_bandwidth(y=y, sr=_sr)
96
+ bandwidth_mean = _safe_mean(bandwidth, "spectral_bandwidth")
97
+
98
+ rolloff = librosa.feature.spectral_rolloff(y=y, sr=_sr)
99
+ rolloff_mean = _safe_mean(rolloff, "rolloff")
100
+
101
+ zcr = librosa.feature.zero_crossing_rate(y)
102
+ zcr_mean = _safe_mean(zcr, "zero_crossing_rate")
103
+
104
+ mfcc = librosa.feature.mfcc(y=y, sr=_sr, n_mfcc=20)
105
+ except Exception as e:
106
+ return AudioFeatureResult(
107
+ checked=True,
108
+ features_found=False,
109
+ error=f"Feature extraction failed: {e}",
110
+ warnings=[f"Feature computation error: {e}"],
111
+ )
112
+
113
+ mfcc_means = [_safe_mean(mfcc[i], f"mfcc{i+1}") for i in range(20)]
114
+
115
+ features = {
116
+ "chroma_stft": round(chroma_mean, 10),
117
+ "rms": round(rms_mean, 10),
118
+ "spectral_centroid": round(centroid_mean, 10),
119
+ "spectral_bandwidth": round(bandwidth_mean, 10),
120
+ "rolloff": round(rolloff_mean, 10),
121
+ "zero_crossing_rate": round(zcr_mean, 10),
122
+ "mfcc1": round(mfcc_means[0], 10),
123
+ "mfcc2": round(mfcc_means[1], 10),
124
+ "mfcc3": round(mfcc_means[2], 10),
125
+ "mfcc4": round(mfcc_means[3], 10),
126
+ "mfcc5": round(mfcc_means[4], 10),
127
+ "mfcc6": round(mfcc_means[5], 10),
128
+ "mfcc7": round(mfcc_means[6], 10),
129
+ "mfcc8": round(mfcc_means[7], 10),
130
+ "mfcc9": round(mfcc_means[8], 10),
131
+ "mfcc10": round(mfcc_means[9], 10),
132
+ "mfcc11": round(mfcc_means[10], 10),
133
+ "mfcc12": round(mfcc_means[11], 10),
134
+ "mfcc13": round(mfcc_means[12], 10),
135
+ "mfcc14": round(mfcc_means[13], 10),
136
+ "mfcc15": round(mfcc_means[14], 10),
137
+ "mfcc16": round(mfcc_means[15], 10),
138
+ "mfcc17": round(mfcc_means[16], 10),
139
+ "mfcc18": round(mfcc_means[17], 10),
140
+ "mfcc19": round(mfcc_means[18], 10),
141
+ "mfcc20": round(mfcc_means[19], 10),
142
+ }
143
+
144
+ if sanitized_count > 0:
145
+ warnings.append(f"{sanitized_count} feature(s) were sanitized (NaN/inf replaced with 0).")
146
+
147
+ logger.info(
148
+ "Extracted %d features, sanitized=%d",
149
+ len(features), sanitized_count,
150
+ )
151
+
152
+ result = AudioFeatureResult(
153
+ checked=True,
154
+ features_found=True,
155
+ feature_count=len(features),
156
+ feature_names=FEATURE_NAMES,
157
+ features=features if return_features else None,
158
+ warnings=warnings,
159
+ )
160
+ return result
161
+
162
+
163
+ def features_to_dataframe(features: dict):
164
+ """Convert a feature dict to a 1-row pandas DataFrame with correct column order."""
165
+ import pandas as pd
166
+ return pd.DataFrame([features], columns=FEATURE_NAMES)
bitcheck-audio-service/tests/test_audio_features.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import wave
2
+ import numpy as np
3
+ import pytest
4
+
5
+ from app.services.audio_feature_extractor import (
6
+ extract_features,
7
+ FEATURE_NAMES,
8
+ features_to_dataframe,
9
+ AudioFeatureResult,
10
+ )
11
+
12
+
13
+ def _make_wav(path: str, duration_ms: int = 2000, sr: int = 22050, amp: float = 0.3):
14
+ num_samples = int(sr * duration_ms / 1000)
15
+ t = np.linspace(0, duration_ms / 1000, num_samples, endpoint=False)
16
+ y = (np.sin(2 * np.pi * 440 * t) * amp).astype(np.float32)
17
+ y_int = (y * 32767).astype(np.int16)
18
+ with wave.open(path, "wb") as wf:
19
+ wf.setnchannels(1)
20
+ wf.setsampwidth(2)
21
+ wf.setframerate(sr)
22
+ wf.writeframes(y_int.tobytes())
23
+
24
+
25
+ def _make_silent_wav(path: str, duration_ms: int = 1000, sr: int = 22050):
26
+ num_samples = int(sr * duration_ms / 1000)
27
+ with wave.open(path, "wb") as wf:
28
+ wf.setnchannels(1)
29
+ wf.setsampwidth(2)
30
+ wf.setframerate(sr)
31
+ wf.writeframes(b"\x00" * num_samples * 2)
32
+
33
+
34
+ def test_extractor_returns_exactly_26_features(tmp_path):
35
+ wav_path = tmp_path / "test.wav"
36
+ _make_wav(str(wav_path))
37
+ result = extract_features(str(wav_path))
38
+ assert result.features_found is True
39
+ assert result.feature_count == 26
40
+
41
+
42
+ def test_feature_names_match_expected(tmp_path):
43
+ wav_path = tmp_path / "test.wav"
44
+ _make_wav(str(wav_path))
45
+ result = extract_features(str(wav_path))
46
+ assert result.feature_names == FEATURE_NAMES
47
+ assert len(result.feature_names) == 26
48
+
49
+
50
+ def test_non_silent_audio_produces_nonzero_features(tmp_path):
51
+ wav_path = tmp_path / "tone.wav"
52
+ _make_wav(str(wav_path), amp=0.5)
53
+ result = extract_features(str(wav_path), return_features=True)
54
+ assert result.features is not None
55
+ assert result.features["rms"] > 0.001
56
+ # Spectral centroid of a 440 Hz tone should be around 440
57
+ assert result.features["spectral_centroid"] > 100
58
+
59
+
60
+ def test_silent_audio_does_not_crash(tmp_path):
61
+ wav_path = tmp_path / "silent.wav"
62
+ _make_silent_wav(str(wav_path))
63
+ result = extract_features(str(wav_path), return_features=True)
64
+ assert result.features_found is True
65
+ assert result.feature_count == 26
66
+ # All features should be finite
67
+ for name in FEATURE_NAMES:
68
+ val = result.features[name]
69
+ assert not np.isnan(val), f"{name} is NaN"
70
+ assert not np.isinf(val), f"{name} is inf"
71
+
72
+
73
+ def test_return_features_false_hides_raw_features(tmp_path):
74
+ wav_path = tmp_path / "test.wav"
75
+ _make_wav(str(wav_path))
76
+ result = extract_features(str(wav_path), return_features=False)
77
+ assert result.features_found is True
78
+ assert result.features is None
79
+
80
+
81
+ def test_return_features_true_includes_raw_features(tmp_path):
82
+ wav_path = tmp_path / "test.wav"
83
+ _make_wav(str(wav_path))
84
+ result = extract_features(str(wav_path), return_features=True)
85
+ assert result.features is not None
86
+ assert len(result.features) == 26
87
+ for name in FEATURE_NAMES:
88
+ assert name in result.features
89
+
90
+
91
+ def test_corrupt_file_handled(tmp_path):
92
+ bad_path = tmp_path / "bad.wav"
93
+ bad_path.write_bytes(b"\x00" * 10)
94
+ result = extract_features(str(bad_path))
95
+ assert result.checked is True
96
+ # Should either find no features or fail gracefully
97
+ assert isinstance(result, AudioFeatureResult)
98
+
99
+
100
+ def test_features_to_dataframe_has_correct_shape():
101
+ features = {name: 0.0 for name in FEATURE_NAMES}
102
+ df = features_to_dataframe(features)
103
+ assert df.shape == (1, 26)
104
+ assert list(df.columns) == FEATURE_NAMES