feat: stop a QSO recording and play it back on the air
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.
This commit is contained in:
+66
-5
@@ -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() {
|
||||
<span className="size-2.5 rounded-full border border-destructive bg-destructive/40 hover:bg-destructive" />
|
||||
</button>
|
||||
)}
|
||||
{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()) && (
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={playRecordingOnAir}
|
||||
title={t('rec.playOnAir')}
|
||||
className="inline-flex items-center justify-center size-4 rounded text-destructive hover:bg-destructive-muted"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={resumeRecording}
|
||||
title={t('rec.resume')}
|
||||
className="inline-flex items-center justify-center size-4 rounded text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<span className="size-2 rounded-full bg-destructive" />
|
||||
</button>
|
||||
<span className="text-[10px] font-semibold tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{recording && !recStopped && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => { e.stopPropagation(); stopRecording(); }}
|
||||
title={t('rec.stop')}
|
||||
className="absolute right-14 top-1/2 -translate-y-1/2 z-10 inline-flex items-center justify-center size-4 rounded text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<span className="size-2 bg-current" />
|
||||
</button>
|
||||
)}
|
||||
{recording && !recStopped && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
|
||||
Reference in New Issue
Block a user