Добавить адаптацию голосовых ответов бота

This commit is contained in:
AidarKC 2026-05-30 00:16:39 +04:00
parent 6f796c98f7
commit b13efa92fd
4 changed files with 251 additions and 38 deletions

View File

@ -0,0 +1,19 @@
# Голосовая адаптация ответов Telegram-бота
## Краткое описание
Добавлены персональные настройки голосовых ответов и адаптации текста перед озвучкой. Если голосовые ответы включены, сервис перед TTS может отдельно прогонять финальный текст через OpenAI-модель и отправлять более короткую голосовую версию в исходный чат, личный чат пользователя и общий чат `@shine_writing`, если эти чаты доступны и отличаются.
## Что проверить
- Команды `/voice_on`, `/voice_off`, `/voice_status` для конкретного пользователя.
- Команды `/voice_rewrite_on`, `/voice_rewrite_off`, `/voice_rewrite_status` для конкретного пользователя.
- Команда `/status` показывает очередь, голосовые ответы и адаптацию текста перед озвучкой.
- При включённых голосовых ответах после задачи приходит текстовый ответ и voice-ответ.
- При включённой адаптации voice-ответ короче и без длинных технических строк.
- При задаче из личного чата voice дополнительно появляется в общем чате `@shine_writing`.
- При задаче из общего чата voice дополнительно появляется в личном чате пользователя, если сервис уже знает его личный chat_id.
## Ожидаемый результат
Текстовый ответ остаётся полным. Голосовая версия приходит отдельно, звучит короче и естественнее, а персональные настройки одного пользователя не меняют поведение других пользователей.
## Статус
pending

View File

@ -33,11 +33,13 @@
- После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать. - После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать.
- Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`. - Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`.
- Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`, `/voice_status`. - Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`, `/voice_status`.
- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS. Промежуточные статусы и публичный отчёт в `@shine_writing` не озвучивать. - Адаптация текста перед озвучкой настраивается персонально командами `/voice_rewrite_on`, `/voice_rewrite_off`, `/voice_rewrite_status`. Если она включена, сервис перед TTS вызывает дешёвую текстовую модель OpenAI и делает короткую голосовую версию без длинных хэшей, путей, команд и технического шума.
- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS. Voice отправляется в исходный чат, а также в известный личный чат пользователя и в общий чат `@shine_writing`, если они отличаются и доступны. Промежуточные статусы не озвучивать.
- Команда `/status` должна показывать состояние очереди и персональные состояния голосовых функций: включены ли voice-ответы и адаптация текста перед озвучкой.
## Правила голосовой версии ответа ## Правила голосовой версии ответа
- Текстовый финальный ответ должен оставаться полноценным: в нём можно указывать команды, пути, хэши коммитов, номера версий, результаты проверок и другие технические детали. - Текстовый финальный ответ должен оставаться полноценным: в нём можно указывать команды, пути, хэши коммитов, номера версий, результаты проверок и другие технические детали.
- Голосовую версию финального ответа нужно делать короче и проще для восприятия на слух. - Голосовую версию финального ответа нужно делать короче и проще для восприятия на слух. Основной механизм — персонально включаемая адаптация текста через дополнительный OpenAI-вызов перед TTS.
- В голосовой версии не зачитывать длинные хэши коммитов, токены, file_id, длинные команды, полные пути и другие строки, которые человек всё равно не сможет надёжно запомнить на слух. - В голосовой версии не зачитывать длинные хэши коммитов, токены, file_id, длинные команды, полные пути и другие строки, которые человек всё равно не сможет надёжно запомнить на слух.
- Для commit/push в голосовой версии достаточно сказать краткий итог: что коммит сделан, что именно изменено, проверки прошли без ошибок, push выполнен, рабочее дерево чистое. - Для commit/push в голосовой версии достаточно сказать краткий итог: что коммит сделан, что именно изменено, проверки прошли без ошибок, push выполнен, рабочее дерево чистое.
- Если пользователю нужны точные команды, хэши или подробности, они должны оставаться в текстовом ответе. - Если пользователю нужны точные команды, хэши или подробности, они должны оставаться в текстовом ответе.

