Spaces:
Sleeping
Sleeping
Commit ·
ea2601f
0
Parent(s):
Initial commit: real-time multi-model baby cry classifier
Browse files- .gitignore +28 -0
- README.md +65 -0
- audio/__init__.py +0 -0
- audio/capture.py +209 -0
- audio/preprocess.py +94 -0
- display/__init__.py +0 -0
- display/table.py +173 -0
- main.py +180 -0
- models/__init__.py +0 -0
- models/base.py +78 -0
- models/ensemble.py +205 -0
- models/foduucom_svc.py +94 -0
- models/kibalama.py +93 -0
- models/wiam_wav2vec2.py +68 -0
- models/yamnet.py +67 -0
- pyproject.toml +24 -0
- uv.lock +0 -0
- weights/.gitignore +3 -0
.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
|