diff --git a/app.go b/app.go index 0182e6e..a609344 100644 --- a/app.go +++ b/app.go @@ -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 diff --git a/app_icom_dvk.go b/app_icom_dvk.go new file mode 100644 index 0000000..a01d5ab --- /dev/null +++ b/app_icom_dvk.go @@ -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") +} diff --git a/changelog.json b/changelog.json index 44530d1..f1a641c 100644 --- a/changelog.json +++ b/changelog.json @@ -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." ] }, { diff --git a/internal/audio/network.go b/internal/audio/network.go index d1a9c7d..b9d7143 100644 --- a/internal/audio/network.go +++ b/internal/audio/network.go @@ -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)") diff --git a/internal/cat/icomaudio.go b/internal/cat/icomaudio.go index 4a7a713..2298e85 100644 --- a/internal/cat/icomaudio.go +++ b/internal/cat/icomaudio.go @@ -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 +} diff --git a/internal/cat/icomnet.go b/internal/cat/icomnet.go index 90f6499..539e72d 100644 --- a/internal/cat/icomnet.go +++ b/internal/cat/icomnet.go @@ -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) diff --git a/internal/cat/icomserial.go b/internal/cat/icomserial.go index 5b4fc3a..704cda2 100644 --- a/internal/cat/icomserial.go +++ b/internal/cat/icomserial.go @@ -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") +}