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 from typing import Any, Dict, Mapping, List, Optional
import base64
from flask import Blueprint, request, jsonify, send_file from flask import Blueprint, request, jsonify, send_file
from flask.typing import ResponseReturnValue from flask.typing import ResponseReturnValue
@@ -6,13 +7,14 @@ from flask.typing import ResponseReturnValue
from abogen.web.routes.utils.settings import ( from abogen.web.routes.utils.settings import (
load_settings, load_settings,
coerce_float, coerce_float,
coerce_bool,
) )
from abogen.voice_profiles import ( from abogen.voice_profiles import (
load_profiles, load_profiles,
save_profiles, save_profiles,
delete_profile, 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 ( from abogen.normalization_settings import (
build_llm_configuration, build_llm_configuration,
build_apostrophe_config, build_apostrophe_config,
@@ -211,3 +213,37 @@ def api_normalization_preview() -> ResponseReturnValue:
"text": sample_text, "text": sample_text,
"normalized_text": normalized_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 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 flask.typing import ResponseReturnValue
from abogen.web.routes.utils.service import require_pending_job, get_service 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, delete_manual_override,
search_manual_override_candidates, 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__) 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) results = search_manual_override_candidates(pending, query, limit=limit_value)
return jsonify({"query": query, "limit": limit_value, "results": results}) 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) time.sleep(0.5)
return Response(generate(), mimetype="text/event-stream") 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()
+25 -4
View File
@@ -25,14 +25,14 @@ def get_preview_pipeline(language: str, device: str) -> Any:
_preview_pipelines[key] = pipeline _preview_pipelines[key] = pipeline
return pipeline return pipeline
def synthesize_preview( def generate_preview_audio(
text: str, text: str,
voice_spec: str, voice_spec: str,
language: str, language: str,
speed: float, speed: float,
use_gpu: bool, use_gpu: bool,
max_seconds: float = 8.0, max_seconds: float = 8.0,
) -> ResponseReturnValue: ) -> bytes:
if not text.strip(): if not text.strip():
raise ValueError("Preview text is required") raise ValueError("Preview text is required")
@@ -92,8 +92,29 @@ def synthesize_preview(
audio_data = np.concatenate(audio_chunks) audio_data = np.concatenate(audio_chunks)
buffer = io.BytesIO() buffer = io.BytesIO()
sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV") 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( response = send_file(
buffer, buffer,
mimetype="audio/wav", mimetype="audio/wav",
+2 -5
View File
@@ -22,11 +22,8 @@ from abogen.constants import VOICES_INTERNAL
voices_bp = Blueprint("voices", __name__) voices_bp = Blueprint("voices", __name__)
@voices_bp.get("/") @voices_bp.get("/")
def voices_list() -> ResponseReturnValue: def voice_profiles() -> ResponseReturnValue:
# This might not be a standalone page in the original app, but useful to have. return render_template("voices.html")
# 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"))
@voices_bp.post("/test") @voices_bp.post("/test")
def test_voice() -> ResponseReturnValue: def test_voice() -> ResponseReturnValue:
+6 -6
View File
@@ -17,12 +17,12 @@
</div> </div>
<nav class="top-actions"> <nav class="top-actions">
{% set endpoint = request.endpoint or '' %} {% 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('main.index') }}" class="btn{% if endpoint == 'main.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('voices.voice_profiles') }}" class="btn{% if endpoint == 'voices.voice_profiles' %} 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('entities.entities_page') }}" class="btn{% if endpoint == 'entities.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('books.find_books_page') }}" class="btn{% if endpoint == 'books.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('jobs.queue_page') }}" class="btn{% if endpoint in ['jobs.queue_page', 'jobs.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('settings.settings_page') }}" class="btn{% if endpoint == 'settings.settings_page' %} is-active{% endif %}">Settings</a>
</nav> </nav>
</header> </header>
<main class="main"> <main class="main">
+5 -5
View File
@@ -44,19 +44,19 @@
<p>Characters: {{ job.processed_characters }} / {{ job.total_characters or '—' }}</p> <p>Characters: {{ job.processed_characters }} / {{ job.total_characters or '—' }}</p>
{% set flags = downloads or {} %} {% set flags = downloads or {} %}
{% if flags.get('m4b') %} {% 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') %} {% 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 %} {% endif %}
{% if flags.get('epub3') %} {% 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 %} {% endif %}
{% if job.status in [JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED] %} {% 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> <button type="submit" class="button button--ghost">Cancel job</button>
</form> </form>
{% elif job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED] %} {% 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> <button type="submit" class="button">Retry job</button>
</form> </form>
{% endif %} {% endif %}
+7 -7
View File
@@ -11,7 +11,7 @@
<li class="job-card" data-status="{{ job.status.value }}"> <li class="job-card" data-status="{{ job.status.value }}">
<div class="job-card__header"> <div class="job-card__header">
<div> <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"> <div class="job-card__meta">
{% if job.queue_position %}Position #{{ job.queue_position }} · {% endif %} {% if job.queue_position %}Position #{{ job.queue_position }} · {% endif %}
{% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }} {% 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> <small>{{ progress_value }}% · {{ job.processed_characters }} / {{ job.total_characters or '—' }}</small>
</div> </div>
<div class="job-card__footer"> <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 %} {% 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 %} {% 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 %} {% 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 %} {% endif %}
{% if job.status in [JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED] %} {% 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 %} {% endif %}
</div> </div>
</li> </li>
@@ -53,7 +53,7 @@
<header class="queue-section__header"> <header class="queue-section__header">
<h3>Recent results</h3> <h3>Recent results</h3>
{% if total_finished > 0 %} {% 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 %} {% endif %}
</header> </header>
{% if finished_jobs %} {% if finished_jobs %}
+2 -2
View File
@@ -2,9 +2,9 @@
<div class="card__title-row"> <div class="card__title-row">
<div class="card__title">Live log</div> <div class="card__title">Live log</div>
{% if not is_static %} {% 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 %} {% 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 %} {% endif %}
</div> </div>
{% if job.logs %} {% if job.logs %}
@@ -249,7 +249,7 @@
{% if notice %} {% if notice %}
<div class="alert alert--info">{{ notice }}</div> <div class="alert alert--info">{{ notice }}</div>
{% endif %} {% 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" method="post"
id="new-job-book-form" id="new-job-book-form"
class="upload-form" class="upload-form"
@@ -1,12 +1,12 @@
<form method="post" <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" class="prepare-form"
id="prepare-form" id="prepare-form"
data-role="prepare-form" data-role="prepare-form"
data-wizard-form="true" data-wizard-form="true"
data-step="chapters" data-step="chapters"
data-pending-id="{{ pending.id }}" 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"> <input type="hidden" name="active_step" value="chapters" data-role="active-step-input">
<div class="wizard-hidden-inputs" aria-hidden="true"> <div class="wizard-hidden-inputs" aria-hidden="true">
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}"> <input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
@@ -24,9 +24,9 @@
{% set total_steps = 3 %} {% set total_steps = 3 %}
{% set current_index = step_number[current_step] %} {% set current_index = step_number[current_step] %}
{% set is_open = open if open is defined else False %} {% set is_open = open if open is defined else False %}
{% set prepare_url_template = url_for('web.prepare_job', pending_id='__pending__') %} {% set prepare_url_template = url_for('main.wizard_step', pending_id='__pending__') %}
{% set cancel_url_template = url_for('web.cancel_pending_job', pending_id='__pending__') %} {% set cancel_url_template = url_for('main.wizard_cancel', pending_id='__pending__') %}
{% set analyze_url_template = url_for('web.analyze_pending_job', pending_id='__pending__') %} {% set analyze_url_template = url_for('main.wizard_update', pending_id='__pending__') %}
<div class="modal" <div class="modal"
data-role="new-job-modal" data-role="new-job-modal"
data-step="{{ current_step }}" data-step="{{ current_step }}"
+6 -6
View File
@@ -21,13 +21,13 @@
</button> </button>
<a class="step-indicator__item is-active" <a class="step-indicator__item is-active"
aria-current="step" 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__index">2</span>
<span class="step-indicator__label">Chapters</span> <span class="step-indicator__label">Chapters</span>
</a> </a>
{% if is_multi_speaker %} {% if is_multi_speaker %}
<a class="step-indicator__item" <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__index">3</span>
<span class="step-indicator__label">Entities</span> <span class="step-indicator__label">Entities</span>
</a> </a>
@@ -41,11 +41,11 @@
</div> </div>
</header> </header>
<form method="post" <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" class="prepare-form"
id="prepare-form" id="prepare-form"
data-speaker-mode="{{ pending.speaker_mode }}" 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"> <input type="hidden" name="active_step" value="chapters" data-role="active-step-input">
<div class="wizard-hidden-inputs" aria-hidden="true"> <div class="wizard-hidden-inputs" aria-hidden="true">
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}"> <input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
@@ -159,7 +159,7 @@
class="button" class="button"
data-role="submit-speaker-analysis" data-role="submit-speaker-analysis"
data-step-toggle="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"> formmethod="post">
Continue to entities Continue to entities
</button> </button>
@@ -170,7 +170,7 @@
</div> </div>
</footer> </footer>
</form> </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>
</div> </div>
</section> </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="normalization">Text Normalization</button>
<button type="button" class="settings-nav__item" data-section="integrations">Integrations</button> <button type="button" class="settings-nav__item" data-section="integrations">Integrations</button>
</nav> </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"> <div class="settings-panels">
<section class="settings-panel is-active" data-section="narration"> <section class="settings-panel is-active" data-section="narration">
<fieldset class="settings__section"> <fieldset class="settings__section">