mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Add entity pronunciation preview API and enhance job management pages
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,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 }}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user