fix: language enum coercion, preview logging, file picker

- form.py: convert language string to Language enum at all entry points
- conversion_request.py: add _coerce_enums() for defense-in-depth
- synthesize.py: fix NameError (lang -> language) in preview pipeline
- voice.py: render LANGUAGE_DESCRIPTIONS keys as .value strings for Jinja
- dashboard.js: open file picker on dropzone click
- api.py: add logging to preview endpoint for debugging
This commit is contained in:
Artem Akymenko
2026-07-29 10:57:21 +00:00
parent 2b70b9ca45
commit c706f7714a
6 changed files with 51 additions and 5 deletions
+20
View File
@@ -105,6 +105,7 @@ class ConversionRequest:
_apply_none_defaults(self) _apply_none_defaults(self)
if not self.tts_provider: if not self.tts_provider:
self.tts_provider = "kokoro" self.tts_provider = "kokoro"
_coerce_enums(self)
_clamp_numerics(self) _clamp_numerics(self)
@@ -119,6 +120,25 @@ def _apply_none_defaults(obj: ConversionRequest) -> None:
setattr(obj, f.name, f.default_factory()) setattr(obj, f.name, f.default_factory())
# Enum fields that accept string coercion: attr -> (enum_class, fallback)
_ENUM_COERCIONS: dict[str, tuple[type, Any]] = {
"language": (Language, Language.EN_US),
"output_format": (OutputFormat, OutputFormat.WAV),
}
def _coerce_enums(obj: ConversionRequest) -> None:
"""Coerce string values to their expected enum types."""
for attr, (enum_cls, fallback) in _ENUM_COERCIONS.items():
val = getattr(obj, attr)
if isinstance(val, enum_cls):
continue
try:
setattr(obj, attr, enum_cls.from_str(str(val)))
except (ValueError, AttributeError):
setattr(obj, attr, fallback)
def _clamp_numerics(obj: ConversionRequest) -> None: def _clamp_numerics(obj: ConversionRequest) -> None:
"""Clamp numeric fields to valid ranges.""" """Clamp numeric fields to valid ranges."""
for attr, (min_v, max_v) in _NUMERIC_CONSTRAINTS.items(): for attr, (min_v, max_v) in _NUMERIC_CONSTRAINTS.items():
+12
View File
@@ -184,6 +184,11 @@ def api_voice_profiles_preview() -> ResponseReturnValue:
voice_spec = "" voice_spec = ""
resolved_provider = provider or "kokoro" resolved_provider = provider or "kokoro"
current_app.logger.info(
"[preview] provider=%s language=%s speed=%.2f profile=%s formula=%s",
resolved_provider, language, speed, profile_name or "-", formula or "-",
)
profiles = load_profiles() profiles = load_profiles()
if resolved_provider == "supertonic" and not profile_name: if resolved_provider == "supertonic" and not profile_name:
voice_spec = str(payload.get("voice") or payload.get("supertonic_voice") or "M1").strip() or "M1" voice_spec = str(payload.get("voice") or payload.get("supertonic_voice") or "M1").strip() or "M1"
@@ -214,7 +219,13 @@ def api_voice_profiles_preview() -> ResponseReturnValue:
voice_spec = formula_from_profile(normalized_entry) or "" voice_spec = formula_from_profile(normalized_entry) or ""
resolved_provider = "kokoro" resolved_provider = "kokoro"
current_app.logger.info(
"[preview] resolved: provider=%s voice_spec=%s",
resolved_provider, voice_spec[:80] if voice_spec else "-",
)
if not voice_spec: if not voice_spec:
current_app.logger.warning("[preview] empty voice_spec, returning 400")
return jsonify({"error": "Unable to resolve preview voice"}), 400 return jsonify({"error": "Unable to resolve preview voice"}), 400
try: try:
@@ -229,6 +240,7 @@ def api_voice_profiles_preview() -> ResponseReturnValue:
max_seconds=max_seconds, max_seconds=max_seconds,
) )
except Exception as exc: except Exception as exc:
current_app.logger.exception("[preview] synthesis failed: %s", exc)
return jsonify({"error": str(exc)}), 500 return jsonify({"error": str(exc)}), 500
@api_bp.post("/speaker-preview") @api_bp.post("/speaker-preview")
+14 -3
View File
@@ -4,6 +4,7 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast
from flask import request, render_template, jsonify from flask import request, render_template, jsonify
from flask.typing import ResponseReturnValue from flask.typing import ResponseReturnValue
from abogen.domain.enums import Language
from abogen.domain.chapter_classification import ( from abogen.domain.chapter_classification import (
supplement_score, supplement_score,
should_preselect_chapter, should_preselect_chapter,
@@ -346,7 +347,10 @@ def apply_book_step_form(
language_fallback = pending.language or settings.get("language", "en") language_fallback = pending.language or settings.get("language", "en")
raw_language = (form.get("language") or language_fallback or "en").strip() raw_language = (form.get("language") or language_fallback or "en").strip()
if raw_language: if raw_language:
pending.language = raw_language try:
pending.language = Language.from_str(raw_language)
except (ValueError, AttributeError):
pending.language = Language.EN_US
subtitle_mode = (form.get("subtitle_mode") or pending.subtitle_mode or "Disabled").strip() subtitle_mode = (form.get("subtitle_mode") or pending.subtitle_mode or "Disabled").strip()
if subtitle_mode: if subtitle_mode:
@@ -513,7 +517,10 @@ def apply_book_step_form(
) )
if resolved_language: if resolved_language:
pending.language = resolved_language try:
pending.language = Language.from_str(str(resolved_language))
except (ValueError, AttributeError):
pass # keep existing language
if profile_selection == "__formula" and custom_formula_raw: if profile_selection == "__formula" and custom_formula_raw:
pending.voice = custom_formula_raw pending.voice = custom_formula_raw
@@ -666,7 +673,11 @@ def build_pending_job_from_extraction(
ensure_at_least_one_chapter_enabled(chapters_payload) ensure_at_least_one_chapter_enabled(chapters_payload)
language = str(form.get("language") or "a").strip() or "a" raw_language = str(form.get("language") or "a").strip() or "a"
try:
language = Language.from_str(raw_language)
except (ValueError, AttributeError):
language = Language.EN_US
profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {}) profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {})
default_voice_setting = settings.get("default_voice") or "" default_voice_setting = settings.get("default_voice") or ""
resolved_default_voice, inferred_profile, inferred_language = resolve_voice_setting( resolved_default_voice, inferred_profile, inferred_language = resolve_voice_setting(
+1 -1
View File
@@ -58,7 +58,7 @@ def get_preview_pipeline(language: Language, device: str) -> Any:
return pipeline return pipeline
from abogen.tts_plugin.utils import create_pipeline from abogen.tts_plugin.utils import create_pipeline
pipeline = create_pipeline("kokoro", language=lang, device=device) pipeline = create_pipeline("kokoro", language=language, device=device)
_preview_pipelines[key] = pipeline _preview_pipelines[key] = pipeline
return pipeline return pipeline
+1 -1
View File
@@ -578,7 +578,7 @@ def template_options() -> Dict[str, Any]:
) )
voice_catalog = build_voice_catalog() voice_catalog = build_voice_catalog()
return { return {
"languages": LANGUAGE_DESCRIPTIONS, "languages": {lang.value: label for lang, label in LANGUAGE_DESCRIPTIONS.items()},
"voices": get_voices("kokoro"), "voices": get_voices("kokoro"),
"subtitle_formats": SUBTITLE_FORMATS, "subtitle_formats": SUBTITLE_FORMATS,
"supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, "supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
+3
View File
@@ -452,6 +452,9 @@ const initDashboard = () => {
return; return;
} }
openUploadModal(dropzone); openUploadModal(dropzone);
if (sourceFileInput) {
sourceFileInput.click();
}
}); });
dropzone.addEventListener("keydown", (event) => { dropzone.addEventListener("keydown", (event) => {