Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa995c250c | ||
|
|
72fee7a090 | ||
|
|
ed67ed7fe3 | ||
|
|
fddb3c45c4 | ||
|
|
da1f3eb2bd | ||
|
|
5e0bb6e68e | ||
|
|
2b61f43780 | ||
|
|
2b66e5bc7f | ||
|
|
84b06ed47b | ||
|
|
39bd1ff414 | ||
|
|
6d54100bc9 | ||
|
|
4fe5811fd2 | ||
|
|
d42e6669e1 | ||
|
|
c14353a399 | ||
|
|
734931cf54 | ||
|
|
cad4ec4b72 |
@@ -141,11 +141,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
|
||||
@@ -1220,6 +1229,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).
|
||||
@@ -6949,6 +6967,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 +6977,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 +7015,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 +7049,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 +7069,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 +7077,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 +7769,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 +7794,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 +8918,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 +8968,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,32 @@
|
||||
[
|
||||
{
|
||||
"version": "0.22.5",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Cluster filters gain NEW POTA and NEW COUNTY, alongside the existing status chips.",
|
||||
"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": [
|
||||
"Les filtres du cluster gagnent NEW POTA et NEW COUNTY, à côté des pastilles de statut existantes.",
|
||||
"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.4",
|
||||
"date": "",
|
||||
|
||||
+70
-38
@@ -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,13 @@ 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.
|
||||
// The room comes from the RST pair, which never needs more than five
|
||||
// characters.
|
||||
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 +3794,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
|
||||
@@ -3844,12 +3869,12 @@ export default function App() {
|
||||
</div>
|
||||
);
|
||||
const rstTxBlock = (
|
||||
<div className="flex flex-col w-20" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
|
||||
<div className="flex flex-col w-16" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
|
||||
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
|
||||
</div>
|
||||
);
|
||||
const rstRxBlock = (
|
||||
<div className="flex flex-col w-20" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
|
||||
<div className="flex flex-col w-16" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
|
||||
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
|
||||
</div>
|
||||
);
|
||||
@@ -3883,7 +3908,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 +3927,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 +4117,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 +4128,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 +4184,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 +4294,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 +4409,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,
|
||||
@@ -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[]>([]);
|
||||
@@ -5115,13 +5115,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.',
|
||||
@@ -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é.',
|
||||
@@ -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']();
|
||||
}
|
||||
|
||||
@@ -1608,6 +1608,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 +1629,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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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