Files

88 lines
3.4 KiB
Go
Raw Permalink 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 (
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Zero-phase filtering. A causal IIR filter delays every
// feature by its group delay; running the same filter backwards over
// its own output doubles the magnitude response and cancels the phase
// exactly, because the reversed pass carries the conjugated transfer
// function. The cost is the edge question: the first pass starts from
// rest against a signal that did not, and its start-up transient would
// otherwise bleed into the answer's first samples.
// Filtfilt filters the rank-1 real signal x forwards and backwards
// with the same transfer function the filter designs hand out (b over
// a, a[0] non-zero and normalised away), and returns a result the
// length of x whose magnitude response is the square of the one-pass
// filter's and whose phase is zero: a sinusoid in the passband comes
// out aligned with its input, not lagged, and the group delay at
// every frequency is 0 samples. The dtype contract is FilterApply's:
// float32 and float64 keep their dtype, other real dtypes widen to
// float64.
//
// Edge initialisation: both ends of the signal are extended by an
// even reflection of pad = 3·(nfilt−1) samples (nfilt the longer
// coefficient list), the edge sample not repeated, so the extension
// is continuous in value at both seams. The pad length is the
// standard three times the filter's memory: for a stable filter the
// start-up transient decays like the impulse response tail, which
// 3·(nfilt−1) samples of a direct-form kernel drive below the rounding
// floor for every design this package produces. The forward pass runs
// over the padded signal, the signal is time-reversed, the second
// pass runs, and the pad region of both ends is cropped away, so the
// residual seam error, a slope kink the reflection cannot hide from a
// filter that differentiates, stays outside the returned range. A
// signal no longer than the pad (length ≤ 3·(nfilt−1)) leaves nothing
// to return once both transient regions are excluded and is refused.
func Filtfilt(b, a []float64, x *core.Array) (*core.Array, error) {
const name = "Filtfilt"
bc, ac, out, err := filterPrepare(name, b, a, x)
if err != nil {
return nil, err
}
n := x.Len()
nfilt := max(len(b), len(a))
pad := 3 * (nfilt - 1)
if n <= pad {
return nil, base.Errf("%s: the length-%d signal must exceed the pad length 3·(%d−1) = %d this filter needs",
name, n, nfilt, pad)
}
src := widenFloats(x)
// The reflected extension: left pad holds x[pad], x[pad−1], …,
// x[1] in reverse order, the right pad mirrors it, and the seam at
// either end repeats no sample (whole-sample symmetric even
// reflection).
padded := make([]float64, n+2*pad)
copy(padded[pad:pad+n], src)
for k := range pad {
padded[k] = src[pad-k]
padded[pad+n+k] = src[n-2-k]
}
p := len(padded)
fwd := make([]float64, p)
filterSweep(bc, ac, padded, fwd)
rev := make([]float64, p)
for i := range p {
rev[i] = fwd[p-1-i]
}
back := make([]float64, p)
filterSweep(bc, ac, rev, back)
outF := out.RawFloats()
if outF == nil || out.Strided() {
for t := range n {
out.SetFloatAt(t, back[p-1-pad-t])
}
} else {
for t := range n {
outF[t] = back[p-1-pad-t]
}
}
return out, nil
}