Skip to content

frontend

birdnet_stm32.models.frontend

AudioFrontendLayer: in-model audio feature extraction for the STM32N6 NPU.

This Keras layer implements the three frontend modes that produce a fixed-size mel-like spectrogram [B, mel_bins, spec_width, 1] from different input representations:

  • precomputed: Pass-through for offline mel spectrograms.
  • hybrid: Linear STFT magnitude -> 1x1 Conv2D mel mixer.
  • raw: Raw waveform -> learned Gabor quadrature filterbank -> magnitude.

Design constraints, in priority order:

  1. Cover the signal. The raw filterbank uses a kernel at least as long as its hop, so every input sample reaches at least one output frame, and the analysis window is long enough to resolve bird harmonics.
  2. Represent magnitude, not phase. A single real-valued bandpass filter oscillates with the carrier; sampling it at the hop rate aliases. The raw path therefore learns a quadrature (cosine/sine) pair and combines them as max(|re|, |im|) + 0.4 * min(|re|, |im|) — within ~4% of the true modulus and, unlike sqrt(re^2 + im^2), INT8-friendly.
  3. Stay on the NPU. No global reductions and no data-dependent scaling: every op here is a convolution, an elementwise op, or a layout change. Per-band calibration is learned (BatchNorm + trainable magnitude scaling) rather than computed per sample at inference time.
  4. Compute the same thing on the NPU. Compiling onto the NPU is not enough; the arithmetic has to survive it. Two measured NPU defects shape the raw path: the filterbank is split into channel-group convolutions (RAW_SPLIT) and |x| is built from ReLU/SUB/ADD (AudioFrontendLayer._abs). Verify any change here with stedgeai validate --mode target on trained weights.

The caller is expected to hand over a peak-normalized waveform (the training, evaluation, calibration and firmware paths all do this), which is what makes fixed learned gains valid in place of a per-sample normalization.

RawGeometry

Bases: NamedTuple

Layout of the folded raw filterbank.

Attributes:

Name Type Description
hop int

Distance between output frames, in input samples.

window int

Analysis window length, in input samples.

fold int

Interleaved channels the waveform is reshaped into.

kernel int

Convolution kernel width, in folded frames.

stride int

Convolution stride, in folded frames (always 2).

crop int

Input samples actually consumed (a whole number of folds).

frames int

Frames the convolution emits before slicing to spec_width.

Source code in birdnet_stm32/models/frontend.py
class RawGeometry(NamedTuple):
    """Layout of the folded raw filterbank.

    Attributes:
        hop: Distance between output frames, in input samples.
        window: Analysis window length, in input samples.
        fold: Interleaved channels the waveform is reshaped into.
        kernel: Convolution kernel width, in folded frames.
        stride: Convolution stride, in folded frames (always 2).
        crop: Input samples actually consumed (a whole number of folds).
        frames: Frames the convolution emits before slicing to spec_width.
    """

    hop: int
    window: int
    fold: int
    kernel: int
    stride: int
    crop: int
    frames: int

AudioFrontendLayer

Bases: Layer

Audio frontend with interchangeable input modes and magnitude scaling.

Modes

precomputed: Mel spectrogram [B, mel_bins, T, 1] -> slice to spec_width. hybrid: Linear STFT bins [B, fft_bins, T, 1] -> 1x1 mel mixer. raw: Waveform [B, T, 1] -> Gabor quadrature filterbank -> L1 magnitude.

Magnitude scaling

'none': Pass-through. 'pwl': Piecewise-linear compression (DW 1x1 branches + ReLU + Add).

Notes
  • is_trainable controls the filterbank (raw) and mel mixer (hybrid). The per-band BatchNorm and the magnitude scaling are always trainable: they replace the per-sample normalization this layer used to apply, so they have to be learned from data to be calibrated.
  • The raw kernel length is at least the hop, so no input sample is skipped.
Source code in birdnet_stm32/models/frontend.py
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
class AudioFrontendLayer(layers.Layer):
    """Audio frontend with interchangeable input modes and magnitude scaling.

    Modes:
        precomputed: Mel spectrogram [B, mel_bins, T, 1] -> slice to spec_width.
        hybrid: Linear STFT bins [B, fft_bins, T, 1] -> 1x1 mel mixer.
        raw: Waveform [B, T, 1] -> Gabor quadrature filterbank -> L1 magnitude.

    Magnitude scaling:
        'none': Pass-through.
        'pwl': Piecewise-linear compression (DW 1x1 branches + ReLU + Add).

    Notes:
        - ``is_trainable`` controls the *filterbank* (raw) and *mel mixer*
          (hybrid). The per-band BatchNorm and the magnitude scaling are always
          trainable: they replace the per-sample normalization this layer used
          to apply, so they have to be learned from data to be calibrated.
        - The raw kernel length is at least the hop, so no input sample is
          skipped.
    """

    def __init__(
        self,
        mode: str,
        mel_bins: int,
        spec_width: int,
        sample_rate: int,
        chunk_duration: int,
        fft_length: int = 512,
        init_mel: bool = True,
        mel_fmin: float = 150.0,
        mel_fmax: float | None = None,
        mel_norm: str = "slaney",
        mag_scale: str = "pwl",
        name: str = "audio_frontend",
        is_trainable: bool = False,
        activation_bounds: dict | None = None,
        **kwargs,
    ):
        super().__init__(name=name, **kwargs)
        assert mode in ("precomputed", "hybrid", "raw")
        assert mag_scale in VALID_MAG_SCALES
        self.mode = mode
        self.mel_bins = int(mel_bins)
        self.spec_width = int(spec_width)
        self.sample_rate = int(sample_rate)
        self.chunk_duration = float(chunk_duration)
        self.fft_length = int(fft_length)
        self.init_mel = bool(init_mel)
        self.mel_fmin = float(mel_fmin)
        self.mel_fmax = mel_fmax
        self.mel_norm = mel_norm
        self.mag_scale = mag_scale
        self.is_trainable = bool(is_trainable)
        reject_activation_bounds(activation_bounds)
        # Training may install a duck-typed quantization hook that simulates
        # the INT8 boundaries hidden inside this custom layer. It is never
        # serialized, so deployment models retain the ordinary clean graph.
        self._quantization_hook = None
        self.geom: RawGeometry | None = None
        # One Conv2D per folded-channel group, per quadrature component.
        self.fb_re: list[layers.Conv2D] = []
        self.fb_im: list[layers.Conv2D] = []
        self.split = RAW_SPLIT

        # Fixed input samples for one chunk
        self._T = int(self.sample_rate * self.chunk_duration)

        # Hybrid 1x1 mel mixer
        self.mel_mixer = layers.Conv2D(
            filters=int(self.mel_bins),
            kernel_size=(1, 1),
            padding="same",
            use_bias=False,
            kernel_constraint=constraints.NonNeg(),
            name=f"{name}_mel_mixer",
            trainable=self.is_trainable,
        )

        if self.mode == "raw":
            self._build_raw_filterbank(name)

        # Lowpass pooling over time, per band. A Gabor modulus is an envelope
        # estimate that rattles frame to frame, where a mel band integrates
        # power over its bandwidth; smoothing recovers that. Measured against
        # librosa mel, it lifts per-band agreement from ~0.43 to ~0.69.
        # Initialized to a Hann window and left trainable, so each band can
        # learn its own time constant.
        self.band_smooth = layers.DepthwiseConv2D(
            kernel_size=(1, SMOOTH_TAPS),
            padding="same",
            use_bias=False,
            depthwise_initializer=_hann_depthwise_init,
            name=f"{name}_band_smooth",
        )

        # Per-band calibration. This is what makes fixed gains work in place of
        # the old per-sample max normalization, so it always trains.
        self.band_bn = layers.BatchNormalization(
            momentum=0.99,
            epsilon=1e-3,
            name=f"{name}_band_bn",
        )
        # ReLU, not ReLU6: the magnitude is already non-negative before BN, and
        # an upper clip would emit a MINIMUM op that the N6 runs in software.
        self.band_relu = layers.ReLU(name=f"{name}_band_relu")

        # Magnitude scaling (composable layer)
        self.mag_layer = MagnitudeScalingLayer(
            method=self.mag_scale,
            channels=self.mel_bins,
            is_trainable=True,
            name=f"{name}_mag",
        )

    def _build_raw_filterbank(self, name: str) -> None:
        """Size and construct the quadrature filterbank for the raw path.

        Each quadrature component is emitted as ``RAW_SPLIT`` convolutions over
        equal groups of the folded channels; their sum is the full filterbank.
        See the note on ``RAW_SPLIT``.
        """
        geom = raw_filterbank_geometry(self._T, int(self.spec_width))
        self.geom = geom
        if geom.fold % RAW_SPLIT:
            raise ValueError(f"fold {geom.fold} is not divisible by RAW_SPLIT {RAW_SPLIT}")
        self.split = RAW_SPLIT
        conv_kwargs = dict(
            filters=int(self.mel_bins),
            kernel_size=(1, geom.kernel),
            strides=(1, geom.stride),
            padding="valid",
            use_bias=False,
            trainable=self.is_trainable,
        )
        self.fb_re = [layers.Conv2D(name=f"{name}_fb_re_{i}", **conv_kwargs) for i in range(self.split)]
        self.fb_im = [layers.Conv2D(name=f"{name}_fb_im_{i}", **conv_kwargs) for i in range(self.split)]

    def build(self, input_shape):
        """Build the frontend layer based on the selected mode."""
        if self.mode == "hybrid":
            self._build_and_set_mel_mixer(n_fft=self.fft_length, cin=hybrid_fft_bins(self.fft_length))
        elif self.mode == "raw":
            if self.geom is None or not self.fb_re or not self.fb_im:
                raise RuntimeError("Raw frontend filterbank was not initialized")
            group = self.geom.fold // self.split
            folded = tf.TensorShape([None, 1, self.geom.crop // self.geom.fold, group])
            for conv in (*self.fb_re, *self.fb_im):
                conv.build(folded)
            self._seed_gabor_weights()

        band_shape = tf.TensorShape([None, 1, int(self.spec_width), int(self.mel_bins)])
        if self.mode != "precomputed":
            if not self.band_smooth.built:
                self.band_smooth.build(band_shape)
            if not self.band_bn.built:
                self.band_bn.build(band_shape)
        self._build_mag_layer()
        super().build(input_shape)

    def _seed_gabor_weights(self) -> None:
        """Seed the raw filterbank with mel-spaced Gabor filters."""
        g = self.geom
        fb_re = self.fb_re
        fb_im = self.fb_im
        if g is None or not fb_re or not fb_im:
            raise RuntimeError("Raw frontend filterbank was not initialized")
        upper = float(self.mel_fmax) if self.mel_fmax is not None else (self.sample_rate / 2.0)
        real, imag = gabor_filterbank(
            mel_bins=self.mel_bins,
            kernel=g.window,
            sample_rate=self.sample_rate,
            fmin=self.mel_fmin,
            fmax=upper,
        )

        def _to_folded_kernel(taps: np.ndarray) -> np.ndarray:
            """[M, window] -> Conv2D kernel [1, kernel, fold, M].

            Folding sends input sample ``p * fold + c`` to channel ``c`` of
            frame ``p``, so tap ``j * fold + c`` of the filter is the weight at
            (frame offset ``j``, channel ``c``) — exactly a C-order reshape.
            """
            return taps.reshape(self.mel_bins, g.kernel, g.fold).transpose(1, 2, 0)[None]

        group = g.fold // self.split
        for i, (conv_re, conv_im) in enumerate(zip(fb_re, fb_im, strict=True)):
            sl = slice(i * group, (i + 1) * group)
            conv_re.set_weights([_to_folded_kernel(real)[:, :, sl, :]])
            conv_im.set_weights([_to_folded_kernel(imag)[:, :, sl, :]])

    def _build_and_set_mel_mixer(self, n_fft: int, cin: int):
        """Initialize mel_mixer from a Slaney mel basis."""
        upper = int(self.mel_fmax) if self.mel_fmax is not None else (self.sample_rate // 2)
        # [cin, mel]: the reference filterbank already excludes the Nyquist bin,
        # which matches hybrid_fft_bins(); no runtime pad needed.
        mel_mat = np.ascontiguousarray(
            mel_filterbank(int(self.sample_rate), int(n_fft), int(self.mel_bins), float(self.mel_fmin), float(upper)).T
        )[:cin, :]
        if not self.mel_mixer.built:
            self.mel_mixer.build(tf.TensorShape([None, 1, None, cin]))
        self.mel_mixer.set_weights([mel_mat[None, None, :, :]])

    def _build_mag_layer(self):
        """Ensure the magnitude scaling layer is built."""
        post_mel_shape = tf.TensorShape([None, 1, None, int(self.mel_bins)])
        if not self.mag_layer.built:
            self.mag_layer.build(post_mel_shape)

    def _apply_mag(self, x):
        """Dispatch to the magnitude scaling layer."""
        return self.mag_layer(x)

    def set_quantization_hook(self, hook) -> None:
        """Install or remove a training-only internal quantization hook."""
        self._quantization_hook = hook
        self.mag_layer.set_quantization_hook(hook)

    def _quantized_call(self, layer, inputs):
        """Call a kernel layer through the QAT hook when one is installed."""
        if self._quantization_hook is None:
            return layer(inputs)
        return self._quantization_hook.kernel(layer, inputs)

    def _quantized_activation(self, name: str, inputs):
        """Mark an internal tensor as an INT8 activation boundary for QAT."""
        if self._quantization_hook is None:
            return inputs
        return self._quantization_hook.activation(name, inputs)

    def _abs(self, x, name: str):
        """Quantization-safe |x| for the NPU: relu(x) + (relu(x) - x).

        ``relu(x) - x`` is exactly ``relu(-x)``, so this is the identity
        ``|x| = relu(x) + relu(-x)`` written without a negation. That matters:
        TFLite has no INT8 negate, so ``-x`` lowers to DEQUANTIZE -> NEG ->
        QUANTIZE and runs in software on the Cortex-M55. In the raw frontend
        that cost six extra software epochs, about 2.0 of 6.7 ms per inference
        at 1 GHz (measured 2026-09-10). RELU, SUB and ADD all stay on the NPU,
        and this form is bit-exact on target (cos 1.000000, l2r 0.00036).
        Activation names are unchanged, so QAT ranges still apply.
        """
        pos = self._quantized_activation(f"{name}_pos", tf.nn.relu(x))
        neg = self._quantized_activation(f"{name}_neg", pos - x)
        return self._quantized_activation(name, pos + neg)

    def _calibrate(self, y, training, smooth: bool = False):
        """Optional temporal lowpass, then per-band normalization and scaling."""
        if smooth:
            y = self._quantized_call(self.band_smooth, y)
        y = self.band_bn(y, training=training)
        y = self.band_relu(y)
        y = self._quantized_activation(self.band_relu.name, y)
        return self._apply_mag(y)

    def call(self, inputs, training=None):
        """Run the selected frontend path and return a fixed-size spectrogram.

        Shapes:
            precomputed: [B, mel_bins, T, 1] -> [B, mel_bins, spec_width, 1]
            hybrid: [B, fft_bins, T, 1] -> [B, mel_bins, spec_width, 1]
            raw: [B, T, 1] -> [B, mel_bins, spec_width, 1]
        """
        if self.mode == "precomputed":
            return inputs[:, :, : self.spec_width, :]

        if self.mode == "hybrid":
            fft_bins = hybrid_fft_bins(self.fft_length)
            if inputs.shape.rank != 4 or (inputs.shape[1] is not None and int(inputs.shape[1]) != fft_bins):
                raise ValueError(f"Hybrid expects [B,{fft_bins},T,1], got {inputs.shape}")
            y = tf.transpose(inputs, [0, 3, 2, 1])  # [B,1,T,fft_bins]
            y = y[:, :, : self.spec_width, :]
            y = self._quantized_call(self.mel_mixer, y)
            y = self._calibrate(y, training)
            y = tf.transpose(y, [0, 3, 2, 1])  # [B,mel,T,1]
            return y[:, :, : self.spec_width, :]

        # raw: fold -> quadrature filterbank -> magnitude -> calibrate
        g = self.geom
        # Reinterpret the waveform as `fold` interleaved channels. Free in NHWC
        # (same contiguous bytes), and it is what lets the convolution run at
        # stride 2 — the only strides the NPU takes — while the effective hop
        # stays at `g.hop` samples.
        y = tf.reshape(inputs[:, : g.crop, :], [-1, 1, g.crop // g.fold, g.fold])

        # Sum of convolutions over a partition of the folded channels. Exactly
        # the full filterbank, but no partial convolution accumulates more than
        # `window / split` taps, which is what the NPU computes correctly.
        def _bank(convs, tag):
            group = g.fold // self.split
            # Each partial convolution output is its own INT8 tensor in the
            # converted graph, so it is a quantization boundary here too. QAT
            # without these simulated a nearly lossless filterbank: measured on
            # the v1.2 raw model, its fake-quant graph scored 0.6435 validation
            # cMAP against 0.6229 converted, and 0.6432 with the filterbank kept
            # in float.
            parts = [
                self._quantized_activation(
                    f"{tag}_part_{i}", self._quantized_call(conv, y[:, :, :, i * group : (i + 1) * group])
                )
                for i, conv in enumerate(convs)
            ]
            total = parts[0]
            for j, part in enumerate(parts[1:], start=1):
                total = self._quantized_activation(f"{tag}_sum_{j}", total + part)
            return total

        # Named for the bank, not for one of its partial convolutions: this is
        # the filterbank output, and the name is part of the QAT range contract.
        re = self._quantized_activation(f"{self.name}_fb_re", _bank(self.fb_re, f"{self.name}_fb_re"))
        im = self._quantized_activation(f"{self.name}_fb_im", _bank(self.fb_im, f"{self.name}_fb_im"))

        # alpha-max-plus-beta-min: |z| ~= max(|re|,|im|) + 0.4*min(|re|,|im|).
        # Within ~4% of the true magnitude, against ~17% ripple for |re|+|im| —
        # and that ripple would beat at the carrier frequency, aliasing into the
        # frame rate. Costs three elementwise ops, all of which stay on the NPU.
        # |x| via _abs (ReLU, SUB, ADD) rather than tf.abs. The N6 NPU's ABS
        # ignores its input's quantization zero-point: on an isolated ABS with
        # zero-point -9 every element came back short by `|zp| * scale`
        # (measured 2026-09-09: mean error -0.1543 against mae 0.1543 -- pure
        # bias, cos 0.951). The filterbank sums feeding this never have a zero
        # zero-point, so ABS is never safe here.
        a = self._abs(re, f"{self.name}_abs_re")
        b = self._abs(im, f"{self.name}_abs_im")
        # Written without MAXIMUM/MINIMUM, using the exact identity
        #     max(a, b) + 0.4 * min(a, b) = b + 0.4 * a + 0.6 * relu(a - b).
        # TFLite's INT8 MINIMUM forces its inputs onto its output's scale, so the
        # converter quantized |re| and |im| to min's range (0..4.31 on the v1.2
        # raw model, against a real 0..7.08) and saturated the loudest bins
        # before MAXIMUM saw them. SUB, RELU, MUL and ADD tie no ranges.
        excess = self._quantized_activation(
            f"{self.name}_abs_excess",
            tf.nn.relu(self._quantized_activation(f"{self.name}_abs_delta", a - b)),
        )
        blend = self._quantized_activation(
            f"{self.name}_abs_blend", b + self._quantized_activation(f"{self.name}_abs_re_scale", 0.4 * a)
        )
        mag = self._quantized_activation(
            f"{self.name}_magnitude",
            blend + self._quantized_activation(f"{self.name}_abs_excess_scale", 0.6 * excess),
        )

        mag = self._calibrate(mag, training, smooth=True)
        mag = mag[:, :, : self.spec_width, :]
        return tf.transpose(mag, [0, 3, 2, 1])  # [B,mel,W,1]

    def compute_output_shape(self, input_shape):
        """Return static output shape: (batch, mel_bins, spec_width, 1)."""
        return (input_shape[0], int(self.mel_bins), int(self.spec_width), 1)

    # Constructor arguments retired in 1.2.0 along with the features behind
    # them. Checkpoints saved before that still carry them in their serialized
    # layer config, so they are dropped on load rather than rejected: removing
    # a training option must not make existing models unreadable.
    _RETIRED_CONFIG_KEYS = ("pcen_K",)

    @classmethod
    def from_config(cls, config):
        """Build from a serialized config, ignoring retired arguments."""
        return cls(**{key: value for key, value in config.items() if key not in cls._RETIRED_CONFIG_KEYS})

    def get_config(self):
        """Return a serializable configuration for model saving/loading."""
        cfg = {
            "mode": self.mode,
            "mel_bins": self.mel_bins,
            "spec_width": self.spec_width,
            "sample_rate": self.sample_rate,
            "chunk_duration": self.chunk_duration,
            "fft_length": self.fft_length,
            "init_mel": self.init_mel,
            "mel_fmin": self.mel_fmin,
            "mel_fmax": self.mel_fmax,
            "mel_norm": self.mel_norm,
            "mag_scale": self.mag_scale,
            "name": self.name,
            "is_trainable": self.is_trainable,
        }
        base = super().get_config()
        base.update(cfg)
        return base

build(input_shape)

Build the frontend layer based on the selected mode.

Source code in birdnet_stm32/models/frontend.py
def build(self, input_shape):
    """Build the frontend layer based on the selected mode."""
    if self.mode == "hybrid":
        self._build_and_set_mel_mixer(n_fft=self.fft_length, cin=hybrid_fft_bins(self.fft_length))
    elif self.mode == "raw":
        if self.geom is None or not self.fb_re or not self.fb_im:
            raise RuntimeError("Raw frontend filterbank was not initialized")
        group = self.geom.fold // self.split
        folded = tf.TensorShape([None, 1, self.geom.crop // self.geom.fold, group])
        for conv in (*self.fb_re, *self.fb_im):
            conv.build(folded)
        self._seed_gabor_weights()

    band_shape = tf.TensorShape([None, 1, int(self.spec_width), int(self.mel_bins)])
    if self.mode != "precomputed":
        if not self.band_smooth.built:
            self.band_smooth.build(band_shape)
        if not self.band_bn.built:
            self.band_bn.build(band_shape)
    self._build_mag_layer()
    super().build(input_shape)

set_quantization_hook(hook)

Install or remove a training-only internal quantization hook.

Source code in birdnet_stm32/models/frontend.py
def set_quantization_hook(self, hook) -> None:
    """Install or remove a training-only internal quantization hook."""
    self._quantization_hook = hook
    self.mag_layer.set_quantization_hook(hook)

call(inputs, training=None)

Run the selected frontend path and return a fixed-size spectrogram.

Shapes

precomputed: [B, mel_bins, T, 1] -> [B, mel_bins, spec_width, 1] hybrid: [B, fft_bins, T, 1] -> [B, mel_bins, spec_width, 1] raw: [B, T, 1] -> [B, mel_bins, spec_width, 1]

Source code in birdnet_stm32/models/frontend.py
def call(self, inputs, training=None):
    """Run the selected frontend path and return a fixed-size spectrogram.

    Shapes:
        precomputed: [B, mel_bins, T, 1] -> [B, mel_bins, spec_width, 1]
        hybrid: [B, fft_bins, T, 1] -> [B, mel_bins, spec_width, 1]
        raw: [B, T, 1] -> [B, mel_bins, spec_width, 1]
    """
    if self.mode == "precomputed":
        return inputs[:, :, : self.spec_width, :]

    if self.mode == "hybrid":
        fft_bins = hybrid_fft_bins(self.fft_length)
        if inputs.shape.rank != 4 or (inputs.shape[1] is not None and int(inputs.shape[1]) != fft_bins):
            raise ValueError(f"Hybrid expects [B,{fft_bins},T,1], got {inputs.shape}")
        y = tf.transpose(inputs, [0, 3, 2, 1])  # [B,1,T,fft_bins]
        y = y[:, :, : self.spec_width, :]
        y = self._quantized_call(self.mel_mixer, y)
        y = self._calibrate(y, training)
        y = tf.transpose(y, [0, 3, 2, 1])  # [B,mel,T,1]
        return y[:, :, : self.spec_width, :]

    # raw: fold -> quadrature filterbank -> magnitude -> calibrate
    g = self.geom
    # Reinterpret the waveform as `fold` interleaved channels. Free in NHWC
    # (same contiguous bytes), and it is what lets the convolution run at
    # stride 2 — the only strides the NPU takes — while the effective hop
    # stays at `g.hop` samples.
    y = tf.reshape(inputs[:, : g.crop, :], [-1, 1, g.crop // g.fold, g.fold])

    # Sum of convolutions over a partition of the folded channels. Exactly
    # the full filterbank, but no partial convolution accumulates more than
    # `window / split` taps, which is what the NPU computes correctly.
    def _bank(convs, tag):
        group = g.fold // self.split
        # Each partial convolution output is its own INT8 tensor in the
        # converted graph, so it is a quantization boundary here too. QAT
        # without these simulated a nearly lossless filterbank: measured on
        # the v1.2 raw model, its fake-quant graph scored 0.6435 validation
        # cMAP against 0.6229 converted, and 0.6432 with the filterbank kept
        # in float.
        parts = [
            self._quantized_activation(
                f"{tag}_part_{i}", self._quantized_call(conv, y[:, :, :, i * group : (i + 1) * group])
            )
            for i, conv in enumerate(convs)
        ]
        total = parts[0]
        for j, part in enumerate(parts[1:], start=1):
            total = self._quantized_activation(f"{tag}_sum_{j}", total + part)
        return total

    # Named for the bank, not for one of its partial convolutions: this is
    # the filterbank output, and the name is part of the QAT range contract.
    re = self._quantized_activation(f"{self.name}_fb_re", _bank(self.fb_re, f"{self.name}_fb_re"))
    im = self._quantized_activation(f"{self.name}_fb_im", _bank(self.fb_im, f"{self.name}_fb_im"))

    # alpha-max-plus-beta-min: |z| ~= max(|re|,|im|) + 0.4*min(|re|,|im|).
    # Within ~4% of the true magnitude, against ~17% ripple for |re|+|im| —
    # and that ripple would beat at the carrier frequency, aliasing into the
    # frame rate. Costs three elementwise ops, all of which stay on the NPU.
    # |x| via _abs (ReLU, SUB, ADD) rather than tf.abs. The N6 NPU's ABS
    # ignores its input's quantization zero-point: on an isolated ABS with
    # zero-point -9 every element came back short by `|zp| * scale`
    # (measured 2026-09-09: mean error -0.1543 against mae 0.1543 -- pure
    # bias, cos 0.951). The filterbank sums feeding this never have a zero
    # zero-point, so ABS is never safe here.
    a = self._abs(re, f"{self.name}_abs_re")
    b = self._abs(im, f"{self.name}_abs_im")
    # Written without MAXIMUM/MINIMUM, using the exact identity
    #     max(a, b) + 0.4 * min(a, b) = b + 0.4 * a + 0.6 * relu(a - b).
    # TFLite's INT8 MINIMUM forces its inputs onto its output's scale, so the
    # converter quantized |re| and |im| to min's range (0..4.31 on the v1.2
    # raw model, against a real 0..7.08) and saturated the loudest bins
    # before MAXIMUM saw them. SUB, RELU, MUL and ADD tie no ranges.
    excess = self._quantized_activation(
        f"{self.name}_abs_excess",
        tf.nn.relu(self._quantized_activation(f"{self.name}_abs_delta", a - b)),
    )
    blend = self._quantized_activation(
        f"{self.name}_abs_blend", b + self._quantized_activation(f"{self.name}_abs_re_scale", 0.4 * a)
    )
    mag = self._quantized_activation(
        f"{self.name}_magnitude",
        blend + self._quantized_activation(f"{self.name}_abs_excess_scale", 0.6 * excess),
    )

    mag = self._calibrate(mag, training, smooth=True)
    mag = mag[:, :, : self.spec_width, :]
    return tf.transpose(mag, [0, 3, 2, 1])  # [B,mel,W,1]

compute_output_shape(input_shape)

Return static output shape: (batch, mel_bins, spec_width, 1).

Source code in birdnet_stm32/models/frontend.py
def compute_output_shape(self, input_shape):
    """Return static output shape: (batch, mel_bins, spec_width, 1)."""
    return (input_shape[0], int(self.mel_bins), int(self.spec_width), 1)

from_config(config) classmethod

Build from a serialized config, ignoring retired arguments.

Source code in birdnet_stm32/models/frontend.py
@classmethod
def from_config(cls, config):
    """Build from a serialized config, ignoring retired arguments."""
    return cls(**{key: value for key, value in config.items() if key not in cls._RETIRED_CONFIG_KEYS})

get_config()

Return a serializable configuration for model saving/loading.

Source code in birdnet_stm32/models/frontend.py
def get_config(self):
    """Return a serializable configuration for model saving/loading."""
    cfg = {
        "mode": self.mode,
        "mel_bins": self.mel_bins,
        "spec_width": self.spec_width,
        "sample_rate": self.sample_rate,
        "chunk_duration": self.chunk_duration,
        "fft_length": self.fft_length,
        "init_mel": self.init_mel,
        "mel_fmin": self.mel_fmin,
        "mel_fmax": self.mel_fmax,
        "mel_norm": self.mel_norm,
        "mag_scale": self.mag_scale,
        "name": self.name,
        "is_trainable": self.is_trainable,
    }
    base = super().get_config()
    base.update(cfg)
    return base

normalize_frontend_name(name)

Validate a frontend name.

Parameters:

Name Type Description Default
name str

Frontend name.

required

Returns:

Type Description
str

The frontend name, unchanged.

Raises:

Type Description
ValueError

If name is not a valid frontend.

Source code in birdnet_stm32/models/frontend.py
def normalize_frontend_name(name: str) -> str:
    """Validate a frontend name.

    Args:
        name: Frontend name.

    Returns:
        The frontend name, unchanged.

    Raises:
        ValueError: If name is not a valid frontend.
    """
    if name in VALID_FRONTENDS:
        return name
    raise ValueError(f"Invalid audio frontend: '{name}'. Valid options: {VALID_FRONTENDS}")

hybrid_fft_bins(fft_length)

Return the number of linear STFT bins the hybrid frontend consumes.

The Nyquist bin is dropped so the count is fft_length // 2 — a multiple of 8 for every sane FFT size, which lets the mel mixer run at full NPU channel utilization without a runtime zero-pad (a FILL + CONCATENATION pair on every inference). The discarded bin carries no bird signal.

Parameters:

Name Type Description Default
fft_length int

FFT size.

required

Returns:

Type Description
int

Number of spectrogram rows expected as model input.

Source code in birdnet_stm32/models/frontend.py
def hybrid_fft_bins(fft_length: int) -> int:
    """Return the number of linear STFT bins the hybrid frontend consumes.

    The Nyquist bin is dropped so the count is ``fft_length // 2`` — a multiple
    of 8 for every sane FFT size, which lets the mel mixer run at full NPU
    channel utilization without a runtime zero-pad (a FILL + CONCATENATION pair
    on every inference). The discarded bin carries no bird signal.

    Args:
        fft_length: FFT size.

    Returns:
        Number of spectrogram rows expected as model input.
    """
    return int(fft_length) // 2

raw_filterbank_geometry(num_samples, spec_width, overlap=RAW_OVERLAP)

Lay out the raw filterbank so its convolution runs on the NPU.

A learned STFT wants a long kernel and a hop of a few hundred samples. Fed to the N6 directly that is a large-stride convolution, which the compiler hands to the Cortex-M55 — measured, and the reason the previous frontend's filterbank never ran on the NPU at all.

The fix is a polyphase view: reinterpret the waveform as hop / 2 interleaved channels (free — NHWC memory is contiguous, so [T, 1] and [T/fold, fold] are the same bytes) and convolve with stride 2. The arithmetic is identical to the long strided kernel, but every convolution now sits inside the NPU's supported stride range and sees a full channel group instead of a single input channel.

Parameters:

Name Type Description Default
num_samples int

Waveform length in samples.

required
spec_width int

Required number of output frames.

required
overlap int

Window length as a multiple of the hop.

RAW_OVERLAP

Returns:

Type Description
RawGeometry

The resolved :class:RawGeometry.

Raises:

Type Description
ValueError

If no hop yields spec_width frames for this chunk.

Source code in birdnet_stm32/models/frontend.py
def raw_filterbank_geometry(
    num_samples: int,
    spec_width: int,
    overlap: int = RAW_OVERLAP,
) -> RawGeometry:
    """Lay out the raw filterbank so its convolution runs on the NPU.

    A learned STFT wants a long kernel and a hop of a few hundred samples. Fed
    to the N6 directly that is a large-stride convolution, which the compiler
    hands to the Cortex-M55 — measured, and the reason the previous frontend's
    filterbank never ran on the NPU at all.

    The fix is a polyphase view: reinterpret the waveform as ``hop / 2``
    interleaved channels (free — NHWC memory is contiguous, so ``[T, 1]`` and
    ``[T/fold, fold]`` are the same bytes) and convolve with **stride 2**. The
    arithmetic is identical to the long strided kernel, but every convolution
    now sits inside the NPU's supported stride range and sees a full channel
    group instead of a single input channel.

    Args:
        num_samples: Waveform length in samples.
        spec_width: Required number of output frames.
        overlap: Window length as a multiple of the hop.

    Returns:
        The resolved :class:`RawGeometry`.

    Raises:
        ValueError: If no hop yields ``spec_width`` frames for this chunk.
    """
    if spec_width < 2:
        raise ValueError(f"spec_width must be >= 2, got {spec_width}")

    # frames >= spec_width  <=>  hop <= num_samples / (spec_width - 1 + overlap)
    hop = (num_samples // (spec_width - 1 + overlap) // _HOP_ALIGN) * _HOP_ALIGN
    if hop < 2 * _HOP_ALIGN:
        raise ValueError(
            f"Cannot fit {spec_width} frames into {num_samples} samples. "
            f"Lower --spec_width or lengthen --chunk_duration."
        )

    fold = hop // 2
    window = overlap * hop
    kernel = 2 * overlap  # window // fold
    crop = (num_samples // fold) * fold
    frames = (crop // fold - kernel) // 2 + 1
    return RawGeometry(hop, window, fold, kernel, 2, crop, frames)

gabor_filterbank(mel_bins, kernel, sample_rate, fmin, fmax)

Build mel-spaced Gabor filters as a cosine/sine quadrature pair.

Each band is a Gaussian-windowed complex exponential centred on a mel frequency, with its time-domain width set from the spacing to its neighbours so the bank's frequency response approximates a mel filterbank. Starting from this initialization, an untrained frontend already produces a mel-like spectrogram; training only refines it.

Parameters:

Name Type Description Default
mel_bins int

Number of filters (output channels).

required
kernel int

Filter length in samples.

required
sample_rate int

Sampling rate (Hz).

required
fmin float

Lowest band centre (Hz).

required
fmax float

Highest band centre (Hz).

required

Returns:

Type Description
ndarray

Tuple of (real, imag) arrays, each [mel_bins, kernel] float32,

ndarray

normalized to unit energy per band.

Source code in birdnet_stm32/models/frontend.py
def gabor_filterbank(
    mel_bins: int,
    kernel: int,
    sample_rate: int,
    fmin: float,
    fmax: float,
) -> tuple[np.ndarray, np.ndarray]:
    """Build mel-spaced Gabor filters as a cosine/sine quadrature pair.

    Each band is a Gaussian-windowed complex exponential centred on a mel
    frequency, with its time-domain width set from the spacing to its
    neighbours so the bank's frequency response approximates a mel filterbank.
    Starting from this initialization, an untrained frontend already produces a
    mel-like spectrogram; training only refines it.

    Args:
        mel_bins: Number of filters (output channels).
        kernel: Filter length in samples.
        sample_rate: Sampling rate (Hz).
        fmin: Lowest band centre (Hz).
        fmax: Highest band centre (Hz).

    Returns:
        Tuple of ``(real, imag)`` arrays, each ``[mel_bins, kernel]`` float32,
        normalized to unit energy per band.
    """
    edges = mel_frequencies(int(mel_bins) + 2, float(fmin), float(fmax))
    centers = edges[1:-1].astype(np.float64)
    # Half the distance between neighbouring centres is the target bandwidth.
    bandwidths = np.maximum((edges[2:] - edges[:-2]) / 2.0, 1.0).astype(np.float64)

    n = np.arange(kernel, dtype=np.float64) - (kernel - 1) / 2.0
    # Gaussian envelope whose spectral width matches the target bandwidth,
    # clamped so the window stays inside the kernel.
    sigma = np.minimum(sample_rate / (2.0 * np.pi * bandwidths), kernel / 6.0)

    envelope = np.exp(-0.5 * (n[None, :] / sigma[:, None]) ** 2)
    phase = 2.0 * np.pi * centers[:, None] * n[None, :] / float(sample_rate)
    real = envelope * np.cos(phase)
    imag = envelope * np.sin(phase)

    # Unit energy per band so every filter starts on a comparable scale and the
    # INT8 activation range is not dominated by a few loud bands.
    norm = np.sqrt((real**2 + imag**2).sum(axis=1, keepdims=True)) + 1e-12
    return (real / norm).astype(np.float32), (imag / norm).astype(np.float32)