Newer
Older
tgclient-mcp / backend / app / tg / waveform.py
"""Голосовые: duration из ogg-opus и waveform 5-бит.

- Duration ogg-opus: последний page заголовков содержит гранула-позицию
  (granule-position): по Ogg-спеке (финальный гранул = длительность в
  сэмплах 48 кГц Opus). Парсим финальную гранулу по последнему page
  (заголовок 'OggS' — байты 6..14 little-endian). Скачивать/декодировать
  PCM не нужно.
- Waveform: Telegram голосовое носит DocumentAttributeAudio(voice=True,
  waveform=bytes) — 63 семпла по 5 бит = 315 бит → 40 байт. Принимаем
  семплы от агента (список 0..100), квантуем и пакуем; без семплов —
  прямой бар высоты по умолчанию (клиент рисует плоский).
"""

import base64
import struct

WAVEFORM_SAMPLES = 63  # Telegram: ровно 63 семпла голосовой волны


def ogg_opus_duration(data: bytes) -> int | None:
    """Длительность ogg-opus в секундах (final granule / 48000), None если
    формат не узнан. Работает по хвосту файла — не декодирует PCM."""
    # последний page 'OggS' в хвосте файла (страницы до 64К; ищем с конца)
    idx = data.rfind(b"OggS")
    if idx < 0 or idx + 18 > len(data):
        return None
    granule = int.from_bytes(data[idx + 6 : idx + 14], "little", signed=True)
    if granule <= 0:
        return None
    return max(1, round(granule / 48000))


def pack_waveform(samples: list[int]) -> bytes:
    """63 семпла 0..100 → 40 байт 5-бит (старшие биты первыми — как у TG)."""
    if not samples:
        return b""
    vals = samples[:WAVEFORM_SAMPLES]
    if len(vals) < WAVEFORM_SAMPLES:
        vals = vals + [0] * (WAVEFORM_SAMPLES - len(vals))
    bits = ""
    for v in vals:
        q = max(0, min(31, round(v / 100 * 31)))
        bits += format(q, "05b")
    out = bytearray((len(bits) + 7) // 8)
    for i in range(len(bits)):  # старший бит первого байта — первый семпл
        if bits[i] == "1":
            out[i // 8] |= 0x80 >> (i % 8)
    return bytes(out)


def unpack_waveform(blob: bytes) -> list[int]:
    """40 байт 5-бит → 63 числа 0..100 (для сериализации сообщений)."""
    if not blob:
        return []
    bits = "".join(format(b, "08b") for b in blob)
    vals = []
    for i in range(WAVEFORM_SAMPLES):
        off = i * 5
        if off + 5 > len(bits):
            break
        q = int(bits[off : off + 5], 2)
        vals.append(round(q / 31 * 100))
    return vals


def base64_or_none(data: str | None) -> bytes:
    if not data:
        return b""
    return base64.b64decode(data, validate=False)