fix(discord): prepend warm-up silence so TTS first word isn't clipped

Discord's voice socket needs a brief warm-up before receiving clients
actually hear audio; the first ~100-200ms is lost, clipping the first
word/syllable of TTS playback. Prepend a configurable lead of silence to
speech on both playback paths:

- Mixer path: new _lead_silence_bytes() helper prepends PCM silence
  (BYTES_PER_MS constant added to voice_mixer.py) before play_speech, on
  both the reply and the pre-tool ack.
- Legacy FFmpegPCMAudio path: apply -af adelay=<ms>:all=1.

Tunable via discord.voice_fx.lead_silence_ms (default 200, 0 disables).

Fixes #66827
This commit is contained in:
PRATHAMESH75 2026-07-18 15:08:52 +05:30 committed by Teknium
parent d22a1ee5be
commit 297f5142a6
3 changed files with 141 additions and 3 deletions

View file

@ -3682,6 +3682,9 @@ class DiscordAdapter(BasePlatformAdapter):
"ambient_gain": 0.18, # idle bed loudness (0..1)
"duck_gain": 0.06, # ambient loudness while speech plays
"speech_gain": 1.0, # TTS / ack loudness
"lead_silence_ms": 200, # silence prepended to each clip so the
# voice socket's warm-up doesn't clip
# the first word/syllable
"ack_enabled": True, # speak a short phrase before tool calls
"ack_phrases": [
"Let me look into that.",
@ -3759,6 +3762,27 @@ class DiscordAdapter(BasePlatformAdapter):
self._voice_mixers[guild_id] = mixer
logger.info("Voice mixer installed (guild=%d, ambient=%s)", guild_id, bool(ambient))
def _lead_silence_bytes(self) -> bytes:
"""PCM silence prepended to speech clips on the mixer path.
Discord's voice socket needs a brief warm-up before receiving clients
actually hear audio; the first ~100-200ms is otherwise clipped, cutting
off the first word/syllable. Returns b"" when ``lead_silence_ms`` is
unset or <= 0 so the behaviour is opt-out.
"""
cfg = getattr(self, "_voice_fx_cfg", None) or {}
try:
lead_ms = int(cfg.get("lead_silence_ms", 0) or 0)
except (TypeError, ValueError):
return b""
if lead_ms <= 0:
return b""
try:
from voice_mixer import BYTES_PER_MS
except ImportError:
from .voice_mixer import BYTES_PER_MS
return b"\x00" * (BYTES_PER_MS * lead_ms)
async def play_ack_in_voice(self, guild_id: int, phrase: Optional[str] = None) -> bool:
"""Speak a short acknowledgement over the ambient bed.
@ -3800,7 +3824,8 @@ class DiscordAdapter(BasePlatformAdapter):
if not pcm:
return False
mixer.play_speech(
pcm, gain=float(self._voice_fx_cfg.get("speech_gain", 1.0))
self._lead_silence_bytes() + pcm,
gain=float(self._voice_fx_cfg.get("speech_gain", 1.0)),
)
self._reset_voice_timeout(guild_id)
return True
@ -3931,7 +3956,7 @@ class DiscordAdapter(BasePlatformAdapter):
pcm = await asyncio.to_thread(decode_to_pcm, audio_path)
if pcm:
speech_gain = float(self._voice_fx_cfg.get("speech_gain", 1.0))
mixer.play_speech(pcm, gain=speech_gain)
mixer.play_speech(self._lead_silence_bytes() + pcm, gain=speech_gain)
# Block until the speech child drains so callers serialise
# replies (mirrors legacy semantics) but the ambient keeps
# playing underneath the whole time.
@ -3970,7 +3995,17 @@ class DiscordAdapter(BasePlatformAdapter):
logger.error("Voice playback error: %s", error)
loop.call_soon_threadsafe(done.set)
source = discord.FFmpegPCMAudio(audio_path)
# Prepend a short lead of silence so the voice socket's warm-up
# doesn't clip the first word (mirrors the mixer path above).
ffmpeg_opts: Dict[str, Any] = {}
_fx_cfg = getattr(self, "_voice_fx_cfg", None) or {}
try:
lead_ms = int(_fx_cfg.get("lead_silence_ms", 0) or 0)
except (TypeError, ValueError):
lead_ms = 0
if lead_ms > 0:
ffmpeg_opts["options"] = f"-af adelay={lead_ms}:all=1"
source = discord.FFmpegPCMAudio(audio_path, **ffmpeg_opts)
source = discord.PCMVolumeTransformer(source, volume=1.0)
vc.play(source, after=_after)
try:

View file

@ -73,6 +73,7 @@ SAMPLE_WIDTH = 2 # bytes per sample (s16)
FRAME_LENGTH_MS = 20
SAMPLES_PER_FRAME = SAMPLE_RATE * FRAME_LENGTH_MS // 1000 # 960
FRAME_SIZE = SAMPLES_PER_FRAME * CHANNELS * SAMPLE_WIDTH # 3840 bytes
BYTES_PER_MS = SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH // 1000 # 192
SILENCE_FRAME = b"\x00" * FRAME_SIZE