Per-window features and a tiny decision tree, from an 8-bit AVR to a Cortex-M4F
The main page makes the case in software; this page makes it on the metal. The whole appeal of statistical features is that they cost almost nothing: a few sums and powers over a window, no FFT, no filterbank, no transform of any kind. For a low-rate sensor stream that is cheap enough to keep up on any microcontroller, down to an 8-bit AVR with no hardware multiplier (only the 16 kHz audio gate strains the smallest parts, as the budget below shows). So this is the one embedded page in the track where the lesson is cross-tier feasibility, and per ADR-005 (amended) it uses the broader capability ladder rather than a single reference board.
The features here are the real features.h library from the HAN Embedded Machine Learning course (Veen & Arends), the same mean, variance, energy, and peak_to_peak that drive its accelerometer activity-classifier labs. We lead with that running example, an on-device motion classifier, then show the same functions doing audio voice-activity detection on the two ADR-005 default platforms.
The shape of the system
sensor ──▶ filter ──▶ rescale ──▶ window buffer ──▶ features ──▶ decision tree ──▶ label
(accel / (N samples) (variance, (a few
mic) energy, ...) comparisons)
A block of samples comes in, gets lightly conditioned, and fills a buffer. When the buffer is full, you compute a handful of features over it and feed them to a classifier that is nothing more than a sequence of threshold comparisons, a decision tree. The tree is trained offline (scikit-learn, a few dozen labelled windows) and its splits are exported as if statements. No model runs on the device; only the comparisons do.
The feature library in C
Each function is a single pass (variance takes two) over the window. They mirror statistical_features.py exactly, in portable float C that compiles unchanged from an ATmega328P to a Cortex-M4F.
#include <stdint.h>float mean(constfloat*x,uint32_t n){float s =0.0f;for(uint32_t i =0; i < n; i++) s += x[i];return s /(float)n;}// Two-pass, population variance. The two-pass form (subtract the mean, then sum// squares) is deliberate: the one-pass identity var = sumsq/n - mean^2 loses// precision catastrophically when the window has a large DC offset, which an// accelerometer always does (gravity is ~1 g of standing offset). Subtracting// the mean first keeps the summed terms small. Cheap insurance for one extra pass.float variance(constfloat*x,uint32_t n){float mu = mean(x, n);float s =0.0f;for(uint32_t i =0; i < n; i++){float d = x[i]- mu; s += d * d;}return s /(float)n;}float energy(constfloat*x,uint32_t n)// sum of squares{float s =0.0f;for(uint32_t i =0; i < n; i++) s += x[i]* x[i];return s;}float peak_to_peak(constfloat*x,uint32_t n){float lo = x[0], hi = x[0];for(uint32_t i =1; i < n; i++){if(x[i]< lo) lo = x[i];if(x[i]> hi) hi = x[i];}return hi - lo;}// The zero-crossing rate, for the audio voice gate below. A strict sign change// needs x[i]*x[i-1] < 0, so exact zeros are not miscounted as crossings.float zero_crossing_rate(constfloat*x,uint32_t n){uint32_t c =0;for(uint32_t i =1; i < n; i++)if(x[i]* x[i -1]<0.0f) c++;return(float)c /(float)(n -1);}
On a part with no FPU (the ATmega328P, or a Cortex-M0+) these compile to software-float, which is slower but still trivially within budget at sensor rates, as the budget section shows. On a Cortex-M4F (the NUCLEO boards) each multiply-accumulate is a single-cycle VMLA.F32.
Running example: an on-device activity classifier
This is the HAN course’s lab, and the genuinely useful one. An accelerometer streams at 100 Hz; you buffer a window (here N = 64, about 0.64 s), compute the per-axis variance, and a two-feature decision tree calls the motion. The variance of each axis is the whole story: a still sensor has near-zero variance on every axis, and the axis with the most variance names the dominant direction of motion.
The accelerometer values are first rescaled by the course’s affine map that sends the \(\pm 1000\) mg full-scale range to \([-1, 1]\) (it works out to a simple divide by 1000, i.e. milli-g to g), so the trained thresholds are unit-free. It is a scaling, not a clamp: a vigorous motion that exceeds 1 g overshoots \(\pm 1\), which is harmless for a variance threshold.
typedefenum{ stationary, up_down, left_right } activity_t;// A decision tree trained offline (scikit-learn) and exported as comparisons.// The thresholds come from labelled windows; these illustrative values match the// rescaled (milli-g / 1000) g-domain. Real splits are emitted by the training script.activity_t classify_motion(float x_var,float y_var){if(x_var <0.02f&& y_var <0.02f)// both axes quiet -> not movingreturn stationary;return(y_var > x_var)? up_down : left_right;// the busier axis wins}// Called once per full buffer (every N samples = 0.64 s at 100 Hz).activity_t on_buffer_full(constfloat*ax,constfloat*ay,uint32_t n){return classify_motion(variance(ax, n), variance(ay, n));}
The Python below is that exact classifier, checked against synthetic windows of the three motions. It both pins the recurrence (a still window classifies as stationary, an up-down shake as up_down, a left-right shake as left_right) and shows the variance separation the tree relies on.
import numpy as npfrom statistical_features import variancerng = np.random.default_rng(0)N, fs_imu =64, 100def rescale_mg(a): # course affine map of +/-1000 mg to [-1,1] (= a/1000)return2.0* (a +1000.0) /2000.0-1.0def window(kind, seed):"""A 0.64 s accelerometer window (x, y) in milli-g for one motion class.""" r = np.random.default_rng(seed) t = np.arange(N) / fs_imuif kind =="stationary": # gravity offset + tiny jitter x =50+5* r.standard_normal(N); y =980+5* r.standard_normal(N)elif kind =="up_down": # strong swing on y, quiet x x =50+8* r.standard_normal(N); y =980+400* np.sin(2*np.pi*2*t)else: # left_right: strong swing on x, quiet y x =50+400* np.sin(2*np.pi*2*t); y =980+8* r.standard_normal(N)return rescale_mg(x), rescale_mg(y)def classify_motion(x_var, y_var):if x_var <0.02and y_var <0.02:return"stationary"return"up_down"if y_var > x_var else"left_right"# every class, several trials each, classified from variance alonefor ci, kind inenumerate(("stationary", "up_down", "left_right")): preds = []for s inrange(8): ax, ay = window(kind, 100* s + ci) # deterministic per-trial seed preds.append(classify_motion(variance(ax), variance(ay)))print(f"{kind:11s} -> {set(preds)}")assertall(p == kind for p in preds) # every trial classified correctly# the separation the threshold exploits: still variance is orders below movingsx, sy = window("stationary", 1); mx, my = window("up_down", 2)print(f"still y-var {variance(sy):.4f} moving y-var {variance(my):.4f}")assert variance(sy) <0.02< variance(my) # the 0.02 split sits cleanly between
The real lab keys on more than two axes and emits its tree from training data, but the principle is exactly this: variance per axis, a few comparisons, a label. This is the EML pattern, grounded in the accelerometer-feature literature that established mean, variance, and energy over short windows as the workhorse activity features (Bao and Intille 2004).
Where it runs
The course ships this on a small ladder of boards, and the point is that it does not care which:
FRDM-KL25Z (Cortex-M0+, 48 MHz, no FPU) with an MMA8451 over I2C: the original lab board.
NUCLEO-F411RE (Cortex-M4F, 100 MHz) with an LSM6DSO: the same code, FPU-accelerated.
ATmega328P (8-bit AVR, 16 MHz, no FPU): the floor, and it still keeps up, as the budget shows.
The same functions as an audio voice gate
Swap the accelerometer for a microphone and the window for an audio frame, and the identical energy and zero_crossing_rate functions become a classic voice-activity detector (Rabiner and Juang 1993): energy says something is there, zero-crossing rate says whether it is voiced or hissy. This is the cheap gate you put in front of an expensive stage, the MFCC wakeword detector, say, so DTW only runs when someone is actually speaking.
Here it is on the two ADR-005 default platforms. The frame plumbing is the only platform-specific part, and it is already built on sibling pages: the ESP32-S3 reads its INMP441 I2S microphone exactly as the MFCC page does, and the NUCLEO-F446RE takes timer-triggered ADC samples over DMA exactly as the biquad page does. Once a frame is in a buffer, the gate is identical on both:
typedefenum{ silence, voiced, unvoiced } vad_t;// One audio frame in, a voice-activity label out. The thresholds are tuned once// at the operating SNR (linear-energy floor; ZCR split between voiced and hiss).vad_t voice_gate(constfloat*frame,uint32_t n,float e_floor,float zcr_split){if(energy(frame, n)< e_floor)// nothing loud enoughreturn silence;return(zero_crossing_rate(frame, n)> zcr_split)? unvoiced : voiced;}
That is the same two-feature rule the main page draws in the (energy, ZCR) plane, now four lines of C that run in microseconds. The only platform-specific part is filling that frame buffer, and each default platform’s peripheral init is already shown in full on the sibling page it points to. The glue that connects either source to the gate is short:
// ESP32-S3: drain one frame from the INMP441 I2S channel (init as on the MFCC// page's mic_init) and gate it. The I2S mic is already DC-centred.void esp32_gate_frame(i2s_chan_handle_t rx){staticfloat frame[512];int32_t raw[512];size_t got =0; i2s_channel_read(rx, raw,sizeof(raw),&got, portMAX_DELAY);for(int i =0; i <512; i++) frame[i]=(float)(raw[i]>>8)/8388608.0f; vad_t v = voice_gate(frame,512, E_FLOOR, ZCR_SPLIT);if(v != silence) wake_downstream(v);// only then run the costly stage}
// NUCLEO-F446RE: the timer-triggered ADC fills adc_buf by DMA (init as on the// biquad page). On the half/full-complete callback, copy out, remove the DC the// ADC carries (~half-scale), and gate. ADC_FS is the converter's full-scale count.void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *h){staticfloat frame[512];float sum =0.0f;for(int i =0; i <512; i++){ frame[i]=(float)adc_buf[i]; sum += frame[i];}float dc = sum /512.0f;// remove the ADC's standing offsetfor(int i =0; i <512; i++) frame[i]=(frame[i]- dc)/ ADC_FS; vad_t v = voice_gate(frame,512, E_FLOOR, ZCR_SPLIT);if(v != silence) wake_downstream(v);}
The mean-removal matters only on the ADC path (its samples sit around half-scale); the I2S mic is already centred, which is why its glue skips that step.
Memory and time budget
The features cost a few floating-point operations per sample and no buffers beyond the window already held. The question is never whether they fit, but by how wide a margin, so the table below is really a demonstration that even the slowest target loafs.
import numpy as np# Count only what each path actually runs, ~1 flop/sample per pass over the window.# IMU classifier (on_buffer_full): two per-axis variances, each a two-pass ~2N -> 4N.# Audio gate (voice_gate): energy(N) + zero_crossing_rate(N), one pass each -> 2N.def imu_flops_per_window(N): return4* Ndef audio_flops_per_window(N): return2* N# IMU case: N=64 window, classified once per window at a 100 Hz sample rateN_imu, fs_imu =64, 100imu_flops_per_s = imu_flops_per_window(N_imu) * (fs_imu / N_imu)# audio case: N=512 frame, gated every hop=256 at 16 kHzN_aud, hop, fs_aud =512, 256, 16000aud_flops_per_s = audio_flops_per_window(N_aud) * (fs_aud / hop)# budget against each tier's rough float throughput (flops/s):# AVR 16 MHz software float ~ 0.05 MFLOP/s; M0+ 48 MHz swfloat ~ 0.5; M4F 100 MHz ~ 50tiers = {"ATmega328P (16 MHz, swfloat)": 0.05e6,"Cortex-M0+ (48 MHz, swfloat)": 0.5e6,"Cortex-M4F (100 MHz, FPU)": 50e6}print(f"IMU load: {imu_flops_per_s:8.0f} flop/s")print(f"audio load: {aud_flops_per_s:8.0f} flop/s")for name, cap in tiers.items():print(f" {name:32s} IMU {100*imu_flops_per_s/cap:7.3f}% | audio {100*aud_flops_per_s/cap:7.2f}%")# the IMU classifier fits even the AVR with vast headroom; audio fits from the M0+ upassert imu_flops_per_s / tiers["ATmega328P (16 MHz, swfloat)"] <0.01# <1% on the 8-bit floorassert aud_flops_per_s / tiers["ATmega328P (16 MHz, swfloat)"] >1.0# audio overruns the AVR in swfloatassert aud_flops_per_s / tiers["Cortex-M4F (100 MHz, FPU)"] <0.01# <1% on the M4F
The accelerometer classifier uses well under 1% of even the 8-bit AVR. The 16 kHz audio gate is the one combination that does not fit everywhere: in software float it overruns the AVR, but it fits from the Cortex-M0+ upward (about an eighth of an M0+, trivial on the M4F). RAM is just the window already buffered: 64 floats (256 bytes) for the IMU case, 512 floats (2 KB) for audio. As always, the honest figure on real hardware is a cycle counter (DWT->CYCCNT on the M-series) around the feature calls, not these flop estimates, which ignore loads, stores, and the software-float call overhead that dominates on the no-FPU parts.
What this does and does not do
The same honesty the main page applies to the features applies to the classifier built on them.
The tree is only as good as its training windows. A decision tree on two variances separates clean, well-chosen motions; it does not generalise to motions it never saw, and it has no notion of confidence. Garbage windows in, confident wrong labels out.
It assumes the window is stationary. A window that straddles two events (half still, half moving) gets a blurred feature vector and an arbitrary label. The window length trades responsiveness against this blur, the same trade the STFT makes.
The audio gate is SNR-bound. Energy and ZCR thresholds tuned in a quiet room drift in a noisy one; ZCR in particular is fooled by additive noise near zero. An adaptive noise floor helps, but this is a gate, not a recogniser.
It does not localise within the window. Like every statistic here (bar the ZCR), the features are order-blind: they cannot say when in the window the event happened, only that it did.
When these limits bite, the upgrade path is the rest of this track: richer features (spectral, MFCC) feeding a stronger classifier, or the same features feeding a small neural net instead of a decision tree, which is exactly the on-ramp the HAN EML course takes next. The feature front end you built here does not change. Only what reads it does.
References
Bao, Ling, and Stephen S. Intille. 2004. “Activity Recognition from User-Annotated Acceleration Data.” In Pervasive Computing (Pervasive 2004), 3001:1–17. Lecture Notes in Computer Science. Springer.
Rabiner, Lawrence R., and Biing-Hwang Juang. 1993. Fundamentals of Speech Recognition. Prentice Hall.
Source Code
---title: "The Cheapest Viable Classifier"subtitle: "Per-window features and a tiny decision tree, from an 8-bit AVR to a Cortex-M4F"bibliography: ../../references.bib---The [main page](index.qmd) makes the case in software; this page makes it on the metal. The whole appeal of statistical features is that they cost almost nothing: a few sums and powers over a window, no FFT, no filterbank, no transform of any kind. For a low-rate sensor stream that is cheap enough to keep up on *any* microcontroller, down to an 8-bit AVR with no hardware multiplier (only the 16 kHz audio gate strains the smallest parts, as the budget below shows). So this is the one embedded page in the track where the lesson is **cross-tier feasibility**, and per [ADR-005](../../docs/adr/005-embedded-platforms.md) (amended) it uses the broader capability ladder rather than a single reference board.The features here are the real `features.h` library from the HAN Embedded Machine Learning course (Veen & Arends), the same `mean`, `variance`, `energy`, and `peak_to_peak` that drive its accelerometer activity-classifier labs. We lead with that running example, an on-device motion classifier, then show the same functions doing audio voice-activity detection on the two [ADR-005](../../docs/adr/005-embedded-platforms.md) default platforms.```{python}#| echo: falseimport numpy as np```<hr>## The shape of the system``` sensor ──▶ filter ──▶ rescale ──▶ window buffer ──▶ features ──▶ decision tree ──▶ label (accel / (N samples) (variance, (a few mic) energy, ...) comparisons)```A block of samples comes in, gets lightly conditioned, and fills a buffer. When the buffer is full, you compute a handful of features over it and feed them to a classifier that is nothing more than a sequence of threshold comparisons, a decision tree. The tree is trained offline (scikit-learn, a few dozen labelled windows) and its splits are exported as `if` statements. No model runs on the device; only the comparisons do.## The feature library in CEach function is a single pass (variance takes two) over the window. They mirror [`statistical_features.py`](statistical_features.py) exactly, in portable `float` C that compiles unchanged from an ATmega328P to a Cortex-M4F.```c#include <stdint.h>float mean(constfloat*x,uint32_t n){float s =0.0f;for(uint32_t i =0; i < n; i++) s += x[i];return s /(float)n;}// Two-pass, population variance. The two-pass form (subtract the mean, then sum// squares) is deliberate: the one-pass identity var = sumsq/n - mean^2 loses// precision catastrophically when the window has a large DC offset, which an// accelerometer always does (gravity is ~1 g of standing offset). Subtracting// the mean first keeps the summed terms small. Cheap insurance for one extra pass.float variance(constfloat*x,uint32_t n){float mu = mean(x, n);float s =0.0f;for(uint32_t i =0; i < n; i++){float d = x[i]- mu; s += d * d;}return s /(float)n;}float energy(constfloat*x,uint32_t n)// sum of squares{float s =0.0f;for(uint32_t i =0; i < n; i++) s += x[i]* x[i];return s;}float peak_to_peak(constfloat*x,uint32_t n){float lo = x[0], hi = x[0];for(uint32_t i =1; i < n; i++){if(x[i]< lo) lo = x[i];if(x[i]> hi) hi = x[i];}return hi - lo;}// The zero-crossing rate, for the audio voice gate below. A strict sign change// needs x[i]*x[i-1] < 0, so exact zeros are not miscounted as crossings.float zero_crossing_rate(constfloat*x,uint32_t n){uint32_t c =0;for(uint32_t i =1; i < n; i++)if(x[i]* x[i -1]<0.0f) c++;return(float)c /(float)(n -1);}```On a part with no FPU (the ATmega328P, or a Cortex-M0+) these compile to software-float, which is slower but still trivially within budget at sensor rates, as the budget section shows. On a Cortex-M4F (the NUCLEO boards) each multiply-accumulate is a single-cycle `VMLA.F32`.## Running example: an on-device activity classifierThis is the HAN course's lab, and the genuinely useful one. An accelerometer streams at 100 Hz; you buffer a window (here `N = 64`, about 0.64 s), compute the per-axis variance, and a two-feature decision tree calls the motion. The variance of each axis is the whole story: a still sensor has near-zero variance on every axis, and the axis with the most variance names the dominant direction of motion.The accelerometer values are first rescaled by the course's affine map that sends the $\pm 1000$ mg full-scale range to $[-1, 1]$ (it works out to a simple divide by 1000, i.e. milli-g to g), so the trained thresholds are unit-free. It is a scaling, not a clamp: a vigorous motion that exceeds 1 g overshoots $\pm 1$, which is harmless for a variance threshold.```ctypedefenum{ stationary, up_down, left_right } activity_t;// A decision tree trained offline (scikit-learn) and exported as comparisons.// The thresholds come from labelled windows; these illustrative values match the// rescaled (milli-g / 1000) g-domain. Real splits are emitted by the training script.activity_t classify_motion(float x_var,float y_var){if(x_var <0.02f&& y_var <0.02f)// both axes quiet -> not movingreturn stationary;return(y_var > x_var)? up_down : left_right;// the busier axis wins}// Called once per full buffer (every N samples = 0.64 s at 100 Hz).activity_t on_buffer_full(constfloat*ax,constfloat*ay,uint32_t n){return classify_motion(variance(ax, n), variance(ay, n));}```The Python below is that exact classifier, checked against synthetic windows of the three motions. It both pins the recurrence (a still window classifies as `stationary`, an up-down shake as `up_down`, a left-right shake as `left_right`) and shows the variance separation the tree relies on.```{python}#| label: motion-classifierimport numpy as npfrom statistical_features import variancerng = np.random.default_rng(0)N, fs_imu =64, 100def rescale_mg(a): # course affine map of +/-1000 mg to [-1,1] (= a/1000)return2.0* (a +1000.0) /2000.0-1.0def window(kind, seed):"""A 0.64 s accelerometer window (x, y) in milli-g for one motion class.""" r = np.random.default_rng(seed) t = np.arange(N) / fs_imuif kind =="stationary": # gravity offset + tiny jitter x =50+5* r.standard_normal(N); y =980+5* r.standard_normal(N)elif kind =="up_down": # strong swing on y, quiet x x =50+8* r.standard_normal(N); y =980+400* np.sin(2*np.pi*2*t)else: # left_right: strong swing on x, quiet y x =50+400* np.sin(2*np.pi*2*t); y =980+8* r.standard_normal(N)return rescale_mg(x), rescale_mg(y)def classify_motion(x_var, y_var):if x_var <0.02and y_var <0.02:return"stationary"return"up_down"if y_var > x_var else"left_right"# every class, several trials each, classified from variance alonefor ci, kind inenumerate(("stationary", "up_down", "left_right")): preds = []for s inrange(8): ax, ay = window(kind, 100* s + ci) # deterministic per-trial seed preds.append(classify_motion(variance(ax), variance(ay)))print(f"{kind:11s} -> {set(preds)}")assertall(p == kind for p in preds) # every trial classified correctly# the separation the threshold exploits: still variance is orders below movingsx, sy = window("stationary", 1); mx, my = window("up_down", 2)print(f"still y-var {variance(sy):.4f} moving y-var {variance(my):.4f}")assert variance(sy) <0.02< variance(my) # the 0.02 split sits cleanly between```The real lab keys on more than two axes and emits its tree from training data, but the principle is exactly this: variance per axis, a few comparisons, a label. This is the EML pattern, grounded in the accelerometer-feature literature that established mean, variance, and energy over short windows as the workhorse activity features [@bao2004activity].### Where it runsThe course ships this on a small ladder of boards, and the point is that it does not care which:- **FRDM-KL25Z** (Cortex-M0+, 48 MHz, no FPU) with an MMA8451 over I2C: the original lab board.- **NUCLEO-F411RE** (Cortex-M4F, 100 MHz) with an LSM6DSO: the same code, FPU-accelerated.- **ATmega328P** (8-bit AVR, 16 MHz, no FPU): the floor, and it still keeps up, as the budget shows.## The same functions as an audio voice gateSwap the accelerometer for a microphone and the window for an audio frame, and the identical `energy` and `zero_crossing_rate` functions become a classic voice-activity detector [@rabiner1993fundamentals]: energy says *something* is there, zero-crossing rate says whether it is voiced or hissy. This is the cheap gate you put in front of an expensive stage, the [MFCC wakeword detector](../mfcc/embedded.qmd), say, so DTW only runs when someone is actually speaking.Here it is on the two [ADR-005](../../docs/adr/005-embedded-platforms.md) default platforms. The frame plumbing is the only platform-specific part, and it is already built on sibling pages: the **ESP32-S3** reads its INMP441 I2S microphone exactly as the [MFCC page](../mfcc/embedded.qmd) does, and the **NUCLEO-F446RE** takes timer-triggered ADC samples over DMA exactly as the [biquad page](../../basics/09-biquad/embedded.qmd) does. Once a frame is in a buffer, the gate is identical on both:```ctypedefenum{ silence, voiced, unvoiced } vad_t;// One audio frame in, a voice-activity label out. The thresholds are tuned once// at the operating SNR (linear-energy floor; ZCR split between voiced and hiss).vad_t voice_gate(constfloat*frame,uint32_t n,float e_floor,float zcr_split){if(energy(frame, n)< e_floor)// nothing loud enoughreturn silence;return(zero_crossing_rate(frame, n)> zcr_split)? unvoiced : voiced;}```That is the same two-feature rule the main page draws in the (energy, ZCR) plane, now four lines of C that run in microseconds. The only platform-specific part is filling that `frame` buffer, and each default platform's peripheral init is already shown in full on the sibling page it points to. The glue that connects either source to the gate is short:```c// ESP32-S3: drain one frame from the INMP441 I2S channel (init as on the MFCC// page's mic_init) and gate it. The I2S mic is already DC-centred.void esp32_gate_frame(i2s_chan_handle_t rx){staticfloat frame[512];int32_t raw[512];size_t got =0; i2s_channel_read(rx, raw,sizeof(raw),&got, portMAX_DELAY);for(int i =0; i <512; i++) frame[i]=(float)(raw[i]>>8)/8388608.0f; vad_t v = voice_gate(frame,512, E_FLOOR, ZCR_SPLIT);if(v != silence) wake_downstream(v);// only then run the costly stage}``````c// NUCLEO-F446RE: the timer-triggered ADC fills adc_buf by DMA (init as on the// biquad page). On the half/full-complete callback, copy out, remove the DC the// ADC carries (~half-scale), and gate. ADC_FS is the converter's full-scale count.void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *h){staticfloat frame[512];float sum =0.0f;for(int i =0; i <512; i++){ frame[i]=(float)adc_buf[i]; sum += frame[i];}float dc = sum /512.0f;// remove the ADC's standing offsetfor(int i =0; i <512; i++) frame[i]=(frame[i]- dc)/ ADC_FS; vad_t v = voice_gate(frame,512, E_FLOOR, ZCR_SPLIT);if(v != silence) wake_downstream(v);}```The mean-removal matters only on the ADC path (its samples sit around half-scale); the I2S mic is already centred, which is why its glue skips that step.## Memory and time budgetThe features cost a few floating-point operations per sample and no buffers beyond the window already held. The question is never whether they fit, but by how wide a margin, so the table below is really a demonstration that even the slowest target loafs.```{python}#| label: budgetimport numpy as np# Count only what each path actually runs, ~1 flop/sample per pass over the window.# IMU classifier (on_buffer_full): two per-axis variances, each a two-pass ~2N -> 4N.# Audio gate (voice_gate): energy(N) + zero_crossing_rate(N), one pass each -> 2N.def imu_flops_per_window(N): return4* Ndef audio_flops_per_window(N): return2* N# IMU case: N=64 window, classified once per window at a 100 Hz sample rateN_imu, fs_imu =64, 100imu_flops_per_s = imu_flops_per_window(N_imu) * (fs_imu / N_imu)# audio case: N=512 frame, gated every hop=256 at 16 kHzN_aud, hop, fs_aud =512, 256, 16000aud_flops_per_s = audio_flops_per_window(N_aud) * (fs_aud / hop)# budget against each tier's rough float throughput (flops/s):# AVR 16 MHz software float ~ 0.05 MFLOP/s; M0+ 48 MHz swfloat ~ 0.5; M4F 100 MHz ~ 50tiers = {"ATmega328P (16 MHz, swfloat)": 0.05e6,"Cortex-M0+ (48 MHz, swfloat)": 0.5e6,"Cortex-M4F (100 MHz, FPU)": 50e6}print(f"IMU load: {imu_flops_per_s:8.0f} flop/s")print(f"audio load: {aud_flops_per_s:8.0f} flop/s")for name, cap in tiers.items():print(f" {name:32s} IMU {100*imu_flops_per_s/cap:7.3f}% | audio {100*aud_flops_per_s/cap:7.2f}%")# the IMU classifier fits even the AVR with vast headroom; audio fits from the M0+ upassert imu_flops_per_s / tiers["ATmega328P (16 MHz, swfloat)"] <0.01# <1% on the 8-bit floorassert aud_flops_per_s / tiers["ATmega328P (16 MHz, swfloat)"] >1.0# audio overruns the AVR in swfloatassert aud_flops_per_s / tiers["Cortex-M4F (100 MHz, FPU)"] <0.01# <1% on the M4F```The accelerometer classifier uses well under 1% of even the 8-bit AVR. The 16 kHz audio gate is the one combination that does not fit everywhere: in software float it overruns the AVR, but it fits from the Cortex-M0+ upward (about an eighth of an M0+, trivial on the M4F). RAM is just the window already buffered: 64 floats (256 bytes) for the IMU case, 512 floats (2 KB) for audio. As always, the honest figure on real hardware is a cycle counter (`DWT->CYCCNT` on the M-series) around the feature calls, not these flop estimates, which ignore loads, stores, and the software-float call overhead that dominates on the no-FPU parts.## What this does and does not doThe same honesty the main page applies to the features applies to the classifier built on them.- **The tree is only as good as its training windows.** A decision tree on two variances separates clean, well-chosen motions; it does not generalise to motions it never saw, and it has no notion of confidence. Garbage windows in, confident wrong labels out.- **It assumes the window is stationary.** A window that straddles two events (half still, half moving) gets a blurred feature vector and an arbitrary label. The window length trades responsiveness against this blur, the same trade the [STFT](../stft/index.qmd) makes.- **The audio gate is SNR-bound.** Energy and ZCR thresholds tuned in a quiet room drift in a noisy one; ZCR in particular is fooled by additive noise near zero. An adaptive noise floor helps, but this is a gate, not a recogniser.- **It does not localise within the window.** Like every statistic here (bar the ZCR), the features are order-blind: they cannot say *when* in the window the event happened, only that it did.When these limits bite, the upgrade path is the rest of this track: richer features ([spectral](../spectral-features/index.qmd), [MFCC](../mfcc/index.qmd)) feeding a stronger classifier, or the same features feeding a small neural net instead of a decision tree, which is exactly the on-ramp the HAN EML course takes next. The feature front end you built here does not change. Only what reads it does.## References::: {#refs}:::