mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
d22a1ee5be
commit
297f5142a6
3 changed files with 141 additions and 3 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue