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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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 one’s 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 one’s 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 à l’affichage 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 n’atteignent plus l’enregistreur — 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 l’application — au lancement, la connexion se faisait audio coupé, et il fallait décocher/recocher l’option pour entendre quelque chose.",
|
||||
"Réseau Icom : quand la radio se tait sur le CI-V alors que la session tient (constaté sur l’IC-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 d’abord l’ancienne session — stoppe la spirale reconnexion-mort-reconnexion vue sur l’IC-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 d’abord l’ancienne session — stoppe la spirale reconnexion-mort-reconnexion vue sur l’IC-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 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."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user