# Listening Core — Phase 2 Model Assets

This folder is the on-device model bundle consumed by
`TfliteRunner.load()` (sahulat_common/lib/listening_core/tflite_runner.dart).

## Files this folder MUST contain

| File | Size | Output | Labels |
|------|------|--------|--------|
| `species_classifier.tflite` | ~120 KB int8 | `[1, 8]` softmax | cow, goat, sheep, buffalo, dog, cat, horse, unknown |
| `emotion_classifier.tflite` | ~120 KB int8 | `[1, 6]` softmax | calm, stress, distress, hunger, pain, unknown |

Both models share the same input tensor:

```
shape  : [1, 64, 96, 1]   (batch, n_mels, n_frames, channels)
dtype  : float32
range  : standardised per-frame z-score (mean 0, std 1)
```

The host-side feature pipeline that produces these inputs lives in
`FeatureExtractor` (sahulat_common/lib/listening_core/feature_extractor.dart).
Training pipelines MUST mirror it exactly, otherwise the model sees a
different feature distribution than it saw at training time.

## Feature contract

- 16 kHz mono PCM, 3-second window (48 000 samples)
- Hann window, 25 ms (400 samples) with 10 ms hop (160 samples) → 96 frames
- 64 mel bands, 0–8 kHz
- `log(mel + 1e-6)` then **per-frame z-score** across the 64 mel axis

Python reference (matches the Dart impl byte-for-byte):

```python
import librosa, numpy as np
def features(pcm16: np.ndarray, sr=16000):
    s = librosa.feature.melspectrogram(
        y=pcm16.astype(np.float32) / 32768.0,
        sr=sr, n_fft=512, hop_length=160, win_length=400,
        n_mels=64, fmin=0, fmax=8000, power=2.0,
    )
    s = np.log(s + 1e-6)
    # per-frame z-score along the mel axis
    mu = s.mean(axis=0, keepdims=True)
    sd = s.std(axis=0, keepdims=True) + 1e-6
    return (s - mu) / sd               # shape (64, 96)
```

## Training plan (#266)

1. **Corpus** — start with public bioacoustic sets:
   - AudioSet animal subset (cow/goat/sheep/dog/cat)
   - ESC-50 animal classes
   - Open Cattle Sound Dataset (Bovine vocalisations)
   - Plus self-collected PK barn audio with annotation via the app's
     "labelled chunk" flow (Phase 4 listening-feedback loop).
2. **Architecture** — small CNN-RNN, target <130 KB int8:
   - Conv2D(16, 3×3) + BN + ReLU + MaxPool(2×2)
   - Conv2D(32, 3×3) + BN + ReLU + MaxPool(2×2)
   - GRU(32) on the time axis
   - Dense(N_CLASSES) + softmax
3. **Quant** — post-training int8 with representative dataset of 200
   clips. Validate score against the FP32 baseline (target ≤2 % drop).
4. **Export** — `tflite_convert --post_training_quantize` → drop the
   resulting `.tflite` into this folder. No code change needed; the
   runner picks up whatever binary is present at load time.

## Stub binaries (today)

The `.tflite` files in this folder right now are zero-weight stubs
that emit a constant `unknown` argmax. They exist so:

- `TfliteRunner.load()` returns non-null in CI + when the app first
  starts on a real device — exercises the wiring path.
- DecisionEngine + ListeningCore code paths run end-to-end against
  real inference output (even though the output is "unknown").
- A reviewer can validate the asset bundling without waiting for the
  trained weights.

When real models arrive via #266, drop them in with the exact same
filenames and bump `pubspec.yaml` if the file size changes
materially (impacts APK size).

## Cross-references

- `feature_extractor.dart` — DSP implementation.
- `tflite_runner.dart` — model loader + inference.
- `decision_engine.dart` — output → user-facing suggestion.
- Memory `listening_core_spec` (originSessionId
  648343b9-9987-4f3b-b0b2-9f5ba0fb5919) — full 4-phase plan.
