Help us shape the future of Go! Take the 2026 Go Developer Survey

dsp

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: MIT Imports: 3 Imported by: 0

Documentation

Overview

Package dsp holds the sample-level building blocks: transforms, filters, resampling, windows and detection.

Everything here is pure: no I/O, no state that outlives a call, no goroutines. That is deliberate. The DSP is the part of murmur most likely to be tuned repeatedly against real rooms, and pure functions are the only kind that can be replayed from a captured WAV file and compared byte for byte.

Index

Constants

This section is empty.

Variables

View Source
var ErrNotImplemented = errors.New("dsp: not implemented")

ErrNotImplemented marks a declared-but-unwritten stage. Callers surface it verbatim so that a partial build never looks like a working one.

View Source
var ErrNotPowerOfTwo = errors.New("dsp: FFT size must be a power of two")

ErrNotPowerOfTwo is returned by NewFFT for non-power-of-two sizes. murmur controls every transform size it uses, so there is no reason to carry the complexity of Bluestein or mixed-radix support.

View Source
var ErrShortProbe = errors.New("dsp: probe or recording too short")

ErrShortProbe means the probe or recording was too short to measure anything.

Functions

func AlignOffset

func AlignOffset(x, ref []float32) int

AlignOffset finds where ref sits inside x, returning the sample offset. offset may be negative in principle (if the recording begins after the reference's start), which the caller must clamp before slicing.

func ApplyWindow

func ApplyWindow(x, w []float64)

ApplyWindow multiplies x by w elementwise, in place.

func ArgMax

func ArgMax(v []float64) (int, float64)

ArgMax returns the index and value of the largest element in v.

func Chirp

func Chirp(durSamples int, lowHz, highHz float64, sampleRate int, amplitude float64, fadeSamples int) []float32

Chirp generates a linear chirp from lowHz to highHz with cosine fades at both ends. The fades are not cosmetic even here. A probe that starts at full amplitude injects a broadband click into the measurement, and since the measurement's purpose is to characterise the channel, contaminating it with a transient is self-defeating.

func ConvolveDirect

func ConvolveDirect(a, b []float32) []float32

ConvolveDirect returns the full linear convolution of a and b. It is the reference implementation that ConvolveFFT is tested against.

func ConvolveFFT

func ConvolveFFT(signal, ir []float32) []float32

ConvolveFFT returns the full linear convolution of signal and ir using overlap-add. It falls back to ConvolveDirect for impulse responses short enough that the transform overhead would dominate.

func DBToLinear

func DBToLinear(db float64) float64

DBToLinear converts a decibel ratio to a linear factor.

func DesignBandpass

func DesignBandpass(lowHz, highHz, sampleRate float64, taps int, kind WindowKind) []float64

DesignBandpass designs a bandpass as the difference of two lowpasses. This is an approximation: the stopband skirts are set by the lowpass design rather than optimised, and passband gain is unity only near the centre. For a channel selector in front of a tone detector that is exactly the right trade - predictor accuracy matters, filter optimality does not.

func DesignFromResponse

func DesignFromResponse(mag []float64, _, taps int, kind WindowKind, maxGainDB float64) []float64

DesignFromResponse inverts a measured magnitude response into FIR taps. This is the mechanism behind per-device equalisation. The response is given as magnitude in linear units at bins spaced evenly across [0, sampleRate/2); taps must be odd. Frequencies with a near-zero response are clamped rather than inverted, because inverting a notch would demand enormous gain and generate more distortion than the notch itself costs.

func DesignLowpass

func DesignLowpass(cutoffHz, sampleRate float64, taps int, kind WindowKind) []float64

DesignLowpass designs an odd-length windowed-sinc lowpass with unity DC gain. Cutoff frequencies are in Hz. The window choice sets the stopband attenuation: Hann gives roughly -44 dB, Blackman roughly -74 dB, which is the difference between "the neighbouring band leaks a little" and "the neighbouring band is gone".

func EnergyDB

func EnergyDB(pcm []float32) float64

EnergyDB returns the level of pcm in dBFS (0 dB meaning a full-scale sine).

func EstimateDriftPPM

func EstimateDriftPPM(first, second []float32, interval time.Duration, sampleRate int) (float64, error)

EstimateDriftPPM measures coarse drift between two signals by finding the fractional delay that best aligns them, then dividing by their separation.

This is a deliberate simplification used by the calibration probe: rather than tracking phase continuously, send a known sync burst, wait a known interval, send it again, and measure how far the correlation peak moved. It produces one number over a long baseline, which is exactly what the rate controller needs to decide whether drift is a plausible explanation for rising BER .

func FillWindow

func FillWindow(w []float64, kind WindowKind)

FillWindow writes the window into w, whose length determines n.

func GoertzelMag

func GoertzelMag(x []float64, freq float64, sampleRate int) float64

GoertzelMag measures a single frequency, for pilots and calibration sweeps where only one tone matters.

func IsPow2

func IsPow2(n int) bool

IsPow2 reports whether n is a power of two.

func LinearToDB

func LinearToDB(x float64) float64

LinearToDB converts a linear ratio to decibels.

func Magnitude

func Magnitude(re, im []float64) []float64

Magnitude converts a complex spectrum to magnitudes in place-safe form.

func MatchedFilter

func MatchedFilter(x, ref []float32) []float32

MatchedFilter returns the cross-correlation of x with ref, computed by FFT. The sliding correlator in pkg/modulation is the right tool for finding a short sync in a long buffer, but it is O(len(x)*len(ref)), which is hopeless for a one-second probe. FFT correlation is O(n log n) and is what makes anything that characterises a room affordable. The result is the raw correlation (not energy-normalized). For alignment, the direct path is the dominant peak, so normalization is unnecessary; for detection with a threshold, use the normalized Correlate instead. Output index i corresponds to ref's first sample aligning with x's sample i - (len(ref) - 1), so the offset is recovered as peakIndex - (len(ref)-1).

func MeasureResponse

func MeasureResponse(probe, recorded []float32, lowHz, highHz float64, sampleRate, irTaps int) ([]float64, []float32, error)

MeasureResponse estimates the channel transfer function from a probe and its recording. probe is what was transmitted, recorded what came back, and lowHz/highHz the band the probe excited. The estimate is a regularized spectral division:

H(f) = Y(f) * conj(X(f)) / (|X(f)|^2 + eps)

The division is exact where the probe has energy, which is why a flat channel measures as exactly unity even though a linear chirp's spectrum ripples by more than 10 dB. Any scheme that estimated the response from the probe's magnitude instead of dividing by its complex spectrum would bake that ripple into the equaliser. Regularization matters where the probe has little energy: dividing by near-zero bins would amplify noise into an apparent notch that the equaliser would then try to fill with enormous gain. eps keeps the estimate bounded. Returns the magnitude response on the FFT grid (0 to sampleRate/2, length nfft/2) suitable for DesignFromResponse, and the impulse response truncated to irTaps samples, which is what a caller wants for reporting delay spread. The out-of-band bins are treated differently for the two outputs, and the distinction is not cosmetic. The response reports them as unity, so that the equaliser does not try to correct a band it was never asked about. The impulse response is computed with them *zeroed*, because leaving them at unity puts a delta at time zero that carries almost all the energy - and in an earlier version of this function that delta swamped the truncated impulse response so completely that a channel attenuating 1 kHz by 87 dB measured as flat.

func NextPow2

func NextPow2(n int) int

NextPow2 returns the smallest power of two greater than or equal to n.

func PeakIndex

func PeakIndex(v []float32) (int, float32)

PeakIndex returns the index and value of the largest element of v.

func RaisedCosinePulse

func RaisedCosinePulse(n int) []float32

RaisedCosinePulse returns a normalized raised-cosine (Hann) envelope of length n, for shaping individual modulation symbols.

func ResampleCubic

func ResampleCubic(dst, src []float32, ratio float64) int

ResampleCubic resamples src into dst using Catmull-Rom cubic interpolation. ratio is how many source samples are consumed per output sample. A ratio of 1.0 is a copy, 1.0001 models a receiver clock running 100 ppm fast, 0.9999 a receiver clock running slow. The first and last two output samples are zero because cubic interpolation needs a neighbour on each side; callers should treat a few samples of edge loss as expected rather than as signal. Returns the number of samples written.

func ResampleLinear

func ResampleLinear(dst, src []float32, ratio float64) int

ResampleLinear resamples src into dst with linear interpolation. It is the fallback for very short buffers where cubic context is unavailable, and the reference that ResampleCubic is tested against for ratio == 1.

func Scale

func Scale(pcm []float32, factor float64) []float32

Scale returns pcm scaled by factor, in place.

func SoftFromTones

func SoftFromTones(mag, dst []float64) []float64

SoftFromTones converts per-tone magnitudes into an approximate log-likelihood per tone, which is what a soft-decision decoder wants. The v0 MFSK decoder uses these only to flag erasures; a future LDPC stage can consume them directly.

func TaperEdges

func TaperEdges(pcm []float32, ramp int)

TaperEdges applies a raised-cosine fade in and out of pcm, in place. ramp is the fade length in samples on each side. A ramp of 5 ms at 48 kHz (240 samples) is the minimum that removes the click; longer is harmless and costs payload time, so callers pass the smallest value that satisfies §N4. If the signal is shorter than twice the ramp the whole signal is tapered, which is degenerate but never produces a discontinuity.

func Window

func Window(kind WindowKind, n int) []float64

Window returns a window of length n.

Types

type DriftEstimator

type DriftEstimator struct {
	// contains filtered or unexported fields
}

DriftEstimator tracks the residual sample-clock offset between two machines.

The algorithm, once written:

  1. Each burst carries a pilot tone at a known frequency. Measuring its phase at the start of a burst and again at the end gives a phase increment over a known time interval.
  2. An unwrapped residual in that increment is proportional to the ratio between the two sample clocks. Unwrapping across bursts is what makes the estimate unambiguous; within a single burst it is not.
  3. The result feeds Resampler.SetRatio, which slews rather than jumps.

It is left unimplemented on purpose. Writing it before the OFDM PHY exists would mean guessing at the pilot structure, and a drift estimator tuned against invented pilots is worse than no estimator at all. MFSK does not need it: its per-symbol Goertzel windows are short enough that drift over one symbol is negligible, and each burst re-syncs from its own preamble anyway.

func NewDriftEstimator

func NewDriftEstimator(sampleRate int, freqHz float64, window int) *DriftEstimator

NewDriftEstimator returns an estimator for a pilot at freqHz, at the given sample rate. window is how many recent estimates to smooth over.

func (*DriftEstimator) Observe

func (d *DriftEstimator) Observe(phaseRadians float64, at time.Time)

Observe records a pilot phase measurement taken at a known instant.

func (*DriftEstimator) PPM

func (d *DriftEstimator) PPM() (float64, error)

PPM returns the estimated drift in parts per million.

func (*DriftEstimator) Ratio

func (d *DriftEstimator) Ratio() (float64, error)

Ratio returns the source-per-output resampling ratio that would cancel the estimated drift, suitable for Resampler.SetRatio.

func (*DriftEstimator) Valid

func (d *DriftEstimator) Valid() bool

Valid reports whether the estimator has enough observations to be trusted.

type FFT

type FFT struct {
	N int
	// contains filtered or unexported fields
}

FFT is a precomputed radix-2 decimation-in-time plan for one fixed size.

Tables are built once so that a session running 32 transforms per second does no trigonometric work in its hot path.

func NewFFT

func NewFFT(n int) (*FFT, error)

NewFFT builds a plan for n samples. n must be a power of two.

func (*FFT) BinFor

func (p *FFT) BinFor(freq float64, sampleRate int) int

BinFor returns the FFT bin containing freq, for a plan at the given rate.

func (*FFT) BinHz

func (p *FFT) BinHz(bin, sampleRate int) float64

BinHz converts a bin index to a frequency for a plan at the given rate.

func (*FFT) Forward

func (p *FFT) Forward(re, im []float64)

Forward transforms (re, im) in place. Both slices must have length N.

func (*FFT) Inverse

func (p *FFT) Inverse(re, im []float64)

Inverse transforms (re, im) in place, including the 1/N scaling, so that Forward followed by Inverse is the identity to within floating-point error.

func (*FFT) RealForward

func (p *FFT) RealForward(x []float64) ([]float64, []float64)

RealForward transforms a purely real signal, returning the full complex spectrum. A half-spectrum packing would halve the cost, but at 32 transforms per second on 1024 points the optimisation would buy microseconds and cost clarity; revisit only if profiling ever says otherwise.

type FIR

type FIR struct {
	// contains filtered or unexported fields
}

FIR is a streaming direct-form FIR filter.

func NewFIR

func NewFIR(taps []float64) *FIR

NewFIR returns a filter with the given taps. A zero-length tap list yields a pass-through filter, which keeps callers free of nil checks.

func (*FIR) GroupDelay

func (f *FIR) GroupDelay() int

GroupDelay is the filter's delay in samples. Odd-length symmetric filters have an integer group delay, which is why the designers below force an odd tap count: a fractional-delay filter would smear the sync correlation peak that the whole receiver is timed against.

func (*FIR) Process

func (f *FIR) Process(dst, src []float32)

Process filters src into dst, which may alias src. len(dst) must be at least len(src).

func (*FIR) Reset

func (f *FIR) Reset()

Reset clears the filter's history, restoring its initial state.

func (*FIR) Taps

func (f *FIR) Taps() []float64

Taps returns a copy of the filter's coefficients.

type Resampler

type Resampler struct {
	// contains filtered or unexported fields
}

Resampler performs continuous fractional resampling across many Process calls while tracking a slowly changing ratio. The burst-oriented design above would let a caller simply resample each burst with its own estimate, and for a short burst that is fine. Across a long burst train it is not: restarting the interpolation at every block boundary puts a phase discontinuity in the middle of a symbol. This type keeps one sample of history and a running fractional read pointer so the phase is continuous for as long as Process keeps being called.

func NewResampler

func NewResampler(ratio float64) *Resampler

NewResampler returns a resampler with the given initial ratio.

func (*Resampler) Process

func (r *Resampler) Process(dst, src []float32) int

Process appends src to the resampler's buffer and writes as many output samples as the accumulated input supports. Returns the number written.

func (*Resampler) Ratio

func (r *Resampler) Ratio() float64

Ratio returns the ratio currently in effect.

func (*Resampler) Reset

func (r *Resampler) Reset()

Reset clears the resampler's stream state, keeping the current ratio.

func (*Resampler) SetRatio

func (r *Resampler) SetRatio(ratio float64)

SetRatio changes the resampling ratio. The change is slewed rather than applied at once, because an instantaneous step is a phase jump, and a phase jump in a tracked loop is a cycle slip.

type ToneBank

type ToneBank struct {
	// contains filtered or unexported fields
}

ToneBank measures energy at a fixed set of frequencies.

func NewToneBank

func NewToneBank(freqs []float64, sampleRate int) *ToneBank

NewToneBank precomputes Goertzel coefficients for freqs at the given rate.

func (*ToneBank) Frequencies

func (b *ToneBank) Frequencies() []float64

Frequencies returns the bank's tone frequencies.

func (*ToneBank) Magnitudes

func (b *ToneBank) Magnitudes(x, dst []float64) []float64

Magnitudes fills dst (length >= len(freqs)) with the magnitude of each tone over x, and returns dst[:len(freqs)].

Magnitudes are normalized so that a full-scale sine at the tone frequency reads approximately 1.0, which makes thresholds portable between the simulator and real hardware instead of being expressed in arbitrary units.

type WindowKind

type WindowKind int

WindowKind selects a spectral window.

const (
	// WindowRect is no window at all.
	WindowRect WindowKind = iota
	// WindowHann is the default for spectral work: low sidelobes, moderate main
	// lobe, and it decays to exactly zero at both ends, which matters when the
	// window doubles as an edge taper.
	WindowHann
	// WindowHamming trades sidelobe height for a narrower main lobe.
	WindowHamming
	// WindowBlackman gives very low sidelobes at the cost of a wide main lobe,
	// useful when a strong nearby tone would otherwise leak across the band.
	WindowBlackman
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL