Skip to content

[BUG] Local CW sidetone latency exceeds ~25 ms claim; iambic paddle sidetone tracks lever state, not element timing (follow-up to #1969) #1989

@LU5DX

Description

@LU5DX

Summary

Two issues identified by code review of #1969 when used with a local keyer:

  1. 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.
  2. 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:

  1. Subscribe to cwPaddleChanged(dit, dah).
  2. 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).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    CWCW keying, decode, and operationaudioAudio engine and streamingbugSomething isn't workingmaintainer-reviewRequires maintainer review before any action is taken

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions