Instructions to use Austin207/voice-fan-controller-model with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Keras
How to use Austin207/voice-fan-controller-model with Keras:
# Available backend options are: "jax", "torch", "tensorflow". import os os.environ["KERAS_BACKEND"] = "jax" import keras model = keras.saving.load_model("hf://Austin207/voice-fan-controller-model") - Notebooks
- Google Colab
- Kaggle
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.
|