fix(audio): replace QsoRecorder QTimer playback with QAudioSink pull mode#2487
Conversation
…mode (aethersdr#2295) Windows' system clock tick (~15.625 ms) causes CoarseTimer-based chunk delivery to starve the WASAPI shared-mode sink, inserting silence and producing choppy playback on Windows. Linux's 1 ms tick makes the same 10 ms timer effectively precise, explaining the platform asymmetry. Port the proven ClientPuduMonitor pattern: read the WAV payload into a QBuffer, open a dedicated QAudioSink in pull mode with a 300 ms internal ring buffer, and let WASAPI/CoreAudio drain at its own cadence. Adds a 48 kHz r8brain resample path (via Resampler) for sinks that don't support 24 kHz natively (common on macOS/Windows). Drops the QTimer, QFile playback members, and the playbackAudio → feedDecodedSpeech route in MainWindow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks @wa2n-code — this is a clean, faithful port of the existing ClientPuduMonitor playback pattern (same QAudioSink pull-mode setup, same 300 ms ring buffer rationale, same L/R-independent r8brain resample to preserve stereo image, same IdleState/StoppedState shutdown). The root-cause analysis (Windows ~15.625 ms timer tick vs. WASAPI 10 ms pull cadence) matches what we saw with PUDU.
Scope check is good:
- All 3 files are tightly scoped to the playback path.
- The removed
playbackAudiosignal has no other consumers. QsoRecorderlives on the main thread (parented toMainWindow), so directQAudioSink/QBufferownership is fine.m_playBuffer.setBuffer(&m_playPcm)is safe becausem_playPcmis a stable member address and the buffer is closed before each new playback.
A couple of minor observations, none blocking:
-
startPlayback()early-returns are silent. IfdefaultAudioOutput()is null, neither 24 kHz nor 48 kHz is supported, orpreparePlaybackPcm()fails, the user clicks Play and nothing happens with no feedback. ExistingClientPuduMonitordoes the same, so this is consistent — but sinceQsoRecorderalready has arecordingErrorsignal pattern, you could optionally add aplaybackError(QString)for these paths. Up to you / a follow-up. -
Potential synchronous
stateChangedduringstart(). IfQAudioSink::start()ever emitsIdleStatesynchronously before the next linem_playing = true;runs,onPlaybackSinkState→stopPlayback()would early-return on!m_playing, leaving the sink running withm_playing=false. PUDU has the exact same ordering and ships fine, so I'd expect the same here, but if you want belt-and-suspenders you could flip the order: setm_playing = truebeforem_playSink->start(&m_playBuffer).
LGTM otherwise — happy to see this lifted out of the timer-paced path.
|
Claude here — really clean cross-platform diagnostic, Wayne. The 'Windows 15.625 ms clock tick vs Linux 1 ms' framing is the kind of insight that's only obvious when you actually test on both platforms — exactly why this got past the original commit. Reusing the ClientPuduMonitor pull-mode pattern is the right call (proven shape, no invention) and the per-channel resample mirrors the lesson from your #2459 fix earlier this session. Format-negotiation order is also right: 24 kHz first means Linux's zero-resample path is preserved, only Windows/macOS pay for the resample. Merged. 73, Jeremy KK7GWY & Claude (AI dev partner) |
Fixes #2295
Root cause
QsoRecorder::startPlayback()used a 10 msQTimer(CoarseTimer) to pace PCM chunk delivery intoAudioEngine::feedDecodedSpeech. On Windows, the system clock tick is ~15.625 ms, so chunks arrive at irregular 16/16/32 ms intervals while the WASAPI shared-mode sink pulls on a tight 10 ms schedule — every missed refill inserts silence, producing choppy audio. On Linux, the 1 ms scheduler tick makes the same timer effectively precise, which is why it only reproduced on Windows.Fix
Port the proven
ClientPuduMonitorpattern:QsoRecorder::startPlayback()— reads the WAV payload into aQByteArray, opens a dedicatedQAudioSinkin pull mode with a 300 ms internal ring buffer (fmt.bytesForDuration(300'000)), and lets WASAPI/CoreAudio drain at its own cadence. No timer, nofeedDecodedSpeech.QsoRecorder::preparePlaybackPcm()— tries 24 kHz passthrough first (zero-resample, common on Linux); falls back to 48 kHz with a one-shot r8brain resample per channel (common on macOS/Windows).QsoRecorder::onPlaybackSinkState()— stops cleanly when the sink reachesIdleState(buffer drained) orStoppedState.MainWindow.cpp— removes the now-unusedplaybackAudio → feedDecodedSpeechconnection.Files changed
src/core/QsoRecorder.h— swapm_playFile/m_playTimerform_playSink/m_playBuffer/m_playPcm; removeplaybackAudiosignal; add private slot + helpersrc/core/QsoRecorder.cpp— implement new playback pathsrc/gui/MainWindow.cpp— drop obsolete signal connection (2 lines)Test plan
🤖 Generated with Claude Code