From ec7a46a6fe88048d62a53901d2daa0bc2c61eaa6 Mon Sep 17 00:00:00 2001 From: Ivan Kharitonov Date: Thu, 4 Jun 2026 13:30:22 +0400 Subject: [PATCH] fix(voice): add WSL audio warmup to eliminate RDP crackling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #38893 --- tools/voice_mode.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/tools/voice_mode.py b/tools/voice_mode.py index e92246ad700e..8d96f8a0e4e1 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -1178,6 +1178,16 @@ def stop_playback() -> None: pass +def _is_wsl() -> bool: + """True when running inside Windows Subsystem for Linux.""" + try: + with open("/proc/version", "r") as f: + return "microsoft" in f.read().lower() + except Exception: + return False + return False + + def play_audio_file(file_path: str) -> bool: """Play an audio file through the default output device. @@ -1206,7 +1216,27 @@ def play_audio_file(file_path: str) -> bool: audio_data = np.frombuffer(frames, dtype=np.int16) sample_rate = wf.getframerate() - sd.play(audio_data, samplerate=sample_rate) + # WSLg RDP audio needs a warmup to avoid crackling at the start. + # The RDP virtual-channel connection takes ~100 ms to stabilise, + # and small default blocksize exasperates timing jitter caused by + # systemd-timesyncd clock adjustments (microsoft/wslg#1257). + if _is_wsl(): + silence_samples = int(0.1 * sample_rate) + 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 + tail = np.zeros(int(0.05 * sample_rate), dtype=np.int16) + audio_data = np.concatenate([ + np.zeros(silence_samples, dtype=np.int16), + audio_float.astype(np.int16), + tail, + ]) + blocksize = 4096 + else: + blocksize = 0 # default (auto) + + sd.play(audio_data, samplerate=sample_rate, blocksize=blocksize) # sd.wait() calls Event.wait() without timeout — hangs forever if # the audio device stalls. Poll with a ceiling and force-stop. duration_secs = len(audio_data) / sample_rate