134 lines
6.5 KiB
Python
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", ""))
|