Audio model with your own MFCC
Write MFCC feature extraction yourself in NumPy, save golden vectors for the future C version, and train the bird-sound classifier (template T1).
Where Where to implement
Data
data/raw/audio/house_sparrow/*.wavdata/raw/audio/rose_ringed_parakeet/*.wavdata/raw/audio/noise/*.wav(traffic, wind, people, fan)
Code
sutra_core/dsp.py:mfcc(), the function the chip must reproduce in Month 3- Model: reuse
Conv1DNetfrom Step 06 (13 MFCC values per frame = 13 "axes")
Do Do it
1NB30: record and convert to 16 kHz mono
- Record at least 15 minutes per class, across different days and places. Your own recordings avoid licence problems.
- Phone recorders usually save .m4a. Convert on the Jetson:
cd ~/sutraai/data/raw/audio
for f in */*.m4a; do ffmpeg -loglevel error -y -i "$f" -ac 1 -ar 16000 "${f%.m4a}.wav"; done
ls */*.wav | wc -limport librosa, librosa.display, numpy as np, matplotlib.pyplot as plt
from pathlib import Path
ROOT = Path.home() / "sutraai"
files = sorted((ROOT / "data/raw/audio").glob("*/*.wav"))
by_label = {}
for f in files:
y, sr = librosa.load(f, sr=16000, mono=True)
by_label.setdefault(f.parent.name, []).append(len(y) / sr)
print({k: f"{sum(v)/60:.1f} min in {len(v)} files" for k, v in by_label.items()})
fig, axes = plt.subplots(1, len(by_label), figsize=(5 * len(by_label), 3), squeeze=False)
for ax, label in zip(axes[0], by_label):
f = next(p for p in files if p.parent.name == label)
y, _ = librosa.load(f, sr=16000, duration=3)
S = librosa.amplitude_to_db(np.abs(librosa.stft(y, n_fft=512, hop_length=160)), ref=np.max)
librosa.display.specshow(S, sr=16000, hop_length=160, x_axis="time", y_axis="hz", ax=ax); ax.set_title(label)
plt.show()2NB31: MFCC from scratch
Five steps turn 1 second of sound into a 50×13 "fingerprint". Plot the output of each step. That is the fastest way to understand it.
import numpy as np
def hz_to_mel(f): return 2595 * np.log10(1 + f / 700)
def mel_to_hz(m): return 700 * (10 ** (m / 2595) - 1)
def mel_filterbank(sr, n_fft, n_mels):
mels = np.linspace(hz_to_mel(0), hz_to_mel(sr / 2), n_mels + 2)
bins = np.floor((n_fft + 1) * mel_to_hz(mels) / sr).astype(int)
fb = np.zeros((n_mels, n_fft // 2 + 1))
for m in range(1, n_mels + 1):
l, c, r = bins[m - 1], bins[m], bins[m + 1]
if c > l: fb[m - 1, l:c] = (np.arange(l, c) - l) / (c - l)
if r > c: fb[m - 1, c:r] = (r - np.arange(c, r)) / (r - c)
return fb
def mfcc(x, sr=16000, frame_ms=20, stride_ms=20, n_fft=512, n_mels=32, n_coef=13, preemph=0.97):
x = np.asarray(x, dtype=np.float64)
if preemph:
x = np.append(x[0], x[1:] - preemph * x[:-1]) # 1 pre-emphasis
flen, step = sr * frame_ms // 1000, sr * stride_ms // 1000
n = 1 + (len(x) - flen) // step
idx = np.arange(flen)[None, :] + step * np.arange(n)[:, None]
frames = x[idx] * np.hamming(flen) # 2 frames + window
power = np.abs(np.fft.rfft(frames, n_fft)) ** 2 / n_fft # 3 power spectrum
logmel = np.log(power @ mel_filterbank(sr, n_fft, n_mels).T + 1e-10) # 4 log mel energies
k = np.arange(n_mels)
dct = np.cos(np.pi / n_mels * (k[None, :] + 0.5) * np.arange(n_coef)[:, None]) # 5 DCT-II
return (logmel @ dct.T).astype(np.float32) # [frames, n_coef]%load_ext autoreload
%autoreload 2
import sys, numpy as np, librosa, matplotlib.pyplot as plt
from pathlib import Path
ROOT = Path.home() / "sutraai"; sys.path.insert(0, str(ROOT))
from sutra_core.dsp import mfcc
wav = sorted((ROOT / "data/raw/audio/rose_ringed_parakeet").glob("*.wav"))[0]
x, _ = librosa.load(wav, sr=16000, mono=True)
x = x[:16000] # exactly 1 second
M = mfcc(x)
print(M.shape) # expect (50, 13)
plt.imshow(M.T, aspect="auto", origin="lower"); plt.xlabel("frame (20 ms)"); plt.ylabel("coefficient"); plt.colorbar(); plt.show()3Compare with librosa, then save golden vectors
librosa uses slightly different defaults, so the numbers won't be equal. With matching framing, each coefficient should follow the same pattern over time (high correlation). Exact equality only matters between this Python and your future C code, and the golden vectors pin that down.
ours = mfcc(x, n_fft=320, preemph=0.0) # same 320-sample frames as librosa below
ref = librosa.feature.mfcc(y=x, sr=16000, n_mfcc=13, n_fft=320, win_length=320, hop_length=320,
n_mels=32, center=False, htk=True, window="hamming").T
n = min(len(ours), len(ref))
corr = [np.corrcoef(ours[:n, k], ref[:n, k])[0, 1] for k in range(13)]
print("per-coefficient correlation:", np.round(corr, 3), "| mean", round(float(np.mean(corr)), 3))
gold = ROOT / "exports/golden"; gold.mkdir(parents=True, exist_ok=True)
rng = np.random.default_rng(0)
for i, start in enumerate(rng.integers(0, max(1, len(x) - 16000), size=3)):
seg = x[start:start + 16000]
np.savez(gold / f"mfcc_{i}.npz", x=seg.astype(np.float32), y=mfcc(seg),
params=np.array([16000, 20, 20, 512, 32, 13], dtype=np.int32))
print(sorted(p.name for p in gold.iterdir()))4NB32: build windows with noise augmentation (Day 12)
%load_ext autoreload
%autoreload 2
import sys, json, random, torch, numpy as np, librosa, torch.nn as nn
from pathlib import Path
ROOT = Path.home() / "sutraai"; sys.path.insert(0, str(ROOT))
from sutra_core.dsp import mfcc
from sutra_core.models import Normalize, Conv1DNet
from sutra_core.train import fit_arrays, accuracy
SR, WIN, HOP = 16000, 16000, 8000 # 1 s windows, 0.5 s stride
files = sorted((ROOT / "data/raw/audio").glob("*/*.wav"))
labels = sorted({f.parent.name for f in files})
noise_clips = [librosa.load(f, sr=SR)[0] for f in files if f.parent.name == "noise"]
def mix_noise(clip, noise, snr_db):
noise = np.resize(noise, clip.shape)
p_c, p_n = np.mean(clip ** 2) + 1e-12, np.mean(noise ** 2) + 1e-12
return clip + noise * np.sqrt(p_c / (p_n * 10 ** (snr_db / 10)))
random.seed(42)
split_of = {f: random.choices(["train", "val", "test"], weights=[70, 15, 15])[0] for f in files} # split by FILE
def build(split, augment=False):
X, y = [], []
for f in files:
if split_of[f] != split: continue
sig, _ = librosa.load(f, sr=SR, mono=True)
for s in range(0, len(sig) - WIN + 1, HOP):
w = sig[s:s + WIN]
X.append(mfcc(w)); y.append(labels.index(f.parent.name))
if augment and f.parent.name != "noise" and noise_clips:
X.append(mfcc(mix_noise(w, random.choice(noise_clips), random.uniform(5, 20))))
y.append(labels.index(f.parent.name))
return np.stack(X).astype(np.float32), np.array(y)
Xtr, ytr = build("train", augment=True); Xva, yva = build("val"); Xte, yte = build("test")
mu, sd = Xtr.mean(axis=(0, 1)), Xtr.std(axis=(0, 1)) + 1e-6
print(Xtr.shape, Xva.shape, Xte.shape, labels)torch.manual_seed(42)
model = nn.Sequential(Normalize(mu, sd), Conv1DNet(13, len(labels)))
model, val_acc = fit_arrays(model, Xtr, ytr, Xva, yva, epochs=100, lr=5e-3)
test_acc = accuracy(model, Xte, yte)
print("audio test accuracy:", round(test_acc, 3))5Export int8 and save metrics
from sklearn.metrics import confusion_matrix
EXP = ROOT / "exports"
model.eval().cpu()
torch.onnx.export(model, torch.randn(1, 50, 13), str(EXP / "audio_v1.onnx"),
input_names=["input"], output_names=["logits"], opset_version=17, dynamo=False)
np.save(EXP / "calib_audio.npy", Xtr[np.random.default_rng(0).choice(len(Xtr), 200, replace=False)])
from sutra_core.export import onnx2tf_int8 # helper from Step 05 (PATH fix + per-tensor int8)
tfl = onnx2tf_int8(EXP / "audio_v1.onnx", EXP / "audio_v1_tf", EXP / "calib_audio.npy", "[[[0.0]]]", "[[[1.0]]]")
print(tfl.name, round(tfl.stat().st_size / 1024, 1), "KB")
with torch.no_grad(): pred = model(torch.tensor(Xte)).argmax(1).numpy()
(ROOT / "reports/audio_v1").mkdir(parents=True, exist_ok=True)
json.dump({"run": "audio_v1", "classes": labels, "val_acc": val_acc, "test_acc": test_acc,
"confusion_matrix": confusion_matrix(yte, pred).tolist()},
open(ROOT / "reports/audio_v1/metrics.json", "w"), indent=2)Converting in Colab (Step 05, Option A)? Upload the .onnx and calibration .npy files, run onnx2tf -i … -o … -oiqt -qt per-tensor -cind input … there with the same mean and std, then copy the *_tf folder back into ~/sutraai/exports/.
Check the int8 accuracy with tflite_accuracy from Step 05 (input Xte, already in [N, 50, 13] layout; check the converted input shape first).
Test Test it
# NB31
assert M.shape == (50, 13), M.shape
assert np.mean(corr) > 0.9, "MFCC pattern differs from librosa: check frame length and mel formula"
assert len(list(gold.glob("mfcc_*.npz"))) >= 3
# NB32
assert test_acc >= 0.80, f"audio test accuracy {test_acc:.3f}: add more recordings, especially of the confused class"
print("✅ audio pipeline OK")Done Done when
- 15+ minutes per class, all 16 kHz mono WAV
- Spectrograms look different per class
mfcc()in sutra_core/dsp.py; output (50, 13) per second- Correlates with librosa; 3 golden vector files saved
- Test accuracy ≥ 80% with noise augmentation
- int8 TFLite exported; drop under 3 points
If it breaks
| Symptom | Fix |
|---|---|
| librosa import error | pip install librosa soundfile; on aarch64 numba may take a while to install. |
| Birds confused with noise | Trim long silences out of the bird recordings; record more calls; check the confusion matrix row by row. |
| Correlation low | Make sure you compared with n_fft=320, preemph=0.0 and librosa center=False, htk=True. |
| Very slow window building | Cache MFCC arrays to data/processed/audio_v1.npz once, like Step 06. |
Learn more
- The Sound of AI (YouTube, Valerio Velardo): "Audio Signal Processing for ML" series, especially the MFCC episodes
- librosa.feature.mfcc reference
- Reference: Edge Impulse bird sound classifier