Summary
Two issues identified by code review of #1969 when used with a local keyer:
- Steady-state sidetone latency from a code reading is closer to ~50 ms than the ~25 ms documented in the PR description. Perceptible at 25+ WPM, and the implementation appears to contradict its own inline comment.
- For iambic paddle input, the local sidetone tracks lever state rather than actual dit/dah element timing, so the sidetone does not match what the radio is actually keying.
Both observations are derived from reading the merged code at c4489fc (current main); I have not yet verified them on hardware. Filing now because Issue 2 in particular is a design-level concern that doesn't depend on hardware measurement.
Issue 1 — sidetone sink stays nearly full, not half-full
src/core/AudioEngine.cpp:559–639 opens a 50 ms QAudioSink and feeds it with a 2 ms Qt::PreciseTimer. The inline comment at lines 593–596 reads:
Real perceived latency stays low (~25 ms typical) because we keep the buffer about half-full via the 2 ms timer, not because the buffer itself is small.
But the timer body (lines 619–631) writes all available bytes every tick:
const qsizetype freeBytes = m_sidetoneSink->bytesFree();
if (freeBytes <= 0) return;
constexpr qsizetype frameBytes = 2 * sizeof(float);
const qsizetype byteCount = (freeBytes / frameBytes) * frameBytes;
if (byteCount == 0) return;
QByteArray chunk(byteCount, '\0');
const int frames = static_cast<int>(byteCount / frameBytes);
m_cwSidetone->process(reinterpret_cast<float*>(chunk.data()), frames);
m_sidetoneDevice->write(chunk);
After the first tick the buffer goes from empty to ~50 ms full. From then on the sink consumes ~2 ms of audio between ticks and the timer immediately refills it. Steady-state occupancy should sit at ~48–50 ms, not ~25 ms.
So the latency from setKeyDown(true) to the corresponding sample reaching the audio device should be roughly buffer occupancy + sink-to-device fixed overhead ≈ ~50 ms plus PulseAudio/PipeWire overhead, not the ~25 ms the comment claims.
Suggested fix
Cap the per-tick write so the timer actively keeps a small target fill (e.g. 10–15 ms) rather than topping the buffer up:
constexpr int kTargetFillMs = 12; // ~6× headroom over 2 ms timer jitter
constexpr qsizetype frameBytes = 2 * sizeof(float);
const qsizetype targetFillBytes =
static_cast<qsizetype>(chosenRate) * frameBytes * kTargetFillMs / 1000;
const qsizetype currentFill = m_sidetoneSink->bufferSize() - freeBytes;
if (currentFill >= targetFillBytes) return;
const qsizetype byteCount = std::min(freeBytes, targetFillBytes - currentFill);
The 50 ms total sink buffer can stay (safety against extreme scheduler jitter); we just stop running with it permanently full. If 12 ms underruns on some hardware the target can be exposed as an AppSettings key.
Issue 2 — iambic paddle sidetone tracks lever, not elements
RadioModel::sendCwPaddle(bool dit, bool dah) (src/models/RadioModel.cpp:615–627) emits:
emit cwKeyDownChanged(dit || dah);
This is the lever-held state, not the element timing. For an iambic paddle (USB CTS/DSR via SerialPortController), the radio's own keyer generates the dit/dah sequence based on lever states + WPM. The local sidetone, driven by cwKeyDownChanged, will therefore play:
- A continuous sustained tone for as long as a lever is held.
- No tone at all when both levers are released.
Meanwhile the radio is actually keying dit-gap-dit-gap-… at the configured WPM, audible (with 30–100 ms latency) over DAX. The two are not the same signal.
This is unrelated to Issue 1 — it would still be wrong even with zero audio latency.
Suggested fix
Add a paddle-side counterpart to CwxLocalKeyer (introduced in #1969 for CWX text). A LocalPaddleKeyer would:
- Subscribe to
cwPaddleChanged(dit, dah).
- Run a Mode A / Mode B iambic state machine (configurable; matches the radio's
cw mode_b setting), driven by a Qt::PreciseTimer at the same WPM as the radio (TransmitModel::cwWpm() — already replicated for the CWX keyer).
- Emit derived
cwKeyDownChanged(true/false) on actual element boundaries instead of lever boundaries.
The existing sidetone generator stays unchanged. The radio's keyer continues to be authoritative for the actually-transmitted CW (raw paddle states still go via cw key <dit> <dah>). The local keyer is a shadow keyer whose only job is to produce element timing for the sidetone, kept in sync via shared WPM and mode settings. Drift over a single transmission burst is bounded.
Suggested reproduction
I have not run these myself. For someone with hardware:
- AetherSDR
c4489fc (current main).
- USB serial paddle on Linux/PulseAudio (CTS=dit, DSR=dah, default polarity).
Local STn enabled in PhoneCwApplet.
- Send any CW at 25 WPM with the paddle; the prediction is the sidetone is a steady tone while levers are held, not a sequence of dits/dahs (Issue 2).
- For Issue 1 alone: connect a straight key (CTS=key, no paddle), measure key-press to sidetone-onset; the prediction is well above 25 ms on default Linux audio.
Scope
Issue 1 is small — a handful of lines in AudioEngine.cpp. Clean candidate for aetherclaude-eligible.
Issue 2 is a new class plus signal rewiring; comparable in scope to CwxLocalKeyer, but probably warrants maintainer review on the Mode A/B behaviour.
Happy to take Issue 1 as a PR if the maintainer wants. Issue 2 likely benefits from a quick design check first.
Summary
Two issues identified by code review of #1969 when used with a local keyer:
Both observations are derived from reading the merged code at
c4489fc(currentmain); I have not yet verified them on hardware. Filing now because Issue 2 in particular is a design-level concern that doesn't depend on hardware measurement.Issue 1 — sidetone sink stays nearly full, not half-full
src/core/AudioEngine.cpp:559–639opens a 50 msQAudioSinkand feeds it with a 2 msQt::PreciseTimer. The inline comment at lines 593–596 reads:But the timer body (lines 619–631) writes all available bytes every tick:
After the first tick the buffer goes from empty to ~50 ms full. From then on the sink consumes ~2 ms of audio between ticks and the timer immediately refills it. Steady-state occupancy should sit at ~48–50 ms, not ~25 ms.
So the latency from
setKeyDown(true)to the corresponding sample reaching the audio device should be roughly buffer occupancy + sink-to-device fixed overhead ≈ ~50 ms plus PulseAudio/PipeWire overhead, not the ~25 ms the comment claims.Suggested fix
Cap the per-tick write so the timer actively keeps a small target fill (e.g. 10–15 ms) rather than topping the buffer up:
The 50 ms total sink buffer can stay (safety against extreme scheduler jitter); we just stop running with it permanently full. If 12 ms underruns on some hardware the target can be exposed as an
AppSettingskey.Issue 2 — iambic paddle sidetone tracks lever, not elements
RadioModel::sendCwPaddle(bool dit, bool dah)(src/models/RadioModel.cpp:615–627) emits:emit cwKeyDownChanged(dit || dah);This is the lever-held state, not the element timing. For an iambic paddle (USB CTS/DSR via
SerialPortController), the radio's own keyer generates the dit/dah sequence based on lever states + WPM. The local sidetone, driven bycwKeyDownChanged, will therefore play:Meanwhile the radio is actually keying
dit-gap-dit-gap-…at the configured WPM, audible (with 30–100 ms latency) over DAX. The two are not the same signal.This is unrelated to Issue 1 — it would still be wrong even with zero audio latency.
Suggested fix
Add a paddle-side counterpart to
CwxLocalKeyer(introduced in #1969 for CWX text). ALocalPaddleKeyerwould:cwPaddleChanged(dit, dah).cw mode_bsetting), driven by aQt::PreciseTimerat the same WPM as the radio (TransmitModel::cwWpm()— already replicated for the CWX keyer).cwKeyDownChanged(true/false)on actual element boundaries instead of lever boundaries.The existing sidetone generator stays unchanged. The radio's keyer continues to be authoritative for the actually-transmitted CW (raw paddle states still go via
cw key <dit> <dah>). The local keyer is a shadow keyer whose only job is to produce element timing for the sidetone, kept in sync via shared WPM and mode settings. Drift over a single transmission burst is bounded.Suggested reproduction
I have not run these myself. For someone with hardware:
c4489fc(currentmain).Local STnenabled inPhoneCwApplet.Scope
Issue 1 is small — a handful of lines in
AudioEngine.cpp. Clean candidate foraetherclaude-eligible.Issue 2 is a new class plus signal rewiring; comparable in scope to
CwxLocalKeyer, but probably warrants maintainer review on the Mode A/B behaviour.Happy to take Issue 1 as a PR if the maintainer wants. Issue 2 likely benefits from a quick design check first.