diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 88d87bc8b35c..99e0f9a2f57b 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -838,34 +838,37 @@ def flush_pending(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 + """Convert raw PCM to 16kHz mono WAV via ffmpeg. - subprocess.run( - [ - resolve_ffmpeg_executable(), "-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 + 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( + [ + resolve_ffmpeg_executable(), "-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, + # Capture ffmpeg's -loglevel error output so a failure's + # CalledProcessError carries the actual message (parity with + # tools/transcription_tools' ffmpeg call sites) instead of + # "returned non-zero exit status N" with stderr detached. + stderr=subprocess.PIPE, + creationflags=windows_hide_flags(), + ) def _read_dm_role_auth_guild() -> Optional[int]: @@ -4481,7 +4484,18 @@ async def _process_voice_input(self, guild_id: int, user_id: int, pcm_data: byte transcript=transcript, ) except Exception as e: - logger.warning("Voice input processing failed: %s", e, exc_info=True) + # CalledProcessError from pcm_to_wav carries ffmpeg's captured + # stderr — surface it, or the log only says "exit status N". + _ff_err = getattr(e, "stderr", None) + if _ff_err: + if isinstance(_ff_err, bytes): + _ff_err = _ff_err.decode("utf-8", "replace") + logger.warning( + "Voice input processing failed: %s (ffmpeg: %s)", + e, _ff_err.strip(), exc_info=True, + ) + else: + logger.warning("Voice input processing failed: %s", e, exc_info=True) finally: try: os.unlink(wav_path) diff --git a/tests/gateway/test_voice_command.py b/tests/gateway/test_voice_command.py index b2d4ab6832a8..4a988a6e036a 100644 --- a/tests/gateway/test_voice_command.py +++ b/tests/gateway/test_voice_command.py @@ -1947,3 +1947,54 @@ def _fake_play(path): ) # And the temp file is cleaned up afterwards. assert not os.path.exists(played[0]), "temp WAV was not unlinked" + + +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