You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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).
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.txt — find_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 PipeWirestaticintpaCallback(constvoid*, void* out, unsignedlong 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, unchangedreturn 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
Build with PortAudio: ./build/AetherSDR — sidetone works as before, perception identical on common test phrases.
Quick switch: settings file edit CwSidetoneBackend=QAudioSink, restart, verify fallback to old path still works.
Latency comparison: send 25 WPM string, A/B between backends. Subjective improvement should be obvious.
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).
macOS build: confirm CoreAudio host API is selected automatically.
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
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
Motivation
Client-side CW sidetone (PR #1969, shipped in v0.9.0) currently uses
QAudioSinkwith 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
paFramesPerBufferUnspecifiedreaches 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
CwSidetoneGeneratorclass (atomic key-down flag, raised-cosine envelope, lock-freeprocess()) 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 invokesm_generator->process(). No Qt timer, no allocations in the callback path.src/core/CwSidetoneQAudioSink.{h,cpp}— extracts the existing QAudioSink + 2 ms timer code fromAudioEngine.cppinto the new interface (refactor, not rewrite).Modified:
CMakeLists.txt—find_package(portaudio QUIET)+HAVE_PORTAUDIOdefine; gate the new.cppbehind it..github/docker/Dockerfile— addportaudio19-dev. Triggers ~3 min image rebuild before next CI run.src/core/AudioEngine.{h,cpp}— replace the inlinem_sidetoneSink/m_sidetoneTimer/m_sidetoneDevicemembers with astd::unique_ptr<CwSidetoneSinkBackend>. Factory function picks backend by build flag + setting.CLAUDE.md— addHAVE_PORTAUDIOto the optional-dependencies table, note "preferred for low-latency CW sidetone on Linux/macOS".Backend selection logic
Default to PortAudio when available. Setting toggle exists for emergency fallback.
PortAudio callback model
Stream opened with
paFramesPerBufferUnspecifiedso 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
./build/AetherSDR— sidetone works as before, perception identical on common test phrases.CwSidetoneBackend=QAudioSink, restart, verify fallback to old path still works.HAVE_PORTAUDIOisOFF(we're not adding it on Windows in v1) — falls through to QAudioSink path unchanged.Estimated effort
Risk register
Pa_GetDefaultHostApidefaults to PulseAudio on PipeWire systems — usually right. ExposeCwSidetoneHostApisetting if reports come in.References
src/core/CwSidetoneGenerator.{h,cpp},src/core/AudioEngine.cpp(startSidetoneStream≈ line 656)