App 里的通话和网页通话一样:Agent 听得到用户,可以展示卡片、调用 App 自己的函数;通话照常列入通话记录、做分析和计费。API 密钥留在你的服务器上,服务器给 App 一个一次性的地址或令牌。
| App | 连接方式 |
|---|---|
| iOS | 音频 WebSocket,用 iOS 的回声消除 |
| Android | 音频 WebSocket,用 Android 的回声消除 |
| React Native | 网页通话令牌加 Pipecat 的 React Native 客户端 |
iOS 和 Android:由你的服务器发起通话,把返回的 websocket_url 交给 App,15 分钟内可以打开一次:
curl https://evasay.com/api/v1/voice/calls/websocket \
-X POST \
-H "Authorization: Bearer sk-your-key" \
-H "Content-Type: application/json" \
-d '{ "agent_id": "AGENT_ID", "encoding": "pcm_s16le", "sample_rate": 16000, "user_id": "u_42", "page_tools": ["open_order"] }'
React Native:服务器用 POST /api/v1/voice/web-calls 创建网页通话令牌,把 token 和 web_call_url 交给 App。
用 URLSessionWebSocketTask 打开 websocket_url。用开启了语音处理的 AVAudioEngine 录音(这样 Agent 听不到扬声器里自己的声音),把麦克风转成通话采样率的 16 位 PCM,每 20 毫秒发一个二进制帧。EvaSay 发来的二进制帧是同样格式的 Agent 声音,用 AVAudioPlayerNode 播放。
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker])
try engine.inputNode.setVoiceProcessingEnabled(true)
let socket = URLSession.shared.webSocketTask(with: websocketURL)
socket.resume()
// microphone tap → 16 kHz Int16 mono → socket.send(.data(frame))
// socket.receive: .data → player.scheduleBuffer, .string → the call's events
文本帧是 JSON 格式的通话事件。收到 clear(用户打断了)就停止播放并丢掉排队的声音;收到 ended 就关闭连接。在 App 的 Info.plist 里加上 NSMicrophoneUsageDescription。
用 OkHttp 打开 websocket_url。用 VOICE_COMMUNICATION 音源的 AudioRecord 录音(会开启手机的回声消除),用 USAGE_VOICE_COMMUNICATION 的 AudioTrack 播放,都是通话采样率的 16 位单声道。
val record = AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION, 16000,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferBytes)
val socket = OkHttpClient().newWebSocket(Request.Builder().url(websocketUrl).build(), listener)
// a thread reads 640 bytes (20 ms) at a time → socket.send(frame.toByteString())
// listener.onMessage(bytes) → track.write(...); onMessage(text) → the call's events
收到 clear 时暂停并清空 AudioTrack;收到 ended 就关闭连接。App 需要 RECORD_AUDIO 和 INTERNET 权限。
把 { "token": "…", "pageTools": ["open_order"] } POST 到 https://evasay.com + web_call_url,返回的 data 是这通电话的连接信息。transport 为 cf 时,通话经 cfUrl 走 Cloudflare Realtime:用 Pipecat 的 React Native SmallWebRTC 传输(@pipecat-ai/react-native-small-webrtc-transport,配合 @pipecat-ai/client-js)连接,把它建立连接的步骤换成下面 EvaSay 的信令。返回里有 offerUrl 时,不改的传输直接连它即可。
// Offer to cfUrl, then answer each offer Cloudflare makes at /api/cf/answer
// until the reply names the two data channels.
await pc.setLocalDescription(await pc.createOffer());
let reply = await post(cfUrl, { sdp: pc.localDescription.sdp, type: 'offer', requestData });
const pcId = reply.pc_id;
for (;;) {
if (reply.answer) await pc.setRemoteDescription({ type: 'answer', sdp: reply.answer });
if (reply.offer) {
await pc.setRemoteDescription({ type: 'offer', sdp: reply.offer });
await pc.setLocalDescription(await pc.createAnswer());
reply = await post(answerUrl, { pc_id: pcId, sdp: pc.localDescription.sdp });
} else if (reply.next) reply = await post(answerUrl, { pc_id: pcId });
else break;
}
// "to-agent" is the channel RTVI messages are sent on, "to-caller" the agent's.
dc = pc.createDataChannel('to-agent', { negotiated: true, id: reply.channels.toAgent });
pc.createDataChannel('to-caller', { negotiated: true, id: reply.channels.toCaller });
发 offer 之前,加一个带麦克风的音频收发器(sendonly);Agent 的声音在 Cloudflare 的 offer 里过来。Cloudflare 不需要 App 发 ICE 候选;连接断开就结束通话。
卡片、页面函数和提示以 RTVI server-message 发来(evasay.cards、evasay.tool、evasay.notice)。打的字用 send-text 发送,页面情况和函数结果用 client-message 的 evasay.context、evasay.tool_result({ id, result } 或 { id, error })发送。
给 Agent 加一个类型为在你的网页上运行("kind": "page")的函数,在 App 里应答:WebSocket 通话会发来 {"type":"tool","id","name","arguments"},App 回 {"type":"tool_result","id","result"}。Agent 只能调用 App 声明过的函数(page_tools 或 pageTools)。这些函数以登录用户的身份运行:涉及付款或不可撤销的操作,先在 App 里让用户确认。