feat: add remote audiobook generation
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
# Aletheia CMP audiobook receiver
|
||||
|
||||
The receiver accepts EPUB/FB2 files, queues one synthesis job at a time, reports progress, and returns an AAC/M4A file. The HTTP process starts with no Qwen model in memory. `Qwen3TTSModel` and `torch` are imported only when a job reaches the synthesis stage; the model is deleted and the CUDA cache is cleared before M4A encoding.
|
||||
|
||||
## Deployed layout
|
||||
|
||||
- CMP host: `192.168.0.112`, service root `/home/sevenhill/apps/aletheia-audiobook`
|
||||
- API: `http://0.0.0.0:8765`
|
||||
- jobs and SQLite state: `/home/sevenhill/apps/aletheia-audiobook/audiobook-jobs`
|
||||
- API token: `/home/sevenhill/apps/aletheia-audiobook/service/service-token.txt`
|
||||
- public route: `https://argus.kusoft.xyz/aletheia-tts/`
|
||||
- voice: Qwen3-TTS 0.6B CustomVoice, `Ryan`, Russian
|
||||
|
||||
The token is intentionally not stored in this repository. The Android build reads it from `%USERPROFILE%\.aletheia\audiobook-api-token.txt` and embeds it in `BuildConfig` for this private installation.
|
||||
|
||||
## Runtime
|
||||
|
||||
The CMP receiver uses `/home/sevenhill/apps/qwen3-tts-venv`. It starts as a lightweight FastAPI process and loads the 0.6B Qwen model only after a queued audiobook reaches the synthesis stage. The model is deleted and CUDA cache cleared before M4A encoding.
|
||||
|
||||
The `systemd --user` service starts only the receiver process. It does not preload Qwen or reserve GPU memory; the model is loaded by an audiobook request. After a job finishes, systemd replaces the receiver process so Qwen and Triton CUDA contexts are fully released before the receiver waits for the next request.
|
||||
|
||||
When an audiobook enters synthesis, the receiver temporarily stops and runtime-masks Ollama, then loads one tested 0.6B Qwen worker on each of GPU 0, 1, and 2. Up to three independent chunks are generated in parallel. After the job, the receiver exits so systemd can release all CUDA contexts; its fresh idle process restores Ollama. Existing WAV chunks are never regenerated during resume.
|
||||
|
||||
Per-batch timings are appended to `audiobook-jobs\performance.jsonl`. The log separates the autoregressive talker, speech-tokenizer decoder, and wrapper time so runtime optimizations can be benchmarked without changing the generated audio path.
|
||||
|
||||
The CMP firewall must permit TCP 8765 only from the Raspberry Pi address. Caddy uses a path handler that removes the public prefix before proxying:
|
||||
|
||||
```caddyfile
|
||||
argus.kusoft.xyz {
|
||||
encode zstd gzip
|
||||
handle_path /aletheia-tts/* {
|
||||
reverse_proxy 192.168.0.112:8765
|
||||
}
|
||||
handle {
|
||||
reverse_proxy 127.0.0.1:5105
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Validate with `caddy validate` before reloading Caddy. `/health` is public and reports `modelLoaded`; all `/v1/audiobooks` endpoints require the bearer token.
|
||||
|
||||
## API
|
||||
|
||||
- `POST /v1/audiobooks` — multipart fields `book`, `title`, `author`
|
||||
- `GET /v1/audiobooks/{id}` — status, stage, processed/total characters, chapter, duration and chapter markers
|
||||
- `GET /v1/audiobooks/{id}/file` — completed M4A
|
||||
- `DELETE /v1/audiobooks/{id}` — cancel an active job or remove a completed job
|
||||
|
||||
Interrupted receiver processes requeue unfinished jobs on the next start. Existing WAV chunks are reused, so synthesis resumes at the first missing chunk.
|
||||
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=Aletheia CMP audiobook receiver
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/sevenhill/apps/aletheia-audiobook/service
|
||||
Environment=AUDIOBOOK_SERVICE_ROOT=/home/sevenhill/apps/aletheia-audiobook
|
||||
Environment=HF_HOME=/home/sevenhill/apps/qwen3-audiobook-test/hf
|
||||
Environment=QWEN_TTS_MODEL_ID=Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice
|
||||
Environment=QWEN_TTS_SPEAKER=Ryan
|
||||
Environment=QWEN_TTS_GPU_IDS=0,1,2
|
||||
Environment=CPATH=/home/sevenhill/apps/qwen3-audiobook-test/sysdeps/extracted/usr/include/python3.12:/home/sevenhill/apps/qwen3-audiobook-test/sysdeps/extracted/usr/include
|
||||
ExecStart=/home/sevenhill/apps/qwen3-tts-venv/bin/python -m uvicorn audiobook_service:app --host 0.0.0.0 --port 8765 --workers 1
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -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()
|
||||
@@ -0,0 +1,37 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$taskName = 'Aletheia Audiobook Receiver'
|
||||
$serviceScript = 'C:\Users\seven\Qwen3-TTS\service\start_service.ps1'
|
||||
|
||||
$existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
if ($null -ne $existing) {
|
||||
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
|
||||
}
|
||||
|
||||
$action = New-ScheduledTaskAction `
|
||||
-Execute 'powershell.exe' `
|
||||
-Argument "-NoProfile -ExecutionPolicy Bypass -File `"$serviceScript`""
|
||||
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||
$principal = New-ScheduledTaskPrincipal `
|
||||
-UserId 'SYSTEM' `
|
||||
-LogonType ServiceAccount `
|
||||
-RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
-AllowStartIfOnBatteries `
|
||||
-DontStopIfGoingOnBatteries `
|
||||
-StartWhenAvailable `
|
||||
-RestartCount 5 `
|
||||
-RestartInterval (New-TimeSpan -Minutes 1) `
|
||||
-ExecutionTimeLimit ([TimeSpan]::Zero)
|
||||
|
||||
Register-ScheduledTask `
|
||||
-TaskName $taskName `
|
||||
-Description 'Lightweight Aletheia API receiver. The Qwen model is loaded only while processing a job.' `
|
||||
-Action $action `
|
||||
-Trigger $trigger `
|
||||
-Principal $principal `
|
||||
-Settings $settings | Out-Null
|
||||
|
||||
Start-ScheduledTask -TaskName $taskName
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
imageio-ffmpeg==0.6.0
|
||||
num2words==0.5.14
|
||||
python-multipart==0.0.32
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = 'C:\Users\seven\Qwen3-TTS'
|
||||
$logs = Join-Path $root 'logs'
|
||||
New-Item -ItemType Directory -Force -Path $logs | Out-Null
|
||||
Start-Transcript -Path (Join-Path $logs 'audiobook-receiver.log') -Append | Out-Null
|
||||
$env:QWEN_TTS_ROOT = $root
|
||||
$env:HF_HUB_OFFLINE = '1'
|
||||
$env:TRANSFORMERS_OFFLINE = '1'
|
||||
Set-Location (Join-Path $root 'service')
|
||||
|
||||
try {
|
||||
& (Join-Path $root '.venv\Scripts\python.exe') -m uvicorn audiobook_service:app `
|
||||
--host 0.0.0.0 `
|
||||
--port 8765 `
|
||||
--workers 1
|
||||
} finally {
|
||||
Stop-Transcript | Out-Null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user