conventional

package
v0.7.0 Latest Latest
Warning

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

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

Documentation

Overview

Package conventional is the fixed-frequency analog FM scanner.

State machine cycles through operator-configured channels, measures IQ-domain RMS power on each tune-and-dwell, and on squelch break hands off to the trunking engine (via Engine.HandleSyntheticCall) so the recorder writes a WAV like any other call. After hangtime silence the scanner publishes CallEnd through EndSyntheticCall and resumes hopping.

IQ-domain squelch is chosen over FM-discriminator squelch because it's measurable before any demod chain spins up (cheap blind-channel visits during scanning) and FM carriers are constant-envelope so the same metric drives hangtime detection while a call is active.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func PowerDbFS

func PowerDbFS(iq []complex64) float64

PowerDbFS measures the RMS power of a complex64 IQ chunk in dB relative to full-scale (where a unity-amplitude tone is 0 dBFS). Returns -infinity for an empty buffer; callers compare against SquelchDbFS and treat -Inf as well below any threshold.

This is the load-bearing primitive for both squelch-open detection (during SCANNING) and hangtime-silence detection (during DWELL).

Types

type CTCSSConfig

type CTCSSConfig struct {
	// SampleHz is the IQ sample rate (typically 2.4e6 for RTL-SDR).
	SampleHz float64
	// TargetHz is the CTCSS frequency to detect. Standard values
	// range from 67.0 to 254.1 Hz; the EIA list has 50 codes but
	// only 38 + 12 are widely used.
	TargetHz float64
	// AudioCutoffHz sets the single-pole IIR low-pass cutoff. The
	// LPF rolls off the audio band so it doesn't alias into the
	// sub-audible band when the Goertzel samples at its block
	// rate. Defaults to 500 Hz when zero — comfortably above the
	// highest CTCSS frequency and below the lowest voice formant.
	AudioCutoffHz float64
	// BlockSize is the Goertzel block size in IQ samples. Larger
	// blocks → finer frequency resolution at the cost of slower
	// detection. Defaults to SampleHz / 5 (≈ 5 Hz bin resolution
	// and ~200 ms detection latency, comfortably under typical
	// CTCSS reaction times on commercial radios).
	BlockSize int
}

CTCSSConfig holds the sample rate of the input IQ stream + the CTCSS frequency to look for. Goertzel block size is derived from the sample rate so the frequency resolution is around 5 Hz at any reasonable SDR rate.

type CTCSSDetector

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

CTCSSDetector matches a single CTCSS tone against a stream of IQ chunks. Construct via NewCTCSSDetector; feed IQ via Process. The detector keeps phase + Goertzel state across calls so block boundaries don't matter to the caller.

Not safe for concurrent use — the conv scanner owns one detector per channel and processes each chunk serially.

func NewCTCSSDetector

func NewCTCSSDetector(cfg CTCSSConfig) *CTCSSDetector

NewCTCSSDetector constructs a detector. TargetHz must be > 0 and inside the practical CTCSS range (50..300 Hz); SampleHz must be the IQ rate the detector will be fed.

func (*CTCSSDetector) Present

func (d *CTCSSDetector) Present() bool

Present reports the latest detection state. Stable between Process calls; flips inside Process when a Goertzel block completes.

func (*CTCSSDetector) Process

func (d *CTCSSDetector) Process(iq []complex64) bool

Process feeds an IQ chunk through the detector chain. Updates the internal Present() state when the Goertzel block boundary lands inside the chunk. Returns the most recent Present() value as a convenience for callers that gate on a single call.

func (*CTCSSDetector) Reset

func (d *CTCSSDetector) Reset()

Reset clears all internal state. Called by the scanner whenever it retunes so a tone match on a previous channel doesn't bleed into the new dwell.

func (*CTCSSDetector) SetMagnitudeThreshold

func (d *CTCSSDetector) SetMagnitudeThreshold(t float64)

SetMagnitudeThreshold tunes the detection threshold. Higher values reject low-level / spurious tones at the cost of slower lock onto a weak repeater. Defaults work for typical RTL-SDR captures.

func (*CTCSSDetector) SetRejectRatio

func (d *CTCSSDetector) SetRejectRatio(r float64)

SetRejectRatio tunes the reverse-bin rejection ratio: target bin magnitude must exceed rejectRatio × max(reverse_bins) to count as a match. Setting ratio ≤ 1 disables the check effectively (any target-bin magnitude above the leak floor will pass). Default is 1.5.

func (*CTCSSDetector) TargetHz

func (d *CTCSSDetector) TargetHz() float64

TargetHz returns the configured CTCSS frequency. Useful for logs.

type Channel

