voice

package
v0.3.8 Latest Latest
Warning

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

Go to latest
Published: Jun 10, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

Documentation

Overview

Package voice provides the voice-decoding plumbing that sits between the trunking engine and the audio output / recording layer.

The package layout:

  • vocoder.go a Vocoder interface + thread-safe Registry. Default build registers NullVocoder (silence), the pure-Go IMBE decoder from internal/voice/imbe, and the pure-Go AMBE+2 decoder from internal/voice/ambe2.
  • wav.go 16-bit PCM mono WAV writer with length-fields patched on Close (so the file is valid even if the daemon dies).
  • recorder.go subscribes to CallStart / CallEnd events from the trunking engine, opens a per-call WAV file (and an optional raw-frame sidecar) under a configurable directory tree, and exposes WritePCM / WriteRawFrame for the demod pipeline to push samples into.

IMBE patents have expired; AMBE+2 carries active patents in some jurisdictions and re-implementing it in pure Go does not change that posture. Operators in licence-restrictive jurisdictions should evaluate before deploying. See docs/vocoders.md for the full picture.

Index

Constants

This section is empty.

Variables

View Source
var DefaultRegistry = NewRegistry()

DefaultRegistry is process-global; init() in subpackages registers here.

View Source
var ErrNoVocoder = errors.New("voice: no vocoder registered for that name")

ErrNoVocoder is returned by recorders when a CallStart references a vocoder name that isn't registered in the build.

View Source
var ErrPartialFrame = errors.New("voice: input ended mid-frame")

ErrPartialFrame is returned by DecodeStream when the input ends in the middle of a vocoder frame — the trailing bytes don't make up a complete frame for the chosen vocoder. Callers can inspect the byte count returned alongside the error to decide whether the partial trailer is recoverable (typically: it isn't).

Functions

func DecodeStream

func DecodeStream(in io.Reader, vocoderName string, out io.WriteSeeker) (int, error)

DecodeStream reads vocoder frames from in, decodes each via the named vocoder from DefaultRegistry, and writes 8 kHz / 16-bit / mono PCM as a WAV stream to out. Returns the number of frames decoded successfully.

out must be an io.WriteSeeker so the WAV header length fields can be patched on close (file handles satisfy this; in-memory callers can wrap a bytes.Buffer with a seeker shim).

Frame size is determined by the chosen vocoder via FrameSize(). Input must be an exact multiple of that frame size; trailing bytes are reported via ErrPartialFrame after the leading complete frames have been written.

On a per-frame Decode error, DecodeStream stops and returns the number of frames decoded so far + the error. The WAV is closed (length fields patched) before returning so callers get a playable file even on partial decode.

func DecodeStreamWithVocoder

func DecodeStreamWithVocoder(in io.Reader, v Vocoder, out io.WriteSeeker) (int, error)

DecodeStreamWithVocoder is the lower-level entry point: callers supply a constructed Vocoder (so they can pin reproducibility via NewWithSeed or tune AGC via NewWithConfig before handing it off). Behaviour matches DecodeStream — see that function's doc for the contract.

The Vocoder is not Reset before use; the caller controls initial state. The caller is responsible for closing v.

func DefaultVocoderForProtocol

func DefaultVocoderForProtocol() map[string]string

DefaultVocoderForProtocol returns the Protocol → vocoder-name mapping NewRecorder uses when RecorderOptions.VocoderForProtocol is nil. The keys match the strings the radio decoders set on Grant.Protocol; the values match factory names registered into voice.DefaultRegistry by the imbe / ambe2 package init()s.

Callers wanting to override one entry should start with a copy of this map (DefaultVocoderForProtocol() returns a fresh map per call) and mutate from there — RecorderOptions.VocoderForProtocol is taken as-is, no merging.

Types

type NullVocoder

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

NullVocoder produces silence. It's the default when no IMBE / AMBE+2 decoder is available, and it is always safe to use because it doesn't touch any patented algorithm.

func NewNullVocoder

func NewNullVocoder(frameSize int) *NullVocoder

