diff --git a/app.go b/app.go index 06253c4..9ebfa15 100644 --- a/app.go +++ b/app.go @@ -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 @@ -6958,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 @@ -6967,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 } @@ -7005,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 } @@ -7036,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 } @@ -7053,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 @@ -7777,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 diff --git a/changelog.json b/changelog.json index 5a8e09d..891530a 100644 --- a/changelog.json +++ b/changelog.json @@ -10,7 +10,8 @@ "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." + "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." ], "fr": [ "Les filtres du cluster gagnent NEW POTA et NEW COUNTY, à côté des pastilles de statut existantes.", @@ -20,7 +21,8 @@ "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." + "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." ] }, { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 28cb4bf..f1be114 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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({ 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([]); const [audioOutputs, setAudioOutputs] = useState([]); @@ -5118,6 +5118,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan onChange={(e) => { const v = parseInt(e.target.value, 10); setAudioField({ from_gain: v }); AudioApplyLevels(v, audioCfg.mic_gain); }} className="w-48 accent-primary" /> {audioCfg.from_gain}% + +
+ setAudioField({ qso_play_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" /> + {audioCfg.qso_play_gain}% +