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.
Files changed (188) hide show
  1. {kenkui-2.3.0/src/kenkui.egg-info → kenkui-2.3.2}/PKG-INFO +1 -1
  2. {kenkui-2.3.0 → kenkui-2.3.2}/pyproject.toml +2 -1
  3. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/api.py +1 -1
  4. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/models/__init__.py +4 -0
  5. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/models/api.py +19 -0
  6. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/application_service.py +42 -0
  7. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/voice_service.py +26 -7
  8. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/voice_registry.py +157 -9
  9. kenkui-2.3.2/src/kenkui/voices/manifest.json +1718 -0
  10. kenkui-2.3.2/src/kenkui/voices/preview_phrases.json +27 -0
  11. {kenkui-2.3.0 → kenkui-2.3.2/src/kenkui.egg-info}/PKG-INFO +1 -1
  12. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui.egg-info/SOURCES.txt +2 -0
  13. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_application_service.py +88 -1
  14. kenkui-2.3.2/tests/test_voice_pack_build.py +339 -0
  15. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_voice_registry.py +90 -1
  16. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_voice_service.py +53 -0
  17. kenkui-2.3.0/tests/test_voice_pack_build.py +0 -112
  18. {kenkui-2.3.0 → kenkui-2.3.2}/LICENSE +0 -0
  19. {kenkui-2.3.0 → kenkui-2.3.2}/MANIFEST.in +0 -0
  20. {kenkui-2.3.0 → kenkui-2.3.2}/README.md +0 -0
  21. {kenkui-2.3.0 → kenkui-2.3.2}/setup.cfg +0 -0
  22. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/__init__.py +0 -0
  23. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/analytics.py +0 -0
  24. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/audio_build.py +0 -0
  25. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/chapter_classifier.py +0 -0
  26. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/chapter_filter.py +0 -0
  27. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/cli.py +0 -0
  28. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/config.py +0 -0
  29. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/errors.py +0 -0
  30. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/file_finder.py +0 -0
  31. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/huggingface_auth.py +0 -0
  32. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/log.py +0 -0
  33. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/model_names.py +0 -0
  34. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/models/audio.py +0 -0
  35. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/models/book.py +0 -0
  36. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/models/common.py +0 -0
  37. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/models/config.py +0 -0
  38. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/models/job.py +0 -0
  39. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/models/nlp.py +0 -0
  40. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/models/queue.py +0 -0
  41. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/__init__.py +0 -0
  42. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/_async.py +0 -0
  43. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/_cache.py +0 -0
  44. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/_filters.py +0 -0
  45. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/_retry.py +0 -0
  46. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/annotator.py +0 -0
  47. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/attribution.py +0 -0
  48. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/booknlp_roster.py +0 -0
  49. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/chunker.py +0 -0
  50. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/entities.py +0 -0
  51. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/llm.py +0 -0
  52. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/models.py +0 -0
  53. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/pipeline.py +0 -0
  54. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/providers/__init__.py +0 -0
  55. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/providers/_base.py +0 -0
  56. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/providers/_factory.py +0 -0
  57. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/providers/base.py +0 -0
  58. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/providers/booknlp.py +0 -0
  59. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/providers/litellm.py +0 -0
  60. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/providers/local.py +0 -0
  61. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/providers/ollama.py +0 -0
  62. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/quotes.py +0 -0
  63. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp/setup.py +0 -0
  64. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/nlp_config.py +0 -0
  65. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/parsing.py +0 -0
  66. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/post_processing.py +0 -0
  67. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/progress.py +0 -0
  68. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/progress_tracking.py +0 -0
  69. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/readers/__init__.py +0 -0
  70. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/readers/_pdf_extract.py +0 -0
  71. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/readers/_pdf_to_epub.py +0 -0
  72. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/readers/epub.py +0 -0
  73. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/readers/fb2.py +0 -0
  74. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/readers/mobi.py +0 -0
  75. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/readers/pdf.py +0 -0
  76. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/samples/Les Miserables - Victor Hugo.epub +0 -0
  77. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/samples/Pride and Predjudice.epub +0 -0
  78. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/samples/__init__.py +0 -0
  79. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/series.py +0 -0
  80. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/server/__init__.py +0 -0
  81. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/server/api.py +0 -0
  82. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/server/openapi.py +0 -0
  83. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/server/server.py +0 -0
  84. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/__init__.py +0 -0
  85. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/auth_service.py +0 -0
  86. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/book_cache.py +0 -0
  87. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/book_service.py +0 -0
  88. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/confirmation_service.py +0 -0
  89. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/download_service.py +0 -0
  90. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/execution_service.py +0 -0
  91. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/job_executor.py +0 -0
  92. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/job_service.py +0 -0
  93. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/nlp_service.py +0 -0
  94. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/openrouter_service.py +0 -0
  95. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/provider_service.py +0 -0
  96. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/queue_manager.py +0 -0
  97. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/runtime_service.py +0 -0
  98. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/series_service.py +0 -0
  99. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/setup_service.py +0 -0
  100. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/task_coordinator.py +0 -0
  101. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/task_service.py +0 -0
  102. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/services/workflow_service.py +0 -0
  103. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/system_check.py +0 -0
  104. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/text_rules.py +0 -0
  105. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/utils.py +0 -0
  106. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/voice_compiler.py +0 -0
  107. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/voice_download.py +0 -0
  108. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/voice_loader.py +0 -0
  109. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/voice_metadata.py +0 -0
  110. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/voice_pool.py +0 -0
  111. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui/workers.py +0 -0
  112. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui.egg-info/dependency_links.txt +0 -0
  113. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui.egg-info/entry_points.txt +0 -0
  114. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui.egg-info/requires.txt +0 -0
  115. {kenkui-2.3.0 → kenkui-2.3.2}/src/kenkui.egg-info/top_level.txt +0 -0
  116. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_analytics.py +0 -0
  117. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_annotator.py +0 -0
  118. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_api_attribution_options.py +0 -0
  119. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_audio_build.py +0 -0
  120. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_auth_service.py +0 -0
  121. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_book_cache.py +0 -0
  122. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_book_service.py +0 -0
  123. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_chapter_classifier.py +0 -0
  124. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_chapter_filter.py +0 -0
  125. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_character_record_dataclass.py +0 -0
  126. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_credential_management.py +0 -0
  127. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_download_service.py +0 -0
  128. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_generation_progress.py +0 -0
  129. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_huggingface_auth.py +0 -0
  130. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_job_executor.py +0 -0
  131. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_library_contracts.py +0 -0
  132. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_modal_runtime_config.py +0 -0
  133. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_modal_runtime_registration.py +0 -0
  134. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_modal_status.py +0 -0
  135. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_model_names.py +0 -0
  136. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_models.py +0 -0
  137. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_multi_voice_models.py +0 -0
  138. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_name_filters.py +0 -0
  139. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_attribution.py +0 -0
  140. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_booknlp_adapters.py +0 -0
  141. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_booknlp_roster.py +0 -0
  142. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_cache.py +0 -0
  143. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_chunker.py +0 -0
  144. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_config.py +0 -0
  145. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_coreference_pierce_brown.py +0 -0
  146. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_entities.py +0 -0
  147. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_factory.py +0 -0
  148. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_italic_dialogue.py +0 -0
  149. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_litellm_adapters.py +0 -0
  150. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_llm_limits.py +0 -0
  151. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_models_enriched.py +0 -0
  152. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_ollama_adapters.py +0 -0
  153. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_pipeline.py +0 -0
  154. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_pipeline_split.py +0 -0
  155. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_providers.py +0 -0
  156. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_providers_base.py +0 -0
  157. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_quotes.py +0 -0
  158. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_quotes_non_dialogue.py +0 -0
  159. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_retry.py +0 -0
  160. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_segments.py +0 -0
  161. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_nlp_service.py +0 -0
  162. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_openrouter_service.py +0 -0
  163. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_optional_dependencies.py +0 -0
  164. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_parsing.py +0 -0
  165. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_pdf_extractor.py +0 -0
  166. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_pdf_reader.py +0 -0
  167. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_pdf_to_epub.py +0 -0
  168. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_post_processing.py +0 -0
  169. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_progress_tracking.py +0 -0
  170. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_provider_service.py +0 -0
  171. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_qp14_efficiency.py +0 -0
  172. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_queue_manager.py +0 -0
  173. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_scare_quote_stripping.py +0 -0
  174. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_series.py +0 -0
  175. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_series_roster.py +0 -0
  176. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_series_service.py +0 -0
  177. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_setup_service.py +0 -0
  178. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_speaker_voices_migration.py +0 -0
  179. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_suggest_cast.py +0 -0
  180. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_task_coordinator.py +0 -0
  181. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_utils.py +0 -0
  182. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_voice_download.py +0 -0
  183. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_voice_loader.py +0 -0
  184. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_voice_metadata.py +0 -0
  185. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_voice_pools.py +0 -0
  186. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_voice_review_ux.py +0 -0
  187. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_workers.py +0 -0
  188. {kenkui-2.3.0 → kenkui-2.3.2}/tests/test_workers_scene_breaks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kenkui
3
- Version: 2.3.0
3
+ Version: 2.3.2
4
4
  Summary: Convert Ebooks to Audiobooks with [custom] voice samples
5
5
  Author-email: Sumner MacArthur <spn1kolat3sla@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kenkui"
7
- version = "2.3.0"
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.0"
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.path:
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(voice_id=voice_id, audio_path=str(path), duration_ms=entry.preview.duration_ms)
229
+ return AudioPreviewResult(
230
+ voice_id=voice_id,
231
+ audio_path=str(path),
232
+ duration_ms=entry.preview.duration_ms,
233
+ )
224
234
 
225
- out_path = preview_cache_dir() / f"{voice_id}.wav"
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(voice_id=voice_id, audio_path=str(out_path), duration_ms=entry.preview.duration_ms)
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(voice_id=voice_id, audio_path=str(out_path), duration_ms=entry.preview.duration_ms)
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, text or entry.preview.text)
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 = "main"
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",