diff --git a/app.go b/app.go index a7aa592..8d5b632 100644 --- a/app.go +++ b/app.go @@ -10729,9 +10729,44 @@ func (a *App) AudioStartTX() error { if strings.TrimSpace(cfg.ToRadio) == "" { 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 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 { a.pttUnkey() return err diff --git a/changelog.json b/changelog.json index a0338c0..4b985ca 100644 --- a/changelog.json +++ b/changelog.json @@ -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: 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.", - "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": [ "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 : 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é.", - "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." ] }, { diff --git a/internal/audio/manager.go b/internal/audio/manager.go index 0c4b36c..f58fed5 100644 --- a/internal/audio/manager.go +++ b/internal/audio/manager.go @@ -356,6 +356,29 @@ func (m *Manager) StartTXAudio(micDev, toRadioDev string) error { 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. func (m *Manager) StopTXAudio() { m.mu.Lock() diff --git a/internal/cat/icomaudio.go b/internal/cat/icomaudio.go index 2298e85..5bb1b24 100644 --- a/internal/cat/icomaudio.go +++ b/internal/cat/icomaudio.go @@ -57,8 +57,14 @@ type icomAudio struct { rxLastSeq uint16 rxMissing map[uint16]int - dumped int // packets hex-dumped so far (≤ icaDumpFirst) - lastRx atomic.Int64 // UnixNano of last packet (liveness) + 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) done chan struct{} closeOnce sync.Once @@ -292,3 +298,38 @@ func (a *icomAudio) PlayTX(pcm []byte, rate, ch, bits int, stop <-chan struct{}) } 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 +} diff --git a/internal/cat/icomnet.go b/internal/cat/icomnet.go index 539e72d..2627ee2 100644 --- a/internal/cat/icomnet.go +++ b/internal/cat/icomnet.go @@ -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) } +// 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 { b := make([]byte, 0x16) icnLE.PutUint32(b[0:], 0x16) diff --git a/internal/cat/icomserial.go b/internal/cat/icomserial.go index 704cda2..232689c 100644 --- a/internal/cat/icomserial.go +++ b/internal/cat/icomserial.go @@ -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") } + +// 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") +}