feat: a level control for the voice keyer, and name every CAT backend in its PTT list

The messages went to the radio exactly as recorded. Nothing in OpsLog could
raise them, so a microphone captured quietly drove the rig quietly and the only
remedies were the radio's own USB input menu or the Windows mixer — which is
where the operator was heading. There is now a level from 10 to 400 %, applied
with clamping (a wrap would turn loud speech into noise on the air), and Play
previews at that same level so the adjustment is made against what will actually
be transmitted.

The PTT method list also named only OmniRig, Flex, Icom and TCI, so choosing a
native Yaesu left "CAT" with no backend beside it — which reads as "there is no
CAT PTT for my radio" and sends the operator to RTS on a COM port that has
nothing to do with the rig. Same list-needing-every-member shape as three
earlier bugs in this feature. The TestPTT log line had the same rot: it said
"CAT via OmniRig" whatever backend was running.
This commit is contained in:
2026-07-29 14:29:47 +02:00
parent 8da3a27803
commit 67a03c3ddc
6 changed files with 84 additions and 11 deletions
+29 -6
View File
@@ -137,6 +137,7 @@ const (
keyAudioQSORecord = "audio.qso_record" // "1" → auto-record every QSO
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"
@@ -6794,6 +6795,7 @@ type AudioSettings struct {
Format string `json:"format"` // "wav" | "mp3"
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
}
// ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints
@@ -6803,14 +6805,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}
out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav", FromGain: 100, MicGain: 100, TXGain: 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)
keyAudioFromGain, keyAudioMicGain, keyAudioTXGain)
if err != nil {
return out, err
}
@@ -6838,6 +6840,9 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
if n, _ := strconv.Atoi(m[keyAudioMicGain]); n > 0 && n <= 400 {
out.MicGain = n
}
if n, _ := strconv.Atoi(m[keyAudioTXGain]); n > 0 && n <= 400 {
out.TXGain = n
}
return out, nil
}
@@ -6867,6 +6872,11 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
if s.MicGain <= 0 || s.MicGain > 400 {
s.MicGain = 100
}
// Up to 400 %: a mic recorded quietly needs real amplification, and the
// clamp in the player keeps it from wrapping into noise.
if s.TXGain <= 0 || s.TXGain > 400 {
s.TXGain = 100
}
for k, v := range map[string]string{
keyAudioFromRadio: s.FromRadio,
keyAudioToRadio: s.ToRadio,
@@ -6880,6 +6890,7 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
keyAudioFormat: format,
keyAudioFromGain: strconv.Itoa(s.FromGain),
keyAudioMicGain: strconv.Itoa(s.MicGain),
keyAudioTXGain: strconv.Itoa(s.TXGain),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -8356,7 +8367,7 @@ func (a *App) DVKPlay(slot int) error {
a.dvkPttKeyed = true
a.pttMu.Unlock()
}
if err := a.audioMgr.Play(cfg.ToRadio, path); err != nil {
if err := a.audioMgr.Play(cfg.ToRadio, path, cfg.TXGain); err != nil {
a.pttMu.Lock()
keyed := a.dvkPttKeyed
gen := a.pttGen
@@ -8400,7 +8411,8 @@ func (a *App) unkeyIfCurrent(gen int64) {
}
// pttKey keys the transmitter using the configured method:
// - "cat" → OmniRig (sets the Tx parameter to PM_TX)
// - "cat" → whichever CAT backend is active (OmniRig, Flex, Icom, TCI,
// Yaesu, Xiegu — they all implement SetPTT)
// - "rts"/"dtr" → open the COM port and assert that line, held during TX
// - "none" → VOX, nothing to do
func (a *App) pttKey(cfg AudioSettings) error {
@@ -8490,7 +8502,16 @@ func (a *App) TestPTT(cfg AudioSettings) error {
if cfg.PTTMethod == "rts" || cfg.PTTMethod == "dtr" {
applog.Printf("ptt: TestPTT method=%q port=%q", cfg.PTTMethod, cfg.PTTPort)
} else {
applog.Printf("ptt: TestPTT method=%q (CAT via OmniRig — serial port not used)", cfg.PTTMethod)
// Name the backend that will actually key — "via OmniRig" was left over
// from when that was the only one, and it sent operators on a native
// backend looking for a problem with OmniRig that they do not even run.
backend := "no CAT backend"
if a.cat != nil {
if st := a.cat.State(); st.Backend != "" {
backend = st.Backend
}
}
applog.Printf("ptt: TestPTT method=%q (CAT via %s — serial port not used)", cfg.PTTMethod, backend)
}
if cfg.PTTMethod == "" || cfg.PTTMethod == "none" {
return fmt.Errorf("PTT method is None (VOX) — pick CAT, RTS or DTR first")
@@ -8524,7 +8545,9 @@ func (a *App) DVKPreview(slot int) error {
return fmt.Errorf("audio not initialized")
}
cfg, _ := a.GetAudioSettings()
return a.audioMgr.Play(cfg.ListeningDevice, a.dvkPath(slot))
// Preview at the SAME level the rig will get, or the operator adjusts against
// a sound that is not the one going on the air.
return a.audioMgr.Play(cfg.ListeningDevice, a.dvkPath(slot), cfg.TXGain)
}
// DVKStop halts any voice-keyer playback.
+4
View File
@@ -3,6 +3,8 @@
"version": "0.21.9",
"date": "2026-07-28",
"en": [
"The voice keyer PTT list now names the Yaesu and Xiegu backends too — with a native backend selected it showed no CAT name, which read as \"CAT PTT is not available for my radio\".",
"Voice keyer: a level control for the messages sent to the radio (Settings → Audio). They went out exactly as recorded, so a quietly recorded microphone drove the rig quietly with nothing in OpsLog to fix it. Play previews at the same level.",
"Yaesu console meters are right: power now reads the meter that follows the RF (in watts, on a curve measured against the rig own display, not the power setting) and SWR shows the ratio itself. Both were reading the wrong index — the SWR bar sat near 80 on a perfect match. The bars also hold their peak for a moment before falling back, so they stay readable through the gaps between words in a CQ, in CW as in SSB.",
"CW speed is now one setting wherever you change it: the Yaesu console slider and the CW panel drive the keyer that is actually sending, and the rig own keyer follows so its front panel agrees.",
"Antenna Genius: port A no longer jumps onto port B antenna a few seconds after a change. Only one port can hold the transmit antenna, so the switch reports the same one on both — the display now follows each port own receive antenna.",
@@ -21,6 +23,8 @@
"CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes."
],
"fr": [
"La liste PTT du voice keyer nomme aussi les backends Yaesu et Xiegu — avec un backend natif sélectionné, aucun nom de CAT n'apparaissait, ce qui se lisait « il n'y a pas de PTT CAT pour ma radio ».",
"Voice keyer : un réglage de niveau pour les messages envoyés à la radio (Réglages → Audio). Ils partaient exactement tels qu'enregistrés : un micro capté faiblement attaquait donc la radio faiblement, sans rien dans OpsLog pour y remédier. Le bouton Lire fait entendre ce même niveau.",
"Les mesures de la console Yaesu sont justes : la puissance lit l'instrument qui suit la HF (en watts, sur une courbe relevée face à l'affichage de la radio, et non le réglage de puissance) et le ROS affiche le rapport lui-même. Les deux lisaient le mauvais index — la barre de ROS restait vers 80 sur une antenne parfaite. Les barres retiennent aussi leur crête un instant avant de redescendre : elles restent lisibles pendant les silences entre les mots dun CQ, en CW comme en phonie.",
"La vitesse CW est désormais un réglage unique où que vous la changiez : le curseur de la console Yaesu et le panneau CW pilotent le manipulateur qui émet réellement, et le keyer interne de la radio suit pour que sa façade affiche la même valeur.",
"Antenna Genius : le port A ne bascule plus sur l'antenne du port B quelques secondes après un changement. Un seul port peut porter l'antenne d'émission, le switch renvoie donc la même sur les deux — l'affichage suit désormais l'antenne de réception propre à chaque port.",
+17 -2
View File
@@ -1114,13 +1114,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;
from_gain: number; mic_gain: number; tx_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,
from_gain: 100, mic_gain: 100, tx_gain: 100,
});
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
@@ -4849,6 +4849,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
icom: t('cat.optIcom'),
'icom-net': t('cat.optIcomNet'),
tci: t('cat.optTci'),
yaesu: t('cat.optYaesu'),
xiegu: t('cat.optXiegu'),
} as Record<string, string>)[catCfg.backend] ?? '';
const deviceSelect = (
@@ -5022,6 +5024,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)}
</div>
</div>
{/* Playback level. Nothing else could raise it: the message went to the
rig exactly as recorded, so a quietly recorded mic drove the radio
quietly and the only remedies were the rig's own USB input menu or
the Windows mixer. */}
<div className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2">
<Label className="text-sm">{t('aud.txLevel')}</Label>
<div className="flex items-center gap-2">
<input type="range" min={10} max={400} step={5} value={audioCfg.tx_gain}
onChange={(e) => setAudioField({ tx_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.tx_gain}%</span>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('aud.txLevelHint')}</p>
{dvkErr && <p className="text-[11px] text-destructive">{dvkErr}</p>}
<div className="space-y-1.5">
{dvkMsgs.map((m) => {
+2 -2
View File
@@ -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.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.', 'aud.micLevel': 'Mic 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 (F1F6)', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR',
'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh',
@@ -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.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.", 'aud.micLevel': 'Niveau micro', '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 (F1F6)', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série',
'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser',
+2
View File
@@ -1601,6 +1601,7 @@ export namespace main {
format: string;
from_gain: number;
mic_gain: number;
tx_gain: number;
static createFrom(source: any = {}) {
return new AudioSettings(source);
@@ -1620,6 +1621,7 @@ export namespace main {
this.format = source["format"];
this.from_gain = source["from_gain"];
this.mic_gain = source["mic_gain"];
this.tx_gain = source["tx_gain"];
}
}
export class AutostartLaunchResult {
+30 -1
View File
@@ -104,11 +104,27 @@ func (m *Manager) IsPlaying() bool {
// Play renders a WAV file to deviceID. Any current playback is stopped first.
// Returns immediately; playback runs in the background.
func (m *Manager) Play(deviceID, path string) error {
// Play sends a recorded message to a device, amplified by gainPct (100 = as
// recorded).
//
// The gain exists because nothing else could raise the level: the message went
// out exactly as captured, so a mic recorded quietly drove the rig quietly and
// the operator had no control anywhere in OpsLog — only the radio's own USB
// input level, buried in its menus, and the Windows mixer.
func (m *Manager) Play(deviceID, path string, gainPct int) error {
pcm, rate, ch, bits, err := readWAV(path)
if err != nil {
return err
}
if gainPct > 0 && gainPct != 100 && bits == 16 {
g := float64(gainPct) / 100
// In place: the buffer is this call's own copy of the file.
for i := 0; i+1 < len(pcm); i += 2 {
v := int16(uint16(pcm[i]) | uint16(pcm[i+1])<<8)
v = scalePCM(v, g)
pcm[i], pcm[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
}
}
m.StopPlayback()
stop := make(chan struct{})
m.mu.Lock()
@@ -265,3 +281,16 @@ func (m *Manager) TXAudioActive() bool {
defer m.mu.Unlock()
return m.txStop != nil
}
// scalePCM applies a gain to one sample, clamping rather than wrapping — an
// overflow that wraps turns loud speech into a burst of noise on the air.
func scalePCM(s int16, g float64) int16 {
v := float64(s) * g
if v > 32767 {
return 32767
}
if v < -32768 {
return -32768
}
return int16(v)
}