diff --git a/app.go b/app.go
index d542207..465bd62 100644
--- a/app.go
+++ b/app.go
@@ -538,10 +538,15 @@ type App struct {
ampsMu sync.Mutex // guards ampInsts
ampInsts map[string]*ampInst // one running client per enabled configured amplifier, by config ID
audioMgr *audio.Manager
- qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
- solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
- lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
- scp *scp.Manager // Super Check Partial / N+1 callsign master list
+ qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
+ // qsoRecManual marks a take the operator started by hand while automatic
+ // recording is OFF. Such a take opened the sound devices itself, so it must
+ // close them again when it ends — an operator who records one contact does
+ // not expect their microphone held open for the rest of the session.
+ qsoRecManual atomic.Bool
+ solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
+ lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
+ scp *scp.Manager // Super Check Partial / N+1 callsign master list
// NET Control: persistent net definitions/rosters (global JSON) + the live
// session (in-memory only — active stations currently in QSO).
@@ -7059,6 +7064,10 @@ func (a *App) saveQSORecording(q *qso.QSO) {
applog.Printf("qso-rec: PANIC in saveQSORecording: %v", r)
}
}()
+ // Every exit from here ends the take, including the digital-mode discard and
+ // the snapshot failure below — so release the devices from one place rather
+ // than remembering to do it at each return.
+ defer a.stopManualQSORecorder()
if a.qsoRec == nil || !a.qsoRec.Active() {
return
}
@@ -7536,12 +7545,74 @@ func (a *App) QSOAudioResetClock() bool {
return a.qsoRec.Active()
}
+// QSOAudioManualReady reports whether a recording can be started BY HAND: the
+// capture devices are configured, but automatic recording is off.
+//
+// It is what decides whether the entry strip offers a record button. Without
+// it the button would appear for operators who have no sound card configured
+// and could only ever produce an error.
+func (a *App) QSOAudioManualReady() bool {
+ if a.qsoRec == nil {
+ return false
+ }
+ cfg, _ := a.GetAudioSettings()
+ return !cfg.QSORecord && strings.TrimSpace(cfg.FromRadio) != ""
+}
+
+// QSOAudioManualStart begins a recording when automatic recording is off.
+//
+// With the automatic recorder disabled the capture engine is not merely idle,
+// it is not running at all — so this starts it first. That costs the pre-roll:
+// an automatic recording opens with the seconds BEFORE the callsign was typed,
+// a manual one can only start now. Better to say that in a comment than to have
+// an operator wonder why one file has the DX's call in it and the other does
+// not.
+func (a *App) QSOAudioManualStart() bool {
+ if a.qsoRec == nil {
+ return false
+ }
+ if !a.qsoRec.Running() {
+ cfg, _ := a.GetAudioSettings()
+ if strings.TrimSpace(cfg.FromRadio) == "" {
+ return false
+ }
+ if err := a.qsoRec.Start(cfg.FromRadio, cfg.RecordingDevice, cfg.PrerollSeconds); err != nil {
+ applog.Printf("qso-rec: manual start failed: %v", err)
+ return false
+ }
+ fromGain, micGain := float64(cfg.FromGain)/100, float64(cfg.MicGain)/100
+ if cfg.FromGain == 0 {
+ fromGain = 1
+ }
+ if cfg.MicGain == 0 {
+ micGain = 1
+ }
+ a.qsoRec.SetGains(fromGain, micGain)
+ a.qsoRecManual.Store(true)
+ applog.Printf("qso-rec: manual recording started by the operator")
+ }
+ a.qsoRec.BeginQSO()
+ return a.qsoRec.Active()
+}
+
+// stopManualQSORecorder releases the sound devices after a hand-started take,
+// but only if this session opened them. When automatic recording is on the
+// engine belongs to the app and must keep running for the next contact.
+func (a *App) stopManualQSORecorder() {
+ if a.qsoRec == nil || !a.qsoRecManual.Swap(false) {
+ return
+ }
+ a.qsoRec.Stop()
+ applog.Printf("qso-rec: manual recording finished — sound devices released")
+}
+
// QSOAudioCancel drops the in-progress recording (callsign cleared, QSO
// abandoned without logging).
func (a *App) QSOAudioCancel() {
if a.qsoRec != nil {
a.qsoRec.DiscardQSO()
}
+ a.stopManualQSORecorder()
}
// RestartQSORecorder applies new audio settings to the running recorder.
diff --git a/changelog.json b/changelog.json
index 206aabf..40858d7 100644
--- a/changelog.json
+++ b/changelog.json
@@ -11,7 +11,8 @@
"The protocol trace checkboxes (CAT and WinKeyer) now show whether the trace is really running. They came back unticked on a trace that was still on, so ticking the box to enable it actually switched it off and the log sent afterwards contained no trace.",
"A radio reporting LSB or USB now selects SSB in the entry form. Radios report the sideband, the mode list holds SSB, so nothing matched and the mode stayed empty while the frequency tracked correctly. It also keeps the logged mode ADIF-valid: LSB and USB are submodes there, not modes.",
"Kenwood CAT can go over the network: give a host:port instead of a COM port and OpsLog talks to a serial bridge (ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio). This is not the radio's own RJ45, which speaks Kenwood's KNS protocol.",
- "The CAT backend list is renamed by how the radio is reached: OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, network), Xiegu (USB), Icom (CI-V USB), Icom (CI-V network), TCI."
+ "The CAT backend list is renamed by how the radio is reached: OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, network), Xiegu (USB), Icom (CI-V USB), Icom (CI-V network), TCI.",
+ "With automatic recording off but the audio devices configured, a red dot appears where the recording counter goes. Click it to record the contact by hand: the counter starts, the dot disappears, and logging the QSO saves the file as usual. The sound devices are released again afterwards."
],
"fr": [
"Le backend Kenwood est désormais éprouvé face à une radio qui répond : OpsLog dialogue avec l'émulateur TS-2000 qu'il embarque déjà pour les amplis ACOM, ce qui vérifie fréquence, mode, VFO, split et PTT de bout en bout. Un essai sur un vrai Kenwood reste nécessaire, mais le dialogue n'est plus non testé.",
@@ -22,7 +23,8 @@
"Les cases de trace du protocole (CAT et WinKeyer) reflètent désormais l'état réel de la trace. Elles réapparaissaient décochées alors que la trace tournait : cocher la case pour l'activer la désactivait en fait, et le journal envoyé ensuite ne contenait aucune trace.",
"Une radio qui annonce LSB ou USB sélectionne désormais SSB dans la saisie. Les radios annoncent la bande latérale, la liste des modes contient SSB : rien ne correspondait et le mode restait vide alors que la fréquence suivait. Cela garde aussi le mode journalisé conforme à l'ADIF, où LSB et USB sont des sous-modes et non des modes.",
"Le CAT Kenwood peut passer par le réseau : indiquez un hôte:port au lieu d'un port COM et OpsLog dialogue avec un pont série (ser2net, boîtier Ethernet-série, Raspberry Pi près de la radio). Il ne s'agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood.",
- "La liste des backends CAT est renommée selon la façon dont la radio est jointe : OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, réseau), Xiegu (USB), Icom (CI-V USB), Icom (CI-V réseau), TCI."
+ "La liste des backends CAT est renommée selon la façon dont la radio est jointe : OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, réseau), Xiegu (USB), Icom (CI-V USB), Icom (CI-V réseau), TCI.",
+ "Quand l'enregistrement automatique est désactivé mais que les périphériques audio sont configurés, un rond rouge apparaît à l'emplacement du compteur. Un clic enregistre le contact à la main : le compteur démarre, le rond disparaît, et journaliser le QSO enregistre le fichier comme d'habitude. Les périphériques audio sont ensuite relâchés."
]
},
{
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 9b421aa..5839087 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -44,6 +44,7 @@ import {
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
+ QSOAudioManualReady, QSOAudioManualStart,
GetAwardDefs,
GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp,
ReportLiveActivity, LiveLastQSOAgeSec,
@@ -771,6 +772,19 @@ export default function App() {
// Bumped on every (re)start so the timer resets even when `recording` was
// already true (jumping spot→spot keeps recording=true but starts a fresh take).
const [recTick, setRecTick] = useState(0);
+ // True when the sound devices are configured but automatic recording is OFF —
+ // the only case where a record button makes sense. Asking the backend rather
+ // than reading a preference here keeps the two from drifting apart.
+ const [manualRecReady, setManualRecReady] = useState(false);
+ const refreshManualRecReady = () => { QSOAudioManualReady().then(setManualRecReady).catch(() => {}); };
+ useEffect(() => { refreshManualRecReady(); }, []);
+ const startManualRecording = () => {
+ QSOAudioManualStart().then((active) => {
+ setRecording(active);
+ setRecTick((t) => t + 1);
+ if (!active) setError(t('rec.manualFailed'));
+ }).catch((e: any) => setError(String(e?.message ?? e)));
+ };
useEffect(() => {
if (!recording) { setRecSeconds(0); return; }
const start = Date.now();
@@ -3680,6 +3694,21 @@ export default function App() {
DUPE
)}
+ {/* Not recording, but able to: offer a record button in the slot the
+ counter will occupy. It disappears the moment recording starts, so the
+ two never fight over the space — and it is only offered on modes that
+ produce audio worth keeping. */}
+ {!recording && manualRecReady && RECORDABLE_MODES.has(mode.toUpperCase()) && (
+
+ )}
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (