diff --git a/app.go b/app.go index 3cc99af..fbe6209 100644 --- a/app.go +++ b/app.go @@ -96,6 +96,7 @@ const ( keyCATIcomNetHost = "cat.icom.net.host" // Icom network remote: rig IP/hostname keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password + keyCATIcomNetAudio = "cat.icom.net.audio" // Icom network: stream RX audio on 50003 (experimental) keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2) keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001) keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama @@ -279,6 +280,7 @@ type CATSettings struct { IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server) IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password + IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR) TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001) TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama @@ -4321,7 +4323,7 @@ func (a *App) GetCATSettings() (CATSettings, error) { if a.settings == nil { return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") } - m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) + m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) if err != nil { return CATSettings{}, err } @@ -4338,6 +4340,7 @@ func (a *App) GetCATSettings() (CATSettings, error) { IcomNetHost: m[keyCATIcomNetHost], IcomNetUser: m[keyCATIcomNetUser], IcomNetPass: m[keyCATIcomNetPass], + IcomNetAudio: m[keyCATIcomNetAudio] == "1", TCIHost: m[keyCATTCIHost], TCIPort: 40001, TCISpots: m[keyCATTCISpots] == "1", @@ -4413,6 +4416,10 @@ func (a *App) SaveCATSettings(s CATSettings) error { if s.TCISpots { tciSpots = "1" } + icomNetAudio := "0" + if s.IcomNetAudio { + icomNetAudio = "1" + } if s.DigitalDefault == "" { s.DigitalDefault = "FT8" } @@ -4429,6 +4436,7 @@ func (a *App) SaveCATSettings(s CATSettings) error { keyCATIcomNetHost: strings.TrimSpace(s.IcomNetHost), keyCATIcomNetUser: strings.TrimSpace(s.IcomNetUser), keyCATIcomNetPass: s.IcomNetPass, + keyCATIcomNetAudio: icomNetAudio, keyCATTCIHost: strings.TrimSpace(s.TCIHost), keyCATTCIPort: strconv.Itoa(s.TCIPort), keyCATTCISpots: tciSpots, @@ -5877,6 +5885,72 @@ func (a *App) DVKStop() { } } +// AudioStartMonitor pipes live RX audio from the rig into your speakers so you +// hear the radio inside OpsLog. Source = the "From radio" capture device (for a +// USB-connected rig, its "USB Audio CODEC" input); sink = the "Listening" +// device. This is the USB half of the audio feature; the network 50003 stream +// will later Push into the same output path (see audio.Manager.PushMonitorAudio). +func (a *App) AudioStartMonitor() error { + if a.audioMgr == nil { + return fmt.Errorf("audio not initialized") + } + cfg, _ := a.GetAudioSettings() + if strings.TrimSpace(cfg.FromRadio) == "" { + return fmt.Errorf(`no "From radio" capture device set — pick the rig's USB Audio CODEC in Settings → Audio`) + } + applog.Printf("audio: RX monitor start (from=%q → listen=%q)", cfg.FromRadio, cfg.ListeningDevice) + return a.audioMgr.StartMonitor(cfg.FromRadio, cfg.ListeningDevice) +} + +// AudioStopMonitor stops the RX monitor passthrough. +func (a *App) AudioStopMonitor() { + if a.audioMgr != nil { + a.audioMgr.StopMonitor() + applog.Printf("audio: RX monitor stopped") + } +} + +// AudioMonitorActive reports whether the RX monitor is running (for the toggle). +func (a *App) AudioMonitorActive() bool { + return a.audioMgr != nil && a.audioMgr.MonitorActive() +} + +// AudioStartTX keys PTT and pipes your live mic into the rig ("To Radio" device) +// so you can talk through the PC — the USB half of TX voice. PTT uses the +// configured method (CAT/RTS/DTR); if keying fails the audio route isn't started. +func (a *App) AudioStartTX() error { + if a.audioMgr == nil { + return fmt.Errorf("audio not initialized") + } + cfg, _ := a.GetAudioSettings() + if strings.TrimSpace(cfg.ToRadio) == "" { + return fmt.Errorf(`no "To radio" device set — pick the rig's USB Audio CODEC output in Settings → Audio`) + } + if err := a.pttKey(cfg); err != nil { // key first — no point streaming to a rig that isn't transmitting + return err + } + if err := a.audioMgr.StartTXAudio(cfg.RecordingDevice, cfg.ToRadio); err != nil { + a.pttUnkey() + return err + } + applog.Printf("audio: TX start (mic=%q → to-radio=%q, ptt=%q)", cfg.RecordingDevice, cfg.ToRadio, cfg.PTTMethod) + return nil +} + +// AudioStopTX stops the TX passthrough and unkeys PTT. +func (a *App) AudioStopTX() { + if a.audioMgr != nil { + a.audioMgr.StopTXAudio() + } + a.pttUnkey() + applog.Printf("audio: TX stopped") +} + +// AudioTXActive reports whether the TX passthrough is running (for the toggle). +func (a *App) AudioTXActive() bool { + return a.audioMgr != nil && a.audioMgr.TXAudioActive() +} + // GetLogFilePath returns where the diagnostic log file lives so the user // can open it from the Settings UI. Empty when applog hasn't initialised. func (a *App) GetLogFilePath() string { @@ -8606,7 +8680,28 @@ func (a *App) reloadCAT() { case "icom-net": // Icom CI-V over the rig's built-in LAN server (remote, no RS-BA1 / Remote // Utility). Reuses the whole IcomController over the network transport. - a.cat.Start(cat.NewIcomNet(s.IcomNetHost, s.IcomNetUser, s.IcomNetPass, s.IcomAddr, s.DigitalDefault)) + var audioSink func([]byte) + if s.IcomNetAudio && a.audioMgr != nil { + // Experimental: stream RX audio over 50003. Start the render-only monitor + // sink (no USB capture) and feed each decoded payload into it, so remote + // RX audio plays through the "Listening" device. Decoding is via the PCM + // codec (Opus later). The 50003 payload OFFSET is still pending on-rig + // verification (see icomaudio.go) — hence experimental + opt-in. + acfg, _ := a.GetAudioSettings() + a.audioMgr.StopMonitor() // clear any prior monitor/sink so a re-save restarts cleanly + if err := a.audioMgr.StartMonitorSink(acfg.ListeningDevice); err != nil { + applog.Printf("icom-net audio: cannot start output sink: %v", err) + } else { + codec := audio.NewPCM16Codec() + audioSink = func(payload []byte) { + if pcm, err := codec.Decode(payload); err == nil { + a.audioMgr.PushMonitorAudio(pcm) + } + } + applog.Printf("icom-net audio: RX audio streaming ENABLED (experimental) → %q", acfg.ListeningDevice) + } + } + a.cat.Start(cat.NewIcomNet(s.IcomNetHost, s.IcomNetUser, s.IcomNetPass, s.IcomAddr, s.DigitalDefault, audioSink)) case "tci": // Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any // TCI-compatible server. diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index a6f749b..aea693c 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -21,6 +21,8 @@ import { GetEmailSettings, SaveEmailSettings, TestEmail, QSLGetEmailTemplates, QSLSaveEmailTemplates, GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus, + AudioStartMonitor, AudioStopMonitor, AudioMonitorActive, + AudioStartTX, AudioStopTX, AudioTXActive, ListClusterServers, SaveClusterServer, DeleteClusterServer, GetClusterAutoConnect, SetClusterAutoConnect, ConnectClusterServer, DisconnectClusterServer, @@ -796,7 +798,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [modeDraft, setModeDraft] = useState(''); const [catCfg, setCatCfg] = useState({ enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, - icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', + icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, digital_default: 'FT8', }); @@ -863,6 +865,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [dvkMsgs, setDvkMsgs] = useState([]); const [dvkStat, setDvkStat] = useState({ recording: false, playing: false, rec_slot: 0 }); const [dvkErr, setDvkErr] = useState(''); + const [monitorOn, setMonitorOn] = useState(false); + const [txOn, setTxOn] = useState(false); + useEffect(() => { + AudioMonitorActive().then(setMonitorOn).catch(() => {}); + AudioTXActive().then(setTxOn).catch(() => {}); + }, []); + const toggleMonitor = async () => { + try { + if (monitorOn) { await AudioStopMonitor(); setMonitorOn(false); } + else { await AudioStartMonitor(); setMonitorOn(true); } + } catch (err: any) { setDvkErr(String(err?.message ?? err)); } + }; + const toggleTX = async () => { + try { + if (txOn) { await AudioStopTX(); setTxOn(false); } + else { await AudioStartTX(); setTxOn(true); } + } catch (err: any) { setDvkErr(String(err?.message ?? err)); } + }; // General behaviour prefs (mirrored to the DB so they travel with data/). const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0'); @@ -2065,6 +2085,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_pass: e.target.value }))} />

{t('cat.icomNetHint')}

+ )} {catCfg.backend === 'tci' && ( @@ -3747,6 +3775,40 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan From Radio = what you receive (used by the QSO recorder).{' '} To Radio = where voice-keyer messages are transmitted.

+
+ + + {monitorOn + ? 'RX monitor running — From Radio → Listening device.' + : 'Live-monitor the rig here (USB codec now; network audio later).'} + +
+
+ + + {txOn + ? 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.' + : 'Live mic → rig with PTT (USB now; network TX later).'} + +
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 8072d57..9bb95fe 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -134,6 +134,8 @@ const en: Dict = { 'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)', 'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password', 'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.", + 'cat.icomNetAudio': 'Stream RX audio over the network (experimental)', + 'cat.icomNetAudioHint': 'Play the rig’s received audio through your Listening device (Settings → Audio) over the 50003 stream. Experimental — the audio framing is pending on-rig verification; leave off if control misbehaves.', 'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)", 'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.', 'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)", @@ -327,6 +329,8 @@ const fr: Dict = { 'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)', 'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau', 'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.", + 'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)', + 'cat.icomNetAudioHint': 'Écoute l’audio reçu du poste sur ton périphérique d’écoute (Réglages → Audio) via le flux 50003. Expérimental — le format audio reste à vérifier sur le poste ; laisse désactivé si le contrôle se comporte mal.', 'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)", 'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.', 'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)", diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 35c68c7..94a9fbb 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -36,6 +36,18 @@ export function ApplyAwardPreset(arg1:string,arg2:string):Promise; export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array):Promise; +export function AudioMonitorActive():Promise; + +export function AudioStartMonitor():Promise; + +export function AudioStartTX():Promise; + +export function AudioStopMonitor():Promise; + +export function AudioStopTX():Promise; + +export function AudioTXActive():Promise; + export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise>; export function AwardFields():Promise>; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index abf20e8..0132188 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -34,6 +34,30 @@ export function AssignAwardRefToQSOs(arg1, arg2, arg3) { return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3); } +export function AudioMonitorActive() { + return window['go']['main']['App']['AudioMonitorActive'](); +} + +export function AudioStartMonitor() { + return window['go']['main']['App']['AudioStartMonitor'](); +} + +export function AudioStartTX() { + return window['go']['main']['App']['AudioStartTX'](); +} + +export function AudioStopMonitor() { + return window['go']['main']['App']['AudioStopMonitor'](); +} + +export function AudioStopTX() { + return window['go']['main']['App']['AudioStopTX'](); +} + +export function AudioTXActive() { + return window['go']['main']['App']['AudioTXActive'](); +} + export function AwardCellQSOs(arg1, arg2, arg3) { return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 1afdfd6..dfeac3b 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1349,6 +1349,7 @@ export namespace main { icom_net_host: string; icom_net_user: string; icom_net_pass: string; + icom_net_audio: boolean; tci_host: string; tci_port: number; tci_spots: boolean; @@ -1374,6 +1375,7 @@ export namespace main { this.icom_net_host = source["icom_net_host"]; this.icom_net_user = source["icom_net_user"]; this.icom_net_pass = source["icom_net_pass"]; + this.icom_net_audio = source["icom_net_audio"]; this.tci_host = source["tci_host"]; this.tci_port = source["tci_port"]; this.tci_spots = source["tci_spots"]; diff --git a/internal/audio/codec.go b/internal/audio/codec.go new file mode 100644 index 0000000..7e7c416 --- /dev/null +++ b/internal/audio/codec.go @@ -0,0 +1,57 @@ +package audio + +import "fmt" + +// Codec converts between the wire payload of a network audio stream (Icom 50003) +// and OpsLog's internal PCM (16 kHz mono 16-bit little-endian — the format the +// capture/render engine and the pcmRing use). It exists so the transport code +// (icomaudio.go) never hard-codes a format: today PCM is an identity passthrough; +// tomorrow an Opus codec implements the same two methods and drops in unchanged. +// This mirrors how civTransport abstracts the CAT byte stream from its transport. +type Codec interface { + // Name is a short label for logs/UI. + Name() string + // Decode turns one received audio payload into internal PCM. The returned + // slice is freshly allocated (the caller may retain it). + Decode(payload []byte) ([]byte, error) + // Encode turns internal PCM into a payload to transmit. + Encode(pcm []byte) ([]byte, error) +} + +// pcm16Codec is the uncompressed 16-bit-PCM codec — the Icom "uncompressed" +// audio mode (rxcodec/txcodec in the conninfo). Icom sends little-endian 16-bit +// mono samples, which is byte-for-byte OpsLog's internal format, so decode/encode +// are copies. It is the Phase-4/5 default: zero-dependency, lossless, ideal on a +// LAN. Opus (for WAN/internet bandwidth) becomes another Codec later. +// +// NOTE: rate conversion is deliberately NOT this layer's job. The Icom RX audio +// is 16 kHz = our internal rate, so RX needs none. The rig's TX side may run at a +// different rate (the captured conninfo showed 8 kHz) — Phase 5 will resample in +// the TX path before Encode; keeping the codec rate-agnostic keeps that concern +// in one place. +type pcm16Codec struct{} + +// NewPCM16Codec returns the uncompressed 16-bit PCM codec. +func NewPCM16Codec() Codec { return pcm16Codec{} } + +func (pcm16Codec) Name() string { return "pcm16" } + +func (pcm16Codec) Decode(payload []byte) ([]byte, error) { + // Icom PCM payloads are whole 16-bit samples; an odd length means a truncated + // packet — trim the stray byte rather than emit a half-sample click. + n := len(payload) &^ 1 + if n != len(payload) { + if n == 0 { + return nil, fmt.Errorf("pcm16: payload too short (%d bytes)", len(payload)) + } + } + out := make([]byte, n) + copy(out, payload[:n]) + return out, nil +} + +func (pcm16Codec) Encode(pcm []byte) ([]byte, error) { + out := make([]byte, len(pcm)) + copy(out, pcm) + return out, nil +} diff --git a/internal/audio/codec_test.go b/internal/audio/codec_test.go new file mode 100644 index 0000000..1ba1c99 --- /dev/null +++ b/internal/audio/codec_test.go @@ -0,0 +1,37 @@ +package audio + +import "testing" + +func TestPCM16CodecRoundTrip(t *testing.T) { + c := NewPCM16Codec() + in := []byte{0x01, 0x02, 0x03, 0x04, 0xff, 0x7f} + enc, err := c.Encode(in) + if err != nil { + t.Fatalf("encode: %v", err) + } + dec, err := c.Decode(enc) + if err != nil { + t.Fatalf("decode: %v", err) + } + if string(dec) != string(in) { + t.Fatalf("round-trip mismatch: got % X want % X", dec, in) + } +} + +func TestPCM16CodecTrimsOddByte(t *testing.T) { + c := NewPCM16Codec() + dec, err := c.Decode([]byte{0x10, 0x20, 0x30}) // 3 bytes = 1 sample + stray + if err != nil { + t.Fatalf("decode: %v", err) + } + if len(dec) != 2 { + t.Fatalf("expected the stray byte trimmed to 2, got %d", len(dec)) + } +} + +func TestPCM16CodecRejectsSingleByte(t *testing.T) { + c := NewPCM16Codec() + if _, err := c.Decode([]byte{0x10}); err == nil { + t.Fatalf("expected an error for a sub-sample payload") + } +} diff --git a/internal/audio/engine.go b/internal/audio/engine.go index d6d0a9f..e2d4b92 100644 --- a/internal/audio/engine.go +++ b/internal/audio/engine.go @@ -5,6 +5,7 @@ package audio import ( "fmt" "runtime" + "sync" "time" "unsafe" @@ -269,3 +270,151 @@ func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct time.Sleep(10 * time.Millisecond) } } + +// pcmRing is a thread-safe, latency-bounded FIFO of PCM bytes feeding a live +// render stream. Producers (a USB-codec capture, or a decoded network audio +// stream) Push freshly-arrived samples; the render loop Pulls. It is the shared +// hand-off point between "where the audio comes from" (USB device / UDP 50003) +// and "where it's heard" (any WASAPI output) — so the transport can be swapped +// without touching the render side, mirroring the civTransport split on the CAT +// side. On overflow the oldest audio is dropped to keep latency bounded; on +// underrun Pull simply returns short and the render loop pads with silence. +type pcmRing struct { + mu sync.Mutex + buf []byte + max int // hard cap in bytes (drops oldest beyond this → bounded latency) +} + +// newPCMRing makes a ring whose backlog is capped at maxBytes. Size it from the +// acceptable latency: bytesPerSec (=32000) worth ≈ 1 s. +func newPCMRing(maxBytes int) *pcmRing { + if maxBytes <= 0 { + maxBytes = bytesPerSec // 1 s default + } + return &pcmRing{max: maxBytes} +} + +// Push appends samples, dropping the oldest audio if the backlog would exceed +// the cap (a slow/absent consumer never makes the producer block or grow without +// bound). A short glitch beats runaway latency for live monitoring. +func (r *pcmRing) Push(p []byte) { + if len(p) == 0 { + return + } + r.mu.Lock() + r.buf = append(r.buf, p...) + if len(r.buf) > r.max { + drop := len(r.buf) - r.max + r.buf = append(r.buf[:0], r.buf[drop:]...) + } + r.mu.Unlock() +} + +// pull removes and returns up to maxBytes of queued PCM (a private copy), or nil +// when empty. The render loop pads any shortfall with silence. +func (r *pcmRing) pull(maxBytes int) []byte { + r.mu.Lock() + defer r.mu.Unlock() + if len(r.buf) == 0 || maxBytes <= 0 { + return nil + } + n := maxBytes + if n > len(r.buf) { + n = len(r.buf) + } + out := make([]byte, n) + copy(out, r.buf[:n]) + r.buf = append(r.buf[:0], r.buf[n:]...) + return out +} + +// renderStream continuously renders PCM pulled from src to a device until stop +// closes — the streaming counterpart to playPCM's fixed buffer. On underrun it +// writes silence rather than glitching, keeping the WASAPI clock steady so live +// monitor audio flows smoothly even when the source stalls briefly. Runs on a +// COM-initialised, OS-locked thread. +func renderStream(deviceID string, rate, ch, bits int, stop <-chan struct{}, src *pcmRing) error { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if err := coInit(); err != nil { + return fmt.Errorf("CoInitialize: %w", err) + } + defer ole.CoUninitialize() + + dev, err := openDevice(wca.ERender, deviceID) + if err != nil { + return err + } + defer dev.Release() + + var ac *wca.IAudioClient + if err := dev.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &ac); err != nil { + return fmt.Errorf("activate render: %w", err) + } + defer ac.Release() + + frameBytes := ch * bits / 8 + if frameBytes <= 0 { + return fmt.Errorf("bad audio format") + } + wfx := &wca.WAVEFORMATEX{ + WFormatTag: 1, NChannels: uint16(ch), NSamplesPerSec: uint32(rate), + NAvgBytesPerSec: uint32(rate * frameBytes), NBlockAlign: uint16(frameBytes), + WBitsPerSample: uint16(bits), CbSize: 0, + } + if err := ac.Initialize(wca.AUDCLNT_SHAREMODE_SHARED, autoConvert, + wca.REFERENCE_TIME(bufferDuration100ns), 0, wfx, nil); err != nil { + return fmt.Errorf("initialize render: %w", err) + } + var bufFrames uint32 + if err := ac.GetBufferSize(&bufFrames); err != nil { + return err + } + var arc *wca.IAudioRenderClient + if err := ac.GetService(wca.IID_IAudioRenderClient, &arc); err != nil { + return fmt.Errorf("get render service: %w", err) + } + defer arc.Release() + + // feed fills up to `frames` render frames: as much real audio as the ring + // has, the remainder silence (so the buffer stays full and the clock steady). + feed := func(frames int) error { + if frames <= 0 { + return nil + } + var data *byte + if err := arc.GetBuffer(uint32(frames), &data); err != nil { + return err + } + dst := unsafe.Slice(data, frames*frameBytes) + got := src.pull(frames * frameBytes) + n := copy(dst, got) + for i := n; i < len(dst); i++ { + dst[i] = 0 // silence-fill the shortfall + } + arc.ReleaseBuffer(uint32(frames), 0) + return nil + } + + if err := feed(int(bufFrames)); err != nil { // pre-fill to avoid a start glitch + return err + } + if err := ac.Start(); err != nil { + return fmt.Errorf("start render: %w", err) + } + defer ac.Stop() + + for { + select { + case <-stop: + return nil + default: + } + var padding uint32 + ac.GetCurrentPadding(&padding) + if err := feed(int(bufFrames - padding)); err != nil { + return err + } + time.Sleep(8 * time.Millisecond) + } +} diff --git a/internal/audio/manager.go b/internal/audio/manager.go index f1a0790..52834af 100644 --- a/internal/audio/manager.go +++ b/internal/audio/manager.go @@ -15,7 +15,10 @@ type Manager struct { recStop chan struct{} recDone chan recResult playStop chan struct{} - onChange func() // fired on any record/playback state transition + monStop chan struct{} // RX monitor passthrough (capture → render) + monRing *pcmRing // live audio hand-off, also fed by the network stream + txStop chan struct{} // TX audio passthrough (mic → rig) + onChange func() // fired on any record/playback state transition } type recResult struct { @@ -135,3 +138,130 @@ func (m *Manager) StopPlayback() { m.notify() } } + +// ---- RX audio monitor (Phase 2: USB codec passthrough) -------------------- +// +// StartMonitor pipes live RX audio from inputDev (e.g. the rig's "USB Audio +// CODEC" capture endpoint) to outputDev (your speakers/headset) through a +// latency-bounded ring, so you HEAR the radio inside OpsLog. The very same ring +// is later fed by the network 50003 stream instead of a USB capture — the render +// half is transport-agnostic. inputDev "" = system default capture. +func (m *Manager) StartMonitor(inputDev, outputDev string) error { + return m.startMonitor(inputDev, outputDev, true) +} + +// StartMonitorSink starts ONLY the render side (no USB capture) so an external +// producer — the network 50003 stream — can feed decoded RX PCM via +// PushMonitorAudio. Same output path as StartMonitor, minus the capture goroutine. +func (m *Manager) StartMonitorSink(outputDev string) error { + return m.startMonitor("", outputDev, false) +} + +// startMonitor wires the RX monitor: always a render loop pulling from monRing; +// when capture is true it also captures inputDev into that ring (USB monitor). +// When false the ring is fed only by PushMonitorAudio (network audio). +func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error { + m.mu.Lock() + if m.monStop != nil { + m.mu.Unlock() + return fmt.Errorf("monitor already running") + } + stop := make(chan struct{}) + ring := newPCMRing(bytesPerSec / 2) // ~500 ms cap — low latency for live monitor + m.monStop, m.monRing = stop, ring + m.mu.Unlock() + + if capture { + // Producer: capture the rig's USB audio into the ring. + go func() { + _ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(chunk) }) + }() + } + // Consumer: render the ring to the output device at the internal 16 kHz mono. + go func() { + _ = renderStream(outputDev, sampleRate, channels, bitsPerSample, stop, ring) + }() + m.notify() + return nil +} + +// StopMonitor stops the RX monitor passthrough. +func (m *Manager) StopMonitor() { + m.mu.Lock() + stop := m.monStop + m.monStop, m.monRing = nil, nil + m.mu.Unlock() + if stop != nil { + close(stop) + m.notify() + } +} + +// MonitorActive reports whether the RX monitor passthrough is running. +func (m *Manager) MonitorActive() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.monStop != nil +} + +// PushMonitorAudio feeds externally-sourced PCM (16 kHz mono 16-bit) into the +// active monitor's output — the hook the network 50003 audio stream uses to play +// decoded RX through the very same output path a USB capture feeds. No-op when no +// monitor is running. Keeps the unexported ring inside the package. +func (m *Manager) PushMonitorAudio(pcm []byte) { + m.mu.Lock() + ring := m.monRing + m.mu.Unlock() + if ring != nil { + ring.Push(pcm) + } +} + +// ---- TX audio passthrough (Phase 3: live mic → rig over USB) -------------- +// +// StartTXAudio pipes your live microphone (micDev) into the rig's audio input +// (toRadioDev — for a USB-connected rig, its "USB Audio CODEC" render endpoint), +// so you talk through the PC. It is the mirror of StartMonitor (same ring + +// capture + render primitives, source/sink swapped). PTT keying is the caller's +// job (the app layer keys PTT before this and unkeys after) so this stays a pure +// audio route. The captured 16 kHz mono stream is also the exact shape the future +// network 50003 TX will encode and send — so Phase 5 reuses this capture side. +func (m *Manager) StartTXAudio(micDev, toRadioDev string) error { + m.mu.Lock() + if m.txStop != nil { + m.mu.Unlock() + return fmt.Errorf("TX audio already running") + } + stop := make(chan struct{}) + ring := newPCMRing(bytesPerSec / 4) // ~250 ms — tighter for live TX latency + m.txStop = stop + m.mu.Unlock() + + go func() { + _ = captureStream(micDev, stop, func(chunk []byte) { ring.Push(chunk) }) + }() + go func() { + _ = renderStream(toRadioDev, sampleRate, channels, bitsPerSample, stop, ring) + }() + m.notify() + return nil +} + +// StopTXAudio stops the TX mic→rig passthrough. +func (m *Manager) StopTXAudio() { + m.mu.Lock() + stop := m.txStop + m.txStop = nil + m.mu.Unlock() + if stop != nil { + close(stop) + m.notify() + } +} + +// TXAudioActive reports whether the TX mic→rig passthrough is running. +func (m *Manager) TXAudioActive() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.txStop != nil +} diff --git a/internal/cat/civ/civ.go b/internal/cat/civ/civ.go index 9c23b94..3590b14 100644 --- a/internal/cat/civ/civ.go +++ b/internal/cat/civ/civ.go @@ -313,6 +313,8 @@ func ModelName(addr byte) string { return "IC-7300" case 0x98: return "IC-7610" + case 0x7C: + return "IC-9100" case 0xA2: return "IC-9700" case 0xA4: diff --git a/internal/cat/flex.go b/internal/cat/flex.go index 26a7c10..de47c08 100644 --- a/internal/cat/flex.go +++ b/internal/cat/flex.go @@ -803,24 +803,44 @@ func (f *Flex) ReadState() (RigState, error) { // band) never hijacks the main frequency. Returns (-1, nil) when no slice is in // use. Caller holds f.mu. func (f *Flex) mainSliceLocked() (int, *flexSlice) { - best, bestS := 1<<30, (*flexSlice)(nil) - for idx, s := range f.slices { + // Iterate in ASCENDING index order — NEVER map-iteration order, which Go + // randomises. When two slices transiently BOTH report active=1 (e.g. an + // external controller like DXHunter activates a slice on another band while + // ours still holds active, before SmartSDR sends active=0 to the old one), + // map order returned a RANDOM active slice each call → the operating frequency + // flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever. + // Deterministic order = the lowest-indexed active slice wins, stably. + firstInUse := -1 + for _, idx := range f.sortedSliceIdxLocked() { + s := f.slices[idx] if !s.inUse { continue } + if firstInUse < 0 { + firstInUse = idx + } if s.active { return idx, s } - if idx < best { - best, bestS = idx, s - } } - if bestS != nil { - return best, bestS + if firstInUse >= 0 { + return firstInUse, f.slices[firstInUse] } return -1, nil } +// sortedSliceIdxLocked returns the slice indices in ascending order so every +// slice-selection helper is deterministic (map iteration is randomised). Caller +// holds f.mu. +func (f *Flex) sortedSliceIdxLocked() []int { + idxs := make([]int, 0, len(f.slices)) + for idx := range f.slices { + idxs = append(idxs, idx) + } + sort.Ints(idxs) + return idxs +} + // activeSliceIndexLocked returns the slice index to send commands to (the main // slice, else 0). Caller holds f.mu. func (f *Flex) activeSliceIndexLocked() int { @@ -841,8 +861,8 @@ func sliceLetter(idx int) string { // txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil. // Caller holds f.mu. func (f *Flex) txSliceLocked() *flexSlice { - for _, s := range f.slices { - if s.inUse && s.tx { + for _, idx := range f.sortedSliceIdxLocked() { + if s := f.slices[idx]; s.inUse && s.tx { return s } } @@ -871,7 +891,8 @@ func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) { if main != nil && main != txS && main.freqHz != txS.freqHz && BandFromHz(main.freqHz) == bt { rx = main } else { - for _, s := range f.slices { + for _, idx := range f.sortedSliceIdxLocked() { + s := f.slices[idx] if s.inUse && s != txS && s.freqHz != txS.freqHz && BandFromHz(s.freqHz) == bt { rx = s break @@ -927,6 +948,15 @@ func (f *Flex) SetFrequency(hz int64) error { f.mu.Lock() idx := f.activeSliceIndexLocked() connected := f.conn != nil + // Optimistically update the active slice's cached freq NOW, before the radio + // echoes the slice status back. Otherwise ReadState/FlexState keep reporting + // the OLD freq for the round-trip: the top display (optimistic liveFreqHz) + // jumped to the new band while the slice cache — which the FlexPanel and the + // Ultrabeam follow loop read — still showed the old one, so the antenna chased + // the stale value. The real echo confirms/corrects this a moment later. + if s := f.slices[idx]; s != nil { + s.freqHz = hz + } f.mu.Unlock() if !connected { return fmt.Errorf("flex: not connected") @@ -952,6 +982,13 @@ func (f *Flex) SetMode(mode string) error { if fm == "" { return fmt.Errorf("flex: unsupported mode %q", mode) } + // Optimistically cache the new mode too (same reasoning as SetFrequency) so the + // panel reflects it immediately instead of lagging the radio's echo. + f.mu.Lock() + if s := f.slices[idx]; s != nil { + s.mode = fm + } + f.mu.Unlock() // "slice s mode=" — set command per the SmartSDR API. f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm)) return nil diff --git a/internal/cat/icomaudio.go b/internal/cat/icomaudio.go new file mode 100644 index 0000000..678166a --- /dev/null +++ b/internal/cat/icomaudio.go @@ -0,0 +1,219 @@ +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 +// (wfview audio_packet: 16-byte common header + ident@0x10 + datalen@0x12 + +// sendseq@0x14 → audio@0x16). Isolated as a const so a capture-confirmed change +// is a one-line edit. +const icaAudioOffset = 0x16 + +// 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 + 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)]) + } + if 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. +func (a *icomAudio) trackRxSeq(seq uint16) { + if !a.rxHaveSeq { + a.rxHaveSeq = true + a.rxLastSeq = seq + return + } + switch d := int16(seq - a.rxLastSeq); { + case d == 0: + case d < 0: + delete(a.rxMissing, seq) + case d == 1: + a.rxLastSeq = seq + case int(d) <= icnMaxMissing: + for f := a.rxLastSeq + 1; f != seq; f++ { + a.rxMissing[f] = 0 + } + a.rxLastSeq = seq + default: + a.rxMissing = make(map[uint16]int) + a.rxLastSeq = seq + } +} + +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) + } +} diff --git a/internal/cat/icomnet.go b/internal/cat/icomnet.go index 0ea72b0..d5fd385 100644 --- a/internal/cat/icomnet.go +++ b/internal/cat/icomnet.go @@ -36,7 +36,13 @@ var icnBE = binary.BigEndian // NewIcomNet builds an (unconnected) Icom backend whose transport is the network // stream. host is the rig's IP/hostname; user/pass are the rig's Network User1 // credentials. Reuses the whole IcomSerial controller — only `open` differs. -func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *IcomSerial { +// +// audioSink (optional) enables the network RX audio stream (UDP 50003): when +// non-nil the conninfo asks the rig to stream audio and each received payload is +// passed to audioSink (the app decodes it via an audio.Codec and plays it). nil +// = CI-V only (the proven default). The audio stream is fully separate from CAT, +// so enabling it can't affect freq/mode/DSP control. +func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial { if civAddr <= 0 || civAddr > 0xFF { civAddr = 0x98 // IC-7610 } @@ -57,7 +63,7 @@ func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *Ic b.dialMu.Lock() cancel := b.dialCancel b.dialMu.Unlock() - return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel) + return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel, audioSink) } return b } @@ -130,6 +136,10 @@ type icomNet struct { // but link fine" (stay connected) from "link dead" (reconnect). See Alive(). lastRx atomic.Int64 + // audio is the optional RX audio stream (UDP 50003). nil when audio is off. + // Torn down alongside the CI-V/control streams in Close. + audio *icomAudio + done chan struct{} closeOnce sync.Once } @@ -232,6 +242,9 @@ var icnTrace = false func (n *icomNet) Close() error { n.closeOnce.Do(func() { close(n.done) + if n.audio != nil { + n.audio.Close() + } // Tell the rig we're leaving so it frees its SINGLE control session at // once. If it never gets a disconnect it holds the session for minutes and // refuses every new login — which is why a lost link (or a hard app exit) @@ -475,8 +488,9 @@ func (n *icomNet) resend(seq uint16) { // ------------------------- connect ------------------------- -func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}) (*icomNet, error) { - debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X)", host, user, compName, rigAddr) +func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}, audioSink func([]byte)) (*icomNet, error) { + wantAudio := audioSink != nil + debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X, audio=%v)", host, user, compName, rigAddr, wantAudio) // ---- control stream (50001): handshake → login → token → conninfo ---- craddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50001")) if err != nil { @@ -562,7 +576,11 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan rigMAC = make([]byte, 6) } - _, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003)) + var rxEnable byte + if wantAudio { + rxEnable = 0x01 // ask the rig to stream RX audio on 50003 + } + _, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003, rxEnable)) cTracked++ cInner++ drainEnd := time.Now().Add(500 * time.Millisecond) @@ -634,6 +652,18 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan go n.ctrlPump() go n.civPump() + + // Optional RX audio stream (50003). The rig was told (conninfo rxEnable=1) to + // stream audio; open the socket + handshake now. A failure here is NON-fatal: + // CAT works without audio, so we log and continue rather than tear down a + // perfectly good control/CI-V session. + if wantAudio { + if a, err := dialIcomAudio(host, audioSink, cancel); err != nil { + debugLog.Printf("icom net: audio stream FAILED (CAT unaffected): %v", err) + } else { + n.audio = a + } + } return n, nil } @@ -772,7 +802,7 @@ func icnTokenRenew(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) [ return b } -func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16) []byte { +func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16, rxEnable byte) []byte { b := make([]byte, 0x90) icnLE.PutUint32(b[0:], 0x90) icnLE.PutUint16(b[6:], seq) @@ -788,8 +818,8 @@ func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, use copy(b[0x2a:0x30], rigMAC) copy(b[0x40:0x60], []byte("IC-7610")) copy(b[0x60:0x70], icnPasscode(user)) - b[0x70] = 0x00 // rxenable (audio off — CI-V only) - b[0x71] = 0x00 // txenable + b[0x70] = rxEnable // rxenable: 1 opens the 50003 RX audio stream, 0 = CI-V only + b[0x71] = 0x00 // txenable (Phase 5) b[0x72] = 0x10 // rxcodec b[0x73] = 0x04 // txcodec icnBE.PutUint32(b[0x74:], 16000)