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
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
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
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",
        raw_magnitude: str = "alpha_max",
        raw_overlap: int = RAW_OVERLAP,
        raw_bank: str = "pair",
        raw_split_axis: str = "channels",
        raw_exposure_gain: float = 1.0,
        raw_exposure_mode: str = "channels",
        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
        if raw_magnitude not in VALID_RAW_MAGNITUDES:
            raise ValueError(f"raw_magnitude '{raw_magnitude}' not in {VALID_RAW_MAGNITUDES}")
        if int(raw_overlap) < 1:
            raise ValueError(f"raw_overlap must be >= 1, got {raw_overlap}")
        if raw_bank not in VALID_RAW_BANKS:
            raise ValueError(f"raw_bank '{raw_bank}' not in {VALID_RAW_BANKS}")
        if raw_split_axis not in VALID_RAW_SPLIT_AXES:
            raise ValueError(f"raw_split_axis '{raw_split_axis}' not in {VALID_RAW_SPLIT_AXES}")
        if float(raw_exposure_gain) < 1.0:
            raise ValueError(f"raw_exposure_gain must be >= 1 (1 = one exposure), got {raw_exposure_gain}")
        if float(raw_exposure_gain) > 1.0 and (mode != "raw" or raw_bank != "fused" or raw_split_axis != "taps"):
            raise ValueError("raw_exposure_gain needs the raw frontend with a fused bank and a tap split")
        if raw_exposure_mode not in VALID_EXPOSURE_MODES:
            raise ValueError(f"raw_exposure_mode '{raw_exposure_mode}' not in {VALID_EXPOSURE_MODES}")
        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.raw_magnitude = raw_magnitude
        self.raw_overlap = int(raw_overlap)
        self.raw_bank = raw_bank
        self.raw_split_axis = raw_split_axis
        self.raw_exposure_gain = float(raw_exposure_gain)
        self.exposures = 2 if self.raw_exposure_gain > 1.0 else 1
        self.raw_exposure_mode = raw_exposure_mode
        compress = self.exposures == 2 and raw_exposure_mode == "compress"
        # Channels of the band-wise tensors from the filterbank to the smoother,
        # of those from band_bn on, and of the frontend output.
        self.band_channels = int(mel_bins) * self.exposures
        self.calib_channels = int(mel_bins) if compress else self.band_channels
        self.out_channels = 1 if compress else self.exposures
        # Normalizing the low exposure needs the unclamped filterbank; never serialized.
        self._exposure_clamp = True
        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.fb: 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")

        # Two-exposure knee compressor (see VALID_EXPOSURE_MODES): band b of the
        # output mixes low band b and high band b. Trainable like the band stage.
        self.exposure_mix = None
        if compress:
            self.exposure_mix = layers.Conv2D(
                filters=int(self.mel_bins),
                kernel_size=(1, 1),
                use_bias=False,
                name=f"{name}_exposure_mix",
            )

        # Magnitude scaling (composable layer)
        self.mag_layer = MagnitudeScalingLayer(
            method=self.mag_scale,
            channels=self.calib_channels,
            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_filterbank_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), overlap=self.raw_overlap)
        self.geom = geom
        split = raw_filterbank_split(geom)
        if geom.fold % split:
            raise ValueError(f"fold {geom.fold} is not divisible by the filterbank split {split}")
        self.split = split
        # A tap split cuts along the kernel, so each partial is one row of it.
        taps = self.raw_split_axis == "taps"
        if taps and split != geom.kernel:
            raise ValueError(f"a tap split needs one partial per kernel row ({geom.kernel}), got {split}")
        conv_kwargs = dict(
            kernel_size=(1, 1 if taps else geom.kernel),
            strides=(1, geom.stride),
            padding="valid",
            use_bias=False,
            trainable=self.is_trainable,
        )
        if self.raw_bank == "fused":
            self.fb = [
                layers.Conv2D(filters=2 * self.band_channels, name=f"{name}_fb_{i}", **conv_kwargs)
                for i in range(self.split)
            ]
        else:
            self.fb_re = [
                layers.Conv2D(filters=int(self.mel_bins), name=f"{name}_fb_re_{i}", **conv_kwargs)
                for i in range(self.split)
            ]
            self.fb_im = [
                layers.Conv2D(filters=int(self.mel_bins), name=f"{name}_fb_im_{i}", **conv_kwargs)
                for i in range(self.split)
            ]

    def filterbank_convs(self) -> list[layers.Conv2D]:
        """Every convolution the raw filterbank is emitted as, in graph order."""
        return list(self.fb) if self.raw_bank == "fused" else [*self.fb_re, *self.fb_im]

    def scale_filterbank_bands(self, gains: np.ndarray) -> None:
        """Multiply band ``c``'s filters by ``gains[c]``, both components.

        Equalization's only handle on the filterbank, written once here because
        a fused bank carries the two components in one kernel (and, with a
        second exposure, both exposures of each: they scale together, so the
        high copy stays exactly ``raw_exposure_gain`` times the low one).
        """
        s = np.asarray(gains, dtype=np.float32)
        if s.shape != (int(self.mel_bins),):
            raise ValueError(f"expected one gain per band ({self.mel_bins}), got {s.shape}")
        per_kernel = np.concatenate([s] * (2 * self.exposures)) if self.raw_bank == "fused" else s
        for conv in self.filterbank_convs():
            (kernel,) = conv.get_weights()
            conv.set_weights([(kernel * per_kernel[None, None, None, :]).astype(np.float32)])

    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.filterbank_convs():
                raise RuntimeError("Raw frontend filterbank was not initialized")
            group = self.geom.fold if self.raw_split_axis == "taps" else self.geom.fold // self.split
            folded = tf.TensorShape([None, 1, self.geom.crop // self.geom.fold, group])
            for conv in self.filterbank_convs():
                conv.build(folded)
            self._seed_gabor_weights()

        band_shape = tf.TensorShape([None, 1, int(self.spec_width), self.band_channels])
        calib_shape = tf.TensorShape([None, 1, int(self.spec_width), self.calib_channels])
        if self.mode != "precomputed":
            if not self.band_smooth.built:
                self.band_smooth.build(band_shape)
            if self.exposure_mix is not None and not self.exposure_mix.built:
                self.exposure_mix.build(band_shape)
                m = int(self.mel_bins)
                w = np.zeros((1, 1, 2 * m, m), dtype=np.float32)
                w[0, 0, np.arange(m), np.arange(m)] = 1.0
                w[0, 0, m + np.arange(m), np.arange(m)] = EXPOSURE_MIX_INIT
                self.exposure_mix.set_weights([w])
            if not self.band_bn.built:
                self.band_bn.build(calib_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
        if g is None or not self.filterbank_convs():
            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]

        self.set_full_filterbank(_to_folded_kernel(real), _to_folded_kernel(imag))

    def partial_kernel(self, full: np.ndarray, i: int) -> np.ndarray:
        """Partial ``i``'s share of a full folded kernel ``[1, kernel, fold, M]``."""
        if self.raw_split_axis == "taps":
            return full[:, i : i + 1, :, :]
        group = self.geom.fold // self.split
        return full[:, :, i * group : (i + 1) * group, :]

    def full_filterbank(self) -> tuple[np.ndarray, np.ndarray]:
        """Reassemble the full folded kernels ``[1, kernel, fold, M]`` (real, imag).

        With a second exposure this is the low copy; the high copy is always it
        times ``raw_exposure_gain``.
        """
        g = self.geom
        m, e = int(self.mel_bins), self.exposures
        halves = []
        for convs, part in (
            ((self.fb, slice(0, m)), (self.fb, slice(e * m, e * m + m)))
            if self.raw_bank == "fused"
            else ((self.fb_re, slice(None)), (self.fb_im, slice(None)))
        ):
            full = np.zeros((1, g.kernel, g.fold, int(self.mel_bins)), dtype=np.float32)
            for i, conv in enumerate(convs):
                w = conv.get_weights()[0][..., part]
                if self.raw_split_axis == "taps":
                    full[:, i : i + 1, :, :] = w
                else:
                    group = g.fold // self.split
                    full[:, :, i * group : (i + 1) * group, :] = w
            halves.append(full)
        return halves[0], halves[1]

    def set_full_filterbank(self, re_kernel: np.ndarray, im_kernel: np.ndarray) -> None:
        """Load full folded kernels ``[1, kernel, fold, M]`` into the partial convolutions.

        With a second exposure they are the low copy; the high copy is set to
        them times ``raw_exposure_gain``.
        """
        g = self.raw_exposure_gain
        for i in range(self.split):
            if self.raw_bank == "fused":
                re_i, im_i = self.partial_kernel(re_kernel, i), self.partial_kernel(im_kernel, i)
                parts = [re_i, g * re_i, im_i, g * im_i] if self.exposures == 2 else [re_i, im_i]
                w = np.concatenate(parts, axis=-1).astype(np.float32)
                self.fb[i].set_weights([w])
            else:
                self.fb_re[i].set_weights([self.partial_kernel(re_kernel, i)])
                self.fb_im[i].set_weights([self.partial_kernel(im_kernel, i)])

    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, self.calib_channels])
        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)
        if self.exposure_mix is not None:
            # The smoother's output is its own INT8 tensor here (a convolution
            # follows), and the mix folds band_bn and band_relu into itself.
            y = self._quantized_activation(f"{self.name}_band_smooth", y)
            y = self._quantized_call(self.exposure_mix, 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 _part(i):
            if self.raw_split_axis == "taps":
                # Kernel row i: folded frames i, i + stride, ... -- one contiguous
                # `fold`-sample segment of every analysis window.
                return y[:, :, i : i + g.stride * (g.frames - 1) + 1, :]
            group = g.fold // self.split
            return y[:, :, :, i * group : (i + 1) * group]

        def _bank(convs, tag):
            # 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._exposure_clip(self._quantized_call(conv, _part(i))))
                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}", self._exposure_clip(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,
        # which equalization reads by the same names.
        if self.raw_bank == "fused":
            # One bank of 2*mel_bins filters; the components are its two halves.
            # Slicing a channel range is data movement, not arithmetic: the
            # halves inherit the bank's scale rather than crossing a grid. With
            # a second exposure each half is [low, high], so every band-wise
            # tensor after this carries both exposures as channels.
            z = _bank(self.fb, f"{self.name}_fb")
            m = self.band_channels
            re = self._quantized_activation(f"{self.name}_fb_re", z[:, :, :, :m])
            im = self._quantized_activation(f"{self.name}_fb_im", z[:, :, :, m:])
        else:
            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"))

        mag = self._band_envelope(re, im)

        mag = self._calibrate(mag, training, smooth=True)
        mag = mag[:, :, : self.spec_width, :]
        if self.out_channels == 1:
            return tf.transpose(mag, [0, 3, 2, 1])  # [B,mel,W,1]
        # [B,1,W,(exposure, band)] -> [B,mel,W,exposure]: the reshape only
        # regroups contiguous channels; the one transpose is the one above.
        mag = tf.reshape(mag, [-1, int(self.spec_width), self.exposures, int(self.mel_bins)])
        return tf.transpose(mag, [0, 3, 1, 2])

    def _exposure_clip(self, x):
        """Clamp a filterbank tensor to the shared [-1, 1] grid of a two-exposure bank.

        The converter folds this into the producing CONV_2D or ADD, so it adds no
        op; it is what makes the high exposure saturate at its own requantization
        instead of widening the grid for both. A no-op with one exposure.
        """
        if self.exposures == 1 or not self._exposure_clamp:
            return x
        return tf.clip_by_value(x, -EXPOSURE_CLAMP, EXPOSURE_CLAMP)

    def _band_envelope(self, re, im):
        """Combine the quadrature pair into a non-negative band envelope.

        The two options trade INT8 activation grids against how faithfully they
        follow the true modulus. Measured on a trained filterbank over 48
        validation recordings, per-band correlation with ``sqrt(re^2+im^2)``
        after the band smoother, and the SNR of the stage's own INT8 simulation:

            alpha_max (11 grids) .... r 0.9999, 20.9 dB
            l1         (7 grids) .... r 0.9980, 23.0 dB

        ``l1`` is what new models are built with: trained with teacher cropping it reached
        the same float cMAP and cut the post-training quantization loss from
        0.139 to 0.104.

        ``|x|`` is built from ReLU/SUB/ADD rather than ``tf.abs`` throughout.
        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")
        if self.raw_magnitude == "l1":
            # |re| + |im|: ~17% ripple against the true modulus on its own, but
            # the ripple beats at the carrier and the band smoother averages it
            # away, which is why the measured per-band correlation is 0.998.
            return self._quantized_activation(f"{self.name}_magnitude", a + b)

        # alpha-max-plus-beta-min: |z| ~= max(|re|,|im|) + 0.4*min(|re|,|im|),
        # within ~4% of the true magnitude. 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)
        )
        return self._quantized_activation(
            f"{self.name}_magnitude",
            blend + self._quantized_activation(f"{self.name}_abs_excess_scale", 0.6 * excess),
        )

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

    # 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,
            "raw_magnitude": self.raw_magnitude,
            "raw_overlap": self.raw_overlap,
            "raw_bank": self.raw_bank,
            "raw_split_axis": self.raw_split_axis,
            "raw_exposure_gain": self.raw_exposure_gain,
            "raw_exposure_mode": self.raw_exposure_mode,
            "name": self.name,
            "is_trainable": self.is_trainable,
        }
        base = super().get_config()
        base.update(cfg)
        return base

filterbank_convs()

Every convolution the raw filterbank is emitted as, in graph order.

Source code in birdnet_stm32/models/frontend.py
def filterbank_convs(self) -> list[layers.Conv2D]:
    """Every convolution the raw filterbank is emitted as, in graph order."""
    return list(self.fb) if self.raw_bank == "fused" else [*self.fb_re, *self.fb_im]

scale_filterbank_bands(gains)

Multiply band c's filters by gains[c], both components.

Equalization's only handle on the filterbank, written once here because a fused bank carries the two components in one kernel (and, with a second exposure, both exposures of each: they scale together, so the high copy stays exactly raw_exposure_gain times the low one).

Source code in birdnet_stm32/models/frontend.py
def scale_filterbank_bands(self, gains: np.ndarray) -> None:
    """Multiply band ``c``'s filters by ``gains[c]``, both components.

    Equalization's only handle on the filterbank, written once here because
    a fused bank carries the two components in one kernel (and, with a
    second exposure, both exposures of each: they scale together, so the
    high copy stays exactly ``raw_exposure_gain`` times the low one).
    """
    s = np.asarray(gains, dtype=np.float32)
    if s.shape != (int(self.mel_bins),):
        raise ValueError(f"expected one gain per band ({self.mel_bins}), got {s.shape}")
    per_kernel = np.concatenate([s] * (2 * self.exposures)) if self.raw_bank == "fused" else s
    for conv in self.filterbank_convs():
        (kernel,) = conv.get_weights()
        conv.set_weights([(kernel * per_kernel[None, None, None, :]).astype(np.float32)])

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.filterbank_convs():
            raise RuntimeError("Raw frontend filterbank was not initialized")
        group = self.geom.fold if self.raw_split_axis == "taps" else self.geom.fold // self.split
        folded = tf.TensorShape([None, 1, self.geom.crop // self.geom.fold, group])
        for conv in self.filterbank_convs():
            conv.build(folded)
        self._seed_gabor_weights()

    band_shape = tf.TensorShape([None, 1, int(self.spec_width), self.band_channels])
    calib_shape = tf.TensorShape([None, 1, int(self.spec_width), self.calib_channels])
    if self.mode != "precomputed":
        if not self.band_smooth.built:
            self.band_smooth.build(band_shape)
        if self.exposure_mix is not None and not self.exposure_mix.built:
            self.exposure_mix.build(band_shape)
            m = int(self.mel_bins)
            w = np.zeros((1, 1, 2 * m, m), dtype=np.float32)
            w[0, 0, np.arange(m), np.arange(m)] = 1.0
            w[0, 0, m + np.arange(m), np.arange(m)] = EXPOSURE_MIX_INIT
            self.exposure_mix.set_weights([w])
        if not self.band_bn.built:
            self.band_bn.build(calib_shape)
    self._build_mag_layer()
    super().build(input_shape)

partial_kernel(full, i)

Partial i's share of a full folded kernel [1, kernel, fold, M].

Source code in birdnet_stm32/models/frontend.py
def partial_kernel(self, full: np.ndarray, i: int) -> np.ndarray:
    """Partial ``i``'s share of a full folded kernel ``[1, kernel, fold, M]``."""
    if self.raw_split_axis == "taps":
        return full[:, i : i + 1, :, :]
    group = self.geom.fold // self.split
    return full[:, :, i * group : (i + 1) * group, :]

