feat: add Hermes WebUI provisioning and English Dragon HD Omni voices
Rebuild & Deploy / rebuild (push) Successful in 2m17s
Rebuild & Deploy / rebuild (push) Successful in 2m17s
This commit is contained in:
1 parent
47cc9c88a0
commit
6b8d55fc92
59 files changed
+1175
-70
No files matched your search
+68
-19
@@ -8,20 +8,11 @@ const { Room, RoomEvent } = LivekitClient;
|
||||
const LIVEKIT_URL = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/livekit`;
|
||||
const AGENT_NAME = "voice-assistant";
|
||||
|
||||
const VOICES = [
|
||||
{ id: "en-US-Ava:DragonHDOmniLatestNeural", name: "Ava", tag: "Dragon HD Omni" },
|
||||
{ id: "en-US-JennyNeural", name: "Jenny" },
|
||||
{ id: "en-US-GuyNeural", name: "Guy" },
|
||||
{ id: "en-US-AndrewNeural", name: "Andrew" },
|
||||
{ id: "en-US-AriaNeural", name: "Aria" },
|
||||
{ id: "en-US-EmmaNeural", name: "Emma" },
|
||||
{ id: "en-US-EricNeural", name: "Eric" },
|
||||
{ id: "en-US-BrianNeural", name: "Brian" },
|
||||
{ id: "en-US-AshleyNeural", name: "Ashley" },
|
||||
{ id: "en-US-RichardNeural", name: "Richard" },
|
||||
{ id: "en-US-TinaNeural", name: "Tina" },
|
||||
{ id: "en-US-SteffanNeural", name: "Steffan" },
|
||||
];
|
||||
const OMNI_DEFAULT = "en-US-Ava:DragonHDOmniLatestNeural";
|
||||
let defaultVoice = OMNI_DEFAULT;
|
||||
let VOICES = [{ id: OMNI_DEFAULT, name: "Ava", locale: "en-US", gender: "Female", tag: "Dragon HD Omni" }];
|
||||
let voiceCatalogRequest = null;
|
||||
let voiceCatalogGeneration = 0;
|
||||
|
||||
let historyKey = null;
|
||||
const HISTORY_MAX = 50;
|
||||
@@ -574,6 +565,7 @@ function handleDataPacket(payload, participant, topic) {
|
||||
if (!HopeAuth.user || !participant || !isAgent(participant)) return;
|
||||
if (msg.type === "agent_ready" && topic === "conversation-control") {
|
||||
agentReady = true;
|
||||
sendVoiceMessage(currentVoiceId());
|
||||
} else if (msg.type === "transcript" && topic === "transcript") {
|
||||
if (!["agent", "user"].includes(msg.role) || typeof msg.text !== "string") return;
|
||||
addMessage(msg.role, msg.text);
|
||||
@@ -745,21 +737,71 @@ modelSettingsForm.addEventListener("submit", async event => {
|
||||
|
||||
// ── Voice settings sheet ────────────────────────────────────────────────────
|
||||
function currentVoiceId() {
|
||||
const saved = localStorage.getItem("preferred_voice");
|
||||
return VOICES.some((v) => v.id === saved) ? saved : VOICES[0].id;
|
||||
let saved = localStorage.getItem("preferred_voice");
|
||||
// Migrate older Dragon HD selections to the equivalent Omni persona.
|
||||
if (saved?.endsWith(":DragonHDLatestNeural")) {
|
||||
saved = saved.replace(":DragonHDLatestNeural", ":DragonHDOmniLatestNeural");
|
||||
localStorage.setItem("preferred_voice", saved);
|
||||
}
|
||||
return VOICES.some((v) => v.id === saved) ? saved : defaultVoice;
|
||||
}
|
||||
|
||||
const voiceSearchEl = document.getElementById("voiceSearch");
|
||||
const voiceStatusEl = document.getElementById("voiceCatalogStatus");
|
||||
const retryVoicesBtn = document.getElementById("retryVoices");
|
||||
voiceSearchEl.addEventListener("input", renderVoiceList);
|
||||
retryVoicesBtn.addEventListener("click", loadVoiceCatalog);
|
||||
|
||||
async function loadVoiceCatalog() {
|
||||
if (!HopeAuth.user) return;
|
||||
if (voiceCatalogRequest) return voiceCatalogRequest;
|
||||
const generation = voiceCatalogGeneration;
|
||||
voiceStatusEl.textContent = "Loading English voices from Azure…";
|
||||
retryVoicesBtn.hidden = true;
|
||||
voiceCatalogRequest = (async () => {
|
||||
try {
|
||||
const data = await HopeAuth.request("/voices");
|
||||
if (generation !== voiceCatalogGeneration) return;
|
||||
const voices = Array.isArray(data.voices) ? data.voices.filter(v =>
|
||||
v && typeof v.id === "string" && /^[A-Za-z0-9:_-]{1,120}$/.test(v.id) && typeof v.name === "string") : [];
|
||||
if (!voices.length) throw new Error("Empty voice catalog");
|
||||
VOICES = voices;
|
||||
defaultVoice = VOICES.some(v => v.id === data.default_voice) ? data.default_voice : OMNI_DEFAULT;
|
||||
voiceStatusEl.textContent = data.catalog_available
|
||||
? `${VOICES.length} English voices · Dragon HD Omni first. Ava Omni is the recommended female default.`
|
||||
: (data.message || "Azure’s full catalog is unavailable. Ava Omni remains available; retry to load all English voices.");
|
||||
retryVoicesBtn.hidden = data.catalog_available === true;
|
||||
renderVoiceList();
|
||||
} catch (_) {
|
||||
if (generation !== voiceCatalogGeneration) return;
|
||||
voiceStatusEl.textContent = "Couldn’t load Azure’s full catalog. Your current choices remain available. Retry to load all English voices.";
|
||||
retryVoicesBtn.hidden = false;
|
||||
} finally {
|
||||
if (generation === voiceCatalogGeneration) voiceCatalogRequest = null;
|
||||
}
|
||||
})();
|
||||
return voiceCatalogRequest;
|
||||
}
|
||||
|
||||
function renderVoiceList() {
|
||||
voiceListEl.innerHTML = "";
|
||||
voiceListEl.replaceChildren();
|
||||
const active = currentVoiceId();
|
||||
for (const v of VOICES) {
|
||||
const query = voiceSearchEl.value.trim().toLowerCase();
|
||||
const visible = VOICES.filter(v => `${v.name} ${v.locale || ""} ${v.gender || ""} ${v.tag || ""}`.toLowerCase().includes(query));
|
||||
if (!visible.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "model-settings-note";
|
||||
empty.textContent = "No matching voices. Try a name, accent locale, gender, or model.";
|
||||
voiceListEl.appendChild(empty);
|
||||
}
|
||||
for (const v of visible) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "voice-card" + (v.id === active ? " active" : "");
|
||||
card.dataset.voice = v.id;
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "voice-name";
|
||||
name.textContent = v.name + (v.tag ? ` (${v.tag})` : "");
|
||||
name.textContent = `${v.name} · ${v.locale || "English"} · ${v.gender || ""}` + (v.tag ? ` (${v.tag})` : "");
|
||||
|
||||
const selected = v.id === active;
|
||||
card.setAttribute("role", "radio");
|
||||
@@ -790,6 +832,7 @@ function selectVoice(voiceId) {
|
||||
function openSheet() {
|
||||
if (!HopeAuth.user) return;
|
||||
renderVoiceList();
|
||||
loadVoiceCatalog();
|
||||
settingsSheet.hidden = false; settingsSheet.inert = false;
|
||||
document.getElementById("authenticatedApp").inert = true;
|
||||
sheetBackdrop.hidden = false;
|
||||
@@ -1130,6 +1173,12 @@ HopeAuth.onChange((user, changed) => {
|
||||
if (changed || !user || user.status !== "ready") stopConversation();
|
||||
if (changed || settingsAccount !== (user ? String(user.id) : null)) resetModelSettings(user);
|
||||
if (changed) {
|
||||
voiceCatalogGeneration++;
|
||||
voiceCatalogRequest = null;
|
||||
VOICES = [{ id: OMNI_DEFAULT, name: "Ava", locale: "en-US", gender: "Female", tag: "Dragon HD Omni" }];
|
||||
defaultVoice = OMNI_DEFAULT;
|
||||
voiceSearchEl.value = "";
|
||||
if (user) loadVoiceCatalog();
|
||||
historyKey = user ? `hope_history:${location.origin}:${encodeURIComponent(user.id)}` : null;
|
||||
history = loadHistory(); renderHistory(); progress.clear(); tasksList.replaceChildren(); tasksBadge.hidden = true;
|
||||
chatInput.value = ""; sendChatBtn.disabled = true;
|
||||
|
||||
+5
-1
@@ -172,7 +172,11 @@
|
||||
</div>
|
||||
</form>
|
||||
<h3 class="sheet-section-title">Voice</h3>
|
||||
<div class="voice-list" id="voiceList" role="radiogroup" aria-label="Voice"></div>
|
||||
<label for="voiceSearch">Search English voices</label>
|
||||
<input id="voiceSearch" type="search" placeholder="Name, locale, gender, or Omni" autocomplete="off" aria-controls="voiceList">
|
||||
<p id="voiceCatalogStatus" class="model-settings-note" role="status" aria-live="polite">Ava Dragon HD Omni is the recommended female default.</p>
|
||||
<button id="retryVoices" type="button" class="btn btn-secondary" hidden>Retry voice catalog</button>
|
||||
<div class="voice-list" id="voiceList" role="radiogroup" aria-label="English voices"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1003,6 +1003,16 @@ body[data-mode="text"] .mic-meter {
|
||||
}
|
||||
.settings-sheet .sheet-header { flex-shrink: 0; }
|
||||
.settings-sheet .voice-list { overflow-y: visible; }
|
||||
#voiceSearch {
|
||||
width: 100%; min-width: 0; min-height: 44px; margin-top: 0.4rem;
|
||||
padding: 0.65rem 0.75rem; border: 1px solid var(--line); border-radius: 10px;
|
||||
background: var(--surface-soft); color: var(--ink); font: inherit;
|
||||
}
|
||||
#voiceSearch::placeholder { color: var(--ink-soft); opacity: 1; }
|
||||
#voiceSearch:focus-visible { outline: 2px solid var(--iris); outline-offset: 2px; }
|
||||
#voiceCatalogStatus { margin: 0.6rem 0; }
|
||||
#retryVoices { margin-bottom: 0.6rem; }
|
||||
.voice-name { overflow-wrap: anywhere; }
|
||||
.model-settings {
|
||||
min-width: 0;
|
||||
padding-bottom: 1.25rem;
|
||||
|
||||
@@ -12,6 +12,11 @@ import httpx
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
try:
|
||||
from web.voice_catalog import catalog
|
||||
except ModuleNotFoundError: # Direct execution: python /app/web/token_server.py
|
||||
from voice_catalog import catalog
|
||||
|
||||
API_KEY = os.getenv("LIVEKIT_API_KEY", "")
|
||||
API_SECRET = os.getenv("LIVEKIT_API_SECRET", "")
|
||||
AGENT_NAME = os.getenv("VOICE_AGENT_NAME", "voice-assistant")
|
||||
@@ -75,6 +80,42 @@ async def token(request: Request):
|
||||
except (httpx.HTTPError, ValueError, KeyError):
|
||||
return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)
|
||||
|
||||
@app.get("/voices")
|
||||
async def voices(request: Request):
|
||||
"""Resolve the caller at the trusted control boundary before reading the cache."""
|
||||
headers = {"Cache-Control": "no-store"}
|
||||
if not CONTROL_SECRET:
|
||||
return JSONResponse({"error": "Service configuration unavailable"}, 503, headers=headers)
|
||||
auth = request.headers.get("authorization", "")
|
||||
bearer = auth[7:] if auth.startswith("Bearer ") else ""
|
||||
credential = bearer or request.cookies.get(COOKIE_NAME, "")
|
||||
if not credential or len(credential) > 512:
|
||||
return JSONResponse({"error": "Sign in required"}, 401, headers=headers)
|
||||
# Safe GET requests normally have no Origin. If supplied with cookie auth,
|
||||
# enforce the same configured origin allowlist as the bootstrap boundary.
|
||||
origin = request.headers.get("origin", "")
|
||||
if not bearer and origin:
|
||||
allowed = set(filter(None, os.getenv("CONTROL_ALLOWED_ORIGINS", "").split(",")))
|
||||
if origin not in allowed:
|
||||
return JSONResponse({"error": "Invalid request origin"}, 403, headers=headers)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.post(f"{CONTROL_URL}/internal/resolve-session",
|
||||
headers={"X-Control-Secret": CONTROL_SECRET},
|
||||
json={"token": credential})
|
||||
if response.status_code != 200:
|
||||
status = response.status_code if response.status_code in (401, 403, 503) else 503
|
||||
return JSONResponse({"error": "Sign in required" if status == 401 else
|
||||
"Your private agent is not ready"}, status, headers=headers)
|
||||
# Verify the expected control response without exposing private metadata.
|
||||
account = response.json()
|
||||
if not isinstance(account, dict) or not isinstance(account.get("user"), dict) or not account["user"].get("id"):
|
||||
return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)
|
||||
except (httpx.HTTPError, ValueError, TypeError):
|
||||
return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)
|
||||
return JSONResponse(await catalog.get(), headers=headers)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="127.0.0.1", port=8091, access_log=False)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""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", ""))
|
||||
Reference in new issue
Block a user