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:
2026-07-31 20:33:39 +02:00
parent 39bd1ff414
commit 84b06ed47b
4 changed files with 112 additions and 37 deletions
+48 -6
View File
@@ -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()