Skip to content

CW sidetone: replace QAudioSink with PortAudio backend for sub-5ms latency #2072

Description

@ten9876

Motivation

Client-side CW sidetone (PR #1969, shipped in v0.9.0) currently uses QAudioSink with a 50 ms buffer + 2 ms push-mode timer, kept half-full to prevent Pulse/PipeWire pull-mode flapping. Net perceived latency from key-down to audible tone is roughly 25 ms — fine for slow CW, marginal at 25+ WPM, and well above what's achievable.

A research pass through three CW projects (K1EL WinKeyer, WKFlex, MORCONI) plus the OpenHPSDR / Hermes-Lite community shows the dominant pattern for low-latency CW sidetone is to bypass the OS audio mixer entirely — PortAudio with paFramesPerBufferUnspecified reaches sub-3 ms on Linux PipeWire and macOS CoreAudio. Hermes-Lite community has reproducible numbers in the 0.8–2.9 ms range with that exact architecture.

The existing CwSidetoneGenerator class (atomic key-down flag, raised-cosine envelope, lock-free process()) is already callback-safe — it just needs a better sink than QAudioSink.

Goal

Realistic target: sub-5 ms key-down → audible-onset on Linux + macOS, while keeping QAudioSink as a working fallback (Windows initially, or any platform where PortAudio init fails).

Implementation plan

File changes

New:

  • src/core/CwSidetoneSinkBackend.h — abstract interface (start(device, rate), stop(), isRunning(), actualRateHz())
  • src/core/CwSidetonePortAudioSink.{h,cpp}Pa_OpenStream + callback that directly invokes m_generator->process(). No Qt timer, no allocations in the callback path.
  • src/core/CwSidetoneQAudioSink.{h,cpp} — extracts the existing QAudioSink + 2 ms timer code from AudioEngine.cpp into the new interface (refactor, not rewrite).

Modified:

  • CMakeLists.txtfind_package(portaudio QUIET) + HAVE_PORTAUDIO define; gate the new .cpp behind it.
  • .github/docker/Dockerfile — add portaudio19-dev. Triggers ~3 min image rebuild before next CI run.
  • src/core/AudioEngine.{h,cpp} — replace the inline m_sidetoneSink/m_sidetoneTimer/m_sidetoneDevice members with a std::unique_ptr<CwSidetoneSinkBackend>. Factory function picks backend by build flag + setting.
  • CLAUDE.md — add HAVE_PORTAUDIO to the optional-dependencies table, note "preferred for low-latency CW sidetone on Linux/macOS".

Backend selection logic

if (HAVE_PORTAUDIO && AppSettings["CwSidetoneBackend"] != "QAudioSink")
    use PortAudio                  // default on Linux + macOS
else
    use QAudioSink (existing)      // Windows v1, or explicit user override

Default to PortAudio when available. Setting toggle exists for emergency fallback.

PortAudio callback model

// Audio thread, called by PortAudio at ~5 ms intervals on Linux PipeWire
static int paCallback(const void*, void* out, unsigned long frames,
                      const PaStreamCallbackTimeInfo*, PaStreamCallbackFlags,
                      void* userData)
{
    auto* self = static_cast<CwSidetonePortAudioSink*>(userData);
    auto* dst = static_cast<float*>(out);
    std::memset(dst, 0, frames * 2 * sizeof(float));
    self->m_generator->process(dst, frames);   // existing generator, unchanged
    return paContinue;
}

Stream opened with paFramesPerBufferUnspecified so PortAudio negotiates the smallest the device supports. On PipeWire that's typically 64–128 frames (~1.5–3 ms).

CwSidetoneGenerator changes: none

CwSidetoneGenerator::process() already runs lock-free in the audio path. The class is unchanged.

Manual test plan

  1. Build with PortAudio: ./build/AetherSDR — sidetone works as before, perception identical on common test phrases.
  2. Quick switch: settings file edit CwSidetoneBackend=QAudioSink, restart, verify fallback to old path still works.
  3. Latency comparison: send 25 WPM string, A/B between backends. Subjective improvement should be obvious.
  4. Audio device edge cases: DAX out, HFP Bluetooth, USB DAC — verify graceful fallback if PortAudio init fails on any of them (auto-revert to QAudioSink, log a warning).
  5. macOS build: confirm CoreAudio host API is selected automatically.
  6. Windows build: confirm HAVE_PORTAUDIO is OFF (we're not adding it on Windows in v1) — falls through to QAudioSink path unchanged.

Estimated effort

  • Scaffolding (interface + refactor existing code into Q backend): ~2 h
  • PortAudio backend + integration: ~3 h
  • CMake + Docker plumbing + first CI cycle: ~1 h
  • Cross-platform smoke tests: ~2 h
  • Total: ~8 hours of focused work, single PR.

Risk register

Risk Likelihood Mitigation
PortAudio init fails on user's device Low-medium Auto-fall-back to QAudioSink, log warning
Linux backend (ALSA vs PulseAudio vs JACK) picks wrong host API Low Pa_GetDefaultHostApi defaults to PulseAudio on PipeWire systems — usually right. Expose CwSidetoneHostApi setting if reports come in.
Latency improvement isn't perceived Low Research suggests 5–25 ms reduction; even if subjective improvement is small, the existing event-loop pressure goes away
Docker image update breaks unrelated CI Very low Image is layered, the package add is additive

References

  • Original CW sidetone: PR Local CW sidetone with dedicated low-latency sink and CWX support #1969, shipped in v0.9.0
  • Source files: src/core/CwSidetoneGenerator.{h,cpp}, src/core/AudioEngine.cpp (startSidetoneStream ≈ line 656)
  • Research projects: K1EL WinKeyer (hardware sidetone reference), WKFlex (no sidetone — bridge only, succeeded by MORCONI), MORCONI (Teensy-based standalone CW interface, sub-5 ms target via raised-cosine envelope + direct DAC)
  • OpenHPSDR / Hermes-Lite reference: 0.8–2.9 ms achievable with PortAudio + small frames-per-buffer

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementImprovement to existing featureno-claudeReserved for human implementation

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions