feat: Add entity pronunciation preview API and enhance job management pages

This commit is contained in:
JB
2025-11-28 15:20:22 -08:00
parent d08cbcfdc9
commit fd93e1c9e9
14 changed files with 122 additions and 45 deletions
+37 -1
View File
@@ -1,4 +1,5 @@
from typing import Any, Dict, Mapping, List, Optional
import base64
from flask import Blueprint, request, jsonify, send_file
from flask.typing import ResponseReturnValue
@@ -6,13 +7,14 @@ from flask.typing import ResponseReturnValue
from abogen.web.routes.utils.settings import (
load_settings,
coerce_float,
coerce_bool,
)
from abogen.voice_profiles import (
load_profiles,
save_profiles,
delete_profile,
)
from abogen.web.routes.utils.preview import synthesize_preview
from abogen.web.routes.utils.preview import synthesize_preview, generate_preview_audio
from abogen.normalization_settings import (
build_llm_configuration,
build_apostrophe_config,
@@ -211,3 +213,37 @@ def api_normalization_preview() -> ResponseReturnValue:
"text": sample_text,
"normalized_text": normalized_text,
})
@api_bp.post("/entity-pronunciation/preview")
def api_entity_pronunciation_preview() -> ResponseReturnValue:
payload = request.get_json(force=True, silent=True) or {}
token = payload.get("token", "").strip()
pronunciation = payload.get("pronunciation", "").strip()
voice = payload.get("voice", "").strip()
language = payload.get("language", "a").strip()
if not token and not pronunciation:
return jsonify({"error": "Token or pronunciation required"}), 400
text_to_speak = pronunciation if pronunciation else token
if not voice:
settings = load_settings()
voice = settings.get("default_voice", "af_heart")
try:
# Check GPU setting
settings = load_settings()
use_gpu = coerce_bool(settings.get("use_gpu"), False)
audio_bytes = generate_preview_audio(
text=text_to_speak,
voice_spec=voice,
language=language,
speed=1.0,
use_gpu=use_gpu,
)
audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
return jsonify({"audio_base64": audio_base64})
except Exception as e:
return jsonify({"error": str(e)}), 400
+14 -2
View File
@@ -1,5 +1,5 @@
from typing import Mapping
from flask import Blueprint, request, jsonify, abort
from flask import Blueprint, request, jsonify, abort, render_template
from flask.typing import ResponseReturnValue
from abogen.web.routes.utils.service import require_pending_job, get_service
@@ -10,7 +10,8 @@ from abogen.web.routes.utils.entity import (
delete_manual_override,
search_manual_override_candidates,
)
from abogen.web.routes.utils.settings import coerce_int
from abogen.web.routes.utils.settings import coerce_int, load_settings
from abogen.web.routes.utils.voice import template_options
entities_bp = Blueprint("entities", __name__)
@@ -94,3 +95,14 @@ def search_candidates(pending_id: str) -> ResponseReturnValue:
results = search_manual_override_candidates(pending, query, limit=limit_value)
return jsonify({"query": query, "limit": limit_value, "results": results})
@entities_bp.get("/")
def entities_page() -> str:
settings = load_settings()
lang = request.args.get("lang") or settings.get("language", "en")
options = template_options()
return render_template(
"entities.html",
language=lang,
languages=options["languages"].items(),
)
+11
View File
@@ -274,3 +274,14 @@ def stream_logs(job_id: str) -> ResponseReturnValue:
time.sleep(0.5)
return Response(generate(), mimetype="text/event-stream")
@jobs_bp.get("/queue")
def queue_page() -> str:
return render_template(
"queue.html",
jobs_panel=render_jobs_panel(),
)
@jobs_bp.get("/partial")
def jobs_partial() -> str:
return render_jobs_panel()
+24 -3
View File
@@ -25,14 +25,14 @@ def get_preview_pipeline(language: str, device: str) -> Any:
_preview_pipelines[key] = pipeline
return pipeline
def synthesize_preview(
def generate_preview_audio(
text: str,
voice_spec: str,
language: str,
speed: float,
use_gpu: bool,
max_seconds: float = 8.0,
) -> ResponseReturnValue:
) -> bytes:
if not text.strip():
raise ValueError("Preview text is required")
@@ -92,8 +92,29 @@ def synthesize_preview(
audio_data = np.concatenate(audio_chunks)
buffer = io.BytesIO()
sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV")
buffer.seek(0)
return buffer.getvalue()
def synthesize_preview(
text: str,
voice_spec: str,
language: str,
speed: float,
use_gpu: bool,
max_seconds: float = 8.0,
) -> ResponseReturnValue:
try:
audio_bytes = generate_preview_audio(
text=text,
voice_spec=voice_spec,
language=language,
speed=speed,
use_gpu=use_gpu,
max_seconds=max_seconds,
)
except Exception as e:
raise e
buffer = io.BytesIO(audio_bytes)
response = send_file(
buffer,
mimetype="audio/wav",
+2 -5
View File
@@ -22,11 +22,8 @@ from abogen.constants import VOICES_INTERNAL
voices_bp = Blueprint("voices", __name__)
@voices_bp.get("/")
def voices_list() -> ResponseReturnValue:
# This might not be a standalone page in the original app, but useful to have.
# Or maybe it redirects to settings or something.
# For now, I'll just redirect to settings as voices are managed there usually.
return redirect(url_for("settings.settings_page"))
def voice_profiles() -> ResponseReturnValue:
return render_template("voices.html")
@voices_bp.post("/test")
def test_voice() -> ResponseReturnValue:
+6 -6
View File
@@ -17,12 +17,12 @@
</div>
<nav class="top-actions">
{% set endpoint = request.endpoint or '' %}
<a href="{{ url_for('web.index') }}" class="btn{% if endpoint == 'web.index' %} is-active{% endif %}">Dashboard</a>
<a href="{{ url_for('web.voice_profiles_page') }}" class="btn{% if endpoint == 'web.voice_profiles_page' %} is-active{% endif %}">Voice Mixer</a>
<a href="{{ url_for('web.entities_page') }}" class="btn{% if endpoint == 'web.entities_page' %} is-active{% endif %}">Entities</a>
<a href="{{ url_for('web.find_books_page') }}" class="btn{% if endpoint == 'web.find_books_page' %} is-active{% endif %}">Find Books</a>
<a href="{{ url_for('web.queue_page') }}" class="btn{% if endpoint in ['web.queue_page', 'web.job_detail'] %} is-active{% endif %}">Queue</a>
<a href="{{ url_for('web.settings_page') }}" class="btn{% if endpoint == 'web.settings_page' %} is-active{% endif %}">Settings</a>
<a href="{{ url_for('main.index') }}" class="btn{% if endpoint == 'main.index' %} is-active{% endif %}">Dashboard</a>
<a href="{{ url_for('voices.voice_profiles') }}" class="btn{% if endpoint == 'voices.voice_profiles' %} is-active{% endif %}">Voice Mixer</a>
<a href="{{ url_for('entities.entities_page') }}" class="btn{% if endpoint == 'entities.entities_page' %} is-active{% endif %}">Entities</a>
<a href="{{ url_for('books.find_books_page') }}" class="btn{% if endpoint == 'books.find_books_page' %} is-active{% endif %}">Find Books</a>
<a href="{{ url_for('jobs.queue_page') }}" class="btn{% if endpoint in ['jobs.queue_page', 'jobs.job_detail'] %} is-active{% endif %}">Queue</a>
<a href="{{ url_for('settings.settings_page') }}" class="btn{% if endpoint == 'settings.settings_page' %} is-active{% endif %}">Settings</a>
</nav>
</header>
<main class="main">
+5 -5
View File
@@ -44,19 +44,19 @@
<p>Characters: {{ job.processed_characters }} / {{ job.total_characters or '—' }}</p>
{% set flags = downloads or {} %}
{% if flags.get('m4b') %}
<p><a class="button" href="{{ url_for('web.download_job_m4b', job_id=job.id) }}">Download M4B</a></p>
<p><a class="button" href="{{ url_for('jobs.download_file', job_id=job.id, file_type='audio') }}">Download M4B</a></p>
{% elif flags.get('audio') %}
<p><a class="button" href="{{ url_for('web.download_job', job_id=job.id) }}">Download audio</a></p>
<p><a class="button" href="{{ url_for('jobs.download_file', job_id=job.id, file_type='audio') }}">Download audio</a></p>
{% endif %}
{% if flags.get('epub3') %}
<p><a class="button button--ghost" href="{{ url_for('web.download_job_epub3', job_id=job.id) }}">Download EPUB 3</a></p>
<p><a class="button button--ghost" href="{{ url_for('jobs.job_epub', job_id=job.id) }}">Download EPUB 3</a></p>
{% endif %}
{% if job.status in [JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED] %}
<form action="{{ url_for('web.cancel_job', job_id=job.id) }}" method="post">
<form action="{{ url_for('jobs.cancel_job', job_id=job.id) }}" method="post">
<button type="submit" class="button button--ghost">Cancel job</button>
</form>
{% elif job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED] %}
<form action="{{ url_for('web.retry_job', job_id=job.id) }}" method="post">
<form action="{{ url_for('jobs.retry_job', job_id=job.id) }}" method="post">
<button type="submit" class="button">Retry job</button>
</form>
{% endif %}
+7 -7
View File
@@ -11,7 +11,7 @@
<li class="job-card" data-status="{{ job.status.value }}">
<div class="job-card__header">
<div>
<a class="job-card__title" href="{{ url_for('web.job_detail', job_id=job.id) }}">{{ job.original_filename }}</a>
<a class="job-card__title" href="{{ url_for('jobs.job_detail', job_id=job.id) }}">{{ job.original_filename }}</a>
<div class="job-card__meta">
{% if job.queue_position %}Position #{{ job.queue_position }} · {% endif %}
{% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }}
@@ -29,16 +29,16 @@
<small>{{ progress_value }}% · {{ job.processed_characters }} / {{ job.total_characters or '—' }}</small>
</div>
<div class="job-card__footer">
<a class="button button--ghost" href="{{ url_for('web.job_detail', job_id=job.id) }}">Details</a>
<a class="button button--ghost" href="{{ url_for('jobs.job_detail', job_id=job.id) }}">Details</a>
{% if job.status == JobStatus.RUNNING %}
<button type="button" class="button button--ghost" hx-post="{{ url_for('web.pause_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Pause</button>
<button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.pause_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Pause</button>
{% elif job.status == JobStatus.PAUSED %}
<button type="button" class="button button--ghost" hx-post="{{ url_for('web.resume_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Resume</button>
<button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.resume_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Resume</button>
{% elif job.status == JobStatus.PENDING %}
<button type="button" class="button button--ghost" hx-post="{{ url_for('web.pause_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Pause</button>
<button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.pause_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Pause</button>
{% endif %}
{% if job.status in [JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED] %}
<button type="button" class="button button--ghost" hx-post="{{ url_for('web.cancel_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Cancel this conversion?">Cancel</button>
<button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.cancel_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Cancel this conversion?">Cancel</button>
{% endif %}
</div>
</li>
@@ -53,7 +53,7 @@
<header class="queue-section__header">
<h3>Recent results</h3>
{% if total_finished > 0 %}
<button type="button" class="button button--ghost" hx-post="{{ url_for('web.clear_finished_jobs') }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Remove completed jobs from this list?">Clear finished</button>
<button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.clear_finished_jobs') }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Remove completed jobs from this list?">Clear finished</button>
{% endif %}
</header>
{% if finished_jobs %}
+2 -2
View File
@@ -2,9 +2,9 @@
<div class="card__title-row">
<div class="card__title">Live log</div>
{% if not is_static %}
<a class="button button--ghost button--small" href="{{ url_for('web.job_logs_static', job_id=job.id) }}" target="_blank" rel="noopener">Open static view</a>
<a class="button button--ghost button--small" href="{{ url_for('jobs.job_logs', job_id=job.id) }}" target="_blank" rel="noopener">Open static view</a>
{% else %}
<a class="button button--ghost button--small" href="{{ url_for('web.job_detail', job_id=job.id) }}">Back to job</a>
<a class="button button--ghost button--small" href="{{ url_for('jobs.job_detail', job_id=job.id) }}">Back to job</a>
{% endif %}
</div>
{% if job.logs %}
@@ -249,7 +249,7 @@
{% if notice %}
<div class="alert alert--info">{{ notice }}</div>
{% endif %}
<form action="{{ url_for('web.enqueue_job') if not readonly else '#' }}"
<form action="{{ url_for('main.wizard_upload') if not readonly else '#' }}"
method="post"
id="new-job-book-form"
class="upload-form"
@@ -1,12 +1,12 @@
<form method="post"
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
action="{{ url_for('main.wizard_finish', pending_id=pending.id) }}"
class="prepare-form"
id="prepare-form"
data-role="prepare-form"
data-wizard-form="true"
data-step="chapters"
data-pending-id="{{ pending.id }}"
data-analyze-url="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}">
data-analyze-url="{{ url_for('main.wizard_update', pending_id=pending.id) }}">
<input type="hidden" name="active_step" value="chapters" data-role="active-step-input">
<div class="wizard-hidden-inputs" aria-hidden="true">
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
@@ -24,9 +24,9 @@
{% set total_steps = 3 %}
{% set current_index = step_number[current_step] %}
{% set is_open = open if open is defined else False %}
{% set prepare_url_template = url_for('web.prepare_job', pending_id='__pending__') %}
{% set cancel_url_template = url_for('web.cancel_pending_job', pending_id='__pending__') %}
{% set analyze_url_template = url_for('web.analyze_pending_job', pending_id='__pending__') %}
{% set prepare_url_template = url_for('main.wizard_step', pending_id='__pending__') %}
{% set cancel_url_template = url_for('main.wizard_cancel', pending_id='__pending__') %}
{% set analyze_url_template = url_for('main.wizard_update', pending_id='__pending__') %}
<div class="modal"
data-role="new-job-modal"
data-step="{{ current_step }}"
+6 -6
View File
@@ -21,13 +21,13 @@
</button>
<a class="step-indicator__item is-active"
aria-current="step"
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='chapters') }}">
href="{{ url_for('main.wizard_step', pending_id=pending.id, step='chapters') }}">
<span class="step-indicator__index">2</span>
<span class="step-indicator__label">Chapters</span>
</a>
{% if is_multi_speaker %}
<a class="step-indicator__item"
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='entities') }}">
href="{{ url_for('main.wizard_step', pending_id=pending.id, step='entities') }}">
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Entities</span>
</a>
@@ -41,11 +41,11 @@
</div>
</header>
<form method="post"
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
action="{{ url_for('main.wizard_finish', pending_id=pending.id) }}"
class="prepare-form"
id="prepare-form"
data-speaker-mode="{{ pending.speaker_mode }}"
data-analyze-url="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}">
data-analyze-url="{{ url_for('main.wizard_update', pending_id=pending.id) }}">
<input type="hidden" name="active_step" value="chapters" data-role="active-step-input">
<div class="wizard-hidden-inputs" aria-hidden="true">
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
@@ -159,7 +159,7 @@
class="button"
data-role="submit-speaker-analysis"
data-step-toggle="analysis"
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
formaction="{{ url_for('main.wizard_update', pending_id=pending.id) }}"
formmethod="post">
Continue to entities
</button>
@@ -170,7 +170,7 @@
</div>
</footer>
</form>
<form method="post" action="{{ url_for('web.cancel_pending_job', pending_id=pending.id) }}" id="cancel-form"></form>
<form method="post" action="{{ url_for('main.wizard_cancel', pending_id=pending.id) }}" id="cancel-form"></form>
</div>
</div>
</section>
+1 -1
View File
@@ -21,7 +21,7 @@
<button type="button" class="settings-nav__item" data-section="normalization">Text Normalization</button>
<button type="button" class="settings-nav__item" data-section="integrations">Integrations</button>
</nav>
<form action="{{ url_for('web.settings_page') }}" method="post" class="settings__form">
<form action="{{ url_for('settings.settings_page') }}" method="post" class="settings__form">
<div class="settings-panels">
<section class="settings-panel is-active" data-section="narration">
<fieldset class="settings__section">