feat: add remote audiobook generation
This commit is contained in:
@@ -0,0 +1,688 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Iterator
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import imageio_ffmpeg
|
||||
import soundfile as sf
|
||||
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from num2words import num2words
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from qwen_tts import Qwen3TTSModel
|
||||
|
||||
|
||||
ROOT = Path(os.environ.get("AUDIOBOOK_SERVICE_ROOT", "/home/sevenhill/apps/aletheia-audiobook"))
|
||||
SERVICE_ROOT = ROOT / "service"
|
||||
DATA_ROOT = ROOT / "audiobook-jobs"
|
||||
TOKEN_FILE = SERVICE_ROOT / "service-token.txt"
|
||||
DATABASE = DATA_ROOT / "jobs.db3"
|
||||
PERFORMANCE_LOG = DATA_ROOT / "performance.jsonl"
|
||||
MODEL_ID = os.environ.get("QWEN_TTS_MODEL_ID", "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice")
|
||||
MAX_UPLOAD_BYTES = 200 * 1024 * 1024
|
||||
GPU_IDS = tuple(
|
||||
int(value.strip())
|
||||
for value in os.environ.get("QWEN_TTS_GPU_IDS", "0,1,2").split(",")
|
||||
if value.strip()
|
||||
)
|
||||
SPEAKER = os.environ.get("QWEN_TTS_SPEAKER", "Ryan")
|
||||
VOICE_INSTRUCTION = (
|
||||
"Read in a calm, clear, natural audiobook style with steady pacing and distinct diction."
|
||||
)
|
||||
|
||||
SERVICE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
DATA_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
OLLAMA_PAUSE_MARKER = SERVICE_ROOT / "ollama-paused-for-audiobook"
|
||||
if not TOKEN_FILE.exists():
|
||||
TOKEN_FILE.write_text(secrets.token_urlsafe(48), encoding="ascii")
|
||||
API_TOKEN = TOKEN_FILE.read_text(encoding="ascii").strip()
|
||||
|
||||
|
||||
def pause_ollama_for_audiobook() -> None:
|
||||
"""Release every CMP GPU before loading the three Qwen workers."""
|
||||
subprocess.run(["systemctl", "--user", "stop", "ollama.service"], check=True)
|
||||
subprocess.run(["systemctl", "--user", "mask", "--runtime", "ollama.service"], check=True)
|
||||
OLLAMA_PAUSE_MARKER.write_text("1", encoding="ascii")
|
||||
|
||||
|
||||
def restore_ollama_after_audiobook() -> None:
|
||||
if not OLLAMA_PAUSE_MARKER.exists():
|
||||
return
|
||||
subprocess.run(["systemctl", "--user", "unmask", "--runtime", "ollama.service"], check=True)
|
||||
subprocess.run(["systemctl", "--user", "start", "ollama.service"], check=True)
|
||||
OLLAMA_PAUSE_MARKER.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# The worker terminates after every job to release CUDA/Triton contexts. The
|
||||
# next lightweight receiver process restores Ollama before accepting new work.
|
||||
restore_ollama_after_audiobook()
|
||||
|
||||
app = FastAPI(title="Aletheia CMP Audiobook Service", version="1.1.0")
|
||||
queue_condition = threading.Condition()
|
||||
queued_jobs: list[str] = []
|
||||
worker_state = {"activeJobId": None, "modelLoaded": False}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def database() -> Iterator[sqlite3.Connection]:
|
||||
connection = sqlite3.connect(DATABASE, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield connection
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def initialize_database() -> None:
|
||||
with database() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
input_path TEXT NOT NULL,
|
||||
output_path TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
stage TEXT NOT NULL,
|
||||
processed_characters INTEGER NOT NULL DEFAULT 0,
|
||||
total_characters INTEGER NOT NULL DEFAULT 0,
|
||||
current_chapter TEXT,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
chapters_json TEXT NOT NULL DEFAULT '[]',
|
||||
error_message TEXT,
|
||||
cancel_requested INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE jobs SET status='queued', stage='queued' "
|
||||
"WHERE status IN ('extracting','synthesizing','encoding')"
|
||||
)
|
||||
rows = connection.execute(
|
||||
"SELECT id FROM jobs WHERE status='queued' ORDER BY created_at"
|
||||
).fetchall()
|
||||
queued_jobs.extend(row["id"] for row in rows)
|
||||
|
||||
|
||||
def require_token(authorization: Annotated[str | None, Header()] = None) -> None:
|
||||
expected = f"Bearer {API_TOKEN}"
|
||||
if not authorization or not hmac.compare_digest(authorization, expected):
|
||||
raise HTTPException(status_code=401, detail="Invalid API token")
|
||||
|
||||
|
||||
def now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def get_job(job_id: str) -> sqlite3.Row:
|
||||
with database() as connection:
|
||||
row = connection.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Audiobook job not found")
|
||||
return row
|
||||
|
||||
|
||||
def job_payload(row: sqlite3.Row) -> dict:
|
||||
total = row["total_characters"]
|
||||
processed = row["processed_characters"]
|
||||
progress = min(100, int(processed * 100 / total)) if total else 0
|
||||
if row["status"] == "ready":
|
||||
progress = 100
|
||||
return {
|
||||
"id": row["id"],
|
||||
"status": row["status"],
|
||||
"stage": row["stage"],
|
||||
"progress": progress,
|
||||
"processedCharacters": processed,
|
||||
"totalCharacters": total,
|
||||
"currentChapter": row["current_chapter"],
|
||||
"durationMs": row["duration_ms"],
|
||||
"chapters": json.loads(row["chapters_json"] or "[]"),
|
||||
"error": row["error_message"],
|
||||
"createdAt": row["created_at"],
|
||||
"updatedAt": row["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"workerMode": "lazy-model",
|
||||
"activeJobId": worker_state["activeJobId"],
|
||||
"modelLoaded": worker_state["modelLoaded"],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/audiobooks", dependencies=[Depends(require_token)])
|
||||
async def create_audiobook(
|
||||
book: Annotated[UploadFile, File()],
|
||||
title: Annotated[str, Form()],
|
||||
author: Annotated[str, Form()] = "",
|
||||
) -> dict:
|
||||
extension = Path(book.filename or "book.epub").suffix.lower()
|
||||
if extension not in {".epub", ".fb2"}:
|
||||
raise HTTPException(status_code=400, detail="Only EPUB and FB2 are supported")
|
||||
job_id = uuid.uuid4().hex
|
||||
job_root = DATA_ROOT / job_id
|
||||
job_root.mkdir(parents=True)
|
||||
input_path = job_root / f"source{extension}"
|
||||
received = 0
|
||||
try:
|
||||
with input_path.open("wb") as output:
|
||||
while chunk := await book.read(1024 * 1024):
|
||||
received += len(chunk)
|
||||
if received > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Book file is too large")
|
||||
output.write(chunk)
|
||||
except Exception:
|
||||
shutil.rmtree(job_root, ignore_errors=True)
|
||||
raise
|
||||
timestamp = now_ms()
|
||||
with database() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO jobs (
|
||||
id,title,author,source_name,input_path,output_path,status,stage,created_at,updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
title.strip() or Path(book.filename or "Книга").stem,
|
||||
author.strip(),
|
||||
book.filename or input_path.name,
|
||||
str(input_path),
|
||||
str(job_root / "audiobook.m4a"),
|
||||
"queued",
|
||||
"queued",
|
||||
timestamp,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
with queue_condition:
|
||||
queued_jobs.append(job_id)
|
||||
queue_condition.notify()
|
||||
return job_payload(get_job(job_id))
|
||||
|
||||
|
||||
@app.get("/v1/audiobooks/{job_id}", dependencies=[Depends(require_token)])
|
||||
def audiobook_status(job_id: str) -> dict:
|
||||
return job_payload(get_job(job_id))
|
||||
|
||||
|
||||
@app.get("/v1/audiobooks/{job_id}/file", dependencies=[Depends(require_token)])
|
||||
def audiobook_file(job_id: str) -> FileResponse:
|
||||
row = get_job(job_id)
|
||||
output = Path(row["output_path"])
|
||||
if row["status"] != "ready" or not output.is_file():
|
||||
raise HTTPException(status_code=409, detail="Audiobook is not ready")
|
||||
safe_title = re.sub(r"[^0-9A-Za-zА-Яа-яЁё._ -]+", "_", row["title"]).strip() or "audiobook"
|
||||
return FileResponse(output, media_type="audio/mp4", filename=f"{safe_title}.m4a")
|
||||
|
||||
|
||||
@app.delete("/v1/audiobooks/{job_id}", dependencies=[Depends(require_token)])
|
||||
def cancel_or_delete(job_id: str) -> dict:
|
||||
row = get_job(job_id)
|
||||
if row["status"] == "queued":
|
||||
with queue_condition:
|
||||
queued_jobs[:] = [queued_id for queued_id in queued_jobs if queued_id != job_id]
|
||||
delete_job(job_id, Path(row["input_path"]).parent)
|
||||
return {"status": "deleted"}
|
||||
if row["status"] in {"extracting", "synthesizing", "encoding"}:
|
||||
with database() as connection:
|
||||
connection.execute(
|
||||
"UPDATE jobs SET cancel_requested=1, stage='cancelling', updated_at=? WHERE id=?",
|
||||
(now_ms(), job_id),
|
||||
)
|
||||
return {"status": "cancelling"}
|
||||
delete_job(job_id, Path(row["input_path"]).parent)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
def delete_job(job_id: str, job_root: Path) -> None:
|
||||
shutil.rmtree(job_root, ignore_errors=True)
|
||||
with database() as connection:
|
||||
connection.execute("DELETE FROM jobs WHERE id=?", (job_id,))
|
||||
|
||||
|
||||
def finish_cancellation(job_id: str, job_root: Path) -> None:
|
||||
update_job(job_id, status="cancelled", stage="cancelled")
|
||||
delete_job(job_id, job_root)
|
||||
|
||||
|
||||
def local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1].lower()
|
||||
|
||||
|
||||
def clean_text(value: str) -> str:
|
||||
value = html.unescape(re.sub(r"<[^>]+>", " ", value))
|
||||
value = value.replace("\u00a0", " ")
|
||||
return re.sub(r"\s+", " ", value).strip()
|
||||
|
||||
|
||||
def extract_epub(path: Path) -> list[tuple[str, str]]:
|
||||
sections: list[tuple[str, str]] = []
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
container = ElementTree.fromstring(archive.read("META-INF/container.xml"))
|
||||
rootfile = next(node for node in container.iter() if local_name(node.tag) == "rootfile")
|
||||
opf_path = rootfile.attrib["full-path"]
|
||||
opf = ElementTree.fromstring(archive.read(opf_path))
|
||||
manifest = {
|
||||
node.attrib["id"]: node.attrib["href"]
|
||||
for node in opf.iter()
|
||||
if local_name(node.tag) == "item" and "id" in node.attrib and "href" in node.attrib
|
||||
}
|
||||
spine = [
|
||||
node.attrib["idref"]
|
||||
for node in opf.iter()
|
||||
if local_name(node.tag) == "itemref" and "idref" in node.attrib
|
||||
]
|
||||
base = PurePosixPath(opf_path).parent
|
||||
for index, item_id in enumerate(spine, start=1):
|
||||
href = manifest.get(item_id)
|
||||
if not href:
|
||||
continue
|
||||
entry = str(base / href.split("#", 1)[0])
|
||||
raw = archive.read(entry).decode("utf-8", errors="replace")
|
||||
heading_match = re.search(r"<h[1-3]\b[^>]*>(.*?)</h[1-3]>", raw, re.I | re.S)
|
||||
title = clean_text(heading_match.group(1)) if heading_match else f"Раздел {index}"
|
||||
paragraphs = [clean_text(match) for match in re.findall(r"<p\b[^>]*>(.*?)</p>", raw, re.I | re.S)]
|
||||
text = "\n".join(paragraph for paragraph in paragraphs if paragraph)
|
||||
if text:
|
||||
sections.append((title, text))
|
||||
return sections
|
||||
|
||||
|
||||
def element_text_without_nested_sections(element: ElementTree.Element) -> str:
|
||||
parts: list[str] = []
|
||||
if element.text:
|
||||
parts.append(element.text)
|
||||
for child in element:
|
||||
if local_name(child.tag) != "section":
|
||||
parts.append(" ".join(child.itertext()))
|
||||
if child.tail:
|
||||
parts.append(child.tail)
|
||||
return clean_text(" ".join(parts))
|
||||
|
||||
|
||||
def extract_fb2(path: Path) -> list[tuple[str, str]]:
|
||||
root = ElementTree.parse(path).getroot()
|
||||
sections: list[tuple[str, str]] = []
|
||||
for section in root.iter():
|
||||
if local_name(section.tag) != "section":
|
||||
continue
|
||||
title_element = next((child for child in section if local_name(child.tag) == "title"), None)
|
||||
title = clean_text(" ".join(title_element.itertext())) if title_element is not None else ""
|
||||
text = element_text_without_nested_sections(section)
|
||||
if text:
|
||||
sections.append((title or f"Раздел {len(sections) + 1}", text))
|
||||
return sections
|
||||
|
||||
|
||||
def normalize_numbers(text: str) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
value = match.group(0)
|
||||
try:
|
||||
return num2words(int(value), lang="ru")
|
||||
except (ValueError, OverflowError):
|
||||
return value
|
||||
|
||||
return re.sub(r"(?<![\w+])\d{1,12}(?!\w)", replace, text)
|
||||
|
||||
|
||||
def split_chunks(sections: list[tuple[str, str]], limit: int = 520) -> list[dict]:
|
||||
chunks: list[dict] = []
|
||||
for chapter, text in sections:
|
||||
sentences = re.split(r"(?<=[.!?…])\s+|\n+", text)
|
||||
current = ""
|
||||
for sentence in sentences:
|
||||
sentence = normalize_numbers(sentence.strip())
|
||||
if not sentence or not re.search(r"[А-Яа-яЁё]", sentence):
|
||||
continue
|
||||
if len(sentence) > limit:
|
||||
pieces = re.split(r"(?<=[,;:])\s+", sentence)
|
||||
else:
|
||||
pieces = [sentence]
|
||||
for piece in pieces:
|
||||
if current and len(current) + 1 + len(piece) > limit:
|
||||
chunks.append({"chapter": chapter, "text": current})
|
||||
current = piece
|
||||
else:
|
||||
current = f"{current} {piece}".strip()
|
||||
if current:
|
||||
chunks.append({"chapter": chapter, "text": current})
|
||||
return chunks
|
||||
|
||||
|
||||
def update_job(job_id: str, **values: object) -> None:
|
||||
values["updated_at"] = now_ms()
|
||||
assignments = ",".join(f"{key}=?" for key in values)
|
||||
with database() as connection:
|
||||
connection.execute(
|
||||
f"UPDATE jobs SET {assignments} WHERE id=?",
|
||||
(*values.values(), job_id),
|
||||
)
|
||||
|
||||
|
||||
def cancellation_requested(job_id: str) -> bool:
|
||||
with database() as connection:
|
||||
row = connection.execute("SELECT cancel_requested FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||||
return row is None or bool(row["cancel_requested"])
|
||||
|
||||
|
||||
def load_model(device_id: int) -> "Qwen3TTSModel":
|
||||
import torch
|
||||
from qwen_tts import Qwen3TTSModel
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
raise RuntimeError("CUDA is unavailable")
|
||||
torch.set_float32_matmul_precision("high")
|
||||
model = Qwen3TTSModel.from_pretrained(
|
||||
MODEL_ID,
|
||||
device_map=f"cuda:{device_id}",
|
||||
dtype=torch.bfloat16,
|
||||
attn_implementation="sdpa",
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
def load_models() -> list["Qwen3TTSModel"]:
|
||||
if not GPU_IDS:
|
||||
raise RuntimeError("No Qwen GPU IDs are configured")
|
||||
models = [load_model(device_id) for device_id in GPU_IDS]
|
||||
worker_state["modelLoaded"] = True
|
||||
return models
|
||||
|
||||
|
||||
def generate_custom_voice_with_metrics(
|
||||
model: Any,
|
||||
batch_chunks: list[dict],
|
||||
device_id: int,
|
||||
) -> tuple[list[Any], int]:
|
||||
"""Measure Qwen talker and codec stages without changing generated audio."""
|
||||
import torch
|
||||
|
||||
timings: dict[str, float] = {}
|
||||
original_generate = model.model.generate
|
||||
speech_tokenizer = model.model.speech_tokenizer
|
||||
original_decode = speech_tokenizer.decode
|
||||
|
||||
def synchronize() -> None:
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.synchronize(device_id)
|
||||
|
||||
def timed_generate(*args: Any, **kwargs: Any) -> Any:
|
||||
synchronize()
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
return original_generate(*args, **kwargs)
|
||||
finally:
|
||||
synchronize()
|
||||
timings["talkerGenerateSeconds"] = time.perf_counter() - started
|
||||
|
||||
def timed_decode(*args: Any, **kwargs: Any) -> Any:
|
||||
synchronize()
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
return original_decode(*args, **kwargs)
|
||||
finally:
|
||||
synchronize()
|
||||
timings["codecDecodeSeconds"] = time.perf_counter() - started
|
||||
|
||||
model.model.generate = timed_generate
|
||||
speech_tokenizer.decode = timed_decode
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
wavs, sample_rate = model.generate_custom_voice(
|
||||
text=[chunk["text"] for chunk in batch_chunks],
|
||||
language=["Russian"] * len(batch_chunks),
|
||||
speaker=[SPEAKER] * len(batch_chunks),
|
||||
instruct=[VOICE_INSTRUCTION] * len(batch_chunks),
|
||||
)
|
||||
synchronize()
|
||||
finally:
|
||||
model.model.generate = original_generate
|
||||
speech_tokenizer.decode = original_decode
|
||||
|
||||
total_seconds = time.perf_counter() - started
|
||||
talker_seconds = timings.get("talkerGenerateSeconds", 0.0)
|
||||
codec_seconds = timings.get("codecDecodeSeconds", 0.0)
|
||||
record = {
|
||||
"timestampMs": now_ms(),
|
||||
"batchSize": len(batch_chunks),
|
||||
"characters": sum(len(chunk["text"]) for chunk in batch_chunks),
|
||||
"totalSeconds": round(total_seconds, 4),
|
||||
"talkerGenerateSeconds": round(talker_seconds, 4),
|
||||
"codecDecodeSeconds": round(codec_seconds, 4),
|
||||
"wrapperSeconds": round(max(0.0, total_seconds - talker_seconds - codec_seconds), 4),
|
||||
"cudaPeakAllocatedMiB": round(
|
||||
torch.cuda.max_memory_allocated(device_id) / (1024 * 1024), 1
|
||||
),
|
||||
}
|
||||
with PERFORMANCE_LOG.open("a", encoding="utf-8") as output:
|
||||
output.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
return wavs, sample_rate
|
||||
|
||||
|
||||
def unload_models(models: list[Any]) -> None:
|
||||
if models:
|
||||
models.clear()
|
||||
import torch
|
||||
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
for device_id in GPU_IDS:
|
||||
torch.cuda.empty_cache()
|
||||
else:
|
||||
gc.collect()
|
||||
worker_state["modelLoaded"] = False
|
||||
|
||||
|
||||
def encode_m4a(chunk_files: list[Path], output: Path, job_root: Path) -> None:
|
||||
concat = job_root / "concat.txt"
|
||||
concat.write_text(
|
||||
"\n".join(f"file '{path.as_posix()}'" for path in chunk_files),
|
||||
encoding="utf-8",
|
||||
)
|
||||
command = [
|
||||
imageio_ffmpeg.get_ffmpeg_exe(),
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(concat),
|
||||
"-vn",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"96k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output),
|
||||
]
|
||||
completed = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(completed.stderr[-2000:] or "FFmpeg failed")
|
||||
|
||||
|
||||
def run_job(job_id: str) -> None:
|
||||
row = get_job(job_id)
|
||||
job_root = Path(row["input_path"]).parent
|
||||
chunks_root = job_root / "chunks"
|
||||
chunks_root.mkdir(exist_ok=True)
|
||||
models: list[Any] = []
|
||||
try:
|
||||
if cancellation_requested(job_id):
|
||||
finish_cancellation(job_id, job_root)
|
||||
return
|
||||
update_job(job_id, status="extracting", stage="extracting", error_message=None)
|
||||
input_path = Path(row["input_path"])
|
||||
sections = extract_epub(input_path) if input_path.suffix.lower() == ".epub" else extract_fb2(input_path)
|
||||
chunks = split_chunks(sections)
|
||||
if not chunks:
|
||||
raise RuntimeError("В книге не найден русский текст для озвучивания")
|
||||
total = sum(len(chunk["text"]) for chunk in chunks)
|
||||
manifest_path = job_root / "chunks.json"
|
||||
manifest_path.write_text(json.dumps(chunks, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
update_job(job_id, status="synthesizing", stage="loading_model", total_characters=total)
|
||||
if cancellation_requested(job_id):
|
||||
finish_cancellation(job_id, job_root)
|
||||
return
|
||||
pause_ollama_for_audiobook()
|
||||
models = load_models()
|
||||
import torch
|
||||
|
||||
processed = 0
|
||||
duration_ms = 0
|
||||
chapter_markers: list[dict] = []
|
||||
last_chapter: str | None = None
|
||||
chunk_files: list[Path] = []
|
||||
index = 0
|
||||
while index < len(chunks):
|
||||
if cancellation_requested(job_id):
|
||||
finish_cancellation(job_id, job_root)
|
||||
return
|
||||
output = chunks_root / f"{index:06d}.wav"
|
||||
completed_indices: list[int]
|
||||
if output.is_file():
|
||||
completed_indices = [index]
|
||||
else:
|
||||
batch_indices: list[int] = []
|
||||
cursor = index
|
||||
while cursor < len(chunks) and len(batch_indices) < len(models):
|
||||
candidate = chunks_root / f"{cursor:06d}.wav"
|
||||
if candidate.is_file():
|
||||
break
|
||||
batch_indices.append(cursor)
|
||||
cursor += 1
|
||||
with ThreadPoolExecutor(max_workers=len(batch_indices)) as executor:
|
||||
futures = [
|
||||
executor.submit(
|
||||
generate_custom_voice_with_metrics,
|
||||
model,
|
||||
[chunks[batch_index]],
|
||||
device_id,
|
||||
)
|
||||
for batch_index, model, device_id in zip(
|
||||
batch_indices,
|
||||
models[: len(batch_indices)],
|
||||
GPU_IDS[: len(batch_indices)],
|
||||
strict=True,
|
||||
)
|
||||
]
|
||||
rendered = [future.result() for future in futures]
|
||||
if cancellation_requested(job_id):
|
||||
finish_cancellation(job_id, job_root)
|
||||
return
|
||||
for batch_index, (wavs, sample_rate) in zip(batch_indices, rendered, strict=True):
|
||||
sf.write(
|
||||
chunks_root / f"{batch_index:06d}.wav",
|
||||
wavs[0],
|
||||
sample_rate,
|
||||
subtype="PCM_16",
|
||||
)
|
||||
completed_indices = batch_indices
|
||||
|
||||
for completed_index in completed_indices:
|
||||
completed_chunk = chunks[completed_index]
|
||||
completed_output = chunks_root / f"{completed_index:06d}.wav"
|
||||
if completed_chunk["chapter"] != last_chapter:
|
||||
chapter_markers.append({"title": completed_chunk["chapter"], "startMs": duration_ms})
|
||||
last_chapter = completed_chunk["chapter"]
|
||||
info = sf.info(completed_output)
|
||||
duration_ms += round(info.frames * 1000 / info.samplerate)
|
||||
chunk_files.append(completed_output)
|
||||
processed += len(completed_chunk["text"])
|
||||
update_job(
|
||||
job_id,
|
||||
status="synthesizing",
|
||||
stage="synthesizing",
|
||||
processed_characters=processed,
|
||||
current_chapter=completed_chunk["chapter"],
|
||||
duration_ms=duration_ms,
|
||||
chapters_json=json.dumps(chapter_markers, ensure_ascii=False),
|
||||
)
|
||||
index = completed_indices[-1] + 1
|
||||
|
||||
unload_models(models)
|
||||
models = []
|
||||
if cancellation_requested(job_id):
|
||||
finish_cancellation(job_id, job_root)
|
||||
return
|
||||
update_job(job_id, status="encoding", stage="encoding", current_chapter=None)
|
||||
output = Path(row["output_path"])
|
||||
encode_m4a(chunk_files, output, job_root)
|
||||
if cancellation_requested(job_id):
|
||||
finish_cancellation(job_id, job_root)
|
||||
return
|
||||
if not output.is_file() or output.stat().st_size == 0:
|
||||
raise RuntimeError("Итоговый M4A-файл не создан")
|
||||
update_job(
|
||||
job_id,
|
||||
status="ready",
|
||||
stage="ready",
|
||||
processed_characters=total,
|
||||
current_chapter=None,
|
||||
chapters_json=json.dumps(chapter_markers, ensure_ascii=False),
|
||||
)
|
||||
except Exception as error:
|
||||
update_job(
|
||||
job_id,
|
||||
status="failed",
|
||||
stage="failed",
|
||||
error_message=f"{type(error).__name__}: {error}",
|
||||
)
|
||||
finally:
|
||||
unload_models(models)
|
||||
|
||||
|
||||
def worker_loop() -> None:
|
||||
while True:
|
||||
with queue_condition:
|
||||
while not queued_jobs:
|
||||
queue_condition.wait()
|
||||
job_id = queued_jobs.pop(0)
|
||||
worker_state["activeJobId"] = job_id
|
||||
try:
|
||||
try:
|
||||
run_job(job_id)
|
||||
except HTTPException as error:
|
||||
if error.status_code != 404:
|
||||
raise
|
||||
finally:
|
||||
worker_state["activeJobId"] = None
|
||||
# Qwen/Triton can retain a CUDA context after Python references are
|
||||
# released. Let systemd start a clean idle receiver so CMP VRAM is
|
||||
# fully available between audiobook requests. Unfinished jobs are
|
||||
# restored from SQLite by initialize_database() after restart.
|
||||
os._exit(0)
|
||||
|
||||
|
||||
initialize_database()
|
||||
threading.Thread(target=worker_loop, name="audiobook-worker", daemon=True).start()
|
||||
Reference in New Issue
Block a user