Part 4 of Trunking Engine. Part 3 followed a grant to the point where the engine has decided it needs a radio. This post is about the thing that hands one out — the voice pool — and the awkward reality that there are almost always more talkgroups than SDRs to follow them.
TL;DR: The
VoicePoolowns the set ofVoiceDevices (each aTuner+ serial) and theActiveCallcurrently bound to each. Allocation is first-fit over the device list;Bindretunes a device to the grant’s frequency and records the call;Retunefollows an already-bound call to a new channel without ending it. The pool also knows that not every radio can reach every frequency — a wideband rig whose IQ window excludes the repeater is incapable, not just busy — and exposes that so the engine can tell a coverage gap from an all-radios-busy condition and emit the right one-time warning.
Key takeaways
- The pool is the classic resource-pool pattern: a fixed set of scarce devices, allocated on demand, returned on call end.
- A
VoiceDeviceis just aTunerinterface plus a serial — the pool retunes throughSetCenterFreqand never touches SDR driver code. Bindstarts a call and stamps a uniqueCallID;Retunecontinues one across a handoff, preserving identity.- Frequency capability is first-class:
FindFreeForFrequencyandHasCapableDevicelet the engine distinguish “no free radio” from “no radio can tune this” — thenoVoiceCoverageOncewarning.
Cheat sheet
| Method | Returns | Role |
|---|---|---|
FindFree() |
*VoiceDevice |
first device with no active call |
FindFreeForFrequency(hz) |
*VoiceDevice |
first free device that can tune hz |
HasCapableDevice(hz) |
bool |
any device (busy or free) can tune hz? |
Bind(d, g, tg, now) |
*ActiveCall, error |
retune d, record the call, stamp CallID |
Retune(serial, g, now) |
error |
follow a bound call to a new frequency |
Release(serial) |
*ActiveCall |
free the device, return the ended call |
Touch(serial, now) |
— | refresh LastHeardAt for the watchdog |
LowestPriorityActiveForFrequency(hz) |
*ActiveCall |
preemption victim (Part 5) |
In this post
- What the pool holds —
VoiceDevice,ActiveCall, and theTunerseam. - Allocation — first-fit, and why order is a preference.
- Bind and Retune — starting a call vs following one.
- Coverage — the frequency-capable interface and the coverage-gap warning.
What the pool holds
A voice device is deliberately thin:
// internal/trunking/voicepool.go
type VoiceDevice struct {
Tuner Tuner
Serial string
}
type Tuner interface {
SetCenterFreq(hz uint32) error
}
That’s the entire coupling between the engine and the radio: a one-method
interface. The pool retunes a device by calling SetCenterFreq; it never opens a
dongle, sets a gain, or reads IQ. A VoiceDevice can be a physical SDR or a
virtual voice tuner backed by a wideband DDC tap — the pool doesn’t care, which
is what lets one wideband rig present several logical voice devices. This is the
same decoupling discipline from
Part 1,
applied at the hardware edge.
The pool tracks which device is serving what:
// internal/trunking/voicepool.go (shape)
type VoicePool struct {
mu sync.Mutex
devices []*VoiceDevice
active map[string]*ActiveCall // by device serial
callSeq atomic.Uint64 // hands out Grant.CallID
// …reacquire callback for USB re-enumerate recovery
}
active maps a device serial to the ActiveCall bound to it. An ActiveCall
carries the Grant, the resolved TalkGroup, StartedAt and LastHeardAt
timestamps (the watchdog’s fuel), and later-stamped quality figures
(SignalDbFS, EVMPct, SNRDb) the composer measures near end-of-call. The
whole struct is guarded by one mutex — the pool is safe for concurrent use
because the decode pipeline Touches it from other goroutines while the engine
loop binds and releases.
Allocation: first-fit, order is preference
Finding a free radio is a linear scan:
// internal/trunking/voicepool.go
func (p *VoicePool) FindFree() *VoiceDevice {
p.mu.Lock()
defer p.mu.Unlock()
for _, d := range p.devices {
if _, busy := p.active[d.Serial]; !busy {
return d
}
}
return nil // every device is busy
}
First-fit, and the device order is meaningful: the daemon lists physical voice
SDRs before virtual wideband taps, so the pool prefers a dedicated radio and
falls back to a tap. nil means every device is busy — the engine’s cue to look
at preemption
(Part 5).
Bind and Retune
Bind is where a grant becomes a call:
// internal/trunking/voicepool.go (shape)
func (p *VoicePool) Bind(d *VoiceDevice, g Grant, tg *TalkGroup, now time.Time) (*ActiveCall, error) {
// …reject if d is nil or already busy…
if err := d.Tuner.SetCenterFreq(g.FrequencyHz); err != nil {
// one reacquire+retry if the SDR pool wired SetReacquire (USB re-enumerate)
return nil, err
}
g.CallID = p.callSeq.Add(1) // fresh, process-unique — fences audio bleed
ac := &ActiveCall{Device: d, Grant: g, Talkgroup: tg, StartedAt: now, LastHeardAt: now}
p.active[d.Serial] = ac
return ac, nil
}
Three things happen: the device is retuned via the one-method Tuner, a fresh
CallID is stamped so the voice chain and recorder can reject stale frames from
the previous call on a reused tap serial
(Part 3),
and the ActiveCall is recorded in active. If the initial SetCenterFreq
fails and the daemon wired a SetReacquire callback, the pool asks the SDR pool
to re-open the serial (recovering a device whose USB handle died while idle) and
retries the tune once — issue #345.
Retune is the handoff path — following a live call to a new channel without
ending it:
// internal/trunking/voicepool.go (shape)
func (p *VoicePool) Retune(serial string, g Grant, now time.Time) error {
ac := p.active[serial] // must already be bound
d := ac.Device
if err := d.Tuner.SetCenterFreq(g.FrequencyHz); err != nil { /* reacquire+retry */ }
g.CallID = ac.Grant.CallID // preserve identity across the handoff
ac.Grant = g // replace the grant, keep StartedAt
ac.LastHeardAt = now
return nil
}
The distinction matters: Bind starts a new StartedAt and a new CallID;
Retune preserves both, so a call that moves across channels stays one call
with one duration and one identity. The engine reaches for Retune from the
duplicate-grant guard’s “same call, new frequency” branch. The returned pointer
is the same ActiveCall instance the engine already mirrors, so the updated
grant is visible with no extra bookkeeping.
Release(serial) deletes the entry and returns the freed ActiveCall;
Touch(serial, now) just advances LastHeardAt and is called from the decode
pipeline every time a frame lands, which is how the watchdog in
Part 12
knows a call is still alive.
Coverage: not every radio can reach every frequency
Here is the subtlety that makes this pool more than a free-list. A physical SDR can tune anywhere in its range, but a virtual voice tuner backed by a wideband DDC tap can only follow grants that fall inside the wideband dongle’s IQ window. A grant for a repeater at the band edge might land outside every tap’s window — the radios exist, they’re even free, but none of them can reach the frequency.
The pool models this with an optional interface:
// internal/trunking/voicepool.go
type FrequencyChecker interface {
CanTune(hz uint32) bool
}
A device whose Tuner implements FrequencyChecker is consulted; a physical SDR
that doesn’t is treated as universally tunable. Allocation then has a
frequency-aware form:
// internal/trunking/voicepool.go
func (p *VoicePool) FindFreeForFrequency(hz uint32) *VoiceDevice {
for _, d := range p.devices {
if _, busy := p.active[d.Serial]; busy { continue }
if fc, ok := d.Tuner.(FrequencyChecker); ok && !fc.CanTune(hz) { continue }
return d
}
return nil
}
And a separate HasCapableDevice(hz) reports whether any device — busy or free
— could tune hz at all. Those two let the engine tell three situations apart
when it can’t place a grant, and give the operator an actionable message for
each:
| Situation | Detected by | Message |
|---|---|---|
| No voice devices configured | len(Devices()) == 0 |
add a role: voice SDR / wideband tap |
Devices exist, none covers hz |
!HasCapableDevice(hz) |
coverage gap — widen sample rate / adjust center |
Devices cover hz, all busy |
preemption falls through | no free device (Part 5) |
The problem we hit
The middle case used to masquerade as an engine bug. A wideband-only rig whose IQ window excluded a repeater would drop every grant for that repeater, and the log read like the pool was broken. It isn’t — it’s a coverage gap, an RF-planning problem, and it deserves its own message exactly once:
// internal/trunking/engine.go (shape) — after allocation + preemption both fail
if !e.pool.HasCapableDevice(g.FrequencyHz) {
e.noVoiceCoverageOnce.Do(func() {
e.log.Warn("voice grant frequency outside every voice device's tuning " +
"window; widen sdr.sample_rate / adjust center_freq_hz, or add a " +
"role: voice SDR", "grant", g.String())
})
return
}
The sync.Once guard (noVoiceCoverageOnce) logs the warning loudly the first
time and then drops subsequent out-of-window grants at DEBUG, so a band-edge
talkgroup that keys up every few seconds doesn’t flood the log with the same
diagnosis. Preemption is also coverage-aware —
LowestPriorityActiveForFrequency(hz) only considers victims on devices that can
tune hz, because ending a call to free a radio that then can’t bind the
incoming grant would be pure loss. That method is the handoff into
Part 5.
Where this goes next
The pool hands out radios until it runs out. When FindFreeForFrequency returns
nil and there are capable-but-busy devices, the engine has to decide whether
the new grant is worth kicking an active call off its radio.
Part 5
is that policy — priority ranking, strict-higher preemption, and the thrash it’s
designed to avoid. Later,
Part 11
returns to the pool for the encrypted-release machinery
(ArmEncryptedRelease/EncryptedReleasesDue) that frees a tuner from a call
that turns out to be encrypted.
FAQ
What is a VoiceDevice?
A Tuner interface (one method, SetCenterFreq) plus a serial string. It can be
a physical SDR or a virtual voice tuner backed by a wideband DDC tap — the pool
treats them uniformly and never touches driver code.
What’s the difference between Bind and Retune?
Bind starts a new call: it stamps a fresh StartedAt and a process-unique
CallID and records a new ActiveCall. Retune follows an existing call to a
new frequency, preserving StartedAt and CallID so the call keeps one identity
and one duration across a handoff.
How does the pool handle more talkgroups than radios?
The pool itself just reports “no free device” (FindFree/FindFreeForFrequency
return nil). The engine then consults LowestPriorityActiveForFrequency and
decides whether to preempt — the subject of Part 5. The pool provides the
capability-aware victim search; the priority policy lives above it.
Why can a free radio still fail to take a grant?
Because a wideband voice tap can only tune frequencies inside its dongle’s IQ
window. FindFreeForFrequency skips a free-but-incapable tap, and if no capable
device exists at all, HasCapableDevice is false and the engine logs the
noVoiceCoverageOnce coverage-gap warning rather than mislabelling it a bug.
What does CallID protect against?
Cross-call audio bleed. When a tap serial is immediately reused for the next
call, a straggling frame from the previous call could otherwise land in the new
recording. Bind stamps a unique CallID the voice chain tags frames with, and
the recorder rejects any frame whose CallID doesn’t match the open session.
Series navigation
Part 4 of 12 · ← Part 3: Grants · Next → Part 5: Priority & Preemption