feat: add Hermes WebUI provisioning and English Dragon HD Omni voices
Rebuild & Deploy / rebuild (push) Successful in 2m17s

This commit is contained in:
snowspeeder committed 2026-10-02 16:07:24 -04:00
1 parent 47cc9c88a0
commit 6b8d55fc92
59 files changed
+1175 -70

No files matched your search

+68 -19
View File
@@ -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
View File
@@ -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>
+10
View File
@@ -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;
+41
View File
@@ -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)
+133
View File
@@ -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", ""))