Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4afd7dda90 | ||
|
|
ed930667a1 | ||
|
|
311479c52f | ||
|
|
b25efabab8 | ||
|
|
a93f52d2b9 | ||
|
|
c9f7279a01 | ||
|
|
2560fced87 | ||
|
|
1718bf6f33 | ||
|
|
c4c5db3921 | ||
|
|
a1ceea978b | ||
|
|
f4956a63bb | ||
|
|
5a4ad800b3 | ||
|
|
e152ef0ee0 | ||
|
|
b6465ddc9d | ||
|
|
961357474d | ||
|
|
702ca4f0c9 |
@@ -844,7 +844,11 @@ func (a *App) startup(ctx context.Context) {
|
||||
// Route CAT/OmniRig debug lines into the unified app log (they used to go
|
||||
// to a separate cat.log in the old HamLog folder, which users couldn't find).
|
||||
cat.LogSink = applog.Printf
|
||||
audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log
|
||||
audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log
|
||||
// A recorder that captures nothing must reach the OPERATOR, not just the log:
|
||||
// they are mid-QSO, and by the time they notice at save time the audio is
|
||||
// gone for good.
|
||||
audio.AlertSink = func(format string, args ...any) { a.toast(fmt.Sprintf(format, args...)) }
|
||||
extsvc.LogSink = applog.Printf // log raw QRZ (and other) service responses for diagnosis
|
||||
lookup.LogSink = applog.Printf // which call was queried, and why a portable lookup fell back
|
||||
db.LogSink = applog.Printf // which schema migrations ran, and how long they took
|
||||
@@ -7107,14 +7111,17 @@ func (a *App) qsoRecDir() string {
|
||||
// be e-mailed later), and auto-sends it to the contacted operator when enabled
|
||||
// and an e-mail is known. Called right after a QSO is inserted (manual + UDP);
|
||||
// q must have its ID set.
|
||||
// recordableMode reports whether a QSO mode is worth an audio recording —
|
||||
// only voice (SSB/AM/FM) and CW. Digital modes (FT8/FT4/RTTY/PSK/JT…) carry no
|
||||
// useful audio, so they are never recorded.
|
||||
// recordableMode reports whether a QSO mode is worth an audio recording: voice
|
||||
// and CW. Digital modes (FT8/FT4/RTTY/PSK/JT…) carry only modem tones, which
|
||||
// nobody will ever replay, so they are never recorded.
|
||||
//
|
||||
// CW was excluded for a while because SmartSDR does not route the operator's own
|
||||
// sidetone through DAX, making the recording sound one-sided. The audio path is
|
||||
// open all the same and the other station IS captured — which is the half worth
|
||||
// keeping. Operator's call.
|
||||
func recordableMode(mode string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
// CW is intentionally excluded: SmartSDR doesn't route CW audio through DAX,
|
||||
// so the recording is empty/useless. Phone modes only.
|
||||
case "SSB", "USB", "LSB", "AM", "FM", "DV":
|
||||
case "SSB", "USB", "LSB", "AM", "FM", "DV", "CW":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -7707,16 +7714,32 @@ func (a *App) QSOAudioResume() bool {
|
||||
// loop. Stopping is the operator's statement that the take is finished, and it
|
||||
// is one click they have already made.
|
||||
func (a *App) QSOAudioPlayOnAir() error {
|
||||
// Every refusal below is LOGGED as well as returned. "I click play and
|
||||
// nothing happens" is what an operator sees when a function returns an error
|
||||
// into a promise the interface swallowed — and the difference between "no
|
||||
// output device configured" and "the take was empty" cannot be guessed from
|
||||
// the outside.
|
||||
if a.qsoRec == nil || a.audioMgr == nil {
|
||||
applog.Printf("qso-rec: play refused — audio subsystem not initialised")
|
||||
return fmt.Errorf("audio not initialized")
|
||||
}
|
||||
if !a.qsoRec.Paused() {
|
||||
applog.Printf("qso-rec: play refused — the recording is still running (stop it first)")
|
||||
return fmt.Errorf("stop the recording before playing it on the air")
|
||||
}
|
||||
pcm, err := a.qsoRec.PeekQSO()
|
||||
if err != nil {
|
||||
applog.Printf("qso-rec: play refused — %v", err)
|
||||
return err
|
||||
}
|
||||
cfgEarly, _ := a.GetAudioSettings()
|
||||
if strings.TrimSpace(cfgEarly.ToRadio) == "" {
|
||||
// The recorder needs an INPUT from the radio; playing back needs an
|
||||
// OUTPUT into it, which is a different device and may well be unset on a
|
||||
// station that only ever recorded.
|
||||
applog.Printf("qso-rec: play refused — no output device to the radio configured (Settings → Audio)")
|
||||
return fmt.Errorf("no audio output to the radio is configured — set it in Settings → Audio")
|
||||
}
|
||||
// A plain WAV in the data dir, overwritten each time: the player takes a
|
||||
// path, and MP3 encoding would add seconds to something the operator is
|
||||
// waiting on with the transmitter keyed.
|
||||
@@ -7725,7 +7748,7 @@ func (a *App) QSOAudioPlayOnAir() error {
|
||||
return fmt.Errorf("prepare playback: %w", err)
|
||||
}
|
||||
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
cfg := cfgEarly
|
||||
if err := a.pttKey(cfg); err != nil {
|
||||
applog.Printf("qso-rec: PTT on failed before playback: %v", err)
|
||||
// Keep going — the audio still reaches the rig and the operator may use VOX.
|
||||
@@ -7735,6 +7758,7 @@ func (a *App) QSOAudioPlayOnAir() error {
|
||||
a.pttMu.Unlock()
|
||||
}
|
||||
if err := a.audioMgr.Play(cfg.ToRadio, path, cfg.TXGain); err != nil {
|
||||
applog.Printf("qso-rec: playback on %q failed: %v", cfg.ToRadio, err)
|
||||
a.pttMu.Lock()
|
||||
keyed := a.dvkPttKeyed
|
||||
gen := a.pttGen
|
||||
@@ -7753,6 +7777,13 @@ func (a *App) QSOAudioPlayOnAir() error {
|
||||
// abandoned without logging).
|
||||
func (a *App) QSOAudioCancel() {
|
||||
if a.qsoRec != nil {
|
||||
// Say so when a take is actually thrown away. This fires when the callsign
|
||||
// is cleared — including when a clicked spot replaces it — and until now
|
||||
// it was silent, so a recording that vanished mid-QSO left the log showing
|
||||
// only its absence at save time.
|
||||
if a.qsoRec.Active() {
|
||||
applog.Printf("qso-rec: in-progress recording discarded (callsign cleared)")
|
||||
}
|
||||
a.qsoRec.DiscardQSO()
|
||||
}
|
||||
a.stopManualQSORecorder()
|
||||
|
||||
+48
-32
@@ -1,42 +1,58 @@
|
||||
[
|
||||
{
|
||||
"version": "0.22.4",
|
||||
"date": "",
|
||||
"en": [
|
||||
"FlexRadio: in split, OpsLog stays on the receive slice instead of following the transmitter.",
|
||||
"QSO recording works on CW again (no sidetone).",
|
||||
"Playing a recording on the air now says why when it cannot.",
|
||||
"The play-on-air button is hidden on CW."
|
||||
],
|
||||
"fr": [
|
||||
"FlexRadio : en split, OpsLog reste sur la slice de réception au lieu de suivre l'émission.",
|
||||
"L'enregistrement des QSO fonctionne à nouveau en CW (sans le signal d'écoute).",
|
||||
"L'émission d'un enregistrement indique désormais pourquoi elle échoue.",
|
||||
"Le bouton d'émission de l'enregistrement est masqué en CW."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.3",
|
||||
"date": "",
|
||||
"en": [
|
||||
"The Kenwood backend is now exercised against a rig that answers: OpsLog talks to the TS-2000 emulator it already carries for ACOM amplifiers, so frequency, mode, VFO, split and PTT are checked end to end. Testing on a real Kenwood is still needed, but the dialogue itself is no longer untried.",
|
||||
"Kenwood: split is now detected from the transmit and receive VFO (FR/FT) when the radio leaves it out of its status frame — the case seen on a Flex in Kenwood CAT mode, where the frequency read correctly but split never appeared. Radios that report it in the status frame are unaffected.",
|
||||
"The CAT protocol trace covers the Kenwood backend as well, not just CI-V. Settings → CAT.",
|
||||
"Updating selected QSOs from the callsign databases now shows a progress bar with the callsign being queried, in a corner rather than a dialog so the rest of OpsLog stays usable — a contest log is thousands of contacts and one network round trip each. The menu entry is renamed \"Update from the callsign databases\": it has always queried every configured provider, QRZ.com then HamQTH.",
|
||||
"The world map can show the grey line: the day/night terminator with its twilight band, redrawn every minute. Button at the top right of the map, off by default, and the choice is remembered.",
|
||||
"The protocol trace checkboxes (CAT and WinKeyer) now show whether the trace is really running. They came back unticked on a trace that was still on, so ticking the box to enable it actually switched it off and the log sent afterwards contained no trace.",
|
||||
"A radio reporting LSB or USB now selects SSB in the entry form. Radios report the sideband, the mode list holds SSB, so nothing matched and the mode stayed empty while the frequency tracked correctly. It also keeps the logged mode ADIF-valid: LSB and USB are submodes there, not modes.",
|
||||
"Kenwood CAT can go over the network: give a host:port instead of a COM port and OpsLog talks to a serial bridge (ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio). This is not the radio's own RJ45, which speaks Kenwood's KNS protocol.",
|
||||
"The CAT backend list is renamed by how the radio is reached: OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, network), Xiegu (USB), Icom (CI-V USB), Icom (CI-V network), TCI.",
|
||||
"With automatic recording off but the audio devices configured, a red dot appears where the recording counter goes. Click it to record the contact by hand: the counter starts, the dot disappears, and logging the QSO saves the file as usual. The sound devices are released again afterwards.",
|
||||
"A recording in progress can be stopped and TRANSMITTED to the station being worked, keyed and sent like a voice-keyer message — for when they ask to hear their own signal. The audio is kept and still saved with the QSO, and recording can be resumed.",
|
||||
"The award reference table can be sorted by reference (the default) or by description — click either heading, click again to reverse. Both the grid and list views follow.",
|
||||
"Deleting a QSO can now withdraw it from QRZ.com and Club Log as well — Settings → External services, off by default. Club Log matches on callsign, time and band so it works for any QSO; QRZ.com can only remove records OpsLog uploaded itself, since its API deletes by record number only.",
|
||||
"QSL and upload status columns are coloured: Y green, N red, R blue. Colour only, no badges. Applies everywhere the QSO table is used — recent QSOs, worked before, NET Control and the QSL manager.",
|
||||
"Settings → General chooses how dates are displayed: Standard (2026-07-30), French (30-07-2026) or US (07-30-2026). Display only — the log is always stored in the ADIF standard format, and exports are unaffected.",
|
||||
"New award: DARC DOK Award (DLD), the German Deutschland-Diplom, matched on the DOK reference in the QSO."
|
||||
"Kenwood backend tested end to end against the built-in TS-2000 emulator.",
|
||||
"Kenwood: split detected from FR/FT when the radio omits it from its status frame.",
|
||||
"CAT protocol trace now covers the Kenwood backend, not only CI-V.",
|
||||
"Updating selected QSOs from the callsign databases shows a progress bar, without blocking the rest of OpsLog. Menu entry renamed \"Update from the callsign databases\".",
|
||||
"Grey line on the world map: day/night terminator and twilight band, button at the top right.",
|
||||
"The CAT and WinKeyer trace checkboxes now show whether the trace is really running.",
|
||||
"A radio reporting LSB or USB now selects SSB in the entry form.",
|
||||
"Kenwood CAT over the network: give a host:port for a serial bridge instead of a COM port.",
|
||||
"CAT backends renamed by how the radio is reached: OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, network), Xiegu (USB), Icom (CI-V USB), Icom (CI-V network), TCI.",
|
||||
"With automatic recording off, a red dot beside the callsign records the contact by hand.",
|
||||
"A recording can be stopped and transmitted to the station being worked, like a voice-keyer message.",
|
||||
"Award references can be sorted by reference or by description.",
|
||||
"Deleting a QSO can also withdraw it from QRZ.com and Club Log — Settings → External services, off by default.",
|
||||
"QSL and upload status columns are coloured: Y green, N red, R blue.",
|
||||
"Settings → General chooses between US / FR / Standard for dates. Display only.",
|
||||
"New award: DARC DOK Award (DLD)."
|
||||
],
|
||||
"fr": [
|
||||
"Le backend Kenwood est désormais éprouvé face à une radio qui répond : OpsLog dialogue avec l'émulateur TS-2000 qu'il embarque déjà pour les amplis ACOM, ce qui vérifie fréquence, mode, VFO, split et PTT de bout en bout. Un essai sur un vrai Kenwood reste nécessaire, mais le dialogue n'est plus non testé.",
|
||||
"Kenwood : le split est désormais détecté à partir des VFO d'émission et de réception (FR/FT) quand la radio ne le renseigne pas dans sa trame d'état — le cas observé sur un Flex en mode CAT Kenwood, où la fréquence était juste mais le split n'apparaissait jamais. Les radios qui le signalent normalement ne changent pas.",
|
||||
"La trace du protocole CAT couvre aussi le backend Kenwood, plus seulement le CI-V. Réglages → CAT.",
|
||||
"La mise à jour des QSO sélectionnés depuis les annuaires affiche désormais une barre de progression avec l'indicatif en cours d'interrogation, dans un coin plutôt qu'en fenêtre : le reste d'OpsLog reste utilisable — un log de concours, c'est des milliers de contacts et un aller-retour réseau pour chacun. L'entrée de menu devient « Mettre à jour depuis les annuaires » : elle a toujours interrogé tous les annuaires configurés, QRZ.com puis HamQTH.",
|
||||
"La carte du monde peut afficher la ligne grise : le terminateur jour/nuit et sa bande de crépuscule, redessinés chaque minute. Bouton en haut à droite de la carte, désactivé par défaut, et le choix est mémorisé.",
|
||||
"Les cases de trace du protocole (CAT et WinKeyer) reflètent désormais l'état réel de la trace. Elles réapparaissaient décochées alors que la trace tournait : cocher la case pour l'activer la désactivait en fait, et le journal envoyé ensuite ne contenait aucune trace.",
|
||||
"Une radio qui annonce LSB ou USB sélectionne désormais SSB dans la saisie. Les radios annoncent la bande latérale, la liste des modes contient SSB : rien ne correspondait et le mode restait vide alors que la fréquence suivait. Cela garde aussi le mode journalisé conforme à l'ADIF, où LSB et USB sont des sous-modes et non des modes.",
|
||||
"Le CAT Kenwood peut passer par le réseau : indiquez un hôte:port au lieu d'un port COM et OpsLog dialogue avec un pont série (ser2net, boîtier Ethernet-série, Raspberry Pi près de la radio). Il ne s'agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood.",
|
||||
"La liste des backends CAT est renommée selon la façon dont la radio est jointe : OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, réseau), Xiegu (USB), Icom (CI-V USB), Icom (CI-V réseau), TCI.",
|
||||
"Quand l'enregistrement automatique est désactivé mais que les périphériques audio sont configurés, un rond rouge apparaît à l'emplacement du compteur. Un clic enregistre le contact à la main : le compteur démarre, le rond disparaît, et journaliser le QSO enregistre le fichier comme d'habitude. Les périphériques audio sont ensuite relâchés.",
|
||||
"Un enregistrement en cours peut être arrêté puis ÉMIS vers la station travaillée, radio passée en émission comme un message du manipulateur vocal — pour quand elle demande à entendre son propre signal. L'audio est conservé et toujours enregistré avec le QSO, et l'enregistrement peut reprendre.",
|
||||
"Le tableau des références d'un diplôme peut être trié par référence (par défaut) ou par description — cliquez sur l'en-tête, un second clic inverse l'ordre. Les vues grille et liste suivent toutes deux.",
|
||||
"Supprimer un QSO peut désormais le retirer aussi de QRZ.com et Club Log — Réglages → Services externes, désactivé par défaut. Club Log retrouve le contact par indicatif, heure et bande, donc cela vaut pour n'importe quel QSO ; QRZ.com ne peut retirer que les enregistrements envoyés par OpsLog, son API ne supprimant que par numéro d'enregistrement.",
|
||||
"Les colonnes de statut QSL et d'envoi sont colorées : Y en vert, N en rouge, R en bleu. Couleur seule, sans pastille. Partout où le tableau des QSO est utilisé — QSO récents, déjà contacté, NET Control et le gestionnaire de QSL.",
|
||||
"Réglages → Général permet de choisir l'affichage des dates : Standard (2026-07-30), Français (30-07-2026) ou US (07-30-2026). Affichage seulement — le journal reste toujours enregistré au format standard ADIF, et les exports ne changent pas.",
|
||||
"Nouveau diplôme : DARC DOK Award (DLD), le Deutschland-Diplom allemand, reconnu d'après la référence DOK du QSO."
|
||||
"Backend Kenwood testé de bout en bout contre l’émulateur TS-2000 intégré.",
|
||||
"Kenwood : split détecté via FR/FT quand la radio ne le renseigne pas dans sa trame d’état.",
|
||||
"La trace du protocole CAT couvre aussi le backend Kenwood, plus seulement le CI-V.",
|
||||
"La mise à jour des QSO sélectionnés depuis les annuaires affiche une barre de progression, sans bloquer le reste d’OpsLog. Entrée de menu renommée « Mettre à jour depuis les annuaires ».",
|
||||
"Ligne grise sur la carte du monde : terminateur jour/nuit et bande de crépuscule, bouton en haut à droite.",
|
||||
"Les cases de trace CAT et WinKeyer reflètent désormais l’état réel de la trace.",
|
||||
"Une radio annonçant LSB ou USB sélectionne désormais SSB dans la saisie.",
|
||||
"CAT Kenwood par le réseau : indiquez un hôte:port de pont série au lieu d’un port COM.",
|
||||
"Backends CAT renommés selon la façon dont la radio est jointe : OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, réseau), Xiegu (USB), Icom (CI-V USB), Icom (CI-V réseau), TCI.",
|
||||
"Enregistrement automatique désactivé : un rond rouge à côté de l’indicatif enregistre le contact à la main.",
|
||||
"Un enregistrement peut être arrêté puis émis vers la station travaillée, comme un message du manipulateur vocal.",
|
||||
"Les références d’un diplôme peuvent être triées par référence ou par description.",
|
||||
"Supprimer un QSO peut aussi le retirer de QRZ.com et Club Log — Réglages → Services externes, désactivé par défaut.",
|
||||
"Colonnes de statut QSL et d’envoi colorées : Y vert, N rouge, R bleu.",
|
||||
"Réglages → Général : choix US / FR / Standard pour les dates. Affichage seulement.",
|
||||
"Nouveau diplôme : DARC DOK Award (DLD)."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+26
-11
@@ -137,7 +137,15 @@ const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALV
|
||||
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
|
||||
// app.go — digital modes carry no useful audio, and CW has no DAX audio on Flex,
|
||||
// so neither is recorded (no REC badge / timer for them).
|
||||
const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV']);
|
||||
// Modes a VOICE keyer may transmit on. Not a recording list — the DVK plays
|
||||
// speech, and sending it on CW or a data slot keys the rig with a human voice.
|
||||
const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV']);
|
||||
|
||||
// Modes worth recording. Phone, plus CW: the audio path stays open in CW, so
|
||||
// the other station's signal is captured. Your own sidetone is not in it —
|
||||
// SmartSDR does not route it through DAX — and that is accepted. Digital modes
|
||||
// stay out: their audio is a modem tone nobody will ever replay.
|
||||
const RECORDABLE_MODES = new Set([...PHONE_MODES, 'CW']);
|
||||
|
||||
const emptyDetails: DetailsState = {
|
||||
state: '', cnty: '', address: '',
|
||||
@@ -1233,7 +1241,7 @@ export default function App() {
|
||||
useEffect(() => { dvkAutoCqSecsRef.current = dvkAutoCqSecs; localStorage.setItem('opslog.dvkAutoCqSecs', String(dvkAutoCqSecs)); }, [dvkAutoCqSecs]);
|
||||
// The DVK is a VOICE keyer — transmitting it on CW/FT8/RTTY would key the rig
|
||||
// with speech on a data slot. Only allow it on phone modes.
|
||||
const isPhoneMode = (m: string) => RECORDABLE_MODES.has((m || '').toUpperCase());
|
||||
const isPhoneMode = (m: string) => PHONE_MODES.has((m || '').toUpperCase());
|
||||
const modeRef = useRef(mode);
|
||||
useEffect(() => { modeRef.current = mode; }, [mode]);
|
||||
function stopDvkAutoCq() { dvkAutoCqSlotRef.current = -1; dvkAutoCqGenRef.current++; }
|
||||
@@ -3731,17 +3739,24 @@ export default function App() {
|
||||
{/* A stopped take: play it to the station being worked, or carry on
|
||||
recording. Both sit where the counter is, which has shifted left to
|
||||
make room. */}
|
||||
{/* A stopped take: resume it, and — on PHONE only — play it to the station
|
||||
being worked. In CW the transmitter builds its tone from the keyer and
|
||||
ignores the DAX TX audio path entirely, so the button would key the rig
|
||||
and send silence. The row itself stays, or a CW operator who stopped
|
||||
would be left with no way to resume. */}
|
||||
{recording && recStopped && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={playRecordingOnAir}
|
||||
title={t('rec.playOnAir')}
|
||||
className="inline-flex items-center justify-center size-4 rounded text-success hover:bg-success/15"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
{PHONE_MODES.has(mode.toUpperCase()) && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={playRecordingOnAir}
|
||||
title={t('rec.playOnAir')}
|
||||
className="inline-flex items-center justify-center size-4 rounded text-success hover:bg-success/15"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
|
||||
@@ -101,3 +101,30 @@ func endpointName(dev *wca.IMMDevice, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// DeviceName resolves an endpoint id to its friendly name.
|
||||
//
|
||||
// Diagnostics quote the id that was CONFIGURED, which is a GUID — an operator
|
||||
// told "no audio at all from {0.0.1.00000000}.{6a27abfd…}" learns nothing they
|
||||
// can act on, while "no audio at all from DAX RX 1 (FlexRadio DAX)" points
|
||||
// straight at the DAX panel.
|
||||
//
|
||||
// Falls back to the id when the endpoint cannot be found, which is itself worth
|
||||
// seeing: a device that has disappeared explains an empty recording too.
|
||||
func DeviceName(id string) string {
|
||||
if id == "" {
|
||||
return "(none)"
|
||||
}
|
||||
for _, list := range []func() ([]Device, error){ListInputDevices, ListOutputDevices} {
|
||||
devs, err := list()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, d := range devs {
|
||||
if d.ID == id {
|
||||
return d.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
+178
-16
@@ -15,6 +15,14 @@ import (
|
||||
// Defaults to a no-op so the package is usable without wiring.
|
||||
var LogSink = func(string, ...any) {}
|
||||
|
||||
// AlertSink receives the few audio problems an operator must see WHILE they are
|
||||
// operating, not afterwards in a log file — a capture device that opens but
|
||||
// never streams being the one that matters: the recording is silently empty and
|
||||
// nothing says so until the QSO is logged and gone.
|
||||
//
|
||||
// Set to a toast emitter at startup; a no-op keeps the package standalone.
|
||||
var AlertSink = func(string, ...any) {}
|
||||
|
||||
// recoverGoroutine turns a panic in a long-running audio goroutine into a logged
|
||||
// event with a stack trace instead of a silent process-killing crash. (It can't
|
||||
// catch a hard Windows access violation from the WASAPI layer — those are fatal
|
||||
@@ -42,12 +50,22 @@ type Recorder struct {
|
||||
prerollSamples int
|
||||
|
||||
// Per-source sample queues (guarded by srcMu), drained by the mixer.
|
||||
srcMu sync.Mutex
|
||||
bufA []int16 // From Radio
|
||||
bufB []int16 // mic
|
||||
twoSrc bool
|
||||
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
|
||||
gainB float64 // mic gain
|
||||
srcMu sync.Mutex
|
||||
bufA []int16 // From Radio
|
||||
bufB []int16 // mic
|
||||
// When each source last delivered samples. A configured device that never
|
||||
// produces anything is not an error anywhere — the capture call just sits
|
||||
// there — so the only way to notice is to watch the clock.
|
||||
lastA, lastB time.Time
|
||||
// startedAt is when capture began — the reference for a source that has not
|
||||
// delivered anything at all yet.
|
||||
startedAt time.Time
|
||||
// deadB (deadA) latches once a source has been declared silent, so the
|
||||
// warning is logged once rather than 25 times a second.
|
||||
deadA, deadB bool
|
||||
twoSrc bool
|
||||
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
|
||||
gainB float64 // mic gain
|
||||
|
||||
// Mixed output state (guarded by mu).
|
||||
ring []int16 // last prerollSamples of mixed audio
|
||||
@@ -115,11 +133,37 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
if prerollSec < 0 {
|
||||
prerollSec = 0
|
||||
}
|
||||
// Does the configured endpoint still EXIST?
|
||||
//
|
||||
// Endpoint ids are stored, and a DAX channel that is reconfigured, disabled
|
||||
// or removed comes back with a different id. The old one then opens without
|
||||
// complaint on some drivers and simply never streams — which is
|
||||
// indistinguishable from a quiet band until the recording turns out empty.
|
||||
// Checking the list takes milliseconds and answers it outright.
|
||||
if devs, derr := ListInputDevices(); derr == nil {
|
||||
known := func(id string) bool {
|
||||
for _, d := range devs {
|
||||
if d.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if fromDev != "" && !known(fromDev) {
|
||||
LogSink("recorder: the configured radio input no longer exists (%s) — re-select it in Settings → Audio", fromDev)
|
||||
AlertSink("The configured radio audio input no longer exists — re-select it in Settings")
|
||||
}
|
||||
if micDev != "" && micDev != fromDev && !known(micDev) {
|
||||
LogSink("recorder: the configured microphone no longer exists (%s) — re-select it in Settings → Audio", micDev)
|
||||
}
|
||||
}
|
||||
r.prerollSamples = prerollSec * sampleRate
|
||||
r.twoSrc = micDev != "" && micDev != fromDev
|
||||
r.stopCh = make(chan struct{})
|
||||
r.running = true
|
||||
r.startedAt = time.Now()
|
||||
r.ring, r.acc, r.active, r.paused, r.bufA, r.bufB = nil, nil, false, false, nil, nil
|
||||
r.lastA, r.lastB, r.deadA, r.deadB = time.Time{}, time.Time{}, false, false
|
||||
stop := r.stopCh
|
||||
twoSrc := r.twoSrc
|
||||
r.mu.Unlock()
|
||||
@@ -129,27 +173,67 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
defer recoverGoroutine("recorder capture (radio)")
|
||||
_ = captureStream(fromDev, stop, func(chunk []byte) {
|
||||
// The error was discarded here. A device that cannot be opened — renamed,
|
||||
// unplugged, held by another program — then looked exactly like a device
|
||||
// that is merely quiet, and the recording came out empty with nothing in
|
||||
// the log to say why.
|
||||
if err := captureStream(fromDev, stop, func(chunk []byte) {
|
||||
s := bytesToInt16(chunk)
|
||||
r.srcMu.Lock()
|
||||
r.bufA = append(r.bufA, s...)
|
||||
r.lastA = time.Now()
|
||||
r.srcMu.Unlock()
|
||||
})
|
||||
}); err != nil {
|
||||
LogSink("recorder: capture from %q failed: %v", DeviceName(fromDev), err)
|
||||
}
|
||||
}()
|
||||
if twoSrc {
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
defer recoverGoroutine("recorder capture (mic)")
|
||||
_ = captureStream(micDev, stop, func(chunk []byte) {
|
||||
if err := captureStream(micDev, stop, func(chunk []byte) {
|
||||
s := bytesToInt16(chunk)
|
||||
r.srcMu.Lock()
|
||||
r.bufB = append(r.bufB, s...)
|
||||
r.lastB = time.Now()
|
||||
r.srcMu.Unlock()
|
||||
})
|
||||
}); err != nil {
|
||||
LogSink("recorder: capture from %q failed: %v", DeviceName(micDev), err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Watchdog. A WASAPI device can open cleanly and then produce nothing at
|
||||
// all — a DAX channel with no stream behind it does exactly that, and it is
|
||||
// indistinguishable from silence until the recording turns out to be empty
|
||||
// at the end of a QSO. Say it once, three seconds in, while there is still
|
||||
// time to fix the setup.
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
defer recoverGoroutine("recorder watchdog")
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
r.srcMu.Lock()
|
||||
aQuiet, bQuiet := r.lastA.IsZero(), r.lastB.IsZero()
|
||||
r.srcMu.Unlock()
|
||||
if aQuiet {
|
||||
LogSink("recorder: no audio at all from %q after 3 s — the device opened but nothing is streaming", DeviceName(fromDev))
|
||||
AlertSink("No audio from %s — nothing is being recorded", DeviceName(fromDev))
|
||||
}
|
||||
// The mic is only worth a warning when the RADIO is silent too. On CW the
|
||||
// mic channel legitimately delivers nothing, and the mixer has already
|
||||
// said "recording the radio alone" — repeating it as an alarm made the log
|
||||
// read as if something were broken while the recording was going fine.
|
||||
if twoSrc && bQuiet && aQuiet {
|
||||
LogSink("recorder: no audio at all from %q either", DeviceName(micDev))
|
||||
}
|
||||
}()
|
||||
|
||||
// Mixer goroutine.
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
@@ -171,15 +255,79 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
|
||||
// mixTick drains the source queues, mixes what's available, and appends to the
|
||||
// ring + active accumulation.
|
||||
// deadSourceAfter is how long a source may deliver nothing before the recorder
|
||||
// carries on without it. Long enough not to trip on a scheduling hiccup, short
|
||||
// enough that almost nothing is lost from the source that IS working.
|
||||
const deadSourceAfter = 1500 * time.Millisecond
|
||||
|
||||
func (r *Recorder) mixTick() {
|
||||
r.srcMu.Lock()
|
||||
var mixed []int16
|
||||
if r.twoSrc {
|
||||
// A source that has been silent for a while is treated as ABSENT and the
|
||||
// other one is recorded alone.
|
||||
//
|
||||
// Two sources used to mean min(len(A), len(B)) samples: if one device
|
||||
// delivered nothing, NOTHING was recorded, and the drift guard below
|
||||
// then threw the live source away a second at a time. That is exactly
|
||||
// what happened on a Flex in CW — DAX Mic delivers nothing when the mic
|
||||
// path is not running — and the operator got "recording was empty" after
|
||||
// a whole QSO. Half a recording is worth having; silence is not.
|
||||
now := time.Now()
|
||||
// A source is dry when it has been quiet for too long — and a source that
|
||||
// has NEVER delivered is measured from when capture started, because it
|
||||
// has no last-delivery time of its own.
|
||||
//
|
||||
// The first version required a source to have spoken at least once. That
|
||||
// covers a device that stops, but not the one that actually happens: a
|
||||
// DAX Mic channel that is simply switched off never delivers a single
|
||||
// sample, so it stayed "not yet dry" forever and took the whole recording
|
||||
// down with it. Three empty CW recordings, with the radio audio streaming
|
||||
// perfectly the entire time.
|
||||
dry := func(last time.Time) bool {
|
||||
if last.IsZero() {
|
||||
return now.Sub(r.startedAt) > deadSourceAfter
|
||||
}
|
||||
return now.Sub(last) > deadSourceAfter
|
||||
}
|
||||
aDry, bDry := dry(r.lastA), dry(r.lastB)
|
||||
if bDry && !aDry && len(r.bufA) > 0 {
|
||||
if !r.deadB {
|
||||
r.deadB = true
|
||||
LogSink("recorder: the second audio source is silent — recording the radio alone")
|
||||
}
|
||||
mixed = make([]int16, len(r.bufA))
|
||||
for i, v := range r.bufA {
|
||||
mixed[i] = scaleSample(v, r.gainA)
|
||||
}
|
||||
r.bufA = r.bufA[:0]
|
||||
r.bufB = r.bufB[:0]
|
||||
r.srcMu.Unlock()
|
||||
r.store(mixed)
|
||||
return
|
||||
}
|
||||
if aDry && !bDry && len(r.bufB) > 0 {
|
||||
if !r.deadA {
|
||||
r.deadA = true
|
||||
LogSink("recorder: the radio audio source is silent — recording the microphone alone")
|
||||
}
|
||||
mixed = make([]int16, len(r.bufB))
|
||||
for i, v := range r.bufB {
|
||||
mixed[i] = scaleSample(v, r.gainB)
|
||||
}
|
||||
r.bufA = r.bufA[:0]
|
||||
r.bufB = r.bufB[:0]
|
||||
r.srcMu.Unlock()
|
||||
r.store(mixed)
|
||||
return
|
||||
}
|
||||
n := len(r.bufA)
|
||||
if len(r.bufB) < n {
|
||||
n = len(r.bufB)
|
||||
}
|
||||
if n > 0 {
|
||||
// Both alive again after one was written off.
|
||||
r.deadA, r.deadB = false, false
|
||||
mixed = make([]int16, n)
|
||||
for i := 0; i < n; i++ {
|
||||
mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB))
|
||||
@@ -187,12 +335,20 @@ func (r *Recorder) mixTick() {
|
||||
r.bufA = append(r.bufA[:0], r.bufA[n:]...)
|
||||
r.bufB = append(r.bufB[:0], r.bufB[n:]...)
|
||||
}
|
||||
// Drift guard: if the clocks diverge, drop the excess so the two
|
||||
// sources stay roughly aligned (≤1 s skew).
|
||||
if d := len(r.bufA) - len(r.bufB); d > sampleRate {
|
||||
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
|
||||
} else if d < -sampleRate {
|
||||
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
|
||||
// Drift guard: two sound cards run on their own clocks, so drop the
|
||||
// excess to keep them within a second of each other.
|
||||
//
|
||||
// ONLY while both are actually running. A starved source is not drift: it
|
||||
// made this guard throw away the radio audio a second at a time during
|
||||
// the grace period, so the opening of every recording was lost even
|
||||
// though it had been captured. Waiting costs nothing now — whatever is
|
||||
// buffered is written whole the moment the silent source is written off.
|
||||
if len(r.bufA) > 0 && len(r.bufB) > 0 {
|
||||
if d := len(r.bufA) - len(r.bufB); d > sampleRate {
|
||||
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
|
||||
} else if d < -sampleRate {
|
||||
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
|
||||
}
|
||||
}
|
||||
} else if len(r.bufA) > 0 {
|
||||
mixed = make([]int16, len(r.bufA))
|
||||
@@ -203,6 +359,12 @@ func (r *Recorder) mixTick() {
|
||||
}
|
||||
r.srcMu.Unlock()
|
||||
|
||||
r.store(mixed)
|
||||
}
|
||||
|
||||
// store appends mixed samples to the pre-roll ring and, when a take is running,
|
||||
// to the take itself.
|
||||
func (r *Recorder) store(mixed []int16) {
|
||||
if len(mixed) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A silent second source must not silence the recording.
|
||||
//
|
||||
// From a real session: a Flex with "From Radio" on DAX RX and "Recording mic"
|
||||
// on DAX Mic. In CW the mic path does not run, so DAX Mic delivered nothing —
|
||||
// and because the mixer took min(len(A), len(B)) samples, nothing at all was
|
||||
// recorded. The whole QSO ended in "recording was empty".
|
||||
func TestMixerSurvivesASilentSource(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
|
||||
// The radio has been talking; the mic has not spoken for well over the
|
||||
// grace period.
|
||||
r.bufA = make([]int16, 800)
|
||||
for i := range r.bufA {
|
||||
r.bufA[i] = 1000
|
||||
}
|
||||
r.lastA = time.Now()
|
||||
r.lastB = time.Now().Add(-5 * time.Second)
|
||||
|
||||
r.mixTick()
|
||||
|
||||
if len(r.acc) == 0 {
|
||||
t.Fatal("nothing was recorded — a silent mic must not stop the radio being captured")
|
||||
}
|
||||
if r.acc[0] != 1000 {
|
||||
t.Errorf("sample = %d, want 1000 — the live source must pass through unchanged", r.acc[0])
|
||||
}
|
||||
}
|
||||
|
||||
// While BOTH sources are alive the two are mixed, as before.
|
||||
func TestMixerStillMixesBothSources(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
r.bufA = []int16{100, 100, 100}
|
||||
r.bufB = []int16{50, 50, 50}
|
||||
now := time.Now()
|
||||
r.lastA, r.lastB = now, now
|
||||
|
||||
r.mixTick()
|
||||
|
||||
if len(r.acc) != 3 {
|
||||
t.Fatalf("recorded %d samples, want 3", len(r.acc))
|
||||
}
|
||||
if r.acc[0] != 150 {
|
||||
t.Errorf("sample = %d, want 150 — both sources should be summed", r.acc[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Just after capture starts, a source that has not delivered yet is NOT written
|
||||
// off: the first samples take a moment to arrive, and declaring the mic dead at
|
||||
// once would drop the opening of every recording.
|
||||
func TestMixerWaitsBrieflyAtStartup(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
r.startedAt = time.Now()
|
||||
r.bufA = []int16{100, 100}
|
||||
|
||||
r.mixTick()
|
||||
|
||||
if len(r.acc) != 0 {
|
||||
t.Errorf("recorded %d samples immediately — the second source deserves a moment to start", len(r.acc))
|
||||
}
|
||||
}
|
||||
|
||||
// A source that has NEVER delivered is written off once enough time has passed.
|
||||
//
|
||||
// This is the case that actually happens, and the one the first version missed:
|
||||
// a DAX Mic channel switched off delivers not one sample, so it had no
|
||||
// last-delivery time and stayed forever "not yet dry" — taking the whole
|
||||
// recording down with it while the radio audio streamed perfectly.
|
||||
func TestMixerWritesOffASourceThatNeverSpoke(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
r.startedAt = time.Now().Add(-5 * time.Second)
|
||||
r.bufA = []int16{700, 700, 700}
|
||||
r.lastA = time.Now()
|
||||
// lastB stays zero: the mic has never produced anything at all.
|
||||
|
||||
r.mixTick()
|
||||
|
||||
if len(r.acc) != 3 {
|
||||
t.Fatalf("recorded %d samples, want 3 — the radio was streaming the whole time", len(r.acc))
|
||||
}
|
||||
if r.acc[0] != 700 {
|
||||
t.Errorf("sample = %d, want 700", r.acc[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing captured during the grace period is thrown away.
|
||||
//
|
||||
// The drift guard exists for two sound cards running on their own clocks. A
|
||||
// STARVED source is not drift, and treating it as such discarded the radio
|
||||
// audio a second at a time while the mixer was still waiting for the mic — so
|
||||
// the opening of every CW recording was lost although it had been captured.
|
||||
func TestGracePeriodKeepsWhatWasCaptured(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
r.startedAt = time.Now()
|
||||
|
||||
// Three seconds of radio audio arrive while the mic says nothing at all.
|
||||
r.bufA = make([]int16, 3*sampleRate)
|
||||
for i := range r.bufA {
|
||||
r.bufA[i] = 500
|
||||
}
|
||||
r.lastA = time.Now()
|
||||
r.mixTick() // still inside the grace period: nothing is written yet…
|
||||
|
||||
if len(r.acc) != 0 {
|
||||
t.Fatalf("wrote %d samples before the mic was written off", len(r.acc))
|
||||
}
|
||||
if len(r.bufA) != 3*sampleRate {
|
||||
t.Fatalf("buffered audio was trimmed to %d samples — it must be kept, not dropped", len(r.bufA))
|
||||
}
|
||||
|
||||
// …and once the mic is written off, everything captured comes through.
|
||||
r.startedAt = time.Now().Add(-5 * time.Second)
|
||||
r.mixTick()
|
||||
if len(r.acc) != 3*sampleRate {
|
||||
t.Errorf("recorded %d samples, want the full %d captured during the wait", len(r.acc), 3*sampleRate)
|
||||
}
|
||||
}
|
||||
@@ -33,13 +33,13 @@ func writeWAV(path string, pcm []byte) error {
|
||||
put(uint32(36 + dataLen))
|
||||
f.WriteString("WAVE")
|
||||
f.WriteString("fmt ")
|
||||
put(uint32(16)) // PCM fmt chunk size
|
||||
put(uint16(1)) // WAVE_FORMAT_PCM
|
||||
put(uint16(channels)) //
|
||||
put(uint32(sampleRate)) //
|
||||
put(uint32(bytesPerSec)) // byte rate
|
||||
put(uint16(blockAlign)) //
|
||||
put(uint16(bitsPerSample)) //
|
||||
put(uint32(16)) // PCM fmt chunk size
|
||||
put(uint16(1)) // WAVE_FORMAT_PCM
|
||||
put(uint16(channels)) //
|
||||
put(uint32(sampleRate)) //
|
||||
put(uint32(bytesPerSec)) // byte rate
|
||||
put(uint16(blockAlign)) //
|
||||
put(uint16(bitsPerSample)) //
|
||||
f.WriteString("data")
|
||||
put(uint32(dataLen))
|
||||
_, err = f.Write(pcm)
|
||||
|
||||
+76
-5
@@ -35,7 +35,12 @@ type Flex struct {
|
||||
model string
|
||||
gotHandle bool
|
||||
|
||||
slices map[int]*flexSlice
|
||||
slices map[int]*flexSlice
|
||||
// pinnedSlice is the slice the operator chose IN OPSLOG (-1 = none). It
|
||||
// overrides the radio's own "active" flag, and only an OpsLog click changes
|
||||
// it — activating a slice on the radio's front panel or in SmartSDR does
|
||||
// not, by design (see mainSliceLocked).
|
||||
pinnedSlice int
|
||||
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
|
||||
amp flexAmp // external amplifier (PowerGenius XL) state
|
||||
micProfiles []string // available mic profiles (SmartSDR "profile mic list")
|
||||
@@ -183,6 +188,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||
pinnedSlice: -1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -968,7 +974,17 @@ func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
||||
// 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.
|
||||
// An explicit choice made in OpsLog wins over everything, including the
|
||||
// radio's active flag. It is dropped only when that slice stops being in
|
||||
// use — a slice the operator closed is not a choice any more.
|
||||
if f.pinnedSlice >= 0 {
|
||||
if s := f.slices[f.pinnedSlice]; s != nil && s.inUse {
|
||||
return f.pinnedSlice, s
|
||||
}
|
||||
}
|
||||
|
||||
firstInUse := -1
|
||||
chosen := -1
|
||||
for _, idx := range f.sortedSliceIdxLocked() {
|
||||
s := f.slices[idx]
|
||||
if !s.inUse {
|
||||
@@ -978,11 +994,56 @@ func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
||||
firstInUse = idx
|
||||
}
|
||||
if s.active {
|
||||
return idx, s
|
||||
chosen = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
if firstInUse >= 0 {
|
||||
return firstInUse, f.slices[firstInUse]
|
||||
if chosen < 0 {
|
||||
chosen = firstInUse
|
||||
}
|
||||
if chosen < 0 {
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
// In SPLIT, stay on the RECEIVE slice.
|
||||
//
|
||||
// SmartSDR moves its "active" flag to the TX slice as soon as the transmit
|
||||
// frequency is touched, so OpsLog followed the transmitter. But the operator
|
||||
// is LISTENING to the DX on the other slice: the S-meter, the audio level,
|
||||
// the filter and the DSP they are adjusting all belong there, and following
|
||||
// the TX slice hands them the controls for a receiver they are not using.
|
||||
//
|
||||
// Only in split, and only when nothing was pinned above — clicking slice B
|
||||
// in OpsLog still selects it and keeps it.
|
||||
if txS := f.txSliceLocked(); txS != nil && f.slices[chosen] == txS {
|
||||
if rxIdx, rxS := f.splitPartnerLocked(txS); rxS != nil {
|
||||
return rxIdx, rxS
|
||||
}
|
||||
}
|
||||
return chosen, f.slices[chosen]
|
||||
}
|
||||
|
||||
// splitPartnerLocked returns the slice that FORMS A SPLIT with txS: in use, on
|
||||
// the same band, at a different frequency, in the same class (phone with phone,
|
||||
// CW with CW — so SSB alongside FT8 on one band is not a split).
|
||||
//
|
||||
// Lowest index first, so the answer is stable; map order is randomised in Go
|
||||
// and gave a different partner on each poll. Caller holds f.mu.
|
||||
func (f *Flex) splitPartnerLocked(txS *flexSlice) (int, *flexSlice) {
|
||||
if txS == nil {
|
||||
return -1, nil
|
||||
}
|
||||
bt := BandFromHz(txS.freqHz)
|
||||
ct := flexSplitClass(txS.mode)
|
||||
if bt == "" || ct == "" {
|
||||
return -1, nil
|
||||
}
|
||||
for _, idx := range f.sortedSliceIdxLocked() {
|
||||
s := f.slices[idx]
|
||||
if s != nil && s.inUse && s != txS && s.freqHz != txS.freqHz &&
|
||||
BandFromHz(s.freqHz) == bt && flexSplitClass(s.mode) == ct {
|
||||
return idx, s
|
||||
}
|
||||
}
|
||||
return -1, nil
|
||||
}
|
||||
@@ -1089,6 +1150,11 @@ func (f *Flex) SetActiveSlice(idx int) error {
|
||||
if !exists {
|
||||
return fmt.Errorf("flex: no slice %d", idx)
|
||||
}
|
||||
// Remember it: this is the operator speaking, and it must survive the radio
|
||||
// moving its own active flag to the transmitter during split.
|
||||
f.mu.Lock()
|
||||
f.pinnedSlice = idx
|
||||
f.mu.Unlock()
|
||||
f.send(fmt.Sprintf("slice s %d active=1", idx))
|
||||
return nil
|
||||
}
|
||||
@@ -1389,6 +1455,7 @@ func (f *Flex) FlexState() FlexTXState {
|
||||
}
|
||||
}
|
||||
sort.Ints(sidx)
|
||||
mainIdx, _ := f.mainSliceLocked()
|
||||
for _, i := range sidx {
|
||||
s := f.slices[i]
|
||||
st.Slices = append(st.Slices, FlexSliceInfo{
|
||||
@@ -1397,7 +1464,11 @@ func (f *Flex) FlexState() FlexTXState {
|
||||
FreqHz: s.freqHz,
|
||||
Mode: flexModeToADIF(s.mode),
|
||||
Band: BandFromHz(s.freqHz),
|
||||
Active: s.active,
|
||||
// The slice OpsLog is working with, which is not always the one the
|
||||
// radio has focused — in split OpsLog stays on RX. Reporting the
|
||||
// radio's flag here would highlight one slice while every control
|
||||
// acted on another.
|
||||
Active: i == mainIdx,
|
||||
TX: s.tx,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package cat
|
||||
|
||||
import "testing"
|
||||
|
||||
// In split, OpsLog stays on the RECEIVE slice.
|
||||
//
|
||||
// SmartSDR moves its "active" flag onto the TX slice as soon as the transmit
|
||||
// frequency is touched. Following it hands the operator the S-meter, audio
|
||||
// level, filter and DSP of a receiver they are not listening to — while the DX
|
||||
// they are working is on the other slice.
|
||||
func TestMainSliceStaysOnRXInSplit(t *testing.T) {
|
||||
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
|
||||
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
|
||||
}
|
||||
|
||||
// Working a DX on 14.100, transmitting up on 14.200. The radio has focused
|
||||
// the transmitter.
|
||||
f := &Flex{pinnedSlice: -1, slices: map[int]*flexSlice{
|
||||
0: mk(14_100_000, "USB", false, false), // where the DX is heard
|
||||
1: mk(14_200_000, "USB", true, true), // where we transmit, radio-focused
|
||||
}}
|
||||
idx, s := f.mainSliceLocked()
|
||||
if idx != 0 || s == nil || s.freqHz != 14_100_000 {
|
||||
t.Errorf("main slice = %d (%v Hz) — want slice 0, the RX side on 14.100", idx, s.freqHz)
|
||||
}
|
||||
|
||||
// Simplex: the radio's focus is authoritative again, nothing to prefer.
|
||||
f2 := &Flex{pinnedSlice: -1, slices: map[int]*flexSlice{
|
||||
0: mk(14_100_000, "USB", false, false),
|
||||
1: mk(14_200_000, "USB", true, true),
|
||||
}}
|
||||
f2.slices[0].inUse = false // only the TX slice is in use → simplex
|
||||
if idx, _ := f2.mainSliceLocked(); idx != 1 {
|
||||
t.Errorf("simplex main slice = %d, want 1 — the only slice in use", idx)
|
||||
}
|
||||
|
||||
// Two same-band slices in DIFFERENT classes are not a split (SSB + FT8), so
|
||||
// there is nothing to prefer and the radio's focus stands.
|
||||
f3 := &Flex{pinnedSlice: -1, slices: map[int]*flexSlice{
|
||||
0: mk(14_074_000, "DIGU", false, false),
|
||||
1: mk(14_200_000, "USB", true, true),
|
||||
}}
|
||||
if idx, _ := f3.mainSliceLocked(); idx != 1 {
|
||||
t.Errorf("SSB+FT8 main slice = %d, want 1 — that is not a split", idx)
|
||||
}
|
||||
}
|
||||
|
||||
// A slice picked IN OPSLOG wins over everything, including the split rule and
|
||||
// the radio's own focus. Picking one on the radio does not move OpsLog.
|
||||
func TestPinnedSliceWins(t *testing.T) {
|
||||
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
|
||||
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
|
||||
}
|
||||
f := &Flex{pinnedSlice: 1, slices: map[int]*flexSlice{
|
||||
0: mk(14_100_000, "USB", true, false), // radio-focused
|
||||
1: mk(14_200_000, "USB", false, true), // chosen in OpsLog
|
||||
}}
|
||||
if idx, _ := f.mainSliceLocked(); idx != 1 {
|
||||
t.Errorf("main slice = %d — an explicit choice in OpsLog must win", idx)
|
||||
}
|
||||
|
||||
// A pin on a slice that is no longer in use is not a choice any more: it
|
||||
// must not strand OpsLog on a receiver that has been closed.
|
||||
f.slices[1].inUse = false
|
||||
if idx, _ := f.mainSliceLocked(); idx != 0 {
|
||||
t.Errorf("main slice = %d — a closed slice cannot stay pinned", idx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// Which modes produce a recording worth keeping.
|
||||
//
|
||||
// CW has moved in and out of this list: it was excluded because SmartSDR does
|
||||
// not route the operator's own sidetone through DAX, then restored because the
|
||||
// other station is captured all the same. Pinned so it does not drift back.
|
||||
func TestRecordableMode(t *testing.T) {
|
||||
for _, m := range []string{"SSB", "USB", "LSB", "AM", "FM", "DV", "CW", "cw", " CW "} {
|
||||
if !recordableMode(m) {
|
||||
t.Errorf("recordableMode(%q) = false — it should be recorded", m)
|
||||
}
|
||||
}
|
||||
// Digital audio is a modem tone nobody replays.
|
||||
for _, m := range []string{"FT8", "FT4", "RTTY", "PSK31", "JT65", "", "DIGU"} {
|
||||
if recordableMode(m) {
|
||||
t.Errorf("recordableMode(%q) = true — digital modes carry no useful audio", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user