"""Голосовые: 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)