Vapiの独自音声:30行でつくる自前のTTSサーバー
Vapiは電話やウェブ向けのAI音声エージェントを動かすサービスで、独自の音声合成を持ち込めます。custom-voiceプロバイダーを使うと、アシスタントが話す各セリフを自分のサーバーに送り、返ってきた音声を再生します。
このガイドでは、そのサーバーをPythonでつくります。自分のマシンで無料のSuperClonicaエンジンを使えば音声そのものは無料で、SuperClonicaの音声合成APIも使えます。どちらでも、落ち着いたナレーターからゲームのキャラクターまでカタログのどの声も、自分の録音から学習させた声も使えます。
Vapiが送るものと期待するもの
Vapiの独自TTSのガイドによると、VapiはJSONのPOSTを送り、そのmessageオブジェクトにはtype voice-request、テキスト、希望するsampleRateが入っています。応答はヘッダーのない生のPCMで、モノラル、16ビット符号付き、リトルエンディアン、そのサンプルレートちょうど、Content-Typeは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 kHzで読み上げ、scipyがVapiの求めるレートにリサンプリングし、サーバーは16ビットPCMを返します。正しいsecretのないリクエストには401を返します。uvicornで起動します:
uvicorn vapi_voice:app --host 0.0.0.0 --port 8000
アシスタントにつなぐ
Vapiが公開HTTPSでサーバーに届く必要があります。いつものリバースプロキシの後ろに置くか、テストならトンネルを使ってください。次に、たとえばVapi APIでアシスタントを更新するときに、アシスタントの声を設定します:
{
"voice": {
"provider": "custom-voice",
"server": {
"url": "https://your-server.example.com/synthesize",
"secret": "the value of VAPI_SECRET",
"timeoutSeconds": 30
}
}
}サーバーのVAPI_SECRETにも同じ値を設定します。Vapiのガイドには、ダッシュボードで作成する認証情報や予備の声も説明されています。サーバーが落ちても通話が止まらないよう、予備の声を追加しておくとよいでしょう。
APIを使う同じサーバー
サーバーが小さくてモデルを置けないなら、SuperClonicaの音声合成APIで読み上げます。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ドルで、すべての通話を合わせて10分ごとの枠で最大3分、Proは20分です。
速度と声
サーバーは各リクエストを最後まで読み上げてから応答します。私たちの計測では、短い文の中央値は4スレッドのデスクトップRyzenで0.43秒、RTX 5070 Tiで0.12秒でした。アシスタントの文は短くしましょう。TTS_VOICEには内蔵の10の声(M1〜M5、F1〜F5)かカタログの声のファイルを指定します。Supertonic 3は日本語(TTS_LANGUAGE=ja)を含む31言語を話しますが、中国語にはまだ対応していません。