Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 42 additions & 28 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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)
Expand Down
51 changes: 51 additions & 0 deletions tests/gateway/test_voice_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<hh", v, v)
for v in (
int(20000 * math.sin(2 * math.pi * 440 * i / 48000))
for i in range(frames)
)
)
out = tmp_path / "out.wav"
VoiceReceiver.pcm_to_wav(pcm, str(out))

with wave.open(str(out)) as w:
assert w.getnchannels() == 1
assert w.getframerate() == 16000
# 48kHz -> 16kHz is a 3x decimation of a 1s clip.
assert w.getnframes() == 16000
Loading