spectrum

package
v0.7.4 Latest Latest
Warning

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

Go to latest
Published: Jul 19, 2026 License: Apache-2.0 Imports: 9 Imported by: 0

Documentation

Overview

Package spectrum produces frame-rate-limited windowed FFT magnitude frames from a stream of IQ chunks. It is the producer side of the live spectrum / waterfall feature — a Frame goes onto an SSE/WS wire at a configurable refresh rate (10 Hz by default), low enough that the producer's CPU cost is negligible relative to control-channel decode and the wire bandwidth stays reasonable for LAN clients.

Design notes:

  • One FFT per Frame, not per IQ chunk. The producer consumes chunks continuously but only invokes an FFT once per 1/FrameRate interval. Intermediate chunks are dropped — for a 2.048 MS/s control SDR running 10 fps, ~204k samples elapse between frames; processing them all would burn CPU for no visual benefit.

  • Power normalized to dBFS so the wire representation is human- readable and small (float32 per bin). Subscribers receive freshly-allocated slices to avoid coupling consumers.

  • The producer doesn't own the IQ source — it consumes from a caller-provided <-chan []complex64 (typically an iqtap.Subscriber.C). Lifecycle is "run until ctx cancels OR the input channel closes."

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Percentile added in v0.4.3

func Percentile(bins []float32, p float64) float32

Percentile returns the p-quantile (p in 0..1) of bins via copy+sort, the robust noise-floor estimate (np.percentile with nearest-rank interpolation). An empty slice returns 0; p is clamped into range.

func PowerDB added in v0.4.0

func PowerDB(plan fft.Plan, win []float64, winSum float64, in []complex64, bufIn, bufOut []complex128, dst []float32)

