File size: 14,731 Bytes
6ef7476
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1671be
6ef7476
a1671be
6ef7476
 
 
a1671be
6ef7476
a1671be
 
 
6ef7476
a1671be
 
 
 
 
 
 
 
 
 
 
6ef7476
 
a1671be
 
 
6ef7476
 
 
 
 
 
 
 
 
 
 
 
 
a1671be
6ef7476
 
 
a1671be
6ef7476
 
 
 
 
 
 
 
 
a1671be
 
6ef7476
 
 
 
 
 
 
 
a1671be
6ef7476
 
 
a1671be
6ef7476
a1671be
6ef7476
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1671be
 
6ef7476
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1671be
 
 
 
 
 
 
6ef7476
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
# Model card β€” `dscnn_fan` int8 (voice-fan-controller)



The deployed model: 18-class spoken-command recognition (16 fan commands +

`_silence_` + `_unknown_`) running on a plain ESP32-WROOM-32. **28,584 bytes**,

int8, no accelerator. Input is a (81, 32) log-mel plane = 2,592 floats; output is

an 18-way softmax.



The flashed image does **not** run TensorFlow Lite Micro. It runs a generated

static-C engine (`dscnn_micro`) that reproduces the same graph β€” verified against
the TFLite interpreter at **0 prediction flips over 200 clips**, pre-softmax
logits within 1 LSB. A TFLM build still exists behind the same C API and is
selectable at menuconfig, but it is not what ships.

The hard requirement is **speaker independence** β€” it has to work for someone who
never recorded a clip. Everything below is organised around how well it does
that, and around not quoting numbers that measure something easier.

> The retired `func_mlp` model card is at `archive/MODEL_CARD_func_mlp.md` in
> this model repo (`docs/archive/MODEL_CARD.md` in the code repo). Its accuracy

> numbers do not apply to this model β€” they are in-speaker.



## The number that matters



**Leave-one-speaker-out (LOSO)** β€” score each speaker with a model that never

heard them. It is the only honest metric here, and it belongs to a **recipe**,

not to a weights file: the shipping model trains on everybody, so it has no

held-out voice of its own left to be measured against.



The **deployed** model's own recipe (`c48_rio15` β€” all voices, two-stage
transfer with BN frozen, `domain_aug=0.5`, encoder widened to 48 channels;
measured 2026-07-28) reaches **91.69% headline**, 5 seeds per fold:

| anirudh | austin | francis | mishelle | headline |
|---|---|---|---|---|
| 91.34% | 93.86% | 95.55% | **86.00%** | **91.69%** |

That is **+3.25pp** over the last `func_mlp`-era shipping recipe (88.44%),
against 1 SE of 1.81pp β€” clear of the pre-registered accept bar (β‰₯2pp *and*
β‰₯1 SE).

Against the *immediately* preceding recipe (`c48_deployable`, 91.37%) it is only
**+0.32pp** β€” inside noise. The two differ by corpus, not by code: three more
speakers' worth of recordings (+710 clips). It is reported as a corpus refresh,
not as a lever that cleared a bar.

Eleven further complete speakers are scored outside the headline. Averaged over
all **fifteen** full-coverage speakers β€” the widest "what a stranger gets" view β€”
it reads **90.05%** (SE 2.27pp), and its **worst voice is 66.85%** (aak). Read
the worst, not the mean: this refresh raised the average while *lowering* the
hardest voice by 1.98pp, which is precisely the failure mode the worst-voice
rule exists to catch. The full spread, weakest first:

```

aak 66.9 Β· savio 74.0 Β· mishelle 86.0 Β· rio 86.5 Β· allen 90.5

anirudh 91.3 Β· adithyasharma 92.9 Β· alwin 94.1 Β· austin 93.9 Β· anon1 93.3

anon2 95.0 Β· danushkrishna 95.4 Β· francis 95.6 Β· adithya 97.0 Β· teresa 98.4

```

**The width helped studio voices and hurt browser ones.** Widening the encoder
raised every studio-corpus fold (+1.53 to +5.64pp) but *lowered* all three small
browser-recorded folds (adithyasharma βˆ’5.93, anon1 βˆ’2.25, anon2 βˆ’6.22). That is
roughly 14pp of fold-sum, and it is the whole reason this model lands just under
90%. A headline win does not automatically transfer to strangers on a laptop mic.

A matched control run separates the two things that changed: the extra recorded
data is worth **+0.88pp** on its own (a reject as a lever), and capacity alone is
**+2.04pp**. The width is the real lever, and it clears the 2pp bar narrowly β€”
not by a comfortable margin.

(The fifteen-speaker average is not comparable to older fourteen- or seven-speaker
figures β€” the corpus grew, so the same statistic now covers a different
population. Only the frozen headline four are comparable across runs.)

**In-speaker validation for the deployed model is 97.47% and is not a

generalization estimate.** Every held-out clip there belongs to a speaker who is
in training β€” by design, since nobody is held out of the shipping model. The gap
between the two kinds of number is the entire point of this project: an earlier
architecture scored ~95% in-speaker and **43.7%** LOSO. If you quote one number,
quote a LOSO one.

## Limitations, in order of how likely they are to bite

1. **Pronunciation, not channel, is what sinks the worst voice.** aak sits at
   66.85% β€” twenty-three points below the mean, and moving the wrong way across the
   last two recipes β€” and the errors are *within-word*
   confusions, not noise. A channel probe put that speaker at z=0.98 (unremarkable
   recording conditions), and cepstral mean normalisation made things worse by
   2.8pp, so the gap is genuinely acoustic: those pronunciations land near the
   wrong class in feature space. More dense recordings of the weak voices is the
   only lever with evidence behind it; roughly eight code-side levers have been
   tried and spent.
2. **Vocal register is the second axis.** Nearly every training speaker sits at
   107–149 Hz median F0; one sits at 255.7 Hz, roughly an octave up. That speaker
   is still the weakest headline fold (86.00%) and is the only one register
   augmentation helps. Expect degradation for voices outside the trained range,
   which includes most children. The headline averages three men and one woman,
   so it *understates* this failure mode.
3. **English only, one accent pool, 18 speakers.** The encoder is pretrained on
   9,286 outside speakers, but fine-tuning corpus diversity is small β€” and four
   of the scored fifteen contributed only ~16–20 clips each, so their coverage is
   nominal rather than substantial. Those tiny folds are honest (genuinely held
   out) but noisy: one misheard clip moves such a fold by ~6pp.
4. **`timer_*` is the weakest command group.** Public pretraining corpora

   contain ten instances of the word "timer" in total, so transfer cannot teach

   it β€” it is learned only from the recorded set.

5. **Siblings dominate the errors, and the gate cannot catch them.** 66.7% of all

   command errors confuse two parameters of the *same* command

   (`power_on`↔`power_off`, `speed_2`↔`speed_3`, `timer_2h`↔`timer_4h`) β€” errors

   that produce a plausible-looking wrong action rather than an obvious one. This

   was priced on silicon (see *Downstream gate*): the wrong answers are often

   delivered with **high** confidence, so no threshold separates them from correct

   ones.

6. **`_unknown_` has zero human speakers** β€” it is sourced from YouTube audio.

   Rejection of *speech* that is not a command is therefore weaker than the

   negative-class accuracy suggests.



## Feature contract β€” not optional



```

sample_rate 20 kHz Β· window 25.6 ms (n_fft 512) Β· stride 20 ms

mel_bins 32 Β· log-mel via power_to_db(ref=max, top_db=80) Β· input (81, 32, 1)

```



`ref=max` is a correctness requirement, not a tuning choice. The alternative

(`log(mel + 1e-6)`) has no amplitude normalisation, so raw int16 PCM on the

device shifts every feature by ~6Οƒ of pure recording level β€” frozen BatchNorm

cannot absorb it and the model runs while predicting nonsense. `window 25.6 ms`

gives `n_fft = 512` so the device can use a radix-2 FFT.



The C frontend is measured amplitude-invariant (0.25Γ— gain moves features by

0.0016 dB) and agrees with the training frontend to **<0.002 dB**.



## Training



DS-CNN (depthwise-separable conv stack, **672,672 MACs, 12,082 params**, 48

channels). The encoder is pretrained on Speech Commands v2 + MSWC β€” 112,049 clips

from 9,286 speakers, 28.1% female β€” then fine-tuned on **5,542 recorded clips
from 18 speakers**.



The 48-channel width is a re-pretrain, not a knob: the encoder weights are shaped

by `channels`, so widening it invalidates the previous encoder and forces a full

pretraining run before any comparison is fair. The previous 32-channel model was

6,706 params / 441k MACs / 20,632 bytes.



**Transfer is what makes this work: from-scratch on the same data scores 45.9%,
against 67.4% pretrained.** That +21.5pp is the single largest gain on record
here, and nothing else has come within 20pp of it. (That pair was measured with
an *earlier* encoder β€” `dscnn_fan_scv2`, 11,376 speakers β€” not the 9,286-speaker
`scv2_max` above. It is quoted as evidence that pretraining is what carries this
model, which is encoder-independent; it is not a measurement of the encoder
shipping today.)

The fine-tune is two-stage: 15 epochs with the encoder frozen so the randomly
initialised 18-class head cannot wash out the transferred filters, then unfreeze
at `lr 3e-4` with **BatchNorm statistics frozen** β€” those were estimated over
9,286 speakers and re-estimating them from a handful of fold voices reintroduces
the per-channel speaker signature the pretraining was for.

Two train-time-only augmentations, neither of which costs anything on device:

- **register augmentation** (Praat "Change gender", moving F0 and formants
  independently) β€” covers the register axis the corpus barely spans;
- **domain augmentation** (reverb + bandlimit + Β΅-law codec round-trip) β€” added
  for the deployed model to close the browser-microphone gap. **It has still

  never been isolated in a controlled run**, so it remains a bet, and it is
  labelled as one. What *is* known is that the gap is not closed: the three
  browser-recorded folds were the only ones the wider encoder made worse.

