diff --git a/abogen/kokoro_text_normalization.py b/abogen/kokoro_text_normalization.py index 2229984..4c7b9fb 100644 --- a/abogen/kokoro_text_normalization.py +++ b/abogen/kokoro_text_normalization.py @@ -3,6 +3,10 @@ import re import unicodedata from dataclasses import dataclass from typing import Callable, Iterable, List, Optional, Sequence, Tuple +try: # pragma: no cover - optional dependency guard + from num2words import num2words +except Exception: # pragma: no cover - graceful degradation + num2words = None # type: ignore # ---------- Configuration Dataclass ---------- @@ -24,6 +28,8 @@ class ApostropheConfig: joiner: str = "" # Replacement used when collapsing internal apostrophes lowercase_for_matching: bool = True # Normalize to lower for rule matching (not output) protect_cultural_names: bool = True # Always keep O'Brien, D'Angelo, etc. + convert_numbers: bool = True # Convert grouped numbers such as 12,500 to words + number_lang: str = "en" # num2words language code # ---------- Dictionaries / Patterns ---------- @@ -80,6 +86,7 @@ CONTRACTIONS_EXACT = { } # For ambiguous 'd and 's we handle separately +_NUMBER_WITH_GROUP_RE = re.compile(r"(? Tup cfg = ApostropheConfig() text = normalize_unicode_apostrophes(text) + text = _normalize_grouped_numbers(text, cfg) tokens = tokenize(text) results = [] @@ -542,6 +550,41 @@ def normalize_apostrophes(text: str, cfg: ApostropheConfig | None = None) -> Tup normalized_text = _cleanup_spacing(" ".join(filtered)) return normalized_text, results +def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: + if not text or not cfg.convert_numbers: + return text + + def _replace(match: re.Match[str]) -> str: + token = match.group(1) + cleaned = token.replace(",", "") + if not cleaned: + return token + negative = cleaned.startswith("-") + cleaned_digits = cleaned[1:] if negative else cleaned + + if not cleaned_digits.isdigit(): + return cleaned_digits if not negative else f"-{cleaned_digits}" + + if num2words is None: + return ("-" if negative else "") + cleaned_digits + + try: + value = int(cleaned) + except ValueError: + return cleaned + + language = (cfg.number_lang or "en").strip() or "en" + try: + words = num2words(abs(value), lang=language) + except Exception: # pragma: no cover - unsupported locale + return str(value) + + if value < 0: + words = f"minus {words}" + return words + + return _NUMBER_WITH_GROUP_RE.sub(_replace, text) + # ---------- Optional phoneme hint post-processing ---------- def apply_phoneme_hints(text: str, iz_marker="‹IZ›") -> str: @@ -567,6 +610,7 @@ def normalize_for_pipeline(text: str, *, config: Optional[ApostropheConfig] = No normalized, _details = normalize_apostrophes(text, cfg) normalized = expand_titles_and_suffixes(normalized) normalized = ensure_terminal_punctuation(normalized) + normalized = _normalize_grouped_numbers(normalized, cfg) if cfg.add_phoneme_hints: normalized = apply_phoneme_hints(normalized, iz_marker=cfg.sibilant_iz_marker) return normalized diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 5f356c6..61d2fd8 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -1508,13 +1508,15 @@ def index() -> str: "index.html", options=_template_options(), settings=_load_settings(), - jobs_panel=_render_jobs_panel(), ) @web_bp.get("/queue") def queue_page() -> ResponseReturnValue: - return redirect(url_for("web.index", _anchor="queue")) + return render_template( + "queue.html", + jobs_panel=_render_jobs_panel(), + ) @web_bp.route("/settings", methods=["GET", "POST"]) diff --git a/abogen/web/static/dashboard.js b/abogen/web/static/dashboard.js index c11b41b..0d75aaf 100644 --- a/abogen/web/static/dashboard.js +++ b/abogen/web/static/dashboard.js @@ -8,15 +8,35 @@ const initDashboard = () => { const defaultReaderHint = readerHint?.textContent || ""; const scope = uploadModal || document; + const parseJSONScript = (id) => { + const element = document.getElementById(id); + if (!element) return null; + try { + const raw = element.textContent || ""; + return raw ? JSON.parse(raw) : null; + } catch (error) { + console.warn(`Failed to parse JSON script: ${id}`, error); + return null; + } + }; + const profileSelect = scope.querySelector('[data-role="voice-profile"]'); const voiceField = scope.querySelector('[data-role="voice-field"]'); const voiceSelect = scope.querySelector('[data-role="voice-select"]'); const formulaField = scope.querySelector('[data-role="formula-field"]'); const formulaInput = scope.querySelector('[data-role="voice-formula"]'); const languageSelect = uploadModal?.querySelector("#language") || document.getElementById("language"); + const speedInput = uploadModal?.querySelector('#speed') || document.getElementById('speed'); + const previewButton = scope.querySelector('[data-role="voice-preview-button"]'); + const previewStatus = scope.querySelector('[data-role="voice-preview-status"]'); + const previewAudio = scope.querySelector('[data-role="voice-preview-audio"]'); + const sampleVoiceTexts = parseJSONScript('voice-sample-texts') || {}; let lastTrigger = null; let readerTrigger = null; + let previewAbortController = null; + let previewObjectUrl = null; + let suppressPauseStatus = false; const openUploadModal = (trigger) => { if (!uploadModal) return; @@ -131,6 +151,215 @@ const initDashboard = () => { } }); + const resolveSampleText = (language) => { + const fallback = typeof sampleVoiceTexts === "object" && sampleVoiceTexts?.a + ? sampleVoiceTexts.a + : "This is a sample of the selected voice."; + if (!language || typeof sampleVoiceTexts !== "object" || !sampleVoiceTexts) { + return fallback; + } + const normalizedKey = language.toLowerCase(); + if (typeof sampleVoiceTexts[normalizedKey] === "string" && sampleVoiceTexts[normalizedKey].trim()) { + return sampleVoiceTexts[normalizedKey]; + } + const baseKey = normalizedKey.split(/[_.-]/)[0]; + if (baseKey && typeof sampleVoiceTexts[baseKey] === "string" && sampleVoiceTexts[baseKey].trim()) { + return sampleVoiceTexts[baseKey]; + } + return fallback; + }; + + const getSelectedLanguage = () => { + const value = languageSelect?.value || "a"; + return (value || "a").trim() || "a"; + }; + + const getSelectedSpeed = () => { + const raw = speedInput?.value || "1"; + const parsed = Number.parseFloat(raw); + return Number.isFinite(parsed) ? parsed : 1; + }; + + const cancelPreviewRequest = () => { + if (!previewAbortController) return; + previewAbortController.abort(); + previewAbortController = null; + }; + + const stopPreviewAudio = () => { + if (previewAudio) { + suppressPauseStatus = true; + try { + previewAudio.pause(); + } catch (error) { + // Ignore pause errors + } + previewAudio.removeAttribute("src"); + previewAudio.load(); + previewAudio.hidden = true; + suppressPauseStatus = false; + } + if (previewObjectUrl) { + URL.revokeObjectURL(previewObjectUrl); + previewObjectUrl = null; + } + }; + + const setPreviewStatus = (message, state = "") => { + if (!previewStatus) return; + if (!message) { + previewStatus.textContent = ""; + previewStatus.hidden = true; + previewStatus.removeAttribute("data-state"); + return; + } + previewStatus.textContent = message; + previewStatus.hidden = false; + if (state) { + previewStatus.dataset.state = state; + } else { + previewStatus.removeAttribute("data-state"); + } + }; + + const setPreviewLoading = (isLoading) => { + if (!previewButton) return; + previewButton.disabled = isLoading; + if (isLoading) { + previewButton.dataset.loading = "true"; + } else { + previewButton.removeAttribute("data-loading"); + } + }; + + const buildPreviewRequest = () => { + const language = getSelectedLanguage(); + const speed = getSelectedSpeed(); + const basePayload = { + language, + speed, + max_seconds: 8, + text: resolveSampleText(language), + }; + + const profileValue = profileSelect?.value || "__standard"; + + if (profileValue && profileValue !== "__standard") { + if (profileValue === "__formula") { + const formulaValue = (formulaInput?.value || "").trim(); + if (!formulaValue) { + return { error: "Enter a custom voice formula to preview." }; + } + return { + endpoint: "/api/voice-profiles/preview", + payload: { ...basePayload, formula: formulaValue }, + }; + } + return { + endpoint: "/api/voice-profiles/preview", + payload: { ...basePayload, profile: profileValue }, + }; + } + + const selectedVoice = (voiceSelect?.value || voiceSelect?.dataset.default || "").trim(); + if (!selectedVoice) { + return { error: "Select a narrator voice to preview." }; + } + return { + endpoint: "/api/speaker-preview", + payload: { ...basePayload, voice: selectedVoice }, + }; + }; + + const resetPreview = () => { + cancelPreviewRequest(); + stopPreviewAudio(); + setPreviewStatus("", ""); + }; + + if (previewAudio) { + previewAudio.addEventListener("ended", () => { + setPreviewStatus("Preview finished", "info"); + }); + previewAudio.addEventListener("pause", () => { + if (suppressPauseStatus || previewAudio.ended || previewAudio.currentTime === 0) { + return; + } + setPreviewStatus("Preview paused", "info"); + }); + } + + const handleVoicePreview = async () => { + if (!previewButton) return; + const request = buildPreviewRequest(); + if (!request) { + return; + } + if (request.error) { + setPreviewStatus(request.error, "error"); + cancelPreviewRequest(); + stopPreviewAudio(); + return; + } + + cancelPreviewRequest(); + stopPreviewAudio(); + previewAbortController = new AbortController(); + setPreviewLoading(true); + setPreviewStatus("Generating preview…", "loading"); + + try { + const response = await fetch(request.endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request.payload), + signal: previewAbortController.signal, + }); + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Preview failed (status ${response.status})`); + } + const blob = await response.blob(); + previewObjectUrl = URL.createObjectURL(blob); + if (previewAudio) { + previewAudio.src = previewObjectUrl; + previewAudio.hidden = false; + try { + await previewAudio.play(); + setPreviewStatus("Preview playing", "success"); + } catch (error) { + setPreviewStatus("Preview ready. Press play to listen.", "success"); + } + } else { + setPreviewStatus("Preview ready.", "success"); + } + } catch (error) { + if (error.name === "AbortError") { + return; + } + console.error("Voice preview failed", error); + setPreviewStatus(error.message || "Preview failed", "error"); + stopPreviewAudio(); + } finally { + setPreviewLoading(false); + } + }; + + if (previewButton) { + previewButton.addEventListener("click", (event) => { + event.preventDefault(); + handleVoicePreview(); + }); + } + + [voiceSelect, profileSelect, formulaInput, languageSelect, speedInput].forEach((input) => { + if (!input) return; + const eventName = input === formulaInput ? "input" : "change"; + input.addEventListener(eventName, () => { + resetPreview(); + }); + }); + const hydrateDefaultVoice = () => { if (!voiceSelect) return; const defaultVoice = voiceSelect.dataset.default; @@ -232,6 +461,11 @@ const initDashboard = () => { } else { hydrateDefaultVoice(); } + + window.addEventListener("beforeunload", () => { + cancelPreviewRequest(); + stopPreviewAudio(); + }); }; if (document.readyState === "loading") { diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 4135c42..799d2b0 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -609,12 +609,122 @@ body { outline-offset: 2px; } +.upload-form__sections { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.form-section { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem; + border-radius: 24px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(15, 23, 42, 0.45); +} + +.form-section__title { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--text); +} + +.form-section__layout { + display: grid; + gap: 1.25rem; +} + +.form-section__layout--split { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +.form-section__group { + display: grid; + gap: 1.25rem; +} + +.field-grid { + display: grid; + gap: 1.25rem; +} + +.field-grid--two { + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.field-grid--two > .field { + max-width: none; +} + +.field-grid--two input[type="text"], +.field-grid--two input[type="number"], +.field-grid--two input[type="file"], +.field-grid--two input[type="range"], +.field-grid--two select, +.field-grid--two textarea { + max-width: 100%; +} + +.field--stack { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.field--with-action .field__label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.field__label-row .field__label, +.field__label-row label { + font-weight: 500; + color: var(--muted); + font-size: 0.9rem; +} + +.field__status { + margin: 0; + font-size: 0.85rem; + color: var(--muted); +} + +.field__status[data-state="loading"] { + color: var(--accent); +} + +.field__status[data-state="error"] { + color: var(--danger); +} + +.field__status[data-state="success"] { + color: var(--success); +} + +.voice-preview__audio { + width: min(100%, 360px); + margin-top: 0.75rem; + border-radius: 14px; + background: rgba(15, 23, 42, 0.4); +} + .field { display: flex; flex-direction: column; gap: 0.4rem; } +.field__caption { + font-weight: 500; + color: var(--muted); + font-size: 0.9rem; +} + .field[hidden], .field[data-state="hidden"] { display: none !important; @@ -1418,9 +1528,45 @@ body { gap: 2rem; } +.prepare-step { + display: grid; + gap: 2rem; +} + +.prepare-step__header { + display: grid; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + .chapter-grid { display: grid; gap: 1.25rem; + margin-top: 1.5rem; +} + +.prepare-options { + display: grid; + gap: 1.5rem; + margin-top: 1.75rem; +} + +@media (min-width: 880px) { + .prepare-options { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .prepare-options .field { + max-width: none; + } +} + +.prepare-step__actions { + margin-top: 0; + padding-top: 1.5rem; + padding-bottom: 0.5rem; + display: flex; + justify-content: flex-end; + border-top: 1px solid rgba(148, 163, 184, 0.22); } .chapter-card { diff --git a/abogen/web/templates/base.html b/abogen/web/templates/base.html index 8d85eda..c87c291 100644 --- a/abogen/web/templates/base.html +++ b/abogen/web/templates/base.html @@ -20,7 +20,7 @@ Dashboard Voice Mixer Speakers - Queue + Queue Settings diff --git a/abogen/web/templates/index.html b/abogen/web/templates/index.html index c85fe35..98e470b 100644 --- a/abogen/web/templates/index.html +++ b/abogen/web/templates/index.html @@ -22,6 +22,7 @@

Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and speakers in the next steps.

