From 40383eb6e7bdf4bfd31d69ad2eec8362dce5e58f Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 5 Oct 2026 01:35:41 +0200 Subject: [PATCH] fix: use APInex TTS for voice ideas --- Dockerfile | 3 -- IDEA.MD | 2 +- README.md | 4 +-- VERSION | 2 +- apinex_client.py | 74 ++++++++++++++++++++++++++++++++++-------------- 5 files changed, 56 insertions(+), 29 deletions(-) diff --git a/Dockerfile b/Dockerfile index af246ed..75aa4e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,9 +3,6 @@ FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 -RUN apt-get update && apt-get install -y --no-install-recommends espeak-ng lame \ - && rm -rf /var/lib/apt/lists/* - WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/IDEA.MD b/IDEA.MD index 03554a5..dbec49c 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -53,7 +53,7 @@ Telegram-бот, который по запросу пользователя г - [x] APInex JSON-вызовы и ошибки разбора журналируются в SQLite без системного промпта и image base64; срок хранения — 30 дней - [x] `/clear` полностью очищает данные текущего пользователя: ToV, скриншоты, историю постов, историю идей и настройки; API-логи сохраняются - [x] `/idea` возвращает одну идею одним сообщением и сохраняет её для исключения повторов -- [x] `/idea_voice` озвучивает одну идею локальным русским TTS (`espeak-ng` + `lame`) и отправляет MP3 как voice message; Docker runtime включает оба инструмента +- [x] `/idea_voice` использует APInex TTS (`APINEX_TTS_MODEL`, default `aura-2-thalia-en`) и отправляет MP3 voice message; в API-логах только метаданные, соответствие русского голоса проверяется отдельно - [x] Все отправленные варианты постов сохраняются по пользователю, включая исправленные; история используется для новых идей - [x] `/clear` очищает историю пользовательских данных и оставляет API-логи - [x] Служебные ответы по-русски, язык текстов постов определяется запросом пользователя diff --git a/README.md b/README.md index 1a182ee..581bbd8 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@ The bot prepares social-media posts only when the user explicitly asks. It trans - In development, processing errors are returned to the user with a concise, secret-redacted reason. - Coolify: project `Social Post Writer Bot`, environment `development`, server `spb-easy-dale` - Gitea: `admin/social-post-writer-bot`, branch `dev` -- API key and model variables are stored only in Coolify: `APINEX_API_KEY`, `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`. +- API key and model variables are stored only in Coolify: `APINEX_API_KEY`, `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`, `APINEX_TTS_MODEL`. - APInex call and parsing logs live in SQLite at `/app/data/social_writer.sqlite3` for 30 days. They store user input/output JSON and error details; system prompt text is omitted in favor of a prompt name and SHA-256. Inline image base64 and binary audio are omitted. Treat log contents as private user data. - `/clear` полностью очищает данные текущего пользователя: Tone of Voice, тексты со скриншотов, историю постов, историю идей и настройки; API-логи сохраняются. - `/idea` и русские запросы на идеи возвращают одну идею одним сообщением и сохраняют её для исключения повторов. -- `/idea_voice` генерирует одну идею и отправляет её голосовым MP3-сообщением; Docker image включает `espeak-ng` и `lame` для локального русского TTS. +- `/idea_voice` озвучивает одну идею через APInex TTS (`/v1/tools/voice/tts`) и отправляет MP3 как Telegram voice message. Голос задаётся переменной Coolify `APINEX_TTS_MODEL` (по умолчанию `aura-2-thalia-en`). В опубликованном каталоге APInex русский голос пока не указан; качество русской речи отдельно проверим следующим шагом. - User-facing service text is always Russian; generated post variants follow the requested post language. Verbatim technical error detail may remain in its original language. - LLM model env defaults are `gemini-3.8-flash`; STT model defaults to APInex `nova-2`. - Per-user Tone of Voice lives in SQLite at `/app/data/social_writer.sqlite3`; persistent Coolify storage must be mounted at `/app/data`. diff --git a/VERSION b/VERSION index 2026767..7ea54d0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+14 +0.1.0+15 diff --git a/apinex_client.py b/apinex_client.py index 1a0a21d..05883b0 100644 --- a/apinex_client.py +++ b/apinex_client.py @@ -5,9 +5,6 @@ import json import logging import os import re -import shutil -import subprocess -import tempfile from typing import Any import aiohttp @@ -17,6 +14,7 @@ from user_store import save_api_call_log logger = logging.getLogger(__name__) APINEX_STT_URL = "https://api.apinex.bond/v1/tools/voice/stt" APINEX_CHAT_URL = "https://api.apinex.bond/v1/chat/completions" +APINEX_TTS_URL = "https://api.apinex.bond/v1/tools/voice/tts" TTS_NOT_CONFIGURED_MESSAGE = "Озвучка идей пока не настроена." USER_AGENT = "Mozilla/5.0 (compatible; SocialPostWriterBot/1.0)" @@ -414,26 +412,58 @@ def _trim_history_posts(posts: list[str], char_limit: int = POST_HISTORY_CHAR_LI async def synthesize_idea_voice(text: str, user_id: int | None = None) -> bytes: - """Generate Russian MP3 speech locally; Telegram accepts MP3 in voice messages.""" - espeak = shutil.which("espeak-ng") or shutil.which("espeak") - lame = shutil.which("lame") - if not espeak or not lame: - raise RuntimeError("Локальная озвучка не настроена: отсутствует espeak-ng или lame") - with tempfile.TemporaryDirectory() as temp_dir: - wav_path = os.path.join(temp_dir, "idea.wav") - mp3_path = os.path.join(temp_dir, "idea.mp3") - await asyncio.to_thread( - subprocess.run, - [espeak, "-v", "ru", "-s", "155", "-w", wav_path, text[:1200]], - check=True, capture_output=True, timeout=30, + api_key = _api_key() + if not api_key: + raise RuntimeError("APINEX_API_KEY is missing") + model = _setting("APINEX_TTS_MODEL", "aura-2-thalia-en") + payload = { + "text": text[:1200], + "model": model, + "encoding": "mp3", + "container": "mp3", + "response_format": "audio", + } + request_for_log = { + "endpoint": "tools/voice/tts", + "model": model, + "text": payload["text"], + "encoding": "mp3", + "container": "mp3", + "response_format": "audio", + } + headers = { + "Authorization": f"Bearer {api_key}", + "Accept": "audio/mpeg, application/octet-stream", + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + } + try: + timeout = aiohttp.ClientTimeout(total=120) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(APINEX_TTS_URL, json=payload, headers=headers) as response: + if response.status != 200: + raise RuntimeError(f"APInex TTS failed with HTTP {response.status}") + audio = await response.read() + response_meta = { + "content_type": response.headers.get("Content-Type", ""), + "audio_bytes": len(audio), + } + if not audio: + raise RuntimeError("APInex TTS returned empty audio") + except Exception as exc: + await _record_api_call( + stage="idea_voice_tts", model=model, prompt_name="idea_voice_tts", + system_prompt="", request=request_for_log, + status_code=_status_from_error(exc), error=f"{type(exc).__name__}: {exc}", + user_id=user_id, ) - await asyncio.to_thread( - subprocess.run, - [lame, "--silent", "-b", "64", wav_path, mp3_path], - check=True, capture_output=True, timeout=30, - ) - with open(mp3_path, "rb") as audio_file: - return await asyncio.to_thread(audio_file.read) + raise + await _record_api_call( + stage="idea_voice_tts", model=model, prompt_name="idea_voice_tts", + system_prompt="", request=request_for_log, response=response_meta, + status_code=200, user_id=user_id, + ) + return audio async def generate_ideas(