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 ¶
- Variables
- func AlignOffset(x, ref []float32) int
- func ApplyWindow(x, w []float64)
- func ArgMax(v []float64) (int, float64)
- func Chirp(durSamples int, lowHz, highHz float64, sampleRate int, amplitude float64, ...) []float32
- func ConvolveDirect(a, b []float32) []float32
- func ConvolveFFT(signal, ir []float32) []float32
- func DBToLinear(db float64) float64
- func DesignBandpass(lowHz, highHz, sampleRate float64, taps int, kind WindowKind) []float64
- func DesignFromResponse(mag []float64, _, taps int, kind WindowKind, maxGainDB float64) []float64
- func DesignLowpass(cutoffHz, sampleRate float64, taps int, kind WindowKind) []float64
- func EnergyDB(pcm []float32) float64
- func EstimateDriftPPM(first, second []float32, interval time.Duration, sampleRate int) (float64, error)
- func FillWindow(w []float64, kind WindowKind)
- func GoertzelMag(x []float64, freq float64, sampleRate int) float64
- func IsPow2(n int) bool
- func LinearToDB(x float64) float64
- func Magnitude(re, im []float64) []float64
- func MatchedFilter(x, ref []float32) []float32
- func MeasureResponse(probe, recorded []float32, lowHz, highHz float64, sampleRate, irTaps int) ([]float64, []float32, error)
- func NextPow2(n int) int
- func PeakIndex(v []float32) (int, float32)
- func RaisedCosinePulse(n int) []float32
- func ResampleCubic(dst, src []float32, ratio float64) int
- func ResampleLinear(dst, src []float32, ratio float64) int
- func Scale(pcm []float32, factor float64) []float32
- func SoftFromTones(mag, dst []float64) []float64
- func TaperEdges(pcm []float32, ramp int)
- func Window(kind WindowKind, n int) []float64
- type DriftEstimator
- type FFT
- type FIR
- type Resampler
- type ToneBank
- type WindowKind
Constants ¶
This section is empty.
Variables ¶
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.
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.
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 ¶
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 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 ¶
ConvolveDirect returns the full linear convolution of a and b. It is the reference implementation that ConvolveFFT is tested against.
func ConvolveFFT ¶
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 ¶
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 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 ¶
GoertzelMag measures a single frequency, for pilots and calibration sweeps where only one tone matters.
func LinearToDB ¶
LinearToDB converts a linear ratio to decibels.
func MatchedFilter ¶
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 RaisedCosinePulse ¶
RaisedCosinePulse returns a normalized raised-cosine (Hann) envelope of length n, for shaping individual modulation symbols.
func ResampleCubic ¶
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 ¶
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 SoftFromTones ¶
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 ¶
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.
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:
- 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.
- 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.
- 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 (*FFT) Inverse ¶
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 ¶
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 ¶
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 ¶
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 ¶
Process filters src into dst, which may alias src. len(dst) must be at least len(src).
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 ¶
NewResampler returns a resampler with the given initial ratio.
func (*Resampler) Process ¶
Process appends src to the resampler's buffer and writes as many output samples as the accumulated input supports. Returns the number written.
type ToneBank ¶
type ToneBank struct {
// contains filtered or unexported fields
}
ToneBank measures energy at a fixed set of frequencies.
func NewToneBank ¶
NewToneBank precomputes Goertzel coefficients for freqs at the given rate.
func (*ToneBank) Frequencies ¶
Frequencies returns the bank's tone frequencies.
func (*ToneBank) Magnitudes ¶
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 )