feat: added full support in USB (local) & ethernet (local or remote) of audio for Icom

This commit is contained in:
2026-07-09 11:30:06 +02:00
parent 1f5e5759cc
commit 521f8266cf
14 changed files with 882 additions and 22 deletions
+57
View File
@@ -0,0 +1,57 @@
package audio
import "fmt"
// Codec converts between the wire payload of a network audio stream (Icom 50003)
// and OpsLog's internal PCM (16 kHz mono 16-bit little-endian — the format the
// capture/render engine and the pcmRing use). It exists so the transport code
// (icomaudio.go) never hard-codes a format: today PCM is an identity passthrough;
// tomorrow an Opus codec implements the same two methods and drops in unchanged.
// This mirrors how civTransport abstracts the CAT byte stream from its transport.
type Codec interface {
// Name is a short label for logs/UI.
Name() string
// Decode turns one received audio payload into internal PCM. The returned
// slice is freshly allocated (the caller may retain it).
Decode(payload []byte) ([]byte, error)
// Encode turns internal PCM into a payload to transmit.
Encode(pcm []byte) ([]byte, error)
}
// pcm16Codec is the uncompressed 16-bit-PCM codec — the Icom "uncompressed"
// audio mode (rxcodec/txcodec in the conninfo). Icom sends little-endian 16-bit
// mono samples, which is byte-for-byte OpsLog's internal format, so decode/encode
// are copies. It is the Phase-4/5 default: zero-dependency, lossless, ideal on a
// LAN. Opus (for WAN/internet bandwidth) becomes another Codec later.
//
// NOTE: rate conversion is deliberately NOT this layer's job. The Icom RX audio
// is 16 kHz = our internal rate, so RX needs none. The rig's TX side may run at a
// different rate (the captured conninfo showed 8 kHz) — Phase 5 will resample in
// the TX path before Encode; keeping the codec rate-agnostic keeps that concern
// in one place.
type pcm16Codec struct{}
// NewPCM16Codec returns the uncompressed 16-bit PCM codec.
func NewPCM16Codec() Codec { return pcm16Codec{} }
func (pcm16Codec) Name() string { return "pcm16" }
func (pcm16Codec) Decode(payload []byte) ([]byte, error) {
// Icom PCM payloads are whole 16-bit samples; an odd length means a truncated
// packet — trim the stray byte rather than emit a half-sample click.
n := len(payload) &^ 1
if n != len(payload) {
if n == 0 {
return nil, fmt.Errorf("pcm16: payload too short (%d bytes)", len(payload))
}
}
out := make([]byte, n)
copy(out, payload[:n])
return out, nil
}
func (pcm16Codec) Encode(pcm []byte) ([]byte, error) {
out := make([]byte, len(pcm))
copy(out, pcm)
return out, nil
}
+37
View File
@@ -0,0 +1,37 @@
package audio
import "testing"
func TestPCM16CodecRoundTrip(t *testing.T) {
c := NewPCM16Codec()
in := []byte{0x01, 0x02, 0x03, 0x04, 0xff, 0x7f}
enc, err := c.Encode(in)
if err != nil {
t.Fatalf("encode: %v", err)
}
dec, err := c.Decode(enc)
if err != nil {
t.Fatalf("decode: %v", err)
}
if string(dec) != string(in) {
t.Fatalf("round-trip mismatch: got % X want % X", dec, in)
}
}
func TestPCM16CodecTrimsOddByte(t *testing.T) {
c := NewPCM16Codec()
dec, err := c.Decode([]byte{0x10, 0x20, 0x30}) // 3 bytes = 1 sample + stray
if err != nil {
t.Fatalf("decode: %v", err)
}
if len(dec) != 2 {
t.Fatalf("expected the stray byte trimmed to 2, got %d", len(dec))
}
}
func TestPCM16CodecRejectsSingleByte(t *testing.T) {
c := NewPCM16Codec()
if _, err := c.Decode([]byte{0x10}); err == nil {
t.Fatalf("expected an error for a sub-sample payload")
}
}
+149
View File
@@ -5,6 +5,7 @@ package audio
import (
"fmt"
"runtime"
"sync"
"time"
"unsafe"
@@ -269,3 +270,151 @@ func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct
time.Sleep(10 * time.Millisecond)
}
}
// pcmRing is a thread-safe, latency-bounded FIFO of PCM bytes feeding a live
// render stream. Producers (a USB-codec capture, or a decoded network audio
// stream) Push freshly-arrived samples; the render loop Pulls. It is the shared
// hand-off point between "where the audio comes from" (USB device / UDP 50003)
// and "where it's heard" (any WASAPI output) — so the transport can be swapped
// without touching the render side, mirroring the civTransport split on the CAT
// side. On overflow the oldest audio is dropped to keep latency bounded; on
// underrun Pull simply returns short and the render loop pads with silence.
type pcmRing struct {
mu sync.Mutex
buf []byte
max int // hard cap in bytes (drops oldest beyond this → bounded latency)
}
// newPCMRing makes a ring whose backlog is capped at maxBytes. Size it from the
// acceptable latency: bytesPerSec (=32000) worth ≈ 1 s.
func newPCMRing(maxBytes int) *pcmRing {
if maxBytes <= 0 {
maxBytes = bytesPerSec // 1 s default
}
return &pcmRing{max: maxBytes}
}
// Push appends samples, dropping the oldest audio if the backlog would exceed
// the cap (a slow/absent consumer never makes the producer block or grow without
// bound). A short glitch beats runaway latency for live monitoring.
func (r *pcmRing) Push(p []byte) {
if len(p) == 0 {
return
}
r.mu.Lock()
r.buf = append(r.buf, p...)
if len(r.buf) > r.max {
drop := len(r.buf) - r.max
r.buf = append(r.buf[:0], r.buf[drop:]...)
}
r.mu.Unlock()
}
// pull removes and returns up to maxBytes of queued PCM (a private copy), or nil
// when empty. The render loop pads any shortfall with silence.
func (r *pcmRing) pull(maxBytes int) []byte {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.buf) == 0 || maxBytes <= 0 {
return nil
}
n := maxBytes
if n > len(r.buf) {
n = len(r.buf)
}
out := make([]byte, n)
copy(out, r.buf[:n])
r.buf = append(r.buf[:0], r.buf[n:]...)
return out
}
// renderStream continuously renders PCM pulled from src to a device until stop
// closes — the streaming counterpart to playPCM's fixed buffer. On underrun it
// writes silence rather than glitching, keeping the WASAPI clock steady so live
// monitor audio flows smoothly even when the source stalls briefly. Runs on a
// COM-initialised, OS-locked thread.
func renderStream(deviceID string, rate, ch, bits int, stop <-chan struct{}, src *pcmRing) error {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err := coInit(); err != nil {
return fmt.Errorf("CoInitialize: %w", err)
}
defer ole.CoUninitialize()
dev, err := openDevice(wca.ERender, deviceID)
if err != nil {
return err
}
defer dev.Release()
var ac *wca.IAudioClient
if err := dev.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &ac); err != nil {
return fmt.Errorf("activate render: %w", err)
}
defer ac.Release()
frameBytes := ch * bits / 8
if frameBytes <= 0 {
return fmt.Errorf("bad audio format")
}
wfx := &wca.WAVEFORMATEX{
WFormatTag: 1, NChannels: uint16(ch), NSamplesPerSec: uint32(rate),
NAvgBytesPerSec: uint32(rate * frameBytes), NBlockAlign: uint16(frameBytes),
WBitsPerSample: uint16(bits), CbSize: 0,
}
if err := ac.Initialize(wca.AUDCLNT_SHAREMODE_SHARED, autoConvert,
wca.REFERENCE_TIME(bufferDuration100ns), 0, wfx, nil); err != nil {
return fmt.Errorf("initialize render: %w", err)
}
var bufFrames uint32
if err := ac.GetBufferSize(&bufFrames); err != nil {
return err
}
var arc *wca.IAudioRenderClient
if err := ac.GetService(wca.IID_IAudioRenderClient, &arc); err != nil {
return fmt.Errorf("get render service: %w", err)
}
defer arc.Release()
// feed fills up to `frames` render frames: as much real audio as the ring
// has, the remainder silence (so the buffer stays full and the clock steady).
feed := func(frames int) error {
if frames <= 0 {
return nil
}
var data *byte
if err := arc.GetBuffer(uint32(frames), &data); err != nil {
return err
}
dst := unsafe.Slice(data, frames*frameBytes)
got := src.pull(frames * frameBytes)
n := copy(dst, got)
for i := n; i < len(dst); i++ {
dst[i] = 0 // silence-fill the shortfall
}
arc.ReleaseBuffer(uint32(frames), 0)
return nil
}
if err := feed(int(bufFrames)); err != nil { // pre-fill to avoid a start glitch
return err
}
if err := ac.Start(); err != nil {
return fmt.Errorf("start render: %w", err)
}
defer ac.Stop()
for {
select {
case <-stop:
return nil
default:
}
var padding uint32
ac.GetCurrentPadding(&padding)
if err := feed(int(bufFrames - padding)); err != nil {
return err
}
time.Sleep(8 * time.Millisecond)
}
}
+131 -1
View File
@@ -15,7 +15,10 @@ type Manager struct {
recStop chan struct{}
recDone chan recResult
playStop chan struct{}
onChange func() // fired on any record/playback state transition
monStop chan struct{} // RX monitor passthrough (capture → render)
monRing *pcmRing // live audio hand-off, also fed by the network stream
txStop chan struct{} // TX audio passthrough (mic → rig)
onChange func() // fired on any record/playback state transition
}
type recResult struct {
@@ -135,3 +138,130 @@ func (m *Manager) StopPlayback() {
m.notify()
}
}
// ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------
//
// StartMonitor pipes live RX audio from inputDev (e.g. the rig's "USB Audio
// CODEC" capture endpoint) to outputDev (your speakers/headset) through a
// latency-bounded ring, so you HEAR the radio inside OpsLog. The very same ring
// is later fed by the network 50003 stream instead of a USB capture — the render
// half is transport-agnostic. inputDev "" = system default capture.
func (m *Manager) StartMonitor(inputDev, outputDev string) error {
return m.startMonitor(inputDev, outputDev, true)
}
// StartMonitorSink starts ONLY the render side (no USB capture) so an external
// producer — the network 50003 stream — can feed decoded RX PCM via
// PushMonitorAudio. Same output path as StartMonitor, minus the capture goroutine.
func (m *Manager) StartMonitorSink(outputDev string) error {
return m.startMonitor("", outputDev, false)
}
// startMonitor wires the RX monitor: always a render loop pulling from monRing;
// when capture is true it also captures inputDev into that ring (USB monitor).
// When false the ring is fed only by PushMonitorAudio (network audio).
func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
m.mu.Lock()
if m.monStop != nil {
m.mu.Unlock()
return fmt.Errorf("monitor already running")
}
stop := make(chan struct{})
ring := newPCMRing(bytesPerSec / 2) // ~500 ms cap — low latency for live monitor
m.monStop, m.monRing = stop, ring
m.mu.Unlock()
if capture {
// Producer: capture the rig's USB audio into the ring.
go func() {
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(chunk) })
}()
}
// Consumer: render the ring to the output device at the internal 16 kHz mono.
go func() {
_ = renderStream(outputDev, sampleRate, channels, bitsPerSample, stop, ring)
}()
m.notify()
return nil
}
// StopMonitor stops the RX monitor passthrough.
func (m *Manager) StopMonitor() {
m.mu.Lock()
stop := m.monStop
m.monStop, m.monRing = nil, nil
m.mu.Unlock()
if stop != nil {
close(stop)
m.notify()
}
}
// MonitorActive reports whether the RX monitor passthrough is running.
func (m *Manager) MonitorActive() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.monStop != nil
}
// PushMonitorAudio feeds externally-sourced PCM (16 kHz mono 16-bit) into the
// active monitor's output — the hook the network 50003 audio stream uses to play
// decoded RX through the very same output path a USB capture feeds. No-op when no
// monitor is running. Keeps the unexported ring inside the package.
func (m *Manager) PushMonitorAudio(pcm []byte) {
m.mu.Lock()
ring := m.monRing
m.mu.Unlock()
if ring != nil {
ring.Push(pcm)
}
}
// ---- TX audio passthrough (Phase 3: live mic → rig over USB) --------------
//
// StartTXAudio pipes your live microphone (micDev) into the rig's audio input
// (toRadioDev — for a USB-connected rig, its "USB Audio CODEC" render endpoint),
// so you talk through the PC. It is the mirror of StartMonitor (same ring +
// capture + render primitives, source/sink swapped). PTT keying is the caller's
// job (the app layer keys PTT before this and unkeys after) so this stays a pure
// audio route. The captured 16 kHz mono stream is also the exact shape the future
// network 50003 TX will encode and send — so Phase 5 reuses this capture side.
func (m *Manager) StartTXAudio(micDev, toRadioDev string) error {
m.mu.Lock()
if m.txStop != nil {
m.mu.Unlock()
return fmt.Errorf("TX audio already running")
}
stop := make(chan struct{})
ring := newPCMRing(bytesPerSec / 4) // ~250 ms — tighter for live TX latency
m.txStop = stop
m.mu.Unlock()
go func() {
_ = captureStream(micDev, stop, func(chunk []byte) { ring.Push(chunk) })
}()
go func() {
_ = renderStream(toRadioDev, sampleRate, channels, bitsPerSample, stop, ring)
}()
m.notify()
return nil
}
// StopTXAudio stops the TX mic→rig passthrough.
func (m *Manager) StopTXAudio() {
m.mu.Lock()
stop := m.txStop
m.txStop = nil
m.mu.Unlock()
if stop != nil {
close(stop)
m.notify()
}
}
// TXAudioActive reports whether the TX mic→rig passthrough is running.
func (m *Manager) TXAudioActive() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.txStop != nil
}