diff --git a/app.go b/app.go index 09ad869..88ef31f 100644 --- a/app.go +++ b/app.go @@ -10239,7 +10239,7 @@ func titleEntity(s string) string { // the configured "Recording mic", transmit via "To Radio", preview via // "Listening". -const dvkSlots = 6 +const dvkSlots = 12 // DVKMessage is one voice-keyer slot for the UI. type DVKMessage struct { diff --git a/changelog.json b/changelog.json index dd12e45..bce44cf 100644 --- a/changelog.json +++ b/changelog.json @@ -4,11 +4,15 @@ "date": "", "en": [ "KPA500: the amplifier no longer switches itself off and commands respond instantly. A command this model does not know (the KPA1500’s ATU poll) was tearing the link down every cycle, and each reconnect toggled the serial control lines — which are the KPA500’s power switch. The lines are now held steady, silence is not treated as a dead link, and the baud is picked from a list.", - "FT decodes: within a period, decodes are listed in arrival order — mirroring the decoder’s own window — instead of strongest-first." + "FT decodes: within a period, decodes are listed in arrival order — mirroring the decoder’s own window — instead of strongest-first.", + "Voice keyer: twelve message slots (F1–F12) instead of six.", + "Preferences open smoothly on a busy station: while the dialog is open, cluster spots, FT decodes and CAT snapshots queue quietly instead of repainting the whole window behind it — everything catches up the moment it closes." ], "fr": [ "KPA500 : l’ampli ne s’éteint plus tout seul et les commandes répondent instantanément. Une commande inconnue de ce modèle (le poll ATU du KPA1500) détruisait le lien à chaque cycle, et chaque reconnexion basculait les lignes de contrôle série — qui sont l’interrupteur du KPA500. Les lignes sont désormais tenues stables, le silence n’est plus traité comme un lien mort, et le baud se choisit dans une liste.", - "FT decodes : dans une période, les décodages sont listés dans l’ordre d’arrivée — comme la fenêtre du décodeur — au lieu du plus fort d’abord." + "FT decodes : dans une période, les décodages sont listés dans l’ordre d’arrivée — comme la fenêtre du décodeur — au lieu du plus fort d’abord.", + "Manipulateur vocal : douze messages (F1–F12) au lieu de six.", + "Les Préférences restent fluides sur une station chargée : dialogue ouvert, les spots cluster, les décodages FT et les instantanés CAT patientent en file au lieu de repeindre toute la fenêtre derrière — tout se rattrape à la fermeture." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 96f2ec0..4ee688c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2228,6 +2228,15 @@ export default function App() { const [bulkEditIds, setBulkEditIds] = useState([]); const [bulkEditOpen, setBulkEditOpen] = useState(false); const [showSettings, setShowSettings] = useState(false); + // While the Settings dialog is open, the spot/decode flushes and the CAT + // snapshot stream are PAUSED (data keeps accumulating in the pending refs). + // Every flush re-renders the whole App tree behind the dialog — cluster + // grid, decodes panel, thousands of nodes — and with a busy cluster plus + // two decoders the pointer visibly stuttered over the preferences. + const showSettingsRef = useRef(false); + useEffect(() => { showSettingsRef.current = showSettings; }, [showSettings]); + const flushSpotsRef = useRef<() => void>(() => {}); + const flushDecodesRef = useRef<() => void>(() => {}); // Re-read the "beam on map" toggle when Preferences closes (it's edited there). useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]); useEffect(() => { if (!showSettings) setRotorCompact(localStorage.getItem('opslog.rotorCompact') === '1'); }, [showSettings]); @@ -3435,7 +3444,15 @@ export default function App() { // Apply a CAT snapshot to the entry strip (freq/band/mode), unless the user // just typed something (freeze window) or locked a field. Shared by the live // cat:state event and the startup poll below. + const lastCatWhileSettingsRef = useRef(0); function applyCatState(s: CATState) { + // Behind the Settings dialog nobody reads a frequency four times a second; + // each snapshot re-renders the whole App tree under the pointer. + if (showSettingsRef.current) { + const now = Date.now(); + if (now - lastCatWhileSettingsRef.current < 2000) return; + lastCatWhileSettingsRef.current = now; + } setCatState(s); if (!s?.connected) return; // A snapshot arriving during the freeze used to be DROPPED, and that lost the @@ -3580,11 +3597,19 @@ export default function App() { // Commit the staged spots: resolve the status for any slot we don't know yet // FIRST, then insert the rows — so they appear with the right badge already // painted instead of flashing plain text then flipping to a pill. + // eslint-disable-next-line prefer-const const flushPendingSpots = async () => { pendingSpotTimer.current = undefined; + // Settings open: leave everything queued (bounded) and repaint nothing. + if (showSettingsRef.current) { + const cap = spotsCapRef.current; + if (pendingSpotsRef.current.length > cap) pendingSpotsRef.current = pendingSpotsRef.current.slice(-cap); + return; + } const batch = pendingSpotsRef.current; pendingSpotsRef.current = []; if (batch.length === 0) return; + // (registered below so closing Settings can drain the queue) // Resolve unknown statuses before the rows go in. try { const known = spotStatusRef.current; @@ -3640,6 +3665,7 @@ export default function App() { return next.length > cap ? next.slice(0, cap) : next; }); }; + flushSpotsRef.current = () => { void flushPendingSpots(); }; const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => { // Stage the spot; a short timer resolves its status then commits it. pendingSpotsRef.current.push(sp); @@ -3687,6 +3713,10 @@ export default function App() { // decodes panel and plain worked in the cluster list two seconds later. const flushDecodes = async () => { pendingDecodeTimer.current = undefined; + if (showSettingsRef.current) { + if (pendingDecodesRef.current.length > 3000) pendingDecodesRef.current = pendingDecodesRef.current.slice(-3000); + return; + } const batch = pendingDecodesRef.current; pendingDecodesRef.current = []; if (batch.length === 0) return; @@ -3729,6 +3759,7 @@ export default function App() { return next; }); }; + flushDecodesRef.current = () => { void flushDecodes(); }; const unsubDecode = EventsOn('udp:decode', (d: DecodeRow) => { pendingDecodesRef.current.push(d); @@ -4375,6 +4406,10 @@ export default function App() { // The stable wrapper the dialog actually receives. const openQSOFromSettings = useCallback((id: number) => openEditRef.current(id), []); const closeSettings = useCallback(() => { + // Synchronously: the ref effect runs after the next render, and flushing + // through a still-true ref would hit the pause gate again. + showSettingsRef.current = false; + window.setTimeout(() => { flushSpotsRef.current(); flushDecodesRef.current(); }, 50); setShowSettings(false); setSettingsSection(undefined); refreshChaseNew(); @@ -5203,7 +5238,7 @@ export default function App() { if (dvkActiveRef.current) { // Voice keyer: plain F1..F6 transmit the message; Ctrl+F1..F5 → tabs. if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; } - if (plain && n <= 6) { e.preventDefault(); dvkPlayRef.current(n); return; } + if (plain && n <= 12) { e.preventDefault(); dvkPlayRef.current(n); return; } return; } // No keyer: plain F1..F5 switch the detail tab (labels read "F1…"). diff --git a/frontend/src/components/DvkPanel.tsx b/frontend/src/components/DvkPanel.tsx index cf30d80..1cec524 100644 --- a/frontend/src/components/DvkPanel.tsx +++ b/frontend/src/components/DvkPanel.tsx @@ -19,7 +19,7 @@ type Props = { phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked }; -// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F6 +// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F12 // voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in // the reserved area. Recording/labeling lives in Settings → Audio. export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) { diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 5cdbf51..4323f1d 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -449,7 +449,7 @@ const en: Dict = { 'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop', 'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL", 'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}', - 'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Repeat a CQ-labelled message on a timer until you stop it or play another slot', 'dvkp.gap': 'Gap', 'dvkp.notPhone': 'The voice keyer only transmits on a phone mode (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1–F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message', + 'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Repeat a CQ-labelled message on a timer until you stop it or play another slot', 'dvkp.gap': 'Gap', 'dvkp.notPhone': 'The voice keyer only transmits on a phone mode (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1–F12.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message', 'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band', 'ampw.title': 'Amplifier', 'ampw.all': 'All amplifiers', 'ampw.pick': 'Which amplifier the widget shows', 'ampw.showHint': 'Amplifier · click to show', 'ampw.hideHint': 'Amplifier — shown · click to hide', @@ -535,7 +535,7 @@ const en: Dict = { 'aud.preroll': 'Pre-roll (seconds)', 'aud.format': 'File format', 'aud.wav': 'WAV (lossless, larger)', 'aud.mp3': 'MP3 (compressed, small)', 'aud.fromLevel': 'From Radio level', 'aud.txLevel': 'Voice keyer level', 'aud.txLevelHint': 'Level of the recorded messages sent to the radio. Raise it if your voice keyer is much quieter than your microphone; Play previews at this same level. If the radio transmits almost nothing, its modulation source is still the front microphone: on an FTDX10 set MENU → SSB MOD SOURCE to REAR (the USB input).', 'aud.micLevel': 'Mic level', 'aud.qsoPlayLevel': 'QSO playback level', 'aud.levelHint': 'If your voice is louder than the station, lower Mic level.', 'aud.autoSend': 'Auto-send the recording to the station by e-mail when I log a QSO', - 'aud.dvkTitle': 'Voice keyer messages (F1–F6)', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR', + 'aud.dvkTitle': 'Voice keyer messages (F1–F12)', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR', 'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh', 'aud.msgPlaceholder': 'Message {n} label (CQ, report, 73…)', 'aud.holdRec': '● Hold to rec', 'aud.recordingNow': '● Recording…', 'aud.play': '▶ Play', 'aud.stop': '■ Stop', 'aud.errPttTest': 'PTT test: ', 'aud.errRecord': 'Record: ', 'aud.errSave': 'Save: ', 'aud.errPlay': 'Play: ', @@ -955,7 +955,7 @@ const fr: Dict = { 'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop', 'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL", 'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}', - 'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Répète un message libellé CQ à intervalle régulier jusqu\'à l\'arrêt ou la lecture d\'un autre slot', 'dvkp.gap': 'Intervalle', 'dvkp.notPhone': 'Le manipulateur vocal n\'émet qu\'en mode phonie (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1–F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message', + 'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Répète un message libellé CQ à intervalle régulier jusqu\'à l\'arrêt ou la lecture d\'un autre slot', 'dvkp.gap': 'Intervalle', 'dvkp.notPhone': 'Le manipulateur vocal n\'émet qu\'en mode phonie (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1–F12.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message', 'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour n’afficher que la bande courante', 'ampw.title': 'Amplificateur', 'ampw.all': 'Tous les amplis', 'ampw.pick': 'Ampli affiché par le widget', 'ampw.showHint': 'Amplificateur · cliquer pour afficher', 'ampw.hideHint': 'Amplificateur — affiché · cliquer pour masquer', @@ -1037,7 +1037,7 @@ const fr: Dict = { 'aud.preroll': 'Pré-enregistrement (secondes)', 'aud.format': 'Format de fichier', 'aud.wav': 'WAV (sans perte, plus volumineux)', 'aud.mp3': 'MP3 (compressé, léger)', 'aud.fromLevel': 'Niveau depuis la radio', 'aud.txLevel': 'Niveau du voice keyer', 'aud.txLevelHint': "Niveau des messages enregistrés envoyés à la radio. Augmentez-le si votre voice keyer est bien plus faible que votre micro ; Lire fait entendre ce même niveau. Si la radio n'émet presque rien, c'est que sa source de modulation est restée le micro de façade : sur un FTDX10, réglez MENU → SSB MOD SOURCE sur REAR (l'entrée USB).", 'aud.micLevel': 'Niveau micro', 'aud.qsoPlayLevel': 'Niveau de relecture QSO', 'aud.levelHint': 'Si votre voix est plus forte que la station, baissez le niveau micro.', 'aud.autoSend': "Envoyer automatiquement l'enregistrement à la station par e-mail lorsque j'enregistre un QSO", - 'aud.dvkTitle': 'Messages du manipulateur vocal (F1–F6)', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série', + 'aud.dvkTitle': 'Messages du manipulateur vocal (F1–F12)', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série', 'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser', 'aud.msgPlaceholder': 'Libellé du message {n} (CQ, report, 73…)', 'aud.holdRec': '● Maintenir pour enreg.', 'aud.recordingNow': '● Enregistrement…', 'aud.play': '▶ Lire', 'aud.stop': '■ Arrêter', 'aud.errPttTest': 'Test PTT : ', 'aud.errRecord': 'Enregistrement : ', 'aud.errSave': 'Sauvegarde : ', 'aud.errPlay': 'Lecture : ',