Skip to content

fix(voice): add WSL audio warmup to eliminate RDP crackling - #38896

Closed
kharitonov-ivan wants to merge 1 commit into
NousResearch:mainfrom
kharitonov-ivan:fix/wsl-audio-warmup
Closed

fix(voice): add WSL audio warmup to eliminate RDP crackling#38896
kharitonov-ivan wants to merge 1 commit into
NousResearch:mainfrom
kharitonov-ivan:fix/wsl-audio-warmup

Conversation

@kharitonov-ivan

Copy link
Copy Markdown
Contributor

Summary

WSLg RDP audio playback produces crackling/popping artifacts even when the generated WAV file is clean. Two interacting issues:

  1. systemd-timesyncd clock adjustments — hyperv_clocksource drift causes PulseAudio RDP sink timing jitter → buffer underruns throughout playback (microsoft/wslg#1257)
  2. Cold-start RDP connection — PortAudio opens a new PA stream on first sd.play(); the RDP virtual channel needs ~100ms to stabilise → first packets dropped

Fix

In play_audio_file() (tools/voice_mode.py), WAV branch (sounddevice path):

  • Detect WSL via /proc/version"microsoft" marker
  • Prepend 100ms silence + apply 100ms fade-in to audio + append 50ms silence tail
  • Set blocksize=4096 explicitly (default auto ~1024 is too small for RDP jitter)
  • All in a single continuous sd.play() buffer — no stop/start gap

Gated behind _is_wsl() → Linux/macOS paths are unchanged.

Testing

Empirical A/B tests on WSL2 Ubuntu 24.04 with RTX 4060:

Condition Result
timesyncd ON, no warmup crackling throughout
timesyncd OFF, no warmup mostly clean, beginning crackles
timesyncd OFF + warmup (this PR) clean from start to end

The timesyncd fix is a documented user action (sudo systemctl stop systemd-timesyncd). The warmup is automated in code.

Related

WSLg RDP audio has two issues causing crackling:
1. systemd-timesyncd clock adjustments jitter PulseAudio timing
   (microsoft/wslg#1257) — user action: stop the service
2. Cold-start RDP connection drops first ~100ms of audio packets
   before the virtual channel stabilises

Fix (automated, WSL-only):
- Detect WSL via /proc/version 'microsoft' marker
- Prepend 100ms silence + apply 100ms fade-in to audio
- Append 50ms silence tail for clean stream teardown
- Set blocksize=4096 (default auto ~1024 is too small for RDP)
- All in a single continuous sd.play() buffer

Non-WSL paths unchanged.

Closes NousResearch#38893
@alt-glitch alt-glitch added type/bug Something isn't working tool/tts Text-to-speech and transcription P3 Low — cosmetic, nice to have labels Jun 4, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for isolating this to the existing WAV/sounddevice playback path. Current main still makes the unmitigated sd.play() call at tools/voice_mode.py:1071, so this addresses a live path.

Problems

  • The new fade multiplication at proposed tools/voice_mode.py:1089 has a shape mismatch for a valid WAV shorter than 100 ms: the audio slice is shorter than the fixed-size fade vector. That raises inside the broad playback try, causing the code to fall through instead of applying the WSL path.
  • The PR changes only tools/voice_mode.py; existing coverage at tests/tools/test_voice_mode.py:884 exercises a one-second ordinary WAV and does not cover WSL padding/fade, blocksize=4096, or short clips.

Suggested changes

  • Clamp the fade length to len(audio_data) before constructing the ramp, while keeping the silence prefix independent.
  • Reuse the tested shared detector at hermes_constants.py:839 instead of adding another /proc/version implementation.
  • Add mocked WSL/non-WSL and short-WAV regression tests in tests/tools/test_voice_mode.py.

This is an automated hermes-sweeper review.

Comment thread tools/voice_mode.py
fade_samples = int(0.1 * sample_rate)
fade = np.linspace(0.0, 1.0, fade_samples, dtype=np.float64)
audio_float = audio_data.astype(np.float64)
audio_float[:fade_samples] *= fade

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For WAVs shorter than 100 ms this slice is shorter than fade, so NumPy raises a shape-mismatch ValueError and the outer handler skips the sounddevice path. Clamp fade_samples to len(audio_data) before constructing and applying the ramp.

Comment thread tools/voice_mode.py
pass


def _is_wsl() -> bool:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please reuse hermes_constants.is_wsl() here. It implements the same marker check, caches the result, and already has WSL1/WSL2 coverage in tests/hermes_cli/test_gateway_wsl.py.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users labels Jul 14, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Merged into main via consolidated salvage PR #73520 (merge e04c2a9ebd). Your WSL audio warmup eliminating RDP crackling was cherry-picked with your authorship.

Your contribution is credited to you in git history. Thank you! Closing this PR as merged-via-salvage.

@teknium1 teknium1 closed this Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P3 Low — cosmetic, nice to have sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows tool/tts Text-to-speech and transcription type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(voice): WSL audio crackling — timesyncd clock skew + cold-start buffer

3 participants