Compare commits

..
18 Commits
Author SHA1 Message Date
rouggy 06e34372ed fix: a hand-assigned award reference no longer loses to the matcher
Reported on the DARC DOK award, with DL2FDM: a German address matches two or
three DOKs, the operator deletes the wrong ones and assigns the right one, and
the next recompute puts the wrong ones straight back.

The manual reference was APPENDED to what the matcher found. Storage already
kept exactly one manual entry per award — setOverrideRef drops the previous one
— so it was an override everywhere except at the one place it mattered. The
operator was arguing with the matcher and could not win.

It now replaces. The trace records what was superseded, so a corrected QSO can
still be explained rather than being merely different. A QSO with no correction
keeps every automatic reference, which the test also pins: an override must not
quietly disable matching for everyone else.

This does not address the over-matching itself — an address containing "Berlin
Mitte, 10115 Berlin" genuinely matches two DOK descriptions. That is the next
question, and a separate one.
2026-08-01 00:57:34 +02:00
rouggy d23c4c4037 fix: RST fields too narrow for a 59+30 report
I took the callsign field's extra width from the RST pair, on the assumption
that five characters was the most they would ever hold. A signal report of
"59+30" is exactly five characters plus its padding, and it was clipped.

The RST fields are back to their original width. The callsign keeps its extra
room — the row has space for both.
2026-08-01 00:50:48 +02:00
rouggy d4bfd30636 fix: serial CAT keyed the radio on connect; Xiegu can key on RTS/DTR
Reported on a Xiegu G90. With the blue programming cable — three wires, no
modem lines — CAT works perfectly. Behind a DE-19, which carries audio, CAT and
PTT, the radio went into transmit the moment OpsLog connected and stayed there.

Windows raises DTR and RTS when a serial port is opened, and the DE-19 reads
them as PTT; Xiegu's own documentation asks for both low. The CI-V backend has
dropped them since it was written, for the same reason on Icom rigs with USB
SEND mapped to a line. Xiegu, Yaesu and Kenwood did not. They do now.

The second half of the report: WSJT-X through OpsLog's rigctld decoded fine and
never transmitted. Nothing was broken in that chain — set_ptt reaches the
backend, which sends the CI-V PTT command, which a G90 ignores. That is why
Xiegu keys on a hardware line instead. The backend can now do that: Settings →
CAT → Xiegu → how the rig is keyed (CI-V / RTS / DTR).

Untested here — no G90 in reach. The operator who reported it offered to try.
2026-07-31 22:16:03 +02:00
rouggy 258fa717f1 docs: give 0.22.6 its own changelog block
Nine entries had been appended to the 0.22.5 block after 0.22.5 was already out,
so what shipped as 0.22.6 had no block at all — and the release script said so:
"changelog.json has no 'fr' entries for 0.22.6 - skipping". No Discord
announcement, and no What's-new for anyone updating.

The split is not guesswork: the changelog at tag v0.22.5 carried exactly one
entry, so everything after it belongs to 0.22.6.
2026-07-31 22:06:19 +02:00
rouggy aa995c250c chore: release v0.22.6 2026-07-31 22:04:09 +02:00
rouggy 72fee7a090 fix: a quiet cluster was treated as a dead one and reconnected
Reported by an operator whose node sends little: nothing for two minutes, then a
reconnect — losing the login, the filters, and any spot that arrived during the
gap.

The read had a 120 s deadline and ANY error ended the session, a timeout
included. That reads a silence as a verdict on the link. It is not: a cluster on
a dead band legitimately says nothing for minutes.

Only a real error ends a session now. A genuinely dead peer is still caught, and
by the mechanism meant for it — TCP keepalive probes an idle connection and its
failure surfaces as an error on Read, not as a timeout. The dialer sets it
explicitly rather than inheriting a default, since it is now what the design
depends on.

One trap the advice this came from did not mention: ReadString returns the bytes
it HAD read along with the timeout. Discarding them would lose the first half of
any spot line straddling a deadline, so the partial is carried over.

Tested: a listener that goes silent past the deadline and then sends a spot —
the spot arrives on the ORIGINAL connection, and the listener never accepts a
second one. The tick is a var so that test runs in four seconds instead of
sixty; a slow test is a test that gets skipped.
2026-07-31 21:48:05 +02:00
rouggy ed67ed7fe3 fix: replaying a QSO used the voice keyer's level and overdrove the rig
A panadapter shot settles it: answering an S5 station, the replayed recording
goes out several times wider and taller than the station being answered — in WAV
as well as MP3, so this is not the encoder.

One setting served two sources that have nothing in common. A voice-keyer
message is a microphone at speaking distance and legitimately wants 195%; a QSO
recording is a receiver's line output, which is far hotter. The same 195% put it
into the transmitter flat out, ALC pinned, noise and voice alike.

The QSO playback now has its own level, defaulting to 100%. The voice keyer
keeps the setting it had, so nobody's F-key messages change.
2026-07-31 21:32:55 +02:00
rouggy fddb3c45c4 fix: MP3 recordings carried a hiss the WAV of the same audio did not
The operator split it in one test: WAV is fine, MP3 is not. The MP3 path is the
only one that resamples — 16 kHz up to 32 kHz, because the encoder emits broken
frames at MPEG-2 rates — and it did so by averaging neighbouring samples.

That is an upsampler in name only. Every component at f is mirrored to
16 kHz − f, and the test measures the mirror just 7 dB below the wanted signal:
a second copy of the whole spectrum. On speech it adds brightness; on receiver
noise, which is broadband, it lays a second noise floor over the top of the
band. Hence "a hiss that is not the QRM, louder than the voice" — and hence its
absence from the WAV, which resamples nothing.

Zero-stuffing plus a 63-tap windowed sinc at the old Nyquist puts the image
56.8 dB down instead of 7, at a cost measured in microseconds against the MP3
encode that follows. Two tests pin it: the image suppression, and that the level
is unchanged — a fix that quietly halved every recording would be a second
surprise on top of the one being repaired.
2026-07-31 21:27:57 +02:00
rouggy da1f3eb2bd fix: say which devices a recording is captured from
A station with two radios has two sets of audio endpoints, and the recorder will
happily capture the one nobody is listening to — a Flex DAX channel while the
operator works a Yaesu over USB. The file is then full of hiss with no relation
to what they hear, unaffected by the rig's AGC, and nothing anywhere said which
receiver it came from.

The device names are now logged, resolved to their friendly names, once when
capture starts.
2026-07-31 21:20:35 +02:00
rouggy 5e0bb6e68e fix: recordings buried under hiss — the cheap resampler was folding it in
Reported precisely: on the air the voice is well clear of the noise; in the
recording the noise is louder than the voice. Same audio, so the fault is in how
it is captured, not in what is captured.

AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY was set alongside AUTOCONVERTPCM. The
name reads like a sensible default and is not: it selects the CHEAP sample-rate
converter, for cases where quality does not matter. Taking a rig's 48 kHz stream
down to our 16 kHz, it folds everything above 8 kHz back into the audio band —
and receiver hiss is mostly high frequencies. The voice keeps its level, the
noise arrives twice.

Dropped, so Windows uses its normal converter, which filters before decimating.
This affects every capture and render path in the app, the voice keyer included.
2026-07-31 21:12:59 +02:00
rouggy 2b61f43780 fix: a level set by the slider was lost when a recording started
The monitor followed the slider, but two takes made at 10% and 300% came out
identical: starting a recording re-reads the STORED settings and calls SetGains
with them, so the live value was overwritten the moment a take began.

The slider now persists as it applies. Saving Settings still works as before —
this only means the stored value is already right when a take starts, which is
what every path downstream reads.
2026-07-31 21:04:20 +02:00
rouggy 2b66e5bc7f fix: the audio level sliders did nothing until Settings were saved
Reported again after the monitor was wired up: 300% still sounded like 10%. The
monitor fix was right but could not show, because the sliders only changed the
settings panel's own state — the value reached the audio engine when the
operator clicked Save.

A level is set by ear, by dragging. It now applies on every move, to the monitor
and to the recorder mix alike; saving still persists it. Both halves were needed
and neither works alone.
2026-07-31 20:58:40 +02:00
rouggy 84b06ed47b fix: padlocks unclickable, From-radio level inert, callsign field too narrow
Padlocks. LockBtn was declared INSIDE the App component, so every render gave
React a new component type and the button was unmounted and rebuilt — about four
times a second while the CAT polls. It flickered under the pointer and swallowed
clicks, because the node being clicked no longer existed when the click landed.
The padlock now lives at module level and is passed its state.

From-radio level. It reached the QSO recorder and nothing else, so an operator
listening through OpsLog heard no difference between 10% and 150% — the control
was not weak, it was disconnected. It now scales the monitor too, applied on the
captured chunk so the network-fed path keeps its own levels, and a running
monitor picks up a change immediately: making someone restart the monitor to
hear the slider is how a working control gets reported as broken.

Callsign field widened to w-56, the room coming from the RST pair which never
needs more than five characters. It is the one field always typed into, it
carries the REC badges, and a long portable call has to stay readable.
2026-07-31 20:33:39 +02:00
rouggy 39bd1ff414 feat: the play button stops the transmission while it is going out
A second press restarted the message from the beginning. What the operator wants
there is to cut it short — enough heard, or pressed by mistake with the
transmitter keyed — and then to be able to send it again at once.

It now shows a stop square while playing and releases the PTT through the same
path the natural end uses, so nothing is left keyed. Together with the device
handover fixed a moment ago, sending again immediately afterwards works.
2026-07-31 20:23:03 +02:00
rouggy 6d54100bc9 fix: a new playback raced the previous one for the audio device
Reported precisely: press play again and it stops the current message, but you
then have to wait the whole length of the message before it will play — otherwise
the radio is keyed and the PTT released at once.

Play only SIGNALLED the previous playback to stop and started a new one straight
away. The old goroutine still held the WASAPI render client for a moment, so the
new client could not start; it returned immediately and the PTT tail was all that
was left. The log now shows both halves of that: a play line, then a release
120 ms later.

Stopping now waits for the goroutine to have returned — the point of stopping
before starting again — bounded at 1.5 s so a wedged device cannot freeze the
operator's click, and saying so in the log if it ever comes to that.
2026-07-31 19:05:09 +02:00
rouggy 4fe5811fd2 fix: a playback that never starts no longer fails silently
From an operator's log, playing a recording on the air repeatedly:

  18:52:40.148 play → 18:52:48.420 PTT released   8.2 s, the whole take
  18:52:57.759 play → 18:52:57.879 PTT released   0.12 s, nothing came out

Those 120 ms are the PTT release tail alone: the render device refused to start
and playPCM returned an error that was thrown away. The call reported success
every time, which is what "it plays once and then never again" looks like from
the outside — and why the search went to the recorder, which was innocent.

The error is now logged with the device name.
2026-07-31 18:55:48 +02:00
rouggy d42e6669e1 fix: name the field when playback has no output device
An operator emptied "Recording mic" and got "no audio output to the radio is
configured", and read it as a restriction: that a recording of the other station
alone cannot be played back. No such rule exists — the mic is an INPUT, one more
source to record, while this message is about the OUTPUT that feeds the
transmitter.

The message now names the setting exactly as the panel labels it, and the log
line carries the other two device values so the next report says which field was
actually empty.
2026-07-31 18:50:48 +02:00
rouggy c14353a399 test: playing a recording must not consume it
Reported: the recording plays once and never again, "as if it threw the sound
away after playing it". PeekQSO is what the playback reads and it is meant to
copy — TakeQSO is the one that clears — so this pins that, and that the copy is
independent of the recorder's own buffer.