View File

@ -322,6 +322,10 @@ class BotConfig:
self.openai_tts_response_format = env.get("OPENAI_TTS_RESPONSE_FORMAT", "opus") self.openai_tts_response_format = env.get("OPENAI_TTS_RESPONSE_FORMAT", "opus")
self.openai_tts_timeout_seconds = int(env.get("OPENAI_TTS_TIMEOUT_SECONDS", "180")) self.openai_tts_timeout_seconds = int(env.get("OPENAI_TTS_TIMEOUT_SECONDS", "180"))
self.openai_tts_chunk_chars = max(500, int(env.get("OPENAI_TTS_CHUNK_CHARS", "3500"))) self.openai_tts_chunk_chars = max(500, int(env.get("OPENAI_TTS_CHUNK_CHARS", "3500")))
self.openai_voice_rewrite_model = env.get("OPENAI_VOICE_REWRITE_MODEL", "gpt-4.1-nano")
self.openai_voice_rewrite_timeout_seconds = int(env.get("OPENAI_VOICE_REWRITE_TIMEOUT_SECONDS", "90"))
self.openai_voice_rewrite_max_input_chars = max(1000, int(env.get("OPENAI_VOICE_REWRITE_MAX_INPUT_CHARS", "12000")))
self.openai_voice_rewrite_max_output_tokens = max(200, int(env.get("OPENAI_VOICE_REWRITE_MAX_OUTPUT_TOKENS", "900")))
self.codex_bin = Path(env.get( self.codex_bin = Path(env.get(
"CODEX_BIN", "CODEX_BIN",
"/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl" "/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl"
@ -608,6 +612,8 @@ class ShinePyBotService:
settings[uname] = user_settings settings[uname] = user_settings
if not isinstance(user_settings.get("voice_replies_enabled"), bool): if not isinstance(user_settings.get("voice_replies_enabled"), bool):
user_settings["voice_replies_enabled"] = False user_settings["voice_replies_enabled"] = False
if not isinstance(user_settings.get("voice_rewrite_enabled"), bool):
user_settings["voice_rewrite_enabled"] = True
return user_settings return user_settings
def _voice_replies_enabled(self, username: str) -> bool: def _voice_replies_enabled(self, username: str) -> bool:
@ -617,6 +623,33 @@ class ShinePyBotService:
self._user_settings(username)["voice_replies_enabled"] = enabled self._user_settings(username)["voice_replies_enabled"] = enabled
self._persist_state() self._persist_state()
def _voice_rewrite_enabled(self, username: str) -> bool:
return bool(self._user_settings(username).get("voice_rewrite_enabled"))
def _set_voice_rewrite_enabled(self, username: str, enabled: bool) -> None:
self._user_settings(username)["voice_rewrite_enabled"] = enabled
self._persist_state()
def _remember_private_chat(self, username: str, chat_id: int) -> None:
uname = normalize_username(username)
if not uname:
return
private_chats = self.state.get("private_chat_ids")
if not isinstance(private_chats, dict):
private_chats = {}
self.state["private_chat_ids"] = private_chats
if private_chats.get(uname) == chat_id:
return
private_chats[uname] = chat_id
self._persist_state()
def _private_chat_id_for_user(self, username: str) -> int | None:
private_chats = self.state.get("private_chat_ids")
if not isinstance(private_chats, dict):
return None
chat_id = private_chats.get(normalize_username(username))
return self._resolve_chat_id(chat_id) if isinstance(chat_id, int) else None
def _append_history(self, history_path: Path, event_type: str, payload: dict[str, Any]) -> None: def _append_history(self, history_path: Path, event_type: str, payload: dict[str, Any]) -> None:
row = {"ts": now_iso(), "type": event_type} row = {"ts": now_iso(), "type": event_type}
row.update(payload) row.update(payload)
@ -754,6 +787,8 @@ class ShinePyBotService:
return return
self._ensure_user_session(actor_username) self._ensure_user_session(actor_username)
if is_private:
self._remember_private_chat(actor_username, chat_id)
history_path = self._current_history_file_for_user(actor_username) history_path = self._current_history_file_for_user(actor_username)
if self._is_allowed_player(actor_username): if self._is_allowed_player(actor_username):
self._send_player_welcome_once(chat_id, message_id, actor_username) self._send_player_welcome_once(chat_id, message_id, actor_username)
@ -906,7 +941,7 @@ class ShinePyBotService:
self._safe_send(chat_id, self._help_text(is_owner=is_owner), reply_to=message_id) self._safe_send(chat_id, self._help_text(is_owner=is_owner), reply_to=message_id)
return return
if command == "/status": if command == "/status":
self._safe_send(chat_id, self._status_text(), reply_to=message_id) self._safe_send(chat_id, self._status_text(username), reply_to=message_id)
return return
if command == "/queue": if command == "/queue":
self._safe_send(chat_id, self._queue_text(), reply_to=message_id) self._safe_send(chat_id, self._queue_text(), reply_to=message_id)
@ -923,7 +958,26 @@ class ShinePyBotService:
return return
if command == "/voice_status": if command == "/voice_status":
status = "включено" if self._voice_replies_enabled(username) else "выключено" status = "включено" if self._voice_replies_enabled(username) else "выключено"
self._safe_send(chat_id, f"Озвучивание финальных ответов: {status}.", reply_to=message_id) rewrite_status = "включена" if self._voice_rewrite_enabled(username) else "выключена"
self._safe_send(
chat_id,
f"Озвучивание финальных ответов: {status}.\nАдаптация текста перед озвучкой: {rewrite_status}.",
reply_to=message_id,
)
return
if command == "/voice_rewrite_on":
self._set_voice_rewrite_enabled(username, True)
self._append_history_event("voice_rewrite_enabled", {"username": normalize_username(username)}, username=username)
self._safe_send(chat_id, "Адаптация текста перед озвучкой включена для вашего пользователя.", reply_to=message_id)
return
if command == "/voice_rewrite_off":
self._set_voice_rewrite_enabled(username, False)
self._append_history_event("voice_rewrite_disabled", {"username": normalize_username(username)}, username=username)
self._safe_send(chat_id, "Адаптация текста перед озвучкой выключена для вашего пользователя.", reply_to=message_id)
return
if command == "/voice_rewrite_status":
status = "включена" if self._voice_rewrite_enabled(username) else "выключена"
self._safe_send(chat_id, f"Адаптация текста перед озвучкой: {status}.", reply_to=message_id)
return return
if command == "/new": if command == "/new":
archived = self._rotate_history("command_new", username) archived = self._rotate_history("command_new", username)
@ -981,26 +1035,35 @@ class ShinePyBotService:
"/new — архивировать историю и начать новую", "/new — архивировать историю и начать новую",
"/voice_on — включить озвучивание финальных ответов", "/voice_on — включить озвучивание финальных ответов",
"/voice_off — выключить озвучивание финальных ответов", "/voice_off — выключить озвучивание финальных ответов",
"/voice_status — показать состояние озвучивания", "/voice_rewrite_on — включить адаптацию текста перед озвучкой",
"/voice_rewrite_off — выключить адаптацию текста перед озвучкой",
"/voice_status — показать состояние голосовых функций",
"/help — эта справка", "/help — эта справка",
] ]
if is_owner: if is_owner:
lines.insert(-1, "/restart_service — перезапустить сервис через systemd") lines.insert(-1, "/restart_service — перезапустить сервис через systemd")
return "\n".join(lines) return "\n".join(lines)
def _status_text(self) -> str: def _status_text(self, username: str) -> str:
with self.queue_lock: with self.queue_lock:
active = next((j for j in self.queue if j.get("status") == "active"), None) active = next((j for j in self.queue if j.get("status") == "active"), None)
pending = sum(1 for j in self.queue if j.get("status") == "pending") pending = sum(1 for j in self.queue if j.get("status") == "pending")
voice_status = "включено" if self._voice_replies_enabled(username) else "выключено"
rewrite_status = "включена" if self._voice_rewrite_enabled(username) else "выключена"
settings_text = (
f"Голосовые ответы: {voice_status}\n"
f"Адаптация текста перед озвучкой: {rewrite_status}"
)
if not active: if not active:
return f"Статус: активной задачи нет.\nВ очереди pending: {pending}" return f"Статус: активной задачи нет.\nВ очереди pending: {pending}\n{settings_text}"
elapsed = int(time.time() - (self.active_job_started_at or time.time())) elapsed = int(time.time() - (self.active_job_started_at or time.time()))
return ( return (
f"Статус: активная задача #{active.get('num', '?')}\n" f"Статус: активная задача #{active.get('num', '?')}\n"
f"Тип: {active.get('type', 'text')}\n" f"Тип: {active.get('type', 'text')}\n"
f"Попытка: {int(active.get('attempts', 0)) + 1}/{self.cfg.max_retries}\n" f"Попытка: {int(active.get('attempts', 0)) + 1}/{self.cfg.max_retries}\n"
f"Выполняется: {elapsed}с\n" f"Выполняется: {elapsed}с\n"
f"Pending: {pending}" f"Pending: {pending}\n"
f"{settings_text}"
) )
def _queue_text(self) -> str: def _queue_text(self) -> str:
@ -1097,12 +1160,12 @@ class ShinePyBotService:
answer = self._run_codex(prompt, chat_id, message_id, job_id, job_num) answer = self._run_codex(prompt, chat_id, message_id, job_id, job_num)
for chunk in split_long_text(answer): for chunk in split_long_text(answer):
self._safe_send(chat_id, chunk, reply_to=message_id) self._safe_send(chat_id, chunk, reply_to=message_id)
if self._voice_replies_enabled(job.get("username") or ""):
self._send_voice_reply_for_answer(chat_id, message_id, job_num, answer, history_path, job_id)
self._safe_send(chat_id, f"Готово #{job_num}.", reply_to=message_id)
self._append_history(history_path, "codex_response", {"jobId": job_id, "text": answer}) self._append_history(history_path, "codex_response", {"jobId": job_id, "text": answer})
self._mark_job_done(job_id)
self._send_private_job_public_report(job, answer) self._send_private_job_public_report(job, answer)
if self._voice_replies_enabled(job.get("username") or ""):
self._send_voice_reply_for_answer(job, answer, history_path, job_id)
self._safe_send(chat_id, f"Готово #{job_num}.", reply_to=message_id)
self._mark_job_done(job_id)
except Exception as e: except Exception as e:
if self.stop_current_job: if self.stop_current_job:
self._append_history(history_path, "job_stopped", {"jobId": job_id, "reason": str(e)}) self._append_history(history_path, "job_stopped", {"jobId": job_id, "reason": str(e)})
@ -1524,28 +1587,85 @@ class ShinePyBotService:
threading.Thread(target=restart, name="shine-py-bot-self-restart", daemon=True).start() threading.Thread(target=restart, name="shine-py-bot-self-restart", daemon=True).start()
def _voice_reply_targets(self, job: dict[str, Any]) -> list[tuple[int | str, int | None, str]]:
chat_id = int(job["chat_id"])
message_id = int(job["message_id"])
targets: list[tuple[int | str, int | None, str]] = [(chat_id, message_id, "source")]
username = job.get("username") or ""
private_chat_id = self._private_chat_id_for_user(username)
if private_chat_id is not None and private_chat_id != self._resolve_chat_id(chat_id):
targets.append((private_chat_id, None, "private"))
report_chat_id = self._public_report_chat_id()
if report_chat_id is not None:
resolved_report_chat_id = self._resolve_chat_id(report_chat_id) if isinstance(report_chat_id, int) else report_chat_id
resolved_current_chat_id: int | str = self._resolve_chat_id(chat_id)
if resolved_report_chat_id != resolved_current_chat_id:
targets.append((report_chat_id, None, "public"))
deduped: list[tuple[int | str, int | None, str]] = []
seen: set[str] = set()
for target_chat_id, target_reply_to, label in targets:
resolved: int | str = self._resolve_chat_id(target_chat_id) if isinstance(target_chat_id, int) else target_chat_id
key = str(resolved)
if key in seen:
continue
seen.add(key)
deduped.append((target_chat_id, target_reply_to, label))
return deduped
def _send_voice_reply_for_answer( def _send_voice_reply_for_answer(
self, self,
chat_id: int, job: dict[str, Any],
message_id: int,
job_num: Any,
answer: str, answer: str,
history_path: Path, history_path: Path,
job_id: str, job_id: str,
) -> None: ) -> None:
chat_id = int(job["chat_id"])
message_id = int(job["message_id"])
job_num = job.get("num", "?")
username = job.get("username") or ""
if not self.cfg.openai_api_key: if not self.cfg.openai_api_key:
note = "не настроен ключ OpenAI для озвучивания." note = "не настроен ключ OpenAI для озвучивания."
self._append_history(history_path, "voice_reply_failed", {"jobId": job_id, "jobNum": job_num, "error": note}) self._append_history(history_path, "voice_reply_failed", {"jobId": job_id, "jobNum": job_num, "error": note})
self._safe_send(chat_id, f"Озвучивание включено, но {note}", reply_to=message_id) self._safe_send(chat_id, f"Озвучивание включено, но {note}", reply_to=message_id)
return return
chunks = split_text_for_tts(answer, self.cfg.openai_tts_chunk_chars) voice_text = answer
rewrite_enabled = self._voice_rewrite_enabled(username)
if rewrite_enabled:
try:
voice_text = self._openai_rewrite_text_for_voice(answer)
self._append_history(history_path, "voice_rewrite_done", {
"jobId": job_id,
"jobNum": job_num,
"model": self.cfg.openai_voice_rewrite_model,
"sourceChars": len(answer or ""),
"resultChars": len(voice_text or ""),
})
except VoiceReplyError as e:
self._append_history(history_path, "voice_rewrite_failed", {
"jobId": job_id,
"jobNum": job_num,
"model": self.cfg.openai_voice_rewrite_model,
"error": str(e),
})
self._safe_send(
chat_id,
f"Не удалось адаптировать ответ #{job_num} для озвучки, озвучиваю обычный текст: {e}",
reply_to=message_id,
)
voice_text = answer
chunks = split_text_for_tts(voice_text, self.cfg.openai_tts_chunk_chars)
if not chunks: if not chunks:
return return
sent_count = 0 sent_count = 0
total = len(chunks) total = len(chunks)
print(f"[py-bot] tts start job={str(job_id)[:8]} chunks={total}", flush=True) targets = self._voice_reply_targets(job)
print(f"[py-bot] tts start job={str(job_id)[:8]} chunks={total} targets={len(targets)} rewrite={rewrite_enabled}", flush=True)
for index, chunk in enumerate(chunks, start=1): for index, chunk in enumerate(chunks, start=1):
try: try:
audio = self._openai_tts(chunk) audio = self._openai_tts(chunk)
@ -1562,12 +1682,13 @@ class ShinePyBotService:
caption = f"Озвучка ответа #{job_num}" caption = f"Озвучка ответа #{job_num}"
if total > 1: if total > 1:
caption += f", часть {index}/{total}" caption += f", часть {index}/{total}"
for target_chat_id, target_reply_to, target_label in targets:
message_sent = self._safe_send_voice_upload( message_sent = self._safe_send_voice_upload(
chat_id, target_chat_id,
audio, audio,
f"shine-answer-{job_num}-{index}.ogg", f"shine-answer-{job_num}-{index}.ogg",
caption=caption, caption=caption,
reply_to=message_id, reply_to=target_reply_to,
) )
if message_sent is None: if message_sent is None:
self._append_history(history_path, "voice_reply_failed", { self._append_history(history_path, "voice_reply_failed", {
@ -1575,14 +1696,85 @@ class ShinePyBotService:
"jobNum": job_num, "jobNum": job_num,
"part": index, "part": index,
"parts": total, "parts": total,
"target": target_label,
"error": "Telegram не принял voice-файл озвучки.", "error": "Telegram не принял voice-файл озвучки.",
}) })
if target_label == "source":
self._safe_send(chat_id, f"Озвучка ответа #{job_num} создана, но Telegram не принял voice-файл.", reply_to=message_id) self._safe_send(chat_id, f"Озвучка ответа #{job_num} создана, но Telegram не принял voice-файл.", reply_to=message_id)
return continue
sent_count += 1 sent_count += 1
self._append_history(history_path, "voice_reply_sent", {"jobId": job_id, "jobNum": job_num, "parts": sent_count}) self._append_history(history_path, "voice_reply_sent", {
"jobId": job_id,
"jobNum": job_num,
"parts": total,
"messages": sent_count,
"targets": len(targets),
"rewriteEnabled": rewrite_enabled,
})
print(f"[py-bot] tts done job={str(job_id)[:8]} sent={sent_count}", flush=True) print(f"[py-bot] tts done job={str(job_id)[:8]} sent={sent_count}", flush=True)
def _openai_rewrite_text_for_voice(self, text: str) -> str:
source = (text or "").strip()
if not source:
return ""
if len(source) > self.cfg.openai_voice_rewrite_max_input_chars:
source = source[:self.cfg.openai_voice_rewrite_max_input_chars].rstrip() + "\n\n...[текстовый ответ был длиннее и обрезан для голосовой версии]"
payload = {
"model": self.cfg.openai_voice_rewrite_model,
"messages": [
{
"role": "system",
"content": (
"Ты готовишь короткую русскую голосовую версию финального ответа технического агента. "
"Сохрани итог, важные предупреждения и действия. Убери длинные пути, хэши, команды, номера версий, "
"JSON, списки файлов и другие строки, которые плохо воспринимаются на слух. "
"Не добавляй новых фактов. Пиши естественно, кратко, без markdown."
),
},
{
"role": "user",
"content": f"Переделай этот финальный текст в вариант для озвучки:\n\n{source}",
},
],
"temperature": 0.2,
"max_tokens": self.cfg.openai_voice_rewrite_max_output_tokens,
}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = request.Request("https://api.openai.com/v1/chat/completions", method="POST", data=data)
req.add_header("Authorization", f"Bearer {self.cfg.openai_api_key}")
req.add_header("Content-Type", "application/json")
try:
with request.urlopen(req, timeout=self.cfg.openai_voice_rewrite_timeout_seconds) as resp:
raw = resp.read().decode("utf-8", errors="replace")
except TimeoutError as e:
raise VoiceReplyError(
f"OpenAI не успел адаптировать текст за {self.cfg.openai_voice_rewrite_timeout_seconds} секунд."
) from e
except error.HTTPError as e:
detail = e.read().decode("utf-8", errors="replace")
if e.code == 401:
message = "OpenAI отклонил ключ API для адаптации текста."
elif e.code == 429:
message = "OpenAI временно ограничил адаптацию текста из-за лимита запросов."
elif e.code >= 500:
message = "OpenAI временно не смог адаптировать текст."
else:
message = f"OpenAI вернул ошибку HTTP {e.code} при адаптации текста."
if detail:
message = f"{message} Детали: {detail[:500]}"
raise VoiceReplyError(message) from e
except error.URLError as e:
raise VoiceReplyError(f"не удалось отправить текст в OpenAI для адаптации из-за сетевой ошибки: {e.reason}") from e
try:
body = json.loads(raw)
content = (((body.get("choices") or [{}])[0].get("message") or {}).get("content") or "").strip()
except Exception as e:
raise VoiceReplyError("OpenAI вернул неразборчивый ответ при адаптации текста.") from e
if not content:
raise VoiceReplyError("OpenAI вернул пустой текст адаптации.")
return content
def _openai_tts(self, text: str) -> bytes: def _openai_tts(self, text: str) -> bytes:
payload = { payload = {
"model": self.cfg.openai_tts_model, "model": self.cfg.openai_tts_model,

View File

@ -1,2 +1,2 @@
client.version=1.2.97 client.version=1.2.98
server.version=1.2.91 server.version=1.2.92