NewNullVocoder returns a silent vocoder with the supplied frame size (in bytes). Output is 8 kHz / 20 ms / 160 samples per frame regardless.

func (*NullVocoder) Close

func (n *NullVocoder) Close() error

func (*NullVocoder) Decode

func (n *NullVocoder) Decode(_ []byte) ([]int16, error)

func (*NullVocoder) FrameSize

func (n *NullVocoder) FrameSize() int

func (*NullVocoder) Name

func (n *NullVocoder) Name() string

func (*NullVocoder) Reset

func (n *NullVocoder) Reset()

type Recorder

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

Recorder writes per-call audio + raw-frame files. It subscribes to events.KindCallStart and events.KindCallEnd from the trunking engine, opens a WAV (and optional raw-frame sidecar) for each new call, and closes them on call end. The demod-pipeline composer pushes PCM samples in via WritePCM (analog protocols) and raw vocoder frames in via WriteRawFrame (digital protocols), keyed by device serial.

Layout under OutDir (Trunk-Recorder-style):

<OutDir>/<system>/<talkgroup-or-decimal-id>/<UTC-RFC3339>_freq<Hz>_src<src>.wav
<OutDir>/<system>/<talkgroup-or-decimal-id>/<UTC-RFC3339>_freq<Hz>_src<src>.raw

(The _freq<Hz> tag carries the RF voice-channel frequency; it is omitted when the grant frequency is unknown. A _ts<slot> tag is appended for slotted protocols. When per-transmission recording is enabled, each over rolls to a new file with a fresh timestamp.)

The raw sidecar is appended once per WriteRawFrame call. It is intentionally a flat concatenation of frames so users can BYO decoder (external libmbe, DVSI hardware, etc.) without parsing surrounding metadata.

Per-call vocoder: when Grant.Protocol matches an entry in the configured VocoderForProtocol map, the recorder instantiates a fresh Vocoder from voice.DefaultRegistry on CallStart and decodes each WriteRawFrame call through it, writing the resulting PCM into the WAV. This makes captures of P25 / DMR / NXDN voice produce playable WAVs alongside the optional raw sidecar — out-of-band decode via `gophertrunk decode` remains available for operators who want bit-exact mbelib / DSD-FME output.

EDACS ProVoice grants (Grant.ProVoice == true) always force a `.raw` sidecar even when WriteRaw is false. The ProVoice vocoder is patent + trade-secret encumbered so we cannot ship a built-in decoder; the sidecar lets researchers feed frames into an external decoder.

func NewRecorder

func NewRecorder(opts RecorderOptions) (*Recorder, error)

NewRecorder validates options and returns a recorder ready to Run. Like the engine, the recorder subscribes to the bus at construction so that CallStart events published before Run starts are not lost.

func (*Recorder) Close

func (r *Recorder) Close() error

Close releases the bus subscription, waits for Run (if running) to exit, then closes any outstanding sessions. Safe to call multiple times; second and later calls are no-ops.

func (*Recorder) HasSession

func (r *Recorder) HasSession(deviceSerial string) bool

HasSession reports whether a session exists for deviceSerial.

func (*Recorder) RecordingEnabled

func (r *Recorder) RecordingEnabled() bool

RecordingEnabled reports the current gate state.

func (*Recorder) Run

func (r *Recorder) Run(ctx context.Context) error

Run drains CallStart/CallEnd events until ctx cancels.

func (*Recorder) SessionCount

func (r *Recorder) SessionCount() int

SessionCount returns the number of currently-open recording sessions. Useful in tests; takes the internal lock so it is race-free.

func (*Recorder) SetRecordingEnabled

func (r *Recorder) SetRecordingEnabled(enabled bool)

SetRecordingEnabled toggles the recorder's runtime "create new sessions" gate. When enabled is false, subsequent CallStart events do NOT open .wav / .raw files; in-flight sessions are left alone so the head of a mid-call disable isn't lost on disk. Default (after NewRecorder) is enabled = true.

func (*Recorder) WritePCM

func (r *Recorder) WritePCM(deviceSerial string, samples []int16) error

