name kokoro's voices without loading it

Languages landed but the voices did not: kokoro reported none until something
loaded it, so a client could see nine languages and still have nothing to pick
from. The names are now read out of the voices file the backend itself selects
from — the same file, so the listing cannot offer a voice that synthesis would
then reject — and the accent map already in the module decodes each one's
language and gender. 54 voices across all nine languages.

Pointedly NOT via _resolve_kokoro_files: that downloads a 28 MB release when
nothing local matches, and /v1/models is a polled endpoint, so it must never be
a download trigger. A file that is not on disk simply yields no names, and there
is a test that fails if the listing path reaches for the network at all. The zip
index read is memoised against the file's own mtime (27ms -> 0.1ms).

A list a load actually enumerated still outranks the file, and a card's
voices_path is honoured. XTTS keeps filling in after its first load, and parler
stays empty with selection "described" — it has no speaker names to give.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T3rZozi1Ehk96NG1WesgC
parent 49543895
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API # Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here. # metadata and the admin web UI read from here.
__version__ = "0.5.38" __version__ = "0.5.39"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference." __tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere. # Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
...@@ -183,6 +183,55 @@ def _resolve_kokoro_files(model_path: str, config: dict) -> Tuple[str, str]: ...@@ -183,6 +183,55 @@ def _resolve_kokoro_files(model_path: str, config: dict) -> Tuple[str, str]:
return onnx_path, voices_path return onnx_path, voices_path
#: Kokoro's voice names, by (path, mtime) of the file they came from. Reading
#: them is a zip-index read of a 28 MB archive, and /v1/models is polled.
_KOKORO_VOICE_MEMO: dict = {}
def _kokoro_voices_file_if_present(config: Optional[dict] = None):
"""The voices file, ONLY if it is already on disk.
Deliberately not :func:`_resolve_kokoro_files`: that downloads the official
release when nothing local matches, and a model LISTING must never start a
28 MB download. An absent file just means no voice names yet.
"""
cand = (config or {}).get("voices_path")
paths = [Path(cand)] if cand else []
paths.append(_cache_dir() / "voices-v1.0.bin")
for p in paths:
try:
if p.is_file() and p.stat().st_size > 0:
return p
except Exception:
continue
return None
def _kokoro_static_voices(config: Optional[dict] = None) -> list:
"""Kokoro's selectable voices, named without loading the model.
The names come from the very file the backend selects out of, so the list
cannot promise a voice synthesis would then reject. Empty when the file is
not on disk — a listing does not fetch it.
"""
f = _kokoro_voices_file_if_present(config)
if f is None:
return []
try:
st = f.stat()
stamp = (str(f), st.st_mtime_ns, st.st_size)
if _KOKORO_VOICE_MEMO.get("stamp") != stamp:
import numpy as _np
with _np.load(str(f)) as z: # npz: reads the zip index only
names = sorted(str(k) for k in z.files)
_KOKORO_VOICE_MEMO.clear()
_KOKORO_VOICE_MEMO["stamp"] = stamp
_KOKORO_VOICE_MEMO["names"] = names
return list(_KOKORO_VOICE_MEMO.get("names") or [])
except Exception:
return []
#: espeak-ng names English by REGION -- ``en-us``, ``en-gb`` -- and rejects the #: espeak-ng names English by REGION -- ``en-us``, ``en-gb`` -- and rejects the
#: bare code outright: `language "en" is not supported by the espeak backend`. #: bare code outright: `language "en" is not supported by the espeak backend`.
#: Every OpenAI-compatible client in existence sends "en", so Kokoro's English #: Every OpenAI-compatible client in existence sends "en", so Kokoro's English
...@@ -648,7 +697,8 @@ def _remembered_voices(model_name: str) -> list: ...@@ -648,7 +697,8 @@ def _remembered_voices(model_name: str) -> list:
return [] return []
def family_voices(model_name: str, backend=None) -> dict: def family_voices(model_name: str, backend=None,
config: Optional[dict] = None) -> dict:
"""The model's built-in voices, and where the list came from. """The model's built-in voices, and where the list came from.
``source`` is part of the answer on purpose: "none" means nothing is known ``source`` is part of the answer on purpose: "none" means nothing is known
...@@ -668,6 +718,10 @@ def family_voices(model_name: str, backend=None) -> dict: ...@@ -668,6 +718,10 @@ def family_voices(model_name: str, backend=None) -> dict:
ids = _remembered_voices(model_name) ids = _remembered_voices(model_name)
if ids: if ids:
source = "remembered" source = "remembered"
if not ids and fam == "kokoro":
ids = _kokoro_static_voices(config)
if ids:
source = "static"
if not ids and fam == "bark": if not ids and fam == "bark":
# Deterministic ids, so they can be named without loading anything. # Deterministic ids, so they can be named without loading anything.
# ALL of bark's languages, not just English: suno/bark ships a speaker # ALL of bark's languages, not just English: suno/bark ships a speaker
......
...@@ -7172,7 +7172,7 @@ class MultiModelManager: ...@@ -7172,7 +7172,7 @@ class MultiModelManager:
from codai.api import tts_backends from codai.api import tts_backends
tts_languages = tts_backends.family_languages( tts_languages = tts_backends.family_languages(
model_id, meta) or None model_id, meta) or None
_v = tts_backends.family_voices(model_id) _v = tts_backends.family_voices(model_id, config=meta)
tts_voices = _v.get("voices") or None tts_voices = _v.get("voices") or None
except Exception: except Exception:
tts_languages, tts_voices = None, None tts_languages, tts_voices = None, None
......
...@@ -108,6 +108,108 @@ def test_bark_voices_start_with_english(tmp_path, monkeypatch): ...@@ -108,6 +108,108 @@ def test_bark_voices_start_with_english(tmp_path, monkeypatch):
assert all(x["languages"] == ["en"] for x in v["voices"][:10]) assert all(x["languages"] == ["en"] for x in v["voices"][:10])
# ── kokoro's voices, named without loading it ────────────────────────────────
def _fake_voices_bin(path, names):
"""An npz whose keys are voice names — the shape kokoro-onnx selects from."""
import numpy as np
np.savez(str(path), **{n: np.zeros((1, 1), dtype=np.float32) for n in names})
# np.savez appends .npz; the real file has no such suffix.
os.replace(str(path) + '.npz', str(path))
return path
def test_kokoro_voices_are_named_from_the_file_the_backend_selects_from(
tmp_path, monkeypatch):
"""Not a hand-kept list: the names come out of the very file synthesis picks
from, so the listing cannot offer a voice that then gets rejected."""
monkeypatch.setattr(T, "_cache_dir", lambda: tmp_path)
monkeypatch.setattr(T, "_KOKORO_VOICE_MEMO", {}, raising=False)
monkeypatch.setattr(T, "_VOICES_MEMO", {}, raising=False)
_fake_voices_bin(tmp_path / 'voices-v1.0.bin',
['af_sarah', 'am_adam', 'if_sara', 'jf_alpha'])
v = T.family_voices('hexgrad/Kokoro-82M')
assert v['voices_source'] == 'static'
assert [x['id'] for x in v['voices']] == ['af_sarah', 'am_adam',
'if_sara', 'jf_alpha']
# And the accent map still decodes each one.
by_id = {x['id']: x for x in v['voices']}
assert by_id['if_sara']['languages'] == ['it']
assert by_id['am_adam']['gender'] == 'male'
def test_a_listing_never_downloads_the_voices_file(tmp_path, monkeypatch):
"""_resolve_kokoro_files fetches a 28 MB release when nothing local matches.
/v1/models is polled; it must not be a download trigger."""
monkeypatch.setattr(T, "_cache_dir", lambda: tmp_path)
monkeypatch.setattr(T, "_KOKORO_VOICE_MEMO", {}, raising=False)
monkeypatch.setattr(T, "_VOICES_MEMO", {}, raising=False)
def explode(*a, **k):
raise AssertionError('the listing tried to download a TTS asset')
monkeypatch.setattr(T, '_download', explode)
monkeypatch.setattr(T, '_resolve_kokoro_files', explode)
v = T.family_voices('hexgrad/Kokoro-82M') # no file on disk
assert v['voices'] == []
assert v['voices_source'] == 'none'
def test_kokoro_voice_file_is_reread_when_it_changes(tmp_path, monkeypatch):
monkeypatch.setattr(T, "_cache_dir", lambda: tmp_path)
monkeypatch.setattr(T, "_KOKORO_VOICE_MEMO", {}, raising=False)
f = tmp_path / 'voices-v1.0.bin'
_fake_voices_bin(f, ['af_sarah'])
assert T._kokoro_static_voices() == ['af_sarah']
assert T._kokoro_static_voices() == ['af_sarah'] # memoised
os.utime(f, (1, 1))
_fake_voices_bin(f, ['af_sarah', 'if_sara'])
assert T._kokoro_static_voices() == ['af_sarah', 'if_sara']
def test_a_corrupt_voices_file_costs_only_the_voice_list(tmp_path, monkeypatch):
monkeypatch.setattr(T, "_cache_dir", lambda: tmp_path)
monkeypatch.setattr(T, "_KOKORO_VOICE_MEMO", {}, raising=False)
(tmp_path / 'voices-v1.0.bin').write_bytes(b'not an npz')
assert T._kokoro_static_voices() == []
def test_a_cards_voices_path_is_honoured(tmp_path, monkeypatch):
monkeypatch.setattr(T, "_cache_dir", lambda: tmp_path / 'nothing-here')
monkeypatch.setattr(T, "_KOKORO_VOICE_MEMO", {}, raising=False)
monkeypatch.setattr(T, "_VOICES_MEMO", {}, raising=False)
custom = tmp_path / 'my-voices.bin'
_fake_voices_bin(custom, ['bf_emma'])
v = T.family_voices('hexgrad/Kokoro-82M', config={'voices_path': str(custom)})
assert [x['id'] for x in v['voices']] == ['bf_emma']
def test_a_remembered_list_still_beats_the_kokoro_file(tmp_path, monkeypatch):
"""What a load actually enumerated outranks what the file contains."""
monkeypatch.setattr(T, "_cache_dir", lambda: tmp_path)
monkeypatch.setattr(T, "_KOKORO_VOICE_MEMO", {}, raising=False)
monkeypatch.setattr(T, "_VOICES_MEMO", {}, raising=False)
_fake_voices_bin(tmp_path / 'voices-v1.0.bin', ['af_sarah', 'am_adam'])
(tmp_path / 'tts_voices.json').write_text(
json.dumps({'hexgrad/Kokoro-82M': ['if_sara']}))
v = T.family_voices('hexgrad/Kokoro-82M')
assert v['voices_source'] == 'remembered'
assert [x['id'] for x in v['voices']] == ['if_sara']
def test_described_engines_report_no_voices_and_say_why():
"""parler has no speaker names at all; an empty list plus selection
'described' is the honest answer, not a bug to fill in."""
v = T.family_voices('parler-tts/parler-tts-mini-multilingual')
assert v['voices'] == []
assert v['voice_selection'] == 'described'
def test_the_listing_passes_the_card_to_the_voice_lookup():
src = open(MANAGER).read()
assert 'tts_backends.family_voices(model_id, config=meta)' in src
def test_a_remembered_voice_list_still_beats_the_static_one(tmp_path, monkeypatch): 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, "_cache_dir", lambda: tmp_path)
monkeypatch.setattr(T, "_VOICES_MEMO", {}, raising=False) monkeypatch.setattr(T, "_VOICES_MEMO", {}, raising=False)
......
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