type Channel struct {
	Label       string
	FrequencyHz uint32
	// Mode is "fm" or "nfm" — the latter narrows the post-demod
	// audio LPF; both share the IQ-power squelch.
	Mode string
	// SquelchDbFS is the threshold above which the scanner declares
	// "carrier present". A typical value for an RTL-SDR-class
	// receiver is around -50 dBFS; tune per channel as needed.
	SquelchDbFS float64
	// Hangtime is how long below threshold must elapse before the
	// scanner declares the call over and resumes hopping. Default
	// 1500 ms keeps the scanner from clipping the tail of normal
	// FM transmissions.
	Hangtime time.Duration
	// Priority is forwarded to the synthetic talkgroup so the
	// engine's preemption logic respects it relative to other
	// conv-scanner channels.
	Priority int
	// Tone is the optional CTCSS / DCS gate. When set, the
	// scanner only declares "carrier present" while BOTH the
	// IQ-power squelch is open AND the configured sub-audible
	// tone is detected. Zero value (Mode="" / "none") disables
	// tone gating and the scanner behaves identically to its
	// pre-tone version. DCS mode parses + validates but the
	// detector is a tracked follow-up — see ctcss.go.
	Tone ToneConfig
}

Channel is one entry in the conventional scan list.

type ChannelStatus

type ChannelStatus struct {
	Index       int    `json:"index"`
	Label       string `json:"label"`
	FrequencyHz uint32 `json:"frequency_hz"`
	Mode        string `json:"mode"`
	Active      bool   `json:"active"`
	// LockedOut reports whether the channel is excluded from the
	// scan cycle by operator action. Runtime-only — the field is
	// not persisted across daemon restarts.
	LockedOut   bool      `json:"locked_out,omitempty"`
	LastBreakAt time.Time `json:"last_break_at,omitempty"`
}

ChannelStatus is one row in the Snapshot.

type DCSConfig

type DCSConfig struct {
	// SampleHz is the IQ sample rate (typically 2.4e6 for RTL-SDR).
	SampleHz float64
	// Code is the 3-digit octal DCS code (e.g. "023", "754"). The
	// 38 EIA codes are widely deployed; the standard accepts any
	// 3-digit octal value.
	Code string
	// AudioCutoffHz sets the single-pole IIR low-pass cutoff.
	// Defaults to 250 Hz — well above the 134.4 baud Nyquist
	// rate and below any voice formant.
	AudioCutoffHz float64
}

DCSConfig holds the IQ sample rate + the DCS code to detect.

type DCSDetector

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

DCSDetector matches a single DCS code on a stream of IQ chunks. Construct via NewDCSDetector. Stateful — owns the demod / bit- recovery / sliding-window state across IQ chunks. Not safe for concurrent use; the conv scanner owns one detector per channel.

func NewDCSDetector

func NewDCSDetector(cfg DCSConfig) *DCSDetector

NewDCSDetector builds a detector for the configured DCS code. Returns nil on bad config (empty code, non-octal digits, missing sample rate) — the scanner falls back to power-only squelch when the constructor returns nil.

func (*DCSDetector) Code

func (d *DCSDetector) Code() string

Code returns the configured 3-digit octal DCS code.

func (*DCSDetector) Present

func (d *DCSDetector) Present() bool

Present reports the latest detection state.

func (*DCSDetector) Process

func (d *DCSDetector) Process(iq []complex64) bool

Process feeds an IQ chunk. Returns the most-recent Present() value as a convenience for callers that gate on a single call.

func (*DCSDetector) Reset

func (d *DCSDetector) Reset()

Reset clears all internal state. Called by the scanner whenever it retunes.

func (*DCSDetector) SetDistanceThreshold

func (d *DCSDetector) SetDistanceThreshold(n int)

SetDistanceThreshold tunes the match tolerance. Lower = fewer false positives at the cost of slower lock under noise; higher = quicker lock but more false alarms.

type Engine

type Engine interface {
	HandleSyntheticCall(g trunking.Grant, deviceSerial string)
	EndSyntheticCall(deviceSerial string, reason trunking.EndReason) bool
	Touch(deviceSerial string)
}

Engine is the subset of trunking.Engine the scanner needs. Only the synthetic-call entry points; this keeps tests trivial without constructing a full Engine.

type IQSource

type IQSource interface {
	StreamIQ(ctx context.Context) (<-chan []complex64, error)
}

IQSource provides the IQ stream the scanner consumes for both squelch detection and (downstream) recorder integration. The scanner cancels and re-opens the stream every tune to drop any in-flight samples from the previous channel.

type Options

type Options struct {
	Log          *slog.Logger
	Tuner        Tuner
	IQ           IQSource
	Engine       Engine
	Recorder     Recorder
	DeviceSerial string
	SystemName   string // surfaces on the synthetic Grant.System
	Channels     []Channel
	// DwellChunkLen is the IQ-power measurement window in samples
	// during SCANNING. Default 4096 (≈ 1.7 ms at 2.4 MS/s).
	DwellChunkLen int
	// MinDwellPerChannel is the minimum time on each channel during
	// SCANNING before advancing — even if squelch never opens. Default
	// 100 ms; let signals settle after retune.
	MinDwellPerChannel time.Duration
	// SampleRateHz is the IQ sample rate the IQ source delivers
	// (typically 2.4e6 for RTL-SDR). Required when any configured
	// channel has Tone gating — without it the CTCSS detector
	// can't pick the right Goertzel bin. Zero is fine when no
	// tone gating is in play; the field is otherwise inert.
	SampleRateHz float64
	// Now is injectable for tests; defaults to time.Now.
	Now func() time.Time
}