WritePCM appends 16-bit PCM samples for the named device serial. If no session is open for that device the samples are dropped (the demod pipeline can race ahead of the CallStart event).

func (*Recorder) WriteRawFrame

func (r *Recorder) WriteRawFrame(deviceSerial string, frame []byte) error

WriteRawFrame consumes a raw vocoder frame for the named device serial. Two outputs are produced when applicable:

  • The .raw sidecar (when one was opened — see handleStart). The frame bytes are appended verbatim so external decoders can consume the file with no surrounding metadata.
  • The .wav (when a vocoder was instantiated for the call's Grant.Protocol). The frame is decoded into PCM and the samples are appended to the WAV. A per-frame Decode error is logged and the frame is dropped from PCM but still written to the sidecar.

Frames for a session without either output (no sidecar, no vocoder) are dropped silently.

type RecorderOptions

type RecorderOptions struct {
	Bus        *events.Bus
	Log        *slog.Logger
	OutDir     string
	SampleRate uint32 // 8000 typical
	WriteRaw   bool   // emit a .raw sidecar alongside each .wav

	// SkipEncrypted, when true, makes the recorder refuse to write files
	// for calls flagged encrypted. A grant that already signals encryption
	// never opens a session; a call whose encryption is only discovered
	// mid-stream (P25 Phase 1 LDU2 Encryption Sync, or a Phase 2 compressed
	// grant resolved in-call) has its in-progress WAV/raw files closed and
	// deleted, and no CallComplete is published so downstream upload feeds
	// never see the partial.
	SkipEncrypted bool

	// VocoderForProtocol maps a Grant.Protocol value to a vocoder
	// registry name used to decode raw frames into PCM that's
	// written to the call's WAV. nil means "use the package
	// defaults" (DefaultVocoderForProtocol). Pass an explicit empty
	// (non-nil) map to disable auto-decode entirely; the .raw
	// sidecar then becomes the only path for digital voice.
	//
	// Protocols not in the map produce no decoded audio — typically
	// analog protocols (motorola, edacs, ltr, mpt1327) where the
	// composer's FM chain feeds WritePCM directly, and ProVoice
	// where no in-binary decoder is available.
	VocoderForProtocol map[string]string
}

RecorderOptions configure a new Recorder.

type Registry

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

Registry holds the set of vocoders the running daemon has linked in. Drivers register from init(); callers fetch by name from config.

func NewRegistry

func NewRegistry() *Registry

func (*Registry) Names

func (r *Registry) Names() []string

Names returns every registered vocoder name, sorted.

func (*Registry) New

func (r *Registry) New(name string) (Vocoder, error)

New returns a fresh instance of the named vocoder.

func (*Registry) Register

func (r *Registry) Register(name string, f VocoderFactory)

Register adds (or replaces) a factory by name.

type Vocoder

type Vocoder interface {
	Name() string
	FrameSize() int // input bytes per frame
	Decode(frame []byte) ([]int16, error)
	Reset()
	Close() error
}

Vocoder is implemented by every voice decoder GopherTrunk can use: pure-Go IMBE, pure-Go AMBE+2, the future DVSI hardware backend, and the NullVocoder used when no decoder is available.

Decode consumes one compressed frame and returns 16-bit PCM samples at 8 kHz mono (one frame is 20 ms = 160 samples for IMBE/AMBE+2). Decoders that need internal state across frames (most vocoders do) keep it on the implementation; they are NOT safe for concurrent calls on the same instance.

type VocoderFactory

type VocoderFactory func() (Vocoder, error)

VocoderFactory builds a fresh vocoder instance per call. We allocate one per call so vocoders with internal state don't bleed between calls.

type WavWriter

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

WavWriter writes a 16-bit PCM mono WAV file. Length fields in the RIFF and data chunks are patched in Close() so that a daemon crash leaves a readable (if length-zero) file behind rather than something most media players reject.

Construction takes any io.WriteSeeker (so tests can use bytes.Buffer wrapped in an in-memory seeker). The dedicated NewFile helper opens a regular file on disk.

func NewWavFile

func NewWavFile(path string, sampleRate uint32) (*WavWriter, error)

NewWavFile opens path for write (creating or truncating) and returns a WavWriter that closes the file on Close().

func NewWavWriter

func NewWavWriter(w io.WriteSeeker, sampleRate uint32) (*WavWriter, error)

NewWavWriter wraps an io.WriteSeeker and emits the WAV header. The sample-rate parameter is the PCM rate in Hz (8000 is typical for digital-radio voice).

func (*WavWriter) Close

func (w *WavWriter) Close() error

Close patches the length fields and closes the underlying file (if the writer owns one).

func (*WavWriter) DataBytes added in v0.1.9

func (w *WavWriter) DataBytes() uint32

DataBytes returns the number of PCM payload bytes written so far (excluding the 44-byte header). Stays readable after Close so the recorder can tell whether a call captured any audio.

func (*WavWriter) WriteSamples

func (w *WavWriter) WriteSamples(samples []int16) error

WriteSamples appends 16-bit PCM samples (little-endian).

Directories

Path Synopsis
Package ambe2 is the in-progress pure-Go AMBE+2 2400 bps voice decoder used by P25 Phase 2, DMR (Tier II / III), and NXDN voice frames.
Package ambe2 is the in-progress pure-Go AMBE+2 2400 bps voice decoder used by P25 Phase 2, DMR (Tier II / III), and NXDN voice frames.
Package calibrate compares an in-tree Vocoder's PCM output against a reference WAV (typically produced by DSD-FME or OP25) from the same raw vocoder-frame source.
Package calibrate compares an in-tree Vocoder's PCM output against a reference WAV (typically produced by DSD-FME or OP25) from the same raw vocoder-frame source.
Package composer bridges the trunking engine's CallStart events to the per-call demod chain that turns IQ samples on a freshly-tuned Voice device into 16-bit PCM the recorder can write.
Package composer bridges the trunking engine's CallStart events to the per-call demod chain that turns IQ samples on a freshly-tuned Voice device into 16-bit PCM the recorder can write.
Package dvsi implements the DVSI USB-3000 / AMBE-3003 hardware vocoder backend.
Package dvsi implements the DVSI USB-3000 / AMBE-3003 hardware vocoder backend.
Package imbe is the pure-Go IMBE 4400 bps voice decoder used by P25 Phase 1 LDU1 / LDU2 frames.
Package imbe is the pure-Go IMBE 4400 bps voice decoder used by P25 Phase 1 LDU1 / LDU2 frames.
Package mbe is the shared Multi-Band Excitation synthesis core used by GopherTrunk's IMBE 4400 (P25 Phase 1) and AMBE+2 2400 (P25 Phase 2 / DMR / NXDN) decoders.
Package mbe is the shared Multi-Band Excitation synthesis core used by GopherTrunk's IMBE 4400 (P25 Phase 1) and AMBE+2 2400 (P25 Phase 2 / DMR / NXDN) decoders.
Package mp3 provides a pure-Go MP3 encoder used to compress completed call audio before it is streamed to broadcast aggregators (Broadcastify Calls, RdioScanner, OpenMHz, Icecast).
Package mp3 provides a pure-Go MP3 encoder used to compress completed call audio before it is streamed to broadcast aggregators (Broadcastify Calls, RdioScanner, OpenMHz, Icecast).
Package player is the live-audio sink that turns int16 PCM coming out of the per-call composer / conventional scanner into sound out of the host's speakers.
Package player is the live-audio sink that turns int16 PCM coming out of the per-call composer / conventional scanner into sound out of the host's speakers.
Package toneout detects fire/EMS paging tones — Two-Tone Sequential (Motorola Quick Call II), single-tone, and DTMF — over the PCM stream produced by the voice composer, and emits events.KindToneAlert when a configured profile matches.
Package toneout detects fire/EMS paging tones — Two-Tone Sequential (Motorola Quick Call II), single-tone, and DTMF — over the PCM stream produced by the voice composer, and emits events.KindToneAlert when a configured profile matches.

Jump to

Keyboard shortcuts

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