Add Hermes ESP32 voice gateway implementation
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
# Hermes ESP32 Voice Gateway
|
||||
|
||||
This folder captures the Hermes API-server implementation used by the ESP32 screen voice demo.
|
||||
|
||||
It is intentionally kept next to the screen firmware because the ESP32 client in `demo_hermes_voice.py` talks to this gateway route:
|
||||
|
||||
`POST /api/esp32/voice`
|
||||
|
||||
## Flow
|
||||
|
||||
1. ESP32 records push-to-talk audio as 16 kHz mono 16-bit PCM WAV.
|
||||
2. ESP32 posts the raw WAV bytes to Hermes with an Authorization header containing the configured API server token.
|
||||
3. Hermes saves the upload to its audio cache and transcribes it with the configured Hermes STT provider.
|
||||
4. Hermes sends the transcript through the normal agent session using a persistent conversation key: `esp32:<device_id>`.
|
||||
5. Hermes turns the final answer into speech with the configured Hermes TTS provider.
|
||||
6. Hermes normalizes the TTS output into an ESP32-friendly WAV: mono, 16 kHz, signed 16-bit little-endian PCM.
|
||||
7. Hermes returns the WAV bytes to the ESP32 for playback.
|
||||
|
||||
## Files
|
||||
|
||||
- `api_server_endpoint.py` — the route/mixin implementation for Hermes' `gateway/platforms/api_server.py`.
|
||||
- `smoke_test.py` — local helper tests for MIME mapping, safe headers, WAV normalization, and fallback ACK audio generation.
|
||||
|
||||
## Client request contract
|
||||
|
||||
Required:
|
||||
|
||||
- Method: `POST`
|
||||
- Path: `/api/esp32/voice`
|
||||
- Header: Authorization containing the configured API server token
|
||||
- Header: `Content-Type: audio/wav`
|
||||
- Header: `X-Device-ID: kitchen-button` or another stable device id
|
||||
- Body: raw audio bytes, preferably 16 kHz mono 16-bit PCM WAV
|
||||
|
||||
Optional headers:
|
||||
|
||||
- `X-Hermes-Instructions` — extra system instructions for this turn.
|
||||
- `X-Hermes-Reply-Mode: ack` — return a quick ACK WAV immediately, then finish the agent turn in the background.
|
||||
- `X-Hermes-Screen-Url` — JSON-RPC MCP endpoint for the initiating screen, e.g. `http://192.168.68.123/api/mcp`.
|
||||
- `X-Hermes-Screen-Device` — local device registry alias to resolve to a screen URL.
|
||||
|
||||
Sync-mode response:
|
||||
|
||||
- `Content-Type: audio/wav`
|
||||
- WAV body playable by the ESP32
|
||||
- Diagnostic headers:
|
||||
- `X-Hermes-Transcript`
|
||||
- `X-Hermes-Text-Response`
|
||||
- `X-Hermes-Response-Id`
|
||||
- `X-Hermes-Conversation`
|
||||
- `X-Hermes-TTS-Source-Type`
|
||||
|
||||
ACK-mode response:
|
||||
|
||||
- `Content-Type: audio/wav`
|
||||
- short acknowledgement WAV immediately
|
||||
- the full answer is displayed/spoken on the initiating MCP screen when a screen URL can be resolved
|
||||
|
||||
## Hermes integration notes
|
||||
|
||||
In Hermes Agent, wire this into `gateway/platforms/api_server.py` by adding:
|
||||
|
||||
```python
|
||||
self._app.router.add_post("/api/esp32/voice", self._handle_esp32_voice)
|
||||
```
|
||||
|
||||
after the API server app is created.
|
||||
|
||||
The mixin expects the API server class to already provide these Hermes internals:
|
||||
|
||||
- `_check_auth(request)`
|
||||
- `_run_agent(...)`
|
||||
- `_build_response_conversation_history(...)`
|
||||
- `_response_messages_turn_start_index(...)`
|
||||
- `_extract_output_items(...)`
|
||||
- `_response_store`
|
||||
- `_model_name`
|
||||
- `_background_tasks`
|
||||
|
||||
Do not commit API keys or device tokens here. The ESP32 should use the live `API_SERVER_KEY` configured on the Hermes host.
|
||||
|
||||
## Smoke test
|
||||
|
||||
From this repo:
|
||||
|
||||
```bash
|
||||
python3 hermes_voice_gateway/smoke_test.py
|
||||
```
|
||||
|
||||
If `ffmpeg` is installed, the smoke test also verifies WAV normalization through the same decode/rewrite path used for TTS output.
|
||||
@@ -0,0 +1,658 @@
|
||||
# pyright: reportMissingImports=false, reportAttributeAccessIssue=false, reportArgumentType=false
|
||||
"""Hermes API-server ESP32 voice gateway implementation.
|
||||
|
||||
This module is a self-contained reference implementation for the Hermes
|
||||
`POST /api/esp32/voice` route used by the ESP32 screen voice demo.
|
||||
|
||||
It is written as a mixin so the implementation can live beside this screen
|
||||
repo while still documenting the exact methods that belong on Hermes'
|
||||
`APIServerPlatform` class. The host class must provide the normal Hermes API
|
||||
server internals listed in README.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try: # aiohttp is present in Hermes gateway runtime.
|
||||
from aiohttp import web
|
||||
except Exception: # pragma: no cover - allows helper smoke tests without aiohttp.
|
||||
web = None # type: ignore[assignment]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ESP32_AUDIO_MAX_BYTES = 8_000_000 # Enough for push-to-talk WAV tests.
|
||||
|
||||
ESP32_AUDIO_EXTENSIONS = {
|
||||
"audio/wav": ".wav",
|
||||
"audio/wave": ".wav",
|
||||
"audio/x-wav": ".wav",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/mp3": ".mp3",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/opus": ".ogg",
|
||||
"audio/webm": ".webm",
|
||||
"audio/mp4": ".m4a",
|
||||
"audio/x-m4a": ".m4a",
|
||||
"application/octet-stream": ".wav",
|
||||
}
|
||||
|
||||
AUDIO_RESPONSE_MIME_BY_SUFFIX = {
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".opus": "audio/ogg",
|
||||
".m4a": "audio/mp4",
|
||||
".mp4": "audio/mp4",
|
||||
".flac": "audio/flac",
|
||||
}
|
||||
|
||||
|
||||
def openai_error(message: str, *, code: str = "esp32_voice_error") -> Dict[str, Any]:
|
||||
"""Small OpenAI-shaped error body for this endpoint."""
|
||||
return {"error": {"message": message, "type": "invalid_request_error", "code": code}}
|
||||
|
||||
|
||||
def audio_extension_for_content_type(content_type: str) -> str:
|
||||
"""Map inbound audio Content-Type to a safe cache-file suffix."""
|
||||
media_type = (content_type or "").split(";", 1)[0].strip().lower()
|
||||
return ESP32_AUDIO_EXTENSIONS.get(media_type, ".wav")
|
||||
|
||||
|
||||
def audio_response_mime(path: str) -> str:
|
||||
"""Return an HTTP Content-Type for a generated audio file path."""
|
||||
suffix = Path(path).suffix.lower()
|
||||
return AUDIO_RESPONSE_MIME_BY_SUFFIX.get(suffix, "application/octet-stream")
|
||||
|
||||
|
||||
def safe_audio_header(value: Any, max_length: int) -> str:
|
||||
"""Make diagnostic text safe for HTTP response headers."""
|
||||
text = str(value or "")[:max_length]
|
||||
return text.replace("\r", " ").replace("\n", " ").replace("\x00", " ")
|
||||
|
||||
|
||||
def convert_audio_to_esp32_wav(input_path: str, *, output_dir: Path, device_id: str) -> Path:
|
||||
"""Normalize generated TTS audio to ESP32-friendly WAV.
|
||||
|
||||
Hermes TTS providers commonly emit MP3 or OGG. The ESP32 client expects a
|
||||
simple RIFF/WAVE file, so decode with ffmpeg to raw PCM and rewrite the WAV
|
||||
container with Python's `wave` module. The result is mono, 16 kHz,
|
||||
signed 16-bit little-endian PCM with a minimal `fmt` + `data` layout.
|
||||
"""
|
||||
safe_device = re.sub(r"[^A-Za-z0-9_.-]+", "_", device_id)[:80] or "default"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
raw_path = output_dir / f"{safe_device}_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}.s16le"
|
||||
wav_path = raw_path.with_suffix(".wav")
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
input_path,
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-f",
|
||||
"s16le",
|
||||
str(raw_path),
|
||||
]
|
||||
try:
|
||||
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
raw = raw_path.read_bytes()
|
||||
with wave.open(str(wav_path), "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(16000)
|
||||
wav.writeframes(raw)
|
||||
return wav_path
|
||||
finally:
|
||||
raw_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def make_ack_wav(path: Path, *, frequency_hz: int = 880, duration_s: float = 0.18) -> Path:
|
||||
"""Create a tiny valid mono 16 kHz WAV tone used as last-resort ACK audio."""
|
||||
sample_rate = 16000
|
||||
frames = bytearray()
|
||||
for i in range(int(sample_rate * duration_s)):
|
||||
value = int(9000 * math.sin(2 * math.pi * frequency_hz * (i / sample_rate)))
|
||||
frames.extend(value.to_bytes(2, "little", signed=True))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with wave.open(str(path), "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(sample_rate)
|
||||
wav.writeframes(bytes(frames))
|
||||
return path
|
||||
|
||||
|
||||
class HermesESP32VoiceGatewayMixin:
|
||||
"""Mixin for Hermes' API-server adapter.
|
||||
|
||||
The consuming class is expected to provide the Hermes-specific internals:
|
||||
auth, agent execution, Responses API storage, and background task tracking.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _audio_extension_for_content_type(content_type: str) -> str:
|
||||
return audio_extension_for_content_type(content_type)
|
||||
|
||||
@staticmethod
|
||||
def _audio_response_mime(path: str) -> str:
|
||||
return audio_response_mime(path)
|
||||
|
||||
@staticmethod
|
||||
def _safe_audio_header(value: Any, max_length: int) -> str:
|
||||
return safe_audio_header(value, max_length)
|
||||
|
||||
@staticmethod
|
||||
def _convert_audio_to_esp32_wav(input_path: str, *, output_dir: Path, device_id: str) -> Path:
|
||||
return convert_audio_to_esp32_wav(input_path, output_dir=output_dir, device_id=device_id)
|
||||
|
||||
@staticmethod
|
||||
def _esp32_reply_mode(request: Any) -> str:
|
||||
value = (
|
||||
request.headers.get("X-Hermes-Reply-Mode")
|
||||
or request.query.get("reply_mode")
|
||||
or request.query.get("mode")
|
||||
or "sync"
|
||||
)
|
||||
value = str(value).strip().lower()
|
||||
if value in {"ack", "async", "background", "quick_ack", "quick-ack"}:
|
||||
return "ack"
|
||||
return "sync"
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_http_url(value: str) -> bool:
|
||||
try:
|
||||
parsed = urlparse(value)
|
||||
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _load_local_device_url(device_name: str) -> Optional[str]:
|
||||
"""Best-effort local MCP screen lookup from ~/.hermes/local_devices.yaml."""
|
||||
if not device_name:
|
||||
return None
|
||||
try:
|
||||
import yaml
|
||||
|
||||
registry = Path.home() / ".hermes" / "local_devices.yaml"
|
||||
data = yaml.safe_load(registry.read_text()) if registry.exists() else {}
|
||||
aliases = data.get("aliases") if isinstance(data, dict) else None
|
||||
devices = data.get("devices") if isinstance(data, dict) else None
|
||||
names = [str(device_name).strip(), str(device_name).strip().replace("-", "_")]
|
||||
seen: set[str] = set()
|
||||
while names:
|
||||
name = names.pop(0)
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
if isinstance(aliases, dict):
|
||||
alias = aliases.get(name)
|
||||
if isinstance(alias, str) and alias:
|
||||
if alias.startswith("http"):
|
||||
return alias
|
||||
names.append(alias)
|
||||
info = devices.get(name) if isinstance(devices, dict) else None
|
||||
if isinstance(info, dict):
|
||||
return str(info.get("fallback_url") or info.get("url") or "") or None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _resolve_esp32_screen_url(self, request: Any, device_id: str) -> Optional[str]:
|
||||
explicit = (
|
||||
request.headers.get("X-Hermes-Screen-Url")
|
||||
or request.headers.get("X-Screen-MCP-Url")
|
||||
or request.query.get("screen_url")
|
||||
)
|
||||
if explicit and self._looks_like_http_url(explicit.strip()):
|
||||
return explicit.strip()
|
||||
screen_device = (
|
||||
request.headers.get("X-Hermes-Screen-Device")
|
||||
or request.query.get("screen_device")
|
||||
or device_id
|
||||
)
|
||||
for candidate in [screen_device, "esp32_screen"]:
|
||||
url = self._load_local_device_url(str(candidate).strip())
|
||||
if url and self._looks_like_http_url(url):
|
||||
return url
|
||||
return None
|
||||
|
||||
async def _call_screen_mcp(self, screen_url: Optional[str], tool_name: str, arguments: Dict[str, Any]) -> bool:
|
||||
"""Call a simple HTTP JSON-RPC MCP screen tool directly."""
|
||||
if not screen_url:
|
||||
return False
|
||||
try:
|
||||
from aiohttp import ClientSession
|
||||
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": uuid.uuid4().hex[:12],
|
||||
"method": "tools/call",
|
||||
"params": {"name": tool_name, "arguments": arguments},
|
||||
}
|
||||
async with ClientSession() as session:
|
||||
async with session.post(screen_url, json=payload, timeout=8) as resp:
|
||||
if resp.status >= 400:
|
||||
logger.warning("screen MCP %s failed: HTTP %s", tool_name, resp.status)
|
||||
return False
|
||||
body = await resp.text()
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
if isinstance(parsed, dict) and parsed.get("error"):
|
||||
logger.warning("screen MCP %s JSON-RPC error: %s", tool_name, parsed["error"])
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("screen MCP %s failed: %s", tool_name, exc)
|
||||
return False
|
||||
|
||||
async def _send_screen_text(self, screen_url: Optional[str], text: str, *, clear: bool = False) -> None:
|
||||
if not screen_url:
|
||||
return
|
||||
if clear:
|
||||
await self._call_screen_mcp(screen_url, "clear_screen", {"color": 0})
|
||||
await self._call_screen_mcp(screen_url, "draw_text", {"x": 8, "y": 8, "text": text[:900]})
|
||||
|
||||
async def _send_screen_audio_response(
|
||||
self,
|
||||
screen_url: Optional[str],
|
||||
text: str,
|
||||
*,
|
||||
audio_dir: Path,
|
||||
device_id: str,
|
||||
volume: int = 80,
|
||||
) -> bool:
|
||||
"""Best-effort spoken follow-up for ACK-mode ESP32 voice turns."""
|
||||
if not screen_url or not text.strip():
|
||||
return False
|
||||
try:
|
||||
import base64
|
||||
from tools.tts_tool import text_to_speech_tool
|
||||
|
||||
tts_raw = await asyncio.to_thread(text_to_speech_tool, text)
|
||||
tts_result = json.loads(tts_raw)
|
||||
src = str(tts_result.get("file_path") or "")
|
||||
if not tts_result.get("success") or not src or not Path(src).exists():
|
||||
logger.warning("screen audio TTS failed: %s", tts_result.get("error") or "missing TTS output")
|
||||
return False
|
||||
wav_path = await asyncio.to_thread(
|
||||
self._convert_audio_to_esp32_wav,
|
||||
src,
|
||||
output_dir=audio_dir,
|
||||
device_id=f"{device_id}_response",
|
||||
)
|
||||
wav_base64 = base64.b64encode(wav_path.read_bytes()).decode("ascii")
|
||||
return await self._call_screen_mcp(
|
||||
screen_url,
|
||||
"play_audio_base64",
|
||||
{"wav_base64": wav_base64, "volume": volume},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("failed to play ESP32 screen audio response")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _short_voice_summary(text: str, limit: int = 220) -> str:
|
||||
clean = " ".join(str(text or "").split())
|
||||
if len(clean) <= limit:
|
||||
return clean
|
||||
return clean[: max(0, limit - 1)].rstrip() + "…"
|
||||
|
||||
async def _esp32_ack_wav(self, *, audio_dir: Path, device_id: str) -> Path:
|
||||
"""Return a reusable quick acknowledgement WAV for low-latency voice UX."""
|
||||
ack_path = audio_dir / "quick_ack.wav"
|
||||
if ack_path.exists():
|
||||
return ack_path
|
||||
try:
|
||||
from tools.tts_tool import text_to_speech_tool
|
||||
|
||||
tts_raw = await asyncio.to_thread(text_to_speech_tool, "Got it. I’m working on that.")
|
||||
tts_result = json.loads(tts_raw)
|
||||
src = str(tts_result.get("file_path") or "")
|
||||
if tts_result.get("success") and src and Path(src).exists():
|
||||
converted = self._convert_audio_to_esp32_wav(src, output_dir=audio_dir, device_id=f"{device_id}_ack")
|
||||
converted.replace(ack_path)
|
||||
return ack_path
|
||||
except Exception:
|
||||
logger.exception("failed to generate spoken ESP32 ack; falling back to tone")
|
||||
return make_ack_wav(ack_path)
|
||||
|
||||
def _append_esp32_voice_audit(self, audio_dir: Path, event: Dict[str, Any]) -> None:
|
||||
try:
|
||||
log_path = audio_dir / "voice_requests.jsonl"
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, default=str) + "\n")
|
||||
except Exception:
|
||||
logger.debug("failed to append ESP32 voice audit log", exc_info=True)
|
||||
|
||||
async def _run_esp32_voice_background(
|
||||
self,
|
||||
*,
|
||||
transcript: str,
|
||||
device_id: str,
|
||||
instructions: Optional[str],
|
||||
audio_dir: Path,
|
||||
screen_url: Optional[str],
|
||||
) -> None:
|
||||
"""Run the slow ESP32 voice turn after the device already heard an ACK."""
|
||||
try:
|
||||
await self._send_screen_text(
|
||||
screen_url,
|
||||
f"Heard: {self._short_voice_summary(transcript, 180)}\n\nWorking…",
|
||||
clear=True,
|
||||
)
|
||||
screen_hint = (
|
||||
"\n\nThis request came from an ESP32 voice/screen device. "
|
||||
"Keep the spoken follow-up short. The gateway will display the full answer on the initiating screen when possible."
|
||||
)
|
||||
merged_instructions = (instructions or "") + screen_hint
|
||||
response_data, final_text = await self._run_esp32_voice_turn(
|
||||
transcript=transcript,
|
||||
device_id=device_id,
|
||||
instructions=merged_instructions,
|
||||
)
|
||||
if not final_text.strip():
|
||||
final_text = self._extract_response_text(response_data) or "I finished, but I do not have a text answer."
|
||||
display_text = final_text
|
||||
if len(display_text) > 1200:
|
||||
display_text = self._short_voice_summary(display_text, 900) + "\n\nFull answer is long; WhatsApp delivery is the next wiring step."
|
||||
await self._send_screen_text(screen_url, display_text, clear=True)
|
||||
await self._send_screen_audio_response(screen_url, final_text, audio_dir=audio_dir, device_id=device_id)
|
||||
except Exception:
|
||||
logger.exception("ESP32 background voice turn failed")
|
||||
await self._send_screen_text(screen_url, "Hermes hit an error while preparing the full answer.", clear=True)
|
||||
|
||||
@staticmethod
|
||||
def _extract_response_text(response_data: Dict[str, Any]) -> str:
|
||||
chunks: List[str] = []
|
||||
for item in response_data.get("output") or []:
|
||||
if not isinstance(item, dict) or item.get("type") != "message":
|
||||
continue
|
||||
for part in item.get("content") or []:
|
||||
if isinstance(part, dict) and part.get("type") in {"output_text", "text"}:
|
||||
text = part.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
chunks.append(text)
|
||||
return "\n".join(chunks).strip()
|
||||
|
||||
async def _run_esp32_voice_turn(
|
||||
self,
|
||||
*,
|
||||
transcript: str,
|
||||
device_id: str,
|
||||
instructions: Optional[str] = None,
|
||||
) -> Tuple[Dict[str, Any], str]:
|
||||
"""Run one persistent Responses-style turn for an ESP32 voice device."""
|
||||
conversation = f"esp32:{device_id}"
|
||||
previous_response_id = self._response_store.get_conversation(conversation)
|
||||
conversation_history: List[Dict[str, Any]] = []
|
||||
stored_session_id = None
|
||||
stored_instructions = None
|
||||
|
||||
if previous_response_id:
|
||||
stored = self._response_store.get(previous_response_id)
|
||||
if stored is not None:
|
||||
conversation_history = list(stored.get("conversation_history", []))
|
||||
stored_session_id = stored.get("session_id")
|
||||
stored_instructions = stored.get("instructions")
|
||||
|
||||
if instructions is None:
|
||||
instructions = stored_instructions
|
||||
|
||||
session_id = stored_session_id or str(uuid.uuid4())
|
||||
user_message = f'[Voice input from ESP32 device "{device_id}"]\n{transcript}'
|
||||
|
||||
result, usage = await self._run_agent(
|
||||
user_message=user_message,
|
||||
conversation_history=conversation_history,
|
||||
ephemeral_system_prompt=instructions,
|
||||
session_id=session_id,
|
||||
gateway_session_key=conversation,
|
||||
)
|
||||
|
||||
final_response = result.get("final_response", "") or result.get("error", "") or "(No response generated)"
|
||||
response_id = f"resp_{uuid.uuid4().hex[:28]}"
|
||||
created_at = int(time.time())
|
||||
full_history = self._build_response_conversation_history(conversation_history, user_message, result, final_response)
|
||||
output_start_index = self._response_messages_turn_start_index(conversation_history, user_message, result)
|
||||
output_items = self._extract_output_items(result, start_index=output_start_index)
|
||||
response_data = {
|
||||
"id": response_id,
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"created_at": created_at,
|
||||
"model": self._model_name,
|
||||
"output": output_items,
|
||||
"usage": {
|
||||
"input_tokens": usage.get("input_tokens", 0),
|
||||
"output_tokens": usage.get("output_tokens", 0),
|
||||
"total_tokens": usage.get("total_tokens", 0),
|
||||
},
|
||||
}
|
||||
self._response_store.put(response_id, {
|
||||
"response": response_data,
|
||||
"conversation_history": full_history,
|
||||
"instructions": instructions,
|
||||
"session_id": session_id,
|
||||
})
|
||||
self._response_store.set_conversation(conversation, response_id)
|
||||
return response_data, final_response
|
||||
|
||||
async def _handle_esp32_voice(self, request: Any) -> Any:
|
||||
"""POST /api/esp32/voice — receive audio, run Hermes, return TTS audio."""
|
||||
if web is None:
|
||||
raise RuntimeError("aiohttp is required for the ESP32 voice endpoint")
|
||||
|
||||
auth_err = self._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
|
||||
device_id = (request.headers.get("X-Device-ID") or request.query.get("device_id") or "default").strip()[:80] or "default"
|
||||
try:
|
||||
audio_bytes = await request.read()
|
||||
except Exception as exc:
|
||||
return web.json_response(openai_error(f"Failed to read audio body: {exc}"), status=400)
|
||||
|
||||
if not audio_bytes:
|
||||
return web.json_response(openai_error("Empty audio body", code="empty_audio"), status=400)
|
||||
if len(audio_bytes) > ESP32_AUDIO_MAX_BYTES:
|
||||
return web.json_response(
|
||||
openai_error(f"Audio body is too large ({len(audio_bytes)} bytes); max is {ESP32_AUDIO_MAX_BYTES}", code="audio_too_large"),
|
||||
status=413,
|
||||
)
|
||||
|
||||
suffix = self._audio_extension_for_content_type(request.headers.get("Content-Type", ""))
|
||||
try:
|
||||
from hermes_constants import get_hermes_dir
|
||||
|
||||
audio_dir = get_hermes_dir("cache/audio", "audio_cache") / "esp32"
|
||||
except Exception:
|
||||
audio_dir = Path.home() / ".hermes" / "cache" / "audio" / "esp32"
|
||||
audio_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe_device = re.sub(r"[^A-Za-z0-9_.-]+", "_", device_id)[:80] or "default"
|
||||
audio_path = audio_dir / f"{safe_device}_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}{suffix}"
|
||||
audio_path.write_bytes(audio_bytes)
|
||||
|
||||
audit_event: Dict[str, Any] = {
|
||||
"timestamp": time.time(),
|
||||
"device_id": device_id,
|
||||
"source_ip": getattr(request, "remote", "") or "",
|
||||
"forwarded_for": request.headers.get("X-Forwarded-For", ""),
|
||||
"real_ip": request.headers.get("X-Real-IP", ""),
|
||||
"user_agent": request.headers.get("User-Agent", ""),
|
||||
"reply_mode": self._esp32_reply_mode(request),
|
||||
"screen_device": request.headers.get("X-Hermes-Screen-Device") or request.query.get("screen_device") or "",
|
||||
"screen_url": request.headers.get("X-Hermes-Screen-Url") or request.headers.get("X-Screen-MCP-Url") or request.query.get("screen_url") or "",
|
||||
"content_type": request.headers.get("Content-Type", ""),
|
||||
"audio_bytes": len(audio_bytes),
|
||||
"audio_path": str(audio_path),
|
||||
"status": "received",
|
||||
}
|
||||
self._append_esp32_voice_audit(audio_dir, audit_event)
|
||||
|
||||
if self._esp32_reply_mode(request) == "ack":
|
||||
screen_url = self._resolve_esp32_screen_url(request, device_id)
|
||||
|
||||
async def _ack_background() -> None:
|
||||
try:
|
||||
from tools.transcription_tools import transcribe_audio
|
||||
|
||||
stt_result_bg = await asyncio.wait_for(asyncio.to_thread(transcribe_audio, str(audio_path)), timeout=45)
|
||||
if not stt_result_bg.get("success"):
|
||||
failed_event = dict(audit_event)
|
||||
failed_event.update({"status": "transcription_failed", "error": stt_result_bg.get("error") or "Transcription failed", "timestamp": time.time()})
|
||||
self._append_esp32_voice_audit(audio_dir, failed_event)
|
||||
await self._send_screen_text(screen_url, "I heard audio, but transcription failed. Try again.", clear=True)
|
||||
return
|
||||
transcript_bg = str(stt_result_bg.get("transcript") or "").strip()
|
||||
if not transcript_bg:
|
||||
empty_event = dict(audit_event)
|
||||
empty_event.update({"status": "empty_transcript", "transcript": "", "timestamp": time.time()})
|
||||
self._append_esp32_voice_audit(audio_dir, empty_event)
|
||||
await self._send_screen_text(screen_url, "I heard audio, but got no words. Try again.", clear=True)
|
||||
return
|
||||
transcript_event = dict(audit_event)
|
||||
transcript_event.update({"status": "transcribed", "transcript": transcript_bg, "timestamp": time.time()})
|
||||
self._append_esp32_voice_audit(audio_dir, transcript_event)
|
||||
await self._run_esp32_voice_background(
|
||||
transcript=transcript_bg,
|
||||
device_id=device_id,
|
||||
instructions=request.headers.get("X-Hermes-Instructions") or None,
|
||||
audio_dir=audio_dir,
|
||||
screen_url=screen_url,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
timeout_event = dict(audit_event)
|
||||
timeout_event.update({"status": "transcription_timeout", "error": "Transcription exceeded 45s", "timestamp": time.time()})
|
||||
self._append_esp32_voice_audit(audio_dir, timeout_event)
|
||||
except Exception as exc:
|
||||
logger.exception("ESP32 ack-mode background processing failed")
|
||||
failed_event = dict(audit_event)
|
||||
failed_event.update({"status": "background_exception", "error": str(exc), "timestamp": time.time()})
|
||||
self._append_esp32_voice_audit(audio_dir, failed_event)
|
||||
|
||||
task = asyncio.create_task(_ack_background())
|
||||
if hasattr(self, "_background_tasks"):
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
ack_path = await self._esp32_ack_wav(audio_dir=audio_dir, device_id=device_id)
|
||||
return web.FileResponse(
|
||||
ack_path,
|
||||
headers={
|
||||
"X-Hermes-Conversation": f"esp32:{device_id}",
|
||||
"X-Hermes-Reply-Mode": "ack",
|
||||
"X-Hermes-Screen-Url": screen_url or "",
|
||||
"Content-Type": "audio/wav",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
from tools.transcription_tools import transcribe_audio
|
||||
|
||||
stt_result = await asyncio.to_thread(transcribe_audio, str(audio_path))
|
||||
except Exception as exc:
|
||||
logger.exception("ESP32 transcription failed")
|
||||
failed_event = dict(audit_event)
|
||||
failed_event.update({"status": "transcription_exception", "error": str(exc), "timestamp": time.time()})
|
||||
self._append_esp32_voice_audit(audio_dir, failed_event)
|
||||
return web.json_response(openai_error(f"Transcription failed: {exc}"), status=500)
|
||||
|
||||
if not stt_result.get("success"):
|
||||
failed_event = dict(audit_event)
|
||||
failed_event.update({"status": "transcription_failed", "error": stt_result.get("error") or "Transcription failed", "timestamp": time.time()})
|
||||
self._append_esp32_voice_audit(audio_dir, failed_event)
|
||||
return web.json_response(openai_error(stt_result.get("error") or "Transcription failed", code="transcription_failed"), status=502)
|
||||
|
||||
transcript = str(stt_result.get("transcript") or "").strip()
|
||||
if not transcript:
|
||||
empty_event = dict(audit_event)
|
||||
empty_event.update({"status": "empty_transcript", "transcript": "", "timestamp": time.time()})
|
||||
self._append_esp32_voice_audit(audio_dir, empty_event)
|
||||
return web.json_response(openai_error("Transcription was empty", code="empty_transcript"), status=422)
|
||||
|
||||
transcript_event = dict(audit_event)
|
||||
transcript_event.update({"status": "transcribed", "transcript": transcript, "timestamp": time.time()})
|
||||
self._append_esp32_voice_audit(audio_dir, transcript_event)
|
||||
|
||||
try:
|
||||
response_data, final_text = await self._run_esp32_voice_turn(
|
||||
transcript=transcript,
|
||||
device_id=device_id,
|
||||
instructions=request.headers.get("X-Hermes-Instructions") or None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("ESP32 agent turn failed")
|
||||
return web.json_response(openai_error(f"Agent turn failed: {exc}"), status=500)
|
||||
|
||||
if not final_text.strip():
|
||||
final_text = self._extract_response_text(response_data) or "I heard you, but I do not have a spoken answer."
|
||||
|
||||
try:
|
||||
from tools.tts_tool import text_to_speech_tool
|
||||
|
||||
tts_raw = await asyncio.to_thread(text_to_speech_tool, final_text)
|
||||
tts_result = json.loads(tts_raw)
|
||||
except Exception as exc:
|
||||
logger.exception("ESP32 TTS failed")
|
||||
return web.json_response(
|
||||
openai_error(f"TTS failed after response was generated: {exc}", code="tts_failed"),
|
||||
status=500,
|
||||
headers={"X-Hermes-Transcript": self._safe_audio_header(transcript, 1000), "X-Hermes-Text-Response": self._safe_audio_header(final_text, 2000)},
|
||||
)
|
||||
|
||||
if not tts_result.get("success"):
|
||||
return web.json_response(
|
||||
openai_error(tts_result.get("error") or "TTS failed", code="tts_failed"),
|
||||
status=502,
|
||||
headers={"X-Hermes-Transcript": self._safe_audio_header(transcript, 1000), "X-Hermes-Text-Response": self._safe_audio_header(final_text, 2000)},
|
||||
)
|
||||
|
||||
tts_path = str(tts_result.get("file_path") or "")
|
||||
if not tts_path or not Path(tts_path).exists():
|
||||
return web.json_response(openai_error("TTS succeeded but no audio file was produced", code="tts_file_missing"), status=500)
|
||||
|
||||
try:
|
||||
response_audio_path = self._convert_audio_to_esp32_wav(tts_path, output_dir=audio_dir, device_id=device_id)
|
||||
except Exception as exc:
|
||||
logger.exception("ESP32 WAV normalization failed")
|
||||
return web.json_response(
|
||||
openai_error(f"Could not convert TTS audio to ESP32 WAV: {exc}", code="wav_conversion_failed"),
|
||||
status=500,
|
||||
headers={"X-Hermes-Transcript": self._safe_audio_header(transcript, 1000), "X-Hermes-Text-Response": self._safe_audio_header(final_text, 2000)},
|
||||
)
|
||||
|
||||
return web.FileResponse(
|
||||
response_audio_path,
|
||||
headers={
|
||||
"X-Hermes-Transcript": self._safe_audio_header(transcript, 1000),
|
||||
"X-Hermes-Text-Response": self._safe_audio_header(final_text, 2000),
|
||||
"X-Hermes-Response-Id": str(response_data.get("id") or ""),
|
||||
"X-Hermes-Conversation": f"esp32:{device_id}",
|
||||
"X-Hermes-TTS-Source-Type": self._audio_response_mime(tts_path),
|
||||
"Content-Type": "audio/wav",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def register_esp32_voice_route(app: Any, api_server: HermesESP32VoiceGatewayMixin) -> None:
|
||||
"""Register the endpoint on an aiohttp app."""
|
||||
app.router.add_post("/api/esp32/voice", api_server._handle_esp32_voice)
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Smoke tests for hermes_voice_gateway helper code.
|
||||
|
||||
These tests avoid live Hermes/STT/TTS calls. They verify the pieces that are
|
||||
safe to exercise locally: content-type mapping, response MIME mapping, header
|
||||
sanitization, fallback ACK WAV generation, and ffmpeg WAV normalization when
|
||||
ffmpeg is available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
from api_server_endpoint import (
|
||||
audio_extension_for_content_type,
|
||||
audio_response_mime,
|
||||
convert_audio_to_esp32_wav,
|
||||
make_ack_wav,
|
||||
safe_audio_header,
|
||||
)
|
||||
|
||||
|
||||
def _assert_wav_16k_mono_s16(path: Path) -> None:
|
||||
with wave.open(str(path), "rb") as wav:
|
||||
assert wav.getnchannels() == 1, wav.getnchannels()
|
||||
assert wav.getsampwidth() == 2, wav.getsampwidth()
|
||||
assert wav.getframerate() == 16000, wav.getframerate()
|
||||
assert wav.getnframes() > 0, wav.getnframes()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
assert audio_extension_for_content_type("audio/wav") == ".wav"
|
||||
assert audio_extension_for_content_type("audio/wav; charset=binary") == ".wav"
|
||||
assert audio_extension_for_content_type("audio/webm") == ".webm"
|
||||
assert audio_extension_for_content_type("application/unknown") == ".wav"
|
||||
|
||||
assert audio_response_mime("reply.wav") == "audio/wav"
|
||||
assert audio_response_mime("reply.mp3") == "audio/mpeg"
|
||||
assert audio_response_mime("reply.bin") == "application/octet-stream"
|
||||
|
||||
assert safe_audio_header("hello\nworld\r\x00!", 100) == "hello world !"
|
||||
assert safe_audio_header("abcdef", 3) == "abc"
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmpdir = Path(tmp)
|
||||
ack = make_ack_wav(tmpdir / "ack.wav")
|
||||
_assert_wav_16k_mono_s16(ack)
|
||||
|
||||
if shutil.which("ffmpeg"):
|
||||
converted = convert_audio_to_esp32_wav(str(ack), output_dir=tmpdir, device_id="kitchen/button")
|
||||
_assert_wav_16k_mono_s16(converted)
|
||||
assert "kitchen_button" in converted.name
|
||||
else:
|
||||
print("ffmpeg not found; skipped conversion path")
|
||||
|
||||
print("hermes_voice_gateway smoke tests passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user