664 lines
18 KiB
Python
664 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
RIME_DEFAULT_SPEAKER_BY_LANG: dict[str, str] = {
|
|
"ar": "sakina",
|
|
"de": "lorelei",
|
|
"en": "astra",
|
|
"es": "seraphina",
|
|
"fr": "destin",
|
|
}
|
|
|
|
RIME_ALLOWED_SPEAKERS_BY_LANG: dict[str, set[str]] = {
|
|
"ar": {"batin", "layla", "qadir", "sakina"},
|
|
"de": {"alfhild", "baldur", "kumara", "liesel", "lorelei", "runa"},
|
|
"en": {
|
|
"ahmed_mohamed",
|
|
"albion",
|
|
"andersen_johan",
|
|
"anderson_emily",
|
|
"anderson_jake",
|
|
"anderson_james",
|
|
"anderson_kevin",
|
|
"andromeda",
|
|
"arcade",
|
|
"astra",
|
|
"atrium",
|
|
"bauer_felix",
|
|
"bennett_emily",
|
|
"bennett_ryan",
|
|
"biondi_paul",
|
|
"bond",
|
|
"brooks_jordan",
|
|
"brown_alex",
|
|
"brown_joshua",
|
|
"brown_madison",
|
|
"brown_matthew",
|
|
"brown_steven",
|
|
"bruno_katie",
|
|
"carter_colin",
|
|
"celeste",
|
|
"chatterjee_rini",
|
|
"chen_david",
|
|
"chen_mei",
|
|
"clark_tyler",
|
|
"cohen_emily",
|
|
"cohen_jared",
|
|
"collins_emily",
|
|
"cooper_logan",
|
|
"cupola",
|
|
"das_sourav",
|
|
"davies_james",
|
|
"dela_cristina",
|
|
"diallo_amara",
|
|
"dubois_emma",
|
|
"duncan_colin",
|
|
"duval_pierre",
|
|
"eliphas",
|
|
"estelle",
|
|
"esther",
|
|
"eucalyptus",
|
|
"evans_jason",
|
|
"fern",
|
|
"fernandez_carlos",
|
|
"goldberg_ryan",
|
|
"gomez_daniela",
|
|
"gomez_diego",
|
|
"gomez_isabel",
|
|
"gomez_isabella",
|
|
"gomez_javon",
|
|
"gonzalez_maya",
|
|
"gonzalez_michael",
|
|
"gonzalez_ryan",
|
|
"grayson_avery",
|
|
"hanson_ryan",
|
|
"harris_luke",
|
|
"harris_lynette",
|
|
"harrison_brianna",
|
|
"harrison_joey",
|
|
"harrison_mary",
|
|
"hassan_omar",
|
|
"henderson_brittney",
|
|
"hernandez_juanita",
|
|
"holliday_jewel",
|
|
"iyer_arun",
|
|
"jensen_mikkel",
|
|
"johnny_jackson",
|
|
"johnson_angela",
|
|
"johnson_asha",
|
|
"johnson_avery",
|
|
"johnson_brianna",
|
|
"johnson_cynthia",
|
|
"johnson_elijah",
|
|
"johnson_james",
|
|
"johnson_joshua",
|
|
"johnson_latisha",
|
|
"johnson_lisa",
|
|
"johnson_madison",
|
|
"johnson_malachi",
|
|
"johnson_marcel",
|
|
"johnson_mary",
|
|
"johnson_matthew",
|
|
"johnson_melissa",
|
|
"johnson_monique",
|
|
"johnson_nia",
|
|
"johnson_tasha",
|
|
"johnson_tia",
|
|
"johnson_walter",
|
|
"kelly_aoife",
|
|
"kelly_jennifer",
|
|
"kelly_john",
|
|
"kelly_maureen",
|
|
"khan_fatima",
|
|
"khan_umar",
|
|
"kim_ashley",
|
|
"kim_daniel",
|
|
"kim_sunny",
|
|
"kima",
|
|
"lee_sarah",
|
|
"levi_david",
|
|
"levine_emily",
|
|
"levine_joshua",
|
|
"levy_hannah",
|
|
"li_xiao",
|
|
"lintel",
|
|
"luna",
|
|
"lyra",
|
|
"maguire_jason",
|
|
"malik_ahmad",
|
|
"marinelli_giulia",
|
|
"marlu",
|
|
"martinez_amber",
|
|
"martinez_ana",
|
|
"martinez_dylan",
|
|
"martinez_jaime",
|
|
"martinez_leticia",
|
|
"martinez_rosa",
|
|
"martinez_ryan",
|
|
"masonry",
|
|
"mbunda_james",
|
|
"mccarthy_james",
|
|
"mccarthy_teresa",
|
|
"mcdowell_peter",
|
|
"mckinley_robert",
|
|
"mendoza_alonzo",
|
|
"mendoza_jesus",
|
|
"mendoza_luz",
|
|
"merritt_jimmy",
|
|
"miller_cameron",
|
|
"miller_judy",
|
|
"miller_kelsey",
|
|
"miller_lisa",
|
|
"miller_logan",
|
|
"miyamoto_akari",
|
|
"montgomery_elise",
|
|
"montgomery_emily",
|
|
"morgan_brianna",
|
|
"morgan_charles",
|
|
"morris_colin",
|
|
"morris_james",
|
|
"morris_leticia",
|
|
"morris_melvin",
|
|
"morton_daine",
|
|
"moss",
|
|
"moyo_david",
|
|
"murphy_colin",
|
|
"murphy_emily",
|
|
"murphy_grace",
|
|
"murphy_hannah",
|
|
"murphy_liam",
|
|
"murphy_nolan",
|
|
"neal_colin",
|
|
"novak_emily",
|
|
"nowak_joanna",
|
|
"nowak_michal",
|
|
"oculus",
|
|
"olsson_erik",
|
|
"orion",
|
|
"parapet",
|
|
"park_minseo",
|
|
"park_sumin",
|
|
"patel_amit",
|
|
"patel_asha",
|
|
"pham_daniel",
|
|
"pilaster",
|
|
"pola",
|
|
"ramirez_maya",
|
|
"ramos_raul",
|
|
"reddy_arjun",
|
|
"reddy_sunil",
|
|
"ricci_giulia",
|
|
"ricci_lorenzo",
|
|
"rodrigues_miguel",
|
|
"rodriguez_carla",
|
|
"rodriguez_carlos",
|
|
"rodriguez_eduardo",
|
|
"rodriguez_isabela",
|
|
"rodriguez_miguel",
|
|
"rossi_matteo",
|
|
"santos_angelica",
|
|
"schmidt_joshua",
|
|
"schmidt_julia",
|
|
"schmidt_sophie",
|
|
"schneider_eric",
|
|
"schneider_jack",
|
|
"sharma_amit",
|
|
"silva_ana",
|
|
"singh_anjali",
|
|
"sirius",
|
|
"smith_heather",
|
|
"smith_lisa",
|
|
"smith_michael",
|
|
"smith_mike",
|
|
"stucco",
|
|
"tauro",
|
|
"thalassa",
|
|
"thomas_sarah",
|
|
"thompson_kevin",
|
|
"torres_miguel",
|
|
"tran_david",
|
|
"tran_jessica",
|
|
"tran_tu",
|
|
"transom",
|
|
"truss",
|
|
"tupou_leilani",
|
|
"ursa",
|
|
"vashti",
|
|
"vespera",
|
|
"walnut",
|
|
"wang_mei",
|
|
"watson_emily",
|
|
"williams_anna",
|
|
"williams_brian",
|
|
"williams_darnell",
|
|
"williams_jennifer",
|
|
"williams_jordan",
|
|
"williams_ryan",
|
|
"williams_terence",
|
|
"williams_tiffany",
|
|
"wilson_emma",
|
|
"wong_kenny",
|
|
"wright_cooper",
|
|
"wright_jason",
|
|
"wright_julianne",
|
|
"wright_michael",
|
|
"zhang_mei",
|
|
},
|
|
"es": {"lark", "nova", "pola", "seraphina", "sirius", "ursa"},
|
|
"fr": {"destin", "morel_marianne", "solstice", "serrin_joseph"},
|
|
}
|
|
|
|
AURA_DEFAULT_VOICE_BY_VARIANT: dict[str, str] = {
|
|
"2": "aura-2-thalia-en",
|
|
"2-en": "aura-2-thalia-en",
|
|
"2-es": "aura-2-celeste-es",
|
|
}
|
|
|
|
SARVAM_BCP47_LANGUAGE_BY_CODE: dict[str, str] = {
|
|
"bn": "bn-IN",
|
|
"bn-in": "bn-IN",
|
|
"en": "en-IN",
|
|
"en-in": "en-IN",
|
|
"gu": "gu-IN",
|
|
"gu-in": "gu-IN",
|
|
"hi": "hi-IN",
|
|
"hi-in": "hi-IN",
|
|
"kn": "kn-IN",
|
|
"kn-in": "kn-IN",
|
|
"ml": "ml-IN",
|
|
"ml-in": "ml-IN",
|
|
"mr": "mr-IN",
|
|
"mr-in": "mr-IN",
|
|
"od": "od-IN",
|
|
"od-in": "od-IN",
|
|
"pa": "pa-IN",
|
|
"pa-in": "pa-IN",
|
|
"ta": "ta-IN",
|
|
"ta-in": "ta-IN",
|
|
"te": "te-IN",
|
|
"te-in": "te-IN",
|
|
}
|
|
|
|
|
|
def normalize_region_override(
|
|
region_override: str | list[str] | None,
|
|
) -> str | None:
|
|
if region_override is None:
|
|
return None
|
|
|
|
if isinstance(region_override, str):
|
|
raw_values = region_override.split(",")
|
|
else:
|
|
raw_values = [str(value) for value in region_override]
|
|
|
|
values = [value.strip().lower() for value in raw_values if value.strip()]
|
|
if not values:
|
|
return None
|
|
|
|
return ", ".join(values)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ModelRef:
|
|
raw: str
|
|
provider: str
|
|
model: str
|
|
variant: str | None
|
|
route_provider: str
|
|
route_model: str
|
|
|
|
|
|
def parse_model_ref(model: str) -> ModelRef:
|
|
raw = (model or "").strip()
|
|
if not raw:
|
|
raise ValueError("model must not be empty")
|
|
|
|
if ":" in raw:
|
|
model_path, variant = raw.rsplit(":", 1)
|
|
if not variant:
|
|
raise ValueError("model variant must not be empty")
|
|
else:
|
|
model_path, variant = raw, None
|
|
|
|
parts = [p for p in model_path.split("/") if p]
|
|
if len(parts) < 2:
|
|
raise ValueError(
|
|
f"invalid model '{raw}'; expected '<provider>/<model>' or 'slng/<provider>/<model>'"
|
|
)
|
|
|
|
provider = parts[0]
|
|
model_name = "/".join(parts[1:])
|
|
|
|
if provider == "slng":
|
|
if len(parts) < 3:
|
|
raise ValueError(f"invalid model '{raw}'; expected 'slng/<provider>/<model>'")
|
|
route_provider = parts[1]
|
|
route_model = "/".join(parts[2:])
|
|
else:
|
|
route_provider = provider
|
|
route_model = model_name
|
|
|
|
if not route_provider or not route_model:
|
|
raise ValueError(f"invalid model '{raw}'; provider and model must both be present")
|
|
|
|
return ModelRef(
|
|
raw=raw,
|
|
provider=provider,
|
|
model=model_name,
|
|
variant=variant,
|
|
route_provider=route_provider,
|
|
route_model=route_model,
|
|
)
|
|
|
|
|
|
def _rime_lang_from_variant(variant: str | None) -> str | None:
|
|
"""Extract language code from Rime Arcana variant strings.
|
|
|
|
Handles both plain variants ("en", "es") and versioned variants ("3-en", "3-es").
|
|
"""
|
|
if not variant:
|
|
return None
|
|
# Plain language code (e.g., "en", "es", "fr")
|
|
if variant in RIME_DEFAULT_SPEAKER_BY_LANG:
|
|
return variant
|
|
# Versioned variant (e.g., "3-en", "3-es") — extract suffix after first hyphen
|
|
if "-" in variant:
|
|
lang = variant.split("-", 1)[1]
|
|
if lang in RIME_DEFAULT_SPEAKER_BY_LANG:
|
|
return lang
|
|
return None
|
|
|
|
|
|
def _is_aura_ref(ref: ModelRef) -> bool:
|
|
return ref.route_provider == "deepgram" and ref.route_model == "aura"
|
|
|
|
|
|
def _is_arcana_ref(ref: ModelRef) -> bool:
|
|
return ref.route_provider == "rime" and ref.route_model == "arcana"
|
|
|
|
|
|
def _is_bulbul_ref(ref: ModelRef) -> bool:
|
|
return ref.route_provider == "sarvam" and ref.route_model == "bulbul"
|
|
|
|
|
|
def _is_sarvam_ref(ref: ModelRef) -> bool:
|
|
return ref.route_provider == "sarvam"
|
|
|
|
|
|
def is_deepgram_aura_model(model: str) -> bool:
|
|
return _is_aura_ref(parse_model_ref(model))
|
|
|
|
|
|
def is_rime_arcana_model(model: str) -> bool:
|
|
return _is_arcana_ref(parse_model_ref(model))
|
|
|
|
|
|
def is_sarvam_bulbul_model(model: str) -> bool:
|
|
return _is_bulbul_ref(parse_model_ref(model))
|
|
|
|
|
|
def is_sarvam_model(model: str) -> bool:
|
|
return _is_sarvam_ref(parse_model_ref(model))
|
|
|
|
|
|
def _normalize_language_for_ref(
|
|
ref: ModelRef | None,
|
|
language: str,
|
|
*,
|
|
model_options: Mapping[str, Any] | None = None,
|
|
) -> str:
|
|
override = None
|
|
if model_options:
|
|
candidate = model_options.get("target_language_code")
|
|
if isinstance(candidate, str):
|
|
override = candidate.strip() or None
|
|
|
|
cleaned = (override or language or "").strip()
|
|
if not cleaned or ref is None:
|
|
return cleaned
|
|
|
|
if _is_sarvam_ref(ref):
|
|
return SARVAM_BCP47_LANGUAGE_BY_CODE.get(cleaned.lower(), cleaned)
|
|
|
|
return cleaned
|
|
|
|
|
|
def normalize_language_for_model(
|
|
model: str | None,
|
|
language: str,
|
|
*,
|
|
model_options: Mapping[str, Any] | None = None,
|
|
) -> str:
|
|
ref = parse_model_ref(model) if model else None
|
|
return _normalize_language_for_ref(ref, language, model_options=model_options)
|
|
|
|
|
|
def _normalize_tts_voice_for_ref(ref: ModelRef, voice: str) -> str:
|
|
cleaned = (voice or "").strip()
|
|
|
|
if _is_arcana_ref(ref):
|
|
if cleaned and cleaned != "default":
|
|
return cleaned
|
|
lang = _rime_lang_from_variant(ref.variant)
|
|
if lang:
|
|
return RIME_DEFAULT_SPEAKER_BY_LANG[lang]
|
|
return RIME_DEFAULT_SPEAKER_BY_LANG["en"]
|
|
|
|
if _is_aura_ref(ref):
|
|
if cleaned and cleaned != "default":
|
|
return cleaned
|
|
if ref.variant and ref.variant in AURA_DEFAULT_VOICE_BY_VARIANT:
|
|
return AURA_DEFAULT_VOICE_BY_VARIANT[ref.variant]
|
|
return AURA_DEFAULT_VOICE_BY_VARIANT["2"]
|
|
|
|
return cleaned
|
|
|
|
|
|
def normalize_tts_voice(model: str, voice: str) -> str:
|
|
return _normalize_tts_voice_for_ref(parse_model_ref(model), voice)
|
|
|
|
|
|
def _validate_tts_voice_for_ref(ref: ModelRef, voice: str) -> list[str]:
|
|
errors: list[str] = []
|
|
cleaned = (voice or "").strip()
|
|
model = ref.raw
|
|
|
|
is_aura = _is_aura_ref(ref)
|
|
is_arcana = _is_arcana_ref(ref)
|
|
|
|
if is_aura:
|
|
if not cleaned:
|
|
errors.append(
|
|
f"tts_voice is required for {model}; expected an aura-2 voice like "
|
|
"'aura-2-thalia-en' or 'aura-2-celeste-es'"
|
|
)
|
|
return errors
|
|
|
|
if not cleaned.startswith("aura-2-"):
|
|
errors.append(
|
|
f"tts_voice '{cleaned}' is invalid for {model}; expected an aura-2 model id"
|
|
)
|
|
return errors
|
|
|
|
if ref.variant == "2-en" and not cleaned.endswith("-en"):
|
|
errors.append(
|
|
f"tts_voice '{cleaned}' is invalid for {model}; expected an English '-en' voice"
|
|
)
|
|
if ref.variant == "2-es" and not cleaned.endswith("-es"):
|
|
errors.append(
|
|
f"tts_voice '{cleaned}' is invalid for {model}; expected a Spanish '-es' voice"
|
|
)
|
|
if ref.variant in {"2", None} and not (cleaned.endswith("-en") or cleaned.endswith("-es")):
|
|
errors.append(
|
|
f"tts_voice '{cleaned}' is invalid for {model}; expected an '-en' or '-es' voice"
|
|
)
|
|
|
|
if is_arcana:
|
|
lang = _rime_lang_from_variant(ref.variant)
|
|
if not cleaned:
|
|
errors.append(f"tts_voice is required for {model}; expected a valid speaker")
|
|
return errors
|
|
if lang and lang in RIME_ALLOWED_SPEAKERS_BY_LANG:
|
|
allowed = RIME_ALLOWED_SPEAKERS_BY_LANG[lang]
|
|
if cleaned not in allowed:
|
|
allowed_speakers = ", ".join(sorted(allowed))
|
|
errors.append(
|
|
f"tts_voice '{cleaned}' is not valid for {model}; "
|
|
f"allowed speakers: {allowed_speakers}"
|
|
)
|
|
|
|
# Generic check for all other models: warn if voice is empty
|
|
if not errors and not cleaned and not is_aura and not is_arcana:
|
|
errors.append(f"tts_voice is empty for {model}; a voice identifier should be provided")
|
|
|
|
return errors
|
|
|
|
|
|
def validate_tts_voice(model: str, voice: str) -> list[str]:
|
|
return _validate_tts_voice_for_ref(parse_model_ref(model), voice)
|
|
|
|
|
|
def _resolve_deepgram_stt_model_for_ref(ref: ModelRef) -> str | None:
|
|
if ref.route_provider != "deepgram" or ref.route_model != "nova":
|
|
return None
|
|
|
|
variant = (ref.variant or "").lower()
|
|
if variant.startswith("3-medical"):
|
|
return "nova-3-medical"
|
|
if variant.startswith("3"):
|
|
return "nova-3"
|
|
if variant.startswith("2"):
|
|
return "nova-2"
|
|
return None
|
|
|
|
|
|
def resolve_deepgram_stt_model(model: str | None) -> str | None:
|
|
if not model:
|
|
return None
|
|
return _resolve_deepgram_stt_model_for_ref(parse_model_ref(model))
|
|
|
|
|
|
def build_tts_init_payload(
|
|
*,
|
|
model: str,
|
|
voice: str,
|
|
language: str,
|
|
sample_rate: int,
|
|
encoding: str,
|
|
speed: float,
|
|
model_options: Mapping[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
ref = parse_model_ref(model)
|
|
options = dict(model_options or {})
|
|
normalized_language = _normalize_language_for_ref(
|
|
ref,
|
|
language,
|
|
model_options=options,
|
|
)
|
|
config: dict[str, Any] = {
|
|
"language": normalized_language,
|
|
"encoding": encoding,
|
|
"sample_rate": sample_rate,
|
|
"speed": speed,
|
|
}
|
|
payload: dict[str, Any] = {
|
|
"type": "init",
|
|
"model": model,
|
|
"voice": voice,
|
|
"language": normalized_language,
|
|
"config": config,
|
|
}
|
|
|
|
if _is_aura_ref(ref):
|
|
payload["model"] = voice
|
|
|
|
if _is_arcana_ref(ref):
|
|
config["modelId"] = options.get("modelId", "arcana")
|
|
config["segment"] = options.get("segment", "bySentence")
|
|
for key in (
|
|
"speakingStyle",
|
|
"addBreathing",
|
|
"addDisfluencies",
|
|
"phonemizeBetweenBrackets",
|
|
"translateTo",
|
|
):
|
|
if key in options:
|
|
config[key] = options[key]
|
|
payload["speaker"] = voice
|
|
|
|
if _is_bulbul_ref(ref):
|
|
config["speech_sample_rate"] = str(sample_rate)
|
|
config["pace"] = options.get("pace", speed)
|
|
for key in (
|
|
"temperature",
|
|
"output_audio_bitrate",
|
|
"min_buffer_size",
|
|
"max_chunk_length",
|
|
):
|
|
if key in options:
|
|
config[key] = options[key]
|
|
|
|
return payload
|
|
|
|
|
|
def build_stt_init_payload(
|
|
*,
|
|
model: str | None,
|
|
language: str,
|
|
sample_rate: int,
|
|
encoding: str,
|
|
vad_threshold: float,
|
|
vad_min_silence_duration_ms: int,
|
|
vad_speech_pad_ms: int,
|
|
enable_diarization: bool,
|
|
enable_partial_transcripts: bool,
|
|
min_speakers: int | None = None,
|
|
max_speakers: int | None = None,
|
|
model_options: Mapping[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
ref = parse_model_ref(model) if model is not None else None
|
|
|
|
normalized_language = _normalize_language_for_ref(
|
|
ref,
|
|
language,
|
|
model_options=model_options,
|
|
)
|
|
config: dict[str, Any] = {
|
|
"language": normalized_language,
|
|
"sample_rate": sample_rate,
|
|
"encoding": "linear16" if encoding == "pcm_s16le" else encoding,
|
|
"vad_threshold": vad_threshold,
|
|
"vad_min_silence_duration_ms": vad_min_silence_duration_ms,
|
|
"vad_speech_pad_ms": vad_speech_pad_ms,
|
|
"enable_diarization": enable_diarization,
|
|
"enable_partials": enable_partial_transcripts,
|
|
"enable_partial_transcripts": enable_partial_transcripts,
|
|
}
|
|
|
|
if min_speakers is not None:
|
|
config["min_speakers"] = min_speakers
|
|
if max_speakers is not None:
|
|
config["max_speakers"] = max_speakers
|
|
|
|
if model_options:
|
|
config.update(model_options)
|
|
|
|
partials_value = config.get(
|
|
"enable_partials",
|
|
config.get("enable_partial_transcripts", enable_partial_transcripts),
|
|
)
|
|
config["enable_partials"] = partials_value
|
|
config["enable_partial_transcripts"] = partials_value
|
|
|
|
payload: dict[str, Any] = {"type": "init", "config": config}
|
|
|
|
if ref is not None:
|
|
deepgram_model = _resolve_deepgram_stt_model_for_ref(ref)
|
|
if deepgram_model:
|
|
payload["model"] = deepgram_model
|
|
|
|
return payload
|