Local Field Potentials: Process Neural Signals on Device in Rust

Neurotechnology
Date:October 19, 2026
Topic:
Local Field Potentials: Process Neural Signals on Device in Rust
⏱ 2 min read

Your EEG headset streams 500 samples per second per channel. Sending that raw to the cloud costs battery, bandwidth, and privacy. The fix? Compute local field potentials (LFPs) on the device, in Rust, before a single byte leaves the board.

Why LFPs Belong on Silicon

LFPs capture synchronized synaptic activity in a volume of tissue. They sit between single-unit spikes and scalp EEG in spatial scale. For brain-computer interfaces, sleep staging, or seizure detection, LFP bands (delta, theta, alpha, beta, gamma) are the feature set. Running bandpower, coherence, or phase-amplitude coupling on a Cortex-M4 or RISC-V core means real-time feedback without a radio link.

💡
TipTarget 10-50 ms inference windows. That aligns with typical LFP stationarity assumptions and keeps RAM usage predictable.

Rust Toolchain for Embedded DSP

Start with embassy for async hardware abstraction, dsp for filter primitives, and heapless for static allocation. No allocator, no panic runtime bloat. Cross-compile with cargo build --target thumbv7em-none-eabihf for STM32 or riscv32imac-unknown-none-elf for ESP32-C3.

rust
#[embassy_executor::main]
async fn main(spawner: Spawner) {
    let p = embassy_stm32::init(Default::default());
    let mut adc = Adc::new(p.ADC1, &mut AdcCalib::new());
    let mut buf = [0u16; 256];
    loop {
        adc.blocking_read(&mut p.PA0, &mut buf).await;
        let lfp = process_lfp(&buf);
        radio::send(&lfp).await;
    }
}

Signal Chain: Anti-Alias to Bandpower

1. Sample at 1 kHz. 2. Apply a 4th-order IIR high-pass at 0.5 Hz (DC drift). 3. Decimate by 2 with a half-band FIR to 500 Hz. 4. Compute Welch PSD: 256-point FFT, 50% overlap, Hann window. 5. Integrate power in canonical bands. All fixed-point Q15 to avoid FPU latency.

rust
fn process_lfp(samples: &[u16]) -> [f32; 5] {
    let mut q15: [i16; 256] = [0; 256];
    for (i, &s) in samples.iter().enumerate() {
        q15[i] = ((s as i32 - 2048) << 4) as i16; // center & scale
    }
    // high-pass IIR (biquad coeffs precomputed)
    biquad::process_inplace(&mut q15, &HP_COEFFS);
    // decimate
    let decimated = fir::decimate2(&q15, &HALF_BAND);
    // Welch PSD
    let psd = welch::psd(&decimated, 256, 128, &HANN);
    // bandpower
    [
        bandpower(&psd, 0.5, 4.0),   // delta
        bandpower(&psd, 4.0, 8.0),   // theta
        bandpower(&psd, 8.0, 13.0),  // alpha
        bandpower(&psd, 13.0, 30.0), // beta
        bandpower(&psd, 30.0, 80.0), // gamma
    ]
}
⚠️
WarningWatch stack usage. A 256-point FFT needs ~2 KB. Use `cortex-m-rt::entry` with `#[link_section = ".ramfunc"]` for critical loops.

Memory Budget Table

BufferSize (bytes)Location
ADC raw512DMA
Q15 work512Stack
Decimated256Stack
FFT complex1024Static
PSD bins512Static
Bandpower out20Stack

On-Device Inference Loop

Feed the 5-band vector into a tiny logistic regression or decision tree (≤20 nodes) stored in flash. No tensor runtime needed. Update thresholds via OTA without reflashing the DSP core. Latency: ~2 ms on Cortex-M4 @ 120 MHz. Power: <5 mA average.

"

The best neural interface is the one that never leaves the skull.

— Embedded BCI engineer, 2024

Ship It This Week

Clone embassy-stm32 examples. Add dsp and micromath to Cargo.toml. Port the biquad and FIR coeffs from Python's SciPy. Verify PSD against a known LFP dataset on host. Flash. Measure cycle count with DWT_CYCCNT. Iterate until 95th percentile < 10 ms. That's your v1.

Share𝕏 Twitterin LinkedInin Whatsapp