Options configure the conventional scanner.

type Recorder

type Recorder interface {
	WritePCM(deviceSerial string, samples []int16) error
}

Recorder is the subset of voice.Recorder the scanner feeds. The real recorder accepts WritePCM by device serial; tests can stub with a noop.

type Scanner

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

Scanner is the state machine. Construct via New, Run with a ctx.

func New

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

New constructs a Scanner. Channels may be empty when the operator only intends to drive the scanner via AddTemporaryChannel (manual VFO tune from the TUI / API).

func (*Scanner) AddTemporaryChannel

func (s *Scanner) AddTemporaryChannel(ch Channel) int

AddTemporaryChannel appends a runtime "VFO" channel and forces the scanner to dwell on it next round. Returns the new index. Defaults are applied identically to the config-seeded path (SquelchDbFS=-50, Hangtime=1500ms, Mode=fm). Tone config is validated and may produce an out-of-range detector failure that's silently ignored — the manual-tune path is best-effort for the v1 surface. Safe to call from any goroutine.

func (*Scanner) DwellOn

func (s *Scanner) DwellOn(idx int) bool

DwellOn asks the scanner to advance to the named channel on the next round. Returns false if idx is out of range.

func (*Scanner) Hold

func (s *Scanner) Hold()

Hold pins the scanner on its current channel (in StateDwell) or pauses scanning (in StateScanning). The held state persists until Resume.

func (*Scanner) IsHeld

func (s *Scanner) IsHeld() bool

IsHeld reports whether the scanner is currently held.

func (*Scanner) LockoutChannel

func (s *Scanner) LockoutChannel(idx int) bool

LockoutChannel marks the channel index as skipped during scan. The scan loop's pickNextChannel respects this — a locked-out channel is omitted from rotation. If the locked-out channel is currently dwelling, the synthetic call ends immediately so the operator's intent ("don't listen to this") takes effect within one IQ chunk rather than waiting for hangtime.

Returns false when idx is out of range. Locking out an already- locked channel is a no-op (still true).

func (*Scanner) RemoveTemporaryChannel

func (s *Scanner) RemoveTemporaryChannel(idx int) bool

RemoveTemporaryChannel deletes a channel previously added via AddTemporaryChannel. Static (config-seeded) channels can't be removed at runtime; this returns false for those. If the channel is currently dwelling, the scanner ends the synthetic call before removing it.

Note: removing a temporary channel re-indexes everything after it. This is fine for the v1 manual-tune flow (which adds one VFO at a time and revokes it on a new tune) but callers must not assume indices are stable across a remove.

func (*Scanner) Resume

func (s *Scanner) Resume()

Resume undoes Hold. The next Run iteration picks scanning back up.

func (*Scanner) Run

func (s *Scanner) Run(ctx context.Context) error

Run blocks until ctx cancels. Tunes, measures squelch, hands off to the engine on break, and resumes scanning after hangtime. Returns ctx.Err() on shutdown.

func (*Scanner) Snapshot

func (s *Scanner) Snapshot() Status

Snapshot returns a copy of the current scanner state for the REST cockpit / TUI panel.

func (*Scanner) UnlockoutChannel

func (s *Scanner) UnlockoutChannel(idx int) bool

UnlockoutChannel undoes LockoutChannel. The scanner picks the channel back up on the next pass.

Returns false when idx is out of range. Unlocking an already- unlocked channel is a no-op (still true).

type State

type State string

State is the high-level scanner state surfaced through Snapshot.

const (
	StateScanning State = "scanning"
	StateDwell    State = "dwell"
	StateHeld     State = "held"
)

type Status

type Status struct {
	State        State           `json:"state"`
	Channels     []ChannelStatus `json:"channels"`
	CursorIndex  int             `json:"cursor_index"`
	DeviceSerial string          `json:"device_serial,omitempty"`
}

Status is the scanner-wide snapshot.

type ToneConfig

type ToneConfig struct {
	// Mode is "ctcss", "dcs", or "" / "none" (default). Unknown
	// values are rejected at validation time.
	Mode string
	// CTCSSHz is the target CTCSS frequency (50–300 Hz). Required
	// when Mode is "ctcss". Standard EIA codes range from 67.0 to
	// 254.1 Hz; 38 are widely deployed.
	CTCSSHz float64
	// DCSCode is the three-digit octal DCS code (e.g. "023",
	// "754"). Required when Mode is "dcs". Detector wiring is
	// deferred — see Workstream D follow-up.
	DCSCode string
}

ToneConfig configures CTCSS / DCS squelch gating for one channel. Mode selects the family; the relevant field for the chosen mode must be populated.

type Tuner

type Tuner interface {
	SetCenterFreq(hz uint32) error
}

Tuner is the subset of sdr.Device the scanner needs.

Jump to

Keyboard shortcuts

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