full_filterbank()

Reassemble the full folded kernels [1, kernel, fold, M] (real, imag).

With a second exposure this is the low copy; the high copy is always it times raw_exposure_gain.

Source code in birdnet_stm32/models/frontend.py
def full_filterbank(self) -> tuple[np.ndarray, np.ndarray]:
    """Reassemble the full folded kernels ``[1, kernel, fold, M]`` (real, imag).

    With a second exposure this is the low copy; the high copy is always it
    times ``raw_exposure_gain``.
    """
    g = self.geom
    m, e = int(self.mel_bins), self.exposures
    halves = []
    for convs, part in (
        ((self.fb, slice(0, m)), (self.fb, slice(e * m, e * m + m)))
        if self.raw_bank == "fused"
        else ((self.fb_re, slice(None)), (self.fb_im, slice(None)))
    ):
        full = np.zeros((1, g.kernel, g.fold, int(self.mel_bins)), dtype=np.float32)
        for i, conv in enumerate(convs):
            w = conv.get_weights()[0][..., part]
            if self.raw_split_axis == "taps":
                full[:, i : i + 1, :, :] = w
            else:
                group = g.fold // self.split
                full[:, :, i * group : (i + 1) * group, :] = w
        halves.append(full)
    return halves[0], halves[1]

set_full_filterbank(re_kernel, im_kernel)

