Web・アプリでつなぐ
ブラウザや
ブラウザやclient_token だけを
サービスのサーバー ── API キーで POST /v1/sessions ──→ voicast
│ client_token・webrtc_url・ice_servers だけを渡す
ブラウザ・アプリ ── client_token 付きの WebRTC の offer ──→ voicast の音声サーバー1. セッションを作る(サーバー)#
curl https://api.voicast.jp/v1/sessions \
-H "Authorization: Bearer $VOICAST_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "tenant_id": "ten_…", "output": "audio" }'{
"id": "sess_…",
"tenant_id": "ten_…",
"agent_id": "agt_…",
"output": "audio",
"client_token": "vct_…",
"expires_at": 1790000120000,
"webrtc_url": "https://voice.voicast.jp/v1/webrtc/offer",
"ice_servers": [{ "urls": "stun:stun.cloudflare.com:3478" }]
}| 入力 | 必須 | 内容 |
|---|---|---|
tenant_id |
○ | テナントの |
agent_id |
会話の |
|
output |
audiotext |
client_tokenは2 分で 切れ、 1 回だけ 使えます。 つなぎ直す ときは 新しい セッションを 作ります。 - 会話の
設定は、 つないだときの 最新の 版を 使います。 ice_serversはRTCPeerConnectionのiceServersにそのまま 渡します。 STUN の ほか、 voicast が TURN を 用意している ときは、 期限の 短い 資格情報 ( username・credential)付きの TURN が 入ります。 会社の ネットワークなど UDP が 通らない ところでも、 TURN が あれば つながりやすくなります。 - 残高が
無いなどで 新しい 通話を 断っている ときは 402 です (エラーと 制限 )。 テナントが 止まっていれば 409 tenant_disabled、会話の 設定が 決まらなければ 422 no_agentです。
2. つなぐ(ブラウザ・アプリ)#
SDK を使う#
SDK のvoicast/web は、
import { VoiceSession } from 'voicast/web';
// client_token・webrtc_url・ice_servers は、自社のサーバーから受け取る
const session = new VoiceSession({ clientToken, webrtcUrl, iceServers });
session.on('user-transcript', ({ text, final }) => showUser(text, final));
session.on('bot-output', ({ text }) => showBot(text));
session.on('closed', ({ reason }) => console.log('closed', reason));
await session.start(); // マイクの許可を求めてからつなぐ
hangupButton.onclick = () => session.stop();ブラウザは、start() は
Pipecat のクライアントを使う#
音声サーバーはwebrtc_url を、requestData に{ token: client_token } を
自分で WebRTC をつなぐ#
ほかの
- マイクの
音声トラックを 足した RTCPeerConnectionと、データチャンネル (名前は 自由。 例 chat)を 作る - offer を
作り、 ICE の 候補を 集め終えてから、 webrtc_urlに{ sdp, type, token }をPOST する - 返った
{ sdp, type, pc_id }をanswer と して 入れる - あとから
見つかった ICE の 候補は、 PATCH webrtc_urlに{ pc_id, candidates: [{ candidate, sdp_mid, sdp_mline_index }] }で足す - データチャンネルが
開いたら RTVI の client-readyを送り、 その あと 1 秒ごとに pingの文字列を 送る (音声サーバーは 数秒途切れると 切れたとみなします)
const pc = new RTCPeerConnection({ iceServers }); // セッションの ice_servers
const mic = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
pc.addTransceiver(mic.getAudioTracks()[0], { direction: 'sendrecv' });
pc.ontrack = (e) => { audioEl.srcObject = e.streams[0]; audioEl.play(); };
const dc = pc.createDataChannel('chat', { ordered: true });
dc.onopen = () => {
dc.send(JSON.stringify({ label: 'rtvi-ai', type: 'client-ready', id: crypto.randomUUID(), data: { version: '1.0.0' } }));
setInterval(() => dc.readyState === 'open' && dc.send(`ping: ${Date.now()}`), 1000);
};
dc.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.label === 'rtvi-ai') onRtvi(msg); // 下の「届くもの」
};
await pc.setLocalDescription(await pc.createOffer());
await iceGatheringComplete(pc);
const r = await fetch(webrtcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sdp: pc.localDescription.sdp, type: pc.localDescription.type, token: clientToken }),
});
if (!r.ok) throw new Error(`つなげません(${r.status})`); // 403: トークンの期限切れ・使用済み
const answer = await r.json();
await pc.setRemoteDescription({ type: answer.type, sdp: answer.sdp });音声サーバーが{ type: 'signalling', message: { type: 'renegotiate' } } がpc_id とrestart_pc: false をwebrtc_url に
届くもの#
返事のoutput がaudio ならlabel: 'rtvi-ai')
type |
内容 |
|---|---|
bot-ready |
AI の |
user-transcription |
相手のdata.text・data.final) |
bot-output |
AI がoutput: 'audio' の |
bot-started-speaking・bot-stopped-speaking |
AI が |
server-message |
voicast の |
文章だけを返す(output: text)#
output: 'text' のserver-message で
type VoicastMessage =
| { type: 'voicast.reply'; text: string } // 返事の 1 文(あいさつ・締めの言葉も同じ形)
| { type: 'voicast.interrupted' } // 相手が話し始めた。話している途中の返事を止める
| { type: 'voicast.end'; outcome: string }; // 会話を終える(このあと接続が閉じる)相手のoutput に
avacast と組み合わせる#
avacast は、
import { VoiceSession } from 'voicast/web';
import { AvacastSession } from 'https://avacast.jp/sdk/v1.js';
// どちらのトークンも自社のサーバーで作る(voicast は output: 'text' のセッション)
const { voicast, avacast } = await fetch('/api/start-reception', { method: 'POST' }).then((r) => r.json());
const avatar = new AvacastSession(avacast.client_token, {
signalingUrl: avacast.webrtc.signaling_url,
iceServers: avacast.webrtc.ice_servers,
});
avatar.attach(document.getElementById('avatar'));
await avatar.start();
const talk = new VoiceSession({
clientToken: voicast.client_token,
webrtcUrl: voicast.webrtc_url,
iceServers: voicast.ice_servers,
});
talk.on('reply', ({ text }) => avatar.speak(text)); // voicast の返事を avacast に話させる
talk.on('interrupted', () => avatar.interrupt()); // 相手が話し始めたら止める
talk.on('end', ({ outcome }) => showResult(outcome));
await talk.start();自社のPOST /v1/sessionsoutput: 'text')
結果#
通話がchannel: 'web' で、idsess_…)provider_call_id に
curl https://api.voicast.jp/v1/calls/by-provider/sess_… \
-H "Authorization: Bearer $VOICAST_API_KEY"注意#
- 相手の
マイクに AI の 声が 回り込むと、 AI が 自分の 声に 反応する ことがあります。 ブラウザの エコーキャンセル ( echoCancellation: true)を 有効にします。 ヘッドホンだと さらに 安定します。 - 会社の
ネットワークなどで UDP が 通らないと、 つながらない ことがあります。 セッションの ice_serversを必ず渡してください (TURN が 入っていれば、 その 経路で つなぎます) 。 - つなぐ
段階 (offer) で 断られた ときは 403 です (トークンの 期限切れ・ 使用済み、 料金の 都合など) 。 料金の 都合は いずれ 402 に そろえる 予定なので、 403 と 402 の どちらも 「つなげない」 と して 扱います。