From 405b0e24cf40777af453cbb3b5ff8cbf603fe469 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sun, 23 Aug 2026 10:43:11 +0200 Subject: [PATCH] feat: network audio into the QSO recorder, and a Yaesu that stays on its VFO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Icom reached over the LAN streams its receive audio through the Icom protocol, and Windows sees no sound card for it at all — the audio settings could only offer the PC's own microphone, so "From radio" had nothing right to point at and the QSO recorder had nothing to record. The recorder now accepts a PUSHED source: the decoded stream goes to the speakers and to the recorder alike, with no virtual cable to set up. Which source it uses follows the CAT backend, and it is restarted only when that answer changes, so an ordinary settings save never cuts a recording in half. OmniRig no longer sends SetSimplexMode to a Yaesu when tuning. It is a silent no-op on some — an FT-891 logged OK on every spot click while FreqA never moved — and actively harmful on others. From an FT-2000 log, one QSY: Vfo="AB"(0x80) Split=0x10000 (off) the operator's state Vfo="BA"(0x100) Split=0x8000 (ON) after SetSimplexMode The rig-agnostic "receive and transmit HERE, simplex" call turned split on and moved reception to VFO B. OpsLog then displayed B — reading the radio correctly, after having moved it itself. Icom is untouched: there the call is the authoritative one and the direct write is unreliable. Also: - the NEW county badge shows in the entry form itself, inside the field, where the operator is deciding whether to call. - the basemap buttons clear the zoom controls; Light sat a few pixels from the minus button and was being clicked by mistake. - French cluster status: DÉJÀ CTC reads DÉJÀ QSO. --- app.go | 63 +++++++++++++++++++---- app_gate.go | 1 + changelog.json | 18 +++++++ frontend/src/App.tsx | 16 +++++- frontend/src/components/MainMap.tsx | 5 +- frontend/src/lib/i18n.tsx | 2 +- internal/audio/recorder.go | 80 ++++++++++++++++++++++------- internal/cat/omnirig.go | 20 ++++++++ internal/cat/omnirig_vfo_test.go | 21 ++++++++ 9 files changed, 194 insertions(+), 32 deletions(-) diff --git a/app.go b/app.go index 743daad..16ec7d5 100644 --- a/app.go +++ b/app.go @@ -709,13 +709,18 @@ type App struct { // Halt Tx is routed by id, and the panel's Halt button must work even when // nothing is transmitting at that instant — so the id is remembered from // every Status rather than read off a live transmission. - lastTxID atomic.Value // string - extsvc *extsvc.Manager - winkeyer *winkeyer.Manager - clublog *clublog.Manager - clublogMW *clublog.MostWanted // ClubLog "Most Wanted" DXCC ranking (opt-in) - motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled - ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off + lastTxID atomic.Value // string + extsvc *extsvc.Manager + winkeyer *winkeyer.Manager + clublog *clublog.Manager + clublogMW *clublog.MostWanted // ClubLog "Most Wanted" DXCC ranking (opt-in) + motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled + ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off + // qsoRecPushed records which SOURCE the recorder was started on: the network + // stream, or a sound device. Switching CAT backends changes the answer, and a + // recorder left capturing a device that no longer carries the radio records + // silence without saying so. + qsoRecPushed bool motorStartMu sync.Mutex // serialises startUltrabeam: two restarts at once left two poll loops on one COM port motorInhibStop chan struct{} // stops the "inhibit TX while moving" loop; nil when off motorMoveCmdNs atomic.Int64 // unixnano of the last commanded antenna move (grace window) @@ -8321,7 +8326,19 @@ func (a *App) startQSORecorderIfEnabled() { if !cfg.QSORecord { return } - if err := a.qsoRec.Start(cfg.FromRadio, cfg.RecordingDevice, cfg.PrerollSeconds); err != nil { + // Over the network there IS no radio sound device. + // + // An IC-705 or IC-7610 reached by LAN streams its receive audio through the + // Icom protocol, and Windows sees nothing: the operator's audio settings can + // only offer the PC's own microphone and speakers, so "From radio" has + // nothing right to point at. The stream is pushed into the recorder instead + // — same samples, no sound card in the middle, and no virtual cable to set up. + from := cfg.FromRadio + a.qsoRecPushed = a.icomNetAudioActive() + if a.qsoRecPushed { + from = audio.PushedSource + } + if err := a.qsoRec.Start(from, cfg.RecordingDevice, cfg.PrerollSeconds); err != nil { applog.Printf("qso-rec: start failed: %v", err) return } @@ -8336,6 +8353,17 @@ func (a *App) startQSORecorderIfEnabled() { applog.Printf("qso-rec: running (preroll %ds, mix=%v, gains rx=%.2f mic=%.2f)", cfg.PrerollSeconds, cfg.RecordingDevice != "" && cfg.RecordingDevice != cfg.FromRadio, fromGain, micGain) } +// icomNetAudioActive reports whether the receive audio is arriving over the +// network rather than from a sound card — the Icom LAN backend with its audio +// option on. +func (a *App) icomNetAudioActive() bool { + s, err := a.GetCATSettings() + if err != nil { + return false + } + return s.Enabled && s.Backend == "icom-net" && s.IcomNetAudio +} + // qsoRecDir returns the configured recordings folder, defaulting to // /Recordings, and ensures it exists. func (a *App) qsoRecDir() string { @@ -14892,6 +14920,13 @@ func (a *App) reloadCAT() { a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots a.catFlexDecodeSecs = s.FlexDecodeSecs a.catFlexDVKDax = s.Enabled && s.Backend == "flex" && s.FlexDVKDax + // The recorder's source depends on the backend: over the Icom LAN there is no + // sound card to capture, the audio is pushed in. Restarted only when the + // answer CHANGES, so an ordinary settings save never interrupts a recording. + if want := s.Enabled && s.Backend == "icom-net" && s.IcomNetAudio; want != a.qsoRecPushed { + applog.Printf("qso-rec: audio source changes (network=%v) — restarting the recorder", want) + go a.startQSORecorderIfEnabled() + } a.reloadCATShare(s) // Nothing about the link changed → leave it connected. See catLinkSig. if sig := catLinkSig(s); sig == a.catSig { @@ -15000,8 +15035,16 @@ func (a *App) reloadCAT() { } else { codec := audio.NewPCM16Codec() audioSink = func(payload []byte) { - if pcm, err := codec.Decode(payload); err == nil { - a.audioMgr.PushMonitorAudio(pcm) + pcm, err := codec.Decode(payload) + if err != nil { + return + } + a.audioMgr.PushMonitorAudio(pcm) + // And to the QSO recorder, which has no device to capture from + // on this backend. It drops the samples unless a recording is + // actually running, so this costs a function call when idle. + if a.qsoRec != nil { + a.qsoRec.PushRX(pcm) } } applog.Printf("icom-net audio: RX audio streaming ENABLED (experimental) → %q", acfg.ListeningDevice) diff --git a/app_gate.go b/app_gate.go index 1f58253..dfa2cdf 100644 --- a/app_gate.go +++ b/app_gate.go @@ -19,6 +19,7 @@ var deniedCallHashes = map[string]struct{}{ "d1ae6212ec057f9d5c6f379fb57acedac7c94142c9d49667dc04b2016d03d1b6": {}, "0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {}, "ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {}, + "9d17c9c213a6cc89c12d7520bcf21c86a0cf43d17e82e74f33f3a77cb865d28a": {}, } // callDenied reports whether a callsign is on deniedCallHashes. The call is diff --git a/changelog.json b/changelog.json index db1bb9a..224bf1d 100644 --- a/changelog.json +++ b/changelog.json @@ -1,4 +1,22 @@ [ + { + "version": "0.26.7", + "date": "", + "en": [ + "The NEW badge on a county now shows in the entry form itself, inside the field, and not only on the Info tab — it answers the question at the moment the operator is deciding whether to call.", + "OmniRig: a Yaesu is no longer sent OmniRig's SetSimplexMode when tuning. It is a no-op on some (an FT-891 never moved) and harmful on others — on an FT-2000 it turned split on and moved reception to VFO B, which is why OpsLog then showed B instead of A. The direct frequency write, which is what tunes these rigs, is unchanged.", + "The basemap buttons on the main map are shifted clear of the zoom controls — Light sat a few pixels from the minus button and was being clicked by mistake.", + "Cluster, French interface: the WKD CALL status now reads DÉJÀ QSO instead of DÉJÀ CTC.", + "Icom over LAN: the receive audio streamed by the rig now feeds the QSO recorder as well as the speakers. On a network connection Windows sees no radio sound card at all — the audio settings could only offer the PC microphone — so the stream is pushed straight into the recorder, with no virtual cable to set up." + ], + "fr": [ + "Le badge NOUV du comté s'affiche maintenant dans le formulaire de saisie lui-même, à l'intérieur du champ, et plus seulement dans l'onglet Info — il répond au moment où l'opérateur décide d'appeler ou non.", + "OmniRig : le SetSimplexMode d'OmniRig n'est plus envoyé aux Yaesu lors d'un changement de fréquence. Il est sans effet sur certains (un FT-891 ne bougeait pas) et nuisible sur d'autres — sur un FT-2000 il activait le split et faisait passer la réception sur le VFO B, d'où l'affichage du B au lieu du A. L'écriture directe de la fréquence, qui est ce qui accorde réellement ces postes, ne change pas.", + "Les boutons de fond de carte sont décalés à l'écart des commandes de zoom — Light était à quelques pixels du bouton moins et se faisait cliquer par erreur.", + "Cluster, interface française : le statut « DÉJÀ CTC » devient « DÉJÀ QSO ».", + "Icom en réseau : l'audio reçu diffusé par le poste alimente maintenant l'enregistreur de QSO en plus des haut-parleurs. Sur une liaison réseau, Windows ne voit aucune carte son de la radio — les réglages audio ne proposaient que le micro du PC — donc le flux est injecté directement dans l'enregistreur, sans câble virtuel à installer." + ] + }, { "version": "0.26.6", "date": "", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6311f60..bcd71ec 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5214,9 +5214,21 @@ export default function App() { }; const geoDetailRow = (
-
- + setDetails((d) => ({ ...d, cnty: e.target.value }))} /> + {entryNewCounty && ( + + {t('detp.newCounty')} + + )}
{/* Basemap picker — Light / Street / Satellite (key-free tiles). */} -
+ {/* Clear of the zoom buttons, not merely next to them: at left-12 the + first basemap sat a few pixels from the − button and operators kept + hitting Light when they meant to zoom out. */} +
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (