feat(icom): phase 5 — the voice keyer plays to the radio over the LAN

The audio session opens its TX side alongside RX (txenable in the conninfo,
16 kHz both ways), the stream gains a paced sender — 320 samples every 20 ms,
the frame mirroring what the rig itself sends on this socket, IDs swapped —
and the network Icom takes the same NetworkPlayer slot a TCI radio does: pick
'Radio (network audio)' as the To-radio device and the voice keyer needs no
cable and no virtual sound card. PTT brackets the message through the same
code as every other audio path.
This commit is contained in:
2026-08-29 19:34:05 +02:00
parent 6345710de5
commit 13434ca36c
7 changed files with 149 additions and 7 deletions
+5 -2
View File
@@ -8440,7 +8440,7 @@ func (a *App) ListAudioInputDevices() ([]audio.Device, error) {
return devs, err
}
if a.tciAudioAvailable() {
devs = append([]audio.Device{{ID: audio.NetworkDeviceID, Name: "Radio (TCI network audio)"}}, devs...)
devs = append([]audio.Device{{ID: audio.NetworkDeviceID, Name: "Radio (network audio)"}}, devs...)
}
return devs, nil
}
@@ -8467,7 +8467,7 @@ func (a *App) ListAudioOutputDevices() ([]audio.Device, error) {
return devs, err
}
if audio.NetworkPlayerReady() {
devs = append([]audio.Device{{ID: audio.NetworkDeviceID, Name: "Radio (TCI network audio)"}}, devs...)
devs = append([]audio.Device{{ID: audio.NetworkDeviceID, Name: "Radio (network audio)"}}, devs...)
}
return devs, nil
}
@@ -15625,6 +15625,9 @@ func (a *App) reloadCAT() {
}
}
a.cat.Start(cat.NewIcomNet(s.IcomNetHost, s.IcomNetUser, s.IcomNetPass, s.IcomAddr, s.DigitalDefault, audioSink))
// With the audio session open the radio can also TAKE audio: offer it to
// the voice keyer, the way a TCI radio is offered.
a.installIcomTXPlayer(audioSink != nil)
case "tci":
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
// TCI-compatible server. The receive audio rides the same socket, so
+49
View File
@@ -0,0 +1,49 @@
package main
// The voice keyer, through an Icom's network link — the Icom face of what
// app_tci_dvk.go does for a SunSDR: selecting the radio as the "To radio"
// output hands messages to the 50003 audio session instead of a sound card.
// The same PTT before and after, the same gain, the same files.
import (
"fmt"
"hamlog/internal/applog"
"hamlog/internal/audio"
"hamlog/internal/cat"
)
// icomTXPlayer hands one message to the radio. Fetched on the CAT goroutine,
// played off it — a ten-second message must not freeze frequency, mode and
// PTT handling for ten seconds (see tciTXPlayer, which set the pattern).
func (a *App) icomTXPlayer(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
if a.cat == nil {
return fmt.Errorf("CAT not initialized")
}
type txPlayer interface {
PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error
}
var player txPlayer
err := a.cat.IcomDo(func(ic cat.IcomController) error {
p, ok := ic.(txPlayer)
if !ok {
return fmt.Errorf("this radio cannot take transmit audio over its CAT link")
}
player = p
return nil
})
if err != nil {
return err
}
return player.PlayTXAudio(pcm, rate, ch, bits, stop)
}
// installIcomTXPlayer offers the network Icom as an audio output, or withdraws
// it. Withdrawing matters as much as offering — see installTCITXPlayer.
func (a *App) installIcomTXPlayer(on bool) {
if !on {
return // the reloadCAT preamble already cleared the network player
}
audio.SetNetworkPlayer(a.icomTXPlayer)
applog.Printf("icom net: the radio is available as an audio output — the voice keyer can play to it without a cable")
}
+4 -2
View File
@@ -20,7 +20,8 @@
"Icom network audio: duplicate and late-retransmitted packets no longer reach the recorder — recordings came out longer than the QSO, slowed and stuttering, while the speakers played fine.",
"Icom network audio: it now starts with the app — launching OpsLog connected with audio silently off, and the option had to be unticked and re-saved to hear anything.",
"Icom network: when the rig goes quiet on CI-V while the session stays up (seen on the IC-7760), the CI-V flow is re-opened on the spot — recovery in seconds instead of the 35-second full reconnect.",
"Icom network: after a session dies young, the redial waits 20 s so the rig can purge the old session first — stops the reconnect-die-reconnect spiral seen on the IC-7760, where each fresh session answered for a second and was then strangled by the previous ones cleanup."
"Icom network: after a session dies young, the redial waits 20 s so the rig can purge the old session first — stops the reconnect-die-reconnect spiral seen on the IC-7760, where each fresh session answered for a second and was then strangled by the previous ones cleanup.",
"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."
],
"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).",
@@ -40,7 +41,8 @@
"Audio réseau Icom : les paquets dupliqués ou retransmis en retard natteignent plus lenregistreur — les enregistrements sortaient plus longs que le QSO, ralentis et hachés, alors que les haut-parleurs jouaient bien.",
"Audio réseau Icom : il démarre maintenant avec lapplication — au lancement, la connexion se faisait audio coupé, et il fallait décocher/recocher loption pour entendre quelque chose.",
"Réseau Icom : quand la radio se tait sur le CI-V alors que la session tient (constaté sur lIC-7760), le flux CI-V est rouvert immédiatement — récupération en quelques secondes au lieu des 35 secondes de reconnexion complète.",
"Réseau Icom : après une session morte jeune, la renumérotation attend 20 s pour que la radio purge dabord lancienne session — stoppe la spirale reconnexion-mort-reconnexion vue sur lIC-7760, où chaque session neuve répondait une seconde avant d’être étranglée par le nettoyage de la précédente."
"Réseau Icom : après une session morte jeune, la renumérotation attend 20 s pour que la radio purge dabord lancienne session — stoppe la spirale reconnexion-mort-reconnexion vue sur lIC-7760, où chaque session neuve répondait une seconde avant d’être étranglée par le nettoyage de la précédente.",
"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."
]
},
{
+1 -1
View File
@@ -64,4 +64,4 @@ func NetworkPlayerReady() bool { return networkPlayer() != nil }
// comes back with. Named, because "the device could not be opened" would send
// an operator hunting through Windows sound settings for a device that never
// existed.
var errNoNetworkRadio = errors.New("no radio is connected to take the audio — check the CAT link (the radio output only works with a TCI radio)")
var errNoNetworkRadio = errors.New("no radio is connected to take the audio — check the CAT link (a TCI radio, or a network Icom with RX audio enabled)")
+56
View File
@@ -22,6 +22,7 @@ package cat
// audio stream is opt-in and entirely separate from control/CI-V.
import (
"fmt"
"net"
"sync"
"sync/atomic"
@@ -236,3 +237,58 @@ func (a *icomAudio) sendRetransmitReq() {
_, _ = a.conn.Write(b)
}
}
// PlayTX sends one already-decoded message out over the audio session, paced
// at the stream's own 20 ms / 320-sample cadence, and returns when the last
// packet has gone or stop is closed.
//
// The frame mirrors what the rig itself sends on this socket byte for byte —
// ident 0x81 0x01 (LPCM mono 16-bit), a big-endian send sequence at 0x12, the
// payload length at 0x14, PCM from 0x18 — with the IDs swapped for direction.
// PTT is the caller's business, exactly as on the TCI and sound-card paths.
func (a *icomAudio) PlayTX(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
mono := decodeToMono(pcm, ch, bits)
if len(mono) == 0 {
return fmt.Errorf("the message is empty")
}
if rate > 0 && rate != 16000 {
mono = resampleLinear(mono, rate, 16000)
}
const frame = 320 // samples per packet: 20 ms at 16 kHz, the rig's own cadence
tick := time.NewTicker(20 * time.Millisecond)
defer tick.Stop()
var outerSeq uint16 = 1
var sendSeq uint16
for pos := 0; pos < len(mono); pos += frame {
select {
case <-stop:
return nil
case <-a.done:
return fmt.Errorf("the audio stream closed mid-message")
case <-tick.C:
}
pkt := make([]byte, 0x18+frame*2)
icnLE.PutUint32(pkt[0:], uint32(len(pkt)))
icnLE.PutUint16(pkt[6:], outerSeq)
icnLE.PutUint32(pkt[8:], a.aID)
icnLE.PutUint32(pkt[12:], a.aRemote)
pkt[0x10], pkt[0x11] = 0x81, 0x01
icnBE.PutUint16(pkt[0x12:], sendSeq)
icnBE.PutUint32(pkt[0x14:], frame*2)
for i := 0; i < frame && pos+i < len(mono); i++ {
v := mono[pos+i] * 32767
if v > 32767 {
v = 32767
} else if v < -32768 {
v = -32768
}
icnLE.PutUint16(pkt[0x18+i*2:], uint16(int16(v)))
}
if _, err := a.conn.Write(pkt); err != nil {
return fmt.Errorf("sending audio to the rig: %w", err)
}
outerSeq++
sendSeq++
}
return nil
}
+17 -2
View File
@@ -940,7 +940,11 @@ func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, use
copy(b[0x40:0x60], []byte("IC-7610"))
copy(b[0x60:0x70], icnPasscode(user))
b[0x70] = rxEnable // rxenable: 1 opens the 50003 RX audio stream, 0 = CI-V only
b[0x71] = 0x00 // txenable (Phase 5)
// TX rides the same audio session: whenever the operator wants RX audio the
// TX side is opened with it, so the voice keyer can play to the radio with
// no cable. Costs nothing when unused — no packets flow until a message is
// actually played.
b[0x71] = rxEnable // txenable
// rxcodec 0x04 = LPCM, ONE channel, 16-BIT — settled by experiment on a
// real IC-7760, one wrong guess at a time: 0x10 produced 1280-byte payloads
// of interleaved 16-bit stereo, 0x02 produced 320-byte payloads of 8-bit
@@ -949,7 +953,7 @@ func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, use
b[0x72] = 0x04 // rxcodec
b[0x73] = 0x04 // txcodec
icnBE.PutUint32(b[0x74:], 16000)
icnBE.PutUint32(b[0x78:], 8000)
icnBE.PutUint32(b[0x78:], 16000) // TX sample rate — the same 16 kHz everything else here runs at
icnBE.PutUint32(b[0x7c:], uint32(civPort))
icnBE.PutUint32(b[0x80:], uint32(audioPort))
icnBE.PutUint32(b[0x84:], 100)
@@ -957,6 +961,17 @@ func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, use
return b
}
// PlayTXAudio plays one voice-keyer message through the 50003 audio session.
// On the transport because the transport owns the session; the IcomSerial
// controller forwards here after a type assertion, exactly as the TCI path
// reaches its radio.
func (n *icomNet) PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
if n.audio == nil {
return fmt.Errorf("the radio's audio stream is not open — enable RX audio in Settings → CAT first")
}
return n.audio.PlayTX(pcm, rate, ch, bits, stop)
}
func icnOpenClose(seq uint16, sentid, rcvdid uint32, civSeq uint16, magic byte) []byte {
b := make([]byte, 0x16)
icnLE.PutUint32(b[0:], 0x16)
+17
View File
@@ -2047,3 +2047,20 @@ func agcValue(name string) byte {
}
return 0
}
// PlayTXAudio hands a voice-keyer message to the network audio session, when
// this rig is reached over one. A USB-connected Icom takes its audio through
// its own sound card instead, and says so.
func (b *IcomSerial) PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
// port is written once per Connect, on the CAT goroutine this is fetched
// from (IcomDo); at worst a reconnect leaves this one connection stale, and
// a stale transport fails fast on its closed done channel.
port := b.port
type txPlayer interface {
PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error
}
if p, ok := port.(txPlayer); ok {
return p.PlayTXAudio(pcm, rate, ch, bits, stop)
}
return fmt.Errorf("this rig takes transmit audio through its USB sound card, not the CAT link")
}