Training is seeded but **not bit-reproducible** β€” oneDNN varies float reduction
order across threads, moving single folds by up to 5.1pp between identical runs.
Compare 5-seed means, not folds.

## On device

Measured on a real ESP32-D0WD-V3 from the **2026-07-27 flashed image**. That
image carries the *previous* weights (`c48_deployable`); the model described by
this card is built but **not yet flashed**. Every number in this section is a
property of the graph, not of the weights β€” identical shapes, ops, parameter
count and MACs β€” so it carries over unchanged. The accuracy numbers above do
not: those belong to `c48_rio15`, and the board has not yet been asked to
reproduce them.

| | | previous (c32, TFLM, single-core) |
|---|---|---|
| frontend | **18.6 ms** | 45.3 ms |
| invoke | **34.2 ms** | 49.75 ms |
| total | **~53 ms** (after a 1,600 ms capture window) | ~95 ms |
| tensor arena | **13,184 B** (static engine) | 17,324 B used / 20,480 reserved |
| flash | **28,584 B** | 20,632 B |

**Do not credit that speedup to the model.** This image is ~1.8Γ— faster while
computing **+53% more MACs**, because two other things changed at the same time:
the static-C engine replaced TFLM, and both the STFT and the conv stack now use
the second core. The wider model taken alone would have been *slower*. Two
variables moved; the comparison is reported, not attributed.

The arena is unchanged at 13,184 B despite the wider layers, which is arithmetic
rather than luck: the peak simultaneously-live tensor pair is the input features
plus the stem output, and `channels` does not touch `stem_channels=16`.

Ops: CONV_2D, DEPTHWISE_CONV_2D, MAX_POOL_2D, RESHAPE, FULLY_CONNECTED, SOFTMAX.
`MAX_POOL_2D` deliberately β€” `GlobalMaxPooling2D` lowers to `REDUCE_MAX`, which
has no ESP-NN kernel and fails `AllocateTensors()` at boot.

### Validated on silicon against a true stranger

This image was tested over UART with **180 clips from a speaker who is not in the

corpus** β€” recorded after the model was built, so it had genuinely never heard
him, and none of his audio influenced training:

| | top-1 (argmax) | actually fires (after the gate) |
|---|---|---|
| all 180 clips | 83.3% | 76.7% |
| excluding `speed_5` | **92.9%** | **89.0%** |
| `speed_5` alone | 24.0% | 0.0% |

Seven of his eight recorded classes land between 84% and 100%. One collapses:
16 of 25 `speed_5` clips are heard as `speed_3`. That is not a mislabelled
recording β€” his `speed_5` runs 0.650 Β± 0.044 s against `speed_3`'s
0.787 Β± 0.135 s, a consistent and distinct utterance the model maps onto the
wrong class.

**Read the whole distribution, not the headline.** A single class failing this
way is invisible in an averaged score and obvious to the person using the fan.

(An earlier image was validated the same way against a different unseen speaker
at 96.5% over 400 clips. She is an easy voice β€” her own held-out fold
independently scores ~96% β€” so that number is evidence the deployment path is
correct, not a generalization estimate.)

An integer (Q15) build of the frontend also exists, verified accuracy-neutral
(0/200 model predictions changed), which removes the FPU requirement for cheaper
targets. It is staged, not flashed.

## Downstream gate

The classifier output is not the device's decision. A confidence gate
(`command_postprocessor`) suppresses low-confidence and ambiguous results before
any UART frame is emitted. The shipped point is **`conf 0.60`**, and it is a real
trade: at 0.60, 23.1 of every 100 commands from a new speaker are silently ignored
against 4.2 wrong actions; at 0.40 that is 11.3 against 8.2. On held-out speakers
saying *non-command* words, false-accept is 22.0% at 0.60 against 50.5% at 0.40.
Pick the point on training speakers, never on the held-out set.

**The gate cannot fix a misclassification, and this is now measured rather than

argued.** All 12 of the stranger's wrongly-fired clips above were sibling
confusions, with top-1-minus-top-2 margins of 0.348, 0.528, 0.574, 0.586, 0.598,
0.657, 0.711, 0.734, 0.742, 0.770, 0.773 and **0.949**. Tightening the sibling
margin to 0.35 would catch 1 of the 12; catching half needs ~0.65; catching all
needs >0.95, which would also reject legitimate commands. One clip was predicted
`speed_3` at 0.969 confidence.

A gate sees only the output distribution, and *wrong-and-certain* is
indistinguishable from *right-and-certain*. Thresholds trade coverage against
precision; they cannot recover information the model never had.

One consequence worth stating plainly: because `CP_MIN_MARGIN` (0.15) is below
`2 Γ— 0.60 βˆ’ 1 = 0.20`, and a softmax sums to 1, the global margin branch **cannot

fire at this operating point**. It is inert by algebra, not by intent, and no
compiler warns about a branch no input can reach.

## Intended use

A hobby/research voice→UART bridge for one fan model. Not suitable for safety-,
security- or accessibility-critical control, where a 5–20% miss rate on an
unfamiliar voice is not acceptable.