# Web・アプリでつなぐ

> ブラウザやアプリから AI と話す手順。セッションの作成、client_token と WebRTC の接続、声で返す・文章だけ返すの違い、avacast との組み合わせ。

Source: https://voicast.jp/docs/web

ブラウザやスマートフォンのアプリからは、WebRTC で voicast の音声サーバーにつなぎます。API キーはサービスのサーバーだけに置き、ブラウザやアプリには使い捨ての `client_token` だけを渡します。

```
サービスのサーバー ── API キーで POST /v1/sessions ──→ voicast
        │ client_token・webrtc_url・ice_servers だけを渡す
ブラウザ・アプリ ── client_token 付きの WebRTC の offer ──→ voicast の音声サーバー
```

## 1. セッションを作る（サーバー）

```bash
curl https://api.voicast.jp/v1/sessions \
  -H "Authorization: Bearer $VOICAST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "tenant_id": "ten_…", "output": "audio" }'
```

```json
{
  "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` | ○ | テナントの ID |
| `agent_id` | | 会話の設定の ID。省くとテナントの既定 |
| `output` | | `audio`（声で返す。既定）か `text`（返事の文章だけを返す） |

- `client_token` は 2 分で切れ、1 回だけ使えます。つなぎ直すときは新しいセッションを作ります。
- 会話の設定は、つないだときの最新の版を使います。
- `ice_servers` は `RTCPeerConnection` の `iceServers` にそのまま渡します。STUN のほか、voicast が TURN を用意しているときは、期限の短い資格情報（`username`・`credential`）付きの TURN が入ります。会社のネットワークなど UDP が通らないところでも、TURN があればつながりやすくなります。
- 残高が無いなどで新しい通話を断っているときは 402 です（[エラーと制限](https://voicast.jp/docs/errors.md)）。テナントが止まっていれば 409 `tenant_disabled`、会話の設定が決まらなければ 422 `no_agent` です。

## 2. つなぐ（ブラウザ・アプリ）

### SDK を使う

[SDK](https://voicast.jp/docs/sdk.md) の `voicast/web` は、マイクの取得・WebRTC の接続・イベントの受け取りをまとめたものです。

```ts
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 のクライアントを使う

音声サーバーは Pipecat の SmallWebRTC と同じ方式です。Pipecat のクライアント（JavaScript・iOS・Android）の SmallWebRTC のトランスポートで、接続先に `webrtc_url` を、`requestData` に `{ token: client_token }` を渡します。

### 自分で WebRTC をつなぐ

ほかの WebRTC の実装からも、次の手順でつなげます。

1. マイクの音声トラックを足した `RTCPeerConnection` と、データチャンネル（名前は自由。例 `chat`）を作る
2. offer を作り、ICE の候補を集め終えてから、`webrtc_url` に `{ sdp, type, token }` を POST する
3. 返った `{ sdp, type, pc_id }` を answer として入れる
4. あとから見つかった ICE の候補は、`PATCH webrtc_url` に `{ pc_id, candidates: [{ candidate, sdp_mid, sdp_mline_index }] }` で足す
5. データチャンネルが開いたら RTVI の `client-ready` を送り、そのあと 1 秒ごとに `ping` の文字列を送る（音声サーバーは数秒途切れると切れたとみなします）

```js
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' } }` が届きます。新しい offer に `pc_id` と `restart_pc: false` を付けて、同じ `webrtc_url` に POST します。

## 届くもの

返事の声は、`output` が `audio` なら音声トラックで届きます。データチャンネルには RTVI のメッセージ（`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'` のセッションは声を出さず、返事の文章を 1 文ずつ `server-message` で届けます。アバター（avacast など）や自社の音声合成に話させるとき、画面に文字で出すときに使います。

```ts
type VoicastMessage =
  | { type: 'voicast.reply'; text: string }    // 返事の 1 文（あいさつ・締めの言葉も同じ形）
  | { type: 'voicast.interrupted' }            // 相手が話し始めた。話している途中の返事を止める
  | { type: 'voicast.end'; outcome: string };  // 会話を終える（このあと接続が閉じる）
```

相手の声は、`output` によらずマイクの音声トラックで送ります。

## avacast と組み合わせる

[avacast](https://avacast.jp/) は、写真から作ったアバターをリアルタイムに話させる API です。voicast が聞き取りと返事の文章を、avacast が顔と声を受け持つと、画面の中の受付係と会話できます。

```ts
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();
```

自社のサーバーでは、voicast の `POST /v1/sessions`（`output: 'text'`）と avacast のセッションの作成を並べて呼び、両方のトークンだけを画面に返します。

## 結果

通話が終わると、電話と同じく通話の記録・Webhook が残ります。Web・アプリの通話は `channel: 'web'` で、セッションの `id`（`sess_…`）が `provider_call_id` になります。

```bash
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 のどちらも「つなげない」として扱います。
