ask a backend for its voices the way backends actually expose them

XTTS reported no voices after seventeen successful syntheses, and the reason was
that the loaded and remembered paths had never worked for any engine since they
were written. Every backend declares `voices` as a METHOD; both callers read it
as a sequence:

    [str(v) for v in (getattr(backend, "voices", None) or [])]

A bound method is truthy, so the `or []` never fired, iterating it raised
TypeError, and both callers caught that and fell back to "nothing known". No
voice was ever recorded or returned, and the cache file had never been written
once. Kokoro and bark hid it behind their static lists; XTTS and parler, having
none, published zero for ever.

The tests passed because their fake sets `voices` as a list attribute — a shape
no real backend has. A new test asserts the contract against the real classes
through inspect rather than against a fixture, which is the only version of this
test that could have failed.

With that fixed, 58 XTTS voices were recorded and the listing still showed none:
they were written under "XTTS-v2", the id the request used, and looked up under
"coqui/XTTS-v2", the id the listing publishes. The lookup now falls back to
matching the last path segment — the idiom the coqui speaker lookup already uses
— with an exact key still winning and distinct models still distinct.

The existing never-breaks-synthesis test then caught a gap in the first fix: a
backend may expose `voices` as a PROPERTY, so merely reading the attribute runs
its code and can raise. The getattr is inside the guard for that reason.

