feat(icom): live microphone over the network — the last remote brick

The talk button learns the network road the voice keyer already took: when
To-radio is the radio itself, the microphone is captured and re-framed into
the rig's 320-sample packets, the capture callback serving as the clock — the
mic delivers in real time, so no ring and no pacer. Counters and the frame
remainder live on the audio stream, so a talk session spans calls. PTT keys
before and releases after, through the same code as the USB path.

With this, a network Icom is a complete remote station over three UDP ports:
RX audio to the headset, live voice and recorded messages back, CW through
the rig's keyer, CAT for everything else.
This commit is contained in:
2026-08-29 21:51:37 +02:00
parent 08bc401681
commit e8b5444fc2
6 changed files with 127 additions and 4 deletions
+35
View File
@@ -10729,9 +10729,44 @@ func (a *App) AudioStartTX() error {
if strings.TrimSpace(cfg.ToRadio) == "" { if strings.TrimSpace(cfg.ToRadio) == "" {
return fmt.Errorf(`no "To radio" device set — pick the rig's USB Audio CODEC output in Settings → Audio`) return fmt.Errorf(`no "To radio" device set — pick the rig's USB Audio CODEC output in Settings → Audio`)
} }
// Live mic over the radio's own link: the To-radio "device" is the radio.
// Fetched before keying so a missing stream refuses cleanly with the PTT
// never touched.
var netSend func([]byte) error
if cfg.ToRadio == audio.NetworkDeviceID {
if strings.TrimSpace(cfg.RecordingDevice) == "" {
return fmt.Errorf("pick your microphone as the Recording mic in Settings → Audio")
}
type sender interface {
TXAudioSender() (func([]byte) error, error)
}
err := a.cat.IcomDo(func(ic cat.IcomController) error {
p, ok := ic.(sender)
if !ok {
return fmt.Errorf("this radio cannot take live microphone audio over its link yet")
}
fn, err := p.TXAudioSender()
if err != nil {
return err
}
netSend = fn
return nil
})
if err != nil {
return err
}
}
if err := a.pttKey(cfg); err != nil { // key first — no point streaming to a rig that isn't transmitting if err := a.pttKey(cfg); err != nil { // key first — no point streaming to a rig that isn't transmitting
return err return err
} }
if netSend != nil {
if err := a.audioMgr.StartTXAudioNetwork(cfg.RecordingDevice, netSend); err != nil {
a.pttUnkey()
return err
}
applog.Printf("audio: TX start (mic=%q → the radio over the network, ptt=%q)", cfg.RecordingDevice, cfg.PTTMethod)
return nil
}
if err := a.audioMgr.StartTXAudio(cfg.RecordingDevice, cfg.ToRadio); err != nil { if err := a.audioMgr.StartTXAudio(cfg.RecordingDevice, cfg.ToRadio); err != nil {
a.pttUnkey() a.pttUnkey()
return err return err
+4 -2
View File
@@ -24,7 +24,8 @@
"Icom network audio, phase 5: the radio can now TAKE audio — with RX audio enabled, pick “Radio (network audio)” as the To-radio device and the voice keyer plays straight to the rig over the LAN, no cable, no virtual sound card. First tested on the IC-7760.", "Icom network audio, phase 5: the radio can now TAKE audio — with RX audio enabled, pick “Radio (network audio)” as the To-radio device and the voice keyer plays straight to the rig over the LAN, no cable, no virtual sound card. First tested on the IC-7760.",
"Icom network audio: listening is now a remembered choice — Stop listening keeps the speakers off across reconnects and restarts, while the stream stays open for the QSO recorder and the voice keyer.", "Icom network audio: listening is now a remembered choice — Stop listening keeps the speakers off across reconnects and restarts, while the stream stays open for the QSO recorder and the voice keyer.",
"Icom console: a speaker button beside ON/OFF toggles listening to the network RX audio right from the console — no more trip through Settings → Audio; recordings and the voice keyer keep working either way, and the choice is remembered.", "Icom console: a speaker button beside ON/OFF toggles listening to the network RX audio right from the console — no more trip through Settings → Audio; recordings and the voice keyer keep working either way, and the choice is remembered.",
"CAT settings: a “Play it through the speakers” checkbox sits under the Icom RX-audio option — the same remembered switch as the consoles speaker button, applied immediately." "CAT settings: a “Play it through the speakers” checkbox sits under the Icom RX-audio option — the same remembered switch as the consoles speaker button, applied immediately.",
"Icom network audio: “Talk to radio” now works over the LAN too — live microphone straight to the rig, PTT included. With RX audio and a headset, OpsLog is a complete remote station: hear, talk, key CW and log over one network link."
], ],
"fr": [ "fr": [
"Console Elecraft : le S-mètre est calibré sur un vrai K3 — S9 et les +dB correspondent désormais à laffichage de la radio (il lisait environ deux points S trop bas).", "Console Elecraft : le S-mètre est calibré sur un vrai K3 — S9 et les +dB correspondent désormais à laffichage de la radio (il lisait environ deux points S trop bas).",
@@ -48,7 +49,8 @@
"Audio réseau Icom, phase 5 : la radio peut maintenant RECEVOIR de laudio — avec le RX audio activé, choisissez « Radio (network audio) » comme périphérique To Radio et le voice keyer joue directement vers la radio par le LAN, sans câble ni carte son virtuelle. Premier test sur lIC-7760.", "Audio réseau Icom, phase 5 : la radio peut maintenant RECEVOIR de laudio — avec le RX audio activé, choisissez « Radio (network audio) » comme périphérique To Radio et le voice keyer joue directement vers la radio par le LAN, sans câble ni carte son virtuelle. Premier test sur lIC-7760.",
"Audio réseau Icom : l’écoute est désormais un choix mémorisé — Stop listening garde les enceintes coupées à travers reconnexions et redémarrages, tandis que le flux reste ouvert pour lenregistreur de QSO et le voice keyer.", "Audio réseau Icom : l’écoute est désormais un choix mémorisé — Stop listening garde les enceintes coupées à travers reconnexions et redémarrages, tandis que le flux reste ouvert pour lenregistreur de QSO et le voice keyer.",
"Console Icom : un bouton haut-parleur à côté de ON/OFF bascule l’écoute du RX audio réseau depuis la console — fini laller-retour dans Réglages → Audio ; enregistrements et voice keyer continuent de fonctionner, et le choix est mémorisé.", "Console Icom : un bouton haut-parleur à côté de ON/OFF bascule l’écoute du RX audio réseau depuis la console — fini laller-retour dans Réglages → Audio ; enregistrements et voice keyer continuent de fonctionner, et le choix est mémorisé.",
"Réglages CAT : une case « Écouter dans les enceintes » sous loption RX audio Icom — le même interrupteur mémorisé que le bouton haut-parleur de la console, appliqué immédiatement." "Réglages CAT : une case « Écouter dans les enceintes » sous loption RX audio Icom — le même interrupteur mémorisé que le bouton haut-parleur de la console, appliqué immédiatement.",
"Audio réseau Icom : « Talk to radio » fonctionne aussi par le LAN — micro en direct vers la radio, PTT compris. Avec le RX audio et un casque, OpsLog devient une station remote complète : écouter, parler, manipuler la CW et loguer sur un seul lien réseau."
] ]
}, },
{ {
+23
View File
@@ -356,6 +356,29 @@ func (m *Manager) StartTXAudio(micDev, toRadioDev string) error {
return nil return nil
} }
// StartTXAudioNetwork pipes the live microphone into a SEND function instead
// of a render device — the talk button when the radio is reached over its own
// link. No ring and no pacing goroutine: the microphone delivers in real time,
// and the sender re-frames to the rig's cadence, so the capture callback IS
// the clock.
func (m *Manager) StartTXAudioNetwork(micDev string, send func([]byte) error) error {
m.mu.Lock()
if m.txStop != nil {
m.mu.Unlock()
return fmt.Errorf("TX audio already running")
}
stop := make(chan struct{})
m.txStop = stop
m.mu.Unlock()
go func() {
if err := captureStream(micDev, stop, func(chunk []byte) { _ = send(chunk) }); err != nil {
LogSink("audio: network TX capture from %q failed: %v", DeviceName(micDev), err)
}
}()
m.notify()
return nil
}
// StopTXAudio stops the TX mic→rig passthrough. // StopTXAudio stops the TX mic→rig passthrough.
func (m *Manager) StopTXAudio() { func (m *Manager) StopTXAudio() {
m.mu.Lock() m.mu.Lock()
+43 -2
View File
@@ -57,8 +57,14 @@ type icomAudio struct {
rxLastSeq uint16 rxLastSeq uint16
rxMissing map[uint16]int rxMissing map[uint16]int
dumped int // packets hex-dumped so far (≤ icaDumpFirst) dumped int // packets hex-dumped so far (≤ icaDumpFirst)
lastRx atomic.Int64 // UnixNano of last packet (liveness)
// Live-TX state — see SendTXChunk.
txMu sync.Mutex
txRem []byte
txOuter uint16
txSend uint16
lastRx atomic.Int64 // UnixNano of last packet (liveness)
done chan struct{} done chan struct{}
closeOnce sync.Once closeOnce sync.Once
@@ -292,3 +298,38 @@ func (a *icomAudio) PlayTX(pcm []byte, rate, ch, bits int, stop <-chan struct{})
} }
return nil return nil
} }
// Live TX — the microphone, not a recorded message. Chunks arrive at the
// microphone's own real-time pace (16 kHz mono 16-bit, whatever length the
// capture delivers) and are re-framed into the rig's 320-sample packets; the
// remainder waits for the next chunk. Counters and remainder live on the
// stream so a talk session survives across calls.
func (a *icomAudio) SendTXChunk(pcm []byte) error {
a.txMu.Lock()
defer a.txMu.Unlock()
a.txRem = append(a.txRem, pcm...)
const frameBytes = 640
for len(a.txRem) >= frameBytes {
select {
case <-a.done:
return fmt.Errorf("the audio stream closed")
default:
}
pkt := make([]byte, 0x18+frameBytes)
icnLE.PutUint32(pkt[0:], uint32(len(pkt)))
a.txOuter++
icnLE.PutUint16(pkt[6:], a.txOuter)
icnLE.PutUint32(pkt[8:], a.aID)
icnLE.PutUint32(pkt[12:], a.aRemote)
pkt[0x10], pkt[0x11] = 0x81, 0x01
icnBE.PutUint16(pkt[0x12:], a.txSend)
a.txSend++
icnBE.PutUint32(pkt[0x14:], frameBytes)
copy(pkt[0x18:], a.txRem[:frameBytes])
a.txRem = a.txRem[frameBytes:]
if _, err := a.conn.Write(pkt); err != nil {
return fmt.Errorf("sending mic audio to the rig: %w", err)
}
}
return nil
}
+9
View File
@@ -972,6 +972,15 @@ func (n *icomNet) PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct
return n.audio.PlayTX(pcm, rate, ch, bits, stop) return n.audio.PlayTX(pcm, rate, ch, bits, stop)
} }
// TXAudioSender returns a function that streams live microphone chunks to the
// rig — the talk button's road, where PlayTXAudio is the voice keyer's.
func (n *icomNet) TXAudioSender() (func([]byte) error, error) {
if n.audio == nil {
return nil, fmt.Errorf("the radio's audio stream is not open — enable RX audio in Settings → CAT first")
}
return n.audio.SendTXChunk, nil
}
func icnOpenClose(seq uint16, sentid, rcvdid uint32, civSeq uint16, magic byte) []byte { func icnOpenClose(seq uint16, sentid, rcvdid uint32, civSeq uint16, magic byte) []byte {
b := make([]byte, 0x16) b := make([]byte, 0x16)
icnLE.PutUint32(b[0:], 0x16) icnLE.PutUint32(b[0:], 0x16)
+13
View File
@@ -2064,3 +2064,16 @@ func (b *IcomSerial) PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan str
} }
return fmt.Errorf("this rig takes transmit audio through its USB sound card, not the CAT link") return fmt.Errorf("this rig takes transmit audio through its USB sound card, not the CAT link")
} }
// TXAudioSender hands back the network transport's live-mic sender, when this
// rig is reached over one.
func (b *IcomSerial) TXAudioSender() (func([]byte) error, error) {
port := b.port
type sender interface {
TXAudioSender() (func([]byte) error, error)
}
if p, ok := port.(sender); ok {
return p.TXAudioSender()
}
return nil, fmt.Errorf("this rig takes transmit audio through its USB sound card, not the CAT link")
}