Files
mcp_screen/hermes_voice_gateway/api_server_endpoint.py
T
2026-06-18 23:02:54 -04:00

659 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. Im 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)