250 lines
9.0 KiB
Go
250 lines
9.0 KiB
Go
// 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
|
|||
|
|
}
|