РУКОВОДСТВО

Свой голос для Vapi: собственный TTS-сервер в 30 строк

Vapi запускает голосовых AI-агентов для телефона и веба и позволяет подключить свою озвучку текста: с провайдером custom-voice он отправляет каждую реплику ассистента на ваш сервер и проигрывает звук, который тот вернёт.

В этом руководстве мы соберём такой сервер на Python. Он озвучивает текст бесплатным движком SuperClonica на вашей машине, так что сама речь ничего не стоит, или через API озвучки SuperClonica. В обоих случаях агенту доступны любые голоса каталога — от спокойных дикторов до игровых персонажей — или голос, обученный по вашей записи.

Что Vapi присылает и чего ждёт

Согласно руководству Vapi по своему TTS, Vapi отправляет POST с JSON, в котором объект message содержит type voice-request, текст и нужный sampleRate. Ответ должен быть сырым PCM без заголовка: моно, 16 бит со знаком, little-endian, ровно с этой частотой и с типом содержимого application/octet-stream. Если задан встроенный secret, Vapi присылает его в заголовке X-VAPI-SECRET.

Сервер

Установите пакеты и сохраните это как vapi_voice.py:

pip install fastapi uvicorn scipy superclonica-cpu

import math, os
import numpy as np
from fastapi import FastAPI, Header, HTTPException, Request, Response
from fastapi.concurrency import run_in_threadpool
from scipy.signal import resample_poly
from superclonica import Superclonica

VOICE = os.environ.get("TTS_VOICE", "F1")  # M1..M5, F1..F5 or a voice file from the catalog
LANGUAGE = os.environ.get("TTS_LANGUAGE", "en")
SECRET = os.environ["VAPI_SECRET"]
app = FastAPI()
tts = Superclonica()

def voice_audio(text):
    audio, rate = tts.synthesize(text, voice=VOICE, lang=LANGUAGE)
    return np.asarray(audio, dtype=np.float32).reshape(-1), rate

@app.post("/synthesize")
async def synthesize(request: Request, x_vapi_secret: str = Header(None)):
    if x_vapi_secret != SECRET:
        raise HTTPException(401)
    message = (await request.json())["message"]
    audio, rate = await run_in_threadpool(voice_audio, message["text"])
    target = int(message["sampleRate"])
    if target != rate:
        divisor = math.gcd(target, rate)
        audio = resample_poly(audio, target // divisor, rate // divisor)
    pcm = (np.clip(audio, -1, 1) * 32767).astype(np.int16).tobytes()
    return Response(pcm, media_type="application/octet-stream")

Движок озвучивает текст на 44,1 кГц в рабочем потоке, scipy пересэмплирует его под частоту, которую попросил Vapi, и сервер возвращает 16-битный PCM. Запросы без правильного секрета получают 401. Запустите его через uvicorn:

uvicorn vapi_voice:app --host 0.0.0.0 --port 8000

Подключение к ассистенту

Vapi должен достучаться до сервера по публичному HTTPS: поставьте его за ваш обычный обратный прокси или, для проверки, за туннель. Затем задайте голос ассистента, например при обновлении ассистента через API Vapi:

{
  "voice": {
    "provider": "custom-voice",
    "server": {
      "url": "https://your-server.example.com/synthesize",
      "secret": "the value of VAPI_SECRET",
      "timeoutSeconds": 30
    }
  }
}

На сервере задайте в VAPI_SECRET то же значение. Руководство Vapi описывает и учётные данные, созданные в его панели, и запасной голос — его стоит добавить, чтобы звонок не замолкал, если ваш сервер недоступен.

Тот же сервер через API

Если сервер слишком мал для модели, озвучивайте текст через API озвучки SuperClonica: замените voice_audio этой функцией и задайте SUPERCLONICA_KEY и VOICE_ID. Пересэмплирование и ответ остаются прежними.

import io, wave
import requests

API = "https://superclonica.com/api/v1/speech"
VOICE_ID = os.environ["VOICE_ID"]  # the ID on the page of a catalog voice

def voice_audio(text):
    answer = requests.post(API, timeout=60,
                           headers={"Authorization": "Bearer " + os.environ["SUPERCLONICA_KEY"]},
                           json={"text": text, "voice": VOICE_ID, "language": LANGUAGE, "format": "wav"})
    answer.raise_for_status()
    with wave.open(io.BytesIO(answer.content)) as file:
        rate = file.getframerate()
        pcm = np.frombuffer(file.readframes(file.getnframes()), dtype=np.int16)
    return pcm.astype(np.float32) / 32768, rate

Любой голос каталога работает по ID со своей страницы, на любом тарифе. «Старт» стоит $5 в месяц и даёт до 3 минут речи в каждом 10-минутном окне на все звонки вместе, Pro — 20 минут.

Скорость и голоса

Сервер озвучивает каждый запрос целиком, прежде чем ответить. По нашим замерам короткая фраза озвучивалась в среднем за 0,43 с на настольном Ryzen с четырьмя потоками и за 0,12 с на RTX 5070 Ti, так что держите реплики ассистента короткими. TTS_VOICE — один из десяти встроенных голосов, M1–M5 и F1–F5, или файл голоса из каталога; Supertonic 3 говорит на 31 языке, в том числе по-русски (TTS_LANGUAGE=ru), но пока не по-китайски.