mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Implement conversion service with job management and logging
- Added `ConversionService` class to handle job queuing, processing, and cancellation. - Introduced `Job`, `JobLog`, and `JobResult` data classes to manage job details and results. - Implemented job status tracking with enums for better state management. - Created a web interface with HTML templates for job submission and monitoring. - Developed CSS styles for a modern UI layout and responsive design. - Added functionality for voice profile management in the voice mixer. - Implemented a Docker Compose configuration for GPU support. - Wrote unit tests for the conversion service to ensure job processing works as expected.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
__all__ = ["create_app"]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "create_app":
|
||||
from .app import create_app
|
||||
|
||||
return create_app
|
||||
raise AttributeError(name)
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from abogen.utils import get_user_cache_path
|
||||
|
||||
from .conversion_runner import run_conversion_job
|
||||
from .service import build_service
|
||||
|
||||
|
||||
def _default_dirs() -> tuple[Path, Path]:
|
||||
uploads_override = os.environ.get("ABOGEN_UPLOAD_ROOT")
|
||||
outputs_override = os.environ.get("ABOGEN_OUTPUT_ROOT")
|
||||
|
||||
if uploads_override and outputs_override:
|
||||
uploads = Path(uploads_override)
|
||||
outputs = Path(outputs_override)
|
||||
else:
|
||||
base = Path(get_user_cache_path("web"))
|
||||
uploads = base / "uploads"
|
||||
outputs = base / "outputs"
|
||||
|
||||
uploads.mkdir(parents=True, exist_ok=True)
|
||||
outputs.mkdir(parents=True, exist_ok=True)
|
||||
return uploads, outputs
|
||||
|
||||
|
||||
def create_app(config: Optional[dict[str, Any]] = None) -> Flask:
|
||||
uploads_dir, outputs_dir = _default_dirs()
|
||||
|
||||
app = Flask(
|
||||
__name__,
|
||||
static_folder="static",
|
||||
template_folder="templates",
|
||||
)
|
||||
base_config = {
|
||||
"SECRET_KEY": os.environ.get("ABOGEN_SECRET_KEY", os.urandom(16)),
|
||||
"UPLOAD_FOLDER": str(uploads_dir),
|
||||
"OUTPUT_FOLDER": str(outputs_dir),
|
||||
"MAX_CONTENT_LENGTH": 1024 * 1024 * 400, # 400 MB uploads
|
||||
}
|
||||
if config:
|
||||
base_config.update(config)
|
||||
app.config.update(base_config)
|
||||
|
||||
service = build_service(
|
||||
runner=run_conversion_job,
|
||||
output_root=Path(app.config["OUTPUT_FOLDER"]),
|
||||
uploads_root=Path(app.config["UPLOAD_FOLDER"]),
|
||||
)
|
||||
app.extensions["conversion_service"] = service
|
||||
|
||||
from .routes import web_bp, api_bp
|
||||
|
||||
app.register_blueprint(web_bp)
|
||||
app.register_blueprint(api_bp, url_prefix="/api")
|
||||
|
||||
atexit.register(service.shutdown)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
app = create_app()
|
||||
host = os.environ.get("ABOGEN_HOST", "0.0.0.0")
|
||||
port = int(os.environ.get("ABOGEN_PORT", "8000"))
|
||||
debug = os.environ.get("ABOGEN_DEBUG", "false").lower() == "true"
|
||||
app.run(host=host, port=port, debug=debug)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,430 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
from contextlib import ExitStack
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import static_ffmpeg
|
||||
|
||||
from abogen.text_extractor import ExtractedChapter, extract_from_path
|
||||
from abogen.utils import (
|
||||
calculate_text_length,
|
||||
create_process,
|
||||
get_user_cache_path,
|
||||
load_config,
|
||||
load_numpy_kpipeline,
|
||||
)
|
||||
from abogen.voice_formulas import get_new_voice
|
||||
|
||||
from .service import Job, JobStatus
|
||||
|
||||
|
||||
SPLIT_PATTERN = r"\n+"
|
||||
SAMPLE_RATE = 24000
|
||||
|
||||
|
||||
class _JobCancelled(Exception):
|
||||
"""Raised internally to abort a conversion when the client cancels."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioSink:
|
||||
write: Callable[[np.ndarray], None]
|
||||
|
||||
|
||||
def run_conversion_job(job: Job) -> None:
|
||||
job.add_log("Preparing conversion pipeline")
|
||||
canceller = _make_canceller(job)
|
||||
|
||||
sink_stack = ExitStack()
|
||||
subtitle_writer: Optional[SubtitleWriter] = None
|
||||
chapter_paths: list[Path] = []
|
||||
try:
|
||||
pipeline = _load_pipeline(job)
|
||||
extraction = extract_from_path(job.stored_path)
|
||||
job.metadata_tags = extraction.metadata or {}
|
||||
|
||||
total_characters = extraction.total_characters or calculate_text_length(extraction.combined_text)
|
||||
if job.total_characters == 0:
|
||||
job.total_characters = total_characters
|
||||
job.add_log(f"Total characters: {job.total_characters:,}")
|
||||
|
||||
_apply_newline_policy(extraction.chapters, job.replace_single_newlines)
|
||||
|
||||
base_output_dir = _prepare_output_dir(job)
|
||||
project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(job, base_output_dir)
|
||||
|
||||
merged_required = job.merge_chapters_at_end or not job.save_chapters_separately
|
||||
audio_path: Optional[Path] = None
|
||||
audio_sink: Optional[AudioSink] = None
|
||||
if merged_required:
|
||||
audio_path = _build_output_path(audio_dir, job.original_filename, job.output_format)
|
||||
meta_for_sink = job.metadata_tags if job.metadata_tags else None
|
||||
audio_sink = _open_audio_sink(audio_path, job, sink_stack, metadata=meta_for_sink)
|
||||
subtitle_writer = _create_subtitle_writer(job, audio_path)
|
||||
job.result.audio_path = audio_path
|
||||
if subtitle_writer:
|
||||
job.result.subtitle_paths.append(subtitle_writer.path)
|
||||
|
||||
chapter_dir: Optional[Path] = None
|
||||
if job.save_chapters_separately:
|
||||
chapter_dir = audio_dir / "chapters"
|
||||
chapter_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
voice = _resolve_voice(pipeline, job)
|
||||
processed_chars = 0
|
||||
subtitle_index = 1
|
||||
current_time = 0.0
|
||||
total_chapters = len(extraction.chapters)
|
||||
|
||||
for idx, chapter in enumerate(extraction.chapters, start=1):
|
||||
canceller()
|
||||
job.add_log(f"Processing chapter {idx}/{total_chapters}: {chapter.title}")
|
||||
|
||||
chapter_sink_stack = ExitStack()
|
||||
chapter_sink: Optional[AudioSink] = None
|
||||
chapter_audio_path: Optional[Path] = None
|
||||
|
||||
if chapter_dir is not None:
|
||||
chapter_audio_path = _build_output_path(
|
||||
chapter_dir,
|
||||
f"{Path(job.original_filename).stem}_{_slugify(chapter.title, idx)}",
|
||||
job.separate_chapters_format,
|
||||
)
|
||||
chapter_sink = _open_audio_sink(
|
||||
chapter_audio_path,
|
||||
job,
|
||||
chapter_sink_stack,
|
||||
fmt=job.separate_chapters_format,
|
||||
)
|
||||
|
||||
for segment in pipeline(
|
||||
chapter.text,
|
||||
voice=voice,
|
||||
speed=job.speed,
|
||||
split_pattern=SPLIT_PATTERN,
|
||||
):
|
||||
canceller()
|
||||
graphemes = segment.graphemes.strip()
|
||||
if not graphemes:
|
||||
continue
|
||||
|
||||
audio = _to_float32(segment.audio)
|
||||
if chapter_sink:
|
||||
chapter_sink.write(audio)
|
||||
if audio_sink:
|
||||
audio_sink.write(audio)
|
||||
|
||||
duration = len(audio) / SAMPLE_RATE
|
||||
processed_chars += len(graphemes)
|
||||
job.processed_characters = processed_chars
|
||||
if job.total_characters:
|
||||
job.progress = min(processed_chars / job.total_characters, 0.999)
|
||||
job.add_log(f"{processed_chars:,}/{job.total_characters or '—'}: {graphemes[:80]}")
|
||||
|
||||
if subtitle_writer and audio_sink:
|
||||
subtitle_writer.write_segment(
|
||||
index=subtitle_index,
|
||||
text=graphemes,
|
||||
start=current_time,
|
||||
end=current_time + duration,
|
||||
)
|
||||
subtitle_index += 1
|
||||
|
||||
if audio_sink:
|
||||
current_time += duration
|
||||
|
||||
if chapter_sink:
|
||||
chapter_sink_stack.close()
|
||||
job.result.artifacts[f"chapter_{idx:02d}"] = chapter_audio_path
|
||||
chapter_paths.append(chapter_audio_path)
|
||||
|
||||
if (
|
||||
audio_sink
|
||||
and job.merge_chapters_at_end
|
||||
and idx < total_chapters
|
||||
and job.silence_between_chapters > 0
|
||||
):
|
||||
silence_samples = int(job.silence_between_chapters * SAMPLE_RATE)
|
||||
if silence_samples > 0:
|
||||
silence = np.zeros(silence_samples, dtype="float32")
|
||||
audio_sink.write(silence)
|
||||
current_time += job.silence_between_chapters
|
||||
|
||||
if not audio_path and chapter_paths:
|
||||
job.result.audio_path = chapter_paths[0]
|
||||
|
||||
if metadata_dir:
|
||||
metadata_dir.mkdir(parents=True, exist_ok=True)
|
||||
metadata_file = metadata_dir / "metadata.json"
|
||||
metadata_file.write_text(json.dumps({"metadata": job.metadata_tags}, indent=2), encoding="utf-8")
|
||||
job.result.artifacts["metadata"] = metadata_file
|
||||
|
||||
if job.save_as_project:
|
||||
job.result.artifacts["project_root"] = project_root
|
||||
|
||||
if job.status != JobStatus.CANCELLED:
|
||||
job.progress = 1.0
|
||||
|
||||
except _JobCancelled:
|
||||
job.status = JobStatus.CANCELLED
|
||||
job.add_log("Job cancelled", level="warning")
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
job.error = str(exc)
|
||||
job.status = JobStatus.FAILED
|
||||
job.add_log(f"Job failed: {exc}", level="error")
|
||||
finally:
|
||||
sink_stack.close()
|
||||
if subtitle_writer:
|
||||
subtitle_writer.close()
|
||||
|
||||
|
||||
def _load_pipeline(job: Job):
|
||||
cfg = load_config()
|
||||
disable_gpu = not job.use_gpu or not cfg.get("use_gpu", True)
|
||||
device = "cpu"
|
||||
if not disable_gpu:
|
||||
device = _select_device()
|
||||
_np, KPipeline = load_numpy_kpipeline()
|
||||
return KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||
|
||||
|
||||
def _select_device() -> str:
|
||||
import platform
|
||||
|
||||
system = platform.system()
|
||||
if system == "Darwin" and platform.processor() == "arm":
|
||||
return "mps"
|
||||
return "cuda"
|
||||
|
||||
|
||||
def _prepare_output_dir(job: Job) -> Path:
|
||||
from platformdirs import user_desktop_dir
|
||||
|
||||
default_output = Path(get_user_cache_path("outputs"))
|
||||
if job.save_mode == "Save to Desktop":
|
||||
directory = Path(user_desktop_dir())
|
||||
elif job.save_mode == "Save next to input file":
|
||||
directory = job.stored_path.parent
|
||||
elif job.save_mode == "Choose output folder" and job.output_folder:
|
||||
directory = Path(job.output_folder)
|
||||
else:
|
||||
directory = default_output
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return directory
|
||||
|
||||
|
||||
def _build_output_path(directory: Path, original_name: str, extension: str) -> Path:
|
||||
base_name = Path(original_name).stem
|
||||
sanitized = re.sub(r"[^\w\-_.]+", "_", base_name).strip("_") or "output"
|
||||
candidate = directory / f"{sanitized}.{extension}"
|
||||
counter = 1
|
||||
while candidate.exists():
|
||||
candidate = directory / f"{sanitized}_{counter}.{extension}"
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _prepare_project_layout(job: Job, base_dir: Path) -> tuple[Path, Path, Path, Optional[Path]]:
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
stem = Path(job.original_filename).stem
|
||||
if job.save_as_project:
|
||||
project_root = _ensure_unique_directory(base_dir, f"{stem}_project")
|
||||
audio_dir = project_root / "audio"
|
||||
subtitle_dir = project_root / "subtitles"
|
||||
metadata_dir = project_root / "metadata"
|
||||
for directory in (audio_dir, subtitle_dir, metadata_dir):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return project_root, audio_dir, subtitle_dir, metadata_dir
|
||||
|
||||
return base_dir, base_dir, base_dir, None
|
||||
|
||||
|
||||
def _ensure_unique_directory(parent: Path, name: str) -> Path:
|
||||
candidate = parent / name
|
||||
counter = 1
|
||||
while candidate.exists():
|
||||
candidate = parent / f"{name}_{counter}"
|
||||
counter += 1
|
||||
candidate.mkdir(parents=True, exist_ok=True)
|
||||
return candidate
|
||||
|
||||
|
||||
def _apply_newline_policy(chapters: List[ExtractedChapter], replace_single_newlines: bool) -> None:
|
||||
if not replace_single_newlines:
|
||||
return
|
||||
newline_regex = re.compile(r"(?<!\n)\n(?!\n)")
|
||||
for chapter in chapters:
|
||||
chapter.text = newline_regex.sub(" ", chapter.text)
|
||||
|
||||
|
||||
def _slugify(title: str, index: int) -> str:
|
||||
sanitized = re.sub(r"[^\w\-]+", "_", title.lower()).strip("_")
|
||||
if not sanitized:
|
||||
sanitized = f"chapter_{index:02d}"
|
||||
return sanitized[:80]
|
||||
|
||||
|
||||
def _open_audio_sink(
|
||||
path: Path,
|
||||
job: Job,
|
||||
stack: ExitStack,
|
||||
*,
|
||||
fmt: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, str]] = None,
|
||||
) -> AudioSink:
|
||||
static_ffmpeg.add_paths()
|
||||
fmt_value = (fmt or job.output_format).lower()
|
||||
|
||||
if fmt_value in {"wav", "flac"}:
|
||||
soundfile = stack.enter_context(
|
||||
sf.SoundFile(path, mode="w", samplerate=SAMPLE_RATE, channels=1, format=fmt_value.upper())
|
||||
)
|
||||
return AudioSink(write=lambda data: soundfile.write(data))
|
||||
|
||||
cmd = _build_ffmpeg_command(path, fmt_value, metadata=metadata)
|
||||
process = create_process(cmd, stdin=subprocess.PIPE, text=False)
|
||||
|
||||
def _finalize() -> None:
|
||||
if process.stdin and not process.stdin.closed:
|
||||
process.stdin.close()
|
||||
process.wait()
|
||||
|
||||
stack.callback(_finalize)
|
||||
|
||||
def _write(data: np.ndarray) -> None:
|
||||
if job.cancel_requested or process.stdin is None:
|
||||
return
|
||||
process.stdin.write(data.tobytes())
|
||||
|
||||
return AudioSink(write=_write)
|
||||
|
||||
|
||||
def _build_ffmpeg_command(path: Path, fmt: str, metadata: Optional[Dict[str, str]] = None) -> list[str]:
|
||||
base = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"f32le",
|
||||
"-ar",
|
||||
str(SAMPLE_RATE),
|
||||
"-ac",
|
||||
"1",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
]
|
||||
if fmt == "mp3":
|
||||
base += ["-c:a", "libmp3lame", "-qscale:a", "2"]
|
||||
elif fmt == "opus":
|
||||
base += ["-c:a", "libopus", "-b:a", "24000"]
|
||||
elif fmt == "m4b":
|
||||
base += ["-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart+use_metadata_tags"]
|
||||
else:
|
||||
base += ["-c:a", "copy"]
|
||||
|
||||
if metadata:
|
||||
for key, value in metadata.items():
|
||||
if value:
|
||||
base += ["-metadata", f"{key}={value}"]
|
||||
base.append(str(path))
|
||||
return base
|
||||
|
||||
|
||||
def _resolve_voice(pipeline, job: Job):
|
||||
if "*" in job.voice:
|
||||
return get_new_voice(pipeline, job.voice, job.use_gpu)
|
||||
return job.voice
|
||||
|
||||
|
||||
def _to_float32(audio_segment) -> np.ndarray:
|
||||
if hasattr(audio_segment, "numpy"):
|
||||
return audio_segment.numpy().astype("float32")
|
||||
return np.asarray(audio_segment, dtype="float32")
|
||||
|
||||
|
||||
class SubtitleWriter:
|
||||
def __init__(self, path: Path, format_key: str) -> None:
|
||||
self.path = path
|
||||
self.format_key = format_key
|
||||
self._file = path.open("w", encoding="utf-8", errors="replace")
|
||||
if format_key == "ass":
|
||||
self._write_ass_header()
|
||||
|
||||
def write_segment(self, *, index: int, text: str, start: float, end: float) -> None:
|
||||
if self.format_key == "ass":
|
||||
self._write_ass_event(text, start, end)
|
||||
else:
|
||||
self._write_srt_line(index, text, start, end)
|
||||
|
||||
def close(self) -> None:
|
||||
self._file.close()
|
||||
|
||||
def _write_srt_line(self, index: int, text: str, start: float, end: float) -> None:
|
||||
self._file.write(f"{index}\n")
|
||||
self._file.write(f"{_format_timestamp(start)} --> {_format_timestamp(end)}\n")
|
||||
self._file.write(text.strip() + "\n\n")
|
||||
|
||||
def _write_ass_header(self) -> None:
|
||||
self._file.write("[Script Info]\n")
|
||||
self._file.write("Title: Generated by Abogen\n")
|
||||
self._file.write("ScriptType: v4.00+\n\n")
|
||||
self._file.write("[V4+ Styles]\n")
|
||||
self._file.write(
|
||||
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
|
||||
)
|
||||
self._file.write(
|
||||
"Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n"
|
||||
)
|
||||
self._file.write("[Events]\n")
|
||||
self._file.write(
|
||||
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
|
||||
)
|
||||
|
||||
def _write_ass_event(self, text: str, start: float, end: float) -> None:
|
||||
self._file.write(
|
||||
f"Dialogue: 0,{_format_timestamp(start, ass=True)},{_format_timestamp(end, ass=True)},Default,,0000,0000,0000,,{text.strip()}\n"
|
||||
)
|
||||
|
||||
|
||||
def _create_subtitle_writer(job: Job, audio_path: Path) -> Optional[SubtitleWriter]:
|
||||
if job.subtitle_mode == "Disabled":
|
||||
return None
|
||||
|
||||
fmt = (job.subtitle_format or "srt").lower()
|
||||
if job.subtitle_mode == "Sentence + Highlighting" and fmt == "srt":
|
||||
job.add_log("Highlighting requires ASS subtitles. Switching format.", level="warning")
|
||||
fmt = "ass"
|
||||
|
||||
if fmt == "srt":
|
||||
return SubtitleWriter(audio_path.with_suffix(".srt"), "srt")
|
||||
if "ass" in fmt:
|
||||
return SubtitleWriter(audio_path.with_suffix(".ass"), "ass")
|
||||
|
||||
job.add_log(f"Unsupported subtitle format '{job.subtitle_format}'. Skipping.", level="warning")
|
||||
return None
|
||||
|
||||
|
||||
def _format_timestamp(value: float, ass: bool = False) -> str:
|
||||
hours = int(value // 3600)
|
||||
minutes = int((value % 3600) // 60)
|
||||
seconds = int(value % 60)
|
||||
milliseconds = int((value - math.floor(value)) * 1000)
|
||||
if ass:
|
||||
centiseconds = int(milliseconds / 10)
|
||||
return f"{hours:d}:{minutes:02d}:{seconds:02d}.{centiseconds:02d}"
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}"
|
||||
|
||||
|
||||
def _make_canceller(job: Job) -> Callable[[], None]:
|
||||
def _cancel() -> None:
|
||||
if job.cancel_requested:
|
||||
raise _JobCancelled
|
||||
|
||||
return _cancel
|
||||
@@ -0,0 +1,335 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
Response,
|
||||
abort,
|
||||
current_app,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
url_for,
|
||||
)
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from abogen.constants import (
|
||||
LANGUAGE_DESCRIPTIONS,
|
||||
SUBTITLE_FORMATS,
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||
SUPPORTED_SOUND_FORMATS,
|
||||
VOICES_INTERNAL,
|
||||
)
|
||||
from abogen.utils import calculate_text_length, clean_text
|
||||
from abogen.voice_profiles import delete_profile, load_profiles, save_profiles
|
||||
|
||||
from .service import ConversionService, Job, JobStatus
|
||||
|
||||
web_bp = Blueprint("web", __name__)
|
||||
api_bp = Blueprint("api", __name__)
|
||||
|
||||
|
||||
def _service() -> ConversionService:
|
||||
return current_app.extensions["conversion_service"]
|
||||
|
||||
|
||||
def _template_options() -> Dict[str, Any]:
|
||||
profiles = load_profiles()
|
||||
ordered_profiles = sorted(profiles.items())
|
||||
return {
|
||||
"languages": LANGUAGE_DESCRIPTIONS,
|
||||
"voices": VOICES_INTERNAL,
|
||||
"subtitle_formats": SUBTITLE_FORMATS,
|
||||
"supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||
"output_formats": SUPPORTED_SOUND_FORMATS,
|
||||
"voice_profiles": ordered_profiles,
|
||||
"separate_formats": ["wav", "flac", "mp3", "opus"],
|
||||
}
|
||||
|
||||
|
||||
def _formula_from_profile(entry: Dict[str, Any]) -> Optional[str]:
|
||||
voices = entry.get("voices") or []
|
||||
if not voices:
|
||||
return None
|
||||
total = sum(weight for _, weight in voices)
|
||||
if total <= 0:
|
||||
return None
|
||||
|
||||
def _format_weight(value: float) -> str:
|
||||
normalized = value / total if total else 0.0
|
||||
return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
|
||||
|
||||
parts = [f"{name}*{_format_weight(weight)}" for name, weight in voices if weight > 0]
|
||||
return "+".join(parts) if parts else None
|
||||
|
||||
|
||||
def _resolve_voice_choice(
|
||||
language: str,
|
||||
base_voice: str,
|
||||
profile_name: str,
|
||||
custom_formula: str,
|
||||
profiles: Dict[str, Any],
|
||||
) -> tuple[str, str, Optional[str]]:
|
||||
resolved_voice = base_voice
|
||||
resolved_language = language
|
||||
selected_profile = None
|
||||
|
||||
if profile_name:
|
||||
entry = profiles.get(profile_name)
|
||||
formula = _formula_from_profile(entry or {}) if entry else None
|
||||
if formula:
|
||||
resolved_voice = formula
|
||||
selected_profile = profile_name
|
||||
profile_language = (entry or {}).get("language")
|
||||
if profile_language:
|
||||
resolved_language = profile_language
|
||||
|
||||
if custom_formula:
|
||||
resolved_voice = custom_formula
|
||||
selected_profile = None
|
||||
|
||||
return resolved_voice, resolved_language, selected_profile
|
||||
|
||||
|
||||
def _parse_voice_formula(formula: str) -> List[tuple[str, float]]:
|
||||
parts = [segment.strip() for segment in formula.split("+") if segment.strip()]
|
||||
voices: List[tuple[str, float]] = []
|
||||
for part in parts:
|
||||
if "*" not in part:
|
||||
raise ValueError("Each component must be in the form voice*weight")
|
||||
name, weight_str = part.split("*", 1)
|
||||
name = name.strip()
|
||||
if name not in VOICES_INTERNAL:
|
||||
raise ValueError(f"Unknown voice '{name}'")
|
||||
try:
|
||||
weight = float(weight_str.strip())
|
||||
except ValueError as exc: # pragma: no cover - validated via form
|
||||
raise ValueError(f"Invalid weight for {name}") from exc
|
||||
if weight <= 0:
|
||||
raise ValueError(f"Weight for {name} must be positive")
|
||||
voices.append((name, weight))
|
||||
total = sum(weight for _, weight in voices)
|
||||
if total <= 0:
|
||||
raise ValueError("Voice weights must sum to a positive value")
|
||||
return voices
|
||||
|
||||
|
||||
@web_bp.app_template_filter("datetimeformat")
|
||||
def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
||||
if not value:
|
||||
return "—"
|
||||
from datetime import datetime
|
||||
|
||||
return datetime.fromtimestamp(value).strftime(fmt)
|
||||
|
||||
|
||||
@web_bp.get("/")
|
||||
def index() -> str:
|
||||
service = _service()
|
||||
jobs = service.list_jobs()
|
||||
return render_template(
|
||||
"index.html",
|
||||
jobs=jobs,
|
||||
options=_template_options(),
|
||||
)
|
||||
|
||||
|
||||
@web_bp.get("/voices")
|
||||
def voice_profiles_page() -> str:
|
||||
profiles = load_profiles()
|
||||
rendered = []
|
||||
for name, data in sorted(profiles.items()):
|
||||
rendered.append(
|
||||
{
|
||||
"name": name,
|
||||
"language": data.get("language", "a"),
|
||||
"formula": _formula_from_profile(data) or "",
|
||||
}
|
||||
)
|
||||
return render_template(
|
||||
"voices.html",
|
||||
profiles=rendered,
|
||||
languages=LANGUAGE_DESCRIPTIONS,
|
||||
voices=VOICES_INTERNAL,
|
||||
)
|
||||
|
||||
|
||||
@web_bp.post("/voices")
|
||||
def save_voice_profile_route() -> Response:
|
||||
name = request.form.get("name", "").strip()
|
||||
language = request.form.get("language", "a").strip() or "a"
|
||||
formula = request.form.get("formula", "").strip()
|
||||
if not name or not formula:
|
||||
abort(400, "Name and formula are required")
|
||||
voices = _parse_voice_formula(formula)
|
||||
profiles = load_profiles()
|
||||
profiles[name] = {"voices": voices, "language": language}
|
||||
save_profiles(profiles)
|
||||
return redirect(url_for("web.voice_profiles_page"))
|
||||
|
||||
|
||||
@web_bp.post("/voices/<name>/delete")
|
||||
def delete_voice_profile_route(name: str) -> Response:
|
||||
delete_profile(name)
|
||||
return redirect(url_for("web.voice_profiles_page"))
|
||||
|
||||
|
||||
@web_bp.post("/jobs")
|
||||
def enqueue_job() -> Response:
|
||||
service = _service()
|
||||
uploads_dir = Path(current_app.config["UPLOAD_FOLDER"])
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file = request.files.get("source_file")
|
||||
text_input = request.form.get("source_text", "").strip()
|
||||
|
||||
if not file and not text_input:
|
||||
return redirect(url_for("web.index"))
|
||||
|
||||
stored_path: Path
|
||||
original_name: str
|
||||
|
||||
if file and file.filename:
|
||||
filename = secure_filename(file.filename)
|
||||
if not filename:
|
||||
return redirect(url_for("web.index"))
|
||||
stored_path = uploads_dir / f"{uuid.uuid4().hex}_{filename}"
|
||||
file.save(stored_path)
|
||||
original_name = filename
|
||||
total_chars = 0
|
||||
else:
|
||||
original_name = "direct_text.txt"
|
||||
stored_path = uploads_dir / f"{uuid.uuid4().hex}_{original_name}"
|
||||
stored_path.write_text(text_input, encoding="utf-8")
|
||||
total_chars = calculate_text_length(clean_text(text_input))
|
||||
|
||||
profiles = load_profiles()
|
||||
|
||||
language = request.form.get("language", "a")
|
||||
base_voice = request.form.get("voice", "af_alloy")
|
||||
profile_name = request.form.get("voice_profile", "").strip()
|
||||
custom_formula = request.form.get("voice_formula", "").strip()
|
||||
voice, language, selected_profile = _resolve_voice_choice(
|
||||
language,
|
||||
base_voice,
|
||||
profile_name,
|
||||
custom_formula,
|
||||
profiles,
|
||||
)
|
||||
speed = float(request.form.get("speed", "1.0"))
|
||||
subtitle_mode = request.form.get("subtitle_mode", "Disabled")
|
||||
output_format = request.form.get("output_format", "wav")
|
||||
subtitle_format = request.form.get("subtitle_format", "srt")
|
||||
save_mode = request.form.get("save_mode", "Save next to input file")
|
||||
replace_single_newlines = request.form.get("replace_single_newlines") in {"true", "on", "1"}
|
||||
use_gpu = request.form.get("use_gpu") in {"true", "on", "1"}
|
||||
save_chapters_separately = request.form.get("save_chapters_separately") in {"true", "on", "1"}
|
||||
merge_chapters_at_end = request.form.get("merge_chapters_at_end") in {"true", "on", "1"}
|
||||
if not save_chapters_separately:
|
||||
merge_chapters_at_end = True
|
||||
save_as_project = request.form.get("save_as_project") in {"true", "on", "1"}
|
||||
separate_chapters_format = request.form.get("separate_chapters_format", "wav").lower()
|
||||
try:
|
||||
silence_between_chapters = float(request.form.get("silence_between_chapters", "2.0") or 0.0)
|
||||
except ValueError:
|
||||
silence_between_chapters = 2.0
|
||||
silence_between_chapters = max(0.0, silence_between_chapters)
|
||||
try:
|
||||
max_subtitle_words = int(request.form.get("max_subtitle_words", "50") or 50)
|
||||
except ValueError:
|
||||
max_subtitle_words = 50
|
||||
max_subtitle_words = max(1, min(max_subtitle_words, 200))
|
||||
|
||||
job = service.enqueue(
|
||||
original_filename=original_name,
|
||||
stored_path=stored_path,
|
||||
language=language,
|
||||
voice=voice,
|
||||
speed=speed,
|
||||
use_gpu=use_gpu,
|
||||
subtitle_mode=subtitle_mode,
|
||||
output_format=output_format,
|
||||
save_mode=save_mode,
|
||||
output_folder=None,
|
||||
replace_single_newlines=replace_single_newlines,
|
||||
subtitle_format=subtitle_format,
|
||||
total_characters=total_chars,
|
||||
save_chapters_separately=save_chapters_separately,
|
||||
merge_chapters_at_end=merge_chapters_at_end,
|
||||
separate_chapters_format=separate_chapters_format,
|
||||
silence_between_chapters=silence_between_chapters,
|
||||
save_as_project=save_as_project,
|
||||
voice_profile=selected_profile,
|
||||
max_subtitle_words=max_subtitle_words,
|
||||
)
|
||||
return redirect(url_for("web.job_detail", job_id=job.id))
|
||||
|
||||
|
||||
@web_bp.get("/jobs/<job_id>")
|
||||
def job_detail(job_id: str) -> str:
|
||||
job = _service().get_job(job_id)
|
||||
if not job:
|
||||
abort(404)
|
||||
return render_template(
|
||||
"job_detail.html",
|
||||
job=job,
|
||||
options=_template_options(),
|
||||
)
|
||||
|
||||
|
||||
@web_bp.post("/jobs/<job_id>/cancel")
|
||||
def cancel_job(job_id: str) -> Response:
|
||||
_service().cancel(job_id)
|
||||
return redirect(url_for("web.job_detail", job_id=job_id))
|
||||
|
||||
|
||||
@web_bp.post("/jobs/<job_id>/delete")
|
||||
def delete_job(job_id: str) -> Response:
|
||||
_service().delete(job_id)
|
||||
return redirect(url_for("web.index"))
|
||||
|
||||
|
||||
@web_bp.get("/jobs/<job_id>/download")
|
||||
def download_job(job_id: str) -> Response:
|
||||
job = _service().get_job(job_id)
|
||||
if not job or job.status != JobStatus.COMPLETED:
|
||||
abort(404)
|
||||
if not job.result.audio_path:
|
||||
abort(404)
|
||||
path = job.result.audio_path
|
||||
if not path.exists():
|
||||
abort(404)
|
||||
mime_type, _ = mimetypes.guess_type(str(path))
|
||||
return send_file(
|
||||
path,
|
||||
mimetype=mime_type or "application/octet-stream",
|
||||
as_attachment=True,
|
||||
download_name=path.name,
|
||||
)
|
||||
|
||||
|
||||
@web_bp.get("/partials/jobs")
|
||||
def jobs_partial() -> str:
|
||||
return render_template("partials/jobs.html", jobs=_service().list_jobs())
|
||||
|
||||
|
||||
@web_bp.get("/partials/jobs/<job_id>/logs")
|
||||
def job_logs_partial(job_id: str) -> str:
|
||||
job = _service().get_job(job_id)
|
||||
if not job:
|
||||
abort(404)
|
||||
return render_template("partials/logs.html", job=job)
|
||||
|
||||
|
||||
@api_bp.get("/jobs/<job_id>")
|
||||
def job_json(job_id: str) -> Response:
|
||||
job = _service().get_job(job_id)
|
||||
if not job:
|
||||
abort(404)
|
||||
return jsonify(job.as_dict())
|
||||
@@ -0,0 +1,325 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Iterable, List, Optional
|
||||
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobLog:
|
||||
timestamp: float
|
||||
message: str
|
||||
level: str = "info"
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobResult:
|
||||
audio_path: Optional[Path] = None
|
||||
subtitle_paths: List[Path] = field(default_factory=list)
|
||||
artifacts: Dict[str, Path] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Job:
|
||||
id: str
|
||||
original_filename: str
|
||||
stored_path: Path
|
||||
language: str
|
||||
voice: str
|
||||
speed: float
|
||||
use_gpu: bool
|
||||
subtitle_mode: str
|
||||
output_format: str
|
||||
save_mode: str
|
||||
output_folder: Optional[Path]
|
||||
replace_single_newlines: bool
|
||||
subtitle_format: str
|
||||
created_at: float
|
||||
save_chapters_separately: bool = False
|
||||
merge_chapters_at_end: bool = True
|
||||
separate_chapters_format: str = "wav"
|
||||
silence_between_chapters: float = 2.0
|
||||
save_as_project: bool = False
|
||||
voice_profile: Optional[str] = None
|
||||
metadata_tags: Dict[str, str] = field(default_factory=dict)
|
||||
max_subtitle_words: int = 50
|
||||
status: JobStatus = JobStatus.PENDING
|
||||
started_at: Optional[float] = None
|
||||
finished_at: Optional[float] = None
|
||||
progress: float = 0.0
|
||||
total_characters: int = 0
|
||||
processed_characters: int = 0
|
||||
logs: List[JobLog] = field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
result: JobResult = field(default_factory=JobResult)
|
||||
chapters: List[str] = field(default_factory=list)
|
||||
queue_position: Optional[int] = None
|
||||
cancel_requested: bool = False
|
||||
|
||||
def add_log(self, message: str, level: str = "info") -> None:
|
||||
self.logs.append(JobLog(timestamp=time.time(), message=message, level=level))
|
||||
|
||||
def as_dict(self) -> Dict[str, object]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"original_filename": self.original_filename,
|
||||
"status": self.status.value,
|
||||
"use_gpu": self.use_gpu,
|
||||
"created_at": self.created_at,
|
||||
"started_at": self.started_at,
|
||||
"finished_at": self.finished_at,
|
||||
"progress": self.progress,
|
||||
"total_characters": self.total_characters,
|
||||
"processed_characters": self.processed_characters,
|
||||
"error": self.error,
|
||||
"logs": [log.__dict__ for log in self.logs],
|
||||
"result": {
|
||||
"audio": str(self.result.audio_path) if self.result.audio_path else None,
|
||||
"subtitles": [str(path) for path in self.result.subtitle_paths],
|
||||
"artifacts": {key: str(path) for key, path in self.result.artifacts.items()},
|
||||
},
|
||||
"queue_position": self.queue_position,
|
||||
"options": {
|
||||
"save_chapters_separately": self.save_chapters_separately,
|
||||
"merge_chapters_at_end": self.merge_chapters_at_end,
|
||||
"separate_chapters_format": self.separate_chapters_format,
|
||||
"silence_between_chapters": self.silence_between_chapters,
|
||||
"save_as_project": self.save_as_project,
|
||||
"voice_profile": self.voice_profile,
|
||||
"max_subtitle_words": self.max_subtitle_words,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ConversionService:
|
||||
def __init__(
|
||||
self,
|
||||
output_root: Path,
|
||||
runner: Callable[[Job], None],
|
||||
*,
|
||||
uploads_root: Optional[Path] = None,
|
||||
poll_interval: float = 0.5,
|
||||
) -> None:
|
||||
self._jobs: Dict[str, Job] = {}
|
||||
self._queue: List[str] = []
|
||||
self._lock = threading.RLock()
|
||||
self._worker_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
self._wake_event = threading.Event()
|
||||
self._output_root = output_root
|
||||
self._uploads_root = uploads_root or output_root / "uploads"
|
||||
self._runner = runner
|
||||
self._poll_interval = poll_interval
|
||||
self._ensure_directories()
|
||||
|
||||
# Public API ---------------------------------------------------------
|
||||
def list_jobs(self) -> List[Job]:
|
||||
with self._lock:
|
||||
return sorted(self._jobs.values(), key=lambda job: job.created_at, reverse=True)
|
||||
|
||||
def get_job(self, job_id: str) -> Optional[Job]:
|
||||
with self._lock:
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
*,
|
||||
original_filename: str,
|
||||
stored_path: Path,
|
||||
language: str,
|
||||
voice: str,
|
||||
speed: float,
|
||||
use_gpu: bool,
|
||||
subtitle_mode: str,
|
||||
output_format: str,
|
||||
save_mode: str,
|
||||
output_folder: Optional[Path],
|
||||
replace_single_newlines: bool,
|
||||
subtitle_format: str,
|
||||
total_characters: int,
|
||||
chapters: Optional[Iterable[str]] = None,
|
||||
save_chapters_separately: bool = False,
|
||||
merge_chapters_at_end: bool = True,
|
||||
separate_chapters_format: str = "wav",
|
||||
silence_between_chapters: float = 2.0,
|
||||
save_as_project: bool = False,
|
||||
voice_profile: Optional[str] = None,
|
||||
max_subtitle_words: int = 50,
|
||||
) -> Job:
|
||||
job_id = uuid.uuid4().hex
|
||||
job = Job(
|
||||
id=job_id,
|
||||
original_filename=original_filename,
|
||||
stored_path=stored_path,
|
||||
language=language,
|
||||
voice=voice,
|
||||
speed=speed,
|
||||
use_gpu=use_gpu,
|
||||
subtitle_mode=subtitle_mode,
|
||||
output_format=output_format,
|
||||
save_mode=save_mode,
|
||||
output_folder=output_folder,
|
||||
replace_single_newlines=replace_single_newlines,
|
||||
subtitle_format=subtitle_format,
|
||||
save_chapters_separately=save_chapters_separately,
|
||||
merge_chapters_at_end=merge_chapters_at_end,
|
||||
separate_chapters_format=separate_chapters_format,
|
||||
silence_between_chapters=silence_between_chapters,
|
||||
save_as_project=save_as_project,
|
||||
voice_profile=voice_profile,
|
||||
max_subtitle_words=max_subtitle_words,
|
||||
created_at=time.time(),
|
||||
total_characters=total_characters,
|
||||
chapters=list(chapters or []),
|
||||
)
|
||||
with self._lock:
|
||||
self._jobs[job_id] = job
|
||||
self._queue.append(job_id)
|
||||
self._update_queue_positions_locked()
|
||||
self._wake_event.set()
|
||||
self._ensure_worker()
|
||||
job.add_log("Job queued")
|
||||
return job
|
||||
|
||||
def cancel(self, job_id: str) -> bool:
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if job is None:
|
||||
return False
|
||||
if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
|
||||
return False
|
||||
job.cancel_requested = True
|
||||
job.add_log("Cancellation requested", level="warning")
|
||||
if job.status == JobStatus.PENDING:
|
||||
job.status = JobStatus.CANCELLED
|
||||
self._queue.remove(job_id)
|
||||
job.finished_at = time.time()
|
||||
self._update_queue_positions_locked()
|
||||
return True
|
||||
|
||||
def delete(self, job_id: str) -> bool:
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if job is None:
|
||||
return False
|
||||
if job.status in {JobStatus.RUNNING}:
|
||||
return False
|
||||
self._jobs.pop(job_id)
|
||||
if job_id in self._queue:
|
||||
self._queue.remove(job_id)
|
||||
self._update_queue_positions_locked()
|
||||
return True
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._stop_event.set()
|
||||
self._wake_event.set()
|
||||
if self._worker_thread and self._worker_thread.is_alive():
|
||||
self._worker_thread.join(timeout=5)
|
||||
self._worker_thread = None
|
||||
|
||||
# Internal -----------------------------------------------------------
|
||||
def _ensure_directories(self) -> None:
|
||||
self._output_root.mkdir(parents=True, exist_ok=True)
|
||||
self._uploads_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _ensure_worker(self) -> None:
|
||||
with self._lock:
|
||||
if self._worker_thread and self._worker_thread.is_alive():
|
||||
return
|
||||
self._stop_event.clear()
|
||||
self._worker_thread = threading.Thread(
|
||||
target=self._worker_loop,
|
||||
name="abogen-conversion-worker",
|
||||
daemon=True,
|
||||
)
|
||||
self._worker_thread.start()
|
||||
|
||||
def _worker_loop(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
job = None
|
||||
with self._lock:
|
||||
self._wake_event.clear()
|
||||
while self._queue and self._jobs[self._queue[0]].status in {
|
||||
JobStatus.CANCELLED,
|
||||
JobStatus.COMPLETED,
|
||||
JobStatus.FAILED,
|
||||
}:
|
||||
self._queue.pop(0)
|
||||
if self._queue:
|
||||
job = self._jobs[self._queue.pop(0)]
|
||||
else:
|
||||
self._update_queue_positions_locked()
|
||||
if job is None:
|
||||
self._wake_event.wait(timeout=self._poll_interval)
|
||||
continue
|
||||
if job.cancel_requested:
|
||||
job.add_log("Job cancelled before start", level="warning")
|
||||
job.status = JobStatus.CANCELLED
|
||||
job.finished_at = time.time()
|
||||
continue
|
||||
self._run_job(job)
|
||||
|
||||
def _run_job(self, job: Job) -> None:
|
||||
job.status = JobStatus.RUNNING
|
||||
job.started_at = time.time()
|
||||
job.add_log("Job started", level="info")
|
||||
try:
|
||||
self._runner(job)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
job.error = str(exc)
|
||||
job.status = JobStatus.FAILED
|
||||
job.finished_at = time.time()
|
||||
job.add_log(f"Job failed: {exc}", level="error")
|
||||
else:
|
||||
if job.cancel_requested:
|
||||
job.status = JobStatus.CANCELLED
|
||||
job.add_log("Job cancelled", level="warning")
|
||||
elif job.status != JobStatus.FAILED:
|
||||
job.status = JobStatus.COMPLETED
|
||||
job.add_log("Job completed", level="success")
|
||||
job.finished_at = time.time()
|
||||
finally:
|
||||
with self._lock:
|
||||
self._update_queue_positions_locked()
|
||||
|
||||
def _update_queue_positions_locked(self) -> None:
|
||||
for index, job_id in enumerate(self._queue, start=1):
|
||||
job = self._jobs.get(job_id)
|
||||
if job:
|
||||
job.queue_position = index
|
||||
|
||||
|
||||
def default_storage_root() -> Path:
|
||||
base = Path.cwd()
|
||||
uploads = base / "var" / "uploads"
|
||||
outputs = base / "var" / "outputs"
|
||||
outputs.mkdir(parents=True, exist_ok=True)
|
||||
uploads.mkdir(parents=True, exist_ok=True)
|
||||
return outputs
|
||||
|
||||
|
||||
def build_service(
|
||||
runner: Callable[[Job], None],
|
||||
*,
|
||||
output_root: Optional[Path] = None,
|
||||
uploads_root: Optional[Path] = None,
|
||||
) -> ConversionService:
|
||||
output_root = output_root or default_storage_root()
|
||||
service = ConversionService(
|
||||
output_root=output_root,
|
||||
uploads_root=uploads_root,
|
||||
runner=runner,
|
||||
)
|
||||
return service
|
||||
@@ -0,0 +1,382 @@
|
||||
:root {
|
||||
color-scheme: dark light;
|
||||
--bg: #0f172a;
|
||||
--bg-alt: #111c30;
|
||||
--panel: rgba(255, 255, 255, 0.04);
|
||||
--panel-border: rgba(148, 163, 184, 0.12);
|
||||
--text: #e2e8f0;
|
||||
--muted: #94a3b8;
|
||||
--accent: #38bdf8;
|
||||
--accent-strong: #0ea5e9;
|
||||
--success: #34d399;
|
||||
--danger: #f87171;
|
||||
--warning: #fbbf24;
|
||||
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at 0% 0%, rgba(56, 189, 248, 0.12), transparent 40%),
|
||||
radial-gradient(circle at 100% 0%, rgba(244, 114, 182, 0.08), transparent 45%),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
backdrop-filter: blur(20px);
|
||||
background: linear-gradient(90deg, rgba(15, 23, 42, 0.9), rgba(30, 41, 59, 0.7));
|
||||
padding: 1.25rem 3rem;
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.brand__mark {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.brand__name {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.brand__tagline {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.top-actions .btn {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 999px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.top-actions .btn:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 2.5rem 3rem 3.5rem;
|
||||
max-width: 1100px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 1.5rem 3rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--muted);
|
||||
border-top: 1px solid var(--panel-border);
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 28px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 20px 50px rgba(8, 15, 32, 0.35);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 0% 0%, rgba(56, 189, 248, 0.15), transparent 55%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card__title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.grid--two {
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
}
|
||||
|
||||
.button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 0.85rem 1.2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
box-shadow: 0 12px 30px rgba(56, 189, 248, 0.2);
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 16px 30px rgba(14, 165, 233, 0.28);
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--panel-border);
|
||||
color: var(--muted);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.button--ghost:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
transition: border 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus,
|
||||
.field textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.15);
|
||||
}
|
||||
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.badge--pending {
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.badge--running {
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge--completed {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.badge--failed,
|
||||
.badge--cancelled {
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.log-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
padding: 0.75rem 0.95rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.log-item--success {
|
||||
border-color: rgba(52, 211, 153, 0.4);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.log-item--error {
|
||||
border-color: rgba(248, 113, 113, 0.45);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.log-item--warning {
|
||||
border-color: rgba(251, 191, 36, 0.45);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.jobs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
overflow: hidden;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.jobs-table thead {
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.jobs-table th,
|
||||
.jobs-table td {
|
||||
padding: 0.9rem 1.15rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.jobs-table tbody tr {
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.jobs-table tbody tr:hover {
|
||||
background: rgba(56, 189, 248, 0.08);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar__fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
|
||||
border-radius: inherit;
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.list__item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 18px;
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.button--danger {
|
||||
background: linear-gradient(135deg, var(--danger), #ef4444);
|
||||
box-shadow: 0 12px 30px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.pill-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
}
|
||||
|
||||
progress.progress {
|
||||
width: 100%;
|
||||
height: 0.6rem;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
progress.progress::-webkit-progress-bar {
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
progress.progress::-webkit-progress-value {
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
progress.progress::-moz-progress-bar {
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
|
||||
border-radius: 999px;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}abogen{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('web.static', filename='styles.css') }}">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
|
||||
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="top-bar">
|
||||
<div class="brand">
|
||||
<span class="brand__mark">🔊</span>
|
||||
<span class="brand__name">abogen</span>
|
||||
<span class="brand__tagline">Audiobooks for humans, not desktops</span>
|
||||
</div>
|
||||
<nav class="top-actions">
|
||||
<a href="{{ url_for('web.index') }}" class="btn">Dashboard</a>
|
||||
<a href="{{ url_for('web.voice_profiles_page') }}" class="btn">Voice mixer</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="footer">
|
||||
<span>Need help?</span>
|
||||
<a href="https://github.com/denizsafak/abogen" target="_blank" rel="noopener">Read the docs</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,130 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}abogen · Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h1 class="card__title">Create a new audiobook</h1>
|
||||
<form action="{{ url_for('web.enqueue_job') }}" method="post" enctype="multipart/form-data" class="grid grid--two">
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="source_file">Source file</label>
|
||||
<input type="file" id="source_file" name="source_file" accept=".txt,.pdf,.epub,.md,.markdown">
|
||||
<p class="tag">You can also paste text below.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="language">Language</label>
|
||||
<select id="language" name="language">
|
||||
{% for key, label in options.languages.items() %}
|
||||
<option value="{{ key }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="voice">Voice</label>
|
||||
<select id="voice" name="voice">
|
||||
{% for voice in options.voices %}
|
||||
<option value="{{ voice }}">{{ voice }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="voice_profile">Voice profile</label>
|
||||
<select id="voice_profile" name="voice_profile">
|
||||
<option value="">Use selected voice</option>
|
||||
{% for name, data in options.voice_profiles %}
|
||||
<option value="{{ name }}">{{ name }} ({{ data.language|upper }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="tag">Manage mixes in the <a href="{{ url_for('web.voice_profiles') }}">voice mixer</a>.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="voice_formula">Custom voice formula</label>
|
||||
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6">
|
||||
<p class="tag">Overrides the dropdown/profile when provided.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="speed">Speed <span class="tag" id="speed_value">1.00×</span></label>
|
||||
<input type="range" id="speed" name="speed" min="0.6" max="1.4" step="0.05" value="1.0" oninput="document.getElementById('speed_value').textContent = Number.parseFloat(this.value).toFixed(2) + '×';">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="subtitle_mode">Subtitle mode</label>
|
||||
<select id="subtitle_mode" name="subtitle_mode">
|
||||
<option value="Disabled">Disabled</option>
|
||||
<option value="Sentence">Sentence</option>
|
||||
<option value="Sentence + Comma">Sentence + Comma</option>
|
||||
<option value="Sentence + Highlighting">Sentence + Highlighting</option>
|
||||
{% for i in range(1, 11) %}
|
||||
<option value="{{ i }} {% if i == 1 %}word{% else %}words{% endif %}">{{ i }} {% if i == 1 %}word{% else %}words{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="output_format">Audio format</label>
|
||||
<select id="output_format" name="output_format">
|
||||
{% for fmt in options.output_formats %}
|
||||
<option value="{{ fmt }}">{{ fmt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="subtitle_format">Subtitle file format</label>
|
||||
<select id="subtitle_format" name="subtitle_format">
|
||||
{% for value, text in options.subtitle_formats %}
|
||||
<option value="{{ value }}">{{ text }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="save_mode">Save location</label>
|
||||
<select id="save_mode" name="save_mode">
|
||||
<option>Save next to input file</option>
|
||||
<option>Save to Desktop</option>
|
||||
<option>Choose output folder</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="split">
|
||||
<label class="tag"><input type="checkbox" name="replace_single_newlines" value="true"> Replace single newlines</label>
|
||||
<label class="tag"><input type="checkbox" name="use_gpu" value="true" checked> Use GPU (when available)</label>
|
||||
</div>
|
||||
<details class="field">
|
||||
<summary>Advanced chapter & project options</summary>
|
||||
<div class="field inline">
|
||||
<label class="tag"><input type="checkbox" name="save_chapters_separately" value="true"> Save each chapter separately</label>
|
||||
<label class="tag"><input type="checkbox" name="merge_chapters_at_end" value="true" checked> Also create merged audiobook</label>
|
||||
<label class="tag"><input type="checkbox" name="save_as_project" value="true"> Save in project folder with metadata</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="separate_chapters_format">Separate chapter format</label>
|
||||
<select id="separate_chapters_format" name="separate_chapters_format">
|
||||
{% for fmt in options.separate_formats %}
|
||||
<option value="{{ fmt }}">{{ fmt|upper }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="silence_between_chapters">Silence between chapters (seconds)</label>
|
||||
<input type="number" step="0.5" min="0" value="2.0" id="silence_between_chapters" name="silence_between_chapters">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="max_subtitle_words">Max words per subtitle entry</label>
|
||||
<input type="number" min="1" max="100" value="50" id="max_subtitle_words" name="max_subtitle_words">
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="source_text">Or paste text directly</label>
|
||||
<textarea id="source_text" name="source_text" rows="12" placeholder="Drop some text here if you don't have a file handy..."></textarea>
|
||||
</div>
|
||||
<div class="field" style="justify-content: flex-end;">
|
||||
<button type="submit" class="button">Queue conversion</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card" id="jobs-panel" hx-get="{{ url_for('web.jobs_partial') }}" hx-trigger="load, every 3s" hx-target="#jobs-panel" hx-swap="innerHTML">
|
||||
{% include "partials/jobs.html" %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Job · {{ job.original_filename }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div class="card__title">{{ job.original_filename }}</div>
|
||||
<div class="grid grid--two">
|
||||
<article>
|
||||
<h2>Settings</h2>
|
||||
<ul>
|
||||
<li><strong>Voice:</strong> {{ job.voice }}</li>
|
||||
<li><strong>Language:</strong> {{ options.languages.get(job.language, job.language) }}</li>
|
||||
{% if job.voice_profile %}
|
||||
<li><strong>Voice profile:</strong> {{ job.voice_profile }}</li>
|
||||
{% endif %}
|
||||
<li><strong>Speed:</strong> {{ '%.2f' | format(job.speed) }}×</li>
|
||||
<li><strong>Subtitle mode:</strong> {{ job.subtitle_mode }}</li>
|
||||
<li><strong>Audio format:</strong> {{ job.output_format }}</li>
|
||||
<li><strong>Subtitle format:</strong> {{ job.subtitle_format }}</li>
|
||||
<li><strong>GPU:</strong> {{ 'Yes' if job.use_gpu else 'No' }}</li>
|
||||
<li><strong>Save chapters separately:</strong> {{ 'Yes' if job.save_chapters_separately else 'No' }}</li>
|
||||
<li><strong>Merge at end:</strong> {{ 'Yes' if job.merge_chapters_at_end else 'No' }}</li>
|
||||
<li><strong>Separate chapter format:</strong> {{ job.separate_chapters_format|upper }}</li>
|
||||
<li><strong>Silence between chapters:</strong> {{ '%.1f'|format(job.silence_between_chapters) }}s</li>
|
||||
<li><strong>Max words per subtitle:</strong> {{ job.max_subtitle_words }}</li>
|
||||
<li><strong>Project folder:</strong> {{ 'Yes' if job.save_as_project else 'No' }}</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Status</h2>
|
||||
<p><span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span></p>
|
||||
<p>Created: {{ job.created_at|datetimeformat }}</p>
|
||||
<p>Started: {{ job.started_at|datetimeformat }}</p>
|
||||
<p>Finished: {{ job.finished_at|datetimeformat }}</p>
|
||||
<p>Characters: {{ job.processed_characters }} / {{ job.total_characters or '—' }}</p>
|
||||
{% if job.result.audio_path %}
|
||||
<p><a class="button" href="{{ url_for('web.download_job', job_id=job.id) }}">Download audio</a></p>
|
||||
{% endif %}
|
||||
<form action="{{ url_for('web.cancel_job', job_id=job.id) }}" method="post">
|
||||
<button type="submit" class="button button--ghost">Cancel job</button>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="logs" hx-get="{{ url_for('web.job_logs_partial', job_id=job.id) }}" hx-trigger="load, every 2s" hx-target="#logs" hx-swap="innerHTML">
|
||||
{% include "partials/logs.html" %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
<div class="card__title">Queue</div>
|
||||
{% if jobs %}
|
||||
<table class="jobs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job</th>
|
||||
<th>Status</th>
|
||||
<th>Progress</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ job.original_filename }}</strong>
|
||||
<div class="tag">
|
||||
{% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<progress value="{{ job.processed_characters or 0 }}" max="{{ job.total_characters or 1 }}" class="progress"></progress>
|
||||
<small>{{ job.processed_characters }} / {{ job.total_characters or '—' }}</small>
|
||||
</td>
|
||||
<td><small>{{ job.created_at|datetimeformat }}</small></td>
|
||||
<td><a class="btn" href="{{ url_for('web.job_detail', job_id=job.id) }}">Inspect</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No jobs yet. Drop a file or paste text to get started.</p>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="card__title">Live log</div>
|
||||
{% if job.logs %}
|
||||
<ul class="log-list">
|
||||
{% for entry in job.logs|reverse %}
|
||||
<li class="log-item log-item--{{ entry.level }}">
|
||||
<small>{{ entry.timestamp|datetimeformat('%H:%M:%S') }}</small>
|
||||
<div>{{ entry.message }}</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No log entries yet. Check back soon!</p>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}abogen · Voice mixer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h1 class="card__title">Voice mixer</h1>
|
||||
<p class="tag">Blend multiple voices, store them as reusable profiles, and reuse them in the dashboard form.</p>
|
||||
<div class="grid grid--two">
|
||||
<div class="grid">
|
||||
<h2>Saved profiles</h2>
|
||||
{% if profiles %}
|
||||
<ul class="list">
|
||||
{% for profile in profiles %}
|
||||
<li class="list__item">
|
||||
<div>
|
||||
<strong>{{ profile.name }}</strong>
|
||||
<p class="tag">Language: {{ languages.get(profile.language, profile.language|upper) }}</p>
|
||||
<p class="tag">Formula: {{ profile.formula }}</p>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('web.delete_voice_profile_route', name=profile.name) }}">
|
||||
<button type="submit" class="button button--danger">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No saved profiles yet. Use the form to create one.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="grid">
|
||||
<h2>Create or update</h2>
|
||||
<form method="post" action="{{ url_for('web.save_voice_profile_route') }}" class="grid">
|
||||
<div class="field">
|
||||
<label for="name">Profile name</label>
|
||||
<input type="text" id="name" name="name" required placeholder="Narrator Mix">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="language">Language</label>
|
||||
<select id="language" name="language">
|
||||
{% for key, label in languages.items() %}
|
||||
<option value="{{ key }}">{{ label }} ({{ key|upper }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="formula">Voice formula</label>
|
||||
<input type="text" id="formula" name="formula" required placeholder="af_nova*0.4+am_liam*0.6">
|
||||
<p class="tag">Use <code>voice_name*weight</code> segments joined with <code>+</code>. Weights will be normalised automatically.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="voices">Available voices</label>
|
||||
<div class="pill-grid">
|
||||
{% for voice in voices %}
|
||||
<span class="pill">{{ voice }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<button type="submit" class="button">Save profile</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user