diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 215ae0d46d18..e754a6edd5b4 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -711,34 +711,32 @@ def check_silence(self) -> list: @staticmethod def pcm_to_wav(pcm_data: bytes, output_path: str, src_rate: int = 48000, src_channels: int = 2): - """Convert raw PCM to 16kHz mono WAV via ffmpeg.""" - with tempfile.NamedTemporaryFile(suffix=".pcm", delete=False) as f: - f.write(pcm_data) - pcm_path = f.name - try: - from hermes_cli._subprocess_compat import windows_hide_flags - - subprocess.run( - [ - "ffmpeg", "-y", "-loglevel", "error", - "-f", "s16le", - "-ar", str(src_rate), - "-ac", str(src_channels), - "-i", pcm_path, - "-ar", "16000", - "-ac", "1", - output_path, - ], - check=True, - timeout=10, - stdin=subprocess.DEVNULL, - creationflags=windows_hide_flags(), - ) - finally: - try: - os.unlink(pcm_path) - except OSError: - pass + """Convert raw PCM to 16kHz mono WAV via ffmpeg. + + The PCM is fed straight to ffmpeg's stdin, which avoids staging it in a + temp file on every utterance. The WAV is still written to *output_path* + rather than captured from stdout: ffmpeg cannot seek on a pipe, so a + piped WAV carries placeholder (0xFFFFFFFF) RIFF/data sizes that make + strict readers misreport the length. + """ + from hermes_cli._subprocess_compat import windows_hide_flags + + subprocess.run( + [ + "ffmpeg", "-y", "-loglevel", "error", + "-f", "s16le", + "-ar", str(src_rate), + "-ac", str(src_channels), + "-i", "pipe:0", + "-ar", "16000", + "-ac", "1", + output_path, + ], + input=pcm_data, + check=True, + timeout=10, + creationflags=windows_hide_flags(), + ) def _read_dm_role_auth_guild() -> Optional[int]: diff --git a/tests/gateway/test_voice_command.py b/tests/gateway/test_voice_command.py index 539648886daf..ba99c837ba4b 100644 --- a/tests/gateway/test_voice_command.py +++ b/tests/gateway/test_voice_command.py @@ -2974,3 +2974,54 @@ def test_per_chat_isolation(self): fn, adapter = self._make_adapter(default=False, enabled={"chat1"}) assert fn(adapter, "chat1") is True assert fn(adapter, "chat2") is False + + +class TestPcmToWav: + """pcm_to_wav streams PCM through ffmpeg's stdin, not a temp file.""" + + def test_pcm_is_piped_to_stdin_not_staged_on_disk(self, tmp_path): + from plugins.platforms.discord.adapter import VoiceReceiver + + out = tmp_path / "out.wav" + with patch("plugins.platforms.discord.adapter.subprocess.run") as run: + VoiceReceiver.pcm_to_wav(b"\x00\x01" * 16, str(out)) + + args, kwargs = run.call_args + cmd = args[0] + assert kwargs["input"] == b"\x00\x01" * 16, "PCM must be fed via stdin" + assert "pipe:0" in cmd, "ffmpeg must read the PCM from stdin" + assert cmd[-1] == str(out), ( + "the WAV must be written to the real path; ffmpeg cannot seek on a " + "pipe, so a piped WAV gets placeholder RIFF/data sizes" + ) + assert not any(str(a).endswith(".pcm") for a in cmd), ( + "no temp .pcm file should be staged" + ) + + @pytest.mark.skipif( + __import__("shutil").which("ffmpeg") is None, reason="ffmpeg not installed", + ) + def test_output_wav_header_reports_true_length(self, tmp_path): + """A piped-stdout WAV reports 0xFFFFFFFF sizes; the written file must not.""" + import math + import struct + import wave + + from plugins.platforms.discord.adapter import VoiceReceiver + + frames = 48000 # 1s @ 48kHz stereo + pcm = b"".join( + struct.pack(" 16kHz is a 3x decimation of a 1s clip. + assert w.getnframes() == 16000