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:
JB
2025-10-05 15:53:33 -07:00
parent 033c809b95
commit 338ff104e8
18 changed files with 2353 additions and 436 deletions
+46 -37
View File
@@ -1,42 +1,51 @@
# Special thanks to @geo38 from Reddit, who provided this Dockerfile:
# https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/
FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04
# Use a docker base image that runs a window manager that can be viewed
# outside the image with a web browser or VNC client.
# https://github.com/jlesage/docker-baseimage-gui
FROM jlesage/baseimage-gui:debian-12-v4
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
VIRTUAL_ENV=/opt/venv \
PATH=/opt/venv/bin:$PATH
ARG TORCH_INDEX_URL=https://download.pytorch.org/whl/cu124
ARG TORCH_VERSION=
# Load stuff needed by abogen
RUN apt-get update \
&& apt-get install -y \
python3 \
python3-venv \
python3-pip \
python3-pyqt5 \
espeak-ng \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
python3 \
python3-venv \
python3-pip \
ffmpeg \
libsndfile1 \
libgl1 \
libglib2.0-0 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# The base image will run /startapp.sh on launch.
#
# The base image runs that script as user 'app' uid=1000. That user
# does not exist in the base image but is created at run time.
#
# We need to install abogen in python venv (requirement of newer python3).
#
# The python venv has to be writable by the 'app' user as abogen dynamically
# installs python packages, so create the venv as that user
#
# We intend to share the /shared directory with the host using a bind volume
# in order to access any source files and the created files.
RUN python3 -m venv "$VIRTUAL_ENV"
RUN echo '#!/bin/bash\nsource /app/venv/bin/activate\nexec abogen' > /startapp.sh \
&& chmod 555 /startapp.sh \
&& mkdir /app /shared \
&& chown 1000:1000 /app /shared \
&& chmod 755 /app /shared
USER 1000:1000
RUN python3 -m venv /app/venv
RUN /bin/bash -c "source /app/venv/bin/activate && pip install abogen"
# Change back to user ROOT as the startup scripts inside base image needs it
USER root
WORKDIR /app
COPY pyproject.toml README.md ./
COPY abogen ./abogen
RUN pip install --upgrade pip \
&& if [ -n "$TORCH_VERSION" ]; then \
pip install torch=="$TORCH_VERSION" torchvision=="$TORCH_VERSION" torchaudio=="$TORCH_VERSION" --index-url "$TORCH_INDEX_URL"; \
else \
pip install torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \
fi \
&& pip install --no-cache-dir .
ENV ABOGEN_HOST=0.0.0.0 \
ABOGEN_PORT=8000
EXPOSE 8000
VOLUME ["/data"]
ENV ABOGEN_UPLOAD_ROOT=/data/uploads \
ABOGEN_OUTPUT_ROOT=/data/outputs
RUN mkdir -p /data/uploads /data/outputs
CMD ["abogen"]
+188
View File
@@ -0,0 +1,188 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
import re
from typing import List, Sequence
import ebooklib
import fitz
import markdown
from bs4 import BeautifulSoup
from ebooklib import epub
from .utils import clean_text, detect_encoding
@dataclass
class ExtractedChapter:
title: str
text: str
@property
def characters(self) -> int:
return len(self.text)
@dataclass
class ExtractionResult:
chapters: List[ExtractedChapter]
metadata: dict[str, str] = field(default_factory=dict)
@property
def combined_text(self) -> str:
return "\n\n".join(chapter.text for chapter in self.chapters)
@property
def total_characters(self) -> int:
return sum(chapter.characters for chapter in self.chapters)
def extract_from_path(path: Path) -> ExtractionResult:
suffix = path.suffix.lower()
if suffix == ".txt":
return _extract_plaintext(path)
if suffix == ".pdf":
return _extract_pdf(path)
if suffix in {".md", ".markdown"}:
return _extract_markdown(path)
if suffix == ".epub":
return _extract_epub(path)
raise ValueError(f"Unsupported input type: {suffix}")
def _extract_plaintext(path: Path) -> ExtractionResult:
encoding = detect_encoding(str(path))
raw = path.read_text(encoding=encoding, errors="replace")
return _extract_from_string(raw, default_title=path.stem)
METADATA_PATTERN = re.compile(r"<<METADATA_([A-Z_]+):(.*?)>>", re.DOTALL)
CHAPTER_PATTERN = re.compile(r"<<CHAPTER_MARKER:(.*?)>>", re.IGNORECASE)
def _extract_from_string(raw: str, default_title: str) -> ExtractionResult:
metadata, body = _strip_metadata(raw)
chapters = _split_chapters(body, default_title)
if not chapters:
chapters = [ExtractedChapter(title=default_title, text="")]
return ExtractionResult(chapters=chapters, metadata=metadata)
def _strip_metadata(content: str) -> tuple[dict[str, str], str]:
metadata: dict[str, str] = {}
def _replacer(match: re.Match) -> str:
key = match.group(1).strip().upper()
value = match.group(2).strip()
if value:
metadata[key] = value
return ""
stripped = METADATA_PATTERN.sub(_replacer, content)
return metadata, stripped
def _split_chapters(content: str, default_title: str) -> List[ExtractedChapter]:
matches = list(CHAPTER_PATTERN.finditer(content))
if not matches:
cleaned = clean_text(content)
return [ExtractedChapter(title=default_title, text=cleaned)]
chapters: List[ExtractedChapter] = []
last_index = 0
current_title = default_title
for match in matches:
segment = content[last_index:match.start()]
if segment.strip():
chapters.append(ExtractedChapter(title=current_title, text=clean_text(segment)))
current_title = match.group(1).strip() or default_title
last_index = match.end()
tail = content[last_index:]
if tail.strip():
chapters.append(ExtractedChapter(title=current_title, text=clean_text(tail)))
return chapters
def _extract_pdf(path: Path) -> ExtractionResult:
document = fitz.open(str(path))
chapters: List[ExtractedChapter] = []
for index, page in enumerate(document):
text = clean_text(page.get_text())
if not text:
continue
title = f"Page {index + 1}"
chapters.append(ExtractedChapter(title=title, text=text))
if not chapters:
chapters.append(ExtractedChapter(title=path.stem, text=""))
return ExtractionResult(chapters)
def _extract_markdown(path: Path) -> ExtractionResult:
encoding = detect_encoding(str(path))
raw = path.read_text(encoding=encoding, errors="replace")
html = markdown.markdown(raw, extensions=["toc", "fenced_code"])
soup = BeautifulSoup(html, "html.parser")
headings = soup.find_all([f"h{i}" for i in range(1, 7)])
chapters: List[ExtractedChapter] = []
if headings:
for heading in headings:
sibling_text = _collect_heading_text(heading)
text = clean_text(sibling_text)
if text:
chapters.append(ExtractedChapter(title=heading.get_text(strip=True), text=text))
if not chapters:
chapters.append(ExtractedChapter(title=path.stem, text=clean_text(raw)))
return ExtractionResult(chapters)
def _collect_heading_text(node) -> str:
texts: List[str] = []
for sibling in node.next_siblings:
if getattr(sibling, "name", None) and sibling.name.startswith("h"):
break
text = getattr(sibling, "get_text", lambda **_: "")()
if text:
texts.append(text)
return "\n".join(texts)
def _extract_epub(path: Path) -> ExtractionResult:
book = epub.read_epub(str(path))
chapters: List[ExtractedChapter] = []
spine_docs: Sequence[str] = [item[0] for item in book.spine]
for item in book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
name = item.get_name()
if name not in spine_docs:
continue
html_bytes = item.get_content()
soup = BeautifulSoup(html_bytes, "html.parser")
for ol in soup.find_all("ol"):
start = int(ol.get("start", 1))
for idx, li in enumerate(ol.find_all("li", recursive=False)):
number = f"{start + idx}. "
if li.string:
li.string.replace_with(number + li.string)
else:
li.insert(0, number)
text = clean_text(soup.get_text())
if not text:
continue
title = _resolve_epub_title(soup, name)
chapters.append(ExtractedChapter(title=title, text=text))
if not chapters:
chapters.append(ExtractedChapter(title=path.stem, text=""))
return ExtractionResult(chapters)
def _resolve_epub_title(soup: BeautifulSoup, fallback: str) -> str:
if soup.title and soup.title.string:
return soup.title.string.strip()
for heading_tag in ("h1", "h2", "h3"):
heading = soup.find(heading_tag)
if heading and heading.get_text(strip=True):
return heading.get_text(strip=True)
return fallback
+9
View File
@@ -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)
+77
View File
@@ -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()
+430
View File
@@ -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
+335
View File
@@ -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())
+325
View File
@@ -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
+382
View File
@@ -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;
}
+31
View File
@@ -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>
+130
View File
@@ -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 &amp; 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 %}
+50
View File
@@ -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 %}
+37
View File
@@ -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 %}
+13
View File
@@ -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 %}
+66
View File
@@ -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 %}