kenkui 2.3.0__tar.gz → 2.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {kenkui-2.3.0/src/kenkui.egg-info → kenkui-2.3.2}/PKG-INFO +1 -1
- {kenkui-2.3.0 → kenkui-2.3.2}/pyproject.toml +2 -1
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/api.py +1 -1
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/models/__init__.py +4 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/models/api.py +19 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/application_service.py +42 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/voice_service.py +26 -7
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/voice_registry.py +157 -9
- kenkui-2.3.2/src/kenkui/voices/manifest.json +1718 -0
- kenkui-2.3.2/src/kenkui/voices/preview_phrases.json +27 -0
- {kenkui-2.3.0 → kenkui-2.3.2/src/kenkui.egg-info}/PKG-INFO +1 -1
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui.egg-info/SOURCES.txt +2 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_application_service.py +88 -1
- kenkui-2.3.2/tests/test_voice_pack_build.py +339 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_voice_registry.py +90 -1
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_voice_service.py +53 -0
- kenkui-2.3.0/tests/test_voice_pack_build.py +0 -112
- {kenkui-2.3.0 → kenkui-2.3.2}/LICENSE +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/MANIFEST.in +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/README.md +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/setup.cfg +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/__init__.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/analytics.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/audio_build.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/chapter_classifier.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/chapter_filter.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/cli.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/config.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/errors.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/file_finder.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/huggingface_auth.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/log.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/model_names.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/models/audio.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/models/book.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/models/common.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/models/config.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/models/job.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/models/nlp.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/models/queue.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/__init__.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/_async.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/_cache.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/_filters.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/_retry.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/annotator.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/attribution.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/booknlp_roster.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/chunker.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/entities.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/llm.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/models.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/pipeline.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/providers/__init__.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/providers/_base.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/providers/_factory.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/providers/base.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/providers/booknlp.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/providers/litellm.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/providers/local.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/providers/ollama.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/quotes.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/setup.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp_config.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/parsing.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/post_processing.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/progress.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/progress_tracking.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/readers/__init__.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/readers/_pdf_extract.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/readers/_pdf_to_epub.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/readers/epub.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/readers/fb2.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/readers/mobi.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/readers/pdf.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/samples/Les Miserables - Victor Hugo.epub +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/samples/Pride and Predjudice.epub +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/samples/__init__.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/series.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/server/__init__.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/server/api.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/server/openapi.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/server/server.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/__init__.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/auth_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/book_cache.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/book_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/confirmation_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/download_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/execution_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/job_executor.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/job_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/nlp_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/openrouter_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/provider_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/queue_manager.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/runtime_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/series_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/setup_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/task_coordinator.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/task_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/workflow_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/system_check.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/text_rules.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/utils.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/voice_compiler.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/voice_download.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/voice_loader.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/voice_metadata.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/voice_pool.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/workers.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui.egg-info/dependency_links.txt +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui.egg-info/entry_points.txt +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui.egg-info/requires.txt +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui.egg-info/top_level.txt +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_analytics.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_annotator.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_api_attribution_options.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_audio_build.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_auth_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_book_cache.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_book_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_chapter_classifier.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_chapter_filter.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_character_record_dataclass.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_credential_management.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_download_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_generation_progress.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_huggingface_auth.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_job_executor.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_library_contracts.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_modal_runtime_config.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_modal_runtime_registration.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_modal_status.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_model_names.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_models.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_multi_voice_models.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_name_filters.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_attribution.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_booknlp_adapters.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_booknlp_roster.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_cache.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_chunker.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_config.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_coreference_pierce_brown.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_entities.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_factory.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_italic_dialogue.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_litellm_adapters.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_llm_limits.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_models_enriched.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_ollama_adapters.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_pipeline.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_pipeline_split.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_providers.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_providers_base.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_quotes.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_quotes_non_dialogue.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_retry.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_segments.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_openrouter_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_optional_dependencies.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_parsing.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_pdf_extractor.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_pdf_reader.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_pdf_to_epub.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_post_processing.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_progress_tracking.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_provider_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_qp14_efficiency.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_queue_manager.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_scare_quote_stripping.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_series.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_series_roster.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_series_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_setup_service.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_speaker_voices_migration.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_suggest_cast.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_task_coordinator.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_utils.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_voice_download.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_voice_loader.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_voice_metadata.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_voice_pools.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_voice_review_ux.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_workers.py +0 -0
- {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_workers_scene_breaks.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kenkui"
|
|
7
|
-
version = "2.3.
|
|
7
|
+
version = "2.3.2"
|
|
8
8
|
description = "Convert Ebooks to Audiobooks with [custom] voice samples"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "Sumner MacArthur", email = "spn1kolat3sla@gmail.com" }]
|
|
@@ -59,6 +59,7 @@ server = ["fastapi>=0.115.0", "uvicorn[standard]>=0.30.0", "httpx>=0.27.0"]
|
|
|
59
59
|
where = ["src"]
|
|
60
60
|
|
|
61
61
|
[tool.setuptools.package-data]
|
|
62
|
+
"kenkui" = ["voices/*.json"]
|
|
62
63
|
"kenkui.samples" = ["*.epub", "*.pdf"]
|
|
63
64
|
|
|
64
65
|
[tool.setuptools.exclude-package-data]
|
|
@@ -119,7 +119,7 @@ from .workers import worker_process_chapter
|
|
|
119
119
|
try:
|
|
120
120
|
__version__ = importlib.metadata.version("kenkui")
|
|
121
121
|
except importlib.metadata.PackageNotFoundError:
|
|
122
|
-
__version__ = "2.3.
|
|
122
|
+
__version__ = "2.3.1"
|
|
123
123
|
|
|
124
124
|
__author__ = "Sumner MacArthur"
|
|
125
125
|
__license__ = "MIT"
|
|
@@ -53,6 +53,8 @@ from .api import (
|
|
|
53
53
|
TaskResponse,
|
|
54
54
|
VoiceListResponse,
|
|
55
55
|
VoicePoolResponse,
|
|
56
|
+
VoicePreviewAssetResponse,
|
|
57
|
+
VoicePreviewPhraseResponse,
|
|
56
58
|
VoiceResponse,
|
|
57
59
|
)
|
|
58
60
|
from .audio import AudioResult, ProcessingConfig
|
|
@@ -148,6 +150,8 @@ __all__ = [
|
|
|
148
150
|
"TTSExecutionMode",
|
|
149
151
|
"VoiceListResponse",
|
|
150
152
|
"VoicePoolResponse",
|
|
153
|
+
"VoicePreviewAssetResponse",
|
|
154
|
+
"VoicePreviewPhraseResponse",
|
|
151
155
|
"VoiceResponse",
|
|
152
156
|
"_migrate_speaker_voices_keys",
|
|
153
157
|
"_normalize_bitrate",
|
|
@@ -188,6 +188,22 @@ class ProviderModelListResponse(BaseModel):
|
|
|
188
188
|
models: list[str]
|
|
189
189
|
|
|
190
190
|
|
|
191
|
+
class VoicePreviewPhraseResponse(BaseModel):
|
|
192
|
+
phrase_id: str
|
|
193
|
+
title: str
|
|
194
|
+
author: str
|
|
195
|
+
text: str
|
|
196
|
+
source_url: str
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class VoicePreviewAssetResponse(BaseModel):
|
|
200
|
+
phrase_id: str
|
|
201
|
+
audio_url: str
|
|
202
|
+
content_type: str
|
|
203
|
+
duration_ms: int | None = None
|
|
204
|
+
sha256: str | None = None
|
|
205
|
+
|
|
206
|
+
|
|
191
207
|
class VoiceResponse(BaseModel):
|
|
192
208
|
name: str
|
|
193
209
|
source: str
|
|
@@ -198,11 +214,14 @@ class VoiceResponse(BaseModel):
|
|
|
198
214
|
description: str
|
|
199
215
|
display_label: str
|
|
200
216
|
excluded: bool
|
|
217
|
+
previews: list[VoicePreviewAssetResponse] = Field(default_factory=list)
|
|
201
218
|
|
|
202
219
|
|
|
203
220
|
class VoiceListResponse(BaseModel):
|
|
204
221
|
voices: list[VoiceResponse]
|
|
205
222
|
total: int
|
|
223
|
+
phrase_catalog: list[VoicePreviewPhraseResponse] = Field(default_factory=list)
|
|
224
|
+
default_phrase_id: str = ""
|
|
206
225
|
|
|
207
226
|
|
|
208
227
|
class AuditionRequest(BaseModel):
|
|
@@ -61,6 +61,8 @@ from kenkui.models.api import (
|
|
|
61
61
|
TaskResponse,
|
|
62
62
|
VoiceListResponse,
|
|
63
63
|
VoicePoolResponse,
|
|
64
|
+
VoicePreviewAssetResponse,
|
|
65
|
+
VoicePreviewPhraseResponse,
|
|
64
66
|
VoiceResponse,
|
|
65
67
|
)
|
|
66
68
|
from kenkui.progress import ProgressEvent
|
|
@@ -115,6 +117,31 @@ def _model_dict(value: Any) -> dict[str, Any] | None:
|
|
|
115
117
|
return {"value": str(value)}
|
|
116
118
|
|
|
117
119
|
|
|
120
|
+
def _voice_preview_responses(voice: Any) -> list[VoicePreviewAssetResponse]:
|
|
121
|
+
from kenkui.voice_registry import PREVIEW_PHRASE_CATALOG
|
|
122
|
+
|
|
123
|
+
previews = [
|
|
124
|
+
VoicePreviewAssetResponse(
|
|
125
|
+
phrase_id=item.phrase_id,
|
|
126
|
+
audio_url=item.audio_url,
|
|
127
|
+
content_type=item.content_type,
|
|
128
|
+
duration_ms=item.duration_ms,
|
|
129
|
+
sha256=item.sha256,
|
|
130
|
+
)
|
|
131
|
+
for item in voice.previews
|
|
132
|
+
]
|
|
133
|
+
if not previews and voice.preview_url:
|
|
134
|
+
content_type = "audio/mpeg" if voice.preview_url.lower().endswith(".mp3") else "audio/wav"
|
|
135
|
+
previews.append(
|
|
136
|
+
VoicePreviewAssetResponse(
|
|
137
|
+
phrase_id=PREVIEW_PHRASE_CATALOG.default_phrase_id,
|
|
138
|
+
audio_url=voice.preview_url,
|
|
139
|
+
content_type=content_type,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
return previews
|
|
143
|
+
|
|
144
|
+
|
|
118
145
|
def _merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
|
119
146
|
result = dict(base)
|
|
120
147
|
for key, value in patch.items():
|
|
@@ -860,8 +887,10 @@ class KenkuiService:
|
|
|
860
887
|
source: str | None = None,
|
|
861
888
|
) -> VoiceListResponse:
|
|
862
889
|
from kenkui.services.voice_service import list_voices
|
|
890
|
+
from kenkui.voice_registry import PREVIEW_PHRASE_CATALOG
|
|
863
891
|
|
|
864
892
|
voices = list_voices(gender=gender, accent=accent, dataset=dataset, origin=source)
|
|
893
|
+
|
|
865
894
|
return VoiceListResponse(
|
|
866
895
|
voices=[
|
|
867
896
|
VoiceResponse(
|
|
@@ -874,10 +903,22 @@ class KenkuiService:
|
|
|
874
903
|
description=v.description,
|
|
875
904
|
display_label=v.display_label,
|
|
876
905
|
excluded=not v.pool_enabled,
|
|
906
|
+
previews=_voice_preview_responses(v),
|
|
877
907
|
)
|
|
878
908
|
for v in voices
|
|
879
909
|
],
|
|
880
910
|
total=len(voices),
|
|
911
|
+
phrase_catalog=[
|
|
912
|
+
VoicePreviewPhraseResponse(
|
|
913
|
+
phrase_id=phrase.phrase_id,
|
|
914
|
+
title=phrase.title,
|
|
915
|
+
author=phrase.author,
|
|
916
|
+
text=phrase.text,
|
|
917
|
+
source_url=phrase.source_url,
|
|
918
|
+
)
|
|
919
|
+
for phrase in PREVIEW_PHRASE_CATALOG.phrases
|
|
920
|
+
],
|
|
921
|
+
default_phrase_id=PREVIEW_PHRASE_CATALOG.default_phrase_id,
|
|
881
922
|
)
|
|
882
923
|
|
|
883
924
|
def get_voice(self, name: str) -> VoiceResponse | None:
|
|
@@ -896,6 +937,7 @@ class KenkuiService:
|
|
|
896
937
|
description=v.description,
|
|
897
938
|
display_label=v.display_label,
|
|
898
939
|
excluded=not v.pool_enabled,
|
|
940
|
+
previews=_voice_preview_responses(v),
|
|
899
941
|
)
|
|
900
942
|
|
|
901
943
|
def set_voice_pool_enabled(self, name: str, enabled: bool) -> VoicePoolResponse:
|
|
@@ -16,6 +16,7 @@ from kenkui.voice_compiler import compile_audio_prompt_source
|
|
|
16
16
|
from kenkui.voice_loader import load_voice_conditioning_source
|
|
17
17
|
from kenkui.voice_registry import (
|
|
18
18
|
PREVIEW_TEXT,
|
|
19
|
+
PreviewAssetInfo,
|
|
19
20
|
VoiceCatalogEntry,
|
|
20
21
|
get_catalog,
|
|
21
22
|
preview_cache_dir,
|
|
@@ -55,6 +56,7 @@ class VoiceInfo:
|
|
|
55
56
|
path: str | None
|
|
56
57
|
preview_path: str | None
|
|
57
58
|
preview_url: str | None
|
|
59
|
+
previews: tuple[PreviewAssetInfo, ...]
|
|
58
60
|
accent: str | None
|
|
59
61
|
dataset: str | None
|
|
60
62
|
speaker_id: str | None
|
|
@@ -102,6 +104,7 @@ def _entry_to_info(v: VoiceCatalogEntry) -> VoiceInfo:
|
|
|
102
104
|
path=str(v.path) if v.path is not None else None,
|
|
103
105
|
preview_path=v.preview.path,
|
|
104
106
|
preview_url=v.preview.url,
|
|
107
|
+
previews=v.previews,
|
|
105
108
|
accent=v.accent,
|
|
106
109
|
dataset=v.dataset,
|
|
107
110
|
speaker_id=v.speaker_id,
|
|
@@ -217,23 +220,39 @@ def prepare_voice_preview(
|
|
|
217
220
|
if entry is None:
|
|
218
221
|
raise KeyError(f"Unknown voice_id: {voice_id}")
|
|
219
222
|
|
|
220
|
-
if entry.preview.
|
|
223
|
+
requested_text = text if text is not None else entry.preview.text
|
|
224
|
+
matches_manifest_preview = requested_text == entry.preview.text
|
|
225
|
+
|
|
226
|
+
if entry.preview.path and matches_manifest_preview:
|
|
221
227
|
path = Path(entry.preview.path)
|
|
222
228
|
if path.exists() and not force:
|
|
223
|
-
return AudioPreviewResult(
|
|
229
|
+
return AudioPreviewResult(
|
|
230
|
+
voice_id=voice_id,
|
|
231
|
+
audio_path=str(path),
|
|
232
|
+
duration_ms=entry.preview.duration_ms,
|
|
233
|
+
)
|
|
224
234
|
|
|
225
|
-
|
|
235
|
+
text_hash = hashlib.sha256(requested_text.encode("utf-8")).hexdigest()[:16]
|
|
236
|
+
out_path = preview_cache_dir() / f"{voice_id}-{text_hash}.wav"
|
|
226
237
|
if out_path.exists() and not force:
|
|
227
|
-
return AudioPreviewResult(
|
|
238
|
+
return AudioPreviewResult(
|
|
239
|
+
voice_id=voice_id,
|
|
240
|
+
audio_path=str(out_path),
|
|
241
|
+
duration_ms=entry.preview.duration_ms if matches_manifest_preview else None,
|
|
242
|
+
)
|
|
228
243
|
|
|
229
|
-
if entry.preview.url:
|
|
244
|
+
if entry.preview.url and matches_manifest_preview:
|
|
230
245
|
try:
|
|
231
246
|
if _download_preview(entry, out_path):
|
|
232
|
-
return AudioPreviewResult(
|
|
247
|
+
return AudioPreviewResult(
|
|
248
|
+
voice_id=voice_id,
|
|
249
|
+
audio_path=str(out_path),
|
|
250
|
+
duration_ms=entry.preview.duration_ms,
|
|
251
|
+
)
|
|
233
252
|
except Exception as exc:
|
|
234
253
|
logger.warning("Failed to download hosted preview for %s: %s", voice_id, exc)
|
|
235
254
|
|
|
236
|
-
_synthesize_preview(entry, out_path,
|
|
255
|
+
_synthesize_preview(entry, out_path, requested_text)
|
|
237
256
|
return AudioPreviewResult(voice_id=voice_id, audio_path=str(out_path), duration_ms=None)
|
|
238
257
|
|
|
239
258
|
|
|
@@ -15,6 +15,7 @@ import re
|
|
|
15
15
|
from dataclasses import asdict, dataclass, field, replace
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from typing import Any, Literal
|
|
18
|
+
from urllib.parse import urlparse
|
|
18
19
|
|
|
19
20
|
from .voice_compiler import is_legacy_audio_prompt_asset
|
|
20
21
|
|
|
@@ -25,16 +26,94 @@ VoiceAssetKind = Literal["pocket_tts_builtin", "safetensors"]
|
|
|
25
26
|
VoiceStatus = Literal["available", "missing", "downloadable"]
|
|
26
27
|
|
|
27
28
|
DEFAULT_VOICE_PACK_REPO = "D1zzl3D0p/kenkui-voices"
|
|
28
|
-
DEFAULT_VOICE_PACK_REVISION = "
|
|
29
|
+
DEFAULT_VOICE_PACK_REVISION = "b513c6aaa350da3fcaff12c9c0afd2a1e97c92d7"
|
|
29
30
|
VOICE_PACK_FORMAT_VERSION = 2
|
|
30
31
|
MANIFEST_FILENAMES = ("manifest.json", "voice_manifest.json")
|
|
31
32
|
CUSTOM_MANIFEST_FILENAME = "custom_manifest.json"
|
|
32
|
-
PREVIEW_TEXT = (
|
|
33
|
-
"The rain in Spain stays mainly in the plain. "
|
|
34
|
-
"How wonderful it is to simply speak and be heard."
|
|
35
|
-
)
|
|
36
33
|
|
|
37
34
|
_VOICE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]*$")
|
|
35
|
+
_PHRASE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class VoiceCatalogError(ValueError):
|
|
39
|
+
"""Raised when voice or preview phrase catalog data is invalid."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class PreviewPhrase:
|
|
44
|
+
phrase_id: str
|
|
45
|
+
title: str
|
|
46
|
+
author: str
|
|
47
|
+
text: str
|
|
48
|
+
source_url: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class PreviewPhraseCatalog:
|
|
53
|
+
version: int
|
|
54
|
+
default_phrase_id: str
|
|
55
|
+
phrases: tuple[PreviewPhrase, ...]
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def default_phrase(self) -> PreviewPhrase:
|
|
59
|
+
return next(phrase for phrase in self.phrases if phrase.phrase_id == self.default_phrase_id)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def preview_phrase_catalog_path() -> Path:
|
|
63
|
+
return Path(__file__).resolve().parent / "voices" / "preview_phrases.json"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_preview_phrase_catalog(path: Path | None = None) -> PreviewPhraseCatalog:
|
|
67
|
+
source = path or preview_phrase_catalog_path()
|
|
68
|
+
try:
|
|
69
|
+
raw = json.loads(source.read_text(encoding="utf-8"))
|
|
70
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
71
|
+
raise VoiceCatalogError(f"Invalid preview phrase catalog {source}") from exc
|
|
72
|
+
if not isinstance(raw, dict):
|
|
73
|
+
raise VoiceCatalogError("Preview phrase catalog must be an object")
|
|
74
|
+
version = raw.get("version")
|
|
75
|
+
if isinstance(version, bool) or not isinstance(version, int) or version <= 0:
|
|
76
|
+
raise VoiceCatalogError("Preview phrase catalog version must be a positive integer")
|
|
77
|
+
default_phrase_id = raw.get("default_phrase_id")
|
|
78
|
+
if not isinstance(default_phrase_id, str) or not _PHRASE_ID_RE.fullmatch(default_phrase_id):
|
|
79
|
+
raise VoiceCatalogError("Preview phrase catalog has invalid default_phrase_id")
|
|
80
|
+
raw_phrases = raw.get("phrases")
|
|
81
|
+
if not isinstance(raw_phrases, list) or not raw_phrases:
|
|
82
|
+
raise VoiceCatalogError("Preview phrase catalog must contain a non-empty phrases list")
|
|
83
|
+
|
|
84
|
+
phrases: list[PreviewPhrase] = []
|
|
85
|
+
seen: set[str] = set()
|
|
86
|
+
for item in raw_phrases:
|
|
87
|
+
if not isinstance(item, dict):
|
|
88
|
+
raise VoiceCatalogError("Each preview phrase must be an object")
|
|
89
|
+
phrase_id = item.get("phrase_id")
|
|
90
|
+
if not isinstance(phrase_id, str) or not _PHRASE_ID_RE.fullmatch(phrase_id):
|
|
91
|
+
raise VoiceCatalogError(f"Invalid phrase_id: {phrase_id!r}")
|
|
92
|
+
if phrase_id in seen:
|
|
93
|
+
raise VoiceCatalogError(f"Duplicate phrase_id {phrase_id!r}")
|
|
94
|
+
seen.add(phrase_id)
|
|
95
|
+
values: dict[str, str] = {}
|
|
96
|
+
for key in ("title", "author", "text", "source_url"):
|
|
97
|
+
value = item.get(key)
|
|
98
|
+
if not isinstance(value, str) or not value.strip():
|
|
99
|
+
raise VoiceCatalogError(f"Preview phrase {phrase_id!r} has invalid {key}")
|
|
100
|
+
values[key] = value.strip()
|
|
101
|
+
parsed_url = urlparse(values["source_url"])
|
|
102
|
+
if parsed_url.scheme != "https" or not parsed_url.netloc:
|
|
103
|
+
raise VoiceCatalogError(f"Preview phrase {phrase_id!r} source_url must use HTTPS")
|
|
104
|
+
phrases.append(PreviewPhrase(phrase_id=phrase_id, **values))
|
|
105
|
+
|
|
106
|
+
if default_phrase_id not in seen:
|
|
107
|
+
raise VoiceCatalogError("Preview phrase catalog default_phrase_id does not identify a phrase")
|
|
108
|
+
return PreviewPhraseCatalog(
|
|
109
|
+
version=version,
|
|
110
|
+
default_phrase_id=default_phrase_id,
|
|
111
|
+
phrases=tuple(phrases),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
PREVIEW_PHRASE_CATALOG = load_preview_phrase_catalog()
|
|
116
|
+
PREVIEW_TEXT = PREVIEW_PHRASE_CATALOG.default_phrase.text
|
|
38
117
|
|
|
39
118
|
_BUILTIN_VOICE_DATA: dict[str, dict[str, str | None]] = {
|
|
40
119
|
"alba": {"gender": "Male", "accent": "American", "dataset": "Alba-Mackenna", "speaker_id": "casual"},
|
|
@@ -68,10 +147,6 @@ _BUILTIN_VOICE_DATA: dict[str, dict[str, str | None]] = {
|
|
|
68
147
|
BUILTIN_VOICE_NAMES: list[str] = list(_BUILTIN_VOICE_DATA)
|
|
69
148
|
|
|
70
149
|
|
|
71
|
-
class VoiceCatalogError(ValueError):
|
|
72
|
-
"""Raised when a voice manifest is invalid."""
|
|
73
|
-
|
|
74
|
-
|
|
75
150
|
@dataclass(frozen=True)
|
|
76
151
|
class PreviewInfo:
|
|
77
152
|
text: str = PREVIEW_TEXT
|
|
@@ -96,6 +171,69 @@ class PreviewInfo:
|
|
|
96
171
|
return {k: v for k, v in asdict(self).items() if v is not None}
|
|
97
172
|
|
|
98
173
|
|
|
174
|
+
@dataclass(frozen=True)
|
|
175
|
+
class PreviewAssetInfo:
|
|
176
|
+
phrase_id: str
|
|
177
|
+
audio_url: str
|
|
178
|
+
content_type: str
|
|
179
|
+
duration_ms: int | None = None
|
|
180
|
+
sha256: str | None = None
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def from_dict(cls, data: dict[str, Any]) -> PreviewAssetInfo:
|
|
184
|
+
phrase_id = str(data.get("phrase_id") or "").strip()
|
|
185
|
+
audio_url = str(data.get("audio_url") or data.get("url") or "").strip()
|
|
186
|
+
content_type = str(data.get("content_type") or "").strip()
|
|
187
|
+
if not phrase_id or not _PHRASE_ID_RE.fullmatch(phrase_id):
|
|
188
|
+
raise VoiceCatalogError(f"Invalid preview phrase_id: {phrase_id!r}")
|
|
189
|
+
parsed_url = urlparse(audio_url)
|
|
190
|
+
if parsed_url.scheme not in ("http", "https") or not parsed_url.netloc:
|
|
191
|
+
raise VoiceCatalogError(f"Invalid preview audio_url: {audio_url!r}")
|
|
192
|
+
if not content_type:
|
|
193
|
+
raise VoiceCatalogError(f"Preview {phrase_id!r} is missing content_type")
|
|
194
|
+
return cls(
|
|
195
|
+
phrase_id=phrase_id,
|
|
196
|
+
audio_url=audio_url,
|
|
197
|
+
content_type=content_type,
|
|
198
|
+
duration_ms=data.get("duration_ms"),
|
|
199
|
+
sha256=data.get("sha256"),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _load_preview_assets(data: dict[str, Any]) -> tuple[PreviewAssetInfo, ...]:
|
|
204
|
+
raw_previews = data.get("previews")
|
|
205
|
+
if raw_previews is not None:
|
|
206
|
+
if not isinstance(raw_previews, list):
|
|
207
|
+
raise VoiceCatalogError("Voice previews must be a list")
|
|
208
|
+
previews = tuple(PreviewAssetInfo.from_dict(item) for item in raw_previews)
|
|
209
|
+
phrase_ids = [preview.phrase_id for preview in previews]
|
|
210
|
+
if len(phrase_ids) != len(set(phrase_ids)):
|
|
211
|
+
raise VoiceCatalogError("Voice previews contain duplicate phrase_id values")
|
|
212
|
+
return previews
|
|
213
|
+
|
|
214
|
+
# Read the singular preview field written by voice-pack manifests before the
|
|
215
|
+
# multi-preview contract. Local path-only previews remain available through
|
|
216
|
+
# PreviewInfo but are not exposed as public URL assets.
|
|
217
|
+
legacy = data.get("preview")
|
|
218
|
+
if isinstance(legacy, dict) and legacy.get("url"):
|
|
219
|
+
url = str(legacy["url"])
|
|
220
|
+
content_type = str(legacy.get("content_type") or "")
|
|
221
|
+
if not content_type:
|
|
222
|
+
content_type = "audio/mpeg" if urlparse(url).path.lower().endswith(".mp3") else "audio/wav"
|
|
223
|
+
return (
|
|
224
|
+
PreviewAssetInfo.from_dict(
|
|
225
|
+
{
|
|
226
|
+
"phrase_id": PREVIEW_PHRASE_CATALOG.default_phrase_id,
|
|
227
|
+
"audio_url": url,
|
|
228
|
+
"content_type": content_type,
|
|
229
|
+
"duration_ms": legacy.get("duration_ms"),
|
|
230
|
+
"sha256": legacy.get("sha256"),
|
|
231
|
+
}
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
return ()
|
|
235
|
+
|
|
236
|
+
|
|
99
237
|
@dataclass(frozen=True)
|
|
100
238
|
class VoiceCatalogEntry:
|
|
101
239
|
voice_id: str
|
|
@@ -107,6 +245,7 @@ class VoiceCatalogEntry:
|
|
|
107
245
|
path: Path | None = None
|
|
108
246
|
status: VoiceStatus = "available"
|
|
109
247
|
preview: PreviewInfo = field(default_factory=PreviewInfo)
|
|
248
|
+
previews: tuple[PreviewAssetInfo, ...] = ()
|
|
110
249
|
accent: str | None = None
|
|
111
250
|
dataset: str | None = None
|
|
112
251
|
speaker_id: str | None = None
|
|
@@ -178,6 +317,7 @@ class VoiceCatalogEntry:
|
|
|
178
317
|
path=path,
|
|
179
318
|
status=status, # type: ignore[arg-type]
|
|
180
319
|
preview=PreviewInfo.from_dict(data.get("preview")),
|
|
320
|
+
previews=_load_preview_assets(data),
|
|
181
321
|
accent=data.get("accent"),
|
|
182
322
|
dataset=data.get("dataset"),
|
|
183
323
|
speaker_id=data.get("speaker_id"),
|
|
@@ -216,6 +356,8 @@ class VoiceCatalogEntry:
|
|
|
216
356
|
preview = self.preview.to_dict()
|
|
217
357
|
if preview:
|
|
218
358
|
data["preview"] = preview
|
|
359
|
+
if self.previews:
|
|
360
|
+
data["previews"] = [asdict(item) for item in self.previews]
|
|
219
361
|
return data
|
|
220
362
|
|
|
221
363
|
|
|
@@ -600,8 +742,12 @@ __all__ = [
|
|
|
600
742
|
"DEFAULT_VOICE_PACK_REPO",
|
|
601
743
|
"DEFAULT_VOICE_PACK_REVISION",
|
|
602
744
|
"VOICE_PACK_FORMAT_VERSION",
|
|
745
|
+
"PREVIEW_PHRASE_CATALOG",
|
|
603
746
|
"PREVIEW_TEXT",
|
|
747
|
+
"PreviewAssetInfo",
|
|
604
748
|
"PreviewInfo",
|
|
749
|
+
"PreviewPhrase",
|
|
750
|
+
"PreviewPhraseCatalog",
|
|
605
751
|
"VoiceCatalog",
|
|
606
752
|
"VoiceCatalogEntry",
|
|
607
753
|
"VoiceCatalogError",
|
|
@@ -612,7 +758,9 @@ __all__ = [
|
|
|
612
758
|
"get_catalog",
|
|
613
759
|
"get_registry",
|
|
614
760
|
"load_manifest",
|
|
761
|
+
"load_preview_phrase_catalog",
|
|
615
762
|
"preview_cache_dir",
|
|
763
|
+
"preview_phrase_catalog_path",
|
|
616
764
|
"validate_manifest",
|
|
617
765
|
"voice_pack_manifest_is_current",
|
|
618
766
|
"verify_manifest_assets",
|