grungecoder commited on
Commit
ea2601f
·
0 Parent(s):

Initial commit: real-time multi-model baby cry classifier

Browse files
.gitignore ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+
10
+ # Virtual environment
11
+ .venv/
12
+
13
+ # uv
14
+ .python-version
15
+
16
+ # OS
17
+ .DS_Store
18
+ Thumbs.db
19
+
20
+ # IDE
21
+ .vscode/
22
+ .idea/
23
+ *.swp
24
+ *.swo
25
+
26
+ # Logs
27
+ *.jsonl
28
+ *.log
README.md ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🍼 TotTalk Cry Eval
2
+
3
+ Real-time multi-model baby cry classification CLI tool. Captures mic audio (or reads a file), runs four open-source models simultaneously on 1-second windows, and displays a live comparison table in the terminal.
4
+
5
+ ## Models
6
+
7
+ | # | Name | Type | Source | Speed |
8
+ |---|------|------|--------|-------|
9
+ | 1 | **foduucom-SVC** | sklearn SVC, 194-dim MFCC features | [HuggingFace](https://huggingface.co/foduucom/baby-cry-classification) | < 1 ms |
10
+ | 2 | **DistilHuBERT** | DistilHuBERT fine-tune (5 classes) | [HuggingFace](https://huggingface.co/AmeerHesham/distilhubert-finetuned-baby_cry) | ~35 ms |
11
+ | 3 | **Kibalama-9c** | Wav2Vec2 fine-tune (9 classes incl. discomfort, tired, cold/hot) | [HuggingFace](https://huggingface.co/Kibalama/baby_cry_classification_model) | ~90 ms |
12
+ | 4 | **YAMNet-detector** | TF Hub YAMNet (binary cry gate) | [TF Hub](https://tfhub.dev/google/yamnet/1) | < 10 ms |
13
+
14
+ ## Quick start
15
+
16
+ ```bash
17
+ # Install dependencies (using uv)
18
+ cd cry-eval
19
+ uv sync
20
+
21
+ # Run with mic input
22
+ uv run python main.py
23
+
24
+ # Run with an audio file
25
+ uv run python main.py --file path/to/cry.wav
26
+
27
+ # Select specific models
28
+ uv run python main.py --models svc,hubert,kibalama
29
+
30
+ # Disable YAMNet gating
31
+ uv run python main.py --no-yamnet-gate
32
+
33
+ # Save predictions to JSONL
34
+ uv run python main.py --save-log results.jsonl
35
+ ```
36
+
37
+ ## Requirements
38
+
39
+ - Python ≥ 3.11
40
+ - A working microphone (for live mode)
41
+ - ~1 GB RAM for transformer models
42
+
43
+ Model weights are auto-downloaded on first run into HuggingFace/TF Hub caches.
44
+
45
+ ## Project structure
46
+
47
+ ```
48
+ cry-eval/
49
+ ├── pyproject.toml
50
+ ├── README.md
51
+ ├── main.py # CLI entrypoint
52
+ ├── models/
53
+ │ ├── base.py # abstract CryClassifier + CryPrediction
54
+ │ ├── foduucom_svc.py # sklearn SVC
55
+ │ ├── wiam_wav2vec2.py # DistilHuBERT fine-tune
56
+ │ ├── kibalama.py # Wav2Vec2 9-class fine-tune
57
+ │ ├── yamnet.py # YAMNet binary detector
58
+ │ └── ensemble.py # orchestrates all models
59
+ ├── audio/
60
+ │ ├── capture.py # MicCapture + FileCapture
61
+ │ └── preprocess.py # MFCC, mel, resample, RMS
62
+ ├── display/
63
+ │ └── table.py # Rich live table renderer
64
+ └── weights/ # auto-downloaded (gitignored)
65
+ ```
audio/__init__.py ADDED
File without changes
audio/capture.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Microphone capture with overlapping sliding windows using sounddevice."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import queue
6
+ import threading
7
+
8
+ import numpy as np
9
+ import sounddevice as sd
10
+
11
+ from audio.preprocess import SAMPLE_RATE, WINDOW_SECONDS
12
+
13
+
14
+ class MicCapture:
15
+ """Captures audio from the default mic and emits overlapping windows.
16
+
17
+ Uses a ring-buffer with a 1-second hop (50 % overlap on 2 s windows) so
18
+ short or quiet sounds that straddle a boundary are still captured in at
19
+ least one complete window.
20
+
21
+ Parameters
22
+ ----------
23
+ sample_rate : int
24
+ Target sample rate (default 16 000).
25
+ window_seconds : float
26
+ Window length in seconds (default 2.0).
27
+ hop_seconds : float
28
+ Hop between consecutive emitted windows (default 1.0 → 50 % overlap).
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ sample_rate: int = SAMPLE_RATE,
34
+ window_seconds: float = WINDOW_SECONDS,
35
+ hop_seconds: float = 0.5,
36
+ ) -> None:
37
+ self.sample_rate = sample_rate
38
+ self.window_seconds = window_seconds
39
+ self.hop_seconds = hop_seconds
40
+
41
+ self._window_samples = int(sample_rate * window_seconds)
42
+ self._hop_samples = int(sample_rate * hop_seconds)
43
+
44
+ # Ring buffer — pre-allocated numpy array
45
+ self._buf = np.zeros(self._window_samples, dtype=np.float32)
46
+ self._write_pos = 0 # how many samples written since last emit
47
+ self._buf_filled = False # True once we have at least one full window
48
+
49
+ # Thread-safe queue so the main loop can pull complete windows
50
+ self.window_queue: queue.Queue[np.ndarray] = queue.Queue(maxsize=16)
51
+
52
+ self._stream: sd.InputStream | None = None
53
+ self._running = threading.Event()
54
+
55
+ # ── sounddevice callback ──────────────────────────────────────────────
56
+ def _audio_callback(
57
+ self,
58
+ indata: np.ndarray,
59
+ frames: int,
60
+ time_info: object,
61
+ status: sd.CallbackFlags,
62
+ ) -> None:
63
+ mono = indata[:, 0].copy()
64
+ n = len(mono)
65
+
66
+ # Shift buffer left and append new samples at the end
67
+ if n >= self._window_samples:
68
+ # Chunk larger than window — just keep the last window_samples
69
+ self._buf[:] = mono[-self._window_samples:]
70
+ self._write_pos = self._window_samples
71
+ self._buf_filled = True
72
+ else:
73
+ self._buf[:-n] = self._buf[n:]
74
+ self._buf[-n:] = mono
75
+ self._write_pos += n
76
+
77
+ # After initial fill, emit a window every hop_samples
78
+ if not self._buf_filled:
79
+ if self._write_pos >= self._window_samples:
80
+ self._buf_filled = True
81
+ self._write_pos = 0
82
+ self._emit()
83
+ else:
84
+ if self._write_pos >= self._hop_samples:
85
+ self._write_pos -= self._hop_samples
86
+ self._emit()
87
+
88
+ def _emit(self) -> None:
89
+ window = self._buf.copy()
90
+ try:
91
+ self.window_queue.put_nowait(window)
92
+ except queue.Full:
93
+ # Drop the oldest unprocessed window to keep latency low
94
+ try:
95
+ self.window_queue.get_nowait()
96
+ except queue.Empty:
97
+ pass
98
+ try:
99
+ self.window_queue.put_nowait(window)
100
+ except queue.Full:
101
+ pass
102
+
103
+ # ── public API ────────────────────────────────────────────────────────
104
+ def start(self) -> None:
105
+ """Open the mic stream and begin capturing."""
106
+ self._running.set()
107
+ self._stream = sd.InputStream(
108
+ samplerate=self.sample_rate,
109
+ channels=1,
110
+ dtype="float32",
111
+ blocksize=int(self.sample_rate * 0.1), # 100 ms blocks
112
+ callback=self._audio_callback,
113
+ )
114
+ self._stream.start()
115
+
116
+ def stop(self) -> None:
117
+ """Stop capturing and close the stream."""
118
+ self._running.clear()
119
+ if self._stream is not None:
120
+ self._stream.stop()
121
+ self._stream.close()
122
+ self._stream = None
123
+
124
+ @property
125
+ def is_running(self) -> bool:
126
+ return self._running.is_set()
127
+
128
+
129
+ class FileCapture:
130
+ """Reads an audio file and emits sliding 2-second windows into a queue.
131
+
132
+ Parameters
133
+ ----------
134
+ path : str
135
+ Path to a WAV/FLAC/MP3 file.
136
+ sample_rate : int
137
+ Target sample rate.
138
+ window_seconds : float
139
+ Window size in seconds.
140
+ hop_seconds : float
141
+ Hop between consecutive windows (default 1.0 s for 50 % overlap).
142
+ loop : bool
143
+ Whether to loop the file indefinitely.
144
+ """
145
+
146
+ def __init__(
147
+ self,
148
+ path: str,
149
+ sample_rate: int = SAMPLE_RATE,
150
+ window_seconds: float = WINDOW_SECONDS,
151
+ hop_seconds: float = 1.0,
152
+ loop: bool = True,
153
+ ) -> None:
154
+ import librosa
155
+
156
+ self.path = path
157
+ self.sample_rate = sample_rate
158
+ self.window_seconds = window_seconds
159
+ self.hop_seconds = hop_seconds
160
+ self.loop = loop
161
+
162
+ self._audio, _ = librosa.load(path, sr=sample_rate, mono=True)
163
+ self._window_samples = int(sample_rate * window_seconds)
164
+ self._hop_samples = int(sample_rate * hop_seconds)
165
+ self._total_samples = len(self._audio)
166
+
167
+ self.window_queue: queue.Queue[np.ndarray] = queue.Queue(maxsize=16)
168
+ self._thread: threading.Thread | None = None
169
+ self._running = threading.Event()
170
+
171
+ def _emit_loop(self) -> None:
172
+ offset = 0
173
+ while self._running.is_set():
174
+ end = offset + self._window_samples
175
+ if end > self._total_samples:
176
+ if self.loop:
177
+ offset = 0
178
+ continue
179
+ else:
180
+ break
181
+ window = self._audio[offset:end].copy()
182
+ try:
183
+ self.window_queue.put(window, timeout=0.5)
184
+ except queue.Full:
185
+ pass
186
+ offset += self._hop_samples
187
+ # Simulate real-time pacing
188
+ import time
189
+ time.sleep(self.hop_seconds)
190
+
191
+ @property
192
+ def current_position_seconds(self) -> float:
193
+ """Approximate playback position — not perfectly precise but useful for display."""
194
+ return 0.0 # simplified; the thread owns the offset
195
+
196
+ def start(self) -> None:
197
+ self._running.set()
198
+ self._thread = threading.Thread(target=self._emit_loop, daemon=True)
199
+ self._thread.start()
200
+
201
+ def stop(self) -> None:
202
+ self._running.clear()
203
+ if self._thread is not None:
204
+ self._thread.join(timeout=3)
205
+ self._thread = None
206
+
207
+ @property
208
+ def is_running(self) -> bool:
209
+ return self._running.is_set()
audio/preprocess.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared audio preprocessing utilities."""
2
+
3
+ import numpy as np
4
+ import librosa
5
+
6
+ # ── Constants ──────────────────────────────────────────────────────────────────
7
+ SAMPLE_RATE = 16_000 # all models normalized to 16 kHz
8
+ WINDOW_SECONDS = 1.0 # inference window size
9
+ SILENCE_RMS_THRESHOLD = 0.001 # skip silent frames (low for phone speaker playback)
10
+ HOP_LENGTH = 512
11
+ N_FFT = 1024
12
+ N_MELS = 128
13
+ N_MFCC = 40
14
+
15
+
16
+ def resample(audio_np: np.ndarray, from_sr: int, to_sr: int) -> np.ndarray:
17
+ """Resample audio from *from_sr* to *to_sr* using librosa."""
18
+ if from_sr == to_sr:
19
+ return audio_np
20
+ return librosa.resample(audio_np, orig_sr=from_sr, target_sr=to_sr)
21
+
22
+
23
+ def extract_mfcc_features(
24
+ audio_np: np.ndarray,
25
+ sr: int,
26
+ n_mels: int = N_MELS,
27
+ ) -> np.ndarray:
28
+ """Return a feature vector (MFCCs + chroma + mel + contrast + tonnetz mean).
29
+
30
+ Concatenation order matches foduucom/baby-cry-classification training code.
31
+ ``n_mels`` can be overridden when the SVC model was trained with a different
32
+ mel band count.
33
+ """
34
+ # MFCCs — 40 coeffs
35
+ mfcc = librosa.feature.mfcc(y=audio_np, sr=sr, n_mfcc=N_MFCC)
36
+ mfcc_mean = np.mean(mfcc, axis=1) # (40,)
37
+
38
+ # Chroma — 12 bins
39
+ stft = np.abs(librosa.stft(audio_np, n_fft=N_FFT, hop_length=HOP_LENGTH))
40
+ chroma = librosa.feature.chroma_stft(S=stft, sr=sr)
41
+ chroma_mean = np.mean(chroma, axis=1) # (12,)
42
+
43
+ # Mel spectrogram summary
44
+ mel = librosa.feature.melspectrogram(
45
+ y=audio_np, sr=sr, n_fft=N_FFT, hop_length=HOP_LENGTH, n_mels=n_mels,
46
+ )
47
+ mel_mean = np.mean(mel, axis=1) # (n_mels,)
48
+
49
+ # Spectral contrast — 7 bands
50
+ contrast = librosa.feature.spectral_contrast(
51
+ S=stft, sr=sr, n_bands=6, fmin=200.0,
52
+ )
53
+ contrast_mean = np.mean(contrast, axis=1) # (7,)
54
+
55
+ # Tonnetz — 6 dims
56
+ tonnetz = librosa.feature.tonnetz(
57
+ y=librosa.effects.harmonic(audio_np), sr=sr,
58
+ )
59
+ tonnetz_mean = np.mean(tonnetz, axis=1) # (6,)
60
+
61
+ # Order: mfcc, chroma, mel, contrast, tonnetz (matches foduucom training)
62
+ return np.concatenate([mfcc_mean, chroma_mean, mel_mean, contrast_mean, tonnetz_mean])
63
+
64
+
65
+ def extract_mel_spectrogram(audio_np: np.ndarray, sr: int) -> np.ndarray:
66
+ """Return a mel spectrogram of shape (128, T) as float32."""
67
+ mel = librosa.feature.melspectrogram(
68
+ y=audio_np, sr=sr, n_fft=N_FFT, hop_length=HOP_LENGTH, n_mels=N_MELS,
69
+ )
70
+ return librosa.power_to_db(mel, ref=np.max).astype(np.float32)
71
+
72
+
73
+ def is_silent(audio_np: np.ndarray) -> bool:
74
+ """Return True when audio RMS is below the silence threshold."""
75
+ rms = np.sqrt(np.mean(audio_np ** 2))
76
+ return rms < SILENCE_RMS_THRESHOLD
77
+
78
+
79
+ def compute_rms(audio_np: np.ndarray) -> float:
80
+ """Return the RMS energy of the audio window."""
81
+ return float(np.sqrt(np.mean(audio_np ** 2)))
82
+
83
+
84
+ def normalize_audio(audio_np: np.ndarray) -> np.ndarray:
85
+ """Peak-normalize audio to [-1, 1].
86
+
87
+ Crucial when playing cry samples through a phone speaker → laptop mic,
88
+ since the captured signal can be very quiet and models perform poorly
89
+ on low-amplitude inputs.
90
+ """
91
+ peak = np.max(np.abs(audio_np))
92
+ if peak < 1e-6:
93
+ return audio_np
94
+ return audio_np / peak
display/__init__.py ADDED
File without changes
display/table.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rich-based live terminal table for displaying predictions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+
7
+ from rich.live import Live
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+
11
+ from models.base import LABEL_EMOJI, LABEL_MEANING, CryPrediction
12
+ from models.ensemble import compute_consensus
13
+
14
+ # ── Helpers ───────────────────────────────────────────────────────────────────
15
+
16
+ _BAR_FULL = "█"
17
+ _BAR_EMPTY = "░"
18
+ _BAR_WIDTH = 5
19
+
20
+
21
+ def confidence_bar(value: float) -> str:
22
+ """Render a 5-char Unicode bar for a 0.0–1.0 confidence value."""
23
+ filled = round(value * _BAR_WIDTH)
24
+ return _BAR_FULL * filled + _BAR_EMPTY * (_BAR_WIDTH - filled)
25
+
26
+
27
+ def format_confidence(value: float) -> str:
28
+ """Bar + percentage string."""
29
+ pct = int(value * 100)
30
+ return f"{confidence_bar(value)} {pct:>3}%"
31
+
32
+
33
+ # ── Display state ─────────────────────────────────────────────────────────────
34
+
35
+ class CryDisplay:
36
+ """Manages a ``rich.live.Live`` context showing model predictions."""
37
+
38
+ def __init__(self, max_history: int = 5) -> None:
39
+ self._window_count = 0
40
+ self._rms = 0.0
41
+ self._yamnet_status = ""
42
+ self._source_label = "mic"
43
+ self._predictions: list[CryPrediction] = []
44
+ self._history: deque[str] = deque(maxlen=max_history)
45
+ self._live: Live | None = None
46
+
47
+ # ── Public API ────────────────────────────────────────────────────────
48
+
49
+ def start(self) -> Live:
50
+ self._live = Live(self._build_table(), refresh_per_second=4)
51
+ self._live.start()
52
+ return self._live
53
+
54
+ def stop(self) -> None:
55
+ if self._live is not None:
56
+ self._live.stop()
57
+ self._live = None
58
+
59
+ def update(
60
+ self,
61
+ predictions: list[CryPrediction],
62
+ rms: float,
63
+ source_label: str = "mic",
64
+ is_silent: bool = False,
65
+ ) -> None:
66
+ self._window_count += 1
67
+ self._rms = rms
68
+ self._source_label = source_label
69
+ self._predictions = predictions
70
+
71
+ # Update YAMNet status line
72
+ yamnet_preds = [p for p in predictions if p.model_name == "YAMNet-detector"]
73
+ if yamnet_preds:
74
+ yp = yamnet_preds[0]
75
+ icon = "✅" if yp.label == "cry" else "❌"
76
+ self._yamnet_status = f"YAMNet: {icon} {yp.label.upper()} ({yp.confidence:.2f})"
77
+ else:
78
+ self._yamnet_status = "YAMNet: n/a"
79
+
80
+ # History
81
+ if is_silent:
82
+ self._history.appendleft(f"#{self._window_count} [silence]")
83
+ else:
84
+ consensus = compute_consensus(predictions)
85
+ tag = consensus if consensus else "—"
86
+ self._history.appendleft(f"#{self._window_count} {tag}")
87
+
88
+ if self._live is not None:
89
+ self._live.update(self._build_table())
90
+
91
+ # ── Table builder ─────────────────────────────────────────────────────
92
+
93
+ def _build_table(self) -> Table:
94
+ outer = Table(
95
+ title=f"🍼 TotTalk Cry Eval — listening ({self._source_label}) (1s windows, 16 kHz)",
96
+ title_style="bold cyan",
97
+ show_header=False,
98
+ show_edge=True,
99
+ pad_edge=True,
100
+ expand=True,
101
+ )
102
+ outer.add_column(ratio=1)
103
+
104
+ # Header row
105
+ header = (
106
+ f" RMS: {self._rms:.4f} | Window #{self._window_count} "
107
+ f"| {self._yamnet_status}"
108
+ )
109
+ outer.add_row(Text(header, style="dim"))
110
+
111
+ # Predictions table
112
+ pred_table = Table(show_edge=False, expand=True, padding=(0, 1))
113
+ pred_table.add_column("Model", style="bold", min_width=18)
114
+ pred_table.add_column("Label", min_width=14)
115
+ pred_table.add_column("Confidence", min_width=12)
116
+ pred_table.add_column("Latency", justify="right", min_width=10)
117
+
118
+ for p in self._predictions:
119
+ if p.error:
120
+ pred_table.add_row(
121
+ p.model_name,
122
+ Text(f"⚠️ {p.error[:30]}", style="red"),
123
+ "",
124
+ "",
125
+ )
126
+ else:
127
+ pred_table.add_row(
128
+ p.model_name,
129
+ p.display_label,
130
+ format_confidence(p.confidence),
131
+ f"{p.latency_ms:.1f} ms",
132
+ )
133
+
134
+ # Consensus row
135
+ consensus = compute_consensus(self._predictions)
136
+ if consensus:
137
+ pred_table.add_row(
138
+ Text("CONSENSUS", style="bold magenta"),
139
+ Text(consensus, style="bold"),
140
+ "",
141
+ "",
142
+ )
143
+
144
+ outer.add_row(pred_table)
145
+
146
+ # History
147
+ if self._history:
148
+ hist_str = " ".join(self._history)
149
+ outer.add_row(Text(f" Last detections: {hist_str}", style="dim"))
150
+
151
+ # Cry meaning legend — show meaning for the consensus / top prediction
152
+ shown_label = self._current_reason_label()
153
+ if shown_label and shown_label in LABEL_MEANING:
154
+ emoji = LABEL_EMOJI.get(shown_label, "")
155
+ outer.add_row(
156
+ Text(
157
+ f" {emoji} {shown_label.replace('_', ' ').title()}: "
158
+ f"{LABEL_MEANING[shown_label]}",
159
+ style="italic yellow",
160
+ )
161
+ )
162
+
163
+ return outer
164
+
165
+ def _current_reason_label(self) -> str | None:
166
+ """Return the most relevant reason label from the current predictions."""
167
+ for p in self._predictions:
168
+ if p.model_name == "YAMNet-detector":
169
+ continue
170
+ if p.error or p.label in ("no_cry", "timeout", "error"):
171
+ continue
172
+ return p.label
173
+ return None
main.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """TotTalk Cry Eval — real-time multi-model baby cry classifier."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import queue
8
+ from pathlib import Path
9
+
10
+ import click
11
+ import numpy as np
12
+ import sounddevice as sd
13
+ from rich.console import Console
14
+ from rich.progress import Progress, SpinnerColumn, TextColumn
15
+
16
+ from audio.capture import FileCapture, MicCapture
17
+ from audio.preprocess import SAMPLE_RATE, compute_rms, is_silent, normalize_audio
18
+ from display.table import CryDisplay
19
+ from models.ensemble import EnsembleClassifier
20
+
21
+ console = Console(stderr=True)
22
+
23
+
24
+ def _print_audio_devices() -> None:
25
+ """Print available audio devices for reference."""
26
+ console.print("\n[bold]Audio devices:[/bold]")
27
+ try:
28
+ devices = sd.query_devices()
29
+ default_in = sd.default.device[0]
30
+ for i, d in enumerate(devices):
31
+ marker = " ← default input" if i == default_in else ""
32
+ if d["max_input_channels"] > 0:
33
+ console.print(f" [{i}] {d['name']} (in:{d['max_input_channels']}){marker}")
34
+ except Exception as exc:
35
+ console.print(f" [red]Could not query devices: {exc}[/red]")
36
+
37
+
38
+ def _load_models(ensemble: EnsembleClassifier) -> None:
39
+ """Load all models with a rich progress spinner."""
40
+ console.print()
41
+ with Progress(
42
+ SpinnerColumn(),
43
+ TextColumn("[progress.description]{task.description}"),
44
+ console=console,
45
+ ) as progress:
46
+ task = progress.add_task("Loading models…", total=None)
47
+ results = ensemble.load_all()
48
+ progress.update(task, description="Models loaded.")
49
+
50
+ for name, error in results.items():
51
+ if error:
52
+ console.print(f" [red]✗ {name}: {error}[/red]")
53
+ else:
54
+ console.print(f" [green]✓ {name}[/green]")
55
+ console.print()
56
+
57
+
58
+ @click.command()
59
+ @click.option(
60
+ "--file", "audio_file", default=None, type=click.Path(exists=True),
61
+ help="Path to a WAV/FLAC/MP3 file (loops in sliding windows instead of mic).",
62
+ )
63
+ @click.option(
64
+ "--models", "model_names", default=None,
65
+ help="Comma-separated subset of models to run: svc,hubert,kibalama,yamnet",
66
+ )
67
+ @click.option(
68
+ "--no-yamnet-gate", is_flag=True, default=False,
69
+ help="Disable YAMNet gating (always run reason classifiers).",
70
+ )
71
+ @click.option(
72
+ "--save-log", default=None, type=click.Path(),
73
+ help="Append JSONL predictions to this file.",
74
+ )
75
+ @click.option(
76
+ "--sensitivity", default=None, type=float,
77
+ help="Silence RMS threshold override (default 0.001). Lower = more sensitive.",
78
+ )
79
+ def cli(
80
+ audio_file: str | None,
81
+ model_names: str | None,
82
+ no_yamnet_gate: bool,
83
+ save_log: str | None,
84
+ sensitivity: float | None,
85
+ ) -> None:
86
+ """🍼 TotTalk Cry Eval — real-time multi-model baby cry classifier."""
87
+ console.print("[bold cyan]🍼 TotTalk Cry Eval[/bold cyan]")
88
+
89
+ # Override silence threshold if requested
90
+ if sensitivity is not None:
91
+ import audio.preprocess as _ap
92
+ _ap.SILENCE_RMS_THRESHOLD = sensitivity
93
+ console.print(f"[dim]Silence threshold set to {sensitivity}[/dim]")
94
+
95
+ # Parse model list
96
+ selected = model_names.split(",") if model_names else None
97
+
98
+ # Init ensemble
99
+ ensemble = EnsembleClassifier(
100
+ model_names=selected,
101
+ use_yamnet_gate=not no_yamnet_gate,
102
+ )
103
+
104
+ # Print device info
105
+ if audio_file is None:
106
+ _print_audio_devices()
107
+
108
+ # Load models
109
+ _load_models(ensemble)
110
+
111
+ # Log file handle
112
+ log_fh = None
113
+ if save_log:
114
+ log_fh = open(save_log, "a") # noqa: SIM115
115
+
116
+ # Set up audio source
117
+ if audio_file:
118
+ source_label = f"file: {Path(audio_file).name}"
119
+ capture = FileCapture(audio_file)
120
+ else:
121
+ source_label = "mic"
122
+ capture = MicCapture()
123
+
124
+ # Display
125
+ display = CryDisplay()
126
+
127
+ try:
128
+ capture.start()
129
+ display.start()
130
+ console.print(f"[dim]Listening ({source_label})… Press Ctrl+C to stop.[/dim]\n")
131
+
132
+ while True:
133
+ try:
134
+ window: np.ndarray = capture.window_queue.get(timeout=3.0)
135
+ except queue.Empty:
136
+ continue
137
+
138
+ rms = compute_rms(window)
139
+ silent = is_silent(window)
140
+
141
+ if silent:
142
+ display.update([], rms, source_label=source_label, is_silent=True)
143
+ continue
144
+
145
+ # Peak-normalize so quiet phone playback reaches model-friendly levels
146
+ window = normalize_audio(window)
147
+
148
+ predictions = ensemble.predict_all(window, SAMPLE_RATE)
149
+ display.update(predictions, rms, source_label=source_label)
150
+
151
+ # Optional JSONL log
152
+ if log_fh is not None:
153
+ record = {
154
+ "window": display._window_count,
155
+ "rms": rms,
156
+ "predictions": [
157
+ {
158
+ "model": p.model_name,
159
+ "label": p.label,
160
+ "confidence": p.confidence,
161
+ "latency_ms": p.latency_ms,
162
+ "error": p.error,
163
+ }
164
+ for p in predictions
165
+ ],
166
+ }
167
+ log_fh.write(json.dumps(record) + "\n")
168
+ log_fh.flush()
169
+
170
+ except KeyboardInterrupt:
171
+ console.print("\n[yellow]Stopped.[/yellow]")
172
+ finally:
173
+ capture.stop()
174
+ display.stop()
175
+ if log_fh is not None:
176
+ log_fh.close()
177
+
178
+
179
+ if __name__ == "__main__":
180
+ cli()
models/__init__.py ADDED
File without changes
models/base.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Abstract base class and shared types for cry classifiers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+
8
+ import numpy as np
9
+
10
+ # ── Label sets ────────────────────────────────────────────────────────────────
11
+ LABELS_5CLASS = ["belly_pain", "burping", "discomfort", "hungry", "tired"]
12
+ LABELS_8CLASS = [
13
+ "hungry", "burping", "scared", "belly_pain",
14
+ "discomfort", "cold_hot", "lonely", "tired",
15
+ ]
16
+
17
+ LABEL_EMOJI: dict[str, str] = {
18
+ "belly_pain": "😣",
19
+ "burping": "🫧",
20
+ "discomfort": "😖",
21
+ "hungry": "🍼",
22
+ "tired": "😴",
23
+ "scared": "😨",
24
+ "cold_hot": "🌡️",
25
+ "lonely": "🥺",
26
+ "cry": "✅",
27
+ "not_cry": "❌",
28
+ }
29
+
30
+ # What each cry label means — shown in the terminal legend
31
+ LABEL_MEANING: dict[str, str] = {
32
+ "belly_pain": "Baby has stomach cramps or gas — try gentle tummy massage or bicycle legs",
33
+ "burping": "Baby needs to burp — hold upright and pat back gently",
34
+ "discomfort": "General discomfort — check diaper, clothing, temperature, or position",
35
+ "hungry": "Baby is hungry — time to feed",
36
+ "tired": "Baby is sleepy or overtired — needs soothing and rest",
37
+ "scared": "Baby is startled or frightened — comfort and hold close",
38
+ "cold_hot": "Baby is too cold or too warm — adjust clothing or room temperature",
39
+ "lonely": "Baby wants attention or closeness — pick up and cuddle",
40
+ }
41
+
42
+
43
+ def display_label(raw: str) -> str:
44
+ """Return an emoji-prefixed human-friendly label."""
45
+ emoji = LABEL_EMOJI.get(raw, "❓")
46
+ name = raw.replace("_", " ").title()
47
+ return f"{emoji} {name}"
48
+
49
+
50
+ # ── Prediction dataclass ─────────────────────────────────────────────────────
51
+ @dataclass
52
+ class CryPrediction:
53
+ model_name: str
54
+ label: str # raw label
55
+ display_label: str # emoji + human name
56
+ confidence: float # 0.0 – 1.0
57
+ latency_ms: float # inference time in ms
58
+ error: str | None = None
59
+
60
+
61
+ # ── Abstract classifier ──────────────────────────────────────────────────────
62
+ class CryClassifier(ABC):
63
+ name: str = "unnamed"
64
+ description: str = ""
65
+
66
+ def __init__(self) -> None:
67
+ self._loaded = False
68
+
69
+ @abstractmethod
70
+ def load(self) -> None:
71
+ """Download weights (if needed) and initialize the model."""
72
+
73
+ @abstractmethod
74
+ def predict(self, audio_np: np.ndarray, sr: int) -> CryPrediction:
75
+ """Run inference on a single audio window and return a prediction."""
76
+
77
+ def is_loaded(self) -> bool:
78
+ return self._loaded
models/ensemble.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ensemble runner — loads all models and orchestrates per-window inference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import concurrent.futures
6
+ import threading
7
+ from typing import Sequence
8
+
9
+ import numpy as np
10
+ from rich.console import Console
11
+
12
+ from models.base import CryClassifier, CryPrediction, display_label
13
+ from models.foduucom_svc import FoduucomSVC
14
+ from models.kibalama import KibalamaCry
15
+ from models.wiam_wav2vec2 import DistilHuBERTCry
16
+ from models.yamnet import YAMNetDetector
17
+
18
+ console = Console(stderr=True)
19
+
20
+ # Map of short names → classes for CLI --models filtering
21
+ MODEL_REGISTRY: dict[str, type[CryClassifier]] = {
22
+ "svc": FoduucomSVC,
23
+ "hubert": DistilHuBERTCry,
24
+ "kibalama": KibalamaCry,
25
+ "yamnet": YAMNetDetector,
26
+ }
27
+
28
+
29
+ class EnsembleClassifier:
30
+ """Loads and runs multiple cry classifiers, aggregating results."""
31
+
32
+ def __init__(
33
+ self,
34
+ model_names: Sequence[str] | None = None,
35
+ use_yamnet_gate: bool = True,
36
+ ) -> None:
37
+ self.use_yamnet_gate = use_yamnet_gate
38
+
39
+ # Decide which models to instantiate
40
+ if model_names is None:
41
+ names = list(MODEL_REGISTRY.keys())
42
+ else:
43
+ names = [n.lower() for n in model_names]
44
+
45
+ # Always include YAMNet if gating is enabled and it's not already in the list
46
+ if use_yamnet_gate and "yamnet" not in names:
47
+ names.insert(0, "yamnet")
48
+
49
+ self._classifiers: list[CryClassifier] = []
50
+ for n in names:
51
+ cls = MODEL_REGISTRY.get(n)
52
+ if cls is None:
53
+ console.print(f"[yellow]⚠ Unknown model '{n}' — skipping[/yellow]")
54
+ continue
55
+ self._classifiers.append(cls())
56
+
57
+ self._yamnet: YAMNetDetector | None = None
58
+ self._reason_classifiers: list[CryClassifier] = []
59
+ for c in self._classifiers:
60
+ if isinstance(c, YAMNetDetector):
61
+ self._yamnet = c
62
+ else:
63
+ self._reason_classifiers.append(c)
64
+
65
+ # ── Loading ───────────────────────────────────────────────────────────
66
+ def load_all(self) -> dict[str, str | None]:
67
+ """Load every model in parallel. Return {name: error_or_None}."""
68
+ results: dict[str, str | None] = {}
69
+ lock = threading.Lock()
70
+
71
+ def _load(clf: CryClassifier) -> None:
72
+ try:
73
+ clf.load()
74
+ with lock:
75
+ results[clf.name] = None
76
+ except Exception as exc:
77
+ with lock:
78
+ results[clf.name] = str(exc)
79
+
80
+ with concurrent.futures.ThreadPoolExecutor(max_workers=len(self._classifiers)) as pool:
81
+ pool.map(_load, self._classifiers)
82
+
83
+ return results
84
+
85
+ # ── Inference ─────────────────────────────────────────────────────────
86
+ def predict_all(
87
+ self,
88
+ audio_np: np.ndarray,
89
+ sr: int,
90
+ ) -> list[CryPrediction]:
91
+ predictions: list[CryPrediction] = []
92
+
93
+ # 1. YAMNet gate
94
+ if self._yamnet is not None and self._yamnet.is_loaded():
95
+ yamnet_pred = self._yamnet.predict(audio_np, sr)
96
+ predictions.append(yamnet_pred)
97
+
98
+ if (
99
+ self.use_yamnet_gate
100
+ and yamnet_pred.label == "not_cry"
101
+ and yamnet_pred.confidence < 0.4 # not_cry with cry-score < 0.4
102
+ ):
103
+ # Skip reason classifiers — no cry detected
104
+ for rc in self._reason_classifiers:
105
+ predictions.append(
106
+ CryPrediction(
107
+ model_name=rc.name,
108
+ label="no_cry",
109
+ display_label="— No cry",
110
+ confidence=0.0,
111
+ latency_ms=0.0,
112
+ )
113
+ )
114
+ return predictions
115
+ elif self._yamnet is not None:
116
+ predictions.append(
117
+ CryPrediction(
118
+ model_name=self._yamnet.name,
119
+ label="error",
120
+ display_label="⚠️ Load Error",
121
+ confidence=0.0,
122
+ latency_ms=0.0,
123
+ error="Model not loaded",
124
+ )
125
+ )
126
+
127
+ # 2. Run reason classifiers
128
+ # SVC is sub-ms — run synchronously
129
+ # Transformer models (HuBERT, Kibalama) — run in threads with timeout
130
+ inline_results: list[CryPrediction] = []
131
+ thread_futures: list[tuple[CryClassifier, concurrent.futures.Future[CryPrediction]]] = []
132
+
133
+ with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
134
+ for clf in self._reason_classifiers:
135
+ if not clf.is_loaded():
136
+ predictions.append(
137
+ CryPrediction(
138
+ model_name=clf.name,
139
+ label="error",
140
+ display_label="⚠️ Load Error",
141
+ confidence=0.0,
142
+ latency_ms=0.0,
143
+ error="Model not loaded",
144
+ )
145
+ )
146
+ continue
147
+
148
+ if isinstance(clf, FoduucomSVC):
149
+ # Fast — run inline
150
+ inline_results.append(clf.predict(audio_np, sr))
151
+ else:
152
+ # Slow — run in a thread
153
+ fut = pool.submit(clf.predict, audio_np, sr)
154
+ thread_futures.append((clf, fut))
155
+
156
+ predictions.extend(inline_results)
157
+
158
+ for clf, fut in thread_futures:
159
+ try:
160
+ result = fut.result(timeout=2.0)
161
+ predictions.append(result)
162
+ except concurrent.futures.TimeoutError:
163
+ predictions.append(
164
+ CryPrediction(
165
+ model_name=clf.name,
166
+ label="timeout",
167
+ display_label="⏳ Timeout",
168
+ confidence=0.0,
169
+ latency_ms=2000.0,
170
+ error="Inference timed out (>2 s)",
171
+ )
172
+ )
173
+
174
+ return predictions
175
+
176
+ @property
177
+ def classifiers(self) -> list[CryClassifier]:
178
+ return list(self._classifiers)
179
+
180
+
181
+ def compute_consensus(predictions: list[CryPrediction]) -> str | None:
182
+ """Weighted-vote consensus across *reason* classifiers (exclude YAMNet).
183
+
184
+ Each model contributes its confidence as a weight.
185
+ Returns the winning label string or None if no agreement / no valid votes.
186
+ """
187
+ weighted_votes: dict[str, float] = {}
188
+ vote_count: dict[str, int] = {}
189
+ total_voters = 0
190
+
191
+ for p in predictions:
192
+ if p.model_name == "YAMNet-detector":
193
+ continue
194
+ if p.error or p.label in ("no_cry", "timeout", "error"):
195
+ continue
196
+ total_voters += 1
197
+ weighted_votes[p.label] = weighted_votes.get(p.label, 0.0) + p.confidence
198
+ vote_count[p.label] = vote_count.get(p.label, 0) + 1
199
+
200
+ if not weighted_votes:
201
+ return None
202
+
203
+ top_label = max(weighted_votes, key=weighted_votes.__getitem__)
204
+ count = vote_count[top_label]
205
+ return f"{display_label(top_label)} ({count}/{total_voters} agree)"
models/foduucom_svc.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model 1 — foduucom/baby-cry-classification sklearn SVC."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+
8
+ import numpy as np
9
+
10
+ from models.base import CryClassifier, CryPrediction, display_label
11
+
12
+
13
+ class FoduucomSVC(CryClassifier):
14
+ name = "foduucom-SVC"
15
+ description = "sklearn SVC trained on 5-class baby cry features (HuggingFace: foduucom/baby-cry-classification)"
16
+
17
+ REPO_ID = "foduucom/baby-cry-classification"
18
+ WEIGHTS_DIR = os.path.join("weights", "foduucom")
19
+
20
+ # Fixed feature dimensions: 40 MFCC + 12 chroma + 7 contrast + 6 tonnetz = 65
21
+ _FIXED_FEATURES = 65
22
+
23
+ def __init__(self) -> None:
24
+ super().__init__()
25
+ self._model = None
26
+ self._label_encoder = None
27
+ self._n_mels: int = 128 # will be auto-detected from model
28
+
29
+ def load(self) -> None:
30
+ from huggingface_hub import hf_hub_download
31
+ import joblib
32
+
33
+ os.makedirs(self.WEIGHTS_DIR, exist_ok=True)
34
+
35
+ model_path = hf_hub_download(
36
+ repo_id=self.REPO_ID,
37
+ filename="model.joblib",
38
+ local_dir=self.WEIGHTS_DIR,
39
+ )
40
+ label_path = hf_hub_download(
41
+ repo_id=self.REPO_ID,
42
+ filename="label.joblib",
43
+ local_dir=self.WEIGHTS_DIR,
44
+ )
45
+
46
+ self._model = joblib.load(model_path)
47
+ self._label_encoder = joblib.load(label_path)
48
+
49
+ # Auto-detect n_mels from the model's expected feature count
50
+ expected = getattr(self._model, "n_features_in_", None)
51
+ if expected is not None:
52
+ self._n_mels = expected - self._FIXED_FEATURES
53
+ if self._n_mels < 1:
54
+ self._n_mels = 128 # fallback
55
+
56
+ self._loaded = True
57
+
58
+ def predict(self, audio_np: np.ndarray, sr: int) -> CryPrediction:
59
+ from audio.preprocess import extract_mfcc_features
60
+
61
+ t0 = time.perf_counter()
62
+ try:
63
+ features = extract_mfcc_features(audio_np, sr, n_mels=self._n_mels).reshape(1, -1)
64
+ pred_encoded = self._model.predict(features)
65
+ label_raw: str = self._label_encoder.inverse_transform(pred_encoded)[0]
66
+
67
+ # SVC doesn't natively support predict_proba. Use decision_function
68
+ # distance as a rough proxy, clamped to [0, 1].
69
+ try:
70
+ decision = self._model.decision_function(features)
71
+ # For multi-class, decision is a matrix — take max distance
72
+ max_dist = float(np.max(np.abs(decision)))
73
+ confidence = min(max_dist / 2.0, 1.0) # rough normalisation
74
+ except Exception:
75
+ confidence = 0.85 # fallback constant
76
+
77
+ latency = (time.perf_counter() - t0) * 1000
78
+ return CryPrediction(
79
+ model_name=self.name,
80
+ label=label_raw,
81
+ display_label=display_label(label_raw),
82
+ confidence=confidence,
83
+ latency_ms=latency,
84
+ )
85
+ except Exception as exc:
86
+ latency = (time.perf_counter() - t0) * 1000
87
+ return CryPrediction(
88
+ model_name=self.name,
89
+ label="error",
90
+ display_label="⚠️ Error",
91
+ confidence=0.0,
92
+ latency_ms=latency,
93
+ error=str(exc),
94
+ )
models/kibalama.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model — Kibalama/baby_cry_classification_model (Wav2Vec2, 9-class)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ import numpy as np
8
+
9
+ from models.base import CryClassifier, CryPrediction, display_label
10
+
11
+
12
+ # Map Kibalama's raw labels to our canonical label set
13
+ _LABEL_MAP: dict[str, str | None] = {
14
+ "belly pain": "belly_pain",
15
+ "burping": "burping",
16
+ "cold_hot": "cold_hot",
17
+ "discomfort": "discomfort",
18
+ "hungry": "hungry",
19
+ "tired": "tired",
20
+ # Non-cry labels — skip (return as-is but with low relevance)
21
+ "laugh": None,
22
+ "noise": None,
23
+ "silence": None,
24
+ }
25
+
26
+
27
+ class KibalamaCry(CryClassifier):
28
+ name = "Kibalama-9c"
29
+ description = (
30
+ "Wav2Vec2 fine-tuned on 9-class baby cry dataset "
31
+ "(HuggingFace: Kibalama/baby_cry_classification_model)"
32
+ )
33
+
34
+ MODEL_ID = "Kibalama/baby_cry_classification_model"
35
+
36
+ def __init__(self) -> None:
37
+ super().__init__()
38
+ self._pipe = None
39
+
40
+ def load(self) -> None:
41
+ from transformers import pipeline
42
+
43
+ self._pipe = pipeline(
44
+ "audio-classification",
45
+ model=self.MODEL_ID,
46
+ device="cpu",
47
+ )
48
+ self._loaded = True
49
+
50
+ def predict(self, audio_np: np.ndarray, sr: int) -> CryPrediction:
51
+ from audio.preprocess import SAMPLE_RATE, resample
52
+
53
+ t0 = time.perf_counter()
54
+ try:
55
+ if sr != SAMPLE_RATE:
56
+ audio_np = resample(audio_np, sr, SAMPLE_RATE)
57
+
58
+ results = self._pipe(
59
+ {"raw": audio_np, "sampling_rate": SAMPLE_RATE},
60
+ top_k=9,
61
+ )
62
+
63
+ # Pick the top *cry-related* label (skip laugh/noise/silence)
64
+ for res in results:
65
+ raw = res["label"]
66
+ mapped = _LABEL_MAP.get(raw, raw)
67
+ if mapped is not None:
68
+ label_raw = mapped
69
+ confidence = res["score"]
70
+ break
71
+ else:
72
+ # All top results were non-cry categories
73
+ label_raw = "no_cry"
74
+ confidence = 0.0
75
+
76
+ latency = (time.perf_counter() - t0) * 1000
77
+ return CryPrediction(
78
+ model_name=self.name,
79
+ label=label_raw,
80
+ display_label=display_label(label_raw),
81
+ confidence=confidence,
82
+ latency_ms=latency,
83
+ )
84
+ except Exception as exc:
85
+ latency = (time.perf_counter() - t0) * 1000
86
+ return CryPrediction(
87
+ model_name=self.name,
88
+ label="error",
89
+ display_label="⚠️ Error",
90
+ confidence=0.0,
91
+ latency_ms=latency,
92
+ error=str(exc),
93
+ )
models/wiam_wav2vec2.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model 2 — AmeerHesham/distilhubert-finetuned-baby_cry (DistilHuBERT)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ import numpy as np
8
+
9
+ from models.base import CryClassifier, CryPrediction, display_label
10
+
11
+
12
+ class DistilHuBERTCry(CryClassifier):
13
+ name = "DistilHuBERT"
14
+ description = (
15
+ "DistilHuBERT fine-tuned for baby cry classification "
16
+ "(HuggingFace: AmeerHesham/distilhubert-finetuned-baby_cry)"
17
+ )
18
+
19
+ MODEL_ID = "AmeerHesham/distilhubert-finetuned-baby_cry"
20
+
21
+ def __init__(self) -> None:
22
+ super().__init__()
23
+ self._pipe = None
24
+
25
+ def load(self) -> None:
26
+ from transformers import pipeline
27
+
28
+ self._pipe = pipeline(
29
+ "audio-classification",
30
+ model=self.MODEL_ID,
31
+ device="cpu",
32
+ )
33
+ self._loaded = True
34
+
35
+ def predict(self, audio_np: np.ndarray, sr: int) -> CryPrediction:
36
+ from audio.preprocess import SAMPLE_RATE, resample
37
+
38
+ t0 = time.perf_counter()
39
+ try:
40
+ if sr != SAMPLE_RATE:
41
+ audio_np = resample(audio_np, sr, SAMPLE_RATE)
42
+
43
+ results = self._pipe(
44
+ {"raw": audio_np, "sampling_rate": SAMPLE_RATE},
45
+ top_k=1,
46
+ )
47
+ top = results[0]
48
+ label_raw: str = top["label"]
49
+ confidence: float = top["score"]
50
+
51
+ latency = (time.perf_counter() - t0) * 1000
52
+ return CryPrediction(
53
+ model_name=self.name,
54
+ label=label_raw,
55
+ display_label=display_label(label_raw),
56
+ confidence=confidence,
57
+ latency_ms=latency,
58
+ )
59
+ except Exception as exc:
60
+ latency = (time.perf_counter() - t0) * 1000
61
+ return CryPrediction(
62
+ model_name=self.name,
63
+ label="error",
64
+ display_label="⚠️ Error",
65
+ confidence=0.0,
66
+ latency_ms=latency,
67
+ error=str(exc),
68
+ )
models/yamnet.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model 3 — YAMNet binary baby-cry detector via TensorFlow Hub."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ import numpy as np
8
+
9
+ from models.base import CryClassifier, CryPrediction, display_label
10
+
11
+
12
+ # "Baby cry, infant cry" class index in the AudioSet ontology used by YAMNet.
13
+ _BABY_CRY_CLASS_INDEX = 20
14
+
15
+
16
+ class YAMNetDetector(CryClassifier):
17
+ name = "YAMNet-detector"
18
+ description = "YAMNet (TF Hub) binary cry detector — gates the reason classifiers"
19
+
20
+ def __init__(self) -> None:
21
+ super().__init__()
22
+ self._model = None
23
+
24
+ def load(self) -> None:
25
+ import tensorflow_hub as hub
26
+
27
+ self._model = hub.load("https://tfhub.dev/google/yamnet/1")
28
+ self._loaded = True
29
+
30
+ def predict(self, audio_np: np.ndarray, sr: int) -> CryPrediction:
31
+ import tensorflow as tf
32
+ from audio.preprocess import SAMPLE_RATE, resample
33
+
34
+ t0 = time.perf_counter()
35
+ try:
36
+ if sr != SAMPLE_RATE:
37
+ audio_np = resample(audio_np, sr, SAMPLE_RATE)
38
+
39
+ waveform = tf.cast(audio_np, tf.float32)
40
+ scores, embeddings, spectrogram = self._model(waveform)
41
+
42
+ # scores shape: (num_frames, 521)
43
+ scores_np = scores.numpy()
44
+ cry_scores = scores_np[:, _BABY_CRY_CLASS_INDEX]
45
+ avg_cry_score = float(np.mean(cry_scores))
46
+
47
+ is_cry = avg_cry_score >= 0.4
48
+ label_raw = "cry" if is_cry else "not_cry"
49
+
50
+ latency = (time.perf_counter() - t0) * 1000
51
+ return CryPrediction(
52
+ model_name=self.name,
53
+ label=label_raw,
54
+ display_label=display_label(label_raw),
55
+ confidence=avg_cry_score,
56
+ latency_ms=latency,
57
+ )
58
+ except Exception as exc:
59
+ latency = (time.perf_counter() - t0) * 1000
60
+ return CryPrediction(
61
+ model_name=self.name,
62
+ label="error",
63
+ display_label="⚠️ Error",
64
+ confidence=0.0,
65
+ latency_ms=latency,
66
+ error=str(exc),
67
+ )
pyproject.toml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "cry-eval"
3
+ version = "0.1.0"
4
+ description = "Real-time multi-model baby cry classification CLI tool"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "sounddevice>=0.4.6",
8
+ "numpy>=1.24.0",
9
+ "librosa>=0.10.0",
10
+ "scikit-learn>=1.3.0",
11
+ "joblib>=1.3.0",
12
+ "torch>=2.1.0",
13
+ "torchaudio>=2.1.0",
14
+ "transformers>=4.38.0",
15
+ "tensorflow>=2.15.0",
16
+ "tensorflow-hub>=0.15.0",
17
+ "huggingface-hub>=0.20.0",
18
+ "rich>=13.7.0",
19
+ "click>=8.1.0",
20
+ "soundfile>=0.12.0",
21
+ ]
22
+
23
+ [project.scripts]
24
+ cry-eval = "main:cli"
uv.lock ADDED
The diff for this file is too large to render. See raw diff
 
weights/.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # auto-downloaded model weights
2
+ *
3
+ !.gitignore