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.
- vozonda-0.7.3/.env.example +1 -0
- vozonda-0.7.3/.gitignore +24 -0
- vozonda-0.7.3/PKG-INFO +40 -0
- vozonda-0.7.3/pyproject.toml +78 -0
- vozonda-0.7.3/src/vozonda_api/__init__.py +5 -0
- vozonda-0.7.3/src/vozonda_api/access.py +178 -0
- vozonda-0.7.3/src/vozonda_api/audio_source.py +368 -0
- vozonda-0.7.3/src/vozonda_api/billing.py +381 -0
- vozonda-0.7.3/src/vozonda_api/blossom.py +403 -0
- vozonda-0.7.3/src/vozonda_api/budget.py +216 -0
- vozonda-0.7.3/src/vozonda_api/clips.py +359 -0
- vozonda-0.7.3/src/vozonda_api/condense.py +151 -0
- vozonda-0.7.3/src/vozonda_api/config.py +29 -0
- vozonda-0.7.3/src/vozonda_api/cover.py +176 -0
- vozonda-0.7.3/src/vozonda_api/custom_styles.py +209 -0
- vozonda-0.7.3/src/vozonda_api/doctor.py +263 -0
- vozonda-0.7.3/src/vozonda_api/env.py +21 -0
- vozonda-0.7.3/src/vozonda_api/envfile.py +45 -0
- vozonda-0.7.3/src/vozonda_api/fetcher.py +444 -0
- vozonda-0.7.3/src/vozonda_api/insights.py +271 -0
- vozonda-0.7.3/src/vozonda_api/jobs.py +642 -0
- vozonda-0.7.3/src/vozonda_api/length.py +172 -0
- vozonda-0.7.3/src/vozonda_api/llms.txt +107 -0
- vozonda-0.7.3/src/vozonda_api/main.py +4000 -0
- vozonda-0.7.3/src/vozonda_api/mcp_server.py +405 -0
- vozonda-0.7.3/src/vozonda_api/music.py +444 -0
- vozonda-0.7.3/src/vozonda_api/music_store.py +259 -0
- vozonda-0.7.3/src/vozonda_api/nostr_auth.py +421 -0
- vozonda-0.7.3/src/vozonda_api/nostr_highlights.py +267 -0
- vozonda-0.7.3/src/vozonda_api/nostr_orchestrator.py +862 -0
- vozonda-0.7.3/src/vozonda_api/nostr_publish.py +243 -0
- vozonda-0.7.3/src/vozonda_api/nostr_relay.py +104 -0
- vozonda-0.7.3/src/vozonda_api/nostr_zaps.py +402 -0
- vozonda-0.7.3/src/vozonda_api/pipeline.py +3349 -0
- vozonda-0.7.3/src/vozonda_api/plugins/__init__.py +25 -0
- vozonda-0.7.3/src/vozonda_api/plugins/protocols.py +118 -0
- vozonda-0.7.3/src/vozonda_api/plugins/registry.py +275 -0
- vozonda-0.7.3/src/vozonda_api/plugins/types.py +74 -0
- vozonda-0.7.3/src/vozonda_api/podcast_key.py +230 -0
- vozonda-0.7.3/src/vozonda_api/providers/__init__.py +686 -0
- vozonda-0.7.3/src/vozonda_api/providers/chatterbox.py +56 -0
- vozonda-0.7.3/src/vozonda_api/providers/dia.py +53 -0
- vozonda-0.7.3/src/vozonda_api/providers/dia2.py +67 -0
- vozonda-0.7.3/src/vozonda_api/providers/higgs.py +62 -0
- vozonda-0.7.3/src/vozonda_api/providers/kokoro.py +196 -0
- vozonda-0.7.3/src/vozonda_api/providers/magpie.py +103 -0
- vozonda-0.7.3/src/vozonda_api/providers/nemo.py +132 -0
- vozonda-0.7.3/src/vozonda_api/providers/piper.py +28 -0
- vozonda-0.7.3/src/vozonda_api/providers/qwen.py +140 -0
- vozonda-0.7.3/src/vozonda_api/providers/script.py +393 -0
- vozonda-0.7.3/src/vozonda_api/providers/vibevoice.py +52 -0
- vozonda-0.7.3/src/vozonda_api/providers/voxtral.py +29 -0
- vozonda-0.7.3/src/vozonda_api/public_address.py +143 -0
- vozonda-0.7.3/src/vozonda_api/render_chatterbox.py +242 -0
- vozonda-0.7.3/src/vozonda_api/render_dia.py +324 -0
- vozonda-0.7.3/src/vozonda_api/render_dia2.py +280 -0
- vozonda-0.7.3/src/vozonda_api/render_higgs.py +226 -0
- vozonda-0.7.3/src/vozonda_api/render_kokoro.py +251 -0
- vozonda-0.7.3/src/vozonda_api/render_magpie.py +160 -0
- vozonda-0.7.3/src/vozonda_api/render_piper.py +165 -0
- vozonda-0.7.3/src/vozonda_api/render_vibevoice.py +252 -0
- vozonda-0.7.3/src/vozonda_api/render_voxtral.py +156 -0
- vozonda-0.7.3/src/vozonda_api/render_vozonda.py +141 -0
- vozonda-0.7.3/src/vozonda_api/retention.py +111 -0
- vozonda-0.7.3/src/vozonda_api/rhythm.py +450 -0
- vozonda-0.7.3/src/vozonda_api/rhythm_layer.py +204 -0
- vozonda-0.7.3/src/vozonda_api/routers/__init__.py +1 -0
- vozonda-0.7.3/src/vozonda_api/routers/billing.py +200 -0
- vozonda-0.7.3/src/vozonda_api/routers/distribution.py +223 -0
- vozonda-0.7.3/src/vozonda_api/routers/feeds.py +573 -0
- vozonda-0.7.3/src/vozonda_api/script_contract.py +172 -0
- vozonda-0.7.3/src/vozonda_api/script_lint.py +361 -0
- vozonda-0.7.3/src/vozonda_api/settings_store.py +585 -0
- vozonda-0.7.3/src/vozonda_api/sources.py +573 -0
- vozonda-0.7.3/src/vozonda_api/style_registry.py +791 -0
- vozonda-0.7.3/src/vozonda_api/styles.py +43 -0
- vozonda-0.7.3/src/vozonda_api/updates.py +96 -0
- vozonda-0.7.3/src/vozonda_api/version.py +93 -0
- vozonda-0.7.3/src/vozonda_api/voices.py +294 -0
- vozonda-0.7.3/src/vozonda_api/watchlist.py +477 -0
- vozonda-0.7.3/src/vozonda_api/watchlist_dedupe.py +175 -0
- vozonda-0.7.3/src/vozonda_api/watchlist_poller.py +568 -0
- vozonda-0.7.3/src/vozonda_api/webhooks.py +120 -0
- vozonda-0.7.3/tests/bench_dsp_slicing.py +491 -0
- vozonda-0.7.3/tests/bip340_test_vectors.csv +20 -0
- vozonda-0.7.3/tests/conftest.py +203 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/asmr.json +12 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/balanced.json +12 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/clash.json +12 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/conspiracy.json +15 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/courtroom.json +20 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/crisis_room.json +20 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/debate.json +12 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/dude.json +16 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/eli5.json +12 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/futbol.json +12 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/meditation.json +20 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/noir.json +20 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/sensational.json +12 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/serious.json +12 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/slang.json +12 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/socrates.json +15 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/storyteller.json +12 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/tech_roast.json +20 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/trivia.json +20 -0
- vozonda-0.7.3/tests/fixtures/style_prompts/true_crime.json +20 -0
- vozonda-0.7.3/tests/test_agent_docs.py +208 -0
- vozonda-0.7.3/tests/test_agent_webhooks.py +532 -0
- vozonda-0.7.3/tests/test_ai_disclosure.py +235 -0
- vozonda-0.7.3/tests/test_api_feed.py +176 -0
- vozonda-0.7.3/tests/test_api_peaks_and_dedupe.py +82 -0
- vozonda-0.7.3/tests/test_api_source.py +28 -0
- vozonda-0.7.3/tests/test_api_watchlist.py +204 -0
- vozonda-0.7.3/tests/test_audio_sources.py +590 -0
- vozonda-0.7.3/tests/test_audio_upload_detect.py +180 -0
- vozonda-0.7.3/tests/test_bench_metrics.py +270 -0
- vozonda-0.7.3/tests/test_bench_report.py +454 -0
- vozonda-0.7.3/tests/test_bench_run.py +369 -0
- vozonda-0.7.3/tests/test_billing.py +238 -0
- vozonda-0.7.3/tests/test_billing_read_auth.py +273 -0
- vozonda-0.7.3/tests/test_billing_router.py +239 -0
- vozonda-0.7.3/tests/test_blossom.py +261 -0
- vozonda-0.7.3/tests/test_brand_assets.py +82 -0
- vozonda-0.7.3/tests/test_budget.py +213 -0
- vozonda-0.7.3/tests/test_cancel_kills_renderer.py +275 -0
- vozonda-0.7.3/tests/test_changelog_link.py +48 -0
- vozonda-0.7.3/tests/test_chat_completion_retry.py +62 -0
- vozonda-0.7.3/tests/test_chatterbox_provider.py +139 -0
- vozonda-0.7.3/tests/test_citations.py +270 -0
- vozonda-0.7.3/tests/test_clips.py +157 -0
- vozonda-0.7.3/tests/test_combine_sources.py +79 -0
- vozonda-0.7.3/tests/test_compose_multisource_contract.py +66 -0
- vozonda-0.7.3/tests/test_condense.py +239 -0
- vozonda-0.7.3/tests/test_config_paths.py +79 -0
- vozonda-0.7.3/tests/test_custom_styles.py +220 -0
- vozonda-0.7.3/tests/test_custom_styles_ui.py +48 -0
- vozonda-0.7.3/tests/test_default_cast.py +44 -0
- vozonda-0.7.3/tests/test_dia2_provider.py +133 -0
- vozonda-0.7.3/tests/test_dia_provider.py +170 -0
- vozonda-0.7.3/tests/test_digest_pipeline.py +274 -0
- vozonda-0.7.3/tests/test_digest_titles.py +41 -0
- vozonda-0.7.3/tests/test_distribution.py +498 -0
- vozonda-0.7.3/tests/test_distribution_ui_text.py +225 -0
- vozonda-0.7.3/tests/test_dockerfile_qwen_optional.py +61 -0
- vozonda-0.7.3/tests/test_docs_links.py +20 -0
- vozonda-0.7.3/tests/test_doctor_llm_chain.py +58 -0
- vozonda-0.7.3/tests/test_doctor_tts_engine.py +98 -0
- vozonda-0.7.3/tests/test_empty_custom_prompt.py +44 -0
- vozonda-0.7.3/tests/test_engine_listing.py +91 -0
- vozonda-0.7.3/tests/test_episode_length.py +221 -0
- vozonda-0.7.3/tests/test_episode_title.py +348 -0
- vozonda-0.7.3/tests/test_export_public.py +70 -0
- vozonda-0.7.3/tests/test_feed_router.py +119 -0
- vozonda-0.7.3/tests/test_fetch_kind.py +83 -0
- vozonda-0.7.3/tests/test_fetch_pdf_limit.py +20 -0
- vozonda-0.7.3/tests/test_fetcher.py +176 -0
- vozonda-0.7.3/tests/test_focus.py +46 -0
- vozonda-0.7.3/tests/test_hallucination_guard.py +379 -0
- vozonda-0.7.3/tests/test_higgs_provider.py +117 -0
- vozonda-0.7.3/tests/test_host_names.py +33 -0
- vozonda-0.7.3/tests/test_isolation_guard.py +100 -0
- vozonda-0.7.3/tests/test_job_language_names.py +40 -0
- vozonda-0.7.3/tests/test_job_og_image.py +39 -0
- vozonda-0.7.3/tests/test_job_show_link.py +47 -0
- vozonda-0.7.3/tests/test_jobs_store.py +111 -0
- vozonda-0.7.3/tests/test_js_budget.py +239 -0
- vozonda-0.7.3/tests/test_kimi_nim.py +70 -0
- vozonda-0.7.3/tests/test_kokoro_default.py +250 -0
- vozonda-0.7.3/tests/test_kokoro_provider.py +109 -0
- vozonda-0.7.3/tests/test_length_language.py +144 -0
- vozonda-0.7.3/tests/test_llm_engines_generic.py +142 -0
- vozonda-0.7.3/tests/test_magpie_provider.py +91 -0
- vozonda-0.7.3/tests/test_master_reach.py +89 -0
- vozonda-0.7.3/tests/test_mcp_server.py +251 -0
- vozonda-0.7.3/tests/test_mcp_watchlist_tools.py +318 -0
- vozonda-0.7.3/tests/test_mixed_sources.py +482 -0
- vozonda-0.7.3/tests/test_mixed_sources_ui.py +373 -0
- vozonda-0.7.3/tests/test_music_beds.py +343 -0
- vozonda-0.7.3/tests/test_music_import.py +126 -0
- vozonda-0.7.3/tests/test_no_silent_exceptions.py +118 -0
- vozonda-0.7.3/tests/test_no_unused_css.py +120 -0
- vozonda-0.7.3/tests/test_nostr_delete_and_retry.py +145 -0
- vozonda-0.7.3/tests/test_nostr_endpoints.py +462 -0
- vozonda-0.7.3/tests/test_nostr_highlights.py +223 -0
- vozonda-0.7.3/tests/test_nostr_orchestrator.py +466 -0
- vozonda-0.7.3/tests/test_nostr_publish.py +372 -0
- vozonda-0.7.3/tests/test_nostr_publish_guards.py +170 -0
- vozonda-0.7.3/tests/test_nostr_relay_protocol.py +110 -0
- vozonda-0.7.3/tests/test_nostr_settings.py +168 -0
- vozonda-0.7.3/tests/test_nostr_signature.py +86 -0
- vozonda-0.7.3/tests/test_nostr_source.py +15 -0
- vozonda-0.7.3/tests/test_nostr_zaps.py +287 -0
- vozonda-0.7.3/tests/test_opencode_writer.py +133 -0
- vozonda-0.7.3/tests/test_oss_hygiene.py +123 -0
- vozonda-0.7.3/tests/test_outline_mode.py +71 -0
- vozonda-0.7.3/tests/test_pacing.py +39 -0
- vozonda-0.7.3/tests/test_piper_voices.py +81 -0
- vozonda-0.7.3/tests/test_plugin_registry.py +348 -0
- vozonda-0.7.3/tests/test_plugins_health_matches_providers.py +31 -0
- vozonda-0.7.3/tests/test_podcast_key.py +372 -0
- vozonda-0.7.3/tests/test_podcasting20.py +165 -0
- vozonda-0.7.3/tests/test_private_feeds.py +145 -0
- vozonda-0.7.3/tests/test_provider_seams.py +143 -0
- vozonda-0.7.3/tests/test_public_scan.py +229 -0
- vozonda-0.7.3/tests/test_public_url.py +57 -0
- vozonda-0.7.3/tests/test_published_loopback.py +34 -0
- vozonda-0.7.3/tests/test_queue_snapshot.py +124 -0
- vozonda-0.7.3/tests/test_quickstart_config.py +231 -0
- vozonda-0.7.3/tests/test_reach_model.py +258 -0
- vozonda-0.7.3/tests/test_reach_ui_address.py +85 -0
- vozonda-0.7.3/tests/test_reach_ui_cards.py +89 -0
- vozonda-0.7.3/tests/test_readable_id.py +29 -0
- vozonda-0.7.3/tests/test_remote_access.py +169 -0
- vozonda-0.7.3/tests/test_rename_job.py +132 -0
- vozonda-0.7.3/tests/test_rename_protocol.py +119 -0
- vozonda-0.7.3/tests/test_retention.py +70 -0
- vozonda-0.7.3/tests/test_review_default.py +90 -0
- vozonda-0.7.3/tests/test_rhythm_layer.py +158 -0
- vozonda-0.7.3/tests/test_rhythm_profiles.py +231 -0
- vozonda-0.7.3/tests/test_script_contract.py +82 -0
- vozonda-0.7.3/tests/test_script_cut_off.py +24 -0
- vozonda-0.7.3/tests/test_script_form_check.py +43 -0
- vozonda-0.7.3/tests/test_script_json_retry.py +223 -0
- vozonda-0.7.3/tests/test_script_parser.py +243 -0
- vozonda-0.7.3/tests/test_script_provider.py +362 -0
- vozonda-0.7.3/tests/test_script_review.py +184 -0
- vozonda-0.7.3/tests/test_script_turns.py +40 -0
- vozonda-0.7.3/tests/test_security_fixes.py +126 -0
- vozonda-0.7.3/tests/test_settings_roundtrip.py +26 -0
- vozonda-0.7.3/tests/test_settings_secrets.py +23 -0
- vozonda-0.7.3/tests/test_settings_validation.py +70 -0
- vozonda-0.7.3/tests/test_show_nostr_optin.py +73 -0
- vozonda-0.7.3/tests/test_show_numbers.py +59 -0
- vozonda-0.7.3/tests/test_shows_current_removed.py +16 -0
- vozonda-0.7.3/tests/test_source_detection.py +72 -0
- vozonda-0.7.3/tests/test_sources.py +345 -0
- vozonda-0.7.3/tests/test_src_required.py +125 -0
- vozonda-0.7.3/tests/test_ssrf_redirects.py +59 -0
- vozonda-0.7.3/tests/test_style_golden.py +219 -0
- vozonda-0.7.3/tests/test_style_registry.py +303 -0
- vozonda-0.7.3/tests/test_style_ui_from_meta.py +141 -0
- vozonda-0.7.3/tests/test_styles.py +133 -0
- vozonda-0.7.3/tests/test_text_title.py +26 -0
- vozonda-0.7.3/tests/test_thinking_off.py +14 -0
- vozonda-0.7.3/tests/test_tray_allocation.py +160 -0
- vozonda-0.7.3/tests/test_tray_privacy.py +122 -0
- vozonda-0.7.3/tests/test_tray_settings_ui.py +51 -0
- vozonda-0.7.3/tests/test_tts_engine_env.py +19 -0
- vozonda-0.7.3/tests/test_tts_engine_registry.py +69 -0
- vozonda-0.7.3/tests/test_ui_spinner_cancel.py +98 -0
- vozonda-0.7.3/tests/test_update_check.py +91 -0
- vozonda-0.7.3/tests/test_v4v_defaults.py +107 -0
- vozonda-0.7.3/tests/test_vibevoice_provider.py +118 -0
- vozonda-0.7.3/tests/test_vite_proxy_paths.py +28 -0
- vozonda-0.7.3/tests/test_voice_auto_language.py +56 -0
- vozonda-0.7.3/tests/test_voice_profiles.py +158 -0
- vozonda-0.7.3/tests/test_voice_samples.py +130 -0
- vozonda-0.7.3/tests/test_watchlist_dedupe.py +763 -0
- vozonda-0.7.3/tests/test_watchlist_schedule.py +704 -0
- vozonda-0.7.3/tests/test_watchlist_show.py +279 -0
- vozonda-0.7.3/tests/test_watchlist_template_text.py +68 -0
- vozonda-0.7.3/tests/test_web_proxy_covers_api.py +22 -0
- vozonda-0.7.3/tests/test_wpm_calibration.py +24 -0
- vozonda-0.7.3/tests/test_write_auth.py +55 -0
- vozonda-0.7.3/uv.lock +4252 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VOZONDA_HF_HOME=/ai/models
|
vozonda-0.7.3/.gitignore
ADDED
|
@@ -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,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
|