+ View queue
@@ -37,97 +38,143 @@
- -
-
- {{ jobs_panel|safe }} -
-
{% endblock %} {% block scripts %} {{ super() }} + {% endblock %} diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index f82a53b..347b8ae 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -13,9 +13,58 @@
- Output Defaults + Narration Defaults
- + + +

Used whenever “Standard voice” is selected for a new job.

+
+
+ + +
+
+ + +
+
+ + +

Speakers detected fewer times fall back to the narrator voice.

+
+
+ + +

Include {{ '{{name}}' }} where the speaker name should be inserted.

+
+
+ + {% set selected_languages = settings.speaker_random_languages or [] %} + +

Limits random voice selection for speakers marked as random. Leave empty to allow any language.

+
+
+ +
+ Audio & Delivery +
+
- - -
-
- - -

Used when “Standard voice” is selected on the dashboard.

-
-
- +

Default output: {{ default_output_dir }}

-
- -
- Processing +
+ + +
- - +
+
+ + +
+
+ + +

Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.

+
+
+ +
+ Subtitles & Text +
+ + +
+
+ + +
+
+
-
- - -
-
- - -
-
- - -
-
- - {% set selected_languages = settings.speaker_random_languages or [] %} - -

Limits random voice selection for speakers marked as random. Leave unused to allow any language.

-
-
- - -

Speakers detected fewer times than this fallback to the narrator voice.

-
-
- - -

Sentence template used when previewing name pronunciation. Include {{ '{{name}}' }} where the speaker name should be inserted.

-
-
- - -
-
- - -

Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.

-
-
- - +
+ +
+ Performance +
+
- +
diff --git a/pyproject.toml b/pyproject.toml index ee9426f..b267a69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,8 @@ dependencies = [ "Markdown>=3.9", "Flask>=3.0.3", "numpy>=1.24.0", - "gpustat>=1.1.1" + "gpustat>=1.1.1", + "num2words>=0.5.13" ] classifiers = [ diff --git a/tests/test_text_normalization.py b/tests/test_text_normalization.py index f6eebca..203c695 100644 --- a/tests/test_text_normalization.py +++ b/tests/test_text_normalization.py @@ -63,3 +63,8 @@ def test_normalize_roman_titles_preserves_separators() -> None: assert normalized[0] == " 4. The Trial" assert normalized[1] == "5 - The Verdict" assert normalized[2].startswith("6\nAftermath") + + +def test_grouped_numbers_are_spelled_out() -> None: + normalized = _normalize_for_pipeline("The vault holds 35,000 credits") + assert "thirty-five thousand" in normalized.lower()