It passes, which is the useful part: the take survives, so the one-shot failure
is downstream of it and the search moves to the file and the player.
2026-07-31 18:45:49 +02:00
23 changed files with 835 additions and 80 deletions
+90 -7
View File
@@ -114,6 +114,7 @@ const (
keyCATXieguPort = "cat.xiegu.port" // Xiegu CI-V serial port (G90/X6100…) keyCATXieguPort = "cat.xiegu.port" // Xiegu CI-V serial port (G90/X6100…)
keyCATXieguBaud = "cat.xiegu.baud" // Xiegu CI-V baud (G90 default 19200) keyCATXieguBaud = "cat.xiegu.baud" // Xiegu CI-V baud (G90 default 19200)
keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70) keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70)
keyCATXieguPTTLine = "cat.xiegu.ptt_line" // "" | rts | dtr — G90 does not key over CI-V
keyCATYaesuPort = "cat.yaesu.port" // Yaesu CAT serial port (e.g. COM4) keyCATYaesuPort = "cat.yaesu.port" // Yaesu CAT serial port (e.g. COM4)
keyCATYaesuBaud = "cat.yaesu.baud" // Yaesu CAT baud (FTDX10/101 default 38400) keyCATYaesuBaud = "cat.yaesu.baud" // Yaesu CAT baud (FTDX10/101 default 38400)
keyCATKenwoodHost = "cat.kenwood.host" // Kenwood CAT over a network serial bridge (ser2net), "host:port" keyCATKenwoodHost = "cat.kenwood.host" // Kenwood CAT over a network serial bridge (ser2net), "host:port"
@@ -141,6 +142,15 @@ const (
keyAudioQSODir = "audio.qso_dir" // folder for QSO recordings keyAudioQSODir = "audio.qso_dir" // folder for QSO recordings
keyAudioPreroll = "audio.preroll_seconds" // rolling-buffer pre-roll length keyAudioPreroll = "audio.preroll_seconds" // rolling-buffer pre-roll length
keyAudioTXGain = "audio.tx_gain" // voice-keyer playback level % (100 = as recorded) keyAudioTXGain = "audio.tx_gain" // voice-keyer playback level % (100 = as recorded)
// Replaying a QSO recording needs its OWN level, and by a wide margin.
//
// A voice-keyer message is a microphone at speaking distance; a QSO
// recording is a receiver's line output, which is far hotter. Sharing one
// setting meant the 195% an operator needs for their F-key messages was
// also applied to a recording, which then hit the transmitter flat out —
// visible on the panadapter as a signal several times the size of the S5
// station being answered.
keyAudioQSOPlayGain = "audio.qso_play_gain" // QSO-recording playback level %
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr" keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
keyAudioFormat = "audio.qso_format" // "wav" | "mp3" keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
@@ -361,6 +371,7 @@ type CATSettings struct {
XieguPort string `json:"xiegu_port"` // Xiegu CI-V serial port (G90/X6100…) XieguPort string `json:"xiegu_port"` // Xiegu CI-V serial port (G90/X6100…)
XieguBaud int `json:"xiegu_baud"` // Xiegu CI-V baud (G90 default 19200) XieguBaud int `json:"xiegu_baud"` // Xiegu CI-V baud (G90 default 19200)
XieguAddr int `json:"xiegu_addr"` // Xiegu CI-V address (factory 0x70) XieguAddr int `json:"xiegu_addr"` // Xiegu CI-V address (factory 0x70)
XieguPTTLine string `json:"xiegu_ptt_line"` // "", "rts" or "dtr" — how the rig is keyed
YaesuPort string `json:"yaesu_port"` // Yaesu CAT serial port (e.g. COM4) YaesuPort string `json:"yaesu_port"` // Yaesu CAT serial port (e.g. COM4)
YaesuBaud int `json:"yaesu_baud"` // Yaesu CAT baud (FTDX10/101 default 38400) YaesuBaud int `json:"yaesu_baud"` // Yaesu CAT baud (FTDX10/101 default 38400)
KenwoodHost string `json:"kenwood_host"` // "host:port" of a serial-over-network bridge (ser2net, Ethernet-serial KenwoodHost string `json:"kenwood_host"` // "host:port" of a serial-over-network bridge (ser2net, Ethernet-serial
@@ -1220,6 +1231,15 @@ func (a *App) startup(ctx context.Context) {
} }
}) })
a.qsoRec = audio.NewRecorder() a.qsoRec = audio.NewRecorder()
if a.audioMgr != nil {
// A running monitor picks the new level up immediately: the operator is
// listening while they move the slider, and asking them to stop and
// restart it to hear the change is how a working control gets reported
// as broken.
if cfg, err := a.GetAudioSettings(); err == nil {
a.audioMgr.SetMonitorGain(cfg.FromGain)
}
}
a.startQSORecorderIfEnabled() a.startQSORecorderIfEnabled()
// NET Control store (global JSON, shared across logbooks). // NET Control store (global JSON, shared across logbooks).
@@ -6730,7 +6750,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil { if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
} }
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort) m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
if err != nil { if err != nil {
return CATSettings{}, err return CATSettings{}, err
} }
@@ -6782,6 +6802,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if n, _ := strconv.Atoi(m[keyCATXieguBaud]); n > 0 { if n, _ := strconv.Atoi(m[keyCATXieguBaud]); n > 0 {
out.XieguBaud = n out.XieguBaud = n
} }
out.XieguPTTLine = m[keyCATXieguPTTLine]
if n, _ := strconv.Atoi(m[keyCATXieguAddr]); n > 0 && n <= 0xFF { if n, _ := strconv.Atoi(m[keyCATXieguAddr]); n > 0 && n <= 0xFF {
out.XieguAddr = n out.XieguAddr = n
} }
@@ -6902,6 +6923,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATXieguPort: strings.TrimSpace(s.XieguPort), keyCATXieguPort: strings.TrimSpace(s.XieguPort),
keyCATXieguBaud: strconv.Itoa(s.XieguBaud), keyCATXieguBaud: strconv.Itoa(s.XieguBaud),
keyCATXieguAddr: strconv.Itoa(s.XieguAddr), keyCATXieguAddr: strconv.Itoa(s.XieguAddr),
keyCATXieguPTTLine: strings.ToLower(strings.TrimSpace(s.XieguPTTLine)),
keyCATYaesuPort: strings.TrimSpace(s.YaesuPort), keyCATYaesuPort: strings.TrimSpace(s.YaesuPort),
keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud), keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud),
keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort), keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort),
@@ -6949,6 +6971,7 @@ type AudioSettings struct {
FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100 FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100
MicGain int `json:"mic_gain"` // mic mix level %, default 100 MicGain int `json:"mic_gain"` // mic mix level %, default 100
TXGain int `json:"tx_gain"` // voice-keyer playback level %, default 100 TXGain int `json:"tx_gain"` // voice-keyer playback level %, default 100
QSOPlayGain int `json:"qso_play_gain"` // QSO-recording playback level %, default 100
} }
// ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints // ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints
@@ -6958,14 +6981,14 @@ func (a *App) ListAudioOutputDevices() ([]audio.Device, error) { return audio.Li
// GetAudioSettings returns the stored audio config (preroll defaults to 8s). // GetAudioSettings returns the stored audio config (preroll defaults to 8s).
func (a *App) GetAudioSettings() (AudioSettings, error) { func (a *App) GetAudioSettings() (AudioSettings, error) {
out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav", FromGain: 100, MicGain: 100, TXGain: 100} out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav", FromGain: 100, MicGain: 100, TXGain: 100, QSOPlayGain: 100}
if a.settings == nil { if a.settings == nil {
return out, nil return out, nil
} }
m, err := a.settings.GetMany(a.ctx, m, err := a.settings.GetMany(a.ctx,
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice, keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat, keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat,
keyAudioFromGain, keyAudioMicGain, keyAudioTXGain) keyAudioFromGain, keyAudioMicGain, keyAudioTXGain, keyAudioQSOPlayGain)
if err != nil { if err != nil {
return out, err return out, err
} }
@@ -6996,6 +7019,9 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
if n, _ := strconv.Atoi(m[keyAudioTXGain]); n > 0 && n <= 400 { if n, _ := strconv.Atoi(m[keyAudioTXGain]); n > 0 && n <= 400 {
out.TXGain = n out.TXGain = n
} }
if n, _ := strconv.Atoi(m[keyAudioQSOPlayGain]); n > 0 && n <= 400 {
out.QSOPlayGain = n
}
return out, nil return out, nil
} }
@@ -7027,6 +7053,9 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
} }
// Up to 400 %: a mic recorded quietly needs real amplification, and the // Up to 400 %: a mic recorded quietly needs real amplification, and the
// clamp in the player keeps it from wrapping into noise. // clamp in the player keeps it from wrapping into noise.
if s.QSOPlayGain <= 0 || s.QSOPlayGain > 400 {
s.QSOPlayGain = 100
}
if s.TXGain <= 0 || s.TXGain > 400 { if s.TXGain <= 0 || s.TXGain > 400 {
s.TXGain = 100 s.TXGain = 100
} }
@@ -7044,6 +7073,7 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
keyAudioFromGain: strconv.Itoa(s.FromGain), keyAudioFromGain: strconv.Itoa(s.FromGain),
keyAudioMicGain: strconv.Itoa(s.MicGain), keyAudioMicGain: strconv.Itoa(s.MicGain),
keyAudioTXGain: strconv.Itoa(s.TXGain), keyAudioTXGain: strconv.Itoa(s.TXGain),
keyAudioQSOPlayGain: strconv.Itoa(s.QSOPlayGain),
} { } {
if err := a.settings.Set(a.ctx, k, v); err != nil { if err := a.settings.Set(a.ctx, k, v); err != nil {
return err return err
@@ -7051,6 +7081,12 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
} }
// Apply device/preroll/enable changes to the running recorder. // Apply device/preroll/enable changes to the running recorder.
a.startQSORecorderIfEnabled() a.startQSORecorderIfEnabled()
// And to a monitor ALREADY RUNNING: the operator is listening while they
// move the slider. Making them stop and restart the monitor to hear the
// change is how a working control gets reported as doing nothing.
if a.audioMgr != nil {
a.audioMgr.SetMonitorGain(s.FromGain)
}
return nil return nil
} }
@@ -7737,8 +7773,13 @@ func (a *App) QSOAudioPlayOnAir() error {
// The recorder needs an INPUT from the radio; playing back needs an // 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 // OUTPUT into it, which is a different device and may well be unset on a
// station that only ever recorded. // station that only ever recorded.
applog.Printf("qso-rec: play refused — no output device to the radio configured (Settings → Audio)") // Name the FIELD, not the concept. An operator who had just emptied
return fmt.Errorf("no audio output to the radio is configured — set it in Settings → Audio") // "Recording mic" read this as "you cannot play back unless you also
// record yourself" — a restriction that does not exist. The mic is an
// input; this is about the output that feeds the transmitter.
applog.Printf("qso-rec: play refused — Settings → Audio → \"To Radio (TX out)\" is empty (from_radio=%q, mic=%q)",
cfgEarly.FromRadio, cfgEarly.RecordingDevice)
return fmt.Errorf("Settings → Audio → \"To Radio (TX out)\" is empty — that is the output that feeds the transmitter (the recording mic has nothing to do with playing back)")
} }
// A plain WAV in the data dir, overwritten each time: the player takes a // 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 // path, and MP3 encoding would add seconds to something the operator is
@@ -7757,7 +7798,7 @@ func (a *App) QSOAudioPlayOnAir() error {
a.dvkPttKeyed = true a.dvkPttKeyed = true
a.pttMu.Unlock() a.pttMu.Unlock()
} }
if err := a.audioMgr.Play(cfg.ToRadio, path, cfg.TXGain); err != nil { if err := a.audioMgr.Play(cfg.ToRadio, path, cfg.QSOPlayGain); err != nil {
applog.Printf("qso-rec: playback on %q failed: %v", cfg.ToRadio, err) applog.Printf("qso-rec: playback on %q failed: %v", cfg.ToRadio, err)
a.pttMu.Lock() a.pttMu.Lock()
keyed := a.dvkPttKeyed keyed := a.dvkPttKeyed
@@ -8881,6 +8922,42 @@ func (a *App) DVKStop() {
} }
} }
// AudioApplyLevels applies the RX and mic levels IMMEDIATELY, without saving.
//
// A level is set by ear, by dragging: the sliders only touched the settings
// panel state, so the value reached the audio engine when the operator clicked
// Save — and while they were listening and dragging, 10%% sounded exactly like
// 300%%. The control was not weak, it was not connected to anything yet.
//
// Persisting still happens on Save. This is the live half.
func (a *App) AudioApplyLevels(fromPct, micPct int) {
// PERSIST as well as apply. Starting a recording re-reads the stored
// settings and calls SetGains with them, so a live-only value was thrown
// away the moment a take began: the monitor followed the slider while two
// recordings made at 10%% and 300%% came out identical.
if a.settings != nil {
if fromPct > 0 {
_ = a.settings.Set(a.ctx, keyAudioFromGain, strconv.Itoa(fromPct))
}
if micPct > 0 {
_ = a.settings.Set(a.ctx, keyAudioMicGain, strconv.Itoa(micPct))
}
}
if a.audioMgr != nil {
a.audioMgr.SetMonitorGain(fromPct)
}
if a.qsoRec != nil {
f, m := float64(fromPct)/100, float64(micPct)/100
if fromPct <= 0 {
f = 1
}
if micPct <= 0 {
m = 1
}
a.qsoRec.SetGains(f, m)
}
}
// AudioStartMonitor pipes live RX audio from the rig into your speakers so you // AudioStartMonitor pipes live RX audio from the rig into your speakers so you
// hear the radio inside OpsLog. Source = the "From radio" capture device (for a // hear the radio inside OpsLog. Source = the "From radio" capture device (for a
// USB-connected rig, its "USB Audio CODEC" input); sink = the "Listening" // USB-connected rig, its "USB Audio CODEC" input); sink = the "Listening"
@@ -8895,6 +8972,7 @@ func (a *App) AudioStartMonitor() error {
return fmt.Errorf(`no "From radio" capture device set — pick the rig's USB Audio CODEC in Settings → Audio`) return fmt.Errorf(`no "From radio" capture device set — pick the rig's USB Audio CODEC in Settings → Audio`)
} }
applog.Printf("audio: RX monitor start (from=%q → listen=%q)", cfg.FromRadio, cfg.ListeningDevice) applog.Printf("audio: RX monitor start (from=%q → listen=%q)", cfg.FromRadio, cfg.ListeningDevice)
a.audioMgr.SetMonitorGain(cfg.FromGain)
return a.audioMgr.StartMonitor(cfg.FromRadio, cfg.ListeningDevice) return a.audioMgr.StartMonitor(cfg.FromRadio, cfg.ListeningDevice)
} }
@@ -12669,7 +12747,12 @@ func (a *App) reloadCAT() {
// Xiegu G90/X6100/X6200/X5105 — CI-V, but a REDUCED command set: no scope, // Xiegu G90/X6100/X6200/X5105 — CI-V, but a REDUCED command set: no scope,
// no DSP block, no data mode. Its own backend rather than the Icom one at // no DSP block, no data mode. Its own backend rather than the Icom one at
// another address, so we never poll a G90 for answers it cannot give. // another address, so we never poll a G90 for answers it cannot give.
a.cat.Start(cat.NewXiegu(s.XieguPort, s.XieguBaud, s.XieguAddr, s.DigitalDefault)) xg := cat.NewXiegu(s.XieguPort, s.XieguBaud, s.XieguAddr, s.DigitalDefault)
// A G90 ignores the CI-V PTT command; its interfaces key on a hardware
// line instead. Selected here so rigctld — and therefore WSJT-X — can
// actually put the rig on the air.
xg.SetPTTLine(s.XieguPTTLine)
a.cat.Start(xg)
case "yaesu": case "yaesu":
// Native Yaesu CAT over the rig's USB/serial port — no OmniRig. Every // Native Yaesu CAT over the rig's USB/serial port — no OmniRig. Every
// Yaesu fault reported so far came from OmniRig's interpretation layer // Yaesu fault reported so far came from OmniRig's interpretation layer
+40
View File
@@ -1,4 +1,44 @@
[ [
{
"version": "0.22.7",
"date": "",
"en": [
"Serial CAT no longer keys the radio just by connecting: DTR and RTS are dropped on Xiegu, Yaesu and Kenwood as they already were on Icom — a Xiegu G90 behind a DE-19 went straight into transmit and stayed there.",
"Xiegu: choose how the rig is keyed — CI-V command, RTS or DTR. A G90 does not transmit on the CI-V command, so this is also what lets WSJT-X transmit through the shared CAT link.",
"A reference you assign by hand now replaces what the matcher found instead of being added to it, so a correction survives the next recompute."
],
"fr": [
"Le CAT série ne met plus la radio en émission par le seul fait de se connecter : DTR et RTS sont abaissés sur Xiegu, Yaesu et Kenwood comme ils l'étaient déjà sur Icom — un Xiegu G90 derrière un DE-19 partait en émission et y restait.",
"Xiegu : choix du passage en émission — commande CI-V, RTS ou DTR. Un G90 n'émet pas sur la commande CI-V, c'est donc aussi ce qui permet à WSJT-X d'émettre via le partage CAT.",
"Une référence que vous affectez à la main remplace désormais ce que la détection avait trouvé au lieu de s'y ajouter : une correction survit au recalcul suivant."
]
},
{
"version": "0.22.6",
"date": "",
"en": [
"A playback that fails to start now says so in the log instead of ending silently after a tenth of a second.",
"Replaying a recording before the previous one has finished now works: the new playback waits for the audio device to be free instead of keying the radio and releasing at once.",
"The play button becomes a stop button while the recording is going out: it cuts the transmission and releases the PTT, and you can send it again straight away.",
"The \"From radio\" and mic levels take effect as you drag the slider, and the RX level now applies to what you HEAR through OpsLog, not only to recordings.",
"The padlocks on frequency, band, mode and times no longer flicker under the pointer and refuse the click.",
"The callsign field is wider, taking the room from the RST pair.",
"MP3 recordings no longer carry a hiss of their own: the 16→32 kHz conversion mirrored the whole spectrum only 7 dB down. It is now 57 dB down.",
"Replaying a QSO recording on the air has its own level, separate from the voice keyer: a recording comes from a receiver line output and needs far less gain than a message spoken into a microphone.",
"A quiet DX cluster no longer looks like a broken one: silence stopped counting as a disconnection, so a node with few spots keeps its session, its login and its filters."
],
"fr": [
"Une lecture qui ne démarre pas le signale désormais dans le journal, au lieu de s'arrêter en silence au bout d'un dixième de seconde.",
"Relancer un enregistrement avant la fin du précédent fonctionne désormais : la nouvelle lecture attend que le périphérique audio soit libre, au lieu de passer en émission et de relâcher aussitôt.",
"Le bouton de lecture devient un bouton d'arrêt pendant l'émission de l'enregistrement : il coupe l'émission et relâche le PTT, et vous pouvez le renvoyer aussitôt.",
"Les niveaux « From radio » et micro agissent pendant que vous déplacez le curseur, et le niveau RX s'applique désormais à ce que vous ENTENDEZ dans OpsLog, pas seulement aux enregistrements.",
"Les cadenas de fréquence, bande, mode et heures ne scintillent plus sous le curseur et acceptent le clic.",
"Le champ indicatif est plus large, la place venant de la paire RST.",
"Les enregistrements MP3 ne portent plus un souffle qui leur est propre : la conversion 16→32 kHz recopiait tout le spectre à seulement 7 dB en dessous. Il est désormais à 57 dB.",
"La relecture d'un enregistrement de QSO a son propre niveau, distinct du manipulateur vocal : une prise vient de la sortie ligne d'un récepteur et demande bien moins de gain qu'un message dit au micro.",
"Un cluster calme ne passe plus pour un cluster mort : le silence ne compte plus comme une déconnexion, donc un nœud avec peu de spots garde sa session, son login et ses filtres."
]
},
{ {
"version": "0.22.5", "version": "0.22.5",
"date": "", "date": "",
+52 -30
View File
@@ -393,6 +393,33 @@ function WindowControls() {
); );
} }
// LockPad — the padlock toggle shown in a lockable field's label. Its icon
// matches the state, so a glance says which fields are immune to CAT updates
// and to the live clock.
//
// It lives HERE, at module level, and not inside App. Defined inside, React saw
// a new component type on every render of App — and App re-renders about four
// times a second while the CAT polls — so the button was unmounted and rebuilt
// each time. It flickered under the pointer and swallowed clicks, because the
// node being clicked no longer existed by the time the click landed.
function LockPad({ on, title, onToggle }: { on: boolean; title: string; onToggle: () => void }) {
const Icon = on ? Lock : Unlock;
return (
<button
type="button"
tabIndex={-1}
onClick={onToggle}
title={`${on ? 'Unlock' : 'Lock'} ${title}`}
className={cn(
'inline-flex items-center justify-center size-3.5 rounded transition-colors',
on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground',
)}
>
<Icon className="size-3" />
</button>
);
}
export default function App() { export default function App() {
const { t, lang } = useI18n(); const { t, lang } = useI18n();
// === Lists from settings (fallback for first paint) === // === Lists from settings (fallback for first paint) ===
@@ -455,27 +482,6 @@ export default function App() {
return next; return next;
}); });
}; };
// Small padlock toggle rendered inside each lockable field's label. Match
// the icon to the current state so the user can tell at a glance which
// fields are immune to CAT updates / live clock.
function LockBtn({ k, title }: { k: LockKey; title: string }) {
const on = locks[k];
const Icon = on ? Lock : Unlock;
return (
<button
type="button"
tabIndex={-1}
onClick={() => toggleLock(k)}
title={`${on ? 'Unlock' : 'Lock'} ${title}`}
className={cn(
'inline-flex items-center justify-center size-3.5 rounded transition-colors',
on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground',
)}
>
<Icon className="size-3" />
</button>
);
}
const [band, setBand] = useState('20m'); const [band, setBand] = useState('20m');
const [mode, setMode] = useState('SSB'); const [mode, setMode] = useState('SSB');
const [freqMhz, setFreqMhz] = useState(''); const [freqMhz, setFreqMhz] = useState('');
@@ -794,7 +800,15 @@ export default function App() {
const resumeRecording = () => { const resumeRecording = () => {
QSOAudioResume().then((ok) => { if (ok) { setRecStopped(false); setRecTick((t) => t + 1); } }).catch(() => {}); QSOAudioResume().then((ok) => { if (ok) { setRecStopped(false); setRecTick((t) => t + 1); } }).catch(() => {});
}; };
// One button, two jobs: play, and stop what is playing.
//
// A second press used to RESTART the message from the beginning. What an
// operator wants there is to cut it short — they have heard enough, or they
// pressed it by mistake with the transmitter keyed — and then to be able to
// send it again straight away. Stopping releases the PTT through the same
// path the natural end uses.
const playRecordingOnAir = () => { const playRecordingOnAir = () => {
if (dvkStat.playing) { DVKStop(); return; }
QSOAudioPlayOnAir().catch((e: any) => setError(String(e?.message ?? e))); QSOAudioPlayOnAir().catch((e: any) => setError(String(e?.message ?? e)));
}; };
const refreshManualRecReady = () => { QSOAudioManualReady().then(setManualRecReady).catch(() => {}); }; const refreshManualRecReady = () => { QSOAudioManualReady().then(setManualRecReady).catch(() => {}); };
@@ -3702,8 +3716,15 @@ export default function App() {
// single-row strip or the full Log4OM-style columnar layout below. Keeping // single-row strip or the full Log4OM-style columnar layout below. Keeping
// them as shared consts avoids duplicating the (large) per-field JSX + // them as shared consts avoids duplicating the (large) per-field JSX +
// handlers across the two layouts. // handlers across the two layouts.
// The callsign field is WIDER than the fields around it, deliberately: it is
// the one field always typed into, it carries the REC badges on its right, and
// a long portable call (VK9/OH1ABC/MM) must stay readable as it is entered.
// It was widened by taking room from the RST pair — until a signal report of
// "59+30" turned up, which is five characters plus its padding and no longer
// fitted. The RST fields are back to their original width; the callsign keeps
// the rest of the row.
const callsignBlock = ( const callsignBlock = (
<div className="flex flex-col w-44" data-esm="call"> <div className="flex flex-col w-56" data-esm="call">
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}> <Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
<span className="text-primary font-semibold">{t('field.callsign')}</span> <span className="text-primary font-semibold">{t('field.callsign')}</span>
{lookupBusy && ( {lookupBusy && (
@@ -3775,10 +3796,11 @@ export default function App() {
type="button" type="button"
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={playRecordingOnAir} onClick={playRecordingOnAir}
title={t('rec.playOnAir')} title={dvkStat.playing ? t('rec.stopPlaying') : t('rec.playOnAir')}
className="inline-flex items-center justify-center size-4 rounded text-success hover:bg-success/15" className={cn('inline-flex items-center justify-center size-4 rounded',
dvkStat.playing ? 'text-destructive hover:bg-destructive-muted' : 'text-success hover:bg-success/15')}
> >
{dvkStat.playing ? <span className="size-2 bg-current" /> : '▶'}
</button> </button>
)} )}
<button <button
@@ -3888,7 +3910,7 @@ export default function App() {
) : null; ) : null;
const startBlock = ( const startBlock = (
<div className="flex flex-col w-28"> <div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockBtn k="start" title="start time" /></Label> <Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockPad on={locks.start} title="start time" onToggle={() => toggleLock('start')} /></Label>
<Input <Input
readOnly={!locks.start} readOnly={!locks.start}
tabIndex={locks.start ? 0 : -1} tabIndex={locks.start ? 0 : -1}
@@ -3907,7 +3929,7 @@ export default function App() {
); );
const endBlock = ( const endBlock = (
<div className="flex flex-col w-28"> <div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')} <LockBtn k="end" title="end time" /></Label> <Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')} <LockPad on={locks.end} title="end time" onToggle={() => toggleLock('end')} /></Label>
<Input <Input
readOnly={!locks.end} readOnly={!locks.end}
tabIndex={locks.end ? 0 : -1} tabIndex={locks.end ? 0 : -1}
@@ -4097,7 +4119,7 @@ export default function App() {
// used in the full layout to save vertical height. // used in the full layout to save vertical height.
const bandRow = ( const bandRow = (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')} <LockBtn k="band" title="band" /></Label> <Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')} <LockPad on={locks.band} title="band" onToggle={() => toggleLock('band')} /></Label>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Select value={band} onValueChange={onBandUserChange}> <Select value={band} onValueChange={onBandUserChange}>
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger> <SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
@@ -4108,7 +4130,7 @@ export default function App() {
); );
const modeRow = ( const modeRow = (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')} <LockBtn k="mode" title="mode" /></Label> <Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')} <LockPad on={locks.mode} title="mode" onToggle={() => toggleLock('mode')} /></Label>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Select value={mode} onValueChange={onModeUserChange}> <Select value={mode} onValueChange={onModeUserChange}>
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger> <SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
@@ -4164,7 +4186,7 @@ export default function App() {
}; };
const freqBlock = ( const freqBlock = (
<div className="flex flex-col w-32"> <div className="flex flex-col w-32">
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockBtn k="freq" title="frequency" /></Label> <Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockPad on={locks.freq} title="frequency" onToggle={() => toggleLock('freq')} /></Label>
<Input <Input
tabIndex={-1} tabIndex={-1}
className="font-mono" className="font-mono"
+25 -6
View File
@@ -16,7 +16,7 @@ import {
GetTunerGeniusSettings, SaveTunerGeniusSettings, GetTunerGeniusSettings, SaveTunerGeniusSettings,
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate, GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT, GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty, GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
GetClublogMostWantedInfo, SetClublogMostWantedEnabled, DownloadClublogMostWanted, GetClublogMostWantedInfo, SetClublogMostWantedEnabled, DownloadClublogMostWanted,
GetSecretStatus, SetPassphrase, RemovePassphrase, GetSecretStatus, SetPassphrase, RemovePassphrase,
@@ -1136,7 +1136,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [modeDraft, setModeDraft] = useState(''); const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({ const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
yaesu_port: '', yaesu_baud: 38400, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, yaesu_port: '', yaesu_baud: 38400, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false, icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8', share_enabled: false, share_port: 4532, digital_default: 'FT8', share_enabled: false, share_port: 4532,
@@ -1198,13 +1198,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
from_radio: string; to_radio: string; recording_device: string; listening_device: string; from_radio: string; to_radio: string; recording_device: string; listening_device: string;
qso_record: boolean; qso_dir: string; preroll_seconds: number; qso_record: boolean; qso_dir: string; preroll_seconds: number;
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3'; ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
from_gain: number; mic_gain: number; tx_gain: number; from_gain: number; mic_gain: number; tx_gain: number; qso_play_gain: number;
}; };
type AudioDev = { id: string; name: string; default: boolean }; type AudioDev = { id: string; name: string; default: boolean };
const [audioCfg, setAudioCfg] = useState<AudioSettings>({ const [audioCfg, setAudioCfg] = useState<AudioSettings>({
from_radio: '', to_radio: '', recording_device: '', listening_device: '', from_radio: '', to_radio: '', recording_device: '', listening_device: '',
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav', qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
from_gain: 100, mic_gain: 100, tx_gain: 100, from_gain: 100, mic_gain: 100, tx_gain: 100, qso_play_gain: 100,
}); });
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]); const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]); const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
@@ -2538,6 +2538,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}} /> }} />
<span className="text-xs text-muted-foreground">{t('cat.xieguAddrHint')}</span> <span className="text-xs text-muted-foreground">{t('cat.xieguAddrHint')}</span>
</div> </div>
<div className="space-y-1">
<Label>{t('cat.xieguPTTLine')}</Label>
<Select value={catCfg.xiegu_ptt_line || 'civ'}
onValueChange={(v) => setCatCfg((s) => ({ ...s, xiegu_ptt_line: v === 'civ' ? '' : v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="civ">{t('cat.xieguPTTCiv')}</SelectItem>
<SelectItem value="rts">RTS</SelectItem>
<SelectItem value="dtr">DTR</SelectItem>
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">{t('cat.xieguPTTHint')}</span>
</div>
</> </>
)} )}
{catCfg.backend === 'yaesu' && ( {catCfg.backend === 'yaesu' && (
@@ -5115,13 +5128,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Label className="text-sm">{t('aud.fromLevel')}</Label> <Label className="text-sm">{t('aud.fromLevel')}</Label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.from_gain} <input type="range" min={10} max={300} step={5} value={audioCfg.from_gain}
onChange={(e) => setAudioField({ from_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" /> onChange={(e) => { const v = parseInt(e.target.value, 10); setAudioField({ from_gain: v }); AudioApplyLevels(v, audioCfg.mic_gain); }} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span> <span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
</div> </div>
<Label className="text-sm">{t('aud.qsoPlayLevel')}</Label>
<div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.qso_play_gain}
onChange={(e) => setAudioField({ qso_play_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.qso_play_gain}%</span>
</div>
<Label className="text-sm">{t('aud.micLevel')}</Label> <Label className="text-sm">{t('aud.micLevel')}</Label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain} <input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain}
onChange={(e) => setAudioField({ mic_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" /> onChange={(e) => { const v = parseInt(e.target.value, 10); setAudioField({ mic_gain: v }); AudioApplyLevels(audioCfg.from_gain, v); }} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span> <span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span>
</div> </div>
</div> </div>
+6 -6
View File
@@ -135,7 +135,7 @@ const en: Dict = {
'imp.ctyDesc': "Recompute Country, DXCC & CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF → Reunion, TO2A 2012 → French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use Update duplicates to re-fix QSOs already in your log.", 'imp.ctyDesc': "Recompute Country, DXCC & CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF → Reunion, TO2A 2012 → French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use Update duplicates to re-fix QSOs already in your log.",
'imp.stationTitle': 'Fill my station fields from my profile', 'imp.stationTitle': 'Fill my station fields from my profile',
'imp.stationDesc': "Backfill empty MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus Operator and Owner callsign from your active profile. Existing values are kept. Only STATION_CALLSIGN is left untouched so a mixed-call log isn't re-routed. It ALSO stamps your default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com sent and received) on the ones the file leaves empty — a WSJT-X log carries almost none. Enable when importing your own log.", 'imp.stationDesc': "Backfill empty MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus Operator and Owner callsign from your active profile. Existing values are kept. Only STATION_CALLSIGN is left untouched so a mixed-call log isn't re-routed. It ALSO stamps your default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com sent and received) on the ones the file leaves empty — a WSJT-X log carries almost none. Enable when importing your own log.",
'imp.cancel': 'Cancel', 'rec.manualStart': 'Record this contact. Automatic recording is off, so capture starts now \u2014 there is no pre-roll of what came before.', 'rec.stop': 'Stop the recording. The audio is kept and still saved with the QSO \u2014 stop it to play it back to the station you are working.', 'rec.resume': 'Carry on recording, appending to what is already captured.', 'rec.playOnAir': 'TRANSMIT the recording to the station you are working: keys the radio and sends it like a voice-keyer message.', 'rec.manualFailed': 'Recording could not be started \u2014 check the audio devices in Settings.', 'imp.mapAdd': 'The file puts a field in the wrong place\u2026', 'imp.mapTitle': 'Move fields on import', 'imp.mapDesc': 'Contest software stores the exchange where its own module keeps it. The RSGB IOTA contest exports the island reference in STATE \u2014 imported as-is it becomes a US state and the IOTA award stays empty. The destination is only filled where the file left it blank.', 'imp.mapMore': 'Add another', 'imp.import': 'Import', 'imp.cancel': 'Cancel', 'rec.manualStart': 'Record this contact. Automatic recording is off, so capture starts now \u2014 there is no pre-roll of what came before.', 'rec.stop': 'Stop the recording. The audio is kept and still saved with the QSO \u2014 stop it to play it back to the station you are working.', 'rec.resume': 'Carry on recording, appending to what is already captured.', 'rec.playOnAir': 'TRANSMIT the recording to the station you are working: keys the radio and sends it like a voice-keyer message.', 'rec.stopPlaying': 'Stop transmitting the recording', 'rec.manualFailed': 'Recording could not be started \u2014 check the audio devices in Settings.', 'imp.mapAdd': 'The file puts a field in the wrong place\u2026', 'imp.mapTitle': 'Move fields on import', 'imp.mapDesc': 'Contest software stores the exchange where its own module keeps it. The RSGB IOTA contest exports the island reference in STATE \u2014 imported as-is it becomes a US state and the IOTA award stays empty. The destination is only filled where the file left it blank.', 'imp.mapMore': 'Add another', 'imp.import': 'Import',
'imp.progressTitle': 'Importing ADIF…', 'bulk.progressTitle': 'Updating the selected QSOs\u2026', 'imp.progressTitle': 'Importing ADIF…', 'bulk.progressTitle': 'Updating the selected QSOs\u2026',
'imp.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…', 'imp.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…',
'imp.complete': 'Import complete.', 'imp.complete': 'Import complete.',
@@ -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 12 min delay so a mis-logged QSO can still be fixed first).', '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 12 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 m6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 1354 MHz (20 m6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer', '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 m6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 1354 MHz (20 m6 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 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', '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.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.xieguPTTLine': 'How the rig is keyed', 'cat.xieguPTTCiv': 'CI-V command', 'cat.xieguPTTHint': 'A G90 does not transmit on the CI-V command: interfaces like the DE-19 key it on RTS or DTR. Pick the line yours uses \u2014 it is also what lets WSJT-X transmit through the shared CAT link.', '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.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.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)', 'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
@@ -424,7 +424,7 @@ const en: Dict = {
'aud.txOn': 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.', 'aud.txHint': 'Live mic → rig with PTT (USB now; network TX later).', 'aud.txOn': 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.', 'aud.txHint': 'Live mic → rig with PTT (USB now; network TX later).',
'aud.recorder': 'QSO recorder', 'aud.recordEvery': 'Record every QSO to an audio file (From Radio + your mic)', 'aud.recFolder': 'Recordings folder', 'aud.browse': 'Browse…', 'aud.recorder': 'QSO recorder', 'aud.recordEvery': 'Record every QSO to an audio file (From Radio + your mic)', 'aud.recFolder': 'Recordings folder', 'aud.browse': 'Browse…',
'aud.preroll': 'Pre-roll (seconds)', 'aud.format': 'File format', 'aud.wav': 'WAV (lossless, larger)', 'aud.mp3': 'MP3 (compressed, small)', 'aud.preroll': 'Pre-roll (seconds)', 'aud.format': 'File format', 'aud.wav': 'WAV (lossless, larger)', 'aud.mp3': 'MP3 (compressed, small)',
'aud.fromLevel': 'From Radio level', 'aud.txLevel': 'Voice keyer level', 'aud.txLevelHint': 'Level of the recorded messages sent to the radio. Raise it if your voice keyer is much quieter than your microphone; Play previews at this same level. If the radio transmits almost nothing, its modulation source is still the front microphone: on an FTDX10 set MENU → SSB MOD SOURCE to REAR (the USB input).', 'aud.micLevel': 'Mic level', 'aud.levelHint': 'If your voice is louder than the station, lower Mic level.', 'aud.fromLevel': 'From Radio level', 'aud.txLevel': 'Voice keyer level', 'aud.txLevelHint': 'Level of the recorded messages sent to the radio. Raise it if your voice keyer is much quieter than your microphone; Play previews at this same level. If the radio transmits almost nothing, its modulation source is still the front microphone: on an FTDX10 set MENU → SSB MOD SOURCE to REAR (the USB input).', 'aud.micLevel': 'Mic level', 'aud.qsoPlayLevel': 'QSO playback level', 'aud.levelHint': 'If your voice is louder than the station, lower Mic level.',
'aud.autoSend': 'Auto-send the recording to the station by e-mail when I log a QSO', 'aud.autoSend': 'Auto-send the recording to the station by e-mail when I log a QSO',
'aud.dvkTitle': 'Voice keyer messages (F1F6)', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR', 'aud.dvkTitle': 'Voice keyer messages (F1F6)', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR',
'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh', 'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh',
@@ -546,7 +546,7 @@ const fr: Dict = {
'imp.ctyDesc': "Recalcule Pays, DXCC & zones CQ/ITU depuis cty.dat, en écrasant le fichier — corrige ce que les logiciels de contest exportent mal (ex. RG2Y en Russie asiatique au lieu d'européenne). Les exceptions DXpédition de ClubLog s'appliquent par-dessus selon la date du QSO (ex. TO974REF → Réunion, TO2A 2012 → Guyane française) dès que les données ClubLog sont téléchargées. Tout le reste de l'ADIF est conservé tel quel. Astuce : utilise Mettre à jour les doublons pour re-corriger des QSO déjà dans le log.", 'imp.ctyDesc': "Recalcule Pays, DXCC & zones CQ/ITU depuis cty.dat, en écrasant le fichier — corrige ce que les logiciels de contest exportent mal (ex. RG2Y en Russie asiatique au lieu d'européenne). Les exceptions DXpédition de ClubLog s'appliquent par-dessus selon la date du QSO (ex. TO974REF → Réunion, TO2A 2012 → Guyane française) dès que les données ClubLog sont téléchargées. Tout le reste de l'ADIF est conservé tel quel. Astuce : utilise Mettre à jour les doublons pour re-corriger des QSO déjà dans le log.",
'imp.stationTitle': 'Remplir mes champs station depuis mon profil', 'imp.stationTitle': 'Remplir mes champs station depuis mon profil',
'imp.stationDesc': "Complète les champs MY_* vides (locator, rig, antenne, adresse, ville, état, comté, réf. SOTA/POTA, puissance TX…) plus Opérateur et Indicatif propriétaire depuis le profil actif. Les valeurs existantes sont conservées. Seul STATION_CALLSIGN n'est jamais touché pour ne pas re-router un log multi-indicatifs. Elle applique AUSSI tes statuts de confirmation par défaut (QSL papier, LoTW, eQSL, Club Log, HRDLog, QRZ.com envoyé et reçu) sur ceux que le fichier laisse vides — un log WSJT-X n'en contient pratiquement aucun. À activer quand tu importes ton propre log.", 'imp.stationDesc': "Complète les champs MY_* vides (locator, rig, antenne, adresse, ville, état, comté, réf. SOTA/POTA, puissance TX…) plus Opérateur et Indicatif propriétaire depuis le profil actif. Les valeurs existantes sont conservées. Seul STATION_CALLSIGN n'est jamais touché pour ne pas re-router un log multi-indicatifs. Elle applique AUSSI tes statuts de confirmation par défaut (QSL papier, LoTW, eQSL, Club Log, HRDLog, QRZ.com envoyé et reçu) sur ceux que le fichier laisse vides — un log WSJT-X n'en contient pratiquement aucun. À activer quand tu importes ton propre log.",
'imp.cancel': 'Annuler', 'rec.manualStart': 'Enregistrer ce contact. L\u2019enregistrement automatique est d\u00e9sactiv\u00e9 : la capture commence maintenant, sans les secondes qui pr\u00e9c\u00e8dent.', 'rec.stop': 'Arr\u00eater l\u2019enregistrement. L\u2019audio est conserv\u00e9 et sera enregistr\u00e9 avec le QSO \u2014 arr\u00eatez-le pour le repasser \u00e0 la station travaill\u00e9e.', 'rec.resume': 'Reprendre l\u2019enregistrement, \u00e0 la suite de ce qui est d\u00e9j\u00e0 captur\u00e9.', 'rec.playOnAir': '\u00c9METTRE l\u2019enregistrement vers la station travaill\u00e9e : passe la radio en \u00e9mission et l\u2019envoie comme un message du manipulateur vocal.', 'rec.manualFailed': 'L\u2019enregistrement n\u2019a pas pu d\u00e9marrer \u2014 v\u00e9rifiez les p\u00e9riph\u00e9riques audio dans les R\u00e9glages.', 'imp.mapAdd': 'Le fichier met un champ au mauvais endroit\u2026', 'imp.mapTitle': 'D\u00e9placer des champs \u00e0 l\u2019import', 'imp.mapDesc': 'Les logiciels de concours rangent l\u2019\u00e9change l\u00e0 o\u00f9 leur module le garde. Le contest IOTA de la RSGB exporte la r\u00e9f\u00e9rence d\u2019\u00eele dans STATE \u2014 import\u00e9e telle quelle elle devient un \u00e9tat am\u00e9ricain et le dipl\u00f4me IOTA reste vide. La destination n\u2019est remplie que l\u00e0 o\u00f9 le fichier l\u2019a laiss\u00e9e vide.', 'imp.mapMore': 'Ajouter une ligne', 'imp.import': 'Importer', 'imp.cancel': 'Annuler', 'rec.manualStart': 'Enregistrer ce contact. L\u2019enregistrement automatique est d\u00e9sactiv\u00e9 : la capture commence maintenant, sans les secondes qui pr\u00e9c\u00e8dent.', 'rec.stop': 'Arr\u00eater l\u2019enregistrement. L\u2019audio est conserv\u00e9 et sera enregistr\u00e9 avec le QSO \u2014 arr\u00eatez-le pour le repasser \u00e0 la station travaill\u00e9e.', 'rec.resume': 'Reprendre l\u2019enregistrement, \u00e0 la suite de ce qui est d\u00e9j\u00e0 captur\u00e9.', 'rec.playOnAir': '\u00c9METTRE l\u2019enregistrement vers la station travaill\u00e9e : passe la radio en \u00e9mission et l\u2019envoie comme un message du manipulateur vocal.', 'rec.stopPlaying': 'Arrêter l’émission de lenregistrement', 'rec.manualFailed': 'L\u2019enregistrement n\u2019a pas pu d\u00e9marrer \u2014 v\u00e9rifiez les p\u00e9riph\u00e9riques audio dans les R\u00e9glages.', 'imp.mapAdd': 'Le fichier met un champ au mauvais endroit\u2026', 'imp.mapTitle': 'D\u00e9placer des champs \u00e0 l\u2019import', 'imp.mapDesc': 'Les logiciels de concours rangent l\u2019\u00e9change l\u00e0 o\u00f9 leur module le garde. Le contest IOTA de la RSGB exporte la r\u00e9f\u00e9rence d\u2019\u00eele dans STATE \u2014 import\u00e9e telle quelle elle devient un \u00e9tat am\u00e9ricain et le dipl\u00f4me IOTA reste vide. La destination n\u2019est remplie que l\u00e0 o\u00f9 le fichier l\u2019a laiss\u00e9e vide.', 'imp.mapMore': 'Ajouter une ligne', 'imp.import': 'Importer',
'imp.progressTitle': 'Import ADIF en cours…', 'bulk.progressTitle': 'Mise \u00e0 jour des QSO s\u00e9lectionn\u00e9s\u2026', 'imp.progressTitle': 'Import ADIF en cours…', 'bulk.progressTitle': 'Mise \u00e0 jour des QSO s\u00e9lectionn\u00e9s\u2026',
'imp.progressCount': '{done} / {tot} enregistrements · {pct}%', 'imp.progressCountOnly': '{done} enregistrements…', 'imp.progressCount': '{done} / {tot} enregistrements · {pct}%', 'imp.progressCountOnly': '{done} enregistrements…',
'imp.complete': 'Import terminé.', 'imp.complete': 'Import terminé.',
@@ -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.", '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 12 min pour corriger un QSO mal saisi avant).", '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 12 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', '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', '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.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.xieguPTTLine': 'Passage en \u00e9mission', 'cat.xieguPTTCiv': 'Commande CI-V', 'cat.xieguPTTHint': 'Un G90 ne passe pas en \u00e9mission sur la commande CI-V : les interfaces comme le DE-19 le pilotent par RTS ou DTR. Choisissez la ligne de la v\u00f4tre \u2014 c\u2019est aussi ce qui permet \u00e0 WSJT-X d\u2019\u00e9mettre via le partage CAT.', '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.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.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 laudio RX par le réseau (expérimental)', 'cat.icomNetAudio': 'Diffuser laudio RX par le réseau (expérimental)',
@@ -815,7 +815,7 @@ const fr: Dict = {
'aud.txOn': 'ÉMISSION — micro → Vers la radio, PTT activé. Cliquez pour arrêter.', 'aud.txHint': "Micro direct → poste avec PTT (USB pour l'instant ; TX réseau plus tard).", 'aud.txOn': 'ÉMISSION — micro → Vers la radio, PTT activé. Cliquez pour arrêter.', 'aud.txHint': "Micro direct → poste avec PTT (USB pour l'instant ; TX réseau plus tard).",
'aud.recorder': 'Enregistreur de QSO', 'aud.recordEvery': 'Enregistrer chaque QSO dans un fichier audio (Depuis la radio + votre micro)', 'aud.recFolder': 'Dossier des enregistrements', 'aud.browse': 'Parcourir…', 'aud.recorder': 'Enregistreur de QSO', 'aud.recordEvery': 'Enregistrer chaque QSO dans un fichier audio (Depuis la radio + votre micro)', 'aud.recFolder': 'Dossier des enregistrements', 'aud.browse': 'Parcourir…',
'aud.preroll': 'Pré-enregistrement (secondes)', 'aud.format': 'Format de fichier', 'aud.wav': 'WAV (sans perte, plus volumineux)', 'aud.mp3': 'MP3 (compressé, léger)', 'aud.preroll': 'Pré-enregistrement (secondes)', 'aud.format': 'Format de fichier', 'aud.wav': 'WAV (sans perte, plus volumineux)', 'aud.mp3': 'MP3 (compressé, léger)',
'aud.fromLevel': 'Niveau depuis la radio', 'aud.txLevel': 'Niveau du voice keyer', 'aud.txLevelHint': "Niveau des messages enregistrés envoyés à la radio. Augmentez-le si votre voice keyer est bien plus faible que votre micro ; Lire fait entendre ce même niveau. Si la radio n'émet presque rien, c'est que sa source de modulation est restée le micro de façade : sur un FTDX10, réglez MENU → SSB MOD SOURCE sur REAR (l'entrée USB).", 'aud.micLevel': 'Niveau micro', 'aud.levelHint': 'Si votre voix est plus forte que la station, baissez le niveau micro.', 'aud.fromLevel': 'Niveau depuis la radio', 'aud.txLevel': 'Niveau du voice keyer', 'aud.txLevelHint': "Niveau des messages enregistrés envoyés à la radio. Augmentez-le si votre voice keyer est bien plus faible que votre micro ; Lire fait entendre ce même niveau. Si la radio n'émet presque rien, c'est que sa source de modulation est restée le micro de façade : sur un FTDX10, réglez MENU → SSB MOD SOURCE sur REAR (l'entrée USB).", 'aud.micLevel': 'Niveau micro', 'aud.qsoPlayLevel': 'Niveau de relecture QSO', 'aud.levelHint': 'Si votre voix est plus forte que la station, baissez le niveau micro.',
'aud.autoSend': "Envoyer automatiquement l'enregistrement à la station par e-mail lorsque j'enregistre un QSO", 'aud.autoSend': "Envoyer automatiquement l'enregistrement à la station par e-mail lorsque j'enregistre un QSO",
'aud.dvkTitle': 'Messages du manipulateur vocal (F1F6)', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série', 'aud.dvkTitle': 'Messages du manipulateur vocal (F1F6)', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série',
'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser', 'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser',
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // 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). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.22.5'; export const APP_VERSION = '0.22.6';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -61,6 +61,8 @@ export function ApplyAwardUpdate(arg1:string):Promise<void>;
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>; export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
export function AudioApplyLevels(arg1:number,arg2:number):Promise<void>;
export function AudioMonitorActive():Promise<boolean>; export function AudioMonitorActive():Promise<boolean>;
export function AudioStartMonitor():Promise<void>; export function AudioStartMonitor():Promise<void>;
+4
View File
@@ -70,6 +70,10 @@ export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3); return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
} }
export function AudioApplyLevels(arg1, arg2) {
return window['go']['main']['App']['AudioApplyLevels'](arg1, arg2);
}
export function AudioMonitorActive() { export function AudioMonitorActive() {
return window['go']['main']['App']['AudioMonitorActive'](); return window['go']['main']['App']['AudioMonitorActive']();
} }
+6
View File
@@ -461,6 +461,7 @@ export namespace award {
ref_count: number; ref_count: number;
steps: Step[]; steps: Step[];
manual?: string[]; manual?: string[];
superseded?: string[];
result: string[]; result: string[];
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@@ -476,6 +477,7 @@ export namespace award {
this.ref_count = source["ref_count"]; this.ref_count = source["ref_count"];
this.steps = this.convertValues(source["steps"], Step); this.steps = this.convertValues(source["steps"], Step);
this.manual = source["manual"]; this.manual = source["manual"];
this.superseded = source["superseded"];
this.result = source["result"]; this.result = source["result"];
} }
@@ -1608,6 +1610,7 @@ export namespace main {
from_gain: number; from_gain: number;
mic_gain: number; mic_gain: number;
tx_gain: number; tx_gain: number;
qso_play_gain: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new AudioSettings(source); return new AudioSettings(source);
@@ -1628,6 +1631,7 @@ export namespace main {
this.from_gain = source["from_gain"]; this.from_gain = source["from_gain"];
this.mic_gain = source["mic_gain"]; this.mic_gain = source["mic_gain"];
this.tx_gain = source["tx_gain"]; this.tx_gain = source["tx_gain"];
this.qso_play_gain = source["qso_play_gain"];
} }
} }
export class AutostartLaunchResult { export class AutostartLaunchResult {
@@ -1910,6 +1914,7 @@ export namespace main {
xiegu_port: string; xiegu_port: string;
xiegu_baud: number; xiegu_baud: number;
xiegu_addr: number; xiegu_addr: number;
xiegu_ptt_line: string;
yaesu_port: string; yaesu_port: string;
yaesu_baud: number; yaesu_baud: number;
kenwood_host: string; kenwood_host: string;
@@ -1949,6 +1954,7 @@ export namespace main {
this.xiegu_port = source["xiegu_port"]; this.xiegu_port = source["xiegu_port"];
this.xiegu_baud = source["xiegu_baud"]; this.xiegu_baud = source["xiegu_baud"];
this.xiegu_addr = source["xiegu_addr"]; this.xiegu_addr = source["xiegu_addr"];
this.xiegu_ptt_line = source["xiegu_ptt_line"];
this.yaesu_port = source["yaesu_port"]; this.yaesu_port = source["yaesu_port"];
this.yaesu_baud = source["yaesu_baud"]; this.yaesu_baud = source["yaesu_baud"];
this.kenwood_host = source["kenwood_host"]; this.kenwood_host = source["kenwood_host"];
+11 -1
View File
@@ -80,7 +80,17 @@ func pcmFormat() *wca.WAVEFORMATEX {
} }
} }
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | wca.AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY // autoConvert lets WASAPI resample between the device format and ours.
//
// SRC_DEFAULT_QUALITY used to be set alongside it, and that name is misleading:
// it selects the CHEAP converter, meant for cases where quality does not matter.
// Going from a rig 48 kHz stream down to our 16 kHz, it folds everything above
// 8 kHz back into the audio band — and a receiver hiss is mostly high
// frequencies. The result was a recording where the noise sat ON TOP of the
// voice, while the same audio heard live had the voice well clear of it.
//
// Without the flag Windows uses its normal converter, which filters first.
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
// recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the // recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the
// stop channel is closed. // stop channel is closed.
+85 -6
View File
@@ -5,6 +5,7 @@ package audio
import ( import (
"fmt" "fmt"
"sync" "sync"
"time"
) )
// Manager owns the DVK record/playback lifecycle: at most one recording and // Manager owns the DVK record/playback lifecycle: at most one recording and
@@ -14,7 +15,18 @@ type Manager struct {
mu sync.Mutex mu sync.Mutex
recStop chan struct{} recStop chan struct{}
recDone chan recResult recDone chan recResult
// monGainPct scales what the RX monitor plays, 100 = as captured.
//
// The "From radio" slider used to reach only the QSO recorder, so an
// operator listening through OpsLog heard no difference between 10% and
// 150% — the setting looked broken because it was, for the thing they were
// listening to.
monGainPct int
playStop chan struct{} playStop chan struct{}
// playDone closes when the playback goroutine has returned and the audio
// device is free again. Without it, the next Play raced the old one for the
// device and lost.
playDone chan struct{}
monStop chan struct{} // RX monitor passthrough (capture → render) monStop chan struct{} // RX monitor passthrough (capture → render)
monRing *pcmRing // live audio hand-off, also fed by the network stream monRing *pcmRing // live audio hand-off, also fed by the network stream
txStop chan struct{} // TX audio passthrough (mic → rig) txStop chan struct{} // TX audio passthrough (mic → rig)
@@ -125,18 +137,38 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error {
pcm[i], pcm[i+1] = byte(uint16(v)), byte(uint16(v)>>8) pcm[i], pcm[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
} }
} }
// Waits for any previous playback to have RELEASED THE DEVICE.
//
// It used to only signal the old one to stop and start a new one at once.
// The old goroutine still held the WASAPI render client for a moment, so the
// new client could not start: it returned immediately, the PTT was keyed and
// released a tenth of a second later, and nothing came out. On the air that
// looked like "press play again and you must wait the whole length of the
// message before it will play at all".
m.StopPlayback() m.StopPlayback()
stop := make(chan struct{}) stop := make(chan struct{})
done := make(chan struct{})
m.mu.Lock() m.mu.Lock()
m.playStop = stop m.playStop = stop
m.playDone = done
m.mu.Unlock() m.mu.Unlock()
go func() { go func() {
_ = playPCM(deviceID, pcm, rate, ch, bits, stop) // The error was discarded. A device that refuses to start returns here
// instantly, the PTT is released 120 ms later, and NOTHING says why —
// which is exactly what a station heard as "it plays once, then never
// again": the call succeeded, the sound did not.
if err := playPCM(deviceID, pcm, rate, ch, bits, stop); err != nil {
LogSink("audio: playback on %q failed: %v", DeviceName(deviceID), err)
}
m.mu.Lock() m.mu.Lock()
if m.playStop == stop { if m.playStop == stop {
m.playStop = nil m.playStop = nil
} }
if m.playDone == done {
m.playDone = nil
}
m.mu.Unlock() m.mu.Unlock()
close(done) // the device is free from here
m.notify() m.notify()
}() }()
m.notify() m.notify()
@@ -147,12 +179,24 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error {
func (m *Manager) StopPlayback() { func (m *Manager) StopPlayback() {
m.mu.Lock() m.mu.Lock()
stop := m.playStop stop := m.playStop
done := m.playDone
m.playStop = nil m.playStop = nil
m.mu.Unlock() m.mu.Unlock()
if stop != nil { if stop == nil {
close(stop) return
m.notify()
} }
close(stop)
// Wait for the goroutine to release the device — that is the whole point of
// stopping before starting again. Bounded: a wedged WASAPI call must not
// freeze the caller, which here is the operator clicking a button.
if done != nil {
select {
case <-done:
case <-time.After(1500 * time.Millisecond):
LogSink("audio: previous playback did not release the device within 1.5 s")
}
}
m.notify()
} }
// ---- RX audio monitor (Phase 2: USB codec passthrough) -------------------- // ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------
@@ -188,9 +232,12 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
m.mu.Unlock() m.mu.Unlock()
if capture { if capture {
// Producer: capture the rig's USB audio into the ring. // Producer: capture the rig's USB audio into the ring, at the level the
// operator set. Applied HERE rather than on the render side so the
// network-fed path (PushMonitorAudio) keeps its own untouched levels —
// that stream is already scaled by the radio.
go func() { go func() {
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(chunk) }) _ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(m.scaleMonitor(chunk)) })
}() }()
} }
// Consumer: render the ring to the output device at the internal 16 kHz mono. // Consumer: render the ring to the output device at the internal 16 kHz mono.
@@ -201,6 +248,38 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
return nil return nil
} }
// SetMonitorGain sets the RX monitor level in percent (100 = as captured).
// Takes effect on the next captured chunk — no need to restart the monitor.
func (m *Manager) SetMonitorGain(pct int) {
if pct <= 0 {
pct = 100
}
m.mu.Lock()
m.monGainPct = pct
m.mu.Unlock()
}
// scaleMonitor applies the monitor level to one captured chunk, returning a
// buffer the ring may keep. At unity it hands the chunk straight back: the
// common case must not pay for a copy 30 times a second.
func (m *Manager) scaleMonitor(chunk []byte) []byte {
m.mu.Lock()
pct := m.monGainPct
m.mu.Unlock()
if pct == 0 || pct == 100 {
return chunk
}
g := float64(pct) / 100
out := make([]byte, len(chunk))
copy(out, chunk)
for i := 0; i+1 < len(out); i += 2 {
v := int16(uint16(out[i]) | uint16(out[i+1])<<8)
v = scalePCM(v, g)
out[i], out[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
}
return out
}
// StopMonitor stops the RX monitor passthrough. // StopMonitor stops the RX monitor passthrough.
func (m *Manager) StopMonitor() { func (m *Manager) StopMonitor() {
m.mu.Lock() m.mu.Lock()
+70 -7
View File
@@ -3,6 +3,7 @@
package audio package audio
import ( import (
"math"
"os" "os"
"github.com/braheezy/shine-mp3/pkg/mp3" "github.com/braheezy/shine-mp3/pkg/mp3"
@@ -50,19 +51,81 @@ func writeMP3(path string, pcm []byte) error {
return enc.Write(f, stereo) return enc.Write(f, stereo)
} }
// upsample2 doubles the sample rate with linear interpolation (16 kHz → 32 kHz). // upsample2 doubles the sample rate, 16 kHz → 32 kHz, WITH the interpolation
// filter that makes the operation legitimate.
//
// It used to interpolate linearly — each new sample the average of its
// neighbours. That is an upsampler in name only: every component at f is
// mirrored to 16 kHz f, measured just 7 dB down (see the test). On speech it
// adds brightness; on receiver noise, which is broadband, it lays a second
// noise floor across the top of the band. Operators heard it as a hiss louder
// than the voice, present in the MP3 and absent from the WAV of the very same
// audio.
//
// Zero-stuffing followed by a windowed-sinc low-pass at the old Nyquist is the
// textbook answer, and it puts the image below 40 dB. 63 taps is a few hundred
// microseconds of work on an eight-second recording — nothing, next to the MP3
// encode that follows.
func upsample2(in []int16) []int16 { func upsample2(in []int16) []int16 {
if len(in) == 0 { if len(in) == 0 {
return in return in
} }
out := make([]int16, len(in)*2) out := make([]int16, len(in)*2)
for i := range in { half := len(upsampleFIR) / 2
out[2*i] = in[i] for i := range out {
if i+1 < len(in) { var acc float64
out[2*i+1] = int16((int32(in[i]) + int32(in[i+1])) / 2) // The zero-stuffed signal is non-zero only at even indices, so only
} else { // every other tap contributes — the loop skips the zeros rather than
out[2*i+1] = in[i] // multiplying by them.
start := (i - half + 1) &^ 1 // first even index in the window
for j := start; j <= i+half; j += 2 {
k := i - j + half
if k < 0 || k >= len(upsampleFIR) {
continue
} }
src := j / 2
if src < 0 || src >= len(in) {
continue
}
acc += float64(in[src]) * upsampleFIR[k]
}
// ×2 for the energy lost to zero-stuffing, then clamp.
v := acc * 2
if v > 32767 {
v = 32767
} else if v < -32768 {
v = -32768
}
out[i] = int16(v)
} }
return out return out
} }
// upsampleFIR is a 63-tap Hamming-windowed sinc, cut off at a quarter of the
// NEW rate (8 kHz at 32 kHz) — exactly the old Nyquist, which is where the
// images begin.
var upsampleFIR = func() []float64 {
const n = 63
const fc = 0.25 // cycles/sample at the new rate
h := make([]float64, n)
mid := (n - 1) / 2
var sum float64
for i := 0; i < n; i++ {
m := float64(i - mid)
var v float64
if m == 0 {
v = 2 * fc
} else {
v = math.Sin(2*math.Pi*fc*m) / (math.Pi * m)
}
// Hamming window: a rectangular one would ring and put the stopband
// only ~21 dB down, which is not enough to be worth the filter.
v *= 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(n-1))
h[i] = v
sum += v
}
for i := range h {
h[i] /= sum // unity gain at DC
}
return h
}()
+56
View File
@@ -0,0 +1,56 @@
package audio
import (
"testing"
"time"
)
// Playing a stopped take must not consume it.
//
// Reported on the air: the recording plays once, then nothing — "as if it threw
// the sound away after playing it". PeekQSO is what the playback reads, and it
// is meant to COPY; TakeQSO is the one that clears.
func TestPeekDoesNotConsumeTheTake(t *testing.T) {
r := &Recorder{running: true, active: true, paused: true, prerollSamples: sampleRate}
r.acc = make([]int16, 4000)
for i := range r.acc {
r.acc[i] = int16(i % 500)
}
first, err := r.PeekQSO()
if err != nil {
t.Fatalf("first peek: %v", err)
}
second, err := r.PeekQSO()
if err != nil {
t.Fatalf("second peek: %v — the take was consumed by the first", err)
}
if len(first) != len(second) {
t.Errorf("second peek returned %d bytes, first %d", len(second), len(first))
}
// And the copy must be independent: the caller holds these bytes while the
// recorder may still be appending to its own slice.
if len(first) > 0 {
first[0] = ^first[0]
again, _ := r.PeekQSO()
if len(again) > 0 && again[0] == first[0] {
t.Error("PeekQSO handed out its internal buffer, not a copy")
}
}
// Resuming and stopping again keeps everything captured so far.
r.ResumeQSO()
r.mu.Lock()
r.acc = append(r.acc, make([]int16, 1000)...)
r.mu.Unlock()
r.PauseQSO()
third, err := r.PeekQSO()
if err != nil {
t.Fatalf("peek after resume: %v", err)
}
if len(third) <= len(second) {
t.Errorf("after resuming, the take is %d bytes — it should have grown past %d", len(third), len(second))
}
_ = time.Now
}
+12
View File
@@ -204,6 +204,18 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
}() }()
} }
// Name the devices being recorded FROM, once, at the start.
//
// A station with two radios has two sets of endpoints, and the recorder will
// happily capture the one that is not being listened to: the result is a
// file full of hiss with no relation to what the operator hears, and nothing
// anywhere said which receiver it came from.
if twoSrc {
LogSink("recorder: capturing %q + %q", DeviceName(fromDev), DeviceName(micDev))
} else {
LogSink("recorder: capturing %q", DeviceName(fromDev))
}
// Watchdog. A WASAPI device can open cleanly and then produce nothing at // 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 // 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 // indistinguishable from silence until the recording turns out to be empty
+79
View File
@@ -0,0 +1,79 @@
package audio
import (
"math"
"testing"
)
// goertzel returns the magnitude of one frequency bin, so a test can ask "how
// much energy is at 10 kHz" without pulling in an FFT.
func goertzel(x []int16, fs, freq float64) float64 {
w := 2 * math.Pi * freq / fs
c := 2 * math.Cos(w)
var s1, s2 float64
for _, v := range x {
s := float64(v) + c*s1 - s2
s2, s1 = s1, s
}
return math.Hypot(s1-s2*math.Cos(w), s2*math.Sin(w))
}
// Upsampling 16 kHz → 32 kHz must not create an audible mirror image.
//
// MP3 recordings came back with a hiss that is not on the air and is not in the
// WAV of the same audio — "louder than the voice", from a station that hears the
// voice well clear of the noise. The MP3 path is the only one that resamples,
// and it did so by linear interpolation: every component at f is mirrored to
// 16 kHz f, which for broadband receiver noise means a second noise floor
// spread across the top of the band.
func TestUpsampleSuppressesTheImage(t *testing.T) {
const fs = 16000.0
const tone = 6000.0 // its image lands at 16000 6000 = 10 kHz
in := make([]int16, 4096)
for i := range in {
in[i] = int16(12000 * math.Sin(2*math.Pi*tone*float64(i)/fs))
}
out := upsample2(in)
if len(out) != 2*len(in) {
t.Fatalf("upsample2 returned %d samples for %d", len(out), len(in))
}
wanted := goertzel(out, 2*fs, tone)
image := goertzel(out, 2*fs, 2*fs-tone-16000) // = 10 kHz
if wanted == 0 {
t.Fatal("the tone itself did not survive upsampling")
}
db := 20 * math.Log10(image/wanted)
t.Logf("image at 10 kHz is %.1f dB below the 6 kHz tone", db)
if db > -35 {
t.Errorf("image only %.1f dB down — it is audible as added hiss; want at least 35 dB", db)
}
}
// The filter must not change the level, or every existing recording would come
// back quieter (or clipped) after the fix — a second surprise on top of the one
// being repaired.
func TestUpsampleKeepsTheLevel(t *testing.T) {
const fs = 16000.0
in := make([]int16, 4096)
for i := range in {
in[i] = int16(10000 * math.Sin(2*math.Pi*1000*float64(i)/fs))
}
rms := func(x []int16) float64 {
var acc float64
for _, v := range x {
acc += float64(v) * float64(v)
}
return math.Sqrt(acc / float64(len(x)))
}
// Skip the filter's warm-up and tail, where the window is only partly fed.
out := upsample2(in)
got, want := rms(out[200:len(out)-200]), rms(in[100:len(in)-100])
ratio := got / want
t.Logf("level after upsampling: ×%.3f", ratio)
if ratio < 0.9 || ratio > 1.1 {
t.Errorf("level changed by ×%.3f — want within 10%%", ratio)
}
}
+20 -1
View File
@@ -859,6 +859,10 @@ type Explanation struct {
RefCount int `json:"ref_count"` // size of that list RefCount int `json:"ref_count"` // size of that list
Steps []Step `json:"steps"` Steps []Step `json:"steps"`
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
// Superseded is what the matcher had found before the operator's choice
// replaced it — shown in the trace so a correction is explainable rather
// than mysterious.
Superseded []string `json:"superseded,omitempty"`
Result []string `json:"result"` // what the QSO finally counts for Result []string `json:"result"` // what the QSO finally counts for
} }
@@ -977,7 +981,22 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
if ex != nil { if ex != nil {
ex.Manual = manual ex.Manual = manual
} }
found = append(found, manual...) // An operator's choice REPLACES what the matcher found. It does not join it.
//
// Adding to it was the behaviour, and it made a correction impossible: a
// German address matching two or three DOKs kept them all, so deleting the
// wrong ones and assigning the right one lasted exactly until the next
// recompute, which put them straight back. The operator was arguing with
// the matcher and could not win.
//
// One manual entry is stored per award (setOverrideRef drops the previous
// one), so this is an override in the storage as well as in the name.
if len(manual) > 0 {
if ex != nil {
ex.Superseded = dedupe(found)
}
found = manual
}
out := dedupe(found) out := dedupe(found)
if ex != nil { if ex != nil {
ex.Result = out ex.Result = out
+56
View File
@@ -0,0 +1,56 @@
package award
import (
"strings"
"testing"
"hamlog/internal/qso"
)
// An operator's correction must survive a recompute.
//
// Reported on the DARC DOK award: a German address matches two or three DOKs,
// the operator deletes the wrong ones and assigns the right one, and the next
// recompute puts the wrong ones straight back. The manual reference was being
// ADDED to what the matcher found instead of replacing it, so the correction
// could never win.
func TestManualReferenceReplacesTheMatch(t *testing.T) {
// An award that reads the address and matches a reference's description —
// the shape the DOK award uses, and the shape that over-matches.
def := Def{
Code: "DLD", Name: "DARC DOK", Field: "address",
MatchBy: "description", Type: "QSOFIELDS", Valid: true,
}
metas := []RefMeta{
{Code: "A01", Name: "Berlin", Valid: true},
{Code: "B05", Name: "Berlin Mitte", Valid: true},
}
// An address that legitimately matches both.
q := &qso.QSO{
Callsign: "DL2FDM",
Address: "Berlin Mitte, 10115 Berlin",
}
auto := Explain(def, metas, q)
if len(auto.Result) < 2 {
t.Fatalf("the test needs an over-matching case; got %v", auto.Result)
}
// The operator picks the right one.
q.Extras = map[string]string{ManualRefsKey: "DLD@B05"}
fixed := Explain(def, metas, q)
if got := strings.Join(fixed.Result, ","); got != "B05" {
t.Errorf("after the correction the QSO counts for %q — want B05 alone", got)
}
if len(fixed.Superseded) == 0 {
t.Error("the trace does not say what the correction replaced")
}
// A QSO with no correction still gets everything the matcher found: the
// override must not disable matching for everyone else.
q2 := &qso.QSO{Callsign: "DL3XYZ", Address: "Berlin Mitte, 10115 Berlin"}
if len(Explain(def, metas, q2).Result) < 2 {
t.Error("an untouched QSO lost its automatic references")
}
}
+19 -1
View File
@@ -492,7 +492,25 @@ func (k *Kenwood) openPort() (serial.Port, error) {
} }
return &tcpSerial{conn: c}, nil return &tcpSerial{conn: c}, nil
} }
return serial.Open(k.portName, &serial.Mode{BaudRate: k.baud}) p, err := serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
if err != nil {
return nil, err
}
// Deassert DTR and RTS.
//
// Windows raises both when a serial port is opened, and a great many
// interfaces read them as PTT: a Xiegu G90 behind a DE-19 goes into
// transmit the moment OpsLog connects and STAYS there — Xiegu's own
// documentation asks for RTS and DTR low. The same applies to an Icom with
// USB SEND mapped to a line (which is why the CI-V backend has done this
// from the start) and to any rig keyed by a home-made cable.
//
// PTT on this backend is a CAT command, so neither line should ever be
// asserted here. A station keying by RTS/DTR configures that separately,
// on its own port.
_ = p.SetDTR(false)
_ = p.SetRTS(false)
return p, nil
} }
// tcpSerial presents a TCP connection as a serial.Port, so the backend has one // tcpSerial presents a TCP connection as a serial.Port, so the backend has one
+38
View File
@@ -56,6 +56,9 @@ type Xiegu struct {
// asking every cycle — a Xiegu that has no split must not cost a timeout per // asking every cycle — a Xiegu that has no split must not cost a timeout per
// poll, which would slow the whole loop to a crawl. // poll, which would slow the whole loop to a crawl.
splitSupported bool splitSupported bool
// pttLine is "", "rts" or "dtr": which hardware line keys the rig, when the
// CI-V command does not.
pttLine string
} }
func NewXiegu(portName string, baud int, addr int, digital string) *Xiegu { func NewXiegu(portName string, baud int, addr int, digital string) *Xiegu {
@@ -74,6 +77,14 @@ func NewXiegu(portName string, baud int, addr int, digital string) *Xiegu {
} }
} }
// SetPTTLine selects the hardware line that keys this rig: "rts", "dtr", or ""
// for the CI-V command. Set before Connect.
func (x *Xiegu) SetPTTLine(line string) {
x.mu.Lock()
x.pttLine = strings.ToLower(strings.TrimSpace(line))
x.mu.Unlock()
}
func (x *Xiegu) Name() string { return "xiegu" } func (x *Xiegu) Name() string { return "xiegu" }
func (x *Xiegu) Connect() error { func (x *Xiegu) Connect() error {
@@ -87,6 +98,20 @@ func (x *Xiegu) Connect() error {
return fmt.Errorf("xiegu: open %s @ %d baud: %w", x.portName, x.baud, err) return fmt.Errorf("xiegu: open %s @ %d baud: %w", x.portName, x.baud, err)
} }
p.SetReadTimeout(200 * time.Millisecond) p.SetReadTimeout(200 * time.Millisecond)
// Deassert DTR and RTS.
//
// Windows raises both when a serial port is opened, and a great many
// interfaces read them as PTT: a Xiegu G90 behind a DE-19 goes into
// transmit the moment OpsLog connects and STAYS there — Xiegu's own
// documentation asks for RTS and DTR low. The same applies to an Icom with
// USB SEND mapped to a line (which is why the CI-V backend has done this
// from the start) and to any rig keyed by a home-made cable.
//
// PTT on this backend is a CAT command, so neither line should ever be
// asserted here. A station keying by RTS/DTR configures that separately,
// on its own port.
_ = p.SetDTR(false)
_ = p.SetRTS(false)
x.port = p x.port = p
x.splitSupported = true x.splitSupported = true
@@ -177,12 +202,25 @@ func (x *Xiegu) SetMode(mode string) error {
return x.send(civ.CmdSetMode, m) return x.send(civ.CmdSetMode, m)
} }
// SetPTT keys the transmitter, by CI-V or by a hardware line.
//
// A G90 does not transmit on the CI-V PTT command. Xiegu's own interfaces key
// on RTS or DTR instead — the DE-19 does exactly that — which is why the line
// can be selected here. Without it, WSJT-X talking to OpsLog's rigctld server
// decoded perfectly and never transmitted: the command left, the radio ignored
// it, and nothing in the chain was wrong enough to complain.
func (x *Xiegu) SetPTT(on bool) error { func (x *Xiegu) SetPTT(on bool) error {
x.mu.Lock() x.mu.Lock()
defer x.mu.Unlock() defer x.mu.Unlock()
if x.port == nil { if x.port == nil {
return fmt.Errorf("xiegu: not connected") return fmt.Errorf("xiegu: not connected")
} }
switch x.pttLine {
case "rts":
return x.port.SetRTS(on)
case "dtr":
return x.port.SetDTR(on)
}
v := byte(0x00) v := byte(0x00)
if on { if on {
v = 0x01 v = 0x01
+14
View File
@@ -150,6 +150,20 @@ func (y *Yaesu) Connect() error {
if err != nil { if err != nil {
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err) return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
} }
// Deassert DTR and RTS.
//
// Windows raises both when a serial port is opened, and a great many
// interfaces read them as PTT: a Xiegu G90 behind a DE-19 goes into
// transmit the moment OpsLog connects and STAYS there — Xiegu's own
// documentation asks for RTS and DTR low. The same applies to an Icom with
// USB SEND mapped to a line (which is why the CI-V backend has done this
// from the start) and to any rig keyed by a home-made cable.
//
// PTT on this backend is a CAT command, so neither line should ever be
// asserted here. A station keying by RTS/DTR configures that separately,
// on its own port.
_ = p.SetDTR(false)
_ = p.SetRTS(false)
p.SetReadTimeout(300 * time.Millisecond) p.SetReadTimeout(300 * time.Millisecond)
y.port = p y.port = p
+49 -3
View File
@@ -11,6 +11,7 @@ package cluster
import ( import (
"bufio" "bufio"
"errors"
"fmt" "fmt"
"net" "net"
"regexp" "regexp"
@@ -346,12 +347,29 @@ func (s *session) run() {
} }
} }
// idleTick is how often the read is interrupted so the loop can notice a stop
// request. It is NOT a liveness test — see the read loop.
//
// A var, not a const, so the test that proves a silent node survives can shorten
// it: at 30 s that test spends a minute doing nothing, and a slow test is a test
// that gets skipped.
var idleTick = 30 * time.Second
// quietNotice is the silence after which the log says so, once.
const quietNotice = 10 * time.Minute
// runOnce dials, optionally logs in, sends init commands, parses spots. // runOnce dials, optionally logs in, sends init commands, parses spots.
// Returns the moment we marked the link "connected" (zero if dial failed) // Returns the moment we marked the link "connected" (zero if dial failed)
// and the error that ended the session (nil if stopCh). // and the error that ended the session (nil if stopCh).
func (s *session) runOnce() (time.Time, error) { func (s *session) runOnce() (time.Time, error) {
addr := net.JoinHostPort(s.cfg.Host, fmt.Sprintf("%d", s.cfg.Port)) // IPv6-safe addr := net.JoinHostPort(s.cfg.Host, fmt.Sprintf("%d", s.cfg.Port)) // IPv6-safe
conn, err := net.DialTimeout("tcp", addr, 10*time.Second) // KeepAlive is set explicitly rather than left to the package default: it is
// what actually detects a dead peer here, so it should be visible in the
// code that depends on it. The OS probes an idle connection and a genuine
// failure surfaces as an error on Read — which is the only thing that ends
// a session below.
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
conn, err := d.Dial("tcp", addr)
if err != nil { if err != nil {
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err) return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
} }
@@ -423,6 +441,8 @@ func (s *session) runOnce() (time.Time, error) {
} }
var connectedAt time.Time var connectedAt time.Time
var quiet time.Duration // how long the node has said nothing
var pending string // a line cut in half by a read deadline
rd := bufio.NewReader(conn) rd := bufio.NewReader(conn)
for { for {
select { select {
@@ -431,11 +451,37 @@ func (s *session) runOnce() (time.Time, error) {
default: default:
} }
_ = conn.SetReadDeadline(time.Now().Add(120 * time.Second)) // The deadline exists to keep this loop responsive to stopCh, NOT to
line, err := rd.ReadString('\n') // judge the link. A quiet node is a quiet node: some clusters send
// nothing for minutes on a dead band, and tearing the socket down over
// it produced a reconnect every two minutes — which loses the login,
// the filters, and any spot that arrived during the gap.
//
// Only a REAL error ends the session. A dead peer still gets caught:
// TCP keepalive probes an idle connection and its failure arrives here
// as an error, not as a timeout.
_ = conn.SetReadDeadline(time.Now().Add(idleTick))
chunk, err := rd.ReadString('\n')
if err != nil { if err != nil {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
// ReadString hands back what it HAD read along with the timeout.
// Keep it: a spot line straddling the deadline would otherwise lose
// its first half and arrive as nonsense, or vanish entirely.
pending += chunk
quiet += idleTick
// Said once at the first long silence, so a genuinely mute node is
// visible without a line every tick.
if quiet == quietNotice {
applog.Printf("cluster[%s] no traffic for %s — still connected", s.cfg.Name, quiet)
}
continue
}
return connectedAt, fmt.Errorf("read: %w", err) return connectedAt, fmt.Errorf("read: %w", err)
} }
quiet = 0
line := pending + chunk
pending = ""
line = strings.TrimRight(line, "\r\n") line = strings.TrimRight(line, "\r\n")
// Strip terminal BELLs (\a) and other control bytes that some nodes // Strip terminal BELLs (\a) and other control bytes that some nodes
// (e.g. F5LEN) append to spot lines — "DX de …0935Z KN04\a\a" — which // (e.g. F5LEN) append to spot lines — "DX de …0935Z KN04\a\a" — which
+89
View File
@@ -0,0 +1,89 @@
package cluster
import (
"net"
"strconv"
"testing"
"time"
)
// A quiet cluster must not be treated as a broken one.
//
// Reported by an operator whose node sends little: nothing for two minutes and
// OpsLog reconnected — losing the login, the filters, and any spot that landed
// during the gap. The read deadline was being read as a verdict on the link
// rather than as a way to stay responsive to a stop request.
//
// The test holds the connection open, silent for longer than one deadline, then
// sends a spot. A session that survives receives it; one that reconnects does
// not, because this listener never accepts twice.
func TestSilenceDoesNotEndTheSession(t *testing.T) {
// Shorten the tick so the test exercises the real code path in seconds.
defer func(orig time.Duration) { idleTick = orig }(idleTick)
idleTick = time.Second
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
accepted := make(chan net.Conn, 2)
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
accepted <- c
}
}()
host, portStr, _ := net.SplitHostPort(ln.Addr().String())
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatal(err)
}
spots := make(chan Spot, 4)
s := &session{
cfg: ServerConfig{Name: "quiet", Host: host, Port: port},
onSpot: func(sp Spot) { spots <- sp },
onLine: func(Line) {},
onStatus: func() {},
stopCh: make(chan struct{}),
}
done := make(chan error, 1)
go func() { _, err := s.runOnce(); done <- err }()
conn := <-accepted
defer conn.Close()
// Silent for longer than one deadline: the loop must ride through it.
time.Sleep(3 * idleTick)
select {
case c := <-accepted:
c.Close()
t.Fatal("the session reconnected during the silence")
case err := <-done:
t.Fatalf("the session ended during the silence: %v", err)
default:
}
// Now a spot: it proves the ORIGINAL connection is still the live one.
if _, err := conn.Write([]byte("DX de F4BPO: 14074.0 OY1CT FT8 1234Z\r\n")); err != nil {
t.Fatal(err)
}
select {
case sp := <-spots:
if sp.DXCall != "OY1CT" {
t.Errorf("spot from the wrong station: %+v", sp)
}
case <-time.After(5 * time.Second):
t.Fatal("the spot sent after the silence never arrived")
}
close(s.stopCh)
<-done
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.22.5" appVersion = "0.22.6"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.