vozonda 0.7.3__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 (265) hide show
  1. vozonda-0.7.3/.env.example +1 -0
  2. vozonda-0.7.3/.gitignore +24 -0
  3. vozonda-0.7.3/PKG-INFO +40 -0
  4. vozonda-0.7.3/pyproject.toml +78 -0
  5. vozonda-0.7.3/src/vozonda_api/__init__.py +5 -0
  6. vozonda-0.7.3/src/vozonda_api/access.py +178 -0
  7. vozonda-0.7.3/src/vozonda_api/audio_source.py +368 -0
  8. vozonda-0.7.3/src/vozonda_api/billing.py +381 -0
  9. vozonda-0.7.3/src/vozonda_api/blossom.py +403 -0
  10. vozonda-0.7.3/src/vozonda_api/budget.py +216 -0
  11. vozonda-0.7.3/src/vozonda_api/clips.py +359 -0
  12. vozonda-0.7.3/src/vozonda_api/condense.py +151 -0
  13. vozonda-0.7.3/src/vozonda_api/config.py +29 -0
  14. vozonda-0.7.3/src/vozonda_api/cover.py +176 -0
  15. vozonda-0.7.3/src/vozonda_api/custom_styles.py +209 -0
  16. vozonda-0.7.3/src/vozonda_api/doctor.py +263 -0
  17. vozonda-0.7.3/src/vozonda_api/env.py +21 -0
  18. vozonda-0.7.3/src/vozonda_api/envfile.py +45 -0
  19. vozonda-0.7.3/src/vozonda_api/fetcher.py +444 -0
  20. vozonda-0.7.3/src/vozonda_api/insights.py +271 -0
  21. vozonda-0.7.3/src/vozonda_api/jobs.py +642 -0
  22. vozonda-0.7.3/src/vozonda_api/length.py +172 -0
  23. vozonda-0.7.3/src/vozonda_api/llms.txt +107 -0
  24. vozonda-0.7.3/src/vozonda_api/main.py +4000 -0
  25. vozonda-0.7.3/src/vozonda_api/mcp_server.py +405 -0
  26. vozonda-0.7.3/src/vozonda_api/music.py +444 -0
  27. vozonda-0.7.3/src/vozonda_api/music_store.py +259 -0
  28. vozonda-0.7.3/src/vozonda_api/nostr_auth.py +421 -0
  29. vozonda-0.7.3/src/vozonda_api/nostr_highlights.py +267 -0
  30. vozonda-0.7.3/src/vozonda_api/nostr_orchestrator.py +862 -0
  31. vozonda-0.7.3/src/vozonda_api/nostr_publish.py +243 -0
  32. vozonda-0.7.3/src/vozonda_api/nostr_relay.py +104 -0
  33. vozonda-0.7.3/src/vozonda_api/nostr_zaps.py +402 -0
  34. vozonda-0.7.3/src/vozonda_api/pipeline.py +3349 -0
  35. vozonda-0.7.3/src/vozonda_api/plugins/__init__.py +25 -0
  36. vozonda-0.7.3/src/vozonda_api/plugins/protocols.py +118 -0
  37. vozonda-0.7.3/src/vozonda_api/plugins/registry.py +275 -0
  38. vozonda-0.7.3/src/vozonda_api/plugins/types.py +74 -0
  39. vozonda-0.7.3/src/vozonda_api/podcast_key.py +230 -0
  40. vozonda-0.7.3/src/vozonda_api/providers/__init__.py +686 -0
  41. vozonda-0.7.3/src/vozonda_api/providers/chatterbox.py +56 -0
  42. vozonda-0.7.3/src/vozonda_api/providers/dia.py +53 -0
  43. vozonda-0.7.3/src/vozonda_api/providers/dia2.py +67 -0
  44. vozonda-0.7.3/src/vozonda_api/providers/higgs.py +62 -0
  45. vozonda-0.7.3/src/vozonda_api/providers/kokoro.py +196 -0
  46. vozonda-0.7.3/src/vozonda_api/providers/magpie.py +103 -0
  47. vozonda-0.7.3/src/vozonda_api/providers/nemo.py +132 -0
  48. vozonda-0.7.3/src/vozonda_api/providers/piper.py +28 -0
  49. vozonda-0.7.3/src/vozonda_api/providers/qwen.py +140 -0
  50. vozonda-0.7.3/src/vozonda_api/providers/script.py +393 -0
  51. vozonda-0.7.3/src/vozonda_api/providers/vibevoice.py +52 -0
  52. vozonda-0.7.3/src/vozonda_api/providers/voxtral.py +29 -0
  53. vozonda-0.7.3/src/vozonda_api/public_address.py +143 -0
  54. vozonda-0.7.3/src/vozonda_api/render_chatterbox.py +242 -0
  55. vozonda-0.7.3/src/vozonda_api/render_dia.py +324 -0
  56. vozonda-0.7.3/src/vozonda_api/render_dia2.py +280 -0
  57. vozonda-0.7.3/src/vozonda_api/render_higgs.py +226 -0
  58. vozonda-0.7.3/src/vozonda_api/render_kokoro.py +251 -0
  59. vozonda-0.7.3/src/vozonda_api/render_magpie.py +160 -0
  60. vozonda-0.7.3/src/vozonda_api/render_piper.py +165 -0
  61. vozonda-0.7.3/src/vozonda_api/render_vibevoice.py +252 -0
  62. vozonda-0.7.3/src/vozonda_api/render_voxtral.py +156 -0
  63. vozonda-0.7.3/src/vozonda_api/render_vozonda.py +141 -0
  64. vozonda-0.7.3/src/vozonda_api/retention.py +111 -0
  65. vozonda-0.7.3/src/vozonda_api/rhythm.py +450 -0
  66. vozonda-0.7.3/src/vozonda_api/rhythm_layer.py +204 -0
  67. vozonda-0.7.3/src/vozonda_api/routers/__init__.py +1 -0
  68. vozonda-0.7.3/src/vozonda_api/routers/billing.py +200 -0
  69. vozonda-0.7.3/src/vozonda_api/routers/distribution.py +223 -0
  70. vozonda-0.7.3/src/vozonda_api/routers/feeds.py +573 -0
  71. vozonda-0.7.3/src/vozonda_api/script_contract.py +172 -0
  72. vozonda-0.7.3/src/vozonda_api/script_lint.py +361 -0
  73. vozonda-0.7.3/src/vozonda_api/settings_store.py +585 -0
  74. vozonda-0.7.3/src/vozonda_api/sources.py +573 -0
  75. vozonda-0.7.3/src/vozonda_api/style_registry.py +791 -0
  76. vozonda-0.7.3/src/vozonda_api/styles.py +43 -0
  77. vozonda-0.7.3/src/vozonda_api/updates.py +96 -0
  78. vozonda-0.7.3/src/vozonda_api/version.py +93 -0
  79. vozonda-0.7.3/src/vozonda_api/voices.py +294 -0
  80. vozonda-0.7.3/src/vozonda_api/watchlist.py +477 -0
  81. vozonda-0.7.3/src/vozonda_api/watchlist_dedupe.py +175 -0
  82. vozonda-0.7.3/src/vozonda_api/watchlist_poller.py +568 -0
  83. vozonda-0.7.3/src/vozonda_api/webhooks.py +120 -0
  84. vozonda-0.7.3/tests/bench_dsp_slicing.py +491 -0
  85. vozonda-0.7.3/tests/bip340_test_vectors.csv +20 -0
  86. vozonda-0.7.3/tests/conftest.py +203 -0
  87. vozonda-0.7.3/tests/fixtures/style_prompts/asmr.json +12 -0
  88. vozonda-0.7.3/tests/fixtures/style_prompts/balanced.json +12 -0
  89. vozonda-0.7.3/tests/fixtures/style_prompts/clash.json +12 -0
  90. vozonda-0.7.3/tests/fixtures/style_prompts/conspiracy.json +15 -0
  91. vozonda-0.7.3/tests/fixtures/style_prompts/courtroom.json +20 -0
  92. vozonda-0.7.3/tests/fixtures/style_prompts/crisis_room.json +20 -0
  93. vozonda-0.7.3/tests/fixtures/style_prompts/debate.json +12 -0
  94. vozonda-0.7.3/tests/fixtures/style_prompts/dude.json +16 -0
  95. vozonda-0.7.3/tests/fixtures/style_prompts/eli5.json +12 -0
  96. vozonda-0.7.3/tests/fixtures/style_prompts/futbol.json +12 -0
  97. vozonda-0.7.3/tests/fixtures/style_prompts/meditation.json +20 -0
  98. vozonda-0.7.3/tests/fixtures/style_prompts/noir.json +20 -0
  99. vozonda-0.7.3/tests/fixtures/style_prompts/sensational.json +12 -0
  100. vozonda-0.7.3/tests/fixtures/style_prompts/serious.json +12 -0
  101. vozonda-0.7.3/tests/fixtures/style_prompts/slang.json +12 -0
  102. vozonda-0.7.3/tests/fixtures/style_prompts/socrates.json +15 -0
  103. vozonda-0.7.3/tests/fixtures/style_prompts/storyteller.json +12 -0
  104. vozonda-0.7.3/tests/fixtures/style_prompts/tech_roast.json +20 -0
  105. vozonda-0.7.3/tests/fixtures/style_prompts/trivia.json +20 -0
  106. vozonda-0.7.3/tests/fixtures/style_prompts/true_crime.json +20 -0
  107. vozonda-0.7.3/tests/test_agent_docs.py +208 -0
  108. vozonda-0.7.3/tests/test_agent_webhooks.py +532 -0
  109. vozonda-0.7.3/tests/test_ai_disclosure.py +235 -0
  110. vozonda-0.7.3/tests/test_api_feed.py +176 -0
  111. vozonda-0.7.3/tests/test_api_peaks_and_dedupe.py +82 -0
  112. vozonda-0.7.3/tests/test_api_source.py +28 -0
  113. vozonda-0.7.3/tests/test_api_watchlist.py +204 -0
  114. vozonda-0.7.3/tests/test_audio_sources.py +590 -0
  115. vozonda-0.7.3/tests/test_audio_upload_detect.py +180 -0
  116. vozonda-0.7.3/tests/test_bench_metrics.py +270 -0
  117. vozonda-0.7.3/tests/test_bench_report.py +454 -0
  118. vozonda-0.7.3/tests/test_bench_run.py +369 -0
  119. vozonda-0.7.3/tests/test_billing.py +238 -0
  120. vozonda-0.7.3/tests/test_billing_read_auth.py +273 -0
  121. vozonda-0.7.3/tests/test_billing_router.py +239 -0
  122. vozonda-0.7.3/tests/test_blossom.py +261 -0
  123. vozonda-0.7.3/tests/test_brand_assets.py +82 -0
  124. vozonda-0.7.3/tests/test_budget.py +213 -0
  125. vozonda-0.7.3/tests/test_cancel_kills_renderer.py +275 -0
  126. vozonda-0.7.3/tests/test_changelog_link.py +48 -0
  127. vozonda-0.7.3/tests/test_chat_completion_retry.py +62 -0
  128. vozonda-0.7.3/tests/test_chatterbox_provider.py +139 -0
  129. vozonda-0.7.3/tests/test_citations.py +270 -0
  130. vozonda-0.7.3/tests/test_clips.py +157 -0
  131. vozonda-0.7.3/tests/test_combine_sources.py +79 -0
  132. vozonda-0.7.3/tests/test_compose_multisource_contract.py +66 -0
  133. vozonda-0.7.3/tests/test_condense.py +239 -0
  134. vozonda-0.7.3/tests/test_config_paths.py +79 -0
  135. vozonda-0.7.3/tests/test_custom_styles.py +220 -0
  136. vozonda-0.7.3/tests/test_custom_styles_ui.py +48 -0
  137. vozonda-0.7.3/tests/test_default_cast.py +44 -0
  138. vozonda-0.7.3/tests/test_dia2_provider.py +133 -0
  139. vozonda-0.7.3/tests/test_dia_provider.py +170 -0
  140. vozonda-0.7.3/tests/test_digest_pipeline.py +274 -0
  141. vozonda-0.7.3/tests/test_digest_titles.py +41 -0
  142. vozonda-0.7.3/tests/test_distribution.py +498 -0
  143. vozonda-0.7.3/tests/test_distribution_ui_text.py +225 -0
  144. vozonda-0.7.3/tests/test_dockerfile_qwen_optional.py +61 -0
  145. vozonda-0.7.3/tests/test_docs_links.py +20 -0
  146. vozonda-0.7.3/tests/test_doctor_llm_chain.py +58 -0
  147. vozonda-0.7.3/tests/test_doctor_tts_engine.py +98 -0
  148. vozonda-0.7.3/tests/test_empty_custom_prompt.py +44 -0
  149. vozonda-0.7.3/tests/test_engine_listing.py +91 -0
  150. vozonda-0.7.3/tests/test_episode_length.py +221 -0
  151. vozonda-0.7.3/tests/test_episode_title.py +348 -0
  152. vozonda-0.7.3/tests/test_export_public.py +70 -0
  153. vozonda-0.7.3/tests/test_feed_router.py +119 -0
  154. vozonda-0.7.3/tests/test_fetch_kind.py +83 -0
  155. vozonda-0.7.3/tests/test_fetch_pdf_limit.py +20 -0
  156. vozonda-0.7.3/tests/test_fetcher.py +176 -0
  157. vozonda-0.7.3/tests/test_focus.py +46 -0
  158. vozonda-0.7.3/tests/test_hallucination_guard.py +379 -0
  159. vozonda-0.7.3/tests/test_higgs_provider.py +117 -0
  160. vozonda-0.7.3/tests/test_host_names.py +33 -0
  161. vozonda-0.7.3/tests/test_isolation_guard.py +100 -0
  162. vozonda-0.7.3/tests/test_job_language_names.py +40 -0
  163. vozonda-0.7.3/tests/test_job_og_image.py +39 -0
  164. vozonda-0.7.3/tests/test_job_show_link.py +47 -0
  165. vozonda-0.7.3/tests/test_jobs_store.py +111 -0
  166. vozonda-0.7.3/tests/test_js_budget.py +239 -0
  167. vozonda-0.7.3/tests/test_kimi_nim.py +70 -0
  168. vozonda-0.7.3/tests/test_kokoro_default.py +250 -0
  169. vozonda-0.7.3/tests/test_kokoro_provider.py +109 -0
  170. vozonda-0.7.3/tests/test_length_language.py +144 -0
  171. vozonda-0.7.3/tests/test_llm_engines_generic.py +142 -0
  172. vozonda-0.7.3/tests/test_magpie_provider.py +91 -0
  173. vozonda-0.7.3/tests/test_master_reach.py +89 -0
  174. vozonda-0.7.3/tests/test_mcp_server.py +251 -0
  175. vozonda-0.7.3/tests/test_mcp_watchlist_tools.py +318 -0
  176. vozonda-0.7.3/tests/test_mixed_sources.py +482 -0
  177. vozonda-0.7.3/tests/test_mixed_sources_ui.py +373 -0
  178. vozonda-0.7.3/tests/test_music_beds.py +343 -0
  179. vozonda-0.7.3/tests/test_music_import.py +126 -0
  180. vozonda-0.7.3/tests/test_no_silent_exceptions.py +118 -0
  181. vozonda-0.7.3/tests/test_no_unused_css.py +120 -0
  182. vozonda-0.7.3/tests/test_nostr_delete_and_retry.py +145 -0
  183. vozonda-0.7.3/tests/test_nostr_endpoints.py +462 -0
  184. vozonda-0.7.3/tests/test_nostr_highlights.py +223 -0
  185. vozonda-0.7.3/tests/test_nostr_orchestrator.py +466 -0
  186. vozonda-0.7.3/tests/test_nostr_publish.py +372 -0
  187. vozonda-0.7.3/tests/test_nostr_publish_guards.py +170 -0
  188. vozonda-0.7.3/tests/test_nostr_relay_protocol.py +110 -0
  189. vozonda-0.7.3/tests/test_nostr_settings.py +168 -0
  190. vozonda-0.7.3/tests/test_nostr_signature.py +86 -0
  191. vozonda-0.7.3/tests/test_nostr_source.py +15 -0
  192. vozonda-0.7.3/tests/test_nostr_zaps.py +287 -0
  193. vozonda-0.7.3/tests/test_opencode_writer.py +133 -0
  194. vozonda-0.7.3/tests/test_oss_hygiene.py +123 -0
  195. vozonda-0.7.3/tests/test_outline_mode.py +71 -0
  196. vozonda-0.7.3/tests/test_pacing.py +39 -0
  197. vozonda-0.7.3/tests/test_piper_voices.py +81 -0
  198. vozonda-0.7.3/tests/test_plugin_registry.py +348 -0
  199. vozonda-0.7.3/tests/test_plugins_health_matches_providers.py +31 -0
  200. vozonda-0.7.3/tests/test_podcast_key.py +372 -0
  201. vozonda-0.7.3/tests/test_podcasting20.py +165 -0
  202. vozonda-0.7.3/tests/test_private_feeds.py +145 -0
  203. vozonda-0.7.3/tests/test_provider_seams.py +143 -0
  204. vozonda-0.7.3/tests/test_public_scan.py +229 -0
  205. vozonda-0.7.3/tests/test_public_url.py +57 -0
  206. vozonda-0.7.3/tests/test_published_loopback.py +34 -0
  207. vozonda-0.7.3/tests/test_queue_snapshot.py +124 -0
  208. vozonda-0.7.3/tests/test_quickstart_config.py +231 -0
  209. vozonda-0.7.3/tests/test_reach_model.py +258 -0
  210. vozonda-0.7.3/tests/test_reach_ui_address.py +85 -0
  211. vozonda-0.7.3/tests/test_reach_ui_cards.py +89 -0
  212. vozonda-0.7.3/tests/test_readable_id.py +29 -0
  213. vozonda-0.7.3/tests/test_remote_access.py +169 -0
  214. vozonda-0.7.3/tests/test_rename_job.py +132 -0
  215. vozonda-0.7.3/tests/test_rename_protocol.py +119 -0
  216. vozonda-0.7.3/tests/test_retention.py +70 -0
  217. vozonda-0.7.3/tests/test_review_default.py +90 -0
  218. vozonda-0.7.3/tests/test_rhythm_layer.py +158 -0
  219. vozonda-0.7.3/tests/test_rhythm_profiles.py +231 -0
  220. vozonda-0.7.3/tests/test_script_contract.py +82 -0
  221. vozonda-0.7.3/tests/test_script_cut_off.py +24 -0
  222. vozonda-0.7.3/tests/test_script_form_check.py +43 -0
  223. vozonda-0.7.3/tests/test_script_json_retry.py +223 -0
  224. vozonda-0.7.3/tests/test_script_parser.py +243 -0
  225. vozonda-0.7.3/tests/test_script_provider.py +362 -0
  226. vozonda-0.7.3/tests/test_script_review.py +184 -0
  227. vozonda-0.7.3/tests/test_script_turns.py +40 -0
  228. vozonda-0.7.3/tests/test_security_fixes.py +126 -0
  229. vozonda-0.7.3/tests/test_settings_roundtrip.py +26 -0
  230. vozonda-0.7.3/tests/test_settings_secrets.py +23 -0
  231. vozonda-0.7.3/tests/test_settings_validation.py +70 -0
  232. vozonda-0.7.3/tests/test_show_nostr_optin.py +73 -0
  233. vozonda-0.7.3/tests/test_show_numbers.py +59 -0
  234. vozonda-0.7.3/tests/test_shows_current_removed.py +16 -0
  235. vozonda-0.7.3/tests/test_source_detection.py +72 -0
  236. vozonda-0.7.3/tests/test_sources.py +345 -0
  237. vozonda-0.7.3/tests/test_src_required.py +125 -0
  238. vozonda-0.7.3/tests/test_ssrf_redirects.py +59 -0
  239. vozonda-0.7.3/tests/test_style_golden.py +219 -0
  240. vozonda-0.7.3/tests/test_style_registry.py +303 -0
  241. vozonda-0.7.3/tests/test_style_ui_from_meta.py +141 -0
  242. vozonda-0.7.3/tests/test_styles.py +133 -0
  243. vozonda-0.7.3/tests/test_text_title.py +26 -0
  244. vozonda-0.7.3/tests/test_thinking_off.py +14 -0
  245. vozonda-0.7.3/tests/test_tray_allocation.py +160 -0
  246. vozonda-0.7.3/tests/test_tray_privacy.py +122 -0
  247. vozonda-0.7.3/tests/test_tray_settings_ui.py +51 -0
  248. vozonda-0.7.3/tests/test_tts_engine_env.py +19 -0
  249. vozonda-0.7.3/tests/test_tts_engine_registry.py +69 -0
  250. vozonda-0.7.3/tests/test_ui_spinner_cancel.py +98 -0
  251. vozonda-0.7.3/tests/test_update_check.py +91 -0
  252. vozonda-0.7.3/tests/test_v4v_defaults.py +107 -0
  253. vozonda-0.7.3/tests/test_vibevoice_provider.py +118 -0
  254. vozonda-0.7.3/tests/test_vite_proxy_paths.py +28 -0
  255. vozonda-0.7.3/tests/test_voice_auto_language.py +56 -0
  256. vozonda-0.7.3/tests/test_voice_profiles.py +158 -0
  257. vozonda-0.7.3/tests/test_voice_samples.py +130 -0
  258. vozonda-0.7.3/tests/test_watchlist_dedupe.py +763 -0
  259. vozonda-0.7.3/tests/test_watchlist_schedule.py +704 -0
  260. vozonda-0.7.3/tests/test_watchlist_show.py +279 -0
  261. vozonda-0.7.3/tests/test_watchlist_template_text.py +68 -0
  262. vozonda-0.7.3/tests/test_web_proxy_covers_api.py +22 -0
  263. vozonda-0.7.3/tests/test_wpm_calibration.py +24 -0
  264. vozonda-0.7.3/tests/test_write_auth.py +55 -0
  265. vozonda-0.7.3/uv.lock +4252 -0
