chore: release v0.21.3

This commit is contained in:
2026-07-26 16:57:19 +02:00
parent 4fd70f6a9d
commit 91b5af1c7b
46 changed files with 2491 additions and 355 deletions
+66 -53
View File
@@ -4528,6 +4528,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}
function AudioPanel() {
// Names the CAT backend the operator has configured, so the PTT dropdown can
// say "CAT — Icom CI-V (USB)" rather than the flatly wrong "CAT (OmniRig)".
const catBackendLabel = ({
omnirig: t('cat.optOmnirig'),
flex: t('cat.optFlex'),
icom: t('cat.optIcom'),
'icom-net': t('cat.optIcomNet'),
tci: t('cat.optTci'),
} as Record<string, string>)[catCfg.backend] ?? '';
const deviceSelect = (
field: keyof AudioSettings,
devices: AudioDev[],
@@ -4539,9 +4549,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
>
<SelectTrigger className="h-8"><SelectValue placeholder={placeholder} /></SelectTrigger>
<SelectContent>
<SelectItem value="_"> none / system default </SelectItem>
<SelectItem value="_">{t('aud.noneDefault')}</SelectItem>
{devices.map((d) => (
<SelectItem key={d.id} value={d.id}>{d.name}{d.default ? ' (default)' : ''}</SelectItem>
<SelectItem key={d.id} value={d.id}>{d.name}{d.default ? ' ' + t('aud.defaultTag') : ''}</SelectItem>
))}
</SelectContent>
</Select>
@@ -4552,24 +4562,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SectionHeader
title={t('hw.audioVoice')}/>
<Button variant="outline" size="sm" className="h-7 text-[11px] shrink-0" onClick={reloadAudioDevices}>
Refresh devices
{t('aud.refreshDevices')}
</Button>
</div>
<div className="space-y-3 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">From Radio (RX in)</Label>
{deviceSelect('from_radio', audioInputs, 'Rig audio output → soundcard input')}
<Label className="text-sm">To Radio (TX out)</Label>
{deviceSelect('to_radio', audioOutputs, 'Soundcard output → rig mic/data in')}
<Label className="text-sm">Recording mic</Label>
{deviceSelect('recording_device', audioInputs, 'Your microphone (record DVK messages)')}
<Label className="text-sm">Listening (preview)</Label>
{deviceSelect('listening_device', audioOutputs, 'Local speakers for preview')}
<Label className="text-sm">{t('aud.fromRadio')}</Label>
{deviceSelect('from_radio', audioInputs, t('aud.phFromRadio'))}
<Label className="text-sm">{t('aud.toRadio')}</Label>
{deviceSelect('to_radio', audioOutputs, t('aud.phToRadio'))}
<Label className="text-sm">{t('aud.recMic')}</Label>
{deviceSelect('recording_device', audioInputs, t('aud.phRecMic'))}
<Label className="text-sm">{t('aud.listening')}</Label>
{deviceSelect('listening_device', audioOutputs, t('aud.phListening'))}
</div>
<p className="text-[11px] text-muted-foreground">
<strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '}
<strong>To Radio</strong> = where voice-keyer messages are transmitted.
<strong>{t('aud.fromRadioShort')}</strong> {t('aud.explainFrom')}{' '}
<strong>{t('aud.toRadioShort')}</strong> {t('aud.explainTo')}
</p>
<div className="flex items-center gap-3">
<Button
@@ -4578,14 +4588,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
className="h-8"
onClick={toggleMonitor}
disabled={!monitorOn && !audioCfg.from_radio}
title="Hear the rig's RX audio (From Radio) through your Listening device"
title={t('aud.monitorTitle')}
>
{monitorOn ? '■ Stop listening' : '▶ Listen to radio'}
{monitorOn ? t('aud.stopListening') : t('aud.listenRadio')}
</Button>
<span className="text-[11px] text-muted-foreground">
{monitorOn
? 'RX monitor running — From Radio → Listening device.'
: 'Live-monitor the rig here (USB codec now; network audio later).'}
{monitorOn ? t('aud.monitorOn') : t('aud.monitorHint')}
</span>
</div>
<div className="flex items-center gap-3">
@@ -4595,101 +4603,106 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
className="h-8"
onClick={toggleTX}
disabled={!txOn && !audioCfg.to_radio}
title="Key PTT and pipe your live mic into the rig (To Radio device)"
title={t('aud.txTitle')}
>
{txOn ? '■ Stop talking (TX)' : '🎙 Talk to radio (TX)'}
{txOn ? t('aud.stopTalk') : t('aud.talkRadio')}
</Button>
<span className="text-[11px] text-muted-foreground">
{txOn
? 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.'
: 'Live mic → rig with PTT (USB now; network TX later).'}
{txOn ? t('aud.txOn') : t('aud.txHint')}
</span>
</div>
</div>
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
<h4 className="text-sm font-semibold text-foreground">QSO recorder</h4>
<h4 className="text-sm font-semibold text-foreground">{t('aud.recorder')}</h4>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={audioCfg.qso_record} onCheckedChange={(c) => setAudioField({ qso_record: !!c })} />
Record every QSO to an audio file (From Radio + your mic)
{t('aud.recordEvery')}
</label>
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">Recordings folder</Label>
<Label className="text-sm">{t('aud.recFolder')}</Label>
<div className="flex gap-2">
<Input value={audioCfg.qso_dir} onChange={(e) => setAudioField({ qso_dir: e.target.value })}
placeholder="C:\…\OpsLog\Recordings" className="h-8 font-mono text-xs" />
<Button variant="outline" size="sm" className="h-8 shrink-0"
onClick={() => PickAudioFolder().then((d) => { if (d) setAudioField({ qso_dir: d }); }).catch(() => {})}>
Browse
{t('aud.browse')}
</Button>
</div>
<Label className="text-sm">Pre-roll (seconds)</Label>
<Label className="text-sm">{t('aud.preroll')}</Label>
<Input type="number" min={0} max={60} value={audioCfg.preroll_seconds}
onChange={(e) => setAudioField({ preroll_seconds: Math.max(0, Math.min(60, parseInt(e.target.value, 10) || 0)) })}
className="h-8 w-24 font-mono" />
<Label className="text-sm">File format</Label>
<Label className="text-sm">{t('aud.format')}</Label>
<Select value={audioCfg.format} onValueChange={(v) => setAudioField({ format: v as any })}>
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="wav">WAV (lossless, larger)</SelectItem>
<SelectItem value="mp3">MP3 (compressed, small)</SelectItem>
<SelectItem value="wav">{t('aud.wav')}</SelectItem>
<SelectItem value="mp3">{t('aud.mp3')}</SelectItem>
</SelectContent>
</Select>
<Label className="text-sm">From Radio level</Label>
<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" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
</div>
<Label className="text-sm">Mic level</Label>
<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" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span>
</div>
</div>
<p className="text-xs text-muted-foreground">If your voice is louder than the station, lower Mic level.</p>
<p className="text-xs text-muted-foreground">{t('aud.levelHint')}</p>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={emailCfg.auto_send} onCheckedChange={(c) => setEmailField({ auto_send: !!c })} />
Auto-send the recording to the station by e-mail when I log a QSO
{t('aud.autoSend')}
</label>
</div>
<div className="border-t border-border/60 pt-3 space-y-2 max-w-2xl">
<h4 className="text-sm font-semibold text-foreground">Voice keyer messages (F1F6)</h4>
<h4 className="text-sm font-semibold text-foreground">{t('aud.dvkTitle')}</h4>
<div className="rounded-md border border-border/60 p-2.5 space-y-2">
<div className="grid grid-cols-[120px_1fr] gap-2 items-center">
<Label className="text-sm">PTT method</Label>
<Label className="text-sm">{t('aud.pttMethod')}</Label>
<div className="flex gap-2 items-center">
<Select value={audioCfg.ptt_method} onValueChange={(v) => setAudioField({ ptt_method: v as any })}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None (VOX)</SelectItem>
<SelectItem value="cat">CAT (OmniRig)</SelectItem>
<SelectItem value="rts">Serial RTS</SelectItem>
<SelectItem value="dtr">Serial DTR</SelectItem>
<SelectItem value="none">{t('aud.pttNone')}</SelectItem>
{/* This has ALWAYS driven whichever CAT backend is active —
it calls the generic manager, and every backend (OmniRig,
FlexRadio, Icom CI-V over USB and over the network, TCI)
implements SetPTT. The label said "CAT (OmniRig)", so
operators on a CI-V rig concluded there was no CAT PTT for
them and reached for RTS/DTR or VOX instead. Name the
backend actually configured. */}
<SelectItem value="cat">{t('aud.pttCat')}{catBackendLabel ? `${catBackendLabel}` : ''}</SelectItem>
<SelectItem value="rts">{t('aud.pttRts')}</SelectItem>
<SelectItem value="dtr">{t('aud.pttDtr')}</SelectItem>
</SelectContent>
</Select>
{audioCfg.ptt_method !== 'none' && (
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT(audioCfg as any).catch((e: any) => setDvkErr('PTT test: ' + String(e?.message ?? e))); }}>
Test PTT
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT(audioCfg as any).catch((e: any) => setDvkErr(t('aud.errPttTest') + String(e?.message ?? e))); }}>
{t('aud.testPtt')}
</Button>
)}
</div>
{(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
<>
<Label className="text-sm">PTT COM port</Label>
<Label className="text-sm">{t('aud.pttPort')}</Label>
<div className="flex gap-2 items-center">
<Select value={audioCfg.ptt_port || '_'} onValueChange={(v) => setAudioField({ ptt_port: v === '_' ? '' : v })}>
<SelectTrigger className="h-8 w-44"><SelectValue placeholder="Pick a COM port" /></SelectTrigger>
<SelectTrigger className="h-8 w-44"><SelectValue placeholder={t('aud.pickPort')} /></SelectTrigger>
<SelectContent>
<SelectItem value="_"> select </SelectItem>
<SelectItem value="_">{t('aud.selectPort')}</SelectItem>
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" className="h-8 text-[11px]"
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
Refresh
{t('aud.refresh')}
</Button>
</div>
</>
@@ -4706,7 +4719,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<span className="w-7 font-mono text-xs font-bold text-muted-foreground">F{m.slot}</span>
<Input
className="h-8 flex-1"
placeholder={`Message ${m.slot} label (CQ, report, 73…)`}
placeholder={t('aud.msgPlaceholder', { n: m.slot })}
value={m.label}
onChange={(e) => setDvkMsgs((ms) => ms.map((x) => x.slot === m.slot ? { ...x, label: e.target.value } : x))}
onBlur={(e) => SetDVKLabel(m.slot, e.target.value).catch(() => {})}
@@ -4722,21 +4735,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
setDvkErr('');
DVKStartRecord(m.slot).catch((err) => setDvkErr('Record: ' + String(err?.message ?? err)));
DVKStartRecord(m.slot).catch((err) => setDvkErr(t('aud.errRecord') + String(err?.message ?? err)));
}}
onPointerUp={() => {
DVKStopRecord().then(reloadDvk).catch((err) => setDvkErr('Save: ' + String(err?.message ?? err)));
DVKStopRecord().then(reloadDvk).catch((err) => setDvkErr(t('aud.errSave') + String(err?.message ?? err)));
}}
>
{recHere ? '● Recording…' : '● Hold to rec'}
{recHere ? t('aud.recordingNow') : t('aud.holdRec')}
</Button>
<Button
type="button"
variant="outline" size="sm" className="h-8 w-20 shrink-0"
disabled={!m.has_audio || dvkStat.recording}
onClick={() => (dvkStat.playing ? DVKStop() : DVKPreview(m.slot).catch((err) => setDvkErr('Play: ' + String(err?.message ?? err))))}
onClick={() => (dvkStat.playing ? DVKStop() : DVKPreview(m.slot).catch((err) => setDvkErr(t('aud.errPlay') + String(err?.message ?? err))))}
>
{dvkStat.playing ? '■ Stop' : '▶ Play'}
{dvkStat.playing ? t('aud.stop') : t('aud.play')}
</Button>
</div>
);