Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06e34372ed | ||
|
|
d23c4c4037 | ||
|
|
d4bfd30636 | ||
|
|
258fa717f1 | ||
|
|
aa995c250c | ||
|
|
72fee7a090 | ||
|
|
ed67ed7fe3 | ||
|
|
fddb3c45c4 | ||
|
|
da1f3eb2bd | ||
|
|
5e0bb6e68e | ||
|
|
2b61f43780 | ||
|
|
2b66e5bc7f | ||
|
|
84b06ed47b | ||
|
|
39bd1ff414 | ||
|
|
6d54100bc9 | ||
|
|
4fe5811fd2 | ||
|
|
d42e6669e1 | ||
|
|
c14353a399 | ||
|
|
734931cf54 | ||
|
|
cad4ec4b72 |
@@ -114,6 +114,7 @@ const (
|
||||
keyCATXieguPort = "cat.xiegu.port" // Xiegu CI-V serial port (G90/X6100…)
|
||||
keyCATXieguBaud = "cat.xiegu.baud" // Xiegu CI-V baud (G90 default 19200)
|
||||
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)
|
||||
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"
|
||||
@@ -141,11 +142,20 @@ const (
|
||||
keyAudioQSODir = "audio.qso_dir" // folder for QSO recordings
|
||||
keyAudioPreroll = "audio.preroll_seconds" // rolling-buffer pre-roll length
|
||||
keyAudioTXGain = "audio.tx_gain" // voice-keyer playback level % (100 = as recorded)
|
||||
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
|
||||
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
|
||||
keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
|
||||
keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent
|
||||
keyAudioMicGain = "audio.mic_gain" // mic mix level, percent
|
||||
// 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"
|
||||
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
|
||||
keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
|
||||
keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent
|
||||
keyAudioMicGain = "audio.mic_gain" // mic mix level, percent
|
||||
|
||||
keyAwardDefs = "awards.defs" // JSON array of award definitions (editable)
|
||||
keyAwardRefsUpdated = "awards.refs.updated." // + CODE → last list-update timestamp
|
||||
@@ -361,6 +371,7 @@ type CATSettings struct {
|
||||
XieguPort string `json:"xiegu_port"` // Xiegu CI-V serial port (G90/X6100…)
|
||||
XieguBaud int `json:"xiegu_baud"` // Xiegu CI-V baud (G90 default 19200)
|
||||
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)
|
||||
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
|
||||
@@ -1220,6 +1231,15 @@ func (a *App) startup(ctx context.Context) {
|
||||
}
|
||||
})
|
||||
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()
|
||||
|
||||
// NET Control store (global JSON, shared across logbooks).
|
||||
@@ -6730,7 +6750,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if a.settings == nil {
|
||||
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 {
|
||||
return CATSettings{}, err
|
||||
}
|
||||
@@ -6782,6 +6802,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if n, _ := strconv.Atoi(m[keyCATXieguBaud]); n > 0 {
|
||||
out.XieguBaud = n
|
||||
}
|
||||
out.XieguPTTLine = m[keyCATXieguPTTLine]
|
||||
if n, _ := strconv.Atoi(m[keyCATXieguAddr]); n > 0 && n <= 0xFF {
|
||||
out.XieguAddr = n
|
||||
}
|
||||
@@ -6902,6 +6923,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
keyCATXieguPort: strings.TrimSpace(s.XieguPort),
|
||||
keyCATXieguBaud: strconv.Itoa(s.XieguBaud),
|
||||
keyCATXieguAddr: strconv.Itoa(s.XieguAddr),
|
||||
keyCATXieguPTTLine: strings.ToLower(strings.TrimSpace(s.XieguPTTLine)),
|
||||
keyCATYaesuPort: strings.TrimSpace(s.YaesuPort),
|
||||
keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud),
|
||||
keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort),
|
||||
@@ -6949,6 +6971,7 @@ type AudioSettings struct {
|
||||
FromGain int `json:"from_gain"` // From Radio (RX) 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
|
||||
QSOPlayGain int `json:"qso_play_gain"` // QSO-recording playback level %, default 100
|
||||
}
|
||||
|
||||
// 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).
|
||||
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 {
|
||||
return out, nil
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
|
||||
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat,
|
||||
keyAudioFromGain, keyAudioMicGain, keyAudioTXGain)
|
||||
keyAudioFromGain, keyAudioMicGain, keyAudioTXGain, keyAudioQSOPlayGain)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -6996,6 +7019,9 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
|
||||
if n, _ := strconv.Atoi(m[keyAudioTXGain]); n > 0 && n <= 400 {
|
||||
out.TXGain = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyAudioQSOPlayGain]); n > 0 && n <= 400 {
|
||||
out.QSOPlayGain = n
|
||||
}
|
||||
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
|
||||
// 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 {
|
||||
s.TXGain = 100
|
||||
}
|
||||
@@ -7044,6 +7073,7 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
|
||||
keyAudioFromGain: strconv.Itoa(s.FromGain),
|
||||
keyAudioMicGain: strconv.Itoa(s.MicGain),
|
||||
keyAudioTXGain: strconv.Itoa(s.TXGain),
|
||||
keyAudioQSOPlayGain: strconv.Itoa(s.QSOPlayGain),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
@@ -7051,6 +7081,12 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
|
||||
}
|
||||
// Apply device/preroll/enable changes to the running recorder.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -7737,8 +7773,13 @@ func (a *App) QSOAudioPlayOnAir() error {
|
||||
// The recorder needs an INPUT from the radio; playing back needs an
|
||||
// OUTPUT into it, which is a different device and may well be unset on a
|
||||
// station that only ever recorded.
|
||||
applog.Printf("qso-rec: play refused — no output device to the radio configured (Settings → Audio)")
|
||||
return fmt.Errorf("no audio output to the radio is configured — set it in Settings → Audio")
|
||||
// Name the FIELD, not the concept. An operator who had just emptied
|
||||
// "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
|
||||
// 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.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)
|
||||
a.pttMu.Lock()
|
||||
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
|
||||
// hear the radio inside OpsLog. Source = the "From radio" capture device (for a
|
||||
// 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`)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -12669,7 +12747,12 @@ func (a *App) reloadCAT() {
|
||||
// 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
|
||||
// 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":
|
||||
// Native Yaesu CAT over the rig's USB/serial port — no OmniRig. Every
|
||||
// Yaesu fault reported so far came from OmniRig's interpretation layer
|
||||
|
||||
@@ -1,4 +1,54 @@
|
||||
[
|
||||
{
|
||||
"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",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Cluster filters gain NEW POTA and NEW COUNTY, alongside the existing status chips."
|
||||
],
|
||||
"fr": [
|
||||
"Les filtres du cluster gagnent NEW POTA et NEW COUNTY, à côté des pastilles de statut existantes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.4",
|
||||
"date": "",
|
||||
|
||||
+70
-36
@@ -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() {
|
||||
const { t, lang } = useI18n();
|
||||
// === Lists from settings (fallback for first paint) ===
|
||||
@@ -455,27 +482,6 @@ export default function App() {
|
||||
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 [mode, setMode] = useState('SSB');
|
||||
const [freqMhz, setFreqMhz] = useState('');
|
||||
@@ -794,7 +800,15 @@ export default function App() {
|
||||
const resumeRecording = () => {
|
||||
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 = () => {
|
||||
if (dvkStat.playing) { DVKStop(); return; }
|
||||
QSOAudioPlayOnAir().catch((e: any) => setError(String(e?.message ?? e)));
|
||||
};
|
||||
const refreshManualRecReady = () => { QSOAudioManualReady().then(setManualRecReady).catch(() => {}); };
|
||||
@@ -1323,7 +1337,12 @@ export default function App() {
|
||||
// Status filter chips. Empty set = show every status (including
|
||||
// already-worked). Otherwise only matching spots pass.
|
||||
type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
|
||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(() => lsSet<SpotStatusKey>('opslog.clusterStatusFilter'));
|
||||
// What the cluster can be FILTERED on. A superset of the entity status: a new
|
||||
// county or a new park is not a DXCC state, it is another dimension of the
|
||||
// same spot — which is why the grid shows them as separate badges. Filtering
|
||||
// is an OR across all of them, as it already was for a worked callsign.
|
||||
type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county';
|
||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
|
||||
// Mode filter chips. Empty set = show every mode. Categories map the
|
||||
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
||||
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
|
||||
@@ -3697,8 +3716,15 @@ export default function App() {
|
||||
// single-row strip or the full Log4OM-style columnar layout below. Keeping
|
||||
// them as shared consts avoids duplicating the (large) per-field JSX +
|
||||
// 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 = (
|
||||
<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 }}>
|
||||
<span className="text-primary font-semibold">{t('field.callsign')}</span>
|
||||
{lookupBusy && (
|
||||
@@ -3770,10 +3796,11 @@ export default function App() {
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={playRecordingOnAir}
|
||||
title={t('rec.playOnAir')}
|
||||
className="inline-flex items-center justify-center size-4 rounded text-success hover:bg-success/15"
|
||||
title={dvkStat.playing ? t('rec.stopPlaying') : t('rec.playOnAir')}
|
||||
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
|
||||
@@ -3883,7 +3910,7 @@ export default function App() {
|
||||
) : null;
|
||||
const startBlock = (
|
||||
<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
|
||||
readOnly={!locks.start}
|
||||
tabIndex={locks.start ? 0 : -1}
|
||||
@@ -3902,7 +3929,7 @@ export default function App() {
|
||||
);
|
||||
const endBlock = (
|
||||
<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
|
||||
readOnly={!locks.end}
|
||||
tabIndex={locks.end ? 0 : -1}
|
||||
@@ -4092,7 +4119,7 @@ export default function App() {
|
||||
// used in the full layout to save vertical height.
|
||||
const bandRow = (
|
||||
<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">
|
||||
<Select value={band} onValueChange={onBandUserChange}>
|
||||
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
||||
@@ -4103,7 +4130,7 @@ export default function App() {
|
||||
);
|
||||
const modeRow = (
|
||||
<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">
|
||||
<Select value={mode} onValueChange={onModeUserChange}>
|
||||
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
||||
@@ -4159,7 +4186,7 @@ export default function App() {
|
||||
};
|
||||
const freqBlock = (
|
||||
<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
|
||||
tabIndex={-1}
|
||||
className="font-mono"
|
||||
@@ -4269,7 +4296,10 @@ export default function App() {
|
||||
// entity status is still new-band/new-slot (the grid flags it WKD CALL),
|
||||
// matching the "Hide worked" toggle. Additive: it still matches its own
|
||||
// entity status too, so it stays visible under NEW BAND / NEW SLOT.
|
||||
const matches = clusterStatusFilter.has(st) || (!!e?.worked_call && clusterStatusFilter.has('worked'));
|
||||
const matches = clusterStatusFilter.has(st)
|
||||
|| (!!e?.worked_call && clusterStatusFilter.has('worked'))
|
||||
|| (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
|
||||
|| (!!e?.new_county && clusterStatusFilter.has('new-county'));
|
||||
if (!matches) return false;
|
||||
}
|
||||
if (clusterHideWorked) {
|
||||
@@ -4381,10 +4411,14 @@ export default function App() {
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{([
|
||||
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
|
||||
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' },
|
||||
{ k: 'new-mode' as SpotStatusKey, label: 'NEW MODE', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||
{ k: 'new' as SpotFilterKey, label: 'NEW', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
|
||||
{ k: 'new-band' as SpotFilterKey, label: 'NEW BAND', cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' },
|
||||
{ k: 'new-mode' as SpotFilterKey, label: 'NEW MODE', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||
{ k: 'new-slot' as SpotFilterKey, label: 'NEW SLOT', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||
// Same colours as the badges in the grid — a filter that does not
|
||||
// look like what it selects has to be learned twice.
|
||||
{ k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
||||
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
|
||||
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
|
||||
]).map((s) => {
|
||||
const on = clusterStatusFilter.has(s.k);
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||
GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||
GetClublogMostWantedInfo, SetClublogMostWantedEnabled, DownloadClublogMostWanted,
|
||||
GetSecretStatus, SetPassphrase, RemovePassphrase,
|
||||
@@ -1136,7 +1136,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [modeDraft, setModeDraft] = useState('');
|
||||
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,
|
||||
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,
|
||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
||||
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;
|
||||
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
||||
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 };
|
||||
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
||||
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
||||
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 [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>
|
||||
</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' && (
|
||||
@@ -5115,13 +5128,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Label className="text-sm">{t('aud.fromLevel')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</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>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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.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.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.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…',
|
||||
'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 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
||||
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
||||
// CAT panel body
|
||||
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: in the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI', '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.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)',
|
||||
@@ -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.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.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.dvkTitle': 'Voice keyer messages (F1–F6)', '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',
|
||||
@@ -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.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.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 l’enregistrement', '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.progressCount': '{done} / {tot} enregistrements · {pct}%', 'imp.progressCountOnly': '{done} enregistrements…',
|
||||
'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.",
|
||||
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
||||
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
||||
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI', '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.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
||||
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
||||
@@ -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.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.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.dvkTitle': 'Messages du manipulateur vocal (F1–F6)', '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',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.22.4';
|
||||
export const APP_VERSION = '0.22.6';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+2
@@ -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 AudioApplyLevels(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function AudioMonitorActive():Promise<boolean>;
|
||||
|
||||
export function AudioStartMonitor():Promise<void>;
|
||||
|
||||
@@ -70,6 +70,10 @@ export function 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() {
|
||||
return window['go']['main']['App']['AudioMonitorActive']();
|
||||
}
|
||||
|
||||
@@ -461,6 +461,7 @@ export namespace award {
|
||||
ref_count: number;
|
||||
steps: Step[];
|
||||
manual?: string[];
|
||||
superseded?: string[];
|
||||
result: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -476,6 +477,7 @@ export namespace award {
|
||||
this.ref_count = source["ref_count"];
|
||||
this.steps = this.convertValues(source["steps"], Step);
|
||||
this.manual = source["manual"];
|
||||
this.superseded = source["superseded"];
|
||||
this.result = source["result"];
|
||||
}
|
||||
|
||||
@@ -1608,6 +1610,7 @@ export namespace main {
|
||||
from_gain: number;
|
||||
mic_gain: number;
|
||||
tx_gain: number;
|
||||
qso_play_gain: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AudioSettings(source);
|
||||
@@ -1628,6 +1631,7 @@ export namespace main {
|
||||
this.from_gain = source["from_gain"];
|
||||
this.mic_gain = source["mic_gain"];
|
||||
this.tx_gain = source["tx_gain"];
|
||||
this.qso_play_gain = source["qso_play_gain"];
|
||||
}
|
||||
}
|
||||
export class AutostartLaunchResult {
|
||||
@@ -1910,6 +1914,7 @@ export namespace main {
|
||||
xiegu_port: string;
|
||||
xiegu_baud: number;
|
||||
xiegu_addr: number;
|
||||
xiegu_ptt_line: string;
|
||||
yaesu_port: string;
|
||||
yaesu_baud: number;
|
||||
kenwood_host: string;
|
||||
@@ -1949,6 +1954,7 @@ export namespace main {
|
||||
this.xiegu_port = source["xiegu_port"];
|
||||
this.xiegu_baud = source["xiegu_baud"];
|
||||
this.xiegu_addr = source["xiegu_addr"];
|
||||
this.xiegu_ptt_line = source["xiegu_ptt_line"];
|
||||
this.yaesu_port = source["yaesu_port"];
|
||||
this.yaesu_baud = source["yaesu_baud"];
|
||||
this.kenwood_host = source["kenwood_host"];
|
||||
|
||||
@@ -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
|
||||
// stop channel is closed.
|
||||
|
||||
+89
-10
@@ -5,16 +5,28 @@ package audio
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Manager owns the DVK record/playback lifecycle: at most one recording and
|
||||
// one playback at a time. Device ids are passed per call so the host can route
|
||||
// recording to the mic and playback to the rig (or the preview speakers).
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
recStop chan struct{}
|
||||
recDone chan recResult
|
||||
playStop chan struct{}
|
||||
mu sync.Mutex
|
||||
recStop chan struct{}
|
||||
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{}
|
||||
// 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)
|
||||
monRing *pcmRing // live audio hand-off, also fed by the network stream
|
||||
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)
|
||||
}
|
||||
}
|
||||
// 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()
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
m.mu.Lock()
|
||||
m.playStop = stop
|
||||
m.playDone = done
|
||||
m.mu.Unlock()
|
||||
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()
|
||||
if m.playStop == stop {
|
||||
m.playStop = nil
|
||||
}
|
||||
if m.playDone == done {
|
||||
m.playDone = nil
|
||||
}
|
||||
m.mu.Unlock()
|
||||
close(done) // the device is free from here
|
||||
m.notify()
|
||||
}()
|
||||
m.notify()
|
||||
@@ -147,12 +179,24 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error {
|
||||
func (m *Manager) StopPlayback() {
|
||||
m.mu.Lock()
|
||||
stop := m.playStop
|
||||
done := m.playDone
|
||||
m.playStop = nil
|
||||
m.mu.Unlock()
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
m.notify()
|
||||
if stop == nil {
|
||||
return
|
||||
}
|
||||
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) --------------------
|
||||
@@ -188,9 +232,12 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
|
||||
m.mu.Unlock()
|
||||
|
||||
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() {
|
||||
_ = 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.
|
||||
@@ -201,6 +248,38 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
|
||||
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.
|
||||
func (m *Manager) StopMonitor() {
|
||||
m.mu.Lock()
|
||||
|
||||
+70
-7
@@ -3,6 +3,7 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
|
||||
"github.com/braheezy/shine-mp3/pkg/mp3"
|
||||
@@ -50,19 +51,81 @@ func writeMP3(path string, pcm []byte) error {
|
||||
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 {
|
||||
if len(in) == 0 {
|
||||
return in
|
||||
}
|
||||
out := make([]int16, len(in)*2)
|
||||
for i := range in {
|
||||
out[2*i] = in[i]
|
||||
if i+1 < len(in) {
|
||||
out[2*i+1] = int16((int32(in[i]) + int32(in[i+1])) / 2)
|
||||
} else {
|
||||
out[2*i+1] = in[i]
|
||||
half := len(upsampleFIR) / 2
|
||||
for i := range out {
|
||||
var acc float64
|
||||
// The zero-stuffed signal is non-zero only at even indices, so only
|
||||
// every other tap contributes — the loop skips the zeros rather than
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
// 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+21
-2
@@ -859,7 +859,11 @@ type Explanation struct {
|
||||
RefCount int `json:"ref_count"` // size of that list
|
||||
Steps []Step `json:"steps"`
|
||||
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
|
||||
Result []string `json:"result"` // what the QSO finally counts for
|
||||
// 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
|
||||
}
|
||||
|
||||
// Explain runs the matcher on a single QSO and reports what it did. It goes
|
||||
@@ -977,7 +981,22 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
|
||||
if ex != nil {
|
||||
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)
|
||||
if ex != nil {
|
||||
ex.Result = out
|
||||
|
||||
@@ -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
@@ -492,7 +492,25 @@ func (k *Kenwood) openPort() (serial.Port, error) {
|
||||
}
|
||||
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
|
||||
|
||||
@@ -56,6 +56,9 @@ type Xiegu struct {
|
||||
// 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.
|
||||
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 {
|
||||
@@ -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) 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)
|
||||
}
|
||||
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.splitSupported = true
|
||||
|
||||
@@ -177,12 +202,25 @@ func (x *Xiegu) SetMode(mode string) error {
|
||||
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 {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
if x.port == nil {
|
||||
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)
|
||||
if on {
|
||||
v = 0x01
|
||||
|
||||
@@ -150,6 +150,20 @@ func (y *Yaesu) Connect() error {
|
||||
if err != nil {
|
||||
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)
|
||||
y.port = p
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ package cluster
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"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.
|
||||
// Returns the moment we marked the link "connected" (zero if dial failed)
|
||||
// and the error that ended the session (nil if stopCh).
|
||||
func (s *session) runOnce() (time.Time, error) {
|
||||
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 {
|
||||
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 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)
|
||||
for {
|
||||
select {
|
||||
@@ -431,11 +451,37 @@ func (s *session) runOnce() (time.Time, error) {
|
||||
default:
|
||||
}
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(120 * time.Second))
|
||||
line, err := rd.ReadString('\n')
|
||||
// The deadline exists to keep this loop responsive to stopCh, NOT to
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
quiet = 0
|
||||
line := pending + chunk
|
||||
pending = ""
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
// 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
|
||||
|
||||
@@ -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
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.22.4"
|
||||
appVersion = "0.22.6"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user