- voice.py: kind=video в TRANSCRIBABLE_KINDS; extract_audio_track() — ffmpeg вытягивает дорожку (моно 16к opus) из видеоконтейнера; речь ≤60с (voice/video_note/video) сразу идёт в faster-whisper-large-v3-turbo (base на видео давал кашу). E2E: m-516173698 расшифрован. - tg_inbox.py: поллер транскрибирует и msg.video, не только voice/audio/video_note. - tools/tui_chat.py: TUI-чат (t-261004132716) лежал только у Volya (33301d3) — в origin его не было, из-за этого терминал не грузился у 6 агентов.
287 lines
No EOL
14 KiB
Python
287 lines
No EOL
14 KiB
Python
"""Simbiot Harness — транскрипция голосовых сообщений Telegram.
|
||
|
||
Пайплайн: file_id (в MessageReceived) → getFile → download → ASR →
|
||
MessageTranscribed (транскрипт в проекцию inbox) → минутный уровень
|
||
видит голосовое как обычный текст и отвечает по смыслу `tg-reply`.
|
||
|
||
ASR — два яруса:
|
||
1) whisper-asr-webservice (docker 127.0.0.1:8084, openai_whisper base):
|
||
быстрый, но короткие русские фразы иногда глуховатые;
|
||
2) fallback: faster-whisper large-v3-turbo (CPU int8, модель уже в
|
||
HF-кэше сервера, ~5-15с на короткое голосовое) — точнее на русском.
|
||
Живёт в системном python3 (в venv харнеса пакета нет) — вызываем
|
||
через subprocess-обёртку `_fw_asr_worker.py`.
|
||
|
||
Вызывает инбокс-поллер сразу после MessageReceived (транскрипт в сторе
|
||
ещё до первого минутного тика — уровню не нужно качать файлы, его
|
||
таймаут мал). Аудио кэшируется по file_unique_id: повторная расшифровка
|
||
не качает из Telegram и не перегоняет через ASR.
|
||
"""
|
||
import base64
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
|
||
from . import es
|
||
|
||
# ─── конфигурация ───────────────────────────────────────────────────────────
|
||
_HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
from . import paths as _paths # t-261004113351-2858c1
|
||
_ENV_PATH = _paths.env_path()
|
||
_FW_WORKER = os.path.join(_HARNESS_DIR, "harness", "_fw_asr_worker.py")
|
||
_FW_PYTHON = "/usr/bin/python3" # faster-whisper живёт в системном python
|
||
|
||
ASR_URL = os.environ.get("SIMBIOT_ASR_URL", "http://127.0.0.1:8084")
|
||
|
||
VOICE_DIR = _paths.voice_dir() # SIMBIOT_HOME → <home>/voice; legacy без хома
|
||
# что расшифровываем: voice — главное; audio/video_note/video — та же речь
|
||
# (t-261004152714: видео-вложения Alex получали not_transcribable — звук
|
||
# из контейнера вообще не извлекался; теперь тянем дорожку ffmpeg'ом)
|
||
TRANSCRIBABLE_KINDS = {"voice", "audio", "video_note", "video"}
|
||
# длина, сек: длиннее не тянем (поллер не должен вязнуть в 10-мин озвучках)
|
||
MAX_DURATION = 300
|
||
# аудио-кэш: file_unique_id → ogg-файл (путь задаёт paths.voice_dir выше)
|
||
|
||
TG_TOKEN = None
|
||
|
||
|
||
def _tg_init():
|
||
"""Токен бота из .env (кэш в глобале)."""
|
||
global TG_TOKEN
|
||
if TG_TOKEN is None:
|
||
if os.path.exists(_ENV_PATH):
|
||
with open(_ENV_PATH) as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if line.startswith("SIMBIOT_TG_TOKEN="):
|
||
TG_TOKEN = line.split("=", 1)[1].strip()
|
||
return TG_TOKEN
|
||
|
||
|
||
# ───────────────────────────── TG download ──────────────────────────────────
|
||
|
||
def _download_voice(file_id: str, file_unique_id: str = ""):
|
||
"""getFile → download. Аудио кэшируется на диске по file_unique_id.
|
||
|
||
Возвращает (bytes, cache_path) или (None, None) при неудаче.
|
||
Старые голосовые (файл истёк у Telegram) дают 400 — это не ошибка
|
||
поллера, просто расшифровка невозможна.
|
||
"""
|
||
if not _tg_init():
|
||
return None, None
|
||
cache = os.path.join(VOICE_DIR, (file_unique_id or file_id) + ".ogg")
|
||
if os.path.exists(cache): # кэш: повтор не качает из Telegram
|
||
with open(cache, "rb") as f:
|
||
return f.read(), cache
|
||
try:
|
||
url = f"https://api.telegram.org/bot{TG_TOKEN}/getFile"
|
||
data = urllib.parse.urlencode({"file_id": file_id}).encode()
|
||
with urllib.request.urlopen(
|
||
urllib.request.Request(url, data=data), timeout=30) as r:
|
||
resp = json.loads(r.read().decode())
|
||
if not resp.get("ok"):
|
||
return None, None
|
||
path = resp["result"].get("file_path")
|
||
if not path:
|
||
return None, None
|
||
durl = f"https://api.telegram.org/file/bot{TG_TOKEN}/{path}"
|
||
with urllib.request.urlopen(durl, timeout=60) as r:
|
||
audio = r.read()
|
||
except urllib.error.HTTPError as e:
|
||
# 400 = file_id истёк (TG хранит файлы ограниченно) — тихо отдаём отказ
|
||
print(f"[voice] getFile HTTP {e.code}: файл недоступен", flush=True)
|
||
return None, None
|
||
except Exception as e:
|
||
print(f"[voice] download error: {e}", flush=True)
|
||
return None, None
|
||
try: # кэш — best effort
|
||
os.makedirs(VOICE_DIR, exist_ok=True)
|
||
with open(cache, "wb") as f:
|
||
f.write(audio)
|
||
except Exception:
|
||
pass
|
||
return audio, cache
|
||
|
||
|
||
# ────────────── извлечение аудио из видеоконтейнера ─────────────────────────
|
||
|
||
def extract_audio_track(container: bytes):
|
||
"""Видеоконтейнер (mp4 и т.п.) → ogg/opus аудиодорожка через ffmpeg.
|
||
|
||
Возвращает bytes дорожки или None (нет дорожки / ffmpeg отказал).
|
||
ffmpeg есть на хосте (6.1.1) — тянуть в venv нечего. t-261004152714
|
||
"""
|
||
import tempfile
|
||
src = dst = None
|
||
try:
|
||
with tempfile.NamedTemporaryFile(prefix="simbiot-v_", delete=False) as tf:
|
||
tf.write(container)
|
||
src = tf.name
|
||
# -vn: только звук; моно 16к — формат, который любят оба ASR-яруса
|
||
proc = subprocess.run(
|
||
["ffmpeg", "-v", "error", "-i", src, "-vn", "-ac", "1",
|
||
"-ar", "16000", "-f", "ogg", "-c:a", "libopus", "-"],
|
||
capture_output=True, timeout=180)
|
||
if proc.returncode != 0 or not proc.stdout:
|
||
err = (proc.stderr or b"").decode()[:200]
|
||
print(f"[voice] ffmpeg: дорожка не извлечена ({err})", flush=True)
|
||
return None
|
||
return proc.stdout
|
||
except Exception as e:
|
||
print(f"[voice] ffmpeg error: {e}", flush=True)
|
||
return None
|
||
finally:
|
||
for p in (src, dst):
|
||
if p and os.path.exists(p):
|
||
try:
|
||
os.unlink(p)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
# ─────────────────────────────── ASR ярусы ──────────────────────────────────
|
||
|
||
def transcribe_bytes(audio: bytes, filename: str = "audio.ogg",
|
||
long: bool = False):
|
||
"""Байты аудио → текст. '' = тишина/пусто, None = не смогли.
|
||
|
||
Ярус 1 — локальный ASR-контейнер whisper base (быстро, ~1с; короткие
|
||
русские фразы распознаёт с ошибками). Ярус 2 — faster-whisper
|
||
large-v3-turbo (точнее на русском, ~7-15с на короткое голосовое).
|
||
Второй ярус включается: при пустом результате первого или при
|
||
long=True (короткие фразы и длинные записи — где base слабее всего).
|
||
"""
|
||
text = _asr_webservice(audio, filename)
|
||
if text:
|
||
return text, "whisper-asr-base"
|
||
# короткие фразы (≤30с) — зона, где base ошибается чаще всего;
|
||
# длинные записи base тянет хорошо, но large-v3-turbo точнее
|
||
fw = _asr_faster_whisper(audio, filename)
|
||
if fw: # непустой результат второго яруса
|
||
return fw, "faster-whisper-large-v3-turbo"
|
||
return text, None # '' = тишина, None = оба яруса недоступны
|
||
|
||
|
||
def _asr_webservice(audio: bytes, filename: str):
|
||
"""POST /asr в whisper-asr-webservice. None при недоступности сервиса."""
|
||
import uuid
|
||
boundary = "----simbiot" + uuid.uuid4().hex
|
||
body = (
|
||
f'--{boundary}\r\nContent-Disposition: form-data; name="audio_file"; '
|
||
f'filename="{filename}"\r\nContent-Type: audio/ogg\r\n\r\n'
|
||
).encode() + audio + f"\r\n--{boundary}--\r\n".encode()
|
||
req = urllib.request.Request(
|
||
f"{ASR_URL}/asr?output=json", data=body,
|
||
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=120) as r:
|
||
data = json.loads(r.read().decode())
|
||
return (data.get("text") or "").strip()
|
||
except Exception as e:
|
||
print(f"[voice] asr-webservice error: {e}", flush=True)
|
||
return None
|
||
|
||
|
||
def _asr_faster_whisper(audio: bytes, filename: str):
|
||
"""faster-whisper large-v3-turbo через системный python3 (subprocess).
|
||
|
||
В venv харнеса пакета нет (и не тащим: ctranslate2 тянет за собой
|
||
тяжёлые бинарники) — модель живёт в системном python, зовём воркером.
|
||
None при отказе, '' при пустой расшифровке.
|
||
"""
|
||
try:
|
||
req = json.dumps({
|
||
"audio_b64": base64.b64encode(audio).decode(),
|
||
"filename": filename,
|
||
}, ensure_ascii=False)
|
||
proc = subprocess.run(
|
||
[_FW_PYTHON, _FW_WORKER], input=req.encode(),
|
||
capture_output=True, timeout=600)
|
||
out = proc.stdout.decode() if proc.stdout else ""
|
||
try:
|
||
data = json.loads(out.strip().splitlines()[-1]) if out.strip() else {}
|
||
except (ValueError, IndexError):
|
||
data = {"error": (proc.stderr or b"").decode()[:300]}
|
||
if data.get("error") or proc.returncode != 0:
|
||
print(f"[voice] faster-whisper error: "
|
||
f"{data.get('error') or proc.returncode}", flush=True)
|
||
return None
|
||
return (data.get("text") or "").strip()
|
||
except Exception as e:
|
||
print(f"[voice] faster-whisper subprocess error: {e}", flush=True)
|
||
return None
|
||
|
||
|
||
# ─────────────────────────── оркестрация ────────────────────────────────────
|
||
|
||
def transcribe_message(msg_id: str, file_id: str, file_unique_id: str = "",
|
||
duration=None, kind: str = "voice"):
|
||
"""Полный путь одного входящего: download + ASR + MessageTranscribed.
|
||
|
||
msg_id — 'm-NNN' (aggregate_id). Возвращает (текст, engine):
|
||
текст '' = тишина/пусто, None = расшифровать не смогли;
|
||
идемпотентно: повторный вызов по уже расшифрованному — транскрипт из
|
||
read model без хождений в Telegram/ASR.
|
||
"""
|
||
model = es.run_projections()
|
||
m = (model.get("inbox") or {}).get(msg_id)
|
||
if m is None:
|
||
return None, None # входящее не найдено (нет MessageReceived)
|
||
if m.get("transcribed"):
|
||
return m.get("transcript") or "", m.get("engine")
|
||
|
||
reason = None
|
||
if kind not in TRANSCRIBABLE_KINDS:
|
||
reason = "not_transcribable"
|
||
elif duration and duration > MAX_DURATION:
|
||
reason = "too_long"
|
||
if reason:
|
||
es.append_event("inbox", msg_id, "MessageTranscribed", {
|
||
"transcript": "", "engine": None, "reason": reason,
|
||
"duration": duration,
|
||
})
|
||
return "", None
|
||
|
||
src_audio, _path = _download_voice(file_id, file_unique_id)
|
||
if src_audio is None:
|
||
es.append_event("inbox", msg_id, "MessageTranscribed", {
|
||
"transcript": "", "engine": None, "reason": "download_failed",
|
||
"duration": duration,
|
||
})
|
||
return None, None
|
||
# видео-контейнер: речь лежит в дорожке — вынимаем её ffmpeg'ом
|
||
if kind == "video":
|
||
audio = extract_audio_track(src_audio)
|
||
if audio is None:
|
||
es.append_event("inbox", msg_id, "MessageTranscribed", {
|
||
"transcript": "", "engine": None, "reason": "audio_track_failed",
|
||
"duration": duration,
|
||
})
|
||
return None, None
|
||
else:
|
||
audio = src_audio
|
||
# короткие русские фразы base-модель коверкает («Туше, что ты тебя
|
||
# слышишь» вместо «Ты теперь слышишь») — для voice/video_note/video ≤60с
|
||
# сразу идём в точный large-v3-turbo, long-записи — как получится
|
||
short_speech = kind in ("voice", "video_note", "video") and (duration or 0) <= 60
|
||
if short_speech:
|
||
text, engine = _asr_faster_whisper(audio, "audio.ogg"), "faster-whisper-large-v3-turbo"
|
||
if text: # точный путь сработал
|
||
es.append_event("inbox", msg_id, "MessageTranscribed", {
|
||
"transcript": text[:4000], "engine": engine,
|
||
"bytes": len(audio), "duration": duration,
|
||
})
|
||
return text, engine
|
||
text, engine = transcribe_bytes(audio, "audio.ogg")
|
||
es.append_event("inbox", msg_id, "MessageTranscribed", {
|
||
"transcript": (text or "")[:4000],
|
||
"engine": engine if text else None,
|
||
"reason": None if text is not None else "asr_failed",
|
||
"bytes": len(audio),
|
||
"duration": duration,
|
||
})
|
||
return text, engine |