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:
@@ -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
@@ -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 console’s 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 console’s 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 à l’affichage 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 à l’affichage 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 l’audio — 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 l’IC-7760.",
|
"Audio réseau Icom, phase 5 : la radio peut maintenant RECEVOIR de l’audio — 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 l’IC-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 l’enregistreur 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 l’enregistreur 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 l’aller-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 l’aller-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 l’option 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 l’option 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."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ type icomAudio struct {
|
|||||||
rxMissing map[uint16]int
|
rxMissing map[uint16]int
|
||||||
|
|
||||||
dumped int // packets hex-dumped so far (≤ icaDumpFirst)
|
dumped int // packets hex-dumped so far (≤ icaDumpFirst)
|
||||||
|
|
||||||
|
// Live-TX state — see SendTXChunk.
|
||||||
|
txMu sync.Mutex
|
||||||
|
txRem []byte
|
||||||
|
txOuter uint16
|
||||||
|
txSend uint16
|
||||||
lastRx atomic.Int64 // UnixNano of last packet (liveness)
|
lastRx atomic.Int64 // UnixNano of last packet (liveness)
|
||||||
|
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user