PowerDB computes the FFT-shifted dBFS power spectrum of in into dst. It is the single source of truth for the spectrum math, shared by the streaming Producer and one-shot callers (the Mixer plot's per-window FFTs).

All of in, dst, win, bufIn and bufOut must have the same length n — a power of two — and plan must be fft.New(n). bufIn/bufOut are caller- owned FFT scratch (reused across calls to stay allocation-free); their contents are overwritten. winSum is Σ(win²), precomputed once.

Bins are FFT-shifted so dst[0] is the lowest frequency (-Fs/2) and dst[n/2] is DC, matching Frame's documented layout. Normalization is power = |X|² / (n · Σ(w²)), so a unit-amplitude tone reads ~0 dBFS at its bin.

func Smooth added in v0.4.3

func Smooth(bins []float32, w int) []float32

Smooth returns bins convolved with a centered boxcar of half-width w (total window 2w+1), the discrete uniform filter used to tame per-bin noise before peak picking (np.convolve with a normalized rectangular kernel). w<=0 returns a copy. Windows are clipped at the array edges so the output keeps the input length without wrap-around.

func Spectrogram added in v0.4.3

func Spectrogram(iq []complex64, fftSize, hop int) [][]float32

Spectrogram computes the short-time Fourier transform of iq as a sequence of FFT-shifted dBFS frames — the time-frequency plane behind a waterfall plot (the scipy.signal.stft / matplotlib specgram analog). It slides a Hann window of length fftSize by hop samples, computing each frame's power spectrum via PowerDB, so each returned row uses the same dBFS normalization and FFT-shift (DC in the middle) as AverageDB and the streaming Producer.

fftSize must be a power of two; hop <= 0 defaults to fftSize/2 (50% overlap). The result is frames×fftSize: rows are time steps, columns are frequency bins (column 0 = centerFreq - sampleRate/2). Returns nil when iq holds fewer than fftSize samples.

func WeightedCentroidHz added in v0.4.3

func WeightedCentroidHz(binsDb []float32, peakBin, halfWidth int, floorDb float32, baseHz, binHz float64) float64

WeightedCentroidHz returns the power-weighted mean frequency of the carrier hump around peakBin: the contiguous band of bins within ±halfWidth, each weighted by its linear power (dB → power via DBToLinearPower). This finds the true center of a wide carrier whose single strongest bin is noisy (np.average of the bin frequencies weighted by power). baseHz is the absolute frequency of bin 0 and binHz the bin spacing. Bins at or below floorDb are excluded; when no bin clears the floor the peak bin's frequency is returned.

func Welch added in v0.4.3

func Welch(plan fft.Plan, win []float64, winSum float64, in []complex64, noverlap int) []float32

Welch returns the averaged power spectral density (dBFS) of in — the native Go equivalent of scipy.signal.welch. It splits in into overlapping segments of length len(win), windows and FFTs each, and averages their periodograms in linear power before a single conversion to dB. Averaging trades frequency resolution for a far lower-variance noise floor than the single-shot PowerDB periodogram, which is what makes a weak carrier legible against noise.

win is the analysis window (length n, a power of two) and winSum is Σ(win²), precomputed once by the caller; plan must be fft.New(n). noverlap is the number of samples shared between consecutive segments (0 ≤ noverlap < n; a common choice is n/2). Bins are FFT-shifted so dst[0] is -Fs/2 and dst[n/2] is DC, identical to PowerDB / Frame. Normalization matches PowerDB (power = |X|²/(n·Σw²)), so a unit-amplitude tone reads ~0 dBFS.

The returned slice is freshly allocated (length n). When in is shorter than one segment, no periodogram can be formed and every bin reads the clamp floor.

Types

type Frame

type Frame struct {
	Timestamp  time.Time
	CenterHz   uint32
	SampleRate uint32
	Bins       []float32
}

Frame is one spectrum snapshot. Bins are dBFS magnitudes, length equal to the configured FFT size. Bin order is FFT-shifted so Bins[0] corresponds to (CenterHz - SampleRate/2) and Bins[len-1] corresponds to (CenterHz + SampleRate/2 - SampleRate/N).

func AverageDB added in v0.4.3

func AverageDB(iq []complex64, centerHz uint32, sampleRateHz float64, n int) Frame

AverageDB builds a Welch-averaged dBFS spectrum over the whole of iq, returning a Frame ready for peak detection or display. It is the offline, one-shot analog of the streaming Producer: rather than emitting frames at a fixed rate it walks iq in non-overlapping n-sample windows, computes the dBFS power spectrum of each via PowerDB, and averages the per-bin dB values across all windows (scipy.signal.welch with a Hann window and zero overlap).

n must be a power of two. When iq holds fewer than n samples a single zero-padded window is taken so a short capture still yields a frame. The returned Frame's CenterHz/SampleRate carry centerHz and the rounded sampleRateHz so callers can map bins back to absolute frequency.

type Occupancy added in v0.5.8

type Occupancy struct {
	// OccupiedBandwidthHz is the ITU 99% occupied bandwidth: the span between
	// the points where the cumulative power crosses 0.5% and 99.5% of the
	// total, i.e. the band holding 99% of the power with 0.5% in each tail.
	OccupiedBandwidthHz float64 `json:"occupied_bandwidth_hz"`
	// ChannelPowerDBFS is the integrated power within ±channelBW/2 of DC,
	// in dBFS (10·log10 of the summed linear bin power).
	ChannelPowerDBFS float64 `json:"channel_power_dbfs"`
	// ACPRUpperDB / ACPRLowerDB are the adjacent-channel power ratios for the
	// upper/lower adjacent channels relative to the main channel, in dB
	// (negative for a clean signal that does not splatter into its neighbours).
	ACPRUpperDB float64 `json:"acpr_upper_db"`
	ACPRLowerDB float64 `json:"acpr_lower_db"`
	// SpectralFlatness is the Wiener entropy (geometric-mean / arithmetic-mean
	// of the in-channel linear power), 0..1: ~1 for white noise, ≪1 for a tone
	// or a narrow carrier.
	SpectralFlatness float64 `json:"spectral_flatness"`
}

Occupancy holds the spectral-occupancy measurements derived from a single FFT-shifted dBFS spectrum (a Frame's Bins). These are the lab-grade channel figures a vector-signal analyzer reports alongside the constellation: how wide the signal really is, how much power sits in the channel, how much leaks into the neighbours, and how tone-like vs noise-like the channel is.

func ComputeOccupancy added in v0.5.8

func ComputeOccupancy(binsDB []float32, sampleRateHz, channelBWHz, adjOffsetHz, adjBWHz float64) Occupancy

ComputeOccupancy derives the spectral-occupancy metrics from FFT-shifted dBFS bins (bin 0 = -sampleRate/2, DC at len/2, the order spectrum.AverageDB produces with centerHz=0). channelBWHz is the main channel width; adjOffsetHz is the centre spacing to each adjacent channel and adjBWHz its width — pass the protocol channel spacing for both to measure ACPR into the neighbouring channels. Non-positive widths fall back to sensible spans so a caller can ask for occupied bandwidth alone.

type Options

type Options struct {
	// FFTSize is the number of complex samples per FFT. Must be a
	// power of two; 4096 is a good default for a 2.048 MS/s control
	// stream (~500 Hz/bin resolution).
	FFTSize int

	// FrameRate caps the output rate in frames per second. 0 picks
	// 10 fps. Higher rates burn CPU and wire bandwidth without
	// visible benefit beyond ~15 fps.
	FrameRate float64

	// CenterFreqHz and SampleRateHz are stamped on every Frame so
	// downstream clients can map bins to absolute frequencies
	// without consulting a side channel. Callers should update them
	// when the device retunes (Producer.SetCenter / SetSampleRate).
	CenterFreqHz uint32
	SampleRateHz uint32
}

Options configure a Producer.

type Producer

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

Producer consumes IQ chunks and emits Frames at a capped rate. One Producer per (device, subscriber) pairing — the bulk of the CPU cost is the FFT, so two Producers off the same source pay double. Callers that want multiple subscribers should keep a single Producer and fan its output channel.

func New

func New(opts Options) (*Producer, error)

New builds a Producer with the given options. Returns an error if the FFT size isn't a positive power of two.

func (*Producer) Dropped

func (p *Producer) Dropped() uint64

Dropped returns the cumulative number of IQ chunks consumed but not processed (i.e. arrived during a rate-limited interval). Non-zero is expected and healthy at typical SDR rates.

func (*Producer) FFTSize

func (p *Producer) FFTSize() int

FFTSize returns the configured FFT size (bins per Frame).

func (*Producer) Run

func (p *Producer) Run(ctx context.Context, in <-chan []complex64, out chan<- Frame) error

Run consumes from in and emits Frames onto out until ctx cancels or in closes. Frames are sent non-blocking; a slow consumer silently drops them (the rate-cap means losses are at most FrameRate per second).

Run owns the goroutine; callers spawn it and read frames on out.

func (*Producer) SetCenter

func (p *Producer) SetCenter(hz uint32)

SetCenter updates the centre frequency stamped on subsequent Frames. Call after the underlying device retunes.

func (*Producer) SetSampleRate

func (p *Producer) SetSampleRate(hz uint32)

SetSampleRate updates the sample rate stamped on subsequent Frames. Call after the underlying device changes rates.

Jump to

Keyboard shortcuts

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