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()}
|
||||
|
||||
@@ -135,7 +135,7 @@ const en: Dict = {
|
||||
'imp.ctyDesc': "Recompute Country, DXCC & CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF → Reunion, TO2A 2012 → French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use Update duplicates to re-fix QSOs already in your log.",
|
||||
'imp.stationTitle': 'Fill my station fields from my profile',
|
||||
'imp.stationDesc': "Backfill empty MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus Operator and Owner callsign from your active profile. Existing values are kept. Only STATION_CALLSIGN is left untouched so a mixed-call log isn't re-routed. It ALSO stamps your default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com sent and received) on the ones the file leaves empty — a WSJT-X log carries almost none. Enable when importing your own log.",
|
||||
'imp.cancel': 'Cancel', 'rec.manualStart': 'Record this contact. Automatic recording is off, so capture starts now \u2014 there is no pre-roll of what came before.', 'rec.manualFailed': 'Recording could not be started \u2014 check the audio devices in Settings.', 'imp.mapAdd': 'The file puts a field in the wrong place\u2026', 'imp.mapTitle': 'Move fields on import', 'imp.mapDesc': 'Contest software stores the exchange where its own module keeps it. The RSGB IOTA contest exports the island reference in STATE \u2014 imported as-is it becomes a US state and the IOTA award stays empty. The destination is only filled where the file left it blank.', 'imp.mapMore': 'Add another', 'imp.import': 'Import',
|
||||
'imp.cancel': 'Cancel', 'rec.manualStart': 'Record this contact. Automatic recording is off, so capture starts now \u2014 there is no pre-roll of what came before.', 'rec.stop': 'Stop the recording. The audio is kept and still saved with the QSO \u2014 stop it to play it back to the station you are working.', 'rec.resume': 'Carry on recording, appending to what is already captured.', 'rec.playOnAir': 'TRANSMIT the recording to the station you are working: keys the radio and sends it like a voice-keyer message.', 'rec.manualFailed': 'Recording could not be started \u2014 check the audio devices in Settings.', 'imp.mapAdd': 'The file puts a field in the wrong place\u2026', 'imp.mapTitle': 'Move fields on import', 'imp.mapDesc': 'Contest software stores the exchange where its own module keeps it. The RSGB IOTA contest exports the island reference in STATE \u2014 imported as-is it becomes a US state and the IOTA award stays empty. The destination is only filled where the file left it blank.', 'imp.mapMore': 'Add another', 'imp.import': 'Import',
|
||||
'imp.progressTitle': 'Importing ADIF…', 'bulk.progressTitle': 'Updating the selected QSOs\u2026',
|
||||
'imp.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…',
|
||||
'imp.complete': 'Import complete.',
|
||||
@@ -546,7 +546,7 @@ const fr: Dict = {
|
||||
'imp.ctyDesc': "Recalcule Pays, DXCC & zones CQ/ITU depuis cty.dat, en écrasant le fichier — corrige ce que les logiciels de contest exportent mal (ex. RG2Y en Russie asiatique au lieu d'européenne). Les exceptions DXpédition de ClubLog s'appliquent par-dessus selon la date du QSO (ex. TO974REF → Réunion, TO2A 2012 → Guyane française) dès que les données ClubLog sont téléchargées. Tout le reste de l'ADIF est conservé tel quel. Astuce : utilise Mettre à jour les doublons pour re-corriger des QSO déjà dans le log.",
|
||||
'imp.stationTitle': 'Remplir mes champs station depuis mon profil',
|
||||
'imp.stationDesc': "Complète les champs MY_* vides (locator, rig, antenne, adresse, ville, état, comté, réf. SOTA/POTA, puissance TX…) plus Opérateur et Indicatif propriétaire depuis le profil actif. Les valeurs existantes sont conservées. Seul STATION_CALLSIGN n'est jamais touché pour ne pas re-router un log multi-indicatifs. Elle applique AUSSI tes statuts de confirmation par défaut (QSL papier, LoTW, eQSL, Club Log, HRDLog, QRZ.com envoyé et reçu) sur ceux que le fichier laisse vides — un log WSJT-X n'en contient pratiquement aucun. À activer quand tu importes ton propre log.",
|
||||
'imp.cancel': 'Annuler', 'rec.manualStart': 'Enregistrer ce contact. L\u2019enregistrement automatique est d\u00e9sactiv\u00e9 : la capture commence maintenant, sans les secondes qui pr\u00e9c\u00e8dent.', 'rec.manualFailed': 'L\u2019enregistrement n\u2019a pas pu d\u00e9marrer \u2014 v\u00e9rifiez les p\u00e9riph\u00e9riques audio dans les R\u00e9glages.', 'imp.mapAdd': 'Le fichier met un champ au mauvais endroit\u2026', 'imp.mapTitle': 'D\u00e9placer des champs \u00e0 l\u2019import', 'imp.mapDesc': 'Les logiciels de concours rangent l\u2019\u00e9change l\u00e0 o\u00f9 leur module le garde. Le contest IOTA de la RSGB exporte la r\u00e9f\u00e9rence d\u2019\u00eele dans STATE \u2014 import\u00e9e telle quelle elle devient un \u00e9tat am\u00e9ricain et le dipl\u00f4me IOTA reste vide. La destination n\u2019est remplie que l\u00e0 o\u00f9 le fichier l\u2019a laiss\u00e9e vide.', 'imp.mapMore': 'Ajouter une ligne', 'imp.import': 'Importer',
|
||||
'imp.cancel': 'Annuler', 'rec.manualStart': 'Enregistrer ce contact. L\u2019enregistrement automatique est d\u00e9sactiv\u00e9 : la capture commence maintenant, sans les secondes qui pr\u00e9c\u00e8dent.', 'rec.stop': 'Arr\u00eater l\u2019enregistrement. L\u2019audio est conserv\u00e9 et sera enregistr\u00e9 avec le QSO \u2014 arr\u00eatez-le pour le repasser \u00e0 la station travaill\u00e9e.', 'rec.resume': 'Reprendre l\u2019enregistrement, \u00e0 la suite de ce qui est d\u00e9j\u00e0 captur\u00e9.', 'rec.playOnAir': '\u00c9METTRE l\u2019enregistrement vers la station travaill\u00e9e : passe la radio en \u00e9mission et l\u2019envoie comme un message du manipulateur vocal.', 'rec.manualFailed': 'L\u2019enregistrement n\u2019a pas pu d\u00e9marrer \u2014 v\u00e9rifiez les p\u00e9riph\u00e9riques audio dans les R\u00e9glages.', 'imp.mapAdd': 'Le fichier met un champ au mauvais endroit\u2026', 'imp.mapTitle': 'D\u00e9placer des champs \u00e0 l\u2019import', 'imp.mapDesc': 'Les logiciels de concours rangent l\u2019\u00e9change l\u00e0 o\u00f9 leur module le garde. Le contest IOTA de la RSGB exporte la r\u00e9f\u00e9rence d\u2019\u00eele dans STATE \u2014 import\u00e9e telle quelle elle devient un \u00e9tat am\u00e9ricain et le dipl\u00f4me IOTA reste vide. La destination n\u2019est remplie que l\u00e0 o\u00f9 le fichier l\u2019a laiss\u00e9e vide.', 'imp.mapMore': 'Ajouter une ligne', 'imp.import': 'Importer',
|
||||
'imp.progressTitle': 'Import ADIF en cours…', 'bulk.progressTitle': 'Mise \u00e0 jour des QSO s\u00e9lectionn\u00e9s\u2026',
|
||||
'imp.progressCount': '{done} / {tot} enregistrements · {pct}%', 'imp.progressCountOnly': '{done} enregistrements…',
|
||||
'imp.complete': 'Import terminé.',
|
||||
|
||||
Reference in New Issue
Block a user