From 069728ac68b20f4e21630a1d26e0eb880e503298 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 30 Jul 2026 21:36:42 +0200 Subject: [PATCH] feat: stop a QSO recording and play it back on the air MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The situation, in the operator's words: working VP2MAA, recording, and he asks to hear his own signal. So: stop, play, and the recording is still saved with the QSO. Stopping FREEZES the take rather than ending it — nothing more is captured, nothing is discarded, and logging writes the file exactly as before. Playback reuses the voice keyer's path: same output device, same PTT keying and release with its generation guard, so a replay cannot be cut short by a stale unkey. Playing is refused while still recording. The transmitted audio would feed back into the same take on any rig monitoring its own transmission, and stopping is one click the operator has already made — it is their statement that the take is finished, not a hoop. The elapsed clock freezes with the recording and resumes from where it stopped, because it now shows the LENGTH OF THE FILE rather than the time since the QSO began. Only a fresh take returns it to zero. --- app.go | 72 +++++++++++++++++++++++++++++++ changelog.json | 6 ++- frontend/src/App.tsx | 71 +++++++++++++++++++++++++++--- frontend/src/lib/i18n.tsx | 4 +- frontend/wailsjs/go/main/App.d.ts | 6 +++ frontend/wailsjs/go/main/App.js | 12 ++++++ internal/audio/recorder.go | 71 +++++++++++++++++++++++++++--- 7 files changed, 226 insertions(+), 16 deletions(-) diff --git a/app.go b/app.go index 465bd62..64d77a3 100644 --- a/app.go +++ b/app.go @@ -7606,6 +7606,78 @@ func (a *App) stopManualQSORecorder() { applog.Printf("qso-rec: manual recording finished — sound devices released") } +// QSOAudioStop freezes the recording without ending it, so it can be played +// back and still be saved with the QSO afterwards. +func (a *App) QSOAudioStop() bool { + if a.qsoRec == nil { + return false + } + return a.qsoRec.PauseQSO() +} + +// QSOAudioResume continues a frozen recording, appending to what is there. +func (a *App) QSOAudioResume() bool { + if a.qsoRec == nil { + return false + } + return a.qsoRec.ResumeQSO() +} + +// QSOAudioPlayOnAir transmits the recording made so far, through the DVK's +// output device and PTT. +// +// The situation this is for: you are working a station, you have been recording, +// and they ask to hear it. You stop, you send it to them, and the recording is +// still saved with the QSO. +// +// It goes out over the AIR. So it refuses unless the take has been stopped +// first: playing while still recording would feed the transmitted audio back +// into the same take, and on a rig monitoring its own transmission that is a +// loop. Stopping is the operator's statement that the take is finished, and it +// is one click they have already made. +func (a *App) QSOAudioPlayOnAir() error { + if a.qsoRec == nil || a.audioMgr == nil { + return fmt.Errorf("audio not initialized") + } + if !a.qsoRec.Paused() { + return fmt.Errorf("stop the recording before playing it on the air") + } + pcm, err := a.qsoRec.PeekQSO() + if err != nil { + return err + } + // 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 + // waiting on with the transmitter keyed. + path := filepath.Join(a.dataDir, "qso_playback.wav") + if err := audio.WritePCM(path, pcm); err != nil { + return fmt.Errorf("prepare playback: %w", err) + } + + cfg, _ := a.GetAudioSettings() + if err := a.pttKey(cfg); err != nil { + applog.Printf("qso-rec: PTT on failed before playback: %v", err) + // Keep going — the audio still reaches the rig and the operator may use VOX. + } else if cfg.PTTMethod != "" && cfg.PTTMethod != "none" { + a.pttMu.Lock() + a.dvkPttKeyed = true + a.pttMu.Unlock() + } + if err := a.audioMgr.Play(cfg.ToRadio, path, cfg.TXGain); err != nil { + a.pttMu.Lock() + keyed := a.dvkPttKeyed + gen := a.pttGen + a.dvkPttKeyed = false + a.pttMu.Unlock() + if keyed { + go a.dvkUnkeyPTT(gen) + } + return err + } + applog.Printf("qso-rec: playing the recording on the air (%d bytes)", len(pcm)) + return nil +} + // QSOAudioCancel drops the in-progress recording (callsign cleared, QSO // abandoned without logging). func (a *App) QSOAudioCancel() { diff --git a/changelog.json b/changelog.json index 40858d7..ab7f6b0 100644 --- a/changelog.json +++ b/changelog.json @@ -12,7 +12,8 @@ "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.", - "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." + "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.", + "A recording in progress can be stopped and TRANSMITTED to the station being worked, keyed and sent like a voice-keyer message — for when they ask to hear their own signal. The audio is kept and still saved with the QSO, and recording can be resumed." ], "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é.", @@ -24,7 +25,8 @@ "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.", - "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." + "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.", + "Un enregistrement en cours peut être arrêté puis ÉMIS vers la station travaillée, radio passée en émission comme un message du manipulateur vocal — pour quand elle demande à entendre son propre signal. L'audio est conservé et toujours enregistré avec le QSO, et l'enregistrement peut reprendre." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5839087..f80d58b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -44,7 +44,7 @@ import { StartCWDecoder, StopCWDecoder, SetCWDecoderPitch, ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators, QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock, - QSOAudioManualReady, QSOAudioManualStart, + QSOAudioManualReady, QSOAudioManualStart, QSOAudioStop, QSOAudioResume, QSOAudioPlayOnAir, GetAwardDefs, GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp, ReportLiveActivity, LiveLastQSOAgeSec, @@ -769,6 +769,10 @@ export default function App() { // Elapsed recording time (seconds) shown next to the red dot, ticking once a // second while a recording is in progress. const [recSeconds, setRecSeconds] = useState(0); + // Mirrored in a ref so the clock can RESUME from where it stopped rather than + // restart, without the timer effect depending on the value it sets. + const recSecondsRef = useRef(0); + useEffect(() => { recSecondsRef.current = recSeconds; }, [recSeconds]); // 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); @@ -776,6 +780,18 @@ export default function App() { // 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); + // A stopped take is still a take: the audio is kept and will be saved with the + // QSO. This only says the clock has stopped and the recording can be played. + const [recStopped, setRecStopped] = useState(false); + const stopRecording = () => { + QSOAudioStop().then((ok) => { if (ok) setRecStopped(true); }).catch(() => {}); + }; + const resumeRecording = () => { + QSOAudioResume().then((ok) => { if (ok) { setRecStopped(false); setRecTick((t) => t + 1); } }).catch(() => {}); + }; + const playRecordingOnAir = () => { + QSOAudioPlayOnAir().catch((e: any) => setError(String(e?.message ?? e))); + }; const refreshManualRecReady = () => { QSOAudioManualReady().then(setManualRecReady).catch(() => {}); }; useEffect(() => { refreshManualRecReady(); }, []); const startManualRecording = () => { @@ -787,11 +803,17 @@ export default function App() { }; useEffect(() => { if (!recording) { setRecSeconds(0); return; } + // A stopped take freezes the clock where it is: it must show the length of + // the file, not the time since the QSO began. + if (recStopped) return; + const from = recSecondsRef.current; const start = Date.now(); - setRecSeconds(0); - const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000); + const id = window.setInterval(() => setRecSeconds(from + Math.floor((Date.now() - start) / 1000)), 1000); return () => window.clearInterval(id); - }, [recording, recTick]); + }, [recording, recTick, recStopped]); + // recTick means "a fresh take" — that is the only case where the clock returns + // to zero, as opposed to resuming after a stop. + useEffect(() => { setRecSeconds(0); recSecondsRef.current = 0; setRecStopped(false); }, [recTick]); // The callsign the in-progress recording belongs to (uppercased; '' = none). // Lets us restart from zero when the operator edits the call to a different // station mid-recording, instead of continuing the old take. @@ -3709,7 +3731,46 @@ export default function App() { )} - {recording && RECORDABLE_MODES.has(mode.toUpperCase()) && ( + {/* A stopped take: play it to the station being worked, or carry on + recording. Both sit where the counter is, which has shifted left to + make room. */} + {recording && recStopped && RECORDABLE_MODES.has(mode.toUpperCase()) && ( + + + + + {String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')} + + + )} + {recording && !recStopped && RECORDABLE_MODES.has(mode.toUpperCase()) && ( + + )} + {recording && !recStopped && RECORDABLE_MODES.has(mode.toUpperCase()) && (