fix: padlocks unclickable, From-radio level inert, callsign field too narrow
Padlocks. LockBtn was declared INSIDE the App component, so every render gave React a new component type and the button was unmounted and rebuilt — about four times a second while the CAT polls. It flickered under the pointer and swallowed clicks, because the node being clicked no longer existed when the click landed. The padlock now lives at module level and is passed its state. From-radio level. It reached the QSO recorder and nothing else, so an operator listening through OpsLog heard no difference between 10% and 150% — the control was not weak, it was disconnected. It now scales the monitor too, applied on the captured chunk so the network-fed path keeps its own levels, and a running monitor picks up a change immediately: making someone restart the monitor to hear the slider is how a working control gets reported as broken. Callsign field widened to w-56, the room coming from the RST pair which never needs more than five characters. It is the one field always typed into, it carries the REC badges, and a long portable call has to stay readable.
This commit is contained in:
@@ -1220,6 +1220,15 @@ func (a *App) startup(ctx context.Context) {
|
||||
}
|
||||
})
|
||||
a.qsoRec = audio.NewRecorder()
|
||||
if a.audioMgr != nil {
|
||||
// A running monitor picks the new level up immediately: the operator is
|
||||
// listening while they move the slider, and asking them to stop and
|
||||
// restart it to hear the change is how a working control gets reported
|
||||
// as broken.
|
||||
if cfg, err := a.GetAudioSettings(); err == nil {
|
||||
a.audioMgr.SetMonitorGain(cfg.FromGain)
|
||||
}
|
||||
}
|
||||
a.startQSORecorderIfEnabled()
|
||||
|
||||
// NET Control store (global JSON, shared across logbooks).
|
||||
@@ -7051,6 +7060,12 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
|
||||
}
|
||||
// Apply device/preroll/enable changes to the running recorder.
|
||||
a.startQSORecorderIfEnabled()
|
||||
// And to a monitor ALREADY RUNNING: the operator is listening while they
|
||||
// move the slider. Making them stop and restart the monitor to hear the
|
||||
// change is how a working control gets reported as doing nothing.
|
||||
if a.audioMgr != nil {
|
||||
a.audioMgr.SetMonitorGain(s.FromGain)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8900,6 +8915,7 @@ func (a *App) AudioStartMonitor() error {
|
||||
return fmt.Errorf(`no "From radio" capture device set — pick the rig's USB Audio CODEC in Settings → Audio`)
|
||||
}
|
||||
applog.Printf("audio: RX monitor start (from=%q → listen=%q)", cfg.FromRadio, cfg.ListeningDevice)
|
||||
a.audioMgr.SetMonitorGain(cfg.FromGain)
|
||||
return a.audioMgr.StartMonitor(cfg.FromRadio, cfg.ListeningDevice)
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -6,13 +6,19 @@
|
||||
"Cluster filters gain NEW POTA and NEW COUNTY, alongside the existing status chips.",
|
||||
"A playback that fails to start now says so in the log instead of ending silently after a tenth of a second.",
|
||||
"Replaying a recording before the previous one has finished now works: the new playback waits for the audio device to be free instead of keying the radio and releasing at once.",
|
||||
"The play button becomes a stop button while the recording is going out: it cuts the transmission and releases the PTT, and you can send it again straight away."
|
||||
"The play button becomes a stop button while the recording is going out: it cuts the transmission and releases the PTT, and you can send it again straight away.",
|
||||
"The \"From radio\" level now applies to what you HEAR through OpsLog, not only to recordings, and takes effect while the monitor is running.",
|
||||
"The padlocks on frequency, band, mode and times no longer flicker under the pointer and refuse the click.",
|
||||
"The callsign field is wider, taking the room from the RST pair."
|
||||
],
|
||||
"fr": [
|
||||
"Les filtres du cluster gagnent NEW POTA et NEW COUNTY, à côté des pastilles de statut existantes.",
|
||||
"Une lecture qui ne démarre pas le signale désormais dans le journal, au lieu de s'arrêter en silence au bout d'un dixième de seconde.",
|
||||
"Relancer un enregistrement avant la fin du précédent fonctionne désormais : la nouvelle lecture attend que le périphérique audio soit libre, au lieu de passer en émission et de relâcher aussitôt.",
|
||||
"Le bouton de lecture devient un bouton d'arrêt pendant l'émission de l'enregistrement : il coupe l'émission et relâche le PTT, et vous pouvez le renvoyer aussitôt."
|
||||
"Le bouton de lecture devient un bouton d'arrêt pendant l'émission de l'enregistrement : il coupe l'émission et relâche le PTT, et vous pouvez le renvoyer aussitôt.",
|
||||
"Le niveau « From radio » agit désormais sur ce que vous ENTENDEZ dans OpsLog, et plus seulement sur les enregistrements ; il prend effet moniteur en marche.",
|
||||
"Les cadenas de fréquence, bande, mode et heures ne scintillent plus sous le curseur et acceptent le clic.",
|
||||
"Le champ indicatif est plus large, la place venant de la paire RST."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+40
-29
@@ -393,6 +393,33 @@ function WindowControls() {
|
||||
);
|
||||
}
|
||||
|
||||
// LockPad — the padlock toggle shown in a lockable field's label. Its icon
|
||||
// matches the state, so a glance says which fields are immune to CAT updates
|
||||
// and to the live clock.
|
||||
//
|
||||
// It lives HERE, at module level, and not inside App. Defined inside, React saw
|
||||
// a new component type on every render of App — and App re-renders about four
|
||||
// times a second while the CAT polls — so the button was unmounted and rebuilt
|
||||
// each time. It flickered under the pointer and swallowed clicks, because the
|
||||
// node being clicked no longer existed by the time the click landed.
|
||||
function LockPad({ on, title, onToggle }: { on: boolean; title: string; onToggle: () => void }) {
|
||||
const Icon = on ? Lock : Unlock;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={onToggle}
|
||||
title={`${on ? 'Unlock' : 'Lock'} ${title}`}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center size-3.5 rounded transition-colors',
|
||||
on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { t, lang } = useI18n();
|
||||
// === Lists from settings (fallback for first paint) ===
|
||||
@@ -455,27 +482,6 @@ export default function App() {
|
||||
return next;
|
||||
});
|
||||
};
|
||||
// Small padlock toggle rendered inside each lockable field's label. Match
|
||||
// the icon to the current state so the user can tell at a glance which
|
||||
// fields are immune to CAT updates / live clock.
|
||||
function LockBtn({ k, title }: { k: LockKey; title: string }) {
|
||||
const on = locks[k];
|
||||
const Icon = on ? Lock : Unlock;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => toggleLock(k)}
|
||||
title={`${on ? 'Unlock' : 'Lock'} ${title}`}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center size-3.5 rounded transition-colors',
|
||||
on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const [band, setBand] = useState('20m');
|
||||
const [mode, setMode] = useState('SSB');
|
||||
const [freqMhz, setFreqMhz] = useState('');
|
||||
@@ -3710,8 +3716,13 @@ export default function App() {
|
||||
// single-row strip or the full Log4OM-style columnar layout below. Keeping
|
||||
// them as shared consts avoids duplicating the (large) per-field JSX +
|
||||
// handlers across the two layouts.
|
||||
// The callsign field is WIDER than the fields around it, deliberately: it is
|
||||
// the one field always typed into, it carries the REC badges on its right, and
|
||||
// a long portable call (VK9/OH1ABC/MM) must stay readable as it is entered.
|
||||
// The room comes from the RST pair, which never needs more than five
|
||||
// characters.
|
||||
const callsignBlock = (
|
||||
<div className="flex flex-col w-44" data-esm="call">
|
||||
<div className="flex flex-col w-56" data-esm="call">
|
||||
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
|
||||
<span className="text-primary font-semibold">{t('field.callsign')}</span>
|
||||
{lookupBusy && (
|
||||
@@ -3858,12 +3869,12 @@ export default function App() {
|
||||
</div>
|
||||
);
|
||||
const rstTxBlock = (
|
||||
<div className="flex flex-col w-20" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
|
||||
<div className="flex flex-col w-16" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
|
||||
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
|
||||
</div>
|
||||
);
|
||||
const rstRxBlock = (
|
||||
<div className="flex flex-col w-20" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
|
||||
<div className="flex flex-col w-16" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
|
||||
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
|
||||
</div>
|
||||
);
|
||||
@@ -3897,7 +3908,7 @@ export default function App() {
|
||||
) : null;
|
||||
const startBlock = (
|
||||
<div className="flex flex-col w-28">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockBtn k="start" title="start time" /></Label>
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockPad on={locks.start} title="start time" onToggle={() => toggleLock('start')} /></Label>
|
||||
<Input
|
||||
readOnly={!locks.start}
|
||||
tabIndex={locks.start ? 0 : -1}
|
||||
@@ -3916,7 +3927,7 @@ export default function App() {
|
||||
);
|
||||
const endBlock = (
|
||||
<div className="flex flex-col w-28">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')} <LockBtn k="end" title="end time" /></Label>
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')} <LockPad on={locks.end} title="end time" onToggle={() => toggleLock('end')} /></Label>
|
||||
<Input
|
||||
readOnly={!locks.end}
|
||||
tabIndex={locks.end ? 0 : -1}
|
||||
@@ -4106,7 +4117,7 @@ export default function App() {
|
||||
// used in the full layout to save vertical height.
|
||||
const bandRow = (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')} <LockBtn k="band" title="band" /></Label>
|
||||
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')} <LockPad on={locks.band} title="band" onToggle={() => toggleLock('band')} /></Label>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Select value={band} onValueChange={onBandUserChange}>
|
||||
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
||||
@@ -4117,7 +4128,7 @@ export default function App() {
|
||||
);
|
||||
const modeRow = (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')} <LockBtn k="mode" title="mode" /></Label>
|
||||
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')} <LockPad on={locks.mode} title="mode" onToggle={() => toggleLock('mode')} /></Label>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Select value={mode} onValueChange={onModeUserChange}>
|
||||
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
||||
@@ -4173,7 +4184,7 @@ export default function App() {
|
||||
};
|
||||
const freqBlock = (
|
||||
<div className="flex flex-col w-32">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockBtn k="freq" title="frequency" /></Label>
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockPad on={locks.freq} title="frequency" onToggle={() => toggleLock('freq')} /></Label>
|
||||
<Input
|
||||
tabIndex={-1}
|
||||
className="font-mono"
|
||||
|
||||
@@ -12,10 +12,17 @@ import (
|
||||
// one playback at a time. Device ids are passed per call so the host can route
|
||||
// recording to the mic and playback to the rig (or the preview speakers).
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
recStop chan struct{}
|
||||
recDone chan recResult
|
||||
playStop chan struct{}
|
||||
mu sync.Mutex
|
||||
recStop chan struct{}
|
||||
recDone chan recResult
|
||||
// monGainPct scales what the RX monitor plays, 100 = as captured.
|
||||
//
|
||||
// The "From radio" slider used to reach only the QSO recorder, so an
|
||||
// operator listening through OpsLog heard no difference between 10% and
|
||||
// 150% — the setting looked broken because it was, for the thing they were
|
||||
// listening to.
|
||||
monGainPct int
|
||||
playStop chan struct{}
|
||||
// playDone closes when the playback goroutine has returned and the audio
|
||||
// device is free again. Without it, the next Play raced the old one for the
|
||||
// device and lost.
|
||||
@@ -225,9 +232,12 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
|
||||
m.mu.Unlock()
|
||||
|
||||
if capture {
|
||||
// Producer: capture the rig's USB audio into the ring.
|
||||
// Producer: capture the rig's USB audio into the ring, at the level the
|
||||
// operator set. Applied HERE rather than on the render side so the
|
||||
// network-fed path (PushMonitorAudio) keeps its own untouched levels —
|
||||
// that stream is already scaled by the radio.
|
||||
go func() {
|
||||
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(chunk) })
|
||||
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(m.scaleMonitor(chunk)) })
|
||||
}()
|
||||
}
|
||||
// Consumer: render the ring to the output device at the internal 16 kHz mono.
|
||||
@@ -238,6 +248,38 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMonitorGain sets the RX monitor level in percent (100 = as captured).
|
||||
// Takes effect on the next captured chunk — no need to restart the monitor.
|
||||
func (m *Manager) SetMonitorGain(pct int) {
|
||||
if pct <= 0 {
|
||||
pct = 100
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.monGainPct = pct
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// scaleMonitor applies the monitor level to one captured chunk, returning a
|
||||
// buffer the ring may keep. At unity it hands the chunk straight back: the
|
||||
// common case must not pay for a copy 30 times a second.
|
||||
func (m *Manager) scaleMonitor(chunk []byte) []byte {
|
||||
m.mu.Lock()
|
||||
pct := m.monGainPct
|
||||
m.mu.Unlock()
|
||||
if pct == 0 || pct == 100 {
|
||||
return chunk
|
||||
}
|
||||
g := float64(pct) / 100
|
||||
out := make([]byte, len(chunk))
|
||||
copy(out, chunk)
|
||||
for i := 0; i+1 < len(out); i += 2 {
|
||||
v := int16(uint16(out[i]) | uint16(out[i+1])<<8)
|
||||
v = scalePCM(v, g)
|
||||
out[i], out[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StopMonitor stops the RX monitor passthrough.
|
||||
func (m *Manager) StopMonitor() {
|
||||
m.mu.Lock()
|
||||
|
||||
Reference in New Issue
Block a user