Compare commits

..
16 Commits
Author SHA1 Message Date
rouggy 4afd7dda90 fix: keep the audio captured while waiting for a silent source
The operator asked the right question: why wait 1.5 s at all, when the mode is
known from CAT?

Because the wait is not the problem — what happened during it was. The drift
guard, meant for two sound cards running on their own clocks, saw a starved
source as a one-second skew and trimmed the radio audio to match. So the opening
of every CW recording was thrown away although it had been captured.

The guard now applies only while BOTH sources are actually delivering. Waiting
then costs nothing: everything buffered is written whole the moment the silent
source is written off, which a test now pins with three seconds of audio.

Using the CAT mode to skip the wait would have been a second mechanism for a
case this one already covers — and it would not cover the others: a device
switched off, unplugged, or held by another program.
2026-07-31 00:31:02 +02:00
rouggy ed930667a1 fix: stop warning about a silent mic when the radio is streaming
The log read:

  recorder: the second audio source is silent — recording the radio alone
  recorder: no audio at all from "DAX Mic" after 3 s — nothing is streaming

Both true, and together they read as a fault while the recording was going
perfectly well — it was saved seconds later. On CW the mic channel legitimately
delivers nothing; the mixer already said what it was doing about it.

The watchdog now mentions the mic only when the radio is silent too, which is
the case where the recording really is empty.
2026-07-31 00:22:44 +02:00
rouggy 311479c52f fix: write off an audio source that never delivers anything
The operator said it from the start: in CW there is no sidetone, only reception.
My previous fix did not cover it — it wrote off a source that STOPPED, but a
source that never starts has no last-delivery time, so it stayed forever "not
yet dry" and held the mixer at min(A,B)=0. Three empty recordings while the
radio audio streamed perfectly.

A source with no delivery is now measured from when capture started. Tested both
ways: nothing is written off in the first moments, and a source that has never
spoken is written off once the grace period has passed.
2026-07-31 00:15:11 +02:00
rouggy b25efabab8 fix: check the configured capture device still exists before recording
Third empty recording, same station, and the sequence was as plain as it gets:
type the call, click record, log the QSO. The device opened and never streamed.

Endpoint ids are what gets stored, and a DAX channel that is reconfigured,
disabled or recreated comes back with a DIFFERENT id. The stale one opens
without complaint on some drivers and simply never delivers — indistinguishable
from a quiet band until the file turns out to be empty.

The recorder now checks the id against the current endpoint list when it starts,
which takes milliseconds, and says outright that the configured input is gone —
on screen, not only in the log. If it IS still there, the answer is equally
useful: the device exists and the fault is upstream, in whatever should be
feeding it.
2026-07-31 00:12:51 +02:00
rouggy a93f52d2b9 fix: name the audio device in diagnostics instead of quoting its GUID
The watchdog fired exactly as intended and said:

  no audio at all from "{0.0.1.00000000}.{6a27abfd-f277-496b-b6f1-f92ec284c636}"

which tells the operator nothing they can act on. "DAX RX 1 (FlexRadio DAX)"
points straight at the DAX panel.

Endpoint ids are what gets CONFIGURED and stored, so they are what the recorder
holds; the friendly name is resolved only when something has gone wrong. When
the endpoint cannot be found at all the id is still shown — a device that has
disappeared explains an empty recording too.
2026-07-31 00:11:29 +02:00
rouggy c9f7279a01 fix: log when an in-progress recording is discarded
"No recording was running at log time" says the take had already ended, but not
who ended it. The only path that does is the callsign being cleared — which also
happens when a clicked spot replaces it — and that path was silent.

Now it names itself, so the log shows the moment the recording was lost instead
of only its absence at the end.
2026-07-31 00:01:08 +02:00
rouggy 2560fced87 docs: match the French changelog to the English one 2026-07-30 23:57:17 +02:00
rouggy 1718bf6f33 fix: say when a capture device is streaming nothing
Second empty recording from the same station, and this time NEITHER source had
delivered a sample — so the fix for a silent second source could not apply, and
the log still said only "recording was empty" at the end of the QSO.

Two blind spots, both now closed:

  - captureStream's error was discarded. A device that cannot be opened — renamed,
    unplugged, held by another program — looked exactly like one that is merely
    quiet.
  - A WASAPI device can open cleanly and then produce nothing at all. A DAX
    channel with no stream behind it does precisely that, and it is
    indistinguishable from silence until the file turns out to be empty.

A watchdog now reports both, three seconds in, and the first goes to the OPERATOR
as a toast rather than only to the log: they are mid-QSO, and by the time they
find out at save time the audio is gone for good.
2026-07-30 23:46:27 +02:00
rouggy c4c5db3921 fix: a silent audio source no longer empties the whole recording
From an operator's log: a Flex with From Radio on DAX RX and Recording mic on
DAX Mic, a full CW QSO recorded, and at log time "snapshot failed: recording
was empty".

