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:
@@ -7606,6 +7606,78 @@ func (a *App) stopManualQSORecorder() {
|
|||||||
applog.Printf("qso-rec: manual recording finished — sound devices released")
|
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
|
// QSOAudioCancel drops the in-progress recording (callsign cleared, QSO
|
||||||
// abandoned without logging).
|
// abandoned without logging).
|
||||||
func (a *App) QSOAudioCancel() {
|
func (a *App) QSOAudioCancel() {
|
||||||
|
|||||||
+4
-2
@@ -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.",
|
"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.",
|
"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."
|
"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": [
|
"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é.",
|
"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.",
|
"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.",
|
"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."
|
"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."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+66
-5
@@ -44,7 +44,7 @@ import {
|
|||||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||||
QSOAudioManualReady, QSOAudioManualStart,
|
QSOAudioManualReady, QSOAudioManualStart, QSOAudioStop, QSOAudioResume, QSOAudioPlayOnAir,
|
||||||
GetAwardDefs,
|
GetAwardDefs,
|
||||||
GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp,
|
GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp,
|
||||||
ReportLiveActivity, LiveLastQSOAgeSec,
|
ReportLiveActivity, LiveLastQSOAgeSec,
|
||||||
@@ -769,6 +769,10 @@ export default function App() {
|
|||||||
// Elapsed recording time (seconds) shown next to the red dot, ticking once a
|
// Elapsed recording time (seconds) shown next to the red dot, ticking once a
|
||||||
// second while a recording is in progress.
|
// second while a recording is in progress.
|
||||||
const [recSeconds, setRecSeconds] = useState(0);
|
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
|
// 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).
|
// already true (jumping spot→spot keeps recording=true but starts a fresh take).
|
||||||
const [recTick, setRecTick] = useState(0);
|
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
|
// the only case where a record button makes sense. Asking the backend rather
|
||||||
// than reading a preference here keeps the two from drifting apart.
|
// than reading a preference here keeps the two from drifting apart.
|
||||||
const [manualRecReady, setManualRecReady] = useState(false);
|
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(() => {}); };
|
const refreshManualRecReady = () => { QSOAudioManualReady().then(setManualRecReady).catch(() => {}); };
|
||||||
useEffect(() => { refreshManualRecReady(); }, []);
|
useEffect(() => { refreshManualRecReady(); }, []);
|
||||||
const startManualRecording = () => {
|
const startManualRecording = () => {
|
||||||
@@ -787,11 +803,17 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!recording) { setRecSeconds(0); return; }
|
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();
|
const start = Date.now();
|
||||||
setRecSeconds(0);
|
const id = window.setInterval(() => setRecSeconds(from + Math.floor((Date.now() - start) / 1000)), 1000);
|
||||||
const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000);
|
|
||||||
return () => window.clearInterval(id);
|
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).
|
// The callsign the in-progress recording belongs to (uppercased; '' = none).
|
||||||
// Lets us restart from zero when the operator edits the call to a different
|
// Lets us restart from zero when the operator edits the call to a different
|
||||||
// station mid-recording, instead of continuing the old take.
|
// 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" />
|
<span className="size-2.5 rounded-full border border-destructive bg-destructive/40 hover:bg-destructive" />
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
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.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.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.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.progressTitle': 'Importing ADIF…', 'bulk.progressTitle': 'Updating the selected QSOs\u2026',
|
||||||
'imp.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…',
|
'imp.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…',
|
||||||
'imp.complete': 'Import complete.',
|
'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.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.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.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.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.progressCount': '{done} / {tot} enregistrements · {pct}%', 'imp.progressCountOnly': '{done} enregistrements…',
|
||||||
'imp.complete': 'Import terminé.',
|
'imp.complete': 'Import terminé.',
|
||||||
|
|||||||
Vendored
+6
@@ -753,10 +753,16 @@ export function QSOAudioManualReady():Promise<boolean>;
|
|||||||
|
|
||||||
export function QSOAudioManualStart():Promise<boolean>;
|
export function QSOAudioManualStart():Promise<boolean>;
|
||||||
|
|
||||||
|
export function QSOAudioPlayOnAir():Promise<void>;
|
||||||
|
|
||||||
export function QSOAudioResetClock():Promise<boolean>;
|
export function QSOAudioResetClock():Promise<boolean>;
|
||||||
|
|
||||||
export function QSOAudioRestart():Promise<boolean>;
|
export function QSOAudioRestart():Promise<boolean>;
|
||||||
|
|
||||||
|
export function QSOAudioResume():Promise<boolean>;
|
||||||
|
|
||||||
|
export function QSOAudioStop():Promise<boolean>;
|
||||||
|
|
||||||
export function QuitApp():Promise<void>;
|
export function QuitApp():Promise<void>;
|
||||||
|
|
||||||
export function RecomputeAllAwardRefs():Promise<number>;
|
export function RecomputeAllAwardRefs():Promise<number>;
|
||||||
|
|||||||
@@ -1454,6 +1454,10 @@ export function QSOAudioManualStart() {
|
|||||||
return window['go']['main']['App']['QSOAudioManualStart']();
|
return window['go']['main']['App']['QSOAudioManualStart']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function QSOAudioPlayOnAir() {
|
||||||
|
return window['go']['main']['App']['QSOAudioPlayOnAir']();
|
||||||
|
}
|
||||||
|
|
||||||
export function QSOAudioResetClock() {
|
export function QSOAudioResetClock() {
|
||||||
return window['go']['main']['App']['QSOAudioResetClock']();
|
return window['go']['main']['App']['QSOAudioResetClock']();
|
||||||
}
|
}
|
||||||
@@ -1462,6 +1466,14 @@ export function QSOAudioRestart() {
|
|||||||
return window['go']['main']['App']['QSOAudioRestart']();
|
return window['go']['main']['App']['QSOAudioRestart']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function QSOAudioResume() {
|
||||||
|
return window['go']['main']['App']['QSOAudioResume']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSOAudioStop() {
|
||||||
|
return window['go']['main']['App']['QSOAudioStop']();
|
||||||
|
}
|
||||||
|
|
||||||
export function QuitApp() {
|
export function QuitApp() {
|
||||||
return window['go']['main']['App']['QuitApp']();
|
return window['go']['main']['App']['QuitApp']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ type Recorder struct {
|
|||||||
// Mixed output state (guarded by mu).
|
// Mixed output state (guarded by mu).
|
||||||
ring []int16 // last prerollSamples of mixed audio
|
ring []int16 // last prerollSamples of mixed audio
|
||||||
active bool
|
active bool
|
||||||
|
// paused freezes an ACTIVE take: nothing more is accumulated, but everything
|
||||||
|
// captured so far is kept and the take still ends normally when the QSO is
|
||||||
|
// logged. It is what lets an operator stop, play the recording back on the
|
||||||
|
// air to the station they are working, and still have it saved with the QSO.
|
||||||
|
paused bool
|
||||||
acc []int16 // active QSO accumulation (seeded from ring on BeginQSO)
|
acc []int16 // active QSO accumulation (seeded from ring on BeginQSO)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +119,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
|||||||
r.twoSrc = micDev != "" && micDev != fromDev
|
r.twoSrc = micDev != "" && micDev != fromDev
|
||||||
r.stopCh = make(chan struct{})
|
r.stopCh = make(chan struct{})
|
||||||
r.running = true
|
r.running = true
|
||||||
r.ring, r.acc, r.active, r.bufA, r.bufB = nil, nil, false, nil, nil
|
r.ring, r.acc, r.active, r.paused, r.bufA, r.bufB = nil, nil, false, false, nil, nil
|
||||||
stop := r.stopCh
|
stop := r.stopCh
|
||||||
twoSrc := r.twoSrc
|
twoSrc := r.twoSrc
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
@@ -206,7 +211,7 @@ func (r *Recorder) mixTick() {
|
|||||||
if len(r.ring) > r.prerollSamples {
|
if len(r.ring) > r.prerollSamples {
|
||||||
r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...)
|
r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...)
|
||||||
}
|
}
|
||||||
if r.active {
|
if r.active && !r.paused {
|
||||||
r.acc = append(r.acc, mixed...)
|
r.acc = append(r.acc, mixed...)
|
||||||
}
|
}
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
@@ -221,7 +226,7 @@ func (r *Recorder) BeginQSO() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.acc = append([]int16(nil), r.ring...)
|
r.acc = append([]int16(nil), r.ring...)
|
||||||
r.active = true
|
r.active, r.paused = true, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestartQSO begins a fresh accumulation even if one is already active —
|
// RestartQSO begins a fresh accumulation even if one is already active —
|
||||||
@@ -236,7 +241,7 @@ func (r *Recorder) RestartQSO() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.acc = append([]int16(nil), r.ring...)
|
r.acc = append([]int16(nil), r.ring...)
|
||||||
r.active = true
|
r.active, r.paused = true, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetQSOClock restarts the active accumulation from ZERO — discarding
|
// ResetQSOClock restarts the active accumulation from ZERO — discarding
|
||||||
@@ -266,7 +271,7 @@ func (r *Recorder) TakeQSO() ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("no active recording")
|
return nil, fmt.Errorf("no active recording")
|
||||||
}
|
}
|
||||||
samples := r.acc
|
samples := r.acc
|
||||||
r.acc, r.active = nil, false
|
r.acc, r.active, r.paused = nil, false, false
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
if len(samples) == 0 {
|
if len(samples) == 0 {
|
||||||
return nil, fmt.Errorf("recording was empty")
|
return nil, fmt.Errorf("recording was empty")
|
||||||
@@ -295,7 +300,7 @@ func (r *Recorder) SaveQSO(path string) error {
|
|||||||
// DiscardQSO drops the active accumulation without saving (callsign cleared).
|
// DiscardQSO drops the active accumulation without saving (callsign cleared).
|
||||||
func (r *Recorder) DiscardQSO() {
|
func (r *Recorder) DiscardQSO() {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.acc, r.active = nil, false
|
r.acc, r.active, r.paused = nil, false, false
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +318,7 @@ func (r *Recorder) Stop() {
|
|||||||
close(stop)
|
close(stop)
|
||||||
r.wg.Wait()
|
r.wg.Wait()
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.ring, r.acc, r.active = nil, nil, false
|
r.ring, r.acc, r.active, r.paused = nil, nil, false, false
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
r.srcMu.Lock()
|
r.srcMu.Lock()
|
||||||
r.bufA, r.bufB = nil, nil
|
r.bufA, r.bufB = nil, nil
|
||||||
@@ -346,3 +351,55 @@ func int16sToBytes(s []int16) []byte {
|
|||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PauseQSO freezes the active take without ending it: nothing more is recorded,
|
||||||
|
// nothing is thrown away, and logging the QSO still writes the file.
|
||||||
|
//
|
||||||
|
// This exists for one situation, which is common enough on the bands to be
|
||||||
|
// worth the state: you are working a station, you have been recording, and they
|
||||||
|
// ask to hear it. You stop, you play it back to them on the air, and the
|
||||||
|
// recording is still saved with the QSO afterwards.
|
||||||
|
func (r *Recorder) PauseQSO() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if !r.active {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r.paused = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumeQSO continues an interrupted take, appending to what is already there.
|
||||||
|
func (r *Recorder) ResumeQSO() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if !r.active {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r.paused = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paused reports whether the active take is frozen.
|
||||||
|
func (r *Recorder) Paused() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.active && r.paused
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeekQSO returns what has been captured so far WITHOUT ending the take.
|
||||||
|
//
|
||||||
|
// Unlike TakeQSO this keeps the audio, because the take is going to be played
|
||||||
|
// back and then still saved with the QSO. The copy is deliberate: the caller
|
||||||
|
// gets bytes it can hold while the recorder keeps appending to its own slice.
|
||||||
|
func (r *Recorder) PeekQSO() ([]byte, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if !r.active {
|
||||||
|
return nil, fmt.Errorf("no active recording")
|
||||||
|
}
|
||||||
|
if len(r.acc) == 0 {
|
||||||
|
return nil, fmt.Errorf("recording is empty")
|
||||||
|
}
|
||||||
|
return int16sToBytes(append([]int16(nil), r.acc...)), nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user