Files
tensor/signal/windows.go
T

213 lines
8.4 KiB
Go
Raw Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package signal
import (
"math"
"sourcedock.dev/petrbalvin/tensor/internal/base"
)
// The public window catalogue. Every builder returns a fresh
// preallocated []float64 of n samples. Two length conventions exist and
// the periodic flag picks between them: the symmetric window divides
// its argument by n−1, so its first and last samples coincide (the
// right shape for a finite impulse-response design) and the periodic
// window divides by n, which makes the sequence one exact period of
// its underlying continuous shape (the right shape for spectral
// estimates, where the segment is treated as one period and the
// doubled lobes of the symmetric tail would leak). periodic is the
// last argument, false everywhere it does not matter; a one-sample
// window is the single value 1 in both conventions. Every builder
// refuses n below 1.
// WindowBox returns the untapered box: n ones, the window that
// filters nothing. The periodic flag changes nothing here and exists
// only for signature uniformity across the catalogue.
func WindowBox(n int, periodic bool) ([]float64, error) {
w, _, one, err := windowSetup("WindowBox", n, periodic)
if err != nil || one {
return w, err
}
for i := range w {
w[i] = 1
}
return w, nil
}
// WindowHann returns the Hann window, the raised cosine
// 0.5 − 0.5·cos(2πx), the gentlest of the generalised cosines: zero
// at the edges in both conventions, −6 dB per octave sidelobe roll-off.
func WindowHann(n int, periodic bool) ([]float64, error) {
return generalCosine("WindowHann", n, hannCoeffs, periodic)
}
// WindowHamming returns the Hamming window, the raised cosine on a
// pedestal 0.54 − 0.46·cos(2πx): the nonzero pedestal cancels the
// Hann window's first sidelobe, at the cost of a floor the outer
// sidelobes never drop below.
func WindowHamming(n int, periodic bool) ([]float64, error) {
return generalCosine("WindowHamming", n, hammingCoeffs, periodic)
}
// WindowBlackman returns the (exact) Blackman window
// 0.42 − 0.5·cos(2πx) + 0.08·cos(4πx): two cosines instead of
// Hann's one buy sidelobes below −58 dB at the price of a doubled
// main lobe.
func WindowBlackman(n int, periodic bool) ([]float64, error) {
return generalCosine("WindowBlackman", n, blackmanCoeffs, periodic)
}
// WindowBlackmanHarris returns the four-term Blackman-Harris window,
// the minimum-sidelobe member of the generalised-cosine family with
// four terms: sidelobes below −92 dB, a main lobe three Hann lobes
// wide. The usual choice when dynamic range matters more than
// resolution.
func WindowBlackmanHarris(n int, periodic bool) ([]float64, error) {
return generalCosine("WindowBlackmanHarris", n, blackmanHarrisCoeffs, periodic)
}
// WindowFlatTop returns the flat-top window, the five-term generalised
// cosine whose main lobe is flat to within a hundredth of a decibel:
// the amplitude of a spectral line reads true to the window's ripple
// no matter where the line falls between bins, which is what the wide
// lobe buys. The edge samples are slightly negative, so the window is
// for amplitude metrology, not for filtering.
func WindowFlatTop(n int, periodic bool) ([]float64, error) {
return generalCosine("WindowFlatTop", n, flatTopCoeffs, periodic)
}
// WindowBartlett returns the Bartlett window, the triangle 1 − |2x − 1|:
// the piecewise-linear taper, zero at both edges, whose sidelobes sit
// between the box's and Hann's. It is the Fejér kernel of the box and
// is non-negative everywhere, which the generalised cosines are not.
func WindowBartlett(n int, periodic bool) ([]float64, error) {
w, den, one, err := windowSetup("WindowBartlett", n, periodic)
if err != nil || one {
return w, err
}
for i := range w {
w[i] = 1 - math.Abs(2*float64(i)/den-1)
}
return w, nil
}
// WindowKaiser returns the Kaiser window of parameter beta: the
// modified Bessel taper I0(beta·sqrt(1 − r²))/I0(beta) over the
// normalised radius r = 2x − 1, the adjustable compromise between main
// lobe width and sidelobe height. beta 0 is the box; near 5 the
// sidelobes sit around −30 dB, near 9 around −60 dB, and the usual
// rule of thumb spends about 2.2·beta decibels of stopband. beta must
// be finite and non-negative.
func WindowKaiser(n int, beta float64, periodic bool) ([]float64, error) {
const name = "WindowKaiser"
if beta < 0 || math.IsNaN(beta) || math.IsInf(beta, 0) {
return nil, base.Errf("%s: beta must be finite and non-negative, got %g", name, beta)
}
w, den, one, err := windowSetup(name, n, periodic)
if err != nil || one {
return w, err
}
i0b := kaiserI0(beta)
for i := range w {
r := 2*float64(i)/den - 1
// The radius can leave the unit disk by a rounding step at
// the edges; the squared radius is clamped so the square
// root stays real.
w[i] = kaiserI0(beta*math.Sqrt(math.Max(0, 1-r*r))) / i0b
}
return w, nil
}
// WindowCosine returns the cosine (sine) window sin(πx): one positive
// half-period whose derivative vanishes at neither edge, the taper of
// the MDCT and of the minimum-tap Blackman derivations.
func WindowCosine(n int, periodic bool) ([]float64, error) {
w, den, one, err := windowSetup("WindowCosine", n, periodic)
if err != nil || one {
return w, err
}
for i := range w {
w[i] = math.Sin(math.Pi * float64(i) / den)
}
return w, nil
}
// The generalised-cosine coefficient sets, with the signs carried in
// the table: sample i of the symmetric n-window is
// Σ_k c_k·cos(2πk·i/(n−1)). The flat-top set is written as the exact
// fractions of 19 the amplitude-calibration standard defines it by.
var (
hannCoeffs = []float64{0.5, -0.5}
hammingCoeffs = []float64{0.54, -0.46}
blackmanCoeffs = []float64{0.42, -0.5, 0.08}
blackmanHarrisCoeffs = []float64{0.35875, -0.48829, 0.14128, -0.01168}
flatTopCoeffs = []float64{4.096 / 19, -7.916 / 19, 5.268 / 19, -1.588 / 19, 0.132 / 19}
)
// generalCosine evaluates the generalised cosine family: the sum of
// signed cosine terms c_k over x = i/den, the denominator chosen by
// the symmetric or periodic convention. The term arguments keep the
// exact shape 2πk·i/den the package's spectral estimates have always
// fed their Hann and Hamming windows, so the periodic two-term
// members reproduce the legacy windowTaper outputs bit for bit.
func generalCosine(name string, n int, coeffs []float64, periodic bool) ([]float64, error) {
w, den, one, err := windowSetup(name, n, periodic)
if err != nil || one {
return w, err
}
for i := range w {
acc := coeffs[0]
for k, c := range coeffs[1:] {
// The explicit conversion is the FMA fence: the v4 build
// contracts a bare product-plus-add and the window bits
// drift a ulp from the portable build's, which the
// bit-identity pin holds.
acc += float64(c * math.Cos(2*math.Pi*float64(k+1)*float64(i)/den))
}
w[i] = acc
}
return w, nil
}
// windowSetup validates the requested length, prepares the window
// buffer and returns the denominator the window argument divides by:
// n−1 for the symmetric convention, n for the periodic one. The
// one-sample window is the constant 1 in both, so the flag-one return
// hands back that finished window and no builder reaches a zero
// denominator.
func windowSetup(name string, n int, periodic bool) (w []float64, den float64, one bool, err error) {
if n < 1 {
return nil, 0, false, base.Errf("%s: n must be at least 1, got %d", name, n)
}
if n == 1 {
return []float64{1}, 1, true, nil
}
if periodic {
return make([]float64, n), float64(n), false, nil
}
return make([]float64, n), float64(n - 1), false, nil
}
// windowTaper builds the named window of the given length for the
// spectral estimates, routing through the catalogue's periodic forms:
// "hann" and "hamming" are WindowHann and WindowHamming at
// periodic=true, "box" is WindowBox, and the outputs are the same
// bits the dedicated loops this replaces produced for every length
// the callers accept (they refuse n below 2, where the two
// conventions differ). The legacy names stay because WelchPSD, STFT
// and Spectrogram publish them.
func windowTaper(window string, n int) ([]float64, error) {
switch window {
case "box":
return WindowBox(n, true)
case "hann":
return WindowHann(n, true)
case "hamming":
return WindowHamming(n, true)
default:
return nil, base.Errf("unknown window %q, want hann, hamming or box", window)
}
}