The mixer took min(len(A), len(B)) samples. In CW the mic path does not run, so
DAX Mic delivered nothing, min stayed 0, and the drift guard then threw the
radio audio away a second at a time. Two devices configured meant the recording
depended on the quieter one being alive.

A source that has delivered nothing for 1.5 s is now treated as absent and the
other is recorded alone, with one line in the log saying so — half a recording
is worth having, silence is not. Both are mixed again the moment the missing one
comes back.

Neither is written off before it has EVER spoken: at startup both timestamps are
zero, and declaring one dead then would clip the opening of every recording.
That case is tested too.
2026-07-30 23:39:54 +02:00
rouggy a1ceea978b fix: no play-on-air button on CW
In CW the transmitter builds its tone from the keyer and ignores the DAX TX
audio path, so the button keyed the rig and sent silence. Confirmed on a Flex:
our side reported the playback as done while nothing went out.

Only the button goes. The row stays, or a CW operator who stopped their
recording would be left with no way to resume it — and the recording itself is
still worth keeping.
2026-07-30 23:34:23 +02:00
rouggy f4956a63bb fix: say why playing a recording on the air did nothing
"I click play and nothing happens" is what an operator sees when a function
returns an error into a promise the interface swallows. Every refusal in the
playback path is now logged as well as returned, and each names its own cause —
audio not initialised, the take still running, the take empty, the device
rejecting the file.

One of those was not even checked: playing back needs an OUTPUT device INTO the
radio, which is a different setting from the recorder's input and is very likely
unset on a station that has only ever recorded. It now refuses with that
sentence instead of failing somewhere inside the player.
2026-07-30 23:27:30 +02:00
rouggy 5a4ad800b3 test: pin which modes produce a recording
CW has moved in and out of this list twice — excluded for the missing sidetone,
restored because the other station is captured regardless. A test says which,
so the next reading of that comment does not undo the decision.
2026-07-30 23:24:58 +02:00
rouggy e152ef0ee0 feat: record CW QSOs again
CW was excluded because SmartSDR does not route the operator's own sidetone
through DAX, so the recording sounds one-sided. The audio path is open all the
same and the other station IS captured — which is the half worth keeping.

The frontend had ONE list serving two purposes: which modes may be recorded, and
which modes the voice keyer may transmit on. Adding CW to it would have let the
DVK key the rig with speech on a CW frequency. They are now two lists: PHONE_MODES
for the keyer, RECORDABLE_MODES (phone + CW) for the recorder.

Digital modes stay out of both: their audio is a modem tone nobody will replay.
2026-07-30 23:13:21 +02:00
rouggy b6465ddc9d docs: trim the Flex split entry to the change itself 2026-07-30 23:07:52 +02:00
rouggy 961357474d feat: Flex stays on the RX slice in split
SmartSDR moves its active flag onto the TX slice as soon as the transmit
frequency is touched, and OpsLog followed it. But the operator is listening to
the DX on the other slice: the S-meter, audio level, filter and DSP they are
adjusting all belong there, so following the transmitter handed them the
controls of a receiver they were not using.

In split the receive slice now wins. Simplex is unchanged, and two same-band
slices in different classes (SSB alongside FT8) are still not a split.

A slice clicked IN OPSLOG is remembered and overrides both the split rule and
the radio's focus — that is the operator speaking. Activating a slice on the
radio or in SmartSDR deliberately does not move OpsLog. The pin is dropped when
that slice stops being in use, so a closed receiver cannot strand the display.

The panel now highlights the slice OpsLog is working with rather than the radio's
focused one; reporting the radio's flag would light up one slice while every
control acted on another.
2026-07-30 23:03:29 +02:00
rouggy 702ca4f0c9 docs: one line per changelog entry
The 0.22.3 block had grown into explanations: why a fault happened, how it was
found, what it does not cover. That belongs in the commits, where it already is.

An operator opens this list to learn what changed for them. Sixteen entries now
say it in a line each.
2026-07-30 23:02:16 +02:00
10 changed files with 614 additions and 79 deletions
+39 -8
View File
@@ -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
View File
@@ -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 dOpsLog. 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 dun 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 lindicatif 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 dun 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 denvoi 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
View File
@@ -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()}
+27
View File
@@ -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
View File
@@ -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
}
+123
View File
@@ -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)
}
}
+7 -7
View File
@@ -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
View File
@@ -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,
})
}
+68
View File
@@ -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)
}
}
+22
View File
@@ -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)
}
}
}