Files
hope-voice-api/web/voice_catalog.py
T
2026-10-02 16:07:24 -04:00

134 lines
6.5 KiB
Python

"""Azure English voice catalog, independent of the speech agent.
Omni names follow Microsoft's persona naming rule (remove final ``Neural``
and append ``:DragonHDOmniLatestNeural``):
https://learn.microsoft.com/en-us/azure/ai-services/speech-service/high-definition-voices
Only the final Neural suffix is removed: Multilingual is retained because the
rule does not document removing it. Advertised Omni identifiers take precedence
for a matching persona. Derived variants follow the documented convention, not
an individual synthesis/region availability probe. MultiTalker composites are
excluded because the single-voice picker cannot supply their dialogue SSML.
"""
import asyncio
import copy
import os
import re
import time
import httpx
DEFAULT_VOICE = "en-US-Ava:DragonHDOmniLatestNeural"
OMNI_SUFFIX = ":DragonHDOmniLatestNeural"
CACHE_TTL = 3600
FAILURE_RETRY_TTL = 60
_REGION = re.compile(r"[a-z][a-z0-9]{0,62}\Z")
def normalize_default_voice(voice: str | None = None) -> str:
"""Read optional AZURE_TTS_VOICE and migrate a legacy DragonHD default.
Explicit regular Neural choices and already-Omni choices are preserved.
This helper has no dependency on, or side effects in, the agent.
"""
value = (os.getenv("AZURE_TTS_VOICE", "") if voice is None else voice).strip()
if not value:
return DEFAULT_VOICE
if value.endswith(":DragonHDLatestNeural"):
return value.removesuffix(":DragonHDLatestNeural") + OMNI_SUFFIX
return value
def _english(locale) -> bool:
return isinstance(locale, str) and (locale.lower() == "en" or locale.lower().startswith("en-"))
def _entry(voice_id, name, locale, gender):
tag = "Omni" if "dragonhdomni" in voice_id.lower() else (
"HD" if "dragonhd" in voice_id.lower() else (
"Neural" if voice_id.endswith("Neural") else "Standard"))
return {"id": voice_id, "name": name, "locale": locale, "gender": gender, "tag": tag}
def english_voices(payload: list) -> list[dict]:
"""Keep advertised English-capable voices and add ordinary Neural Omni variants."""
if not isinstance(payload, list):
raise ValueError("Invalid catalog")
voices = {}
for row in payload:
if not isinstance(row, dict):
continue
voice_id, locale = row.get("ShortName"), row.get("Locale")
secondary = row.get("SecondaryLocaleList", [])
if not isinstance(secondary, list):
secondary = []
if not isinstance(voice_id, str) or not voice_id or "multitalker" in voice_id.lower():
continue
if not isinstance(locale, str) or not (_english(locale) or any(_english(s) for s in secondary)):
continue
name = row.get("DisplayName")
gender = row.get("Gender")
voices[voice_id.lower()] = _entry(voice_id, name if isinstance(name, str) else voice_id,
locale, gender if isinstance(gender, str) else "Unknown")
if not voices:
raise ValueError("No English voices in catalog")
# Advertised entries win (including identifier spelling and display metadata).
advertised_personas = {v["id"].split(":", 1)[0].lower() for v in voices.values() if v["tag"] == "Omni"}
for voice in list(voices.values()):
voice_id = voice["id"]
if ":" not in voice_id and voice_id.endswith("Neural"):
persona = voice_id.removesuffix("Neural")
if persona.lower() not in advertised_personas:
omni_id = persona + OMNI_SUFFIX
voices.setdefault(omni_id.lower(), _entry(omni_id, voice["name"], voice["locale"], voice["gender"]))
voices.setdefault(DEFAULT_VOICE.lower(), _entry(DEFAULT_VOICE, "Ava", "en-US", "Female"))
return sorted(voices.values(), key=lambda v: (v["id"].lower() != DEFAULT_VOICE.lower(),
v["tag"] != "Omni", v["locale"].lower(), v["name"].lower(), v["id"].lower()))
class VoiceCatalog:
"""Process-local 1h cache; coalesces refreshes and retains last good data.
Failed refreshes are retried after 60s, not by every concurrent caller.
Failures always report catalog_available=False even when returning stale data.
No upstream exception, response body, header, or key is logged/returned.
"""
def __init__(self, region: str, speech_key: str, default_voice: str | None = None):
self.region = region
self.speech_key = speech_key
self.default_voice = normalize_default_voice(default_voice)
self._lock = asyncio.Lock()
self._expires = 0.0
self._good = None
self._result = None
async def get(self) -> dict:
if self._result is not None and time.monotonic() < self._expires:
return copy.deepcopy(self._result)
async with self._lock:
if self._result is not None and time.monotonic() < self._expires:
return copy.deepcopy(self._result)
try:
# A single DNS label, never a URL, port, userinfo or path. Redirects
# are disabled so the subscription header cannot leave Azure.
if not self.speech_key or not isinstance(self.region, str) or not _REGION.fullmatch(self.region):
raise ValueError("Invalid speech configuration")
async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
response = await client.get(
f"https://{self.region}.tts.speech.microsoft.com/cognitiveservices/voices/list",
headers={"Ocp-Apim-Subscription-Key": self.speech_key})
response.raise_for_status()
voices = english_voices(response.json())
self._good = voices
self._result = {"default_voice": self.default_voice, "voices": voices, "catalog_available": True}
self._expires = time.monotonic() + CACHE_TTL
except (httpx.HTTPError, ValueError, TypeError):
voices = self._good or [_entry(DEFAULT_VOICE, "Ava", "en-US", "Female")]
self._result = {"default_voice": self.default_voice, "voices": voices, "catalog_available": False,
"message": "Azure voice catalog unavailable. Using last known voices." if self._good else
"Azure voice catalog unavailable. Using the default voice."}
self._expires = time.monotonic() + FAILURE_RETRY_TTL
return copy.deepcopy(self._result)
catalog = VoiceCatalog(os.getenv("AZURE_SPEECH_REGION", "eastus"), os.getenv("AZURE_SPEECH_KEY", ""))