Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5623b860c2 | ||
|
|
3e22c4d1a3 | ||
|
|
d90f953df4 | ||
|
|
e4217da010 | ||
|
|
c5ac945de0 | ||
|
|
eb40991482 | ||
|
|
781cf636ea | ||
|
|
62b6fe0a3a | ||
|
|
bd2a8524dc | ||
|
|
4afd7dda90 | ||
|
|
ed930667a1 | ||
|
|
311479c52f | ||
|
|
b25efabab8 | ||
|
|
a93f52d2b9 | ||
|
|
c9f7279a01 | ||
|
|
2560fced87 | ||
|
|
1718bf6f33 | ||
|
|
c4c5db3921 | ||
|
|
a1ceea978b | ||
|
|
f4956a63bb | ||
|
|
5a4ad800b3 | ||
|
|
e152ef0ee0 | ||
|
|
b6465ddc9d | ||
|
|
961357474d | ||
|
|
702ca4f0c9 |
@@ -845,6 +845,10 @@ func (a *App) startup(ctx context.Context) {
|
||||
// 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
|
||||
// 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()
|
||||
@@ -10101,6 +10132,7 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
mIdx, _ := a.qso.BuildMatchIndex(ctx, qrzOwner)
|
||||
const qrzMatchWindow = 10 * time.Minute
|
||||
qrzSkippedOtherCall := 0
|
||||
unconfirmed := 0 // QSOs whose wrongly-set QRZ flag was taken back
|
||||
// QRZ confirmations are QRZ-specific (not award-valid), so NEW is
|
||||
// judged only against other QRZ confirmations.
|
||||
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"qrzcom_qso_download_status"})
|
||||
@@ -10127,6 +10159,25 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
allKeys[k] = true
|
||||
}
|
||||
if !qrzRecordConfirmed(rec) {
|
||||
// QRZ says this one is NOT confirmed. If our log says it is, that
|
||||
// is a mark OpsLog set wrongly before it learned which field
|
||||
// carries QRZ's answer — take it back rather than leave a QSO
|
||||
// counting towards an award it has not earned.
|
||||
//
|
||||
// Only on an EXPLICIT no: a record with no app_qrzlog_status at
|
||||
// all is QRZ saying nothing, and silence is not a retraction.
|
||||
if st := strings.TrimSpace(rec["app_qrzlog_status"]); st != "" {
|
||||
if q, ok := adif.RecordToQSO(rec); ok && q.Callsign != "" {
|
||||
if id, found := mIdx.Match(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), qrzMatchWindow); found && alreadyQrz[id] {
|
||||
if e := a.qso.ClearQRZConfirmed(ctx, id); e == nil {
|
||||
delete(alreadyQrz, id)
|
||||
unconfirmed++
|
||||
emit(fmt.Sprintf("Callsign: %s Date: %s Band: %s Mode: %s ### NOT CONFIRMED ON QRZ — flag cleared ###",
|
||||
q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04"), q.Band, q.Mode))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
q, ok := adif.RecordToQSO(rec)
|
||||
@@ -10221,6 +10272,11 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
sort.Strings(keys)
|
||||
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
|
||||
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
|
||||
if unconfirmed > 0 {
|
||||
// Worth its own line and not a footnote: these QSOs were showing as
|
||||
// confirmed a moment ago, and an award total may move because of it.
|
||||
emit(fmt.Sprintf("Cleared %d QSO(s) that QRZ.com does NOT confirm — they had been marked wrongly by an earlier version", unconfirmed))
|
||||
}
|
||||
if perr != nil {
|
||||
emit("The download was INCOMPLETE — QRZ.com cut the ADIF short, so the records after that point were not read.")
|
||||
emit("The last-download date has NOT been moved, so nothing is lost: run it again (a narrower window returns less data).")
|
||||
@@ -10375,18 +10431,20 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
// "they all turn to Y" (2026-07-29), and on a log full of paper QSLs that is
|
||||
// most of it.
|
||||
//
|
||||
// A QRZ confirmation is QRZ's own statement, and only two fields carry it:
|
||||
// ONE field carries it: app_qrzlog_status, where C means confirmed.
|
||||
//
|
||||
// app_qrzlog_status = C QRZ's confirmed marker on the record
|
||||
// qrzcom_qso_download_status = Y the ADIF field QRZ sets for it
|
||||
// qrzcom_qso_download_status was accepted too, and that was wrong. It is not a
|
||||
// confirmation: QRZ sets it to Y on everything it hands back. An operator's own
|
||||
// fetch showed both fields on the same record —
|
||||
//
|
||||
// Anything else is a claim from some other source and must not be read as a QRZ
|
||||
// confirmation — this status feeds award slots, so a false Y is a QSO counted as
|
||||
// confirmed when it is not.
|
||||
// <app_qrzlog_status:1>N <qrzcom_qso_download_status:1>Y
|
||||
//
|
||||
// — a QSO QRZ says is NOT confirmed, which OpsLog then marked confirmed. That
|
||||
// is how a whole download turned green (2026-07-31).
|
||||
//
|
||||
// This status feeds award slots, so a false Y is a QSO counted as confirmed when
|
||||
// it is not — the one error worth being strict about here.
|
||||
func qrzRecordConfirmed(rec adif.Record) bool {
|
||||
if strings.EqualFold(rec["qrzcom_qso_download_status"], "Y") {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(rec["app_qrzlog_status"]), "C")
|
||||
}
|
||||
|
||||
@@ -12199,6 +12257,106 @@ func (a *App) FlexApplyBandAntenna(band string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// keyFlexBandPower stores the per-band, per-mode TX power map (global,
|
||||
// machine-local — it describes this station's amplifier and antennas).
|
||||
const keyFlexBandPower = "flex.band_power"
|
||||
|
||||
// FlexBandPower is the TX power to set on a band, per class of mode.
|
||||
//
|
||||
// Three classes rather than per-mode: what decides the power is the duty cycle
|
||||
// and the amplifier's tolerance for it, not the mode's name. FT8 and RTTY hurt
|
||||
// an amplifier the same way; SSB and AM do not.
|
||||
//
|
||||
// Zero means "leave the power alone" — an empty box must not silently mean
|
||||
// zero watts, and an operator who configures 20 m only does not want every
|
||||
// other band reset when they get there.
|
||||
type FlexBandPower struct {
|
||||
Phone int `json:"phone"`
|
||||
CW int `json:"cw"`
|
||||
Digi int `json:"digi"`
|
||||
}
|
||||
|
||||
// GetFlexBandPower returns the band→power map (band key uppercased, "20M").
|
||||
func (a *App) GetFlexBandPower() (map[string]FlexBandPower, error) {
|
||||
out := map[string]FlexBandPower{}
|
||||
if a.settings == nil {
|
||||
return out, nil
|
||||
}
|
||||
v, _ := a.settings.GetGlobal(a.ctx, keyFlexBandPower)
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return out, nil
|
||||
}
|
||||
_ = json.Unmarshal([]byte(v), &out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveFlexBandPower persists the band→power map.
|
||||
func (a *App) SaveFlexBandPower(m map[string]FlexBandPower) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.settings.SetGlobal(a.ctx, keyFlexBandPower, string(b))
|
||||
}
|
||||
|
||||
// flexPowerClass sorts an ADIF mode into the three classes the power table uses.
|
||||
// An unknown mode returns "" and changes nothing — guessing would be setting a
|
||||
// transmit power from a name we do not recognise.
|
||||
func flexPowerClass(mode string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "SSB", "USB", "LSB", "AM", "FM", "DV":
|
||||
return "phone"
|
||||
case "CW", "CWR":
|
||||
return "cw"
|
||||
case "FT8", "FT4", "JT65", "JT9", "JS8", "RTTY", "PSK", "PSK31", "PSK63",
|
||||
"BPSK", "QPSK", "MFSK", "OLIVIA", "CONTESTI", "DATA", "DIGITALVOICE",
|
||||
"DIGU", "DIGL", "Q65", "MSK144", "WSPR", "FST4", "FST4W", "ARDOP", "VARA":
|
||||
return "digi"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// FlexApplyBandPower sets the configured TX power for a band and mode.
|
||||
//
|
||||
// Called on band AND mode changes: the whole point is that switching to FT8 on
|
||||
// a band where 1.5 kW is fine for SSB does not put 1.5 kW into a 100% duty-cycle
|
||||
// signal. No mapping, or a zero, leaves the radio alone.
|
||||
func (a *App) FlexApplyBandPower(band, mode string) error {
|
||||
if a.cat == nil {
|
||||
return nil
|
||||
}
|
||||
band = strings.ToUpper(strings.TrimSpace(band))
|
||||
class := flexPowerClass(mode)
|
||||
if band == "" || class == "" {
|
||||
return nil
|
||||
}
|
||||
m, _ := a.GetFlexBandPower()
|
||||
e, ok := m[band]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
pct := 0
|
||||
switch class {
|
||||
case "phone":
|
||||
pct = e.Phone
|
||||
case "cw":
|
||||
pct = e.CW
|
||||
case "digi":
|
||||
pct = e.Digi
|
||||
}
|
||||
if pct <= 0 {
|
||||
return nil
|
||||
}
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
applog.Printf("flex: %s %s → TX power %d%%", band, class, pct)
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRFPower(pct) })
|
||||
}
|
||||
|
||||
// RIT/XIT on the active slice. The offset is kept by the radio when the switch is
|
||||
// off, so turning it back on restores it — the UI must not zero it on toggle.
|
||||
func (a *App) FlexSetRIT(on bool) error {
|
||||
|
||||
+60
-32
@@ -1,42 +1,70 @@
|
||||
[
|
||||
{
|
||||
"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.",
|
||||
"CW decoder: a station replying at a different speed no longer decodes as a run of dashes, and callsigns are no longer split into single letters by a wide fist.",
|
||||
"FlexRadio: one table per band for the antennas and the TX power per mode class (phone / CW / digital). Antennas follow the band, power follows the band and the mode; an empty box leaves the power alone.",
|
||||
"Grouping the digital modes into one slot now takes effect on the spots already on screen, instead of only on those arriving afterwards.",
|
||||
"The QSO right-click menu no longer runs off the bottom of the window: it opens above the cursor when there is not enough room below.",
|
||||
"QRZ.com confirmations: only QRZ own confirmed marker counts now. The download also clears QSOs an earlier version marked confirmed when QRZ says they are not — run it once to correct your log.",
|
||||
"Awards: a mode filter (All / CW / Phone / Digital) that stacks with the worked/confirmed one — so \"worked on CW but not confirmed on CW\" is one click."
|
||||
],
|
||||
"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.",
|
||||
"Décodeur CW : une station qui répond à une autre vitesse ne se décode plus en série de traits, et les indicatifs ne sont plus coupés lettre par lettre par un espacement large.",
|
||||
"FlexRadio : un seul tableau par bande pour les antennes et la puissance d'émission par type de mode (phonie / CW / numérique). Les antennes suivent la bande, la puissance suit la bande et le mode ; une case vide ne touche pas à la puissance.",
|
||||
"Le regroupement des modes numériques en un seul créneau s'applique désormais aux spots déjà affichés, et non plus seulement à ceux qui arrivent ensuite.",
|
||||
"Le menu contextuel des QSO ne déborde plus en bas de la fenêtre : il s'ouvre au-dessus du curseur quand la place manque en dessous.",
|
||||
"Confirmations QRZ.com : seul le marqueur de confirmation propre à QRZ compte désormais. Le téléchargement efface aussi les QSO qu'une version antérieure avait marqués confirmés alors que QRZ dit le contraire — lancez-le une fois pour corriger votre journal.",
|
||||
"Diplômes : un filtre par mode (Tous / CW / Phonie / Numérique) qui se cumule avec le filtre contacté/confirmé — « contacté en CW mais pas confirmé en CW » se lit en un clic."
|
||||
]
|
||||
},
|
||||
{
|
||||
"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)."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// Which class of power a mode falls into.
|
||||
//
|
||||
// This decides how much power leaves the amplifier, so an error here is not a
|
||||
// display bug: a digital mode sorted as phone gets the SSB setting, which is
|
||||
// exactly the 1.5 kW into a 100% duty cycle this feature exists to prevent.
|
||||
func TestFlexPowerClass(t *testing.T) {
|
||||
for _, c := range []struct{ mode, want string }{
|
||||
{"SSB", "phone"}, {"USB", "phone"}, {"LSB", "phone"}, {"AM", "phone"}, {"FM", "phone"},
|
||||
{"CW", "cw"}, {"cw", "cw"}, {" CW ", "cw"},
|
||||
{"FT8", "digi"}, {"FT4", "digi"}, {"RTTY", "digi"}, {"PSK31", "digi"},
|
||||
{"JS8", "digi"}, {"Q65", "digi"}, {"MSK144", "digi"}, {"DATA", "digi"},
|
||||
{"DIGU", "digi"}, {"DIGL", "digi"}, {"WSPR", "digi"},
|
||||
// Unknown or empty: no class, and the caller changes nothing. Setting a
|
||||
// transmit power from a name we do not recognise is not a good guess.
|
||||
{"", ""}, {"SSTV", ""}, {"BANANA", ""},
|
||||
} {
|
||||
if got := flexPowerClass(c.mode); got != c.want {
|
||||
t.Errorf("flexPowerClass(%q) = %q, want %q", c.mode, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
-4
@@ -17,7 +17,7 @@ import {
|
||||
SMTPConfigured, SendLogToDeveloper,
|
||||
WorkedBefore,
|
||||
SetCompactMode,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
|
||||
GetSecretStatus, UnlockSecrets,
|
||||
RefreshCtyDat, DownloadAllReferenceLists,
|
||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
||||
@@ -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++; }
|
||||
@@ -2296,6 +2304,25 @@ export default function App() {
|
||||
FlexApplyBandAntenna(b).catch(() => {});
|
||||
}, [band, catState.backend, locks.band]);
|
||||
|
||||
// Per-band, per-mode TX power. Applied on band AND mode changes — the point is
|
||||
// that moving to FT8 on a band where full power is fine for SSB does not put
|
||||
// full power into a 100% duty-cycle signal.
|
||||
//
|
||||
// Not skipped on a locked band, unlike the antennas: the lock means "do not
|
||||
// let the rig drag my log entry around", not "let the amplifier take whatever
|
||||
// the last mode left it at".
|
||||
const lastPwrRef = useRef('');
|
||||
useEffect(() => {
|
||||
if (catState.backend !== 'flex') return;
|
||||
const b = band.trim();
|
||||
const m = mode.trim();
|
||||
if (!b || !m) return;
|
||||
const key = b + '/' + m;
|
||||
if (key === lastPwrRef.current) return;
|
||||
lastPwrRef.current = key;
|
||||
FlexApplyBandPower(b, m).catch(() => {});
|
||||
}, [band, mode, catState.backend]);
|
||||
|
||||
// Cluster live wiring: hydrate per-server status + saved server list,
|
||||
// then subscribe to push events.
|
||||
async function reloadClusterMeta() {
|
||||
@@ -3731,8 +3758,14 @@ 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">
|
||||
{PHONE_MODES.has(mode.toUpperCase()) && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
@@ -3742,6 +3775,7 @@ export default function App() {
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
@@ -6284,7 +6318,14 @@ export default function App() {
|
||||
<SettingsModal
|
||||
initialSection={settingsSection}
|
||||
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
|
||||
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); refreshManualRecReady(); }}
|
||||
onSaved={() => {
|
||||
loadStation(); loadLists(); loadCATCfg(); reloadWk(); refreshManualRecReady();
|
||||
// Drop the cached spot statuses. They are computed once per
|
||||
// call+band+mode and never expire, so a rule change in Settings —
|
||||
// grouping the digital modes into one slot, above all — left every
|
||||
// spot already on screen showing the answer to the OLD question.
|
||||
setSpotStatus({});
|
||||
}}
|
||||
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
|
||||
flexAvailable={catState.backend === 'flex'}
|
||||
icomAvailable={catState.backend === 'icom'}
|
||||
|
||||
@@ -15,6 +15,8 @@ type AwardRef = {
|
||||
ref: string; name?: string; group?: string; subgrp?: string;
|
||||
worked: boolean; confirmed: boolean; validated: boolean;
|
||||
bands: string[]; confirmed_bands: string[]; validated_bands: string[];
|
||||
// Mode CLASSES — "CW" | "PHONE" | "DIGI" — not ADIF modes.
|
||||
modes?: string[]; confirmed_modes?: string[];
|
||||
};
|
||||
type AwardResult = {
|
||||
code: string; name: string; dimension: string;
|
||||
@@ -75,6 +77,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
||||
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
||||
// Mode filter, stacked ON TOP of the status one. "Worked on CW but not
|
||||
// confirmed" is two questions at once, and answering only one of them is what
|
||||
// sends an operator to a spreadsheet.
|
||||
const [modeFilter, setModeFilter] = useState<'all' | 'CW' | 'PHONE' | 'DIGI'>('all');
|
||||
const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
|
||||
const [showMissing, setShowMissing] = useState(false);
|
||||
const [stats, setStats] = useState<AwardStats | null>(null);
|
||||
@@ -222,6 +228,16 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
if (refFilter === 'worked' && !r.worked) return false;
|
||||
if (refFilter === 'notworked' && r.worked) return false;
|
||||
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
||||
if (modeFilter !== 'all' && refFilter !== 'notworked') {
|
||||
// A reference never worked has no mode, so "not worked" plus a mode is
|
||||
// a contradiction: the mode filter stands aside rather than emptying
|
||||
// the list.
|
||||
if (!(r.modes ?? []).includes(modeFilter)) return false;
|
||||
// Not-confirmed must mean not confirmed ON THIS MODE. An entity worked
|
||||
// on CW and confirmed on SSB is still a CW entity to chase, and the
|
||||
// whole point of the filter is to find those.
|
||||
if (refFilter === 'worked_notconf' && (r.confirmed_modes ?? []).includes(modeFilter)) return false;
|
||||
}
|
||||
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
|
||||
return true;
|
||||
});
|
||||
@@ -237,7 +253,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
}
|
||||
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||
});
|
||||
}, [current, refSearch, refFilter, refSort, refSortDir]);
|
||||
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
@@ -361,6 +377,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||
{([['all', t('awp.filterAll')], ['CW', 'CW'], ['PHONE', t('awp.modePhone')], ['DIGI', t('awp.modeDigital')]] as const).map(([k, label]) => (
|
||||
<button key={k} onClick={() => setModeFilter(k)}
|
||||
className={cn('px-2 py-1', modeFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||
<button
|
||||
onClick={() => setShowMissing(true)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
@@ -38,6 +38,12 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
||||
// which used to dismiss the menu the instant it appeared.)
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||
const { t } = useI18n();
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
// Starts at the cursor; the layout effect corrects it once the real size is
|
||||
// known. Rendering at the cursor first avoids a visible jump for the common
|
||||
// case where it already fits.
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
useEffect(() => { if (menu) setPos({ x: menu.x, y: menu.y }); }, [menu]);
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const close = () => onClose();
|
||||
@@ -50,16 +56,40 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
};
|
||||
}, [menu, onClose]);
|
||||
|
||||
// Position AFTER measuring.
|
||||
//
|
||||
// The menu used to be clamped against a guessed height — 230 px when the
|
||||
// upload submenu was present, 110 otherwise. It is far taller than that with
|
||||
// a selection, so right-clicking near the bottom of the window cut half the
|
||||
// entries off. Its height also depends on which actions the caller passes, so
|
||||
// no constant can be right for long.
|
||||
//
|
||||
// Measured instead: if it does not fit below the cursor it is placed above,
|
||||
// and failing that pinned to the top with its own scrollbar.
|
||||
useLayoutEffect(() => {
|
||||
if (!menu || !boxRef.current) return;
|
||||
const r = boxRef.current.getBoundingClientRect();
|
||||
const pad = 6;
|
||||
let x = menu.x;
|
||||
let y = menu.y;
|
||||
if (x + r.width > window.innerWidth - pad) {
|
||||
x = Math.max(pad, window.innerWidth - r.width - pad);
|
||||
}
|
||||
if (y + r.height > window.innerHeight - pad) {
|
||||
// Above the cursor, if there is room there.
|
||||
y = menu.y - r.height >= pad ? menu.y - r.height : Math.max(pad, window.innerHeight - r.height - pad);
|
||||
}
|
||||
setPos({ x, y });
|
||||
}, [menu, onSendTo]);
|
||||
|
||||
if (!menu) return null;
|
||||
const n = menu.ids.length;
|
||||
// Keep the menu on-screen near the cursor.
|
||||
const x = Math.min(menu.x, window.innerWidth - 248);
|
||||
const y = Math.min(menu.y, window.innerHeight - (onSendTo ? 230 : 110));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[200] min-w-[240px] rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
||||
style={{ left: x, top: y }}
|
||||
ref={boxRef}
|
||||
className="fixed z-[200] min-w-[240px] max-h-[85vh] overflow-y-auto rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
||||
style={{ left: pos.x, top: pos.y }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
DownloadULSCounties, ULSStatus, BackfillUSCounties,
|
||||
ComputeStationInfo,
|
||||
GetUIPref, SetUIPref,
|
||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexBandPower, SaveFlexBandPower,
|
||||
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
@@ -953,81 +953,121 @@ function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
||||
);
|
||||
}
|
||||
|
||||
// FlexBandAntennasPanel — pick the RX/TX antenna per band. Applied automatically
|
||||
// when the band changes (frequency change / spot click). Antennas come live from
|
||||
// the connected FlexRadio; bands come from Lists → Bands.
|
||||
function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
|
||||
// FlexBandPanel — everything that follows the band, in ONE row per band:
|
||||
// antennas and TX power.
|
||||
//
|
||||
// They were two tables listing the same bands one under the other, which made
|
||||
// the operator match rows by eye between them. The two settings are stored and
|
||||
// applied separately (antennas on band change, power on band or mode change) —
|
||||
// that is a backend detail, and no reason to split what is one decision per
|
||||
// band for the person configuring it.
|
||||
function FlexBandPanel({ bands }: { bands: string[] }) {
|
||||
const { t } = useI18n();
|
||||
const [rxList, setRxList] = useState<string[]>([]);
|
||||
const [txList, setTxList] = useState<string[]>([]);
|
||||
const [map, setMap] = useState<Record<string, { rx: string; tx: string }>>({});
|
||||
const [ant, setAnt] = useState<Record<string, { rx: string; tx: string }>>({});
|
||||
const [pwr, setPwr] = useState<Record<string, { phone: number; cw: number; digi: number }>>({});
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
GetFlexState().then((s: any) => {
|
||||
setRxList((s?.ant_list ?? []) as string[]);
|
||||
setTxList(((s?.tx_ant_list?.length ? s.tx_ant_list : s?.ant_list) ?? []) as string[]);
|
||||
}).catch(() => {});
|
||||
GetFlexBandAntennas().then((m: any) => setMap(m ?? {})).catch(() => {});
|
||||
GetFlexBandAntennas().then((m: any) => setAnt(m ?? {})).catch(() => {});
|
||||
GetFlexBandPower().then((m: any) => setPwr(m ?? {})).catch(() => {});
|
||||
}, []);
|
||||
const set = (band: string, side: 'rx' | 'tx', v: string) => {
|
||||
|
||||
const saved = () => { setMsg(t('flxpw.saved')); window.setTimeout(() => setMsg(''), 1200); };
|
||||
|
||||
const setAntenna = (band: string, side: 'rx' | 'tx', v: string) => {
|
||||
const key = band.toUpperCase();
|
||||
setMap((m) => {
|
||||
const cur = m[key] ?? { rx: '', tx: '' };
|
||||
const next = { ...m, [key]: { ...cur, [side]: v } };
|
||||
SaveFlexBandAntennas(next as any)
|
||||
.then(() => { setMsg('Saved'); window.setTimeout(() => setMsg(''), 1200); })
|
||||
.catch((e: any) => setMsg(String(e?.message ?? e)));
|
||||
setAnt((m) => {
|
||||
const next = { ...m, [key]: { ...(m[key] ?? { rx: '', tx: '' }), [side]: v } };
|
||||
SaveFlexBandAntennas(next as any).then(saved).catch((e: any) => setMsg(String(e?.message ?? e)));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const setPower = (band: string, kind: 'phone' | 'cw' | 'digi', v: string) => {
|
||||
const key = band.toUpperCase();
|
||||
const n = Math.max(0, Math.min(100, parseInt(v, 10) || 0));
|
||||
setPwr((m) => {
|
||||
const next = { ...m, [key]: { ...(m[key] ?? { phone: 0, cw: 0, digi: 0 }), [kind]: n } };
|
||||
SaveFlexBandPower(next as any).then(saved).catch((e: any) => setMsg(String(e?.message ?? e)));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const antCell = (b: string, side: 'rx' | 'tx', list: string[]) => {
|
||||
const e = ant[b.toUpperCase()] ?? { rx: '', tx: '' };
|
||||
return (
|
||||
<td className="px-2 py-1.5">
|
||||
<select value={e[side] ?? ''} onChange={(ev) => setAntenna(b, side, ev.target.value)}
|
||||
className="h-8 w-28 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
|
||||
<option value="">— none —</option>
|
||||
{list.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
);
|
||||
};
|
||||
|
||||
// The rule marking where band-driven settings end and mode-driven ones begin
|
||||
// is drawn on the FIRST power cell, header and body alike. It used to be an
|
||||
// extra empty cell in the body only, which gave the two rows different column
|
||||
// counts and slid every heading one column left.
|
||||
const pwrCell = (b: string, kind: 'phone' | 'cw' | 'digi') => {
|
||||
const e = pwr[b.toUpperCase()] ?? { phone: 0, cw: 0, digi: 0 };
|
||||
return (
|
||||
<td className={cn('px-2 py-1.5', kind === 'phone' && 'border-l border-border/60')}>
|
||||
<Input
|
||||
type="number" min={0} max={100}
|
||||
className="h-8 w-16 text-xs"
|
||||
value={e[kind] ? String(e[kind]) : ''}
|
||||
placeholder="—"
|
||||
onChange={(ev) => setPower(b, kind, ev.target.value)}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">FlexRadio — per-band antennas</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose the RX and TX antenna for each band. They're applied automatically when the band
|
||||
changes (frequency change or clicking a spot).
|
||||
</p>
|
||||
<h3 className="text-base font-semibold text-foreground">{t('flxb.title')}</h3>
|
||||
<p className="text-xs text-muted-foreground">{t('flxb.hint')}</p>
|
||||
</div>
|
||||
{rxList.length === 0 && (
|
||||
<div className="text-xs text-warning-muted-foreground bg-warning-muted border border-warning-border rounded-md px-3 py-2 max-w-xl">
|
||||
No antennas reported yet — make sure the FlexRadio is connected (CAT interface), then reopen this panel.
|
||||
<div className="text-xs text-warning-muted-foreground bg-warning-muted border border-warning-border rounded-md px-3 py-2 max-w-2xl">
|
||||
{t('flxb.noRadio')}
|
||||
</div>
|
||||
)}
|
||||
{bands.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No bands configured — add them in Lists → Bands.</p>
|
||||
<p className="text-xs text-muted-foreground">{t('flxpw.noBands')}</p>
|
||||
) : (
|
||||
<div className="rounded-md border border-border overflow-hidden max-w-xl">
|
||||
<div className="rounded-md border border-border overflow-x-auto max-w-3xl">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-muted-foreground text-xs">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-1.5 font-semibold">Band</th>
|
||||
<th className="text-left px-3 py-1.5 font-semibold">RX antenna</th>
|
||||
<th className="text-left px-3 py-1.5 font-semibold">TX antenna</th>
|
||||
<th className="text-left px-3 py-1.5 font-semibold">{t('flxpw.band')}</th>
|
||||
<th className="text-left px-2 py-1.5 font-semibold">{t('flxb.rxAnt')}</th>
|
||||
<th className="text-left px-2 py-1.5 font-semibold">{t('flxb.txAnt')}</th>
|
||||
<th className="text-left px-2 py-1.5 font-semibold border-l border-border/60">{t('flxpw.phone')}</th>
|
||||
<th className="text-left px-2 py-1.5 font-semibold">CW</th>
|
||||
<th className="text-left px-2 py-1.5 font-semibold">{t('flxpw.digi')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bands.map((b) => {
|
||||
const e = map[b.toUpperCase()] ?? { rx: '', tx: '' };
|
||||
return (
|
||||
{bands.map((b) => (
|
||||
<tr key={b} className="border-t border-border/50">
|
||||
<td className="px-3 py-1.5 font-mono font-semibold">{b}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<select value={e.rx ?? ''} onChange={(ev) => set(b, 'rx', ev.target.value)}
|
||||
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
|
||||
<option value="">— none —</option>
|
||||
{rxList.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<select value={e.tx ?? ''} onChange={(ev) => set(b, 'tx', ev.target.value)}
|
||||
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
|
||||
<option value="">— none —</option>
|
||||
{txList.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
{antCell(b, 'rx', rxList)}
|
||||
{antCell(b, 'tx', txList)}
|
||||
{pwrCell(b, 'phone')}
|
||||
{pwrCell(b, 'cw')}
|
||||
{pwrCell(b, 'digi')}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -5591,7 +5631,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
antgenius: AntGeniusPanelSettings,
|
||||
tunergenius: TunerGeniusPanelSettings,
|
||||
pgxl: PGXLPanelSettings,
|
||||
flex: () => <FlexBandAntennasPanel bands={lists.bands ?? []} />,
|
||||
flex: () => <FlexBandPanel bands={lists.bands ?? []} />,
|
||||
audio: AudioPanel,
|
||||
};
|
||||
|
||||
|
||||
@@ -277,7 +277,7 @@ const en: Dict = {
|
||||
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
||||
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
||||
// CAT panel body
|
||||
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: in the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI',
|
||||
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: in the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 power per band and mode', 'flxpw.hint': 'TX power applied when the band or mode changes. Leave a box empty to leave the power alone.', 'flxpw.band': 'Band', 'flxb.title': 'FlexRadio \u2014 per-band antennas and power', 'flxb.hint': 'Antennas are applied when the band changes; power when the band or the mode changes. Leave a power box empty to leave it alone.', 'flxb.rxAnt': 'RX antenna', 'flxb.txAnt': 'TX antenna', 'flxb.noRadio': 'No antennas reported yet \u2014 connect the FlexRadio, then reopen this panel.', 'flxpw.phone': 'Phone', 'flxpw.digi': 'Digital', 'flxpw.noBands': 'No bands configured \u2014 add them in Lists \u2192 Bands.', 'flxpw.saved': 'Saved',
|
||||
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
|
||||
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
|
||||
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
||||
@@ -370,7 +370,7 @@ const en: Dict = {
|
||||
// Awards (ref picker / ref selector / awards panel / award editor)
|
||||
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
|
||||
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
|
||||
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
|
||||
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.modePhone': 'Phone', 'awp.modeDigital': 'Digital', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
|
||||
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
|
||||
'awed.updateAvailable': 'An updated version of this award is available', 'awed.updateOverwrites': 'You have modified this award, so the update was not applied. Taking it replaces your definition and reference list.', 'awed.updateApply': 'Update', 'awed.updateKeepMine': 'Keep mine',
|
||||
'awed.tabTest': 'Test', 'awed.testCallsign': 'Test against callsign', 'awed.testRun': 'Test', 'awed.testSavedOnly': 'Tests the SAVED award — save your changes first.', 'awed.testNoMatch': 'no match', 'awed.testOutOfScope': 'QSO out of scope — no rule was run.', 'awed.testSkipped': 'not run: an earlier rule already matched', 'awed.testFieldValue': 'Field', 'awed.testEmptyField': 'empty', 'awed.testNoCandidate': 'produced no candidate', 'awed.testManual': 'Manual override', 'awed.testSameAs': '+{n} other QSO(s), same result',
|
||||
@@ -678,7 +678,7 @@ const fr: Dict = {
|
||||
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
||||
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
||||
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
||||
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI',
|
||||
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 puissance par bande et par mode', 'flxpw.hint': 'Puissance d\u2019\u00e9mission appliqu\u00e9e au changement de bande ou de mode. Laissez une case vide pour ne pas toucher \u00e0 la puissance.', 'flxpw.band': 'Bande', 'flxb.title': 'FlexRadio \u2014 antennes et puissance par bande', 'flxb.hint': 'Les antennes sont appliqu\u00e9es au changement de bande ; la puissance au changement de bande ou de mode. Laissez une case de puissance vide pour ne pas y toucher.', 'flxb.rxAnt': 'Antenne RX', 'flxb.txAnt': 'Antenne TX', 'flxb.noRadio': 'Aucune antenne remont\u00e9e \u2014 connectez le FlexRadio, puis rouvrez ce panneau.', 'flxpw.phone': 'Phonie', 'flxpw.digi': 'Num\u00e9rique', 'flxpw.noBands': 'Aucune bande configur\u00e9e \u2014 ajoutez-les dans Listes \u2192 Bandes.', 'flxpw.saved': 'Enregistr\u00e9',
|
||||
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
|
||||
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
||||
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
||||
@@ -763,7 +763,7 @@ const fr: Dict = {
|
||||
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
|
||||
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
|
||||
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
|
||||
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
|
||||
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.modePhone': 'Phonie', 'awp.modeDigital': 'Numérique', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
|
||||
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
|
||||
'awed.updateAvailable': 'Une nouvelle version de ce diplôme est disponible', 'awed.updateOverwrites': "Tu as modifié ce diplôme, la mise à jour n'a donc pas été appliquée. L'accepter remplacera ta définition et ta liste de références.", 'awed.updateApply': 'Mettre à jour', 'awed.updateKeepMine': 'Garder les miennes',
|
||||
'awed.tabTest': 'Test', 'awed.testCallsign': 'Tester avec un indicatif', 'awed.testRun': 'Tester', 'awed.testSavedOnly': 'Teste le diplôme ENREGISTRÉ — enregistre tes modifications avant.', 'awed.testNoMatch': 'aucune correspondance', 'awed.testOutOfScope': "QSO hors périmètre — aucune règle n'a été exécutée.", 'awed.testSkipped': "non exécutée : une règle précédente a déjà trouvé", 'awed.testFieldValue': 'Champ', 'awed.testEmptyField': 'vide', 'awed.testNoCandidate': "n'a produit aucun candidat", 'awed.testManual': 'Référence forcée à la main', 'awed.testSameAs': '+{n} autre(s) QSO, même résultat',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// folder (data/) to another machine. These helpers mirror them into the DB
|
||||
// settings table (ui.* keys, like the grid columns) so the whole setup is
|
||||
// identical after a copy.
|
||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||
import { GetUIPref, SetUIPref, LogUIError } from '../../wailsjs/go/main/App';
|
||||
|
||||
// Keys that must travel with data/ (DB is the portable source of truth; the
|
||||
// localStorage copy is just a fast, synchronous cache).
|
||||
@@ -65,5 +65,11 @@ export async function syncPortablePrefs(): Promise<void> {
|
||||
// Use it everywhere these keys are written instead of localStorage.setItem.
|
||||
export function writeUiPref(key: string, value: string): void {
|
||||
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
|
||||
SetUIPref(key, value).catch(() => { /* DB unavailable — the cache still holds it */ });
|
||||
SetUIPref(key, value).catch((e: any) => {
|
||||
// The cache still holds it, so the interface behaves — but the BACKEND
|
||||
// reads some of these keys too (digital-mode grouping decides how the
|
||||
// cluster judges a slot). A write that reaches localStorage and not the
|
||||
// database makes the two disagree, and used to do so in silence.
|
||||
try { LogUIError("ui pref", "could not store " + key + ": " + String(e?.message ?? e), ""); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.22.3';
|
||||
export const APP_VERSION = '0.22.4';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+6
@@ -217,6 +217,8 @@ export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexApplyBandPower(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function FlexBackspaceCW(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexMox(arg1:boolean):Promise<void>;
|
||||
@@ -419,6 +421,8 @@ export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||
|
||||
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
|
||||
|
||||
export function GetFlexBandPower():Promise<Record<string, main.FlexBandPower>>;
|
||||
|
||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||
|
||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||
@@ -849,6 +853,8 @@ export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>
|
||||
|
||||
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
|
||||
|
||||
export function SaveFlexBandPower(arg1:Record<string, main.FlexBandPower>):Promise<void>;
|
||||
|
||||
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||
|
||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||
|
||||
@@ -382,6 +382,10 @@ export function FlexApplyBandAntenna(arg1) {
|
||||
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
|
||||
}
|
||||
|
||||
export function FlexApplyBandPower(arg1, arg2) {
|
||||
return window['go']['main']['App']['FlexApplyBandPower'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function FlexBackspaceCW(arg1) {
|
||||
return window['go']['main']['App']['FlexBackspaceCW'](arg1);
|
||||
}
|
||||
@@ -786,6 +790,10 @@ export function GetFlexBandAntennas() {
|
||||
return window['go']['main']['App']['GetFlexBandAntennas']();
|
||||
}
|
||||
|
||||
export function GetFlexBandPower() {
|
||||
return window['go']['main']['App']['GetFlexBandPower']();
|
||||
}
|
||||
|
||||
export function GetFlexState() {
|
||||
return window['go']['main']['App']['GetFlexState']();
|
||||
}
|
||||
@@ -1646,6 +1654,10 @@ export function SaveFlexBandAntennas(arg1) {
|
||||
return window['go']['main']['App']['SaveFlexBandAntennas'](arg1);
|
||||
}
|
||||
|
||||
export function SaveFlexBandPower(arg1) {
|
||||
return window['go']['main']['App']['SaveFlexBandPower'](arg1);
|
||||
}
|
||||
|
||||
export function SaveListsSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
||||
}
|
||||
|
||||
@@ -506,6 +506,8 @@ export namespace award {
|
||||
worked: boolean;
|
||||
confirmed: boolean;
|
||||
validated: boolean;
|
||||
modes: string[];
|
||||
confirmed_modes: string[];
|
||||
bands: string[];
|
||||
confirmed_bands: string[];
|
||||
validated_bands: string[];
|
||||
@@ -523,6 +525,8 @@ export namespace award {
|
||||
this.worked = source["worked"];
|
||||
this.confirmed = source["confirmed"];
|
||||
this.validated = source["validated"];
|
||||
this.modes = source["modes"];
|
||||
this.confirmed_modes = source["confirmed_modes"];
|
||||
this.bands = source["bands"];
|
||||
this.confirmed_bands = source["confirmed_bands"];
|
||||
this.validated_bands = source["validated_bands"];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+168
-6
@@ -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
|
||||
@@ -45,6 +53,16 @@ type Recorder struct {
|
||||
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
|
||||
@@ -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,13 +335,21 @@ 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).
|
||||
// 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))
|
||||
for i, s := range 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)
|
||||
}
|
||||
}
|
||||
+41
-1
@@ -341,6 +341,12 @@ type Ref struct {
|
||||
Worked bool `json:"worked"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
Validated bool `json:"validated"`
|
||||
// Modes / ConfirmedModes are CLASSES, not ADIF modes: "CW", "PHONE",
|
||||
// "DIGI". An operator asking "which entities have I worked on CW but not
|
||||
// confirmed" does not care whether it was FT8 or RTTY on the digital side,
|
||||
// and a list of twenty mode names would not answer the question they asked.
|
||||
Modes []string `json:"modes"`
|
||||
ConfirmedModes []string `json:"confirmed_modes"`
|
||||
Bands []string `json:"bands"`
|
||||
ConfirmedBands []string `json:"confirmed_bands"`
|
||||
ValidatedBands []string `json:"validated_bands"`
|
||||
@@ -360,11 +366,33 @@ type Result struct {
|
||||
Error string `json:"error,omitempty"` // e.g. bad regexp pattern
|
||||
}
|
||||
|
||||
// ModeClass sorts an ADIF mode into the three classes an operator thinks in:
|
||||
// CW, PHONE, DIGI. Anything unrecognised returns "" and is simply not counted
|
||||
// under any class — better than inventing one, since these classes drive a
|
||||
// filter that decides what the operator is shown.
|
||||
func ModeClass(mode string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "CW", "CWR":
|
||||
return "CW"
|
||||
case "SSB", "USB", "LSB", "AM", "FM", "DV", "PHONE", "DIGITALVOICE":
|
||||
return "PHONE"
|
||||
case "":
|
||||
return ""
|
||||
}
|
||||
// Everything else that a logbook actually carries is a data mode: FT8, FT4,
|
||||
// RTTY, PSK31, JS8, Q65, MSK144, OLIVIA, VARA… Listing them exhaustively
|
||||
// would mean a new mode silently vanishing from the filter the day it
|
||||
// appears, which is worse than treating an unknown data mode as data.
|
||||
return "DIGI"
|
||||
}
|
||||
|
||||
// NameResolver optionally maps a (field, ref) pair to a human name. May be nil.
|
||||
type NameResolver func(field, ref string) string
|
||||
|
||||
type refAgg struct {
|
||||
bands map[string]struct{}
|
||||
modes map[string]struct{}
|
||||
confirmedModes map[string]struct{}
|
||||
confirmedBands map[string]struct{}
|
||||
validatedBands map[string]struct{}
|
||||
anyConfirmed bool
|
||||
@@ -481,22 +509,33 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
||||
continue
|
||||
}
|
||||
band := strings.ToLower(strings.TrimSpace(q.Band))
|
||||
modeClass := ModeClass(q.Mode)
|
||||
isConf := confirmed(q, d.Confirm)
|
||||
isVal := confirmed(q, d.Validate)
|
||||
for _, ref := range refs {
|
||||
a := agg[i][ref]
|
||||
if a == nil {
|
||||
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}, validatedBands: map[string]struct{}{}}
|
||||
a = &refAgg{
|
||||
bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{},
|
||||
validatedBands: map[string]struct{}{},
|
||||
modes: map[string]struct{}{}, confirmedModes: map[string]struct{}{},
|
||||
}
|
||||
agg[i][ref] = a
|
||||
}
|
||||
if band != "" {
|
||||
a.bands[band] = struct{}{}
|
||||
}
|
||||
if modeClass != "" {
|
||||
a.modes[modeClass] = struct{}{}
|
||||
}
|
||||
if isConf {
|
||||
a.anyConfirmed = true
|
||||
if band != "" {
|
||||
a.confirmedBands[band] = struct{}{}
|
||||
}
|
||||
if modeClass != "" {
|
||||
a.confirmedModes[modeClass] = struct{}{}
|
||||
}
|
||||
}
|
||||
if isVal {
|
||||
a.anyValidated = true
|
||||
@@ -525,6 +564,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
||||
r.Validated++
|
||||
}
|
||||
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
|
||||
Modes: setToSorted(a.modes), ConfirmedModes: setToSorted(a.confirmedModes),
|
||||
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands), ValidatedBands: setToSorted(a.validatedBands)}
|
||||
labelRef(&rf, d, ref, rl, hasList, nameOf)
|
||||
r.Refs = append(r.Refs, rf)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package award
|
||||
|
||||
import "testing"
|
||||
|
||||
// The three classes an operator thinks in — the ones the awards filter offers.
|
||||
//
|
||||
// "Which entities have I worked on CW but not confirmed" is the question this
|
||||
// serves, so the classes must match how the question is asked: CW, phone, data.
|
||||
// Which data mode it was does not enter into it.
|
||||
func TestModeClass(t *testing.T) {
|
||||
for _, c := range []struct{ mode, want string }{
|
||||
{"CW", "CW"}, {"cw", "CW"}, {" CW ", "CW"}, {"CWR", "CW"},
|
||||
{"SSB", "PHONE"}, {"USB", "PHONE"}, {"LSB", "PHONE"},
|
||||
{"AM", "PHONE"}, {"FM", "PHONE"}, {"DV", "PHONE"},
|
||||
{"FT8", "DIGI"}, {"FT4", "DIGI"}, {"RTTY", "DIGI"}, {"PSK31", "DIGI"},
|
||||
{"JS8", "DIGI"}, {"Q65", "DIGI"}, {"OLIVIA", "DIGI"},
|
||||
// A mode nobody has heard of yet is data, not nothing: the alternative
|
||||
// is a new digital mode silently vanishing from the filter the day it
|
||||
// appears.
|
||||
{"SOMETHINGNEW", "DIGI"},
|
||||
// No mode at all is not a class, and must not be counted as one.
|
||||
{"", ""}, {" ", ""},
|
||||
} {
|
||||
if got := ModeClass(c.mode); got != c.want {
|
||||
t.Errorf("ModeClass(%q) = %q, want %q", c.mode, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
-4
@@ -36,6 +36,11 @@ type Flex struct {
|
||||
gotHandle bool
|
||||
|
||||
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,12 +994,57 @@ func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
||||
firstInUse = idx
|
||||
}
|
||||
if s.active {
|
||||
chosen = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
if firstInUse >= 0 {
|
||||
return firstInUse, f.slices[firstInUse]
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,10 @@ type Decoder struct {
|
||||
pendHops int // consecutive hops the raw state has disagreed
|
||||
|
||||
// Two-cluster element timing (ms).
|
||||
// muLetterGap is this operator's typical gap BETWEEN LETTERS (ms), so the
|
||||
// word boundary can follow a wide fist instead of chopping up callsigns.
|
||||
// Zero until enough gaps have been seen.
|
||||
muLetterGap float64
|
||||
muDit, muDah float64
|
||||
marksSeen int
|
||||
|
||||
@@ -195,6 +199,19 @@ const (
|
||||
|
||||
charGapDits = 2.2 // gap > this ⇒ character boundary (geom. mean of 1 & 3 ≈ 1.7, plus margin for sloppy fists)
|
||||
wordGapDits = 4.6 // gap > this ⇒ word boundary (geom. mean of 3 & 7)
|
||||
|
||||
// newOverFloorMs is the shortest silence that may end an over — see newOverMs.
|
||||
// Past it the speed estimate is dropped: the next voice on the frequency is
|
||||
// probably somebody else, and the last operator's speed is not evidence
|
||||
// about them. Two seconds is far longer than any word gap (7 dits is 0.42 s
|
||||
// even at 10 wpm) and far shorter than a pause between overs.
|
||||
newOverFloorMs = 600.0
|
||||
|
||||
// wordGapRatio places the word boundary relative to the letter gaps this
|
||||
// operator ACTUALLY sends, for fists whose letter spacing runs wide. The
|
||||
// fixed 4.6-dit rule turned a 5-dit letter gap into a word, so a callsign
|
||||
// arrived as "O Y 1 C T" — which is worse than two words run together.
|
||||
wordGapRatio = 1.6
|
||||
)
|
||||
|
||||
// New builds a decoder for the given sample rate. onChar receives decoded text
|
||||
@@ -260,6 +277,7 @@ func (d *Decoder) Reset() {
|
||||
d.stableHops, d.pendHops = 0, 0
|
||||
d.bankTick, d.betterHops = 0, 0
|
||||
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
|
||||
d.muLetterGap = 0
|
||||
d.elemMs = d.elemMs[:0]
|
||||
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
|
||||
}
|
||||
@@ -578,6 +596,30 @@ func (d *Decoder) endMark(hops int) {
|
||||
// steadier than dits in hand keying).
|
||||
func (d *Decoder) endSpace(hops int) {
|
||||
ms := float64(hops)*d.hopMs + d.biasMs // gaps shrink by what marks gained
|
||||
|
||||
// A long silence ends the over — see newOverMs. Forget the speed: coming back at 14 wpm
|
||||
// after following someone at 32, every element of the newcomer measured
|
||||
// longer than the stale dah threshold and the first words decoded as a run
|
||||
// of T's. Starting from the seed instead, the estimate re-converges within a
|
||||
// character — which is exactly how a freshly started decoder behaves, and
|
||||
// that case was always fine.
|
||||
if d.marksSeen > 0 && ms > d.newOverMs() {
|
||||
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
|
||||
d.muLetterGap = 0
|
||||
d.elemMs = d.elemMs[:0]
|
||||
return
|
||||
}
|
||||
|
||||
// Letter gaps: everything between a character boundary and a word boundary.
|
||||
// Kept so the word boundary can follow this operator's own spacing.
|
||||
if d.marksSeen > 0 && ms > charGapDits*d.muDit && ms < wordGapDits*d.muDit*2 {
|
||||
if d.muLetterGap == 0 {
|
||||
d.muLetterGap = ms
|
||||
} else {
|
||||
d.muLetterGap += (ms - d.muLetterGap) * 0.2
|
||||
}
|
||||
}
|
||||
|
||||
if d.marksSeen < 1 || ms < 0.35*d.muDit || ms > 1.7*d.muDit {
|
||||
return
|
||||
}
|
||||
@@ -592,6 +634,40 @@ func (d *Decoder) endSpace(hops int) {
|
||||
d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
|
||||
}
|
||||
|
||||
// newOverMs is the silence above which the speed estimate is dropped.
|
||||
//
|
||||
// It cannot be a fixed duration: 0.75 s is a quick turnaround at 30 wpm but is
|
||||
// barely a word gap at 10 wpm (7 dits = 0.84 s). So it scales with the current
|
||||
// estimate, with a floor so a fast operator pausing to think is not mistaken
|
||||
// for a new station on every hesitation.
|
||||
func (d *Decoder) newOverMs() float64 {
|
||||
if v := 12 * d.muDit; v > newOverFloorMs {
|
||||
return v
|
||||
}
|
||||
return newOverFloorMs
|
||||
}
|
||||
|
||||
// wordGapMs is the silence above which a word boundary is declared.
|
||||
//
|
||||
// The textbook answer is 4.6 dits — the geometric mean of a 3-dit letter gap
|
||||
// and a 7-dit word gap. It assumes the operator sends textbook spacing. Many do
|
||||
// not: a fist that leaves 5 dits between letters had every letter turned into a
|
||||
// word, so callsigns arrived in pieces.
|
||||
//
|
||||
// So the boundary also follows the letter gaps actually observed. Whichever is
|
||||
// larger wins: a textbook fist keeps the textbook boundary, a wide one gets a
|
||||
// wider boundary. The cost is that a wide sender's words may run together —
|
||||
// which is a far smaller price than a callsign broken into letters.
|
||||
func (d *Decoder) wordGapMs() float64 {
|
||||
fixed := wordGapDits * d.muDit
|
||||
if d.muLetterGap > 0 {
|
||||
if adaptive := wordGapRatio * d.muLetterGap; adaptive > fixed {
|
||||
return adaptive
|
||||
}
|
||||
}
|
||||
return fixed
|
||||
}
|
||||
|
||||
// spaceProgress emits the pending character / word space LIVE once the current
|
||||
// gap crosses each boundary (instead of waiting for the next mark), so text
|
||||
// appears as it is sent.
|
||||
@@ -601,7 +677,7 @@ func (d *Decoder) spaceProgress() {
|
||||
d.flushChar()
|
||||
d.charEmitted = true
|
||||
}
|
||||
if !d.wordEmitted && gapMs > wordGapDits*d.muDit {
|
||||
if !d.wordEmitted && gapMs > d.wordGapMs() {
|
||||
if d.textSince && d.onChar != nil {
|
||||
d.onChar(" ")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package cwdecode
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// keyLoose keys a message with SLIGHTLY WIDE letter spacing — the way a great
|
||||
// many operators actually send. letterGapDits replaces the standard 3.
|
||||
//
|
||||
// Same envelope shaping as keyMessage: hard edges would make an easier signal
|
||||
// than anything on the air, and would prove nothing.
|
||||
func keyLoose(msg string, fs, wpm int, pitch, amp float64, letterGapDits float64) []int16 {
|
||||
dot := fs * 1200 / (wpm * 1000)
|
||||
edge := fs * 5 / 1000
|
||||
c2m := charToMorse()
|
||||
var out []float64
|
||||
phase := 0.0
|
||||
dphi := 2 * math.Pi * pitch / float64(fs)
|
||||
|
||||
tone := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
g := 1.0
|
||||
if i < edge {
|
||||
g = 0.5 - 0.5*math.Cos(math.Pi*float64(i)/float64(edge))
|
||||
} else if n-1-i < edge {
|
||||
g = 0.5 - 0.5*math.Cos(math.Pi*float64(n-1-i)/float64(edge))
|
||||
}
|
||||
out = append(out, amp*g*math.Sin(phase))
|
||||
phase += dphi
|
||||
}
|
||||
}
|
||||
silence := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, 0)
|
||||
phase += dphi
|
||||
}
|
||||
}
|
||||
|
||||
silence(fs / 4)
|
||||
for i := 0; i < len(msg); i++ {
|
||||
ch := msg[i]
|
||||
if ch == ' ' {
|
||||
silence(4 * dot)
|
||||
continue
|
||||
}
|
||||
code := c2m[ch]
|
||||
for j := 0; j < len(code); j++ {
|
||||
if code[j] == '.' {
|
||||
tone(dot)
|
||||
} else {
|
||||
tone(3 * dot)
|
||||
}
|
||||
silence(dot)
|
||||
}
|
||||
// The element gap above already contributes one dit.
|
||||
silence(int((letterGapDits - 1) * float64(dot)))
|
||||
}
|
||||
silence(fs / 2)
|
||||
return toInt16(out)
|
||||
}
|
||||
|
||||
// The first character of an over must decode, not arrive as "?".
|
||||
//
|
||||
// Reported on the air: "the first letter or digit often turns into ?". Each
|
||||
// element is classified against the running dit estimate, and at the start of
|
||||
// an over that estimate belongs to whatever was decoded last — a different
|
||||
// operator, at a different speed. The first character pays for it.
|
||||
func TestFirstCharacterOfAnOver(t *testing.T) {
|
||||
const fs = 16000
|
||||
for _, wpm := range []int{15, 22, 30} {
|
||||
got := decode(t, keyMessage("F4BPO DE OY1CT K", fs, wpm, 700, 9000), 0)
|
||||
if strings.HasPrefix(got, "?") {
|
||||
t.Errorf("@%d wpm: decoded %q — the first character was lost", wpm, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A callsign must not be broken up when the sender's letter spacing is a little
|
||||
// wide.
|
||||
//
|
||||
// Reported on the air: "sometimes there are spaces inside the call". A word
|
||||
// boundary is declared above 4.6 dits of silence, so an operator whose letter
|
||||
// gaps run to 4.5 dits has every letter turned into a word of its own.
|
||||
func TestWideLetterSpacingDoesNotSplitTheCall(t *testing.T) {
|
||||
const fs = 16000
|
||||
for _, gap := range []float64{3.5, 4.0, 4.5} {
|
||||
got := decode(t, keyLoose("DE OY1CT K", fs, 20, 700, 9000, gap), 0)
|
||||
for _, split := range []string{"O Y", "Y 1", "1 C", "C T"} {
|
||||
if strings.Contains(got, split) {
|
||||
t.Errorf("letter gap %.1f dits: decoded %q — the callsign was broken at %q", gap, got, split)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decodeSeq runs several transmissions through the SAME decoder, as happens on
|
||||
// the air: the estimate carried into an over is whatever the previous station
|
||||
// left behind.
|
||||
func decodeSeq(t *testing.T, parts ...[]int16) []string {
|
||||
t.Helper()
|
||||
var cur strings.Builder
|
||||
d := New(16000, func(s string) { cur.WriteString(s) }, nil)
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
cur.Reset()
|
||||
for i := 0; i < len(p); i += 256 {
|
||||
end := i + 256
|
||||
if end > len(p) {
|
||||
end = len(p)
|
||||
}
|
||||
d.Process(p[i:end])
|
||||
}
|
||||
out = append(out, strings.ToUpper(cur.String()))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A fast station, then a slow one — the real reason the first character is
|
||||
// lost. A fresh decoder starts from a neutral estimate and adapts within a
|
||||
// character or two; a decoder that has just followed someone at 30 wpm judges
|
||||
// the newcomer's first dits against 30 wpm.
|
||||
func TestFirstCharacterAfterASpeedChange(t *testing.T) {
|
||||
const fs = 16000
|
||||
got := decodeSeq(t,
|
||||
keyMessage("CQ CQ DE DL1ABC K", fs, 32, 700, 9000),
|
||||
keyMessage("DL1ABC DE OY1CT K", fs, 14, 700, 9000),
|
||||
)
|
||||
if strings.HasPrefix(got[1], "?") {
|
||||
t.Errorf("after 32→14 wpm: %q — the first character of the reply was lost", got[1])
|
||||
}
|
||||
if !strings.Contains(got[1], "OY1CT") {
|
||||
t.Errorf("after 32→14 wpm: %q — want the callsign intact", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Hand-sent letter gaps run wide. Above 4.6 dits every letter becomes a word,
|
||||
// so a callsign arrives in pieces.
|
||||
func TestVeryWideLetterSpacing(t *testing.T) {
|
||||
const fs = 16000
|
||||
for _, gap := range []float64{5.0, 5.5, 6.0} {
|
||||
got := decode(t, keyLoose("DE OY1CT K", fs, 18, 700, 9000, gap), 0)
|
||||
if strings.Contains(got, "O Y") || strings.Contains(got, "Y 1") || strings.Contains(got, "1 C") {
|
||||
t.Errorf("letter gap %.1f dits: decoded %q — the callsign was broken up", gap, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2506,6 +2506,23 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearQRZConfirmed takes back a QRZ confirmation.
|
||||
//
|
||||
// Needed because OpsLog set some wrongly: it read qrzcom_qso_download_status,
|
||||
// which QRZ puts on everything it hands back, as QRZ's confirmation. Those
|
||||
// QSOs count towards award slots, so leaving them green until the operator
|
||||
// notices is not an option — and nothing else in the app would ever undo them.
|
||||
func (r *Repo) ClearQRZConfirmed(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET qrzcom_qso_download_status = 'N', qrzcom_qso_download_date = NULL,
|
||||
updated_at = ? WHERE id = ?`,
|
||||
db.NowISO(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clear qrz confirmed %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkEQSLConfirmed stamps EQSL_QSL_RCVD=Y and the received date on a QSO after
|
||||
// an eQSL Inbox download. date is an ADIF YYYYMMDD string.
|
||||
func (r *Repo) MarkEQSLConfirmed(ctx context.Context, id int64, date string) error {
|
||||
|
||||
+24
-7
@@ -6,12 +6,22 @@ import (
|
||||
"hamlog/internal/adif"
|
||||
)
|
||||
|
||||
// What counts as a QRZ.com confirmation.
|
||||
// What counts as a QRZ.com confirmation: app_qrzlog_status = C, and nothing else.
|
||||
//
|
||||
// Reported 2026-07-29: after a confirmation download, every QSO turned to Y. The
|
||||
// test accepted qsl_rcvd = Y — but that is the operator's own PAPER QSL flag,
|
||||
// which they uploaded to QRZ themselves, so every QSO with a card came back
|
||||
// looking QRZ-confirmed. On a log full of paper QSLs that is most of it.
|
||||
// Narrowed twice, each time on evidence from an operator's own log.
|
||||
//
|
||||
// 2026-07-29 — qsl_rcvd = Y was accepted. That is the operator's own PAPER QSL
|
||||
// flag, which they uploaded to QRZ themselves, so every QSO with a card came
|
||||
// back looking QRZ-confirmed.
|
||||
//
|
||||
// 2026-07-31 — qrzcom_qso_download_status = Y was accepted, and this test said
|
||||
// it should be. A fetch showed both fields on ONE record:
|
||||
//
|
||||
// <app_qrzlog_status:1>N <qrzcom_qso_download_status:1>Y
|
||||
//
|
||||
// QRZ says not confirmed and sets the download field anyway: it marks what was
|
||||
// handed back, not what was confirmed. Eighteen QSOs, all "UPDATED", all green,
|
||||
// none of them confirmed on QRZ.
|
||||
//
|
||||
// This status feeds the award slots, so a false Y is a QSO counted as confirmed
|
||||
// when it is not — the reason the rule is narrow rather than generous.
|
||||
@@ -21,8 +31,7 @@ func TestQRZRecordConfirmed(t *testing.T) {
|
||||
rec adif.Record
|
||||
want bool
|
||||
}{
|
||||
// QRZ's own statements.
|
||||
{"QRZ download status Y", adif.Record{"qrzcom_qso_download_status": "Y"}, true},
|
||||
// QRZ's own statement.
|
||||
{"QRZ log status C", adif.Record{"app_qrzlog_status": "C"}, true},
|
||||
{"lower case is still QRZ's answer", adif.Record{"app_qrzlog_status": "c"}, true},
|
||||
{"padded", adif.Record{"app_qrzlog_status": " C "}, true},
|
||||
@@ -33,6 +42,14 @@ func TestQRZRecordConfirmed(t *testing.T) {
|
||||
{"LoTW confirmed, QRZ silent", adif.Record{"lotw_qsl_rcvd": "Y"}, false},
|
||||
{"eQSL confirmed, QRZ silent", adif.Record{"eqsl_qsl_rcvd": "Y"}, false},
|
||||
|
||||
// The download flag is not an answer: QRZ sets it on everything it returns.
|
||||
{"downloaded, not confirmed", adif.Record{"qrzcom_qso_download_status": "Y"}, false},
|
||||
{"the record from the report", adif.Record{
|
||||
"call": "IQ4J", "band": "17m", "mode": "FT8",
|
||||
"qrzcom_qso_upload_status": "Y", "app_qrzlog_status": "N",
|
||||
"qrzcom_qso_download_date": "20260731", "qrzcom_qso_download_status": "Y",
|
||||
}, false},
|
||||
|
||||
// Explicit negatives and nothing at all.
|
||||
{"QRZ says no", adif.Record{"qrzcom_qso_download_status": "N"}, false},
|
||||
{"QRZ status not confirmed", adif.Record{"app_qrzlog_status": "N"}, false},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.22.3"
|
||||
appVersion = "0.22.4"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user