Models & benchmarks
Training report
Training tenms-gru-v1 on a laptop: the model, the data, the run and what the benchmark shipped.
Run: 2026-11-05 23:51 → 2026-11-06 ~11:45 IST, on one Apple M4 Max. Status: evaluated, not deployed.
This report covers how we trained the first real Tenms model, what it learned, and what the
benchmark decided to ship. Every number comes from the run logs (training/runs/mac/), the
Tenms Arena results (bench/results/*/2026-11-06.json, bench/results/LEADERBOARD.md) or the
exported model metadata. The figures are generated from those logs by
training/scripts/plot_training_report.py.
TL;DR
- One small model, four jobs. A causal 2-layer GRU (H = 128, int8, ~0.22 MB) with four output heads: Focus (noise/voice mask), Turn (voice activity, end of turn, interruptions), Pulse (call quality) and speaker (embedding). It runs every 5 ms inside the Tenms engine.
- Trained in about 10 hours on a laptop GPU (Apple Metal), on ~1.9 M simulated 8-second phone calls (≈ 4,200 h of audio). int8 quantisation lost only 0.013 dB of output quality.
- Fast: p99 48 µs per 10 ms frame (77 µs with echo cancellation). That is 5% of the 1 ms budget, with 8 ms added latency.
- The benchmark shipped only one head. The Focus head wins for audio people listen to: it removes 2× more background than our DSP (12.1 vs 5.9 dB) at 8 ms, against FastEnhancer’s 27–30 ms. It loses on audio sent to speech recognition. The Turn head has the best voice detection we have measured (F1 0.803), but it is too slow to decide end of turn. Pulse came second. The speaker head never learned.
1. Why train our own model
Tenms runs in the audio path of voice agents (Pipecat, LiveKit, ElevenLabs) under hard constraints: CPU only, a 30 ms hard / < 10 ms ideal latency budget, and < 1 ms of CPU per 10 ms frame. Our signal-processing (DSP) engine meets those numbers. But a listening test showed its weakness: on a call with background music it removed only 0.3–0.5 dB. Classic noise suppression tracks steady noise, and music and babble are not steady.
The open neural enhancers we benchmarked do remove music, but with 27–58 ms of delay, and every one of them made speech recognition worse. A model of our own can run inside the existing 5 ms STFT frame, with no added window. That is the only route to removing music under 10 ms.
2. The model
mic (16 kHz) → STFT (10 ms window, 5 ms hop, 81 bins) → log-power features (81)
→ Linear(81→128) + ReLU → GRU(128) → GRU(128) causal, one step per 5 ms hop
→ heads: focus 81 → sigmoid gain mask per frequency bin
turn 4 → speech, end-of-turn-within-200 ms, backchannel, non-speech event
pulse 6 → SNR, MOS, ASR-risk, overlap, echo, dropout
speaker 32 → L2-normalised speaker embedding
| Property | Value |
|---|---|
| Parameters | ~250 k |
| File | TNW1 format, int8 weights with per-tensor scales, f32 biases, JSON header with training metadata |
| Size on disk | 0.22–0.23 MB per file (models/tenms-gru-v1-mac*.tnw) |
| Latency added | none beyond the engine’s 5 ms STFT (8 ms total measured at 8 kHz) |
| CPU, p99 per 10 ms frame (1 thread, M4 Max, quiet machine) | 47.6 µs model, 77 µs model + AEC (DSP: 7.3 µs, 39.4 µs with AEC) |
| Rust vs PyTorch parity | max abs difference 1.6 × 10⁻⁵ (target 10⁻³) |
The engine uses a head only if it is present in the file. Each head can ship or be left out on its own, and anything left out falls back to DSP.
3. Data
All training data is commercially licensed: CC BY 4.0 or Apache 2.0, built with
prepare --commercial-only. Nothing the benchmark tests on is in the training set
(training/runs/mac/heldout_check.json, passed: true).
| Source | Used for | Amount | Kept out of training |
|---|---|---|---|
| LibriSpeech train-clean-100 | caller, interfering and agent speech | 100.6 h, 251 speakers | all test-clean files and all 40 Arena speakers (0 overlap) |
| LibriSpeech dev-clean | validation only | 5.4 h, 40 speakers | disjoint from train and test |
| AMI meetings, headset audio (16 ES/IS/TS meetings) | real turn ends, pauses, 2,234 backchannels | 38.1 h | EN2002 meetings (the Arena’s real-speech turn set) and their 4 participants |
| MUSAN music + noise | background music, noise | 42.6 h + 6.2 h | — (not used by the Arena) |
| OpenSLR 28 | room impulse responses, point-source and isotropic noise | 53.7 k RIRs, 4.6 h noise | the Arena’s 940 files (315 noises, 625 RIRs) |
The call simulator builds every training example on the fly:
- A main speaker, plus 1–3 interfering talkers at 0–20 dB SIR half the time.
- Noise or music at −5 to 30 dB SNR (90% of calls).
- Room reverberation, and agent echo through a random echo path.
- Telephony codecs (G.711, AMR-NB style band-limiting, real Opus), packet loss and clipping.
Labels are computed from the clean sources:
- Focus: an ideal mask for the main speaker only.
- Turn: foreground voice activity (only the dominant speaker counts), end-of-turn targets, pauses and backchannels.
- Pulse: quality targets.
ASR-risk labels for this run came from a simulator proxy (SNR, codec, loss, reverb), not from real Deepgram transcripts.
4. Training setup
| Hardware | Apple M4 Max, PyTorch MPS (Metal) fp32; 10 CPU workers running the simulator |
| Optimiser | AdamW, lr 1e-3, 500-step warmup, cosine decay to 2% |
| Sequence | 8 s calls, truncated backprop over 400 frames (2 s) |
| Batch | 128 for the first 500 steps, then 192 (one deliberate restart for the change) |
| Steps | 10,000 ≈ 1.9 M simulated calls ≈ 4,200 h of audio |
| Focus loss | SI-SDR + mask MSE + over-suppression penalty, with the penalty weight doubled to protect speech |
| Wall clock | 9 h 57 min training + 1 h int8 fine-tune |
| Not done in this run | teacher model, distillation, Deepgram-labelled ASR targets |
Throughput: the GPU waited on data.
- A GPU step took about 2.7 s at any batch size. The bottleneck was the CPU simulator generating calls: 0.14 s per 8 s call per core when idle, 0.23–0.38 s on the shared machine.
- Fixes made during the run:
- An in-process Opus codec; spawning ffmpeg had been half of the simulator’s time.
- Random crops from long music files instead of caching each file whole.
- Lower prefetch.
- A cache fix for macOS worker processes.
- Raising the batch to 192 brought throughput to 41–58 examples/s (3.3–4.7 s per step).
- The 15,000 steps originally planned would have taken 15–20 h, so the run was cut to 10,000.
5. Training progress

The training loss fell from 2.30 (first-100-step average) to 0.96 with no divergence or NaNs. Most of the drop came in the first 500 steps, followed by a slow, steady decline.

Validation ran on held-out simulated calls (dev-clean speakers, dev-split noises and rooms) every 1,000 steps:
| Step | Focus output SI-SDR | Pulse loss | Turn loss | Speaker AAM loss |
|---|---|---|---|---|
| 1,000 | 1.90 dB | 0.925 | 0.588 | 4.99 |
| 3,000 | 2.24 dB | 0.796 | 0.576 | 4.66 |
| 6,000 | 2.89 dB | 0.729 | 0.569 | 4.94 |
| 10,000 | 3.05 dB | 0.701 | 0.569 | 5.18 |
- Focus improved steadily (+1.15 dB), and was still improving slightly at the end.
- Pulse improved steadily (−24% loss).
- Turn plateaued after about 4,000 steps.
- The speaker head never learned. Its loss stayed at chance (ln 192 ≈ 5.26), because about 250 training speakers at batch 192 is far too few for this kind of embedding loss.
- One side effect of the speaker head: it dominated the total validation loss, so the “best” checkpoint froze at step 3,000. The int8 fine-tune therefore started from step 10,000, the best checkpoint on every other head.

The learning-rate schedule ran as configured. The gradient norm stayed between about 0.8 and 1.0 throughout, with no spikes.
6. int8 quantisation

A 1,000-step quantisation-aware fine-tune at lr 1e-4 used int8 fake-quantisation per tensor.
| fp32 | int8 | Drop | Gate | |
|---|---|---|---|---|
| Focus SI-SDR, 64 held-out calls | 2.050 dB | 2.037 dB | 0.013 dB | < 0.2 dB ✅ |
7. Results: the Arena decides
The Tenms Arena tests every candidate on frozen evaluation sets it never trains on. It runs each one on the same CPU with one thread and measures downstream outcomes such as Deepgram word error rate, not only signal metrics. A head ships only if it beats the current default within the latency and CPU budget.
7.1 Focus, audio sent to speech-to-text (the voice-agent path)

| Candidate | WER, noise set (77 clips) | WER, echo set with reference (33 clips) | Signal quality (SI-SDR) |
|---|---|---|---|
| No processing (winner) | 0.207 | 0.331 | — |
| Tenms DSP, stt profile | 0.211 | 0.274 | 1.10 dB |
| Tenms trained model, stt | 0.240 | 0.282 | 2.97 dB |
| GTCRN (over latency budget) | 0.279 | — | — |
| DeepFilterNet3 (over latency budget) | 0.356 | — | — |
The model produces much cleaner audio (2.97 vs 1.10 dB SI-SDR) yet more recognition errors. On clean speech it is identical (0.047). The losses come from babble (0.509 vs 0.444) and echo (0.340 vs 0.280). It is suppressing cues the recogniser uses. This is the “enhancement hurts ASR” effect reported in recent research (see §9). Decision: don’t ship for STT.
7.2 Focus, audio people listen to

| Candidate | Background removed in pauses | Music / babble | Quality (UTMOS) | Speech loss | Added latency | CPU p99 |
|---|---|---|---|---|---|---|
| Tenms DSP, listening | 5.9 dB | 7.4 / 3.2 dB | 1.497 | 1.8 dB | 8 ms | 0.02 ms |
Tenms trained model (listening_mini) | 12.1 dB | 13.3 / 5.8 dB | 1.545 | 2.4 dB | 8 ms | 0.061 ms |
FastEnhancer-M (listening_pro, opt-in) | 37.6 dB | 43.8 / 13.2 dB | 2.12 | 3.5 dB | 27–30 ms | 0.8 ms |
| DPDFNet-4 (rejected: latency) | 42.1 dB | — | 2.25 | 3.7 dB | 55–58 ms | — |
Decision: ship for the listening profile - as the opt-in listening_mini Focus profile
(models/tenms-mini-focus-v1.tnw, the Focus-only file, sha256-pinned; Edge, CLI, Python SDK).
- Within the budget, the trained model is the winner: it doubles background removal against DSP at the same 8 ms.
- FastEnhancer-M removes far more, but needs 27–30 ms and about 15× the CPU. That keeps it an
opt-in mode (
listening_pro) for recordings and listen-in.
7.3 Turn: voice activity and end of turn

| Metric | Tenms DSP | Silero VAD v6 | Smart Turn v3.2 | Tenms trained model |
|---|---|---|---|---|
| Voice activity F1 | 0.742 | 0.797 | — | 0.803 (AUC 0.860, onset 30 ms) |
| End-of-turn score | 0.689 | — | 0.745 | 0.763 (real-speech accuracy 0.879, best measured) |
| Mean end-of-turn decision delay | 633 ms | — | 336 ms | 1,158 ms (budget 600 ms) |
| Barge-ins detected (96) | 96 | — | — | 78 |
| Acknowledgements kept as backchannels (144) | 140 | — | — | 140 |
| False events from echo alone, 0–1000 ms (396 runs) | 0 | — | — | 0 |
Decision: don’t make it the default Turn head.
- It has the best voice detection and end-of-turn accuracy we have measured.
- But it waits too long to decide (1.16 s), and it misses 18 of 96 barge-ins.
- The engine can’t yet take only the head’s speech probability, which is the part that wins. Adding that option is the next step.
7.4 Pulse: call quality
| Candidate | Correlation with recognition degradation (SRCC) | Correlation with SNR | Cost |
|---|---|---|---|
| UTMOS22 (winner) | 0.479 | — | real-time factor 0.046 |
| Tenms trained model | 0.447 | 0.78–0.79 (best of any row) | real-time factor 0.004 (12× cheaper) |
| Tenms DSP Pulse | 0.128 | — | ~0 |
Decision: don’t ship by the rule (runner-up).
- It is 3.5× better than DSP Pulse, and it fixes the SNR estimate that was badly wrong in the listening test.
- It’s a strong candidate for a product decision outside the strict rule.
7.5 Speaker
The speaker head scored EER 0.468, worse than the DSP baseline’s 0.426, so it doesn’t ship. CAM++ (EER 0.133 on demand) already wins this task.
8. Ship decision
| Head | Ships? | Why |
|---|---|---|
| Focus, STT path | ❌ | WER 0.240 vs 0.207 unprocessed / 0.211 DSP |
| Focus, listening path | ✅ ships as the opt-in listening_mini profile | 2× DSP background removal at 8 ms, best quality within budget |
| Turn | ❌ default; ⚙️ experiments | best VAD and EOT accuracy, but 1.16 s EOT delay and missed barge-ins |
| Pulse | ❌ by rule (runner-up) | 3.5× DSP, 12× cheaper than the winner; a product decision |
| Speaker | ❌ | never learned |
Shipped file: models/tenms-mini-focus-v1.tnw (= tenms-gru-v1-mac-focus.tnw, Focus head only) for
the listening_mini profile; Turn, Pulse and the speaker lock stay DSP there. Experiments can use
models/tenms-gru-v1-mac-fpt.tnw (Focus + Turn + Pulse; local, not committed).
9. Listen to it
The listening page plays six simulated noisy calls through every version, with Deepgram
transcripts and word error rates: unprocessed, Tenms DSP, FastEnhancer listening_pro, the
trained model and the clean original. On the music clip the trained model removes 28 dB,
against 0.4 dB for DSP.
| Clip | DSP stt: WER / background removed | Trained model, stt: WER / background removed |
|---|---|---|
| Heavy noise / music (−4.6 dB) | 0.000 / 0.4 dB | 0.000 / 28.0 dB |
| Heavy noise (−4.2 dB) | 0.000 / 0.2 dB | 0.045 / 19.6 dB |
| 8 kHz phone + background talker | 1.000 / 0.7 dB | 1.000 / 10.3 dB |
| Talker + echo, no reference | 0.500 / 4.5 dB | 0.571 / 6.9 dB |
| Agent echo, with reference | 0.250 / 4.1 dB | 0.250 / 11.5 dB |
| Agent echo, second clip | 0.625 / 10.4 dB | 0.625 / 10.5 dB |
10. Lessons
-
Cleaner is not the same as more recognisable. Our model nearly triples SI-SDR on STT audio and still raises WER. Recent work explains why:
- over-suppression removes low-energy cues the recogniser relies on;
- remixing a little of the original signal back in, or training against recogniser- or SSL-representation losses, closes the gap.
Sources: arXiv 2607.11157, 2512.17562, 2602.20967, 2507.07631, 2609.11092.
-
On a laptop the simulator is the bottleneck, not the GPU. Moving codec work in-process and cropping long music files mattered more than batch size.
-
One model, four heads, per-head shipping works. We get the Focus win now without being held back by the heads that lost.
-
Watch what dominates your validation loss. A head stuck at chance froze the “best” checkpoint. Pick checkpoints per head, or exclude heads that aren’t learning.
-
Measure the outcome you sell. Signal metrics alone would have shipped the STT Focus head. The Arena’s downstream-WER rule stopped that.
11. Next steps
- Focus for STT:
- an ASR-aware loss (Deepgram-labelled shards via
label.py, or an SSL-representation loss); - an adaptive mask floor and remix in the engine.
- an ASR-aware loss (Deepgram-labelled shards via
- Turn:
- an engine option to use only the model’s speech probability, which keeps the DSP interruption logic;
- tune the end-of-turn threshold and minimum silence to bring the delay under 600 ms;
- more barge-in-shaped training examples.
- Speaker: many more speakers (LibriSpeech 360/500, VoxCeleb-style data), or keep CAM++.
- Scale:
- more data (train-clean-360, DNS noise, Fisher and Switchboard for telephone turn-taking);
- the full GPU recipe: teacher, distillation, 300 k steps (
training/GPU_RUNBOOK.md).
- Real calls: design-partner recordings, the single biggest expected improvement.