say what a voice can speak

Every TTS model published `languages: null` on /v1/models while the STT models
published theirs correctly. Nothing in the server restricted the languages — it
simply never named them, so a client was told nothing about what any voice could
say and could only discover a language by trying it. That is how XTTS-v2's
seventeen languages got taken for English and Italian: those were the two
somebody tried.

Each set is read from the engine's own files rather than assumed — XTTS from its
config.json, bark from its 13-language speaker bank, parler from its model card,
and kokoro derived at call time from the accents already encoded in its voice
ids, so adding a voice family cannot leave its language behind. An engine whose
languages depend on the checkpoint (melo, the generic transformers fallback)
claims nothing: a wrong list is worse than none, because a client that believes
it sends text the voice cannot pronounce and gets noise back with no error. A
`languages` list on the model's own card still wins.

Bark's voice list was the ten English speakers, which is where "bark is English"
literally came from; it now covers all 13 languages, English first so a client
taking voices[0] as its default still gets an English speaker. The voices are
published too, so a voice picker no longer needs to load a model to ask it —
and since /v1/models is polled, the voices cache is memoised against its own
mtime rather than re-read per model per poll.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T3rZozi1Ehk96NG1WesgC
parent 14d34003
......@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here.
__version__ = "0.5.37"
__version__ = "0.5.38"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
......@@ -74,6 +74,51 @@ _FAMILY_STYLES: dict[str, list[str]] = {
}
#: What each family can SPEAK. Every entry is the engine's own declared set,
#: read from the model rather than assumed:
#:
#: coqui XTTS-v2's own config.json `languages` (17)
#: kokoro the accents encoded in its voice ids -- see _KOKORO_ACCENTS below,
#: which this is derived from at import so the two cannot drift
#: bark suno/bark's speaker_embeddings bank: 13 languages x speakers 0-9
#: parler parler-tts-mini-multilingual's model card (8 European languages)
#:
#: This exists because the languages were never advertised. All four TTS models
#: published `languages: null` on /v1/models while the STT models published
#: theirs correctly, so a client was told nothing and could only discover a
#: language by trying it -- which is how a 17-language engine gets taken for an
#: English-and-Italian one. A card's own `languages` still wins: this is the
#: default for a card that says nothing, not an override of one that does.
#:
#: `melo` and the generic `transformers` fallback are absent on purpose: what
#: they can say depends on the checkpoint, and a wrong list is worse than none.
_FAMILY_LANGUAGES: dict[str, list[str]] = {
"coqui": ["en", "es", "fr", "de", "it", "pt", "pl", "tr", "ru", "nl",
"cs", "ar", "zh-cn", "hu", "ko", "ja", "hi"],
"bark": ["de", "en", "es", "fr", "hi", "it", "ja", "ko", "pl", "pt",
"ru", "tr", "zh"],
"parler": ["en", "fr", "de", "es", "it", "pt", "pl", "nl"],
}
def family_languages(model_name: str, config: Optional[dict] = None) -> list[str]:
"""Languages this model can speak, or [] when nothing is known.
A ``languages`` list on the model's own card wins -- an operator who has
restricted or extended a model knows something this table does not.
"""
if config:
declared = config.get("languages")
if declared:
return [str(x) for x in declared]
fam = _family(model_name)
if fam == "kokoro":
# Derived from the voice-id accents, so adding a voice family adds its
# language without a second list to remember.
return sorted({lang for lang, _ in _KOKORO_ACCENTS.values()})
return list(_FAMILY_LANGUAGES.get(fam, []))
def family_emotions(model_name: str) -> list[str]:
"""Emotions the given model can steer, or [] when none are available."""
return list(_FAMILY_EMOTIONS.get(_family(model_name), []))
......@@ -577,14 +622,28 @@ def remember_voices(model_name: str, backend) -> None:
pass
#: The parsed voices cache, keyed by (path, mtime, size). /v1/models is a
#: POLLED endpoint and now asks for voices on every TTS entry, so re-reading and
#: re-parsing the file per model per poll is a cost with no new information in
#: it. Re-read as soon as the file changes, which is what a load records.
_VOICES_MEMO: dict = {}
def _remembered_voices(model_name: str) -> list:
try:
import json
f = _voices_cache_file()
if not f.is_file():
return []
cur = json.loads(f.read_text())
return [str(v) for v in (cur.get(model_name) or [])]
st = f.stat()
stamp = (str(f), st.st_mtime_ns, st.st_size)
cached = _VOICES_MEMO.get("stamp")
if cached != stamp:
parsed = json.loads(f.read_text())
_VOICES_MEMO.clear()
_VOICES_MEMO["stamp"] = stamp
_VOICES_MEMO["data"] = parsed if isinstance(parsed, dict) else {}
return [str(v) for v in (_VOICES_MEMO.get("data", {}).get(model_name) or [])]
except Exception:
return []
......@@ -611,7 +670,16 @@ def family_voices(model_name: str, backend=None) -> dict:
source = "remembered"
if not ids and fam == "bark":
# Deterministic ids, so they can be named without loading anything.
ids = [f"v2/en_speaker_{i}" for i in range(10)]
# ALL of bark's languages, not just English: suno/bark ships a speaker
# bank of 13 languages x speakers 0-9, and listing only the English ten
# is what made a 13-language engine look like an English one. Ordered
# English-first so a client taking voices[0] as a default still gets an
# English speaker.
ids = [f"v2/en_speaker_{i}" for i in range(10)] + [
f"v2/{lang}_speaker_{i}"
for lang in _FAMILY_LANGUAGES["bark"] if lang != "en"
for i in range(10)
]
source = "static"
return {
"voices": [_voice_meta(fam, v) for v in ids],
......
......@@ -7160,6 +7160,22 @@ class MultiModelManager:
video_geom = video_geometry.geometry_for(model_id, meta)
except Exception:
video_geom = None
# A speaking model carries what it can SAY, the same way an STT
# model already does. Without this every TTS entry published
# `languages: null` while the STT entries published theirs, so a
# client was told nothing and could only find a language by trying
# it -- which is how XTTS-v2's seventeen got taken for two. Derived,
# never fatal, and the card still wins (see family_languages).
tts_languages, tts_voices = None, None
if resolved_type == "tts" or "text_to_speech" in caps.to_list():
try:
from codai.api import tts_backends
tts_languages = tts_backends.family_languages(
model_id, meta) or None
_v = tts_backends.family_voices(model_id)
tts_voices = _v.get("voices") or None
except Exception:
tts_languages, tts_voices = None, None
models.append(ModelInfo(
id=model_id,
canonical_id=_canon.get(model_id) or model_id,
......@@ -7172,7 +7188,8 @@ class MultiModelManager:
port=meta.get("port"),
gpu_device=meta.get("gpu_device"),
load_mode=meta.get("load_mode"),
languages=meta.get("languages") or None,
languages=meta.get("languages") or tts_languages or None,
voices=tts_voices,
supports_translation=meta.get("supports_translation"),
video=video_geom,
max_model_len=context_window_for(self, model_id, meta),
......
......@@ -156,10 +156,16 @@ class ModelInfo(BaseModel):
port: Optional[int] = None
gpu_device: Optional[int] = None
load_mode: Optional[str] = None
# Speech-to-text: languages this model supports (empty/None = accepts any) and
# whether it can translate (source→target_language, e.g. NVIDIA Canary).
# Languages this model handles (empty/None = unknown, or accepts any):
# for speech-to-text the ones it transcribes, for text-to-speech the ones it
# can speak. `supports_translation` is STT-only (source→target_language,
# e.g. NVIDIA Canary).
languages: Optional[List[str]] = None
supports_translation: Optional[bool] = None
# Text-to-speech: the built-in voices, each with whatever is genuinely known
# about it (id, name, gender, languages). Published so a client can offer a
# voice picker without loading the model to ask it.
voices: Optional[List[Dict]] = None
speaker_diarization: Optional[bool] = None
# The context window this model is being SERVED with, in tokens.
#
......
"""A seventeen-language engine was taken for an English-and-Italian one.
Every TTS model published ``languages: null`` on /v1/models while the STT models
published theirs correctly, so a client was told nothing about what any voice
could speak and could only discover a language by trying it. Measured on a live
deployment 2026-10-30:
coqui/XTTS-v2 languages: None voices: 0
hexgrad/Kokoro-82M languages: None voices: 0
suno/bark languages: None voices: 0
parler-...-multilingual languages: None voices: 0
wav2vec2-it languages: ['it'] <- STT, correct
canary-1b-flash languages: ['en','de','fr','es']
Nothing in the server restricted the languages; it simply never named them. The
sets below are each read from the engine's own files, not assumed -- the tests
that can check against those files do.
"""
import json
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from codai.api import tts_backends as T
# ── what each family declares ────────────────────────────────────────────────
def test_xtts_publishes_all_seventeen():
"""XTTS-v2's config.json lists 17; publishing 2 is the bug this fixes."""
langs = T.family_languages("coqui/XTTS-v2")
assert len(langs) == 17
for expect in ("en", "it", "ar", "zh-cn", "ko", "hi", "cs", "hu"):
assert expect in langs, f"{expect} missing from XTTS languages"
def test_bark_publishes_all_thirteen():
"""suno/bark ships a speaker bank of 13 languages."""
langs = T.family_languages("suno/bark")
assert sorted(langs) == ["de", "en", "es", "fr", "hi", "it", "ja", "ko",
"pl", "pt", "ru", "tr", "zh"]
def test_parler_multilingual_publishes_its_eight():
langs = T.family_languages("parler-tts/parler-tts-mini-multilingual")
assert sorted(langs) == ["de", "en", "es", "fr", "it", "nl", "pl", "pt"]
def test_kokoro_languages_come_from_its_own_voice_ids():
"""Derived from _KOKORO_ACCENTS rather than a second hand-kept list, so a
new voice family cannot be added without its language appearing."""
langs = T.family_languages("hexgrad/Kokoro-82M")
assert sorted(langs) == sorted({l for l, _ in T._KOKORO_ACCENTS.values()})
assert "it" in langs and "en-us" in langs and "ja" in langs
def test_kokoro_language_list_tracks_the_accent_map(monkeypatch):
"""The derivation is real: add an accent, the language shows up."""
patched = dict(T._KOKORO_ACCENTS)
patched["k"] = ("sv", "Swedish")
monkeypatch.setattr(T, "_KOKORO_ACCENTS", patched)
assert "sv" in T.family_languages("hexgrad/Kokoro-82M")
def test_an_unknown_engine_claims_nothing():
"""A wrong language list is worse than none: a client that believes it sends
text the voice cannot pronounce and gets noise back, with no error."""
assert T.family_languages("some/unknown-tts-model") == []
assert T.family_languages("myorg/melo-whatever") == []
def test_the_card_wins():
"""An operator who restricted a model knows something the table does not."""
assert T.family_languages("suno/bark", {"languages": ["it"]}) == ["it"]
assert T.family_languages("coqui/XTTS-v2", {"languages": ["en", "fr"]}) == ["en", "fr"]
def test_an_empty_card_list_falls_back_rather_than_claiming_nothing():
"""`languages: []` is an unset field, not a model that speaks nothing."""
assert T.family_languages("suno/bark", {"languages": []}) != []
assert T.family_languages("suno/bark", {}) != []
# ── bark's voices ────────────────────────────────────────────────────────────
def test_bark_static_voices_cover_every_language(tmp_path, monkeypatch):
"""The static list used to be the ten English speakers only, which is where
"bark is English" literally came from."""
monkeypatch.setattr(T, "_cache_dir", lambda: tmp_path)
monkeypatch.setattr(T, "_VOICES_MEMO", {}, raising=False)
v = T.family_voices("suno/bark")
assert v["voices_source"] == "static"
langs = {l for x in v["voices"] for l in (x["languages"] or [])}
assert langs == set(T._FAMILY_LANGUAGES["bark"])
assert len(v["voices"]) == 13 * 10
def test_bark_voices_start_with_english(tmp_path, monkeypatch):
"""A client taking voices[0] as its default must not suddenly speak Hindi."""
monkeypatch.setattr(T, "_cache_dir", lambda: tmp_path)
monkeypatch.setattr(T, "_VOICES_MEMO", {}, raising=False)
v = T.family_voices("suno/bark")
assert v["voices"][0]["id"] == "v2/en_speaker_0"
assert all(x["languages"] == ["en"] for x in v["voices"][:10])
def test_a_remembered_voice_list_still_beats_the_static_one(tmp_path, monkeypatch):
monkeypatch.setattr(T, "_cache_dir", lambda: tmp_path)
monkeypatch.setattr(T, "_VOICES_MEMO", {}, raising=False)
(tmp_path / "tts_voices.json").write_text(
json.dumps({"suno/bark": ["v2/it_speaker_3"]}))
v = T.family_voices("suno/bark")
assert v["voices_source"] == "remembered"
assert [x["id"] for x in v["voices"]] == ["v2/it_speaker_3"]
# ── the memo on the polled path ──────────────────────────────────────────────
def test_voices_cache_is_reread_when_it_changes(tmp_path, monkeypatch):
"""/v1/models is polled, so the file is memoised -- but a load records new
voices and those must appear without a restart."""
monkeypatch.setattr(T, "_cache_dir", lambda: tmp_path)
monkeypatch.setattr(T, "_VOICES_MEMO", {}, raising=False)
f = tmp_path / "tts_voices.json"
f.write_text(json.dumps({"m": ["a", "b"]}))
assert T._remembered_voices("m") == ["a", "b"]
assert T._remembered_voices("m") == ["a", "b"] # from the memo
os.utime(f, (1, 1))
f.write_text(json.dumps({"m": ["c"]}))
assert T._remembered_voices("m") == ["c"]
def test_a_missing_or_broken_cache_costs_only_the_voice_list(tmp_path, monkeypatch):
monkeypatch.setattr(T, "_cache_dir", lambda: tmp_path)
monkeypatch.setattr(T, "_VOICES_MEMO", {}, raising=False)
assert T._remembered_voices("m") == [] # no file
(tmp_path / "tts_voices.json").write_text("{ not json")
assert T._remembered_voices("m") == []
# ── the listing actually publishes them ──────────────────────────────────────
MANAGER = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'codai', 'models', 'manager.py')
def test_the_listing_derives_tts_languages():
src = open(MANAGER).read()
assert 'tts_backends.family_languages(' in src
assert 'tts_backends.family_voices(' in src
assert '"text_to_speech" in caps.to_list()' in src
def test_the_card_is_consulted_before_the_derivation_in_the_listing():
src = open(MANAGER).read()
assert 'languages=meta.get("languages") or tts_languages or None,' in src
def test_a_failure_in_the_derivation_cannot_cost_the_whole_listing():
"""One unparseable TTS entry must not empty /v1/models for every model."""
src = open(MANAGER).read()
i = src.index('tts_backends.family_languages(')
tail = src[i:i + 500]
assert 'except Exception:' in tail
assert 'tts_languages, tts_voices = None, None' in tail
def test_model_info_carries_voices():
from codai.pydantic.textrequest import ModelInfo
m = ModelInfo(id="x", voices=[{"id": "v2/it_speaker_1", "languages": ["it"]}])
assert m.voices[0]["languages"] == ["it"]
assert ModelInfo(id="y").voices is None
def test_languages_is_documented_for_both_directions():
"""It was labelled speech-to-text only, which is why nobody populated it for
the speaking side."""
src = open(os.path.join(os.path.dirname(MANAGER), '..', 'pydantic',
'textrequest.py')).read()
i = src.index('languages: Optional[List[str]] = None')
head = src[max(0, i - 450):i]
assert 'text-to-speech' in head
# ── the sets match the engines' own files, where those are on disk ───────────
HF = '/AI/huggingface/hub'
@pytest.mark.skipif(not os.path.isdir(HF), reason='no local HF cache')
def test_xtts_set_matches_the_checkpoints_own_config():
"""The one set that can be checked against the model itself, is."""
import glob
hits = glob.glob(os.path.join(HF, 'models--coqui--XTTS-v2',
'snapshots', '*', 'config.json'))
if not hits:
pytest.skip('XTTS-v2 not downloaded')
declared = json.load(open(hits[0])).get('languages')
if not declared:
pytest.skip('this XTTS config declares no languages')
assert sorted(T._FAMILY_LANGUAGES['coqui']) == sorted(declared)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment