From 297f5142a651e80e005e4ba34968c55daba289cb Mon Sep 17 00:00:00 2001 From: PRATHAMESH75 Date: Sat, 18 Jul 2026 15:08:52 +0530 Subject: [PATCH] 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=:all=1. Tunable via discord.voice_fx.lead_silence_ms (default 200, 0 disables). Fixes #66827 --- plugins/platforms/discord/adapter.py | 41 ++++++++- plugins/platforms/discord/voice_mixer.py | 1 + tests/gateway/test_discord_voice_mixer.py | 102 ++++++++++++++++++++++ 3 files changed, 141 insertions(+), 3 deletions(-) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index dbc43a884f91..821971d7f109 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -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: diff --git a/plugins/platforms/discord/voice_mixer.py b/plugins/platforms/discord/voice_mixer.py index e2a4ba99259c..254380a9064d 100644 --- a/plugins/platforms/discord/voice_mixer.py +++ b/plugins/platforms/discord/voice_mixer.py @@ -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 diff --git a/tests/gateway/test_discord_voice_mixer.py b/tests/gateway/test_discord_voice_mixer.py index dae6da27f4b1..a55923911482 100644 --- a/tests/gateway/test_discord_voice_mixer.py +++ b/tests/gateway/test_discord_voice_mixer.py @@ -234,6 +234,108 @@ class TestPlayInVoiceChannelMixerPath: assert vc.play.called +class TestLeadSilence: + """Warm-up lead silence prepended to speech so the first word isn't clipped + (issue #66827).""" + + def test_bytes_empty_when_unset(self): + adapter = _make_adapter() # default cfg has no lead_silence_ms + assert adapter._lead_silence_bytes() == b"" + + def test_bytes_empty_when_zero_or_negative(self): + assert _make_adapter({"lead_silence_ms": 0})._lead_silence_bytes() == b"" + assert _make_adapter({"lead_silence_ms": -50})._lead_silence_bytes() == b"" + + def test_bytes_empty_when_non_numeric(self): + assert _make_adapter({"lead_silence_ms": "nope"})._lead_silence_bytes() == b"" + + def test_bytes_length_matches_ms(self): + adapter = _make_adapter({"lead_silence_ms": 200}) + lead = adapter._lead_silence_bytes() + assert lead == b"\x00" * (vm.BYTES_PER_MS * 200) + assert len(lead) == 200 * 192 # 48kHz stereo s16 -> 192 bytes/ms + + @pytest.mark.asyncio + async def test_mixer_path_prepends_lead_silence(self): + adapter = _make_adapter({ + "enabled": True, "speech_gain": 1.0, "lead_silence_ms": 200, + }) + vc = MagicMock() + vc.is_connected.return_value = True + adapter._voice_clients[111] = vc + + class _Mixer: + def __init__(self): + self._polls = 0 + self.play_speech = MagicMock() + + @property + def speech_active(self): + self._polls += 1 + return self._polls <= 1 + + mixer = _Mixer() + adapter._voice_mixers[111] = mixer + adapter._reset_voice_timeout = MagicMock() + + fake_pcm = b"\x11" * vm.FRAME_SIZE + with patch.object(vm, "decode_to_pcm", return_value=fake_pcm): + ok = await adapter.play_in_voice_channel(111, "/tmp/x.mp3") + assert ok is True + sent = mixer.play_speech.call_args.args[0] + assert len(sent) == vm.BYTES_PER_MS * 200 + vm.FRAME_SIZE + assert sent.startswith(b"\x00" * (vm.BYTES_PER_MS * 200)) + assert sent.endswith(fake_pcm) + + @pytest.mark.asyncio + async def test_legacy_path_applies_adelay(self): + adapter = _make_adapter({"lead_silence_ms": 150}) # no mixer installed + vc = MagicMock() + vc.is_connected.return_value = True + vc.is_playing.return_value = False + adapter._voice_clients[111] = vc + adapter._reset_voice_timeout = MagicMock() + adapter._voice_receivers[111] = MagicMock() + + with patch("plugins.platforms.discord.adapter.discord") as mock_discord: + mock_discord.FFmpegPCMAudio.return_value = MagicMock() + mock_discord.PCMVolumeTransformer.return_value = MagicMock() + + async def _fast(coro, *a, **k): + if hasattr(coro, "close"): + coro.close() + return None + with patch("asyncio.wait_for", _fast): + await adapter.play_in_voice_channel(111, "/tmp/x.mp3") + + _, kwargs = mock_discord.FFmpegPCMAudio.call_args + assert kwargs.get("options") == "-af adelay=150:all=1" + + @pytest.mark.asyncio + async def test_legacy_path_no_option_when_disabled(self): + adapter = _make_adapter({"lead_silence_ms": 0}) + vc = MagicMock() + vc.is_connected.return_value = True + vc.is_playing.return_value = False + adapter._voice_clients[111] = vc + adapter._reset_voice_timeout = MagicMock() + adapter._voice_receivers[111] = MagicMock() + + with patch("plugins.platforms.discord.adapter.discord") as mock_discord: + mock_discord.FFmpegPCMAudio.return_value = MagicMock() + mock_discord.PCMVolumeTransformer.return_value = MagicMock() + + async def _fast(coro, *a, **k): + if hasattr(coro, "close"): + coro.close() + return None + with patch("asyncio.wait_for", _fast): + await adapter.play_in_voice_channel(111, "/tmp/x.mp3") + + _, kwargs = mock_discord.FFmpegPCMAudio.call_args + assert "options" not in kwargs + + class TestPlayAckInVoice: @pytest.mark.asyncio async def test_noop_when_ack_disabled(self):