Live after this: XTTS-v2 58 voices, Kokoro 54, bark 130, parler 0 with
voice_selection "described", which is the honest answer for an engine steered by
a text description.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T3rZozi1Ehk96NG1WesgC
parent 4fc14c34
......@@ -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.44"
__version__ = "0.5.45"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
......@@ -668,6 +668,42 @@ def _voice_meta(family: str, voice_id: str) -> dict:
return out
def _voice_ids(backend) -> list:
"""The voice ids a loaded backend can offer, however it exposes them.
Every backend in this module declares ``voices`` as a METHOD, and both
callers used to read it as if it were a sequence::
[str(v) for v in (getattr(backend, "voices", None) or [])]
A bound method is truthy, so the ``or []`` never fired; iterating it raised
TypeError; both callers caught that and fell back to "nothing known". So the
loaded and remembered paths never recorded or returned a single voice for any
engine, and the cache file was never written at all. Kokoro and bark hid it
behind their static lists; XTTS and parler, which have none, reported zero
voices for ever.
The tests did not catch it because their fake set ``voices`` as a list
attribute -- a shape no real backend has.
"""
try:
# The getattr is inside the guard too: a backend may expose `voices` as
# a PROPERTY, and then merely reading the attribute runs its code and
# can raise. This runs on the load path, so every failure mode here
# costs the voice list and nothing else.
got = getattr(backend, "voices", None)
if callable(got):
got = got()
except Exception:
return []
if not got:
return []
try:
return [str(v) for v in got]
except TypeError:
return []
def _voices_cache_file() -> Path:
return _cache_dir() / "tts_voices.json"
......@@ -679,10 +715,7 @@ def remember_voices(model_name: str, backend) -> None:
unwritable cache or a malformed existing file must not affect synthesis,
which is what the caller is actually doing.
"""
try:
voices = [str(v) for v in (getattr(backend, "voices", None) or [])]
except Exception:
return
voices = _voice_ids(backend)
if not voices:
return
try:
......@@ -726,7 +759,21 @@ def _remembered_voices(model_name: str) -> list:
_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 [])]
data = _VOICES_MEMO.get("data", {})
got = data.get(model_name)
if got is None:
# A model answers to several ids, and the one that RECORDED the
# voices is whichever the request used while the one asking is
# whichever the listing publishes: "XTTS-v2" was written and
# "coqui/XTTS-v2" looked up, so 58 recorded voices read as none.
# Match on the last path segment, as the coqui speaker lookup
# already does for the same reason.
want = str(model_name).rsplit("/", 1)[-1].lower()
for key, val in data.items():
if str(key).rsplit("/", 1)[-1].lower() == want:
got = val
break
return [str(v) for v in (got or [])]
except Exception:
return []
......@@ -742,12 +789,9 @@ def family_voices(model_name: str, backend=None,
fam = _family(model_name)
ids, source = [], "none"
if backend is not None:
try:
ids = [str(v) for v in (getattr(backend, "voices", None) or [])]
ids = _voice_ids(backend)
if ids:
source = "loaded"
except Exception:
ids = []
if not ids:
ids = _remembered_voices(model_name)
if ids:
......
......@@ -228,3 +228,146 @@ def test_the_engine_package_alone_is_not_evidence(tmp_path, monkeypatch):
def test_every_venv_family_requires_torch():
for fam, modules in T._VENV_FAMILIES.items():
assert "torch" in modules, f"{fam} can report ready without torch"
# ── the loaded/remembered paths had never once worked ────────────────────────
#
# Every backend in tts_backends declares `voices` as a METHOD, and both callers
# read it as a sequence:
#
# [str(v) for v in (getattr(backend, "voices", None) or [])]
#
# A bound method is truthy, so `or []` never fired; iterating it raised
# TypeError; both callers caught that and fell back to "nothing known". So no
# voice was ever recorded or returned for any engine, and the cache file was
# never written. Kokoro and bark hid it behind their static lists; XTTS and
# parler, having none, reported zero voices for ever -- after 17 successful
# syntheses in one afternoon.
#
# These tests did not catch it because `_Loaded` sets `voices` as a LIST
# ATTRIBUTE, a shape no real backend has. The fixtures below use both.
class _LoadedMethod:
"""How every real backend actually looks."""
def __init__(self, voices):
self._v = voices
def voices(self):
return self._v
def test_a_backend_whose_voices_is_a_method_is_read():
got = T.family_voices("coqui/XTTS-v2", _LoadedMethod(["Claribel Dervla",
"Daisy Studious"]))
assert got["voices_source"] == "loaded"
assert [v["id"] for v in got["voices"]] == ["Claribel Dervla", "Daisy Studious"]
def test_the_attribute_shape_still_works():
"""Belt and braces: whichever way a backend exposes them."""
got = T.family_voices("coqui/XTTS-v2", _Loaded(["A", "B"]))
assert [v["id"] for v in got["voices"]] == ["A", "B"]
def test_every_real_backend_exposes_voices_the_way_the_reader_expects():
"""The assumption the bug rested on, asserted against the real classes
instead of a fixture."""
import inspect
classes = [obj for _, obj in inspect.getmembers(T, inspect.isclass)
if hasattr(obj, "voices") and obj.__module__ == T.__name__]
assert classes, "no backend classes found — this test has stopped checking"
for cls in classes:
assert callable(cls.voices), f"{cls.__name__}.voices is not callable"
# And the reader handles exactly that.
assert T._voice_ids(_LoadedMethod(["x"])) == ["x"]
def test_a_backend_that_raises_when_asked_costs_only_the_list(tmp_path, monkeypatch):
monkeypatch.setattr(T, "_cache_dir", lambda: tmp_path)
monkeypatch.setattr(T, "_VOICES_MEMO", {}, raising=False)
class Broken:
def voices(self):
raise RuntimeError("engine not ready")
class Angry:
@property
def voices(self):
raise RuntimeError("cannot even read the attribute")
assert T._voice_ids(Broken()) == []
assert T._voice_ids(Angry()) == []
assert T.family_voices("coqui/XTTS-v2", Broken())["voices_source"] == "none"
def test_a_backend_with_no_voices_attribute_at_all():
class Bare:
pass
assert T._voice_ids(Bare()) == []
def test_remember_voices_actually_writes_now(tmp_path, monkeypatch):
"""It had never written the file once."""
import json
monkeypatch.setattr(T, "_cache_dir", lambda: tmp_path)
monkeypatch.setattr(T, "_VOICES_MEMO", {}, raising=False)
T.remember_voices("coqui/XTTS-v2", _LoadedMethod(["Claribel Dervla"]))
stored = json.loads((tmp_path / "tts_voices.json").read_text())
assert stored["coqui/XTTS-v2"] == ["Claribel Dervla"]
# ── recorded under one id, published under another ───────────────────────────
def test_voices_recorded_under_an_alias_are_found_by_the_canonical_id(
tmp_path, monkeypatch):
"""A model answers to several ids. The one that RECORDS is whichever the
request used; the one that ASKS is whichever the listing publishes. 58 XTTS
voices were written under "XTTS-v2" and looked up under "coqui/XTTS-v2",
so they read as none."""
import json
monkeypatch.setattr(T, "_cache_dir", lambda: tmp_path)
monkeypatch.setattr(T, "_VOICES_MEMO", {}, raising=False)
(tmp_path / "tts_voices.json").write_text(
json.dumps({"XTTS-v2": ["Claribel Dervla", "Daisy Studious"]}))
assert len(T._remembered_voices("coqui/XTTS-v2")) == 2
assert len(T._remembered_voices("XTTS-v2")) == 2
def test_the_match_works_in_the_other_direction_too(tmp_path, monkeypatch):
import json
monkeypatch.setattr(T, "_cache_dir", lambda: tmp_path)
monkeypatch.setattr(T, "_VOICES_MEMO", {}, raising=False)
(tmp_path / "tts_voices.json").write_text(
json.dumps({"coqui/XTTS-v2": ["Claribel Dervla"]}))
assert len(T._remembered_voices("XTTS-v2")) == 1
def test_the_match_is_case_insensitive(tmp_path, monkeypatch):
import json
monkeypatch.setattr(T, "_cache_dir", lambda: tmp_path)
monkeypatch.setattr(T, "_VOICES_MEMO", {}, raising=False)
(tmp_path / "tts_voices.json").write_text(json.dumps({"XTTS-v2": ["A"]}))
assert len(T._remembered_voices("coqui/xtts-v2")) == 1
def test_a_different_model_is_not_matched(tmp_path, monkeypatch):
"""Matching on the last segment must not collapse distinct models."""
import json
monkeypatch.setattr(T, "_cache_dir", lambda: tmp_path)
monkeypatch.setattr(T, "_VOICES_MEMO", {}, raising=False)
(tmp_path / "tts_voices.json").write_text(json.dumps({"XTTS-v2": ["A"]}))
assert T._remembered_voices("suno/bark") == []
assert T._remembered_voices("hexgrad/Kokoro-82M") == []
def test_an_exact_key_wins_over_a_segment_match(tmp_path, monkeypatch):
import json
monkeypatch.setattr(T, "_cache_dir", lambda: tmp_path)
monkeypatch.setattr(T, "_VOICES_MEMO", {}, raising=False)
(tmp_path / "tts_voices.json").write_text(json.dumps({
"XTTS-v2": ["alias-recorded"],
"coqui/XTTS-v2": ["canonical-recorded"]}))
assert T._remembered_voices("coqui/XTTS-v2") == ["canonical-recorded"]
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