Load full folded kernels [1, kernel, fold, M] into the partial convolutions.

With a second exposure they are the low copy; the high copy is set to them times raw_exposure_gain.

Source code in birdnet_stm32/models/frontend.py
def set_full_filterbank(self, re_kernel: np.ndarray, im_kernel: np.ndarray) -> None:
    """Load full folded kernels ``[1, kernel, fold, M]`` into the partial convolutions.

    With a second exposure they are the low copy; the high copy is set to
    them times ``raw_exposure_gain``.
    """
    g = self.raw_exposure_gain
    for i in range(self.split):
        if self.raw_bank == "fused":
            re_i, im_i = self.partial_kernel(re_kernel, i), self.partial_kernel(im_kernel, i)
            parts = [re_i, g * re_i, im_i, g * im_i] if self.exposures == 2 else [re_i, im_i]
            w = np.concatenate(parts, axis=-1).astype(np.float32)
            self.fb[i].set_weights([w])
        else:
            self.fb_re[i].set_weights([self.partial_kernel(re_kernel, i)])
            self.fb_im[i].set_weights([self.partial_kernel(im_kernel, i)])

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 _part(i):
        if self.raw_split_axis == "taps":
            # Kernel row i: folded frames i, i + stride, ... -- one contiguous
            # `fold`-sample segment of every analysis window.
            return y[:, :, i : i + g.stride * (g.frames - 1) + 1, :]
        group = g.fold // self.split
        return y[:, :, :, i * group : (i + 1) * group]

    def _bank(convs, tag):
        # 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._exposure_clip(self._quantized_call(conv, _part(i))))
            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}", self._exposure_clip(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,
    # which equalization reads by the same names.
    if self.raw_bank == "fused":
        # One bank of 2*mel_bins filters; the components are its two halves.
        # Slicing a channel range is data movement, not arithmetic: the
        # halves inherit the bank's scale rather than crossing a grid. With
        # a second exposure each half is [low, high], so every band-wise
        # tensor after this carries both exposures as channels.
        z = _bank(self.fb, f"{self.name}_fb")
        m = self.band_channels
        re = self._quantized_activation(f"{self.name}_fb_re", z[:, :, :, :m])
        im = self._quantized_activation(f"{self.name}_fb_im", z[:, :, :, m:])
    else:
        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"))

    mag = self._band_envelope(re, im)

    mag = self._calibrate(mag, training, smooth=True)
    mag = mag[:, :, : self.spec_width, :]
    if self.out_channels == 1:
        return tf.transpose(mag, [0, 3, 2, 1])  # [B,mel,W,1]
    # [B,1,W,(exposure, band)] -> [B,mel,W,exposure]: the reshape only
    # regroups contiguous channels; the one transpose is the one above.
    mag = tf.reshape(mag, [-1, int(self.spec_width), self.exposures, int(self.mel_bins)])
    return tf.transpose(mag, [0, 3, 1, 2])

compute_output_shape(input_shape)

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

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

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,
        "raw_magnitude": self.raw_magnitude,
        "raw_overlap": self.raw_overlap,
        "raw_bank": self.raw_bank,
        "raw_split_axis": self.raw_split_axis,
        "raw_exposure_gain": self.raw_exposure_gain,
        "raw_exposure_mode": self.raw_exposure_mode,
        "name": self.name,
        "is_trainable": self.is_trainable,
    }
    base = super().get_config()
    base.update(cfg)
    return base

raw_filterbank_split(geom)

Number of partial convolutions the filterbank is emitted as.

Source code in birdnet_stm32/models/frontend.py
def raw_filterbank_split(geom: "RawGeometry") -> int:
    """Number of partial convolutions the filterbank is emitted as."""
    return int(geom.kernel)

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)