Every audio packet was delivered as if it were the next 20 ms, duplicates and late retransmits included. The monitor's capped ring quietly threw the surplus away, so the speakers sounded fine — but the recorder keeps every sample it is given, and the file came out longer than the QSO, slowed and stuttering, each lost-then-resent packet heard twice. trackRxSeq now says whether a packet advances the stream, and only those are handed on.
239 lines
8.2 KiB
Go
239 lines
8.2 KiB
Go
package cat
|
|
|
|
// icomaudio.go — the NETWORK AUDIO stream (UDP 50003) for the Icom LAN protocol.
|
|
// It is the third stream alongside control (50001) and CI-V (50002): once the
|
|
// control login + conninfo (with rxenable=1) authorize audio, the rig streams RX
|
|
// audio here as data packets. This file dials/handshakes/keeps-alive that socket
|
|
// exactly like the CI-V stream (icomnet.go) — those parts are byte-for-byte the
|
|
// PROVEN transport — and hands each received audio payload to a sink callback
|
|
// (the app decodes it via an audio.Codec and plays it through the RX monitor).
|
|
//
|
|
// Reuses icomnet.go's helpers (icnCtrl, icnHandshake, icnPingReply, icnRecv,
|
|
// icnLocalID, icnLE) and the same seq/retransmit discipline.
|
|
//
|
|
// ⚠️ PAYLOAD OFFSET PENDING ON-RIG VERIFICATION. The stream framing (handshake,
|
|
// ping, idle, retransmit, common 16-byte header) is identical to CI-V and proven.
|
|
// The AUDIO data packet's inner layout — where the PCM starts and the datalen
|
|
// field — is reconstructed from wfview's audio_packet (ident@0x10, datalen@0x12,
|
|
// sendseq@0x14, audio@0x16) but NOT yet confirmed against a real 50003 capture.
|
|
// audioPump logs the first few raw packets (icaDumpFirst) so the offset can be
|
|
// confirmed/corrected on the first on-rig test without a packet capture, the same
|
|
// way the CI-V/scope framing was iterated. Nothing here can destabilize CAT: the
|
|
// audio stream is opt-in and entirely separate from control/CI-V.
|
|
|
|
import (
|
|
"net"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// icaAudioOffset is where the PCM payload begins inside an audio data packet.
|
|
// CONFIRMED on a real IC-7760 (2026-08-29): 16-byte common header, ident@0x10,
|
|
// send seq (BE) @0x12, payload length (BE uint32) @0x14 — 0x500 observed on
|
|
// every packet — and the PCM starts at 0x18. The 0x16 first guessed from
|
|
// wfview's struct swallowed two header bytes into the audio, one broken sample
|
|
// per packet: a 50 Hz click track under everything.
|
|
const icaAudioOffset = 0x18
|
|
|
|
// icaDumpFirst is how many initial audio packets to hex-dump to the debug log for
|
|
// offset verification. After the layout is confirmed on a real rig this can go to
|
|
// 0 (or the const above corrected).
|
|
const icaDumpFirst = 6
|
|
|
|
// icomAudio is the connected audio stream. RX only for now (Phase 4); TX (Phase
|
|
// 5) will add an encode+send path mirroring icomNet.Write.
|
|
type icomAudio struct {
|
|
conn *net.UDPConn
|
|
aID, aRemote uint32
|
|
|
|
sink func([]byte) // receives each raw audio payload (app decodes + plays)
|
|
|
|
// Receive-side retransmit (audio is a heavy stream, like the scope): track the
|
|
// rig's data-packet send seq and ask it to resend gaps, or the rig drops the
|
|
// session. Same mechanism as icomNet. Owned solely by audioPump → no lock.
|
|
rxHaveSeq bool
|
|
rxLastSeq uint16
|
|
rxMissing map[uint16]int
|
|
|
|
dumped int // packets hex-dumped so far (≤ icaDumpFirst)
|
|
lastRx atomic.Int64 // UnixNano of last packet (liveness)
|
|
|
|
done chan struct{}
|
|
closeOnce sync.Once
|
|
}
|
|
|
|
func (a *icomAudio) markRx() { a.lastRx.Store(time.Now().UnixNano()) }
|
|
|
|
// Close tears the audio stream down (disconnect a few times; UDP is lossy).
|
|
func (a *icomAudio) Close() {
|
|
a.closeOnce.Do(func() {
|
|
close(a.done)
|
|
for i := 0; i < 3; i++ {
|
|
_, _ = a.conn.Write(icnCtrl(0x05, 0, a.aID, a.aRemote)) // disconnect
|
|
time.Sleep(15 * time.Millisecond)
|
|
}
|
|
_ = a.conn.Close()
|
|
debugLog.Printf("icom audio: stream closed")
|
|
})
|
|
}
|
|
|
|
// dialIcomAudio opens the audio UDP stream to rig:50003, binding LOCAL :50003
|
|
// (mirroring the civ stream's local :50002). The control conninfo (rxenable=1,
|
|
// audioport=50003) must already have authorized it. sink receives each raw audio
|
|
// payload. cancel aborts a slow dial (Stop/Start).
|
|
func dialIcomAudio(host string, sink func([]byte), cancel <-chan struct{}) (*icomAudio, error) {
|
|
araddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50003"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
conn, err := net.DialUDP("udp4", &net.UDPAddr{Port: 50003}, araddr)
|
|
if err != nil {
|
|
debugLog.Printf("icom audio: cannot bind local :50003 (Remote Utility running?): %v", err)
|
|
return nil, err
|
|
}
|
|
aID := icnLocalID(conn)
|
|
aRemote, err := icnHandshake(conn, aID, cancel)
|
|
if err != nil {
|
|
_ = conn.Close()
|
|
debugLog.Printf("icom audio: handshake FAILED: %v", err)
|
|
return nil, err
|
|
}
|
|
_ = conn.SetReadBuffer(1 << 20)
|
|
a := &icomAudio{
|
|
conn: conn, aID: aID, aRemote: aRemote,
|
|
sink: sink,
|
|
rxMissing: make(map[uint16]int),
|
|
done: make(chan struct{}),
|
|
}
|
|
a.markRx()
|
|
debugLog.Printf("icom audio: stream up (rig id 0x%08X) — awaiting RX audio", aRemote)
|
|
go a.audioPump()
|
|
return a, nil
|
|
}
|
|
|
|
// audioPump drains the audio socket: replies to pings, sends idle keepalives,
|
|
// requests retransmits for lost packets, and hands each audio payload to sink.
|
|
func (a *icomAudio) audioPump() {
|
|
buf := make([]byte, 8192)
|
|
lastIdle := time.Now()
|
|
lastReq := time.Now()
|
|
for {
|
|
select {
|
|
case <-a.done:
|
|
return
|
|
default:
|
|
}
|
|
_ = a.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
|
|
if k, err := a.conn.Read(buf); err == nil && k >= 16 {
|
|
a.markRx()
|
|
switch typ := icnLE.Uint16(buf[4:]); {
|
|
case typ == 0x07: // ping
|
|
_, _ = a.conn.Write(icnPingReply(buf[:k], a.aID, a.aRemote))
|
|
case typ == 0x01: // retransmit request from the rig (we send no tracked audio yet)
|
|
case typ == 0x05: // rig-initiated disconnect
|
|
debugLog.Printf("icom audio: rig sent DISCONNECT — audio stream dropped by the rig")
|
|
case typ == 0x00 && k > icaAudioOffset: // audio data packet
|
|
fresh := a.trackRxSeq(icnLE.Uint16(buf[6:]))
|
|
if a.dumped < icaDumpFirst {
|
|
a.dumped++
|
|
debugLog.Printf("icom audio raw #%d: len=%d head=% X", a.dumped, k, buf[:min(icaAudioOffset+8, k)])
|
|
}
|
|
// Only a packet that ADVANCES the sequence reaches the sink. A
|
|
// duplicate or a late retransmit used to be delivered as if it
|
|
// were the next 20 ms of audio: the monitor's capped ring threw
|
|
// the surplus away (the speakers stayed clean), but the recorder
|
|
// keeps every sample it is given — the file grew longer than the
|
|
// QSO and played back slowed and stuttering, each lost-then-
|
|
// resent packet heard twice.
|
|
if fresh && a.sink != nil {
|
|
payload := append([]byte(nil), buf[icaAudioOffset:k]...)
|
|
a.sink(payload)
|
|
}
|
|
}
|
|
}
|
|
if time.Since(lastIdle) > 100*time.Millisecond {
|
|
_, _ = a.conn.Write(icnCtrl(0x00, 0, a.aID, a.aRemote))
|
|
lastIdle = time.Now()
|
|
}
|
|
if time.Since(lastReq) > 100*time.Millisecond {
|
|
a.sendRetransmitReq()
|
|
lastReq = time.Now()
|
|
}
|
|
}
|
|
}
|
|
|
|
// trackRxSeq / sendRetransmitReq mirror icomNet's receive-side retransmit exactly
|
|
// (audio is as loss-sensitive as the scope stream). Duplicated deliberately so
|
|
// the audio stream owns its own seq state with no shared locking.
|
|
//
|
|
// The return value says whether this packet moves the stream FORWARD — the
|
|
// only kind the sink may hear. A duplicate is the same 20 ms again; a late
|
|
// retransmit would play old audio in the middle of new. Both are accounted
|
|
// for here and dropped by the caller.
|
|
func (a *icomAudio) trackRxSeq(seq uint16) bool {
|
|
if !a.rxHaveSeq {
|
|
a.rxHaveSeq = true
|
|
a.rxLastSeq = seq
|
|
return true
|
|
}
|
|
switch d := int16(seq - a.rxLastSeq); {
|
|
case d == 0:
|
|
return false
|
|
case d < 0:
|
|
delete(a.rxMissing, seq)
|
|
return false
|
|
case d == 1:
|
|
a.rxLastSeq = seq
|
|
return true
|
|
case int(d) <= icnMaxMissing:
|
|
for f := a.rxLastSeq + 1; f != seq; f++ {
|
|
a.rxMissing[f] = 0
|
|
}
|
|
a.rxLastSeq = seq
|
|
return true
|
|
default:
|
|
a.rxMissing = make(map[uint16]int)
|
|
a.rxLastSeq = seq
|
|
return true
|
|
}
|
|
}
|
|
|
|
func (a *icomAudio) sendRetransmitReq() {
|
|
if len(a.rxMissing) == 0 {
|
|
return
|
|
}
|
|
if len(a.rxMissing) > icnMaxMissing {
|
|
a.rxMissing = make(map[uint16]int)
|
|
return
|
|
}
|
|
var seqs []uint16
|
|
for s, cnt := range a.rxMissing {
|
|
if cnt >= 4 {
|
|
delete(a.rxMissing, s)
|
|
continue
|
|
}
|
|
a.rxMissing[s] = cnt + 1
|
|
seqs = append(seqs, s)
|
|
}
|
|
switch {
|
|
case len(seqs) == 0:
|
|
return
|
|
case len(seqs) == 1:
|
|
_, _ = a.conn.Write(icnCtrl(0x01, seqs[0], a.aID, a.aRemote))
|
|
default:
|
|
b := make([]byte, 16+4*len(seqs))
|
|
icnLE.PutUint32(b[0:], uint32(len(b)))
|
|
icnLE.PutUint16(b[4:], 0x01)
|
|
icnLE.PutUint32(b[8:], a.aID)
|
|
icnLE.PutUint32(b[12:], a.aRemote)
|
|
off := 16
|
|
for _, s := range seqs {
|
|
icnLE.PutUint16(b[off:], s)
|
|
icnLE.PutUint16(b[off+2:], s)
|
|
off += 4
|
|
}
|
|
_, _ = a.conn.Write(b)
|
|
}
|
|
}
|