feat: network audio into the QSO recorder, and a Yaesu that stays on its VFO

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.
This commit is contained in:
2026-08-23 10:43:11 +02:00
parent 778db0542e
commit 405b0e24cf
9 changed files with 194 additions and 32 deletions
+45 -2
View File
@@ -716,6 +716,11 @@ type App struct {
clublogMW *clublog.MostWanted // ClubLog "Most Wanted" DXCC ranking (opt-in) clublogMW *clublog.MostWanted // ClubLog "Most Wanted" DXCC ranking (opt-in)
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off 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 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 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) motorMoveCmdNs atomic.Int64 // unixnano of the last commanded antenna move (grace window)
@@ -8321,7 +8326,19 @@ func (a *App) startQSORecorderIfEnabled() {
if !cfg.QSORecord { if !cfg.QSORecord {
return 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) applog.Printf("qso-rec: start failed: %v", err)
return 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) 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 // qsoRecDir returns the configured recordings folder, defaulting to
// <dataDir>/Recordings, and ensures it exists. // <dataDir>/Recordings, and ensures it exists.
func (a *App) qsoRecDir() string { func (a *App) qsoRecDir() string {
@@ -14892,6 +14920,13 @@ func (a *App) reloadCAT() {
a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots
a.catFlexDecodeSecs = s.FlexDecodeSecs a.catFlexDecodeSecs = s.FlexDecodeSecs
a.catFlexDVKDax = s.Enabled && s.Backend == "flex" && s.FlexDVKDax 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) a.reloadCATShare(s)
// Nothing about the link changed → leave it connected. See catLinkSig. // Nothing about the link changed → leave it connected. See catLinkSig.
if sig := catLinkSig(s); sig == a.catSig { if sig := catLinkSig(s); sig == a.catSig {
@@ -15000,8 +15035,16 @@ func (a *App) reloadCAT() {
} else { } else {
codec := audio.NewPCM16Codec() codec := audio.NewPCM16Codec()
audioSink = func(payload []byte) { audioSink = func(payload []byte) {
if pcm, err := codec.Decode(payload); err == nil { pcm, err := codec.Decode(payload)
if err != nil {
return
}
a.audioMgr.PushMonitorAudio(pcm) 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) applog.Printf("icom-net audio: RX audio streaming ENABLED (experimental) → %q", acfg.ListeningDevice)
+1
View File
@@ -19,6 +19,7 @@ var deniedCallHashes = map[string]struct{}{
"d1ae6212ec057f9d5c6f379fb57acedac7c94142c9d49667dc04b2016d03d1b6": {}, "d1ae6212ec057f9d5c6f379fb57acedac7c94142c9d49667dc04b2016d03d1b6": {},
"0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {}, "0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {},
"ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {}, "ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {},
"9d17c9c213a6cc89c12d7520bcf21c86a0cf43d17e82e74f33f3a77cb865d28a": {},
} }
// callDenied reports whether a callsign is on deniedCallHashes. The call is // callDenied reports whether a callsign is on deniedCallHashes. The call is
+18
View File
@@ -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", "version": "0.26.6",
"date": "", "date": "",
+14 -2
View File
@@ -5214,9 +5214,21 @@ export default function App() {
}; };
const geoDetailRow = ( const geoDetailRow = (
<div className="flex gap-4 items-end"> <div className="flex gap-4 items-end">
<div className="flex flex-col flex-1 min-w-0"><Label className="mb-1 h-3.5">{t('field.cnty')}</Label> {/* The badge sits INSIDE the field: this row is already tight, and a
<Input value={details.cnty ?? ''} county name is short enough to leave room at its right. It answers the
question at the moment the operator is deciding whether to call the
same answer the Info tab gives, one tab away from where they are. */}
<div className="flex flex-col flex-1 min-w-0 relative"><Label className="mb-1 h-3.5">{t('field.cnty')}</Label>
<Input value={details.cnty ?? ''} className={cn(entryNewCounty && 'pr-12')}
onChange={(e) => setDetails((d) => ({ ...d, cnty: e.target.value }))} /> onChange={(e) => setDetails((d) => ({ ...d, cnty: e.target.value }))} />
{entryNewCounty && (
<span
title={t('detp.newCountyTip')}
className="absolute right-1.5 bottom-1.5 rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wide bg-success text-success-foreground pointer-events-none"
>
{t('detp.newCounty')}
</span>
)}
</div> </div>
<div className="flex flex-col w-[64px] shrink-0"><Label className="mb-1 h-3.5">{t('field.cqz')}</Label> <div className="flex flex-col w-[64px] shrink-0"><Label className="mb-1 h-3.5">{t('field.cqz')}</Label>
<Input value={details.cqz ?? ''} className="font-mono" <Input value={details.cqz ?? ''} className="font-mono"
+4 -1
View File
@@ -366,7 +366,10 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border"> <div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
<div ref={worldRef} className="absolute inset-0" /> <div ref={worldRef} className="absolute inset-0" />
{/* Basemap picker — Light / Street / Satellite (key-free tiles). */} {/* Basemap picker — Light / Street / Satellite (key-free tiles). */}
<div className="absolute top-1 left-12 z-[500] flex rounded-md overflow-hidden shadow border border-border backdrop-blur"> {/* 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. */}
<div className="absolute top-1 left-[4.5rem] z-[500] flex rounded-md overflow-hidden shadow border border-border backdrop-blur">
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => ( {(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
<button <button
key={k} key={k}
+1 -1
View File
@@ -957,7 +957,7 @@ const fr: Dict = {
'wbg.awardTip': '{name} — référence comptée pour ce QSO', 'wbg.typeCall': 'Saisissez un indicatif dans la barre pour voir les contacts précédents.', 'wbg.checking': 'vérification…', 'wbg.new': 'NOUVEAU', 'wbg.noPriorPre': 'Aucun QSO précédent avec ', 'wbg.noPriorPost': '.', 'wbg.workedBefore': 'Déjà contacté', 'wbg.first': 'Premier :', 'wbg.last': 'Dernier :', 'wbg.dxcc': 'DXCC :', 'wbg.entityQsos': '{n} QSO avec cette entité', 'wbg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'wbg.clearFilters': 'Effacer les filtres', 'wbg.columns': 'Colonnes', 'wbg.olderQsos': '+ {n} QSO plus anciens (non affichés — limités pour la performance)', 'wbg.pickerTitle': 'Colonnes « Déjà contacté »', 'wbg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau « Déjà contacté ».', 'wbg.allGroups': 'Tous les groupes :', 'wbg.all': 'tout', 'wbg.none': 'aucun', 'wbg.grpAwards': 'Diplômes', 'wbg.resetDefaults': 'Réinitialiser', 'wbg.done': 'Terminé', 'wbg.awardTip': '{name} — référence comptée pour ce QSO', 'wbg.typeCall': 'Saisissez un indicatif dans la barre pour voir les contacts précédents.', 'wbg.checking': 'vérification…', 'wbg.new': 'NOUVEAU', 'wbg.noPriorPre': 'Aucun QSO précédent avec ', 'wbg.noPriorPost': '.', 'wbg.workedBefore': 'Déjà contacté', 'wbg.first': 'Premier :', 'wbg.last': 'Dernier :', 'wbg.dxcc': 'DXCC :', 'wbg.entityQsos': '{n} QSO avec cette entité', 'wbg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'wbg.clearFilters': 'Effacer les filtres', 'wbg.columns': 'Colonnes', 'wbg.olderQsos': '+ {n} QSO plus anciens (non affichés — limités pour la performance)', 'wbg.pickerTitle': 'Colonnes « Déjà contacté »', 'wbg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau « Déjà contacté ».', 'wbg.allGroups': 'Tous les groupes :', 'wbg.all': 'tout', 'wbg.none': 'aucun', 'wbg.grpAwards': 'Diplômes', 'wbg.resetDefaults': 'Réinitialiser', 'wbg.done': 'Terminé',
'chn.title': 'Chasse au nouveau', 'chn.close': 'Masquer le panneau', 'chn.filterHint': 'Afficher ou masquer ce type', 'chn.allFiltered': 'Tout ce qui est entendu est filtré — réactivez une catégorie ci-dessus.', 'chn.toggle': 'Chasse au nouveau', 'chn.count': '{n} entendus', 'chn.loading': 'En attente du flux…', 'chn.empty': 'Rien de nouveau décodé près de vous pour le moment.', 'chn.digitalOnly': 'PSK Reporter — modes numériques uniquement, entendus à moins de ~300 km.', 'chn.option': 'Chasse au nouveau (PSK Reporter)', 'chn.optionHelp': 'Liste les stations décodées près de chez vous qui sont nouvelles par rapport à votre log — entité, bande, mode, créneau, préfixe ou carré. Modes numériques uniquement.', 'chn.show': 'Panneau chasse au nouveau', 'chn.title': 'Chasse au nouveau', 'chn.close': 'Masquer le panneau', 'chn.filterHint': 'Afficher ou masquer ce type', 'chn.allFiltered': 'Tout ce qui est entendu est filtré — réactivez une catégorie ci-dessus.', 'chn.toggle': 'Chasse au nouveau', 'chn.count': '{n} entendus', 'chn.loading': 'En attente du flux…', 'chn.empty': 'Rien de nouveau décodé près de vous pour le moment.', 'chn.digitalOnly': 'PSK Reporter — modes numériques uniquement, entendus à moins de ~300 km.', 'chn.option': 'Chasse au nouveau (PSK Reporter)', 'chn.optionHelp': 'Liste les stations décodées près de chez vous qui sont nouvelles par rapport à votre log — entité, bande, mode, créneau, préfixe ou carré. Modes numériques uniquement.', 'chn.show': 'Panneau chasse au nouveau',
'clg2.allFiltered': '{n} spots reçus, aucun affiché — vos filtres les masquent tous.', 'clg2.activeFilters': 'Actifs :', 'clg2.clearAllFilters': 'Effacer tous les filtres', 'clg2.fBandLock': 'bande verrouillée sur la radio', 'clg2.fBands': 'bandes {list}', 'clg2.fModeLock': 'mode verrouillé sur la radio', 'clg2.fModes': 'modes {list}', 'clg2.fStatus': 'pastilles de statut', 'clg2.fHideWorked': 'masquer les contactés', 'clg2.fLotwOnly': 'utilisateurs LoTW uniquement', 'clg2.fSpotterCont': 'continent du spotteur', 'clg2.fSource': 'un seul nœud source', 'clg2.fSearch': 'recherche « {q} »', 'clg2.allFiltered': '{n} spots reçus, aucun affiché — vos filtres les masquent tous.', 'clg2.activeFilters': 'Actifs :', 'clg2.clearAllFilters': 'Effacer tous les filtres', 'clg2.fBandLock': 'bande verrouillée sur la radio', 'clg2.fBands': 'bandes {list}', 'clg2.fModeLock': 'mode verrouillé sur la radio', 'clg2.fModes': 'modes {list}', 'clg2.fStatus': 'pastilles de statut', 'clg2.fHideWorked': 'masquer les contactés', 'clg2.fLotwOnly': 'utilisateurs LoTW uniquement', 'clg2.fSpotterCont': 'continent du spotteur', 'clg2.fSource': 'un seul nœud source', 'clg2.fSearch': 'recherche « {q} »',
'clg2.c.time': 'Heure', 'clg2.c.call': 'Indicatif', 'clg2.c.status': 'Statut', 'clg2.c.pota': 'POTA', 'clg2.c.freq': 'Fréq', 'clg2.c.band': 'Bande', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Préf.', 'clg2.c.cqz': 'Zone CQ', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'Zone ITU', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Chemin court (°)', 'clg2.h.sp_deg': 'CC°', 'clg2.c.lp_deg': 'Chemin long (°)', 'clg2.h.lp_deg': 'CL°', 'clg2.c.country': 'Pays', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Locator du spotter', 'clg2.h.locator': 'Loc spotter', 'clg2.c.county': 'Comté US', 'clg2.tipNewCounty': 'NOUVEAU COMTÉ — jamais contacté', 'clg2.tipNewPfx': "NOUVEAU PRÉFIXE — ce préfixe WPX n'a jamais été contacté", 'clg2.c.comment': 'Commentaire', 'clg2.c.received_at': 'Reçu le', 'clg2.h.received_at': 'Reçu UTC', 'clg2.c.raw': 'Brut', 'clg2.newDxcc': 'NOUV DXCC', 'clg2.newBandMode': 'NOUV B+M', 'clg2.newBand': 'NOUV BANDE', 'clg2.newMode': 'NOUV MODE', 'clg2.newSlot': 'NOUV SLOT', 'clg2.newCall': 'CALL NEUF', 'clg2.wkdCall': 'DÉJÀ CTC', 'clg2.newCounty': 'NOUV CTY', 'clg2.newGridUnconf': 'GRID?', 'clg2.newGrid': 'NOUV GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NOUVEAU GRID — ce carré n a jamais été contacté (grid entendu dans un CQ sur le lien UDP)', 'clg2.newPfx': "NOUVEAU PFX", 'clg2.newPota': 'NOUV POTA', 'clg2.tipNewDxcc': 'NOUVEAU DXCC : {country}', 'clg2.tipWorkedCall': 'Indicatif déjà contacté', 'clg2.tipNewBandMode': 'NOUVELLE BANDE ET NOUVEAU MODE pour cette entité — aucun des deux na été fait avec elle', 'clg2.tipNewBand': 'NOUVELLE BANDE pour cette entité', 'clg2.tipNewSlotBand': 'NOUVEAU SLOT (mode pas encore contacté sur cette bande)', 'clg2.tipNewMode': 'NOUVEAU MODE (ce mode jamais contacté sur cette entité)', 'clg2.tipNewSlot': 'NOUVEAU SLOT (cette bande+mode pas encore contactée)', 'clg2.tipNewCall': "CALL NEUF — cet indicatif n a jamais été contacté sur cette bande et ce mode (l entité, si)", 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Géo', 'clg2.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'clg2.clearFilters': 'Effacer les filtres', 'clg2.columns': 'Colonnes', 'clg2.pickerTitle': 'Colonnes du cluster', 'clg2.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau du cluster.', 'clg2.allGroups': 'Tous les groupes :', 'clg2.all': 'tout', 'clg2.none': 'aucun', 'clg2.resetDefaults': 'Réinitialiser', 'clg2.done': 'Terminé', 'clg2.c.time': 'Heure', 'clg2.c.call': 'Indicatif', 'clg2.c.status': 'Statut', 'clg2.c.pota': 'POTA', 'clg2.c.freq': 'Fréq', 'clg2.c.band': 'Bande', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Préf.', 'clg2.c.cqz': 'Zone CQ', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'Zone ITU', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Chemin court (°)', 'clg2.h.sp_deg': 'CC°', 'clg2.c.lp_deg': 'Chemin long (°)', 'clg2.h.lp_deg': 'CL°', 'clg2.c.country': 'Pays', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Locator du spotter', 'clg2.h.locator': 'Loc spotter', 'clg2.c.county': 'Comté US', 'clg2.tipNewCounty': 'NOUVEAU COMTÉ — jamais contacté', 'clg2.tipNewPfx': "NOUVEAU PRÉFIXE — ce préfixe WPX n'a jamais été contacté", 'clg2.c.comment': 'Commentaire', 'clg2.c.received_at': 'Reçu le', 'clg2.h.received_at': 'Reçu UTC', 'clg2.c.raw': 'Brut', 'clg2.newDxcc': 'NOUV DXCC', 'clg2.newBandMode': 'NOUV B+M', 'clg2.newBand': 'NOUV BANDE', 'clg2.newMode': 'NOUV MODE', 'clg2.newSlot': 'NOUV SLOT', 'clg2.newCall': 'CALL NEUF', 'clg2.wkdCall': 'DÉJÀ QSO', 'clg2.newCounty': 'NOUV CTY', 'clg2.newGridUnconf': 'GRID?', 'clg2.newGrid': 'NOUV GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NOUVEAU GRID — ce carré n a jamais été contacté (grid entendu dans un CQ sur le lien UDP)', 'clg2.newPfx': "NOUVEAU PFX", 'clg2.newPota': 'NOUV POTA', 'clg2.tipNewDxcc': 'NOUVEAU DXCC : {country}', 'clg2.tipWorkedCall': 'Indicatif déjà contacté', 'clg2.tipNewBandMode': 'NOUVELLE BANDE ET NOUVEAU MODE pour cette entité — aucun des deux na été fait avec elle', 'clg2.tipNewBand': 'NOUVELLE BANDE pour cette entité', 'clg2.tipNewSlotBand': 'NOUVEAU SLOT (mode pas encore contacté sur cette bande)', 'clg2.tipNewMode': 'NOUVEAU MODE (ce mode jamais contacté sur cette entité)', 'clg2.tipNewSlot': 'NOUVEAU SLOT (cette bande+mode pas encore contactée)', 'clg2.tipNewCall': "CALL NEUF — cet indicatif n a jamais été contacté sur cette bande et ce mode (l entité, si)", 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Géo', 'clg2.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'clg2.clearFilters': 'Effacer les filtres', 'clg2.columns': 'Colonnes', 'clg2.pickerTitle': 'Colonnes du cluster', 'clg2.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau du cluster.', 'clg2.allGroups': 'Tous les groupes :', 'clg2.all': 'tout', 'clg2.none': 'aucun', 'clg2.resetDefaults': 'Réinitialiser', 'clg2.done': 'Terminé',
// Périphériques audio et manipulateur vocal (Préférences → Périphériques audio). // Périphériques audio et manipulateur vocal (Préférences → Périphériques audio).
'aud.refreshDevices': 'Actualiser les périphériques', 'aud.fromRadio': 'Depuis la radio (entrée RX)', 'aud.toRadio': 'Vers la radio (sortie TX)', 'aud.recMic': "Micro d'enregistrement", 'aud.listening': 'Écoute (pré-écoute)', 'aud.refreshDevices': 'Actualiser les périphériques', 'aud.fromRadio': 'Depuis la radio (entrée RX)', 'aud.toRadio': 'Vers la radio (sortie TX)', 'aud.recMic': "Micro d'enregistrement", 'aud.listening': 'Écoute (pré-écoute)',
'aud.phFromRadio': 'Sortie audio du poste → entrée carte son', 'aud.phToRadio': 'Sortie carte son → entrée micro/data du poste', 'aud.phRecMic': 'Votre microphone (enregistrer les messages vocaux)', 'aud.phListening': 'Haut-parleurs locaux pour la pré-écoute', 'aud.phFromRadio': 'Sortie audio du poste → entrée carte son', 'aud.phToRadio': 'Sortie carte son → entrée micro/data du poste', 'aud.phRecMic': 'Votre microphone (enregistrer les messages vocaux)', 'aud.phListening': 'Haut-parleurs locaux pour la pré-écoute',
+47 -3
View File
@@ -168,7 +168,48 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
twoSrc := r.twoSrc twoSrc := r.twoSrc
r.mu.Unlock() r.mu.Unlock()
// Capture goroutine(s) feed the per-source queues. // The radio side is either CAPTURED from a sound device or PUSHED in from
// somewhere else. Over the network there is no sound device at all: an
// IC-705 reached by LAN streams its receive audio over the Icom protocol,
// and the operator's audio settings can only offer the PC's own microphone
// and speakers. fromDev == PushedSource says "someone else will feed me",
// and PushRX is how they do it.
if fromDev == PushedSource {
LogSink("recorder: radio audio is pushed in (network), not captured from a device")
} else {
r.startRadioCapture(fromDev, stop)
}
if twoSrc {
r.startMicCapture(micDev, stop)
}
r.finishStart(fromDev, micDev, twoSrc, stop)
return nil
}
// PushedSource is the "device" name that means the radio audio arrives through
// PushRX instead of a capture stream.
const PushedSource = "\x00pushed"
// PushRX feeds one chunk of 16-bit mono PCM from a non-device source.
//
// Safe to call when nothing is recording — the samples are dropped, which is
// what should happen: the network stream runs whenever the rig is connected,
// and the recorder only wants it between BeginQSO and the save.
func (r *Recorder) PushRX(pcm []byte) {
r.mu.Lock()
running := r.running
r.mu.Unlock()
if !running || len(pcm) == 0 {
return
}
sm := bytesToInt16(pcm)
r.srcMu.Lock()
r.bufA = append(r.bufA, sm...)
r.lastA = time.Now()
r.srcMu.Unlock()
}
func (r *Recorder) startRadioCapture(fromDev string, stop chan struct{}) {
r.wg.Add(1) r.wg.Add(1)
go func() { go func() {
defer r.wg.Done() defer r.wg.Done()
@@ -187,7 +228,9 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
LogSink("recorder: capture from %q failed: %v", DeviceName(fromDev), err) LogSink("recorder: capture from %q failed: %v", DeviceName(fromDev), err)
} }
}() }()
if twoSrc { }
func (r *Recorder) startMicCapture(micDev string, stop chan struct{}) {
r.wg.Add(1) r.wg.Add(1)
go func() { go func() {
defer r.wg.Done() defer r.wg.Done()
@@ -204,6 +247,8 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
}() }()
} }
// finishStart logs what is being recorded and arms the silence watchdog.
func (r *Recorder) finishStart(fromDev, micDev string, twoSrc bool, stop chan struct{}) {
// Name the devices being recorded FROM, once, at the start. // Name the devices being recorded FROM, once, at the start.
// //
// A station with two radios has two sets of endpoints, and the recorder will // A station with two radios has two sets of endpoints, and the recorder will
@@ -262,7 +307,6 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
} }
} }
}() }()
return nil
} }
// mixTick drains the source queues, mixes what's available, and appends to the // mixTick drains the source queues, mixes what's available, and appends to the
+20
View File
@@ -500,10 +500,30 @@ func (o *OmniRig) SetFrequency(hz int64) error {
// deliberately; a spot click is a request to change frequency, not to change // deliberately; a spot click is a request to change frequency, not to change
// VFO. On SUB the direct FreqB write below does the whole job. // VFO. On SUB the direct FreqB write below does the whole job.
onSubVFO := vfo == "B" || vfo == "BB" || vfo == "BA" onSubVFO := vfo == "B" || vfo == "BB" || vfo == "BA"
// …and NEVER on a Yaesu.
//
// On Yaesu the call is useless at best: an FT-891 logged "SetSimplexMode OK"
// on every spot click while FreqA never moved (see below — the direct property
// write is what actually tunes these rigs). On an FT-2000 it is worse than
// useless. From a log of one QSY, before and after the call:
//
// 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" method turned split ON
// and moved reception to VFO B, which is exactly what the operator reported:
// OpsLog showing B instead of A. OpsLog was not misreading the radio — it was
// reading it correctly after having moved it itself.
//
// Restricted to Yaesu because that is where the evidence is: on Icom the call
// is authoritative and the direct write is the unreliable one.
isYaesu := isYaesuRig(rigType)
simplexOK := false simplexOK := false
switch { switch {
case onSubVFO: case onSubVFO:
debugLog.Printf("OmniRig.SetFrequency: on VFO %q — skipping SetSimplexMode so the rig stays on SUB", vfo) debugLog.Printf("OmniRig.SetFrequency: on VFO %q — skipping SetSimplexMode so the rig stays on SUB", vfo)
case isYaesu:
debugLog.Printf("OmniRig.SetFrequency: %q is a Yaesu — skipping SetSimplexMode (a no-op on some, and on an FT-2000 it turns split on and jumps to VFO B); the direct write below does the work", rigType)
default: default:
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil { if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
simplexOK = true simplexOK = true
+21
View File
@@ -125,3 +125,24 @@ func TestOmniRigWriteTarget(t *testing.T) {
} }
} }
} }
// SetSimplexMode is skipped on Yaesu, and that is not a preference.
//
// On an FT-891 it returned OK on every spot click while the rig never moved. On
// an FT-2000 it did something worse — a log of one QSY shows the state going
// from Vfo="AB" split off to Vfo="BA" split ON, so the operator's reception
// jumped to VFO B and OpsLog, reading the radio correctly, displayed B. The
// direct FreqA/Freq write does the tuning on these rigs.
func TestYaesuRigsAreRecognisedForTheSimplexSkip(t *testing.T) {
for _, yes := range []string{"FT-2000", "FT-891", "FTDX10", "ftdx101", " FT-991A "} {
if !isYaesuRig(yes) {
t.Errorf("%q not recognised as a Yaesu — SetSimplexMode would still be called on it", yes)
}
}
// Everything else keeps the call: on Icom it is the authoritative one.
for _, no := range []string{"IC-7610", "TS-590", "K3", "", "Flex-6600"} {
if isYaesuRig(no) {
t.Errorf("%q wrongly treated as a Yaesu", no)
}
}
}