Skip to content

Commit 4a410e2

Browse files
committed
Release 0.10.19: engine-free voice discovery
Provider and model discovery is answered from the model cache instead of by loading engines, so listing no longer imports torch/transformers or loads weights. With ABSTRACTVOICE_TTS_ENGINE=audiodit, list_tts_models() goes from 28.6s to 0.07s and list_cloning_models() from 49.5s to 0.2s, and local engines report their own model ids instead of Piper's. Remote providers are probed concurrently under one 5s discovery budget (ABSTRACTVOICE_DISCOVERY_TIMEOUT_S), and a provider that does not answer is marked unreachable rather than reported as having no models. A local engine is now listed when its models are present on the machine; selecting an uncached engine still downloads on demand. Adds abstractvoice.local_models, docs/troubleshooting.md, CODE_OF_CONDUCT.md, and refreshes the docs set plus llms.txt / llms-full.txt.
1 parent f1c875d commit 4a410e2

18 files changed

Lines changed: 1909 additions & 238 deletions

CHANGELOG.md

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,56 @@ Older changelog entries may reference historical CLI commands or model choices.
1010

1111
## [Unreleased]
1212

13+
## [0.10.19] - 2026-08-03
14+
15+
### Changed
16+
- **Provider and model discovery no longer loads engines.** Listing providers, models, or voices is
17+
now answered from the model cache, so it does not import torch or transformers and does not load
18+
weights. With `ABSTRACTVOICE_TTS_ENGINE=audiodit`, `list_tts_models()` goes from 28.6s to 0.07s and
19+
`list_cloning_models()` from 49.5s to 0.2s, with the same content. Local engines also report their
20+
own model ids — `meituan-longcat/LongCat-AudioDiT-1B`, `k2-fsa/OmniVoice` — where previously they
21+
reported Piper's voices.
22+
23+
Engine-free surfaces: `available_providers()`, `list_models(...)` for every kind and filter,
24+
`list_tts_voices(provider=...)`, `compatibility_catalog()`, `capability_support()`,
25+
`find_compatible_models()`, `voice_catalog(providers_only=True)`, and
26+
`voice_catalog(provider=<local>)`. The unfiltered `voice_catalog()`, `list_profiles()`, and
27+
`list_cloned_voices()` report the active engine's live state and still build it; see the cost model
28+
in `docs/api.md`.
29+
- **A local TTS engine is listed when its models are present on the machine.** `audiodit` and
30+
`omnivoice` previously appeared whenever their runtime was importable, even with nothing
31+
downloaded; `piper` and `supertonic` already required a populated cache. Selecting an engine that
32+
has nothing downloaded still works and still downloads on demand with `allow_downloads=True`
33+
only the listing changed. Prefetch an engine (`python -m abstractvoice download --audiodit`) to
34+
have it listed. A checkpoint configured through `ABSTRACTVOICE_TTS_MODEL` counts as present, so
35+
your own finetune is selectable; it applies to the engine named by `ABSTRACTVOICE_TTS_ENGINE`.
36+
- **Remote providers are probed concurrently under one 5-second discovery budget**, instead of one
37+
synthesis-length timeout each in sequence. An unreachable host now costs one pause for the whole
38+
listing. Override the budget with `ABSTRACTVOICE_DISCOVERY_TIMEOUT_S`; the per-request socket
39+
timeout is the smaller of the budget and `ABSTRACTVOICE_REMOTE_TIMEOUT_S`.
40+
41+
### Added
42+
- `abstractvoice.local_models` for reading what is on the machine without loading an engine:
43+
`cached_tts_model_ids(engine)` and `hf_repo_is_cached(model_id)`. `abstractvoice.adapters.tts_piper`
44+
gains `cached_piper_model_ids()` and `cached_piper_voice_profiles()`.
45+
- `voice_catalog()` reports reachability: `unreachable_tts_providers`, plus an `unreachable` flag on
46+
the affected `tts_catalog_by_provider` entry. A provider that did not answer within the budget is
47+
marked rather than reported as a provider with no models, so an unavailable server is
48+
distinguishable from an empty one. The key is absent on catalog paths that contact nobody.
49+
- `docs/troubleshooting.md`, a symptom-oriented page covering install, discovery, audio devices, and
50+
performance; and `CODE_OF_CONDUCT.md`.
51+
1352
### Fixed
14-
- **Capability asset loading survives package-name shadowing (laurent's offline voice outage, 2026-07-17)**: a serving process launched with cwd = the monorepo root resolves `abstractvoice` as a loaderless NAMESPACE package (the repo checkout directory shadows the installed package on `sys.path[0]`), so `pkgutil.get_data("abstractvoice", ...)` returned `None` and every voice call died with `Capability asset not found: abstractvoice/assets/voice_model_capabilities.json` — retried as if transient, burning the effect retry budget on a deterministic import-layout error. `compatibility._load_capability_asset` now resolves the asset relative to the module's own `__file__` first (immune to the shadow: this module was imported from the real install even when the package NAME resolves to the shadow), keeps `pkgutil` as the fallback for non-filesystem installs, and when both fail raises a diagnostic error that names the attempted path and the namespace-shadow condition (`abstractvoice resolved as a NAMESPACE package from [...] — a directory named 'abstractvoice' on sys.path ... is shadowing the installed package`) instead of the honest-but-mute one-liner. Regression pins in `tests/test_compatibility_catalog.py`.
15-
- **Builtin voice profiles survive the same shadow (silent-degradation shape, found hunting the class)**: `voice_profiles.get_builtin_voice_profiles` resolved its JSON assets via `importlib.resources.files("abstractvoice")` — also keyed on the package NAME — and under the cwd shadow returned `[]` silently: the supertonic builtin profiles (incl. the operator's configured `M1`/`M2` voices) vanished with no error (live-verified: 10 profiles from a neutral cwd, 0 under the shadow, 10 after the fix). Now resolves module-`__file__`-relative first with `importlib.resources` as the fallback; genuinely absent engine files still return `[]` (piper has no builtin profiles file — legitimate absence, unchanged). Suite 218 green.
53+
- `voice_catalog(provider="piper")` now includes Piper's voice profiles. Piper ships no packaged
54+
profile asset because its voices are its downloaded files, so both catalog paths read them from the
55+
same cache probe that reports its model ids.
56+
- Capability assets and built-in voice profiles load correctly when a directory named
57+
`abstractvoice` on `sys.path` shadows the installed package — for example a server started with the
58+
working directory set to a monorepo root. Previously voice calls could fail with
59+
`Capability asset not found: abstractvoice/assets/voice_model_capabilities.json`, or built-in
60+
Supertonic profiles (`M1``M5`, `F1``F5`) could come back empty with no error. Both now resolve
61+
their assets relative to the module file, and an unresolvable asset raises an error that names the
62+
shadowing condition.
1663

1764
## [0.10.18] - 2026-06-14
1865

CODE_OF_CONDUCT.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Code of Conduct
2+
3+
## Our Pledge
4+
5+
We want AbstractVoice to be a project that anyone can use, learn from, and contribute to. We pledge
6+
to make participation a harassment-free experience for everyone, regardless of age, body size,
7+
visible or invisible disability, ethnicity, sex characteristics, gender identity and expression,
8+
level of experience, education, socio-economic status, nationality, personal appearance, race,
9+
religion, or sexual identity and orientation.
10+
11+
## Our Standards
12+
13+
Behavior that helps this community:
14+
15+
- Being kind and respectful to other people
16+
- Assuming good faith, and asking before assuming a mistake was careless
17+
- Giving and accepting constructive feedback about the work rather than the person
18+
- Being clear about what you verified and what you are guessing
19+
- Taking responsibility for mistakes, correcting them, and moving on
20+
21+
Behavior that is not acceptable:
22+
23+
- Harassment, insults, or derogatory comments, public or private
24+
- Sexualized language or imagery, and unwelcome sexual attention
25+
- Publishing others' private information without explicit permission
26+
- Sustained disruption of discussions, issues, or reviews
27+
- Other conduct that would reasonably be considered inappropriate in a professional setting
28+
29+
## Scope
30+
31+
This Code of Conduct applies in all project spaces — issues, pull requests, discussions, commit
32+
messages, and documentation — and when an individual is representing the project in public spaces.
33+
34+
## Reporting
35+
36+
Report unacceptable behavior by opening a confidential report through the process in `SECURITY.md`,
37+
or by contacting the maintainer directly through the address listed on the repository's GitHub
38+
profile. Reports are reviewed as promptly as the maintainer's availability allows, and the reporter's
39+
identity is kept confidential.
40+
41+
## Enforcement
42+
43+
Maintainers are responsible for clarifying and enforcing these standards. They may edit, remove, or
44+
reject comments, commits, code, issues, and other contributions that do not align with this Code of
45+
Conduct, and may temporarily or permanently ban any contributor for behavior they judge
46+
inappropriate, threatening, offensive, or harmful.
47+
48+
## Attribution
49+
50+
Adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.

abstractvoice/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
This file is the single source of truth for AbstractVoice releases.
44
"""
55

6-
__version__ = "0.10.18"
6+
__version__ = "0.10.19"

abstractvoice/adapters/tts_piper.py

Lines changed: 82 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@
2626
logger = logging.getLogger(__name__)
2727

2828

29+
def default_piper_model_dir() -> Path:
30+
"""Directory Piper voice files are downloaded into."""
31+
return Path.home() / '.piper' / 'models'
32+
33+
34+
def _piper_model_files(model_dir: Path, model_filename: str) -> tuple[Path, Path]:
35+
"""(weights, config) paths for a Piper voice inside `model_dir`."""
36+
return model_dir / f"{model_filename}.onnx", model_dir / f"{model_filename}.onnx.json"
37+
38+
2939
class PiperTTSAdapter(TTSAdapter):
3040
"""Piper TTS adapter using piper-tts package.
3141
@@ -81,8 +91,7 @@ def __init__(
8191

8292
# Set model directory
8393
if model_dir is None:
84-
home = Path.home()
85-
self._model_dir = home / '.piper' / 'models'
94+
self._model_dir = default_piper_model_dir()
8695
else:
8796
self._model_dir = Path(model_dir)
8897

@@ -160,41 +169,7 @@ def _voice_id_from_hf_path(hf_path: str) -> str:
160169
return parts[2] if len(parts) >= 3 else str(hf_path or "")
161170

162171
def _profile_for_language(self, language: str) -> Optional[VoiceProfile]:
163-
model_info = self.PIPER_MODELS.get(str(language or ""))
164-
if not model_info:
165-
return None
166-
167-
hf_path, model_filename = model_info
168-
voice_id = self._voice_id_from_hf_path(hf_path)
169-
if not voice_id:
170-
return None
171-
172-
cached = False
173-
try:
174-
model_path, config_path = self._get_model_path(str(language))
175-
cached = model_path.exists() and config_path.exists()
176-
except Exception:
177-
cached = False
178-
179-
return VoiceProfile(
180-
engine_id="piper",
181-
profile_id=voice_id,
182-
label=f"Piper {voice_id}",
183-
description=f"Default Piper voice for {language}",
184-
params={
185-
"provider": "piper",
186-
"language": str(language),
187-
"voice": voice_id,
188-
"model": model_filename,
189-
"model_filename": model_filename,
190-
},
191-
tags={
192-
"provider": "piper",
193-
"engine_id": "piper",
194-
"kind": "profile",
195-
"cached": "true" if cached else "false",
196-
},
197-
)
172+
return _piper_voice_profile(self._model_dir, language)
198173

199174
def _profile_candidates_for_language(self, language: str) -> set[str]:
200175
model_info = self.PIPER_MODELS.get(str(language or ""))
@@ -230,10 +205,7 @@ def _get_model_path(self, language: str) -> tuple[Path, Path]:
230205
raise ValueError(f"Unsupported language: {language}")
231206

232207
_, model_filename = model_info
233-
model_path = self._model_dir / f"{model_filename}.onnx"
234-
config_path = self._model_dir / f"{model_filename}.onnx.json"
235-
236-
return model_path, config_path
208+
return _piper_model_files(self._model_dir, model_filename)
237209

238210
def ensure_model_downloaded(self, language: str) -> bool:
239211
"""Explicitly download Piper model files for a language (no implicit calls).
@@ -784,3 +756,72 @@ def _parse_size_mb(size: str) -> int:
784756
}
785757

786758
return models
759+
760+
761+
def _piper_voice_profile(model_dir: Path, language: str) -> Optional[VoiceProfile]:
762+
"""The voice profile for a Piper language, tagged with whether it is cached."""
763+
model_info = PiperTTSAdapter.PIPER_MODELS.get(str(language or ""))
764+
if not model_info:
765+
return None
766+
767+
hf_path, model_filename = model_info
768+
voice_id = PiperTTSAdapter._voice_id_from_hf_path(hf_path)
769+
if not voice_id:
770+
return None
771+
772+
weights, config = _piper_model_files(Path(model_dir), model_filename)
773+
cached = weights.is_file() and config.is_file()
774+
775+
return VoiceProfile(
776+
engine_id="piper",
777+
profile_id=voice_id,
778+
label=f"Piper {voice_id}",
779+
description=f"Default Piper voice for {language}",
780+
params={
781+
"provider": "piper",
782+
"language": str(language),
783+
"voice": voice_id,
784+
"model": model_filename,
785+
"model_filename": model_filename,
786+
},
787+
tags={
788+
"provider": "piper",
789+
"engine_id": "piper",
790+
"kind": "profile",
791+
"cached": "true" if cached else "false",
792+
},
793+
)
794+
795+
796+
def _cached_piper_languages(model_dir: Optional[str]) -> tuple[Path, list[str]]:
797+
"""(voice directory, languages whose weights AND config are both present).
798+
799+
The single presence predicate behind both public probes, so the model ids and
800+
the voice profiles can never disagree about what is downloaded. Filesystem
801+
only: constructing the adapter imports `piper` and through it ONNX Runtime,
802+
which is a synthesis cost, not a discovery cost.
803+
"""
804+
root = Path(model_dir).expanduser() if model_dir else default_piper_model_dir()
805+
languages = [
806+
language
807+
for language, (_hf_path, model_filename) in PiperTTSAdapter.PIPER_MODELS.items()
808+
if all(path.is_file() for path in _piper_model_files(root, model_filename))
809+
]
810+
return root, languages
811+
812+
813+
def cached_piper_model_ids(model_dir: Optional[str] = None) -> list[str]:
814+
"""Piper voice ids whose model files are on this machine."""
815+
_root, languages = _cached_piper_languages(model_dir)
816+
return [PiperTTSAdapter.PIPER_MODELS[language][1] for language in languages]
817+
818+
819+
def cached_piper_voice_profiles(model_dir: Optional[str] = None) -> list[VoiceProfile]:
820+
"""Voice profiles for the Piper voices on this machine.
821+
822+
Piper has no packaged profile asset because its voices *are* its downloaded
823+
files, so the filesystem is the only honest source.
824+
"""
825+
root, languages = _cached_piper_languages(model_dir)
826+
profiles = (_piper_voice_profile(root, language) for language in languages)
827+
return [profile for profile in profiles if profile is not None]

0 commit comments

Comments
 (0)