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

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
This commit is contained in:
Ivan Kharitonov 2026-06-04 13:30:22 +04:00 committed by Teknium
parent ef686c3878
commit ec7a46a6fe

View file

@ -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