@@ -0,0 +1 @@
1
+ VOZONDA_HF_HOME=/ai/models
@@ -0,0 +1,24 @@
1
+ node_modules/
2
+ **/node_modules/
3
+ dist/
4
+ __pycache__/
5
+ *.pyc
6
+ .venv/
7
+ **/.venv/
8
+ data/
9
+ *.db
10
+ .DS_Store
11
+ apps/web/public/demo/*.mp3
12
+
13
+ # caches
14
+ test-results/
15
+ .pytest_cache/
16
+ .ruff_cache/
17
+ .mypy_cache/
18
+ .env
19
+ *.env
20
+ .aider*
21
+
22
+ # Internal working docs live outside this repo (symlinked on the maintainer machine)
23
+ docs/archive
24
+ opencode.json
vozonda-0.7.3/PKG-INFO ADDED
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.5
2
+ Name: vozonda
3
+ Version: 0.7.3
4
+ Summary: Vozonda orchestrator: url to two-voice podcast dialogue
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: coincurve>=21.0.0
7
+ Requires-Dist: fastapi>=0.115
8
+ Requires-Dist: httpx>=0.28
9
+ Requires-Dist: lxml-html-clean>=0.4
10
+ Requires-Dist: pillow>=10
11
+ Requires-Dist: pydantic>=2.10
12
+ Requires-Dist: readability-lxml>=0.8.4.1
13
+ Requires-Dist: uvicorn[standard]>=0.34
14
+ Provides-Extra: mcp
15
+ Requires-Dist: mcp>=1.9; extra == 'mcp'
16
+ Provides-Extra: stt
17
+ Requires-Dist: av<19,>=11; extra == 'stt'
18
+ Requires-Dist: faster-whisper>=1.1; extra == 'stt'
19
+ Provides-Extra: tts
20
+ Requires-Dist: av<19,>=11; extra == 'tts'
21
+ Requires-Dist: faster-whisper>=1.1; extra == 'tts'
22
+ Requires-Dist: kokoro-onnx>=0.4; extra == 'tts'
23
+ Requires-Dist: numpy>=1.26; extra == 'tts'
24
+ Requires-Dist: numpy>=1.26.0; extra == 'tts'
25
+ Requires-Dist: piper-tts>=1.2.0; extra == 'tts'
26
+ Requires-Dist: qwen-tts>=0.1.0; extra == 'tts'
27
+ Requires-Dist: soundfile>=0.12; extra == 'tts'
28
+ Requires-Dist: soundfile>=0.12.0; extra == 'tts'
29
+ Requires-Dist: torch>=2.2.0; extra == 'tts'
30
+ Provides-Extra: tts-kokoro
31
+ Requires-Dist: kokoro-onnx>=0.4; extra == 'tts-kokoro'
32
+ Requires-Dist: numpy>=1.26; extra == 'tts-kokoro'
33
+ Requires-Dist: soundfile>=0.12; extra == 'tts-kokoro'
34
+ Provides-Extra: tts-piper
35
+ Requires-Dist: piper-tts>=1.2.0; extra == 'tts-piper'
36
+ Provides-Extra: tts-qwen
37
+ Requires-Dist: numpy>=1.26.0; extra == 'tts-qwen'
38
+ Requires-Dist: qwen-tts>=0.1.0; extra == 'tts-qwen'
39
+ Requires-Dist: soundfile>=0.12.0; extra == 'tts-qwen'
40
+ Requires-Dist: torch>=2.2.0; extra == 'tts-qwen'
@@ -0,0 +1,78 @@
1
+ [project]
2
+ name = "vozonda"
3
+ version = "0.7.3"
4
+ description = "Vozonda orchestrator: url to two-voice podcast dialogue"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "fastapi>=0.115",
8
+ "uvicorn[standard]>=0.34",
9
+ "httpx>=0.28",
10
+ "pydantic>=2.10",
11
+ "readability-lxml>=0.8.4.1",
12
+ "lxml_html_clean>=0.4",
13
+ "coincurve>=21.0.0",
14
+ "pillow>=10",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ tts-qwen = [
19
+ "torch>=2.2.0",
20
+ "soundfile>=0.12.0",
21
+ "numpy>=1.26.0",
22
+ "qwen-tts>=0.1.0",
23
+ ]
24
+ tts-piper = [
25
+ "piper-tts>=1.2.0",
26
+ ]
27
+ tts-kokoro = [
28
+ "kokoro-onnx>=0.4",
29
+ "soundfile>=0.12",
30
+ "numpy>=1.26",
31
+ ]
32
+ # speech to text for audio sources (tray: audio files and podcast episodes)
33
+ stt = [
34
+ "faster-whisper>=1.1",
35
+ # faster-whisper 1.2.1 calls av.open(metadata_errors=...), which PyAV 19 removed
36
+ "av>=11,<19",
37
+ ]
38
+ tts = ["vozonda[tts-qwen]", "vozonda[tts-piper]", "vozonda[tts-kokoro]", "vozonda[stt]"]
39
+ mcp = [
40
+ "mcp>=1.9",
41
+ ]
42
+
43
+ [tool.uv]
44
+ package = true
45
+
46
+ [build-system]
47
+ requires = ["hatchling"]
48
+ build-backend = "hatchling.build"
49
+
50
+ [dependency-groups]
51
+ dev = [
52
+ "mypy>=2.3.1",
53
+ "pytest>=9.1.1",
54
+ "pytest-asyncio>=0.24.0",
55
+ "pytest-xdist>=3.5.0",
56
+ "ruff>=0.16.4",
57
+ ]
58
+
59
+ [tool.hatch.build.targets.wheel]
60
+ packages = ["src/vozonda_api"]
61
+
62
+ [tool.pytest.ini_options]
63
+ testpaths = ["tests"]
64
+ filterwarnings = [
65
+ "ignore:.*starlette\\.testclient.*",
66
+ "ignore:.*httpx.*",
67
+ "ignore::DeprecationWarning",
68
+ ]
69
+
70
+ [project.scripts]
71
+ vozonda = "vozonda_api.main:serve"
72
+ vozonda-api = "vozonda_api.main:serve"
73
+
74
+ [tool.ruff]
75
+ line-length = 120
76
+
77
+ [tool.ruff.lint]
78
+ ignore = ["BLE001", "S112", "S110"]
@@ -0,0 +1,5 @@
1
+ """Vozonda API package."""
2
+
3
+ from .version import BASE_VERSION, DISPLAY_VERSION, GIT_REV, __version__, get_version_info
4
+
5
+ __all__ = ["BASE_VERSION", "DISPLAY_VERSION", "GIT_REV", "__version__", "get_version_info"]
@@ -0,0 +1,178 @@
1
+ """Who may read and write what once Vozonda is reachable from outside the host.
2
+
3
+ Local use (the default: the API and the web UI published on 127.0.0.1 only) stays open, exactly as before:
4
+ nobody else can reach it. A request counts as *remote* when
5
+
6
+ - the install says it is exposed (VOZONDA_HOST is not a loopback address and the port is not published on
7
+ loopback only), or
8
+ - it came through a reverse proxy: it carries X-Forwarded-For or Forwarded. A proxy in front of the web UI
9
+ (Caddy, nginx, Tailscale serve, a tunnel) adds them; the bundled web container and the Vite preview pass on
10
+ only what they received, plus the client address when it is not loopback, so a local browser sends none.
11
+ A client cannot make itself local by sending fewer headers than its proxy adds; or
12
+ - it was addressed by a name other than a loopback one (Host header): a proxy that keeps the original host,
13
+ or the LAN and VPN address of the machine. VOZONDA_LOCAL_HOSTNAMES adds names that count as local.
14
+
15
+ Remote requests are denied by default (GHSA-crq5-73gf-fv2h). They pass when
16
+
17
+ - they carry the VOZONDA_TOKEN: as `Authorization: Bearer`, or as the session cookie the web UI gets once from
18
+ POST /auth/session (HttpOnly, SameSite=Strict), so the audio player and live updates work without headers;
19
+ - the path is public by nature (health, voice probes, intro music, default artwork, the login itself);
20
+ - the path is a feed (the feed checks its own key) or an episode's media or share page, and the request carries
21
+ the feed key (?key=, as the links in a private feed do) or the episode is public: its show's feed is public
22
+ or it was published to Nostr.
23
+
24
+ Without a configured token, remote requests that need one get 503 with a hint, like writes always did.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import hmac
30
+ import re
31
+ import sqlite3
32
+ from contextvars import ContextVar
33
+
34
+ from fastapi import Request
35
+
36
+ from .env import env
37
+
38
+ # Set per request by the access middleware in main.py, read by require_write_auth, so the many existing
39
+ # require_write_auth(authorization) calls keep their signature.
40
+ request_remote: ContextVar[bool] = ContextVar("vozonda_request_remote", default=False)
41
+ request_authed: ContextVar[bool] = ContextVar("vozonda_request_authed", default=False)
42
+
43
+ SESSION_COOKIE = "vozonda_session"
44
+ SESSION_MAX_AGE = 30 * 24 * 3600
45
+ _LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"}
46
+
47
+ # Always public: nothing private behind them.
48
+ _PUBLIC_EXACT = {"/health", "/og-default.png", "/img/og-default.png", "/llms.txt", "/styles/custom",
49
+ "/auth/session", "/auth/nostr/challenge"}
50
+ _PUBLIC_PREFIXES = ("/music/", "/audio/probe-")
51
+ _FEED = re.compile(r"^/(?:feed\.xml|[^/]+/[^/]+/feed\.xml)$")
52
+ # Paths that belong to one episode; group 1 is the file or id that starts with the job id.
53
+ _EPISODE_PATHS = [
54
+ re.compile(r"^/audio/(.+)\.mp3$"),
55
+ re.compile(r"^/audio/(.+)\.peaks\.json$"),
56
+ re.compile(r"^/vtt/(.+)\.vtt$"),
57
+ re.compile(r"^/srt/(.+)\.srt$"),
58
+ re.compile(r"^/e/([^/]+)(?:/clip/.*)?$"),
59
+ re.compile(r"^/share/([^/]+)$"),
60
+ re.compile(r"^/source/([^/]+)$"),
61
+ re.compile(r"^/img/(.+)$"),
62
+ ]
63
+
64
+
65
+ def install_exposed() -> bool:
66
+ """The install itself listens beyond loopback (or bills money): every request is remote."""
67
+ billing = env("ENABLE_BILLING", "false").strip().lower() == "true"
68
+ exposed = env("HOST", "127.0.0.1").strip() not in _LOOPBACK_HOSTS
69
+ if env("PUBLISHED_ON", "").strip().lower() == "loopback":
70
+ exposed = False
71
+ return billing or exposed
72
+
73
+
74
+ def _host_name(host: str) -> str:
75
+ host = host.strip().lower()
76
+ if host.startswith("["): # [::1]:8787
77
+ return host[1:host.find("]")] if "]" in host else host
78
+ return host.rsplit(":", 1)[0] if host.count(":") == 1 else host
79
+
80
+
81
+ def is_remote(request: Request) -> bool:
82
+ if install_exposed():
83
+ return True
84
+ h = request.headers
85
+ if h.get("x-forwarded-for", "").strip() or h.get("forwarded", "").strip():
86
+ return True
87
+ host = _host_name(h.get("host", ""))
88
+ extra = {n.strip().lower() for n in env("LOCAL_HOSTNAMES", "").split(",") if n.strip()}
89
+ return bool(host) and host not in _LOOPBACK_HOSTS and host not in extra
90
+
91
+
92
+ def token() -> str:
93
+ return env("TOKEN", "")
94
+
95
+
96
+ def session_value(tok: str) -> str:
97
+ """Cookie value derived from the token: changing VOZONDA_TOKEN ends every session."""
98
+ return hmac.new(tok.encode(), b"vozonda-session-v1", hashlib.sha256).hexdigest()
99
+
100
+
101
+ def is_authed(request: Request) -> bool:
102
+ tok = token()
103
+ if not tok:
104
+ return False
105
+ auth = request.headers.get("authorization") or ""
106
+ if hmac.compare_digest(auth.encode(), f"Bearer {tok}".encode()):
107
+ return True
108
+ cookie = request.cookies.get(SESSION_COOKIE) or ""
109
+ return bool(cookie) and hmac.compare_digest(cookie.encode(), session_value(tok).encode())
110
+
111
+
112
+ def is_public_path(path: str) -> bool:
113
+ return path in _PUBLIC_EXACT or path.startswith(_PUBLIC_PREFIXES)
114
+
115
+
116
+ def is_feed_path(path: str) -> bool:
117
+ return bool(_FEED.match(path))
118
+
119
+
120
+ def episode_ref(path: str) -> str | None:
121
+ """The file or id an episode path names (its job id plus a suffix), or None if it is no episode path."""
122
+ for rx in _EPISODE_PATHS:
123
+ m = rx.match(path)
124
+ if m:
125
+ return m.group(1)
126
+ return None
127
+
128
+
129
+ def _job_for_ref(ref: str) -> dict | None:
130
+ """The job a media name belongs to: the name itself, the part before -clip-, or a cover name's prefix."""
131
+ from .main import store
132
+
133
+ candidates = [ref, ref.split("-clip-")[0]]
134
+ stem = ref.rsplit(".", 1)[0]
135
+ candidates += [stem, stem.rsplit("-", 1)[0]]
136
+ for c in dict.fromkeys(candidates):
137
+ try:
138
+ return store.get(c)
139
+ except KeyError:
140
+ continue
141
+ return None
142
+
143
+
144
+ def feed_key_ok(request: Request) -> bool:
145
+ from .settings_store import get_private_feed_key
146
+
147
+ key = request.query_params.get("key") or ""
148
+ return bool(key) and hmac.compare_digest(key.encode(), get_private_feed_key().encode())
149
+
150
+
151
+ def job_is_public(job: dict) -> bool:
152
+ """Public when its show is public with RSS on (the feeds' rule, feeds._public_checker) or it was
153
+ published to Nostr."""
154
+ from .jobs import DB_PATH
155
+ from .routers.feeds import _public_checker
156
+
157
+ # the same rule as the feeds (feeds._job_show_num + _feed_is_public), so links and gate agree
158
+ if _public_checker()(job):
159
+ return True
160
+ try:
161
+ with sqlite3.connect(DB_PATH) as c:
162
+ row = c.execute("SELECT 1 FROM nostr_publish WHERE job_id = ? AND kind = 54 LIMIT 1",
163
+ (job.get("id"),)).fetchone()
164
+ return row is not None
165
+ except sqlite3.Error:
166
+ return False
167
+
168
+
169
+ def episode_media_allowed(request: Request, ref: str) -> bool:
170
+ if feed_key_ok(request):
171
+ return True
172
+ job = _job_for_ref(ref)
173
+ if job is None:
174
+ # watchlist show artwork is public; anything else unknown is not
175
+ return ref.startswith("watchlist-")
176
+ return job_is_public(job)
177
+
178
+
@@ -0,0 +1,368 @@
1
+ """Audio transcription and punctuation restoration.
2
+
3
+ Transcribes audio files (mp3, m4a, wav, ogg, opus) and podcast enclosures
4
+ using faster-whisper, then restores punctuation with the local LLM punctuate
5
+ pattern with word-for-word validation.
6
+
7
+ Privacy: audio bytes are never written to disk in test paths; only the
8
+ transcribed text is kept.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import contextlib
15
+ import json
16
+ import logging
17
+ import os
18
+ import re
19
+ import subprocess
20
+ import sys
21
+ import tempfile
22
+ from typing import Any
23
+
24
+ import httpx
25
+
26
+ from .env import env
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Audio extensions that the source tray accepts.
31
+ AUDIO_EXTS = (".mp3", ".m4a", ".wav", ".ogg", ".opus")
32
+ # Maximum audio duration we will transcribe (seconds).
33
+ MAX_AUDIO_DURATION_S = 3600 # 1 hour
34
+
35
+ # Whisper model from env (default 'base').
36
+ WHISPER_MODEL = env("WHISPER_MODEL", "base")
37
+
38
+ # Punctuation chunk size (approximate words per chunk).
39
+ PUNCTUATION_CHUNK_WORDS = 300
40
+ # local servers (vLLM, Ollama) batch parallel requests; 4 keeps a small machine responsive
41
+ PUNCTUATION_CONCURRENCY = int(env("PUNCTUATION_CONCURRENCY", "4"))
42
+
43
+
44
+ def _words(text: str) -> list[str]:
45
+ return re.findall(r"[a-zA-Z0-9\u00c0-\u024f]+", text.lower())
46
+
47
+
48
+ # ISO base media brands for M4A/AAC files.
49
+ _ISO_AUDIO_BRANDS = (b"M4A ", b"M4B ", b"mp42", b"isom", b"dash")
50
+
51
+
52
+ def detect_audio(data: bytes, ctype: str = "", ext: str = "") -> bool:
53
+ """Return True if the content looks like an audio file.
54
+
55
+ Recognised formats and their magic bytes::
56
+
57
+ MP3: ID3v2 tag (b'ID3') or an MPEG audio frame sync (11 set bits)
58
+ WAV: RIFF header with b'WAVE' at offset 8
59
+ Ogg: page signature b'OggS' (Vorbis, Opus)
60
+ M4A: ISO base media 'ftyp' box at offset 4, brand M4A , M4B , mp42, isom or dash at offset 8
61
+ FLAC: b'fLaC'
62
+
63
+ The ctype and ext arguments keep working as before: a content-type
64
+ starting with ``audio/`` or a matching extension returns True without
65
+ inspecting the bytes.
66
+ """
67
+ if ctype and any(ctype.startswith(p) for p in ("audio/",)):
68
+ return True
69
+ if ext and ext.lower() in AUDIO_EXTS:
70
+ return True
71
+ if not data:
72
+ return False
73
+ # ID3v2 tag (MP3 with metadata)
74
+ if data[:3] == b"ID3":
75
+ return True
76
+ # MPEG audio frame sync (bare MP3 stream)
77
+ if len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0:
78
+ return True
79
+ # WAV
80
+ if data[:4] == b"RIFF" and len(data) > 8 and data[8:12] == b"WAVE":
81
+ return True
82
+ # Ogg (Vorbis, Opus)
83
+ if data[:4] == b"OggS":
84
+ return True
85
+ # ISO base media file (M4A / AAC)
86
+ # the first 4 bytes are the box size, the box type follows at offset 4
87
+ if len(data) >= 12 and data[4:8] == b"ftyp" and data[8:12] in _ISO_AUDIO_BRANDS:
88
+ return True
89
+ # FLAC
90
+ return data[:4] == b"fLaC"
91
+
92
+
93
+ async def transcribe_audio(
94
+ file_path: str | None = None,
95
+ file_bytes: bytes | None = None,
96
+ ) -> tuple[str, str, int, str | None]:
97
+ """Transcribe an audio file using faster-whisper.
98
+
99
+ Args:
100
+ file_path: Path to the audio file.
101
+ file_bytes: Raw audio bytes (file_path takes precedence).
102
+
103
+ Returns:
104
+ (title, text, duration_seconds, language) or raises SourceError.
105
+ """
106
+ from .sources import SourceError
107
+
108
+ tmp: tempfile.NamedTemporaryFile | None = None
109
+ actual_path: str = ""
110
+
111
+ try:
112
+ if file_bytes is not None:
113
+ tmp = tempfile.NamedTemporaryFile( # noqa: SIM115
114
+ suffix=".audio", delete=False
115
+ )
116
+ tmp.write(file_bytes)
117
+ tmp.close()
118
+ actual_path = tmp.name
119
+ elif file_path:
120
+ actual_path = file_path
121
+ else:
122
+ raise SourceError("unreadable", "no audio data provided")
123
+
124
+ duration = _get_audio_duration(actual_path)
125
+ if duration > MAX_AUDIO_DURATION_S:
126
+ raise SourceError(
127
+ "too_large",
128
+ f"audio is {int(duration)}s (max {MAX_AUDIO_DURATION_S}s). "
129
+ "Split the file or provide a transcript.",
130
+ )
131
+
132
+ result = await asyncio.to_thread(_run_whisper_sync, actual_path, whisper_timeout(duration))
133
+ text = result.get("text", "").strip()
134
+ if not text:
135
+ raise SourceError("too_short", "the audio has no detectable speech")
136
+
137
+ lang = result.get("language")
138
+ title = f"Audio source ({int(duration)}s)"
139
+ return title, text, int(duration), lang
140
+
141
+ except SourceError:
142
+ raise
143
+ except Exception as exc:
144
+ raise SourceError("unreadable", f"transcription failed: {exc}") from exc
145
+ finally:
146
+ if tmp:
147
+ with contextlib.suppress(OSError):
148
+ os.unlink(actual_path)
149
+
150
+
151
+ def _get_audio_duration(file_path: str) -> float:
152
+ """Get the duration of an audio file using ffprobe (if available).
153
+
154
+ Returns duration in seconds. Unknown duration is 0.
155
+ """
156
+ try:
157
+ res = subprocess.run(
158
+ [
159
+ "ffprobe",
160
+ "-v", "error",
161
+ "-show_entries", "format=duration",
162
+ "-of", "default=noprint_wrappers=1:nokey=1",
163
+ file_path,
164
+ ],
165
+ capture_output=True,
166
+ text=True,
167
+ timeout=10.0,
168
+ check=False,
169
+ )
170
+ if res.returncode == 0 and res.stdout.strip():
171
+ return float(res.stdout.strip())
172
+ except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
173
+ pass
174
+ return 0.0
175
+
176
+
177
+ def whisper_timeout(duration_s: float) -> float:
178
+ """Seconds the Whisper subprocess may take: model load plus the audio at real time.
179
+
180
+ A fixed 300 s cut off long files on an ordinary CPU (an hour of audio with the base
181
+ model can take longer than five minutes). Unknown length gets the full one-hour budget.
182
+ """
183
+ length = duration_s if duration_s > 0 else MAX_AUDIO_DURATION_S
184
+ return max(300.0, 120.0 + length)
185
+
186
+
187
+ def _run_whisper_sync(file_path: str, timeout: float = 300.0) -> dict[str, Any]:
188
+ """Run the Whisper transcription (synchronous).
189
+
190
+ Called via asyncio.to_thread from the async context.
191
+ In tests this is monkey-patched to avoid needing torch/faster-whisper.
192
+ """
193
+ import subprocess
194
+
195
+ from .sources import SourceError
196
+
197
+ try:
198
+ res = subprocess.run(
199
+ [
200
+ sys.executable,
201
+ "-c",
202
+ _WHISPER_SCRIPT_TEMPLATE,
203
+ file_path,
204
+ WHISPER_MODEL,
205
+ ],
206
+ capture_output=True,
207
+ text=True,
208
+ timeout=timeout,
209
+ check=False,
210
+ )
211
+ if res.returncode != 0:
212
+ err = (res.stderr or "").strip()
213
+ raise SourceError("unreadable", f"transcription failed: {err}")
214
+ return json.loads(res.stdout)
215
+ except FileNotFoundError:
216
+ raise SourceError("unreadable", "whisper is not available")
217
+ except subprocess.TimeoutExpired:
218
+ raise SourceError("unreadable", f"transcription took longer than {int(timeout)} s")
219
+
220
+
221
+ # Template for the inline Whisper script that runs in the renderer's venv.
222
+ # The renderer venv has faster-whisper installed; the API venv may not.
223
+ # The file path is passed as the first argument (sys.argv[1]).
224
+ # The model name is passed as the second argument (sys.argv[2]).
225
+ _WHISPER_SCRIPT_TEMPLATE = """\
226
+ import json, sys, os
227
+ try:
228
+ from faster_whisper import WhisperModel
229
+ model = WhisperModel(sys.argv[2], device="cpu", compute_type="int8")
230
+ # vad_filter skips silence: faster, and no invented text in quiet passages
231
+ segments, info = model.transcribe(sys.argv[1], language=None, vad_filter=True)
232
+ text = " ".join(s.text.strip() for s in segments).strip()
233
+ lang = info.language or "en"
234
+ print(json.dumps({"text": text, "language": lang}))
235
+ except Exception as e:
236
+ print(json.dumps({"error": str(e)}))
237
+ sys.exit(1)
238
+ """
239
+
240
+
241
+ async def restore_punctuation(text: str) -> tuple[str, bool]:
242
+ """Restore punctuation to raw whisper output with word-for-word validation.
243
+
244
+ Splits the text into chunks of about 300 words at word boundaries,
245
+ restores each chunk with its own request, and checks each chunk word
246
+ for word. A chunk that fails the check keeps its raw text; the others
247
+ keep their restored text. The result is the chunks joined with a space.
248
+
249
+ Each punctuation request sends "chat_template_kwargs": {"enable_thinking": false}
250
+ like the script provider and uses httpx.AsyncClient (no blocking call in the
251
+ event loop); max_tokens fits one chunk.
252
+
253
+ Args:
254
+ text: Raw transcribed text (may be unpunctuated).
255
+
256
+ Returns:
257
+ (punctuated_text, validated).
258
+ """
259
+ if not text or not text.strip():
260
+ return text, True
261
+
262
+ # Split original into words for chunking
263
+ words = re.findall(r"\S+", text)
264
+ if not words:
265
+ return text, True
266
+
267
+ # Split into chunks of ~PUNCTUATION_CHUNK_WORDS words at word boundaries
268
+ chunks: list[str] = []
269
+ for i in range(0, len(words), PUNCTUATION_CHUNK_WORDS):
270
+ chunk_words = words[i : i + PUNCTUATION_CHUNK_WORDS]
271
+ chunks.append(" ".join(chunk_words))
272
+
273
+ # Chunks run in parallel (PUNCTUATION_CONCURRENCY at a time); gather keeps their order.
274
+ sem = asyncio.Semaphore(PUNCTUATION_CONCURRENCY)
275
+
276
+ async def one(chunk: str) -> tuple[str, bool]:
277
+ original_words = _words(chunk)
278
+ if not original_words:
279
+ return chunk, True
280
+ try:
281
+ async with sem:
282
+ punctuated = await _llm_punctuate_async(chunk)
283
+ except Exception as exc:
284
+ logger.warning("punctuation restore failed for chunk: %s", exc, exc_info=True)
285
+ return chunk, False
286
+ # word for word: punctuation and capitals may change, the words and their order may not
287
+ if not punctuated or _words(punctuated) != original_words:
288
+ logger.debug("punctuation restore changed the words, keeping the raw chunk")
289
+ return chunk, False
290
+ return punctuated.strip(), True
291
+
292
+ results = await asyncio.gather(*(one(c) for c in chunks))
293
+ restored_chunks = [text for text, _ in results]
294
+ all_validated = all(ok for _, ok in results)
295
+
296
+ return " ".join(restored_chunks), all_validated
297
+
298
+
299
+ async def _llm_punctuate_async(text: str) -> str:
300
+ """Send text to the local LLM for punctuation restoration (async).
301
+
302
+ Uses httpx.AsyncClient with chat_template_kwargs to disable thinking.
303
+ max_tokens is sized for a single chunk (~300 words).
304
+ """
305
+ from .providers import LLM_BASE, LLM_MODEL
306
+
307
+ prompt = (
308
+ "Restore punctuation and capitalization to the following text.\n"
309
+ "Do not add, remove, or reorder any words.\n"
310
+ "Output ONLY the punctuated text, nothing else.\n\n"
311
+ f"{text}"
312
+ )
313
+
314
+ # ~300 words * ~5 chars/word + prompt overhead = ~2000 tokens, use 4096 for safety
315
+ max_tokens = 4096
316
+
317
+ payload = {
318
+ "model": LLM_MODEL,
319
+ "messages": [{"role": "user", "content": prompt}],
320
+ "max_tokens": max_tokens,
321
+ "temperature": 0.0,
322
+ "chat_template_kwargs": {"enable_thinking": False},
323
+ }
324
+
325
+ async with httpx.AsyncClient(timeout=60.0) as client:
326
+ resp = await client.post(
327
+ f"{LLM_BASE.rstrip('/')}/chat/completions",
328
+ json=payload,
329
+ )
330
+ resp.raise_for_status()
331
+ data = resp.json()
332
+ result = data["choices"][0]["message"]["content"].strip()
333
+ if not result:
334
+ raise ValueError("punctuation model returned empty text")
335
+ return result
336
+
337
+
338
+ # ---------------------------------------------------------------------------
339
+ # Existing transcript extraction
340
+ # ---------------------------------------------------------------------------
341
+
342
+ # Regex patterns for podcast transcript tags.
343
+ _PODCAST_TRANSCRIPT_RE = re.compile(
344
+ r"<podcast:transcript[^>]*>\s*(.*?)\s*</podcast:transcript>",
345
+ re.DOTALL,
346
+ )
347
+
348
+
349
+ def extract_existing_transcript(source_text: str | None) -> str | None:
350
+ """Extract an existing transcript from source text (RSS / podcast).
351
+
352
+ Some sources include a <podcast:transcript> tag with a pre-existing
353
+ transcript. Extract and return it without re-transcribing.
354
+
355
+ Args:
356
+ source_text: The full source text to search.
357
+
358
+ Returns:
359
+ The extracted transcript text, or None if no transcript found.
360
+ """
361
+ if not source_text:
362
+ return None
363
+
364
+ match = _PODCAST_TRANSCRIPT_RE.search(source_text)
365
+ if match:
366
+ return match.group(1).strip()
367
+
368
+ return None