YAML Metadata Warning:empty or missing yaml metadata in repo card

Check out the documentation for more information.

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.

Downloads last month
321
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support