Files
tensor/signal/resample.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

250 lines
9.0 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"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Sample-rate conversion. Decimation and rational resampling run a
// linear-phase FIR anti-alias filter in the time domain, so they suit
// aperiodic streams; ResampleFourier is the exact band-limited
// resample of the Fourier definition and suits whole records.
// kaiserI0 evaluates the modified Bessel function of the first kind
// at a real order zero by its power series, which converges in a few
// dozen terms across the window betas any reasonable design uses.
func kaiserI0(x float64) float64 {
sum, term := 1.0, 1.0
half := x / 2
for k := 1; k < 64; k++ {
term *= (half * half) / float64(k*k)
sum += term
if term < 1e-18*sum {
break
}
}
return sum
}
// kaiserSinc builds the odd-length FIR low-pass of a windowed sinc:
// gain 1 at DC, cutoff fc normalised to the sampling rate (half the
// output rate after conversion), Kaiser taper with beta 8.6, which
// puts the stopband around 80 dB. The cutoff rides the middle of the
// transition band, so the design slightly attenuates the top of the
// passband by construction; tests pin that below a dB.
func kaiserSinc(taps int, fc float64) []float64 {
if taps%2 == 0 {
taps++
}
// A 1-tap kernel has no window to apply: the ratio inside r
// divides by taps−1 = 0 and turns every coefficient NaN. The
// kernel is the identity, gain 1 at DC, which is exactly what a
// one-tap low-pass means: keep the sample, filter nothing.
if taps == 1 {
return []float64{1}
}
const beta = 8.6
i0b := kaiserI0(beta)
h := make([]float64, taps)
centre := (taps - 1) / 2
sum := 0.0
for i := range taps {
r := 2*float64(i)/float64(taps-1) - 1
w := kaiserI0(beta*math.Sqrt(math.Max(0, 1-r*r))) / i0b
arg := 2 * fc * float64(i-centre)
s := 1.0
if arg != 0 {
s = math.Sin(math.Pi*arg) / (math.Pi * arg)
}
h[i] = 2 * fc * s * w
sum += h[i]
}
for i := range h {
h[i] /= sum
}
return h
}
// Decimate reduces the sample rate by the integer factor: a Kaiser
// tapered FIR anti-alias filter runs first, the filter's group delay
// is compensated, and every factor-th sample of the compensated
// series is kept. The passband ends at nine tenths of the new
// Nyquist; the transition band then reaches its stopband floor of
// about 80 dB before the new Nyquist, so everything that would fold
// into the output band is suppressed. Taps sets the filter length; a
// non-positive value means 32·factor+1, which is the length that
// fits that transition. Output starts once the filter has full
// context, so the result holds about (n − taps)/factor samples of an
// input of n; when the tap count leaves no sample with full context,
// the result is empty rather than read past the end of the signal.
func Decimate(data *core.Array, factor, taps int) (*core.Array, error) {
const name = "Decimate"
if data.NDim() != 1 {
return nil, base.Errf("%s: the series must be a vector, got shape %s", name, base.ShapeText(data.Shape()))
}
if data.Dtype() == core.Complex {
return nil, base.Errf("%s: complex series are not supported", name)
}
if factor < 2 {
return nil, base.Errf("%s: the factor must be at least 2, got %d", name, factor)
}
n := data.Len()
if n == 0 {
return nil, base.Errf("%s: the series must not be empty", name)
}
if taps <= 0 {
taps = 32*factor + 1
}
if taps%2 == 0 {
taps++
}
if taps >= n {
return nil, base.Errf("%s: %d taps against %d samples leaves nothing after the filter delay", name, taps, n)
}
h := kaiserSinc(taps, 0.45/float64(factor))
filtered, err := FilterApply(h, []float64{1}, data)
if err != nil {
return nil, base.Errf("%s: %w", name, err)
}
delay := (taps - 1) / 2
// Sample the compensated series (compensated index m lives at
// filtered[m + delay]) from where its filter context is complete:
// m + delay ≥ taps − 1 means m ≥ delay, so the first kept index
// is the first multiple of factor at or past delay, and the last
// is the last multiple whose compensated read still lies inside
// the signal. When no multiple qualifies, the result is empty:
// a negative numerator must not be left to truncate toward zero,
// which used to turn "no fully filtered sample" into one read
// past the end of the signal.
skip := (delay + factor - 1) / factor
outLen := 0
if span := filtered.Len() - 1 - delay; span >= 0 {
outLen = max(span/factor-skip+1, 0)
}
out := core.New(core.Float, outLen)
vals := out.RawFloats()
// FilterApply keeps the input dtype, so a float32 series comes
// back with a float32 payload: read it through widenFloats (the
// FloatAt widening the rest of the package reads with) instead of
// the nil float64 payload.
src := widenFloats(filtered)
for i := range outLen {
vals[i] = src[skip*factor+delay+i*factor]
}
return out, nil
}
// Resample converts the sample rate by the rational factor up/down:
// the series is filtered on the up-sampled grid by a Kaiser tapered
// FIR at the tighter of the two Nyquists with the up gain folded in,
// and every down-th sample of the compensated result is kept. Up and
// down must be at least 1 and not both 1; taps sets the kernel length
// on the up-sampled grid, a non-positive value meaning
// 32·max(up, down)+1, the length that fits the anti-alias transition.
// At the series ends the filter reads past the data: the up-sampled
// grid counts as zeros outside the input, so the head and the tail
// after the delay compensation are the sums over those zeros, not
// wrapped or skipped samples.
func Resample(data *core.Array, up, down, taps int) (*core.Array, error) {
const name = "Resample"
if data.NDim() != 1 {
return nil, base.Errf("%s: the series must be a vector, got shape %s", name, base.ShapeText(data.Shape()))
}
if data.Dtype() == core.Complex {
return nil, base.Errf("%s: complex series are not supported", name)
}
if up < 1 || down < 1 || (up == 1 && down == 1) {
return nil, base.Errf("%s: the rate change up/down must not be the identity %d/%d", name, up, down)
}
n := data.Len()
if n == 0 {
return nil, base.Errf("%s: the series must not be empty", name)
}
if taps <= 0 {
taps = 32*max(up, down) + 1
}
if taps%2 == 0 {
taps++
}
if taps > up*n {
return nil, base.Errf("%s: %d taps against %d up-sampled samples leaves nothing after the filter delay", name, taps, up*n)
}
// The source is read through widenFloats: a float32 or int series
// carries no float64 payload, and widenFloats is the exact FloatAt
// widening the rest of the package reads with.
src := widenFloats(data)
// Cutoff on the up-sampled grid: the smaller Nyquist of input and
// output, in cycles per up-sampled sample.
fc := 0.5 * math.Min(1/float64(up), 1/float64(down))
h := kaiserSinc(taps, fc)
delay := (taps - 1) / 2
outLen := (n*up + down - 1) / down
out := core.New(core.Float, outLen)
vals := out.RawFloats()
for m := range outLen {
// Output sample m is up-sample index m·down; the filter
// centred there sums the inputs within its support. Only k
// with centre − k·up inside [0, taps) contributes, so the loop
// runs over that window alone, in the same ascending order.
centre := delay + m*down
kmin := max(0, (centre-taps+1+up-1)/up)
kmax := min(n-1, centre/up)
total := 0.0
for k := kmin; k <= kmax; k++ {
total += h[centre-k*up] * src[k]
}
// j runs over the kernel, so the compensated sample sits at
// up-index centre − delay; scale by up for the zero-stuffed
// grid's unit gain.
vals[m] = float64(up) * total
}
return out, nil
}
// ResampleFourier resamples a series to exactly size samples by the
// Fourier (band-limited) definition: the spectrum's bins are kept,
// padded with zeros or truncated at the fold, and scaled so the
// result carries the same tone amplitudes as the input. It is exact
// for series band-limited below the new Nyquist and treats the input
// as one period, the same convention AnalyticSignal uses. Signals
// with energy past the new Nyquist lose it, which is the brick-wall
// anti-alias this resample implies.
func ResampleFourier(data *core.Array, size int) (*core.Array, error) {
const name = "ResampleFourier"
if data.NDim() != 1 {
return nil, base.Errf("%s: the series must be a vector, got shape %s", name, base.ShapeText(data.Shape()))
}
n := data.Len()
if n == 0 {
return nil, base.Errf("%s: the series must not be empty", name)
}
if size < 1 {
return nil, base.Errf("%s: the target size must be at least 1, got %d", name, size)
}
spec, err := RFFT(data)
if err != nil {
return nil, base.Errf("%s: %w", name, err)
}
oldHalf := spec.Len()
newHalf := size/2 + 1
scaled := make([]complex128, newHalf)
ratio := float64(size) / float64(n)
for k := range min(oldHalf, newHalf) {
scaled[k] = complex(ratio, 0) * spec.RawComplexes()[k]
}
target, err := core.FromComplexes(scaled, newHalf)
if err != nil {
return nil, base.Errf("%s: %w", name, err)
}
out, err := IRFFT(target, size)
if err != nil {
return nil, base.Errf("%s: %w", name, err)
}
return out, nil
}