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 ¶
- func PowerDbFS(iq []complex64) float64
- type CTCSSConfig
- type CTCSSDetector
- type Channel
- type ChannelStatus
- type DCSConfig
- type DCSDetector
- type Engine
- type IQSource
- type Options
- type Recorder
- type Scanner
- func (s *Scanner) AddTemporaryChannel(ch Channel) int
- func (s *Scanner) DwellOn(idx int) bool
- func (s *Scanner) Hold()
- func (s *Scanner) IsHeld() bool
- func (s *Scanner) LockoutChannel(idx int) bool
- func (s *Scanner) RemoveTemporaryChannel(idx int) bool
- func (s *Scanner) Resume()
- func (s *Scanner) Run(ctx context.Context) error
- func (s *Scanner) Snapshot() Status
- func (s *Scanner) UnlockoutChannel(idx int) bool
- type State
- type Status
- type ToneConfig
- type Tuner
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func PowerDbFS ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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) LockoutChannel ¶
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 ¶
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 ¶
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 ¶
Snapshot returns a copy of the current scanner state for the REST cockpit / TUI panel.
func (*Scanner) UnlockoutChannel ¶
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 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.