8.3.8. Utilities

The ulab.utils module sits outside numpy proper. It provides small helpers that bridge ndarray and the kinds of raw buffers you typically get from peripherals on a microcontroller. Two families of functions are available:

  • Buffer convertersfrom_int16_buffer, from_uint16_buffer, from_int32_buffer, from_uint32_buffer. These read 16- or 32-bit integer samples out of a bytes-like buffer and return them as a float ndarray.

  • spectrogram – a memory-efficient np.abs(np.fft.fft(signal)).

8.3.8.1. Importing

from ulab import numpy as np
from ulab import utils

8.3.8.2. Buffer converters

np.frombuffer only handles dtypes that ulab natively supports: uint8, int8, uint16, int16, float. If your peripheral produces 32-bit integers (a 24- or 32-bit ADC, a high-resolution microphone, …) you need utils.from_int32_buffer or utils.from_uint32_buffer.

The functions take a buffer and return a float ndarray:

from ulab import numpy as np
from ulab import utils

buf = bytearray([1, 1, 0, 0, 0, 0, 0, 255])
print(utils.from_uint32_buffer(buf))
# array([257.0, 4278190080.0], dtype=float64)
print(utils.from_int32_buffer(buf))
# array([257.0, -16777216.0], dtype=float64)

Like np.frombuffer, they accept count=-1 and offset=0:

utils.from_uint32_buffer(buf, count=1, offset=4)

To avoid an allocation per call, pre-allocate a destination float ndarray and pass it as out=:

out = np.zeros(2, dtype=np.float)
utils.from_uint32_buffer(buf, out=out)

If the peripheral byte order differs from the MCU, use byteswap=True:

utils.from_uint32_buffer(buf, byteswap=True)

The 16-bit variants from_int16_buffer and from_uint16_buffer work the same way, but read 16-bit samples.

8.3.8.3. spectrogram: abs(fft(…)) without intermediate arrays

utils.spectrogram(signal) computes the magnitude of the Fourier transform. It is conceptually equivalent to:

  • np.abs(np.fft.fft(signal)) on a numpy-compatible build, or

  • np.sqrt(real * real + imag * imag) on a split build,

but does it without allocating a*a, b*b, the sum, or the output of np.abs. That makes it a good choice in any loop where you compute spectra repeatedly:

from ulab import numpy as np
from ulab import utils

x = np.linspace(0, 10, num=1024)
y = np.sin(x)

spectrum = utils.spectrogram(y)

The function takes one or two positional arguments depending on the firmware: a single 1-D real-or-complex array on numpy-compatible builds, or one or two real arrays (real, imag) on split builds.

Three keyword arguments help with allocation:

  • scratchpad=None – a 1-D dense float ndarray of length 2 * len(signal) used as internal scratch space. If you supply one, the function does not have to allocate it.

  • out=None – a 1-D float ndarray to write the result into. If you supply out, no result array is allocated.

  • log=False – if True, take np.log of the magnitude before returning. Cheaper than calling np.log separately because the work is folded into the same loops.

Pattern – pre-allocate everything, never allocate again:

from ulab import numpy as np
from ulab import utils

N = 1024
t = np.linspace(0, 2 * np.pi, num=N)
scratch = np.zeros(2 * N)
out     = np.zeros(N)

while True:
    signal = np.sin(t)              # this still allocates each iter
    utils.spectrogram(signal, out=out, scratchpad=scratch, log=True)
    # out now holds log magnitudes
    # ... feed `out` into the next stage ...

Compare this to the obvious-but-wasteful version:

while True:
    signal = np.sin(t)
    spectrum = np.log(utils.spectrogram(signal))   # 2 allocations / iter

Both produce the same numbers, but the first version generates no garbage in the hot loop – the heap stays clean and the loop is faster.

8.3.8.4. Probing for numpy-compatible FFT

If you ship code that needs to run on both flavours of ulab firmware, you can detect the FFT mode at runtime by checking what np.fft.fft returns:

from ulab import numpy as np

if len(np.fft.fft(np.zeros(4))) == 2:
    fft_is_numpy_compat = False    # split real/imag
else:
    fft_is_numpy_compat = True     # complex output

This is the same check utils.spectrogram uses internally, and it’s the cleanest way to keep one code base for both build flavours.

8.3.8.5. Where to go next