Files
tensor/signal/windows.go
T
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

213 lines
8.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}
}