Files
xiaozhi-esp32-server/main/manager-api-fastapi/app/integrations/voiceprint.py
T

103 lines
3.8 KiB
Python

from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from urllib.parse import urlsplit
import httpx
class VoicePrintIntegrationError(RuntimeError):
def __init__(self, code: int, message: str | None = None, params: tuple[object, ...] = ()):
super().__init__(message or str(code))
self.code = code
self.message = message
self.params = params
@dataclass(slots=True, frozen=True)
class VoicePrintEndpoint:
base_url: str
authorization: str
@classmethod
def parse(cls, configured_url: str | None) -> VoicePrintEndpoint:
if configured_url is None:
raise VoicePrintIntegrationError(10084)
parsed = urlsplit(configured_url)
if not parsed.scheme or not parsed.hostname:
raise VoicePrintIntegrationError(10084)
marker = "key="
marker_index = parsed.query.find(marker)
key = parsed.query[marker_index + len(marker) :] if marker_index >= 0 else parsed.query[3:]
port = f":{parsed.port}" if parsed.port is not None else ""
return cls(f"{parsed.scheme}://{parsed.hostname}{port}", f"Bearer {key}")
class VoicePrintClient:
def __init__(self, configured_url: str, *, timeout: float = 10.0, client: httpx.AsyncClient | None = None):
self.endpoint = VoicePrintEndpoint.parse(configured_url)
self.timeout = timeout
self._client = client
async def _request(
self,
method: str,
path: str,
*,
data: Mapping[str, str] | None = None,
files: Mapping[str, tuple[str, bytes, str]] | None = None,
) -> httpx.Response:
headers = {"Authorization": self.endpoint.authorization}
if self._client is not None:
return await self._client.request(
method, f"{self.endpoint.base_url}{path}", headers=headers, data=data, files=files
)
async with httpx.AsyncClient(timeout=self.timeout) as client:
return await client.request(
method, f"{self.endpoint.base_url}{path}", headers=headers, data=data, files=files
)
async def identify(self, speaker_ids: list[str], audio: bytes) -> tuple[str | None, float | None] | None:
if not speaker_ids:
return None
response = await self._request(
"POST",
"/voiceprint/identify",
data={"speaker_ids": ",".join(speaker_ids)},
files={"file": ("VoicePrint.WAV", audio, "application/octet-stream")},
)
if response.status_code != 200:
raise VoicePrintIntegrationError(10091)
try:
payload = response.json()
except ValueError as exc:
raise VoicePrintIntegrationError(10091) from exc
if not isinstance(payload, dict):
return None
speaker_id = payload.get("speaker_id")
score = payload.get("score")
return (
str(speaker_id) if speaker_id is not None else None,
float(score) if isinstance(score, int | float) else None,
)
async def register(self, speaker_id: str, audio: bytes) -> None:
response = await self._request(
"POST",
"/voiceprint/register",
data={"speaker_id": speaker_id},
files={"file": ("VoicePrint.WAV", audio, "application/octet-stream")},
)
if response.status_code != 200:
raise VoicePrintIntegrationError(10087)
if "true" not in response.text:
raise VoicePrintIntegrationError(10088)
async def cancel(self, speaker_id: str) -> None:
response = await self._request("DELETE", f"/voiceprint/{speaker_id}")
if response.status_code != 200:
raise VoicePrintIntegrationError(10089)
if "true" not in response.text:
raise VoicePrintIntegrationError(10090)