本文档介绍如何集成扣子编程 Realtime Android SDK,将你在扣子编程中搭建的 AI 智能体集成到你的 Android 应用中,实现用户与智能体进行音视频通话。
扣子编程 Realtime Android SDK 封装了火山引擎 Android RTC 音视频链路相关 API,接入流程简洁高效。
开始集成 Realtime SDK 前,请确保开发环境满足以下要求:
| 操作 | 说明 |
|---|---|
|
发布智能体 |
已成功搭建并发布智能体为 API 服务。搭建步骤可参考搭建可视化智能体或搭建低延时语音助手,发布步骤请参见发布为 API 服务。 说明 若需使用视频理解能力,请为智能体配置一个视觉模型,例如豆包视觉理解模型。 |
|
获取访问密钥 |
获取访问密钥,用于身份认证与鉴权。
说明 扣子编程 SDK 封装了多种鉴权方式,能够有效简化鉴权流程,你可以参考鉴权示例代码实现不同方式的 OAuth 认证,以获取和管理访问扣子编程 API 所需的令牌。 |
|
准备开发环境 |
|
音视频通话 Android 示例项目的目录结构如下:
app/
├── build.gradle.kts
├── .gitignore
└── src/
└── main/
├── java/
│ └── com/
│ └── coze/
│ └── java_example/
│ ├── MainActivity.java
│ ├── config/
│ │ └── Config.java
│ ├── manager/
│ │ └── CozeAPIManager.java
│ └── utils/
│ └── ToastUtil.java
├── res/
│ ├── layout/
│ │ └── activity_main.xml
│ ├── values/
│ │ ├── colors.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ ├── ... 其他目录
└── AndroidManifest.xml
kotlin-example/
├── build.gradle.kts
├── .gitignore
└── src/
└── main/
├── java/
│ └── com/
│ └── coze/
│ └── kotlin_example/
│ ├── MainActivity.kt
│ ├── config/
│ │ └── Config.kt
│ ├── manager/
│ │ └── CozeAPIManager.kt
│ └── utils/
│ └── ToastUtil.kt
├── res/
│ ├── layout/
│ │ └── activity_main.xml
│ ├── values/
│ │ ├── colors.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ ├── ... 其他文件
└── AndroidManifest.xml
git clone https://github.com/coze-dev/coze-android
main/java-example/src/main/res/values/strings.xml 配置文件,配置 coze_access_token 和 bot_id和音色 ID。 <resources>
<!-- 其他配置 -->
<!-- 替换为从 https://www.coze.cn/open/oauth/pats 获取到的token-->
<string name="coze_access_token">pat_PLraJCVarqLuoE1********</string>
<string name="base_url">https://api.coze.cn</string>
<!-- 替换为自己的bot -->
<string name="bot_id">74********</string>
<!-- 音色ID,可选 -->
<string name="voice_id">742********09</string>
</resources>
本步骤为如何创建一个新项目,如集成到已有项目,请直接查看步骤二。
本文以通过 Gradle 直接集成为例。如需手动集成,请参考火山引擎 RTC 文档。
settings.gradle.kts 文件,并添加以下内容以配置 Maven 仓库:
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url = uri("https://artifact.bytedance.com/repository/Volcengine/") }
maven { url = uri("https://repo.maven.apache.org/maven2/") }
}
}
gradle/libs.versions.toml 文件,配置火山 RTC SDK 和扣子编程 SDK 的版本信息。建议使用 VSCode 或其他文本编辑器打开该文件。[versions]
# 你的其他配置
volcenginertc = "3.58.1.19400" # 替换为实际版本号
cozeApi = "0.2.1" # Coze SDK 版本号
[libraries]
# 你的其他配置
volcenginertc = { module = "com.volcengine:VolcEngineRTC", version.ref = "volcenginertc" }
coze-api = { module = "com.coze:coze-api", version.ref = "cozeApi" }
[versions]
# 你的其他配置
volcenginertc = "3.58.1.19400" # 替换为实际版本号
cozeApi = "0.2.1" # Coze SDK 版本号
[libraries]
# 你的其他配置
volcenginertc = { module = "com.volcengine:VolcEngineRTC", version.ref = "volcenginertc" }
coze-api = { module = "com.coze:coze-api", version.ref = "cozeApi" }
说明
volcenginertc 中的版本号替换为 RTC 实际使用的版本号。具体版本信息可在下载 RTC SDK页面查看。cozeApi 中的版本号替换为扣子编程 SDK 的最新版本,具体版本信息可在扣子编程 releases 页面查看。toml 文件后,可以通过以下方式拉取相关依赖:
./gradlew clean build --refresh-dependencies
说明
本文以 MainActivity.kt 和 MainActivity.java 中的实现步骤为例,讲解如何实现一个基本的音视频通话功能。本示例并未覆盖全部代码内容,如需查看完整代码,请从音视频通话 Android 示例项目获取完整代码。
引入实现音视频通话功能所需的头文件。
import com.ss.bytertc.engine.RTCRoom;
import com.ss.bytertc.engine.RTCVideo;
import com.coze.openapi.service.service.CozeAPI
public class MainActivity extends AppCompatActivity {
private RTCVideo rtcVideo;
private RTCRoom rtcRoom;
private CozeAPI cozeCli;
}
import com.ss.bytertc.engine.RTCRoom;
import com.ss.bytertc.engine.RTCVideo;
import com.coze.openapi.service.service.CozeAPI
class MainActivity : AppCompatActivity() {
private var rtcVideo: RTCVideo? = null
private var rtcRoom: RTCRoom? = null
private lateinit var cozeCli: CozeAPI
}
创建基本的用户界面,包括本地视频预览视图、控制按钮等,并设置初始状态。将布局文件中定义的组件绑定到代码中,如控制按钮、本地视频预览视图等,并设置相应的事件监听器。
private void initUI() {
localViewContainer = findViewById(R.id.local_view_container);
btnConnect = findViewById(R.id.btn_connect);
btnConnect.setBackgroundColor(Color.GRAY);
btnVideo = findViewById(R.id.btn_video);
btnVideo.setBackgroundColor(Color.GRAY);
btnAudio = findViewById(R.id.btn_audio);
btnAudio.setBackgroundColor(Color.GRAY);
btnInterrupt = findViewById(R.id.btn_interrupt);
btnInterrupt.setBackgroundColor(Color.GRAY);
roomIdInput = findViewById(R.id.room_id_input);
messageTextView = findViewById(R.id.message_text_view);
connect();
initVideoControl();
initAudioControl();
setBtnInterrupt();
}
private fun initUI() {
localViewContainer = findViewById(R.id.local_view_container)
btnConnect = findViewById<Button>(R.id.btn_connect).apply {
setBackgroundColor(Color.GRAY)
}
btnVideo = findViewById<Button>(R.id.btn_video).apply {
setBackgroundColor(Color.GRAY)
}
btnAudio = findViewById<Button>(R.id.btn_audio).apply {
setBackgroundColor(Color.GRAY)
}
btnInterrupt = findViewById<Button>(R.id.btn_interrupt).apply {
setBackgroundColor(Color.GRAY)
}
roomIdInput = findViewById(R.id.room_id_input)
messageTextView = findViewById(R.id.message_text_view)
connect()
initVideoControl()
initAudioControl()
setBtnInterrupt()
}
在使用音视频通话功能之前,需要确保应用已获取必要的权限,包括麦克风、摄像头、网络访问权限。
public void requestPermission() {
String[] PERMISSIONS_STORAGE = {
Manifest.permission.RECORD_AUDIO,
Manifest.permission.CAMERA,
Manifest.permission.INTERNET
};
boolean needPermission = false;
for (String permission : PERMISSIONS_STORAGE) {
if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
needPermission = true;
break;
}
}
if(needPermission){
requestPermissions(PERMISSIONS_STORAGE, 22);
}
}
private fun checkAndRequestPermissions(): Boolean {
val permissions = arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO,
Manifest.permission.INTERNET,
Manifest.permission.MODIFY_AUDIO_SETTINGS
)
val permissionsToRequest = permissions.filter { permission ->
ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED
}
return if (permissionsToRequest.isNotEmpty()) {
ActivityCompat.requestPermissions(
this,
permissionsToRequest.toTypedArray(),
100
)
false // 返回false表示有权限需要申请
} else {
true // 返回true表示所有权限都已获取
}
}
调用扣子编程的创建房间接口创建房间,以便后续从扣子编程平台获取房间信息。
// 第一步,在coze创建房间
CreateRoomReq req = CreateRoomReq.builder()
.botID(Config.getInstance().getBotID())
.voiceID(Config.getInstance().getVoiceID())
.build();
roomInfoTemp = cozeCli.audio().rooms().create(req);
// 第一步,在coze创建房间
CreateRoomReq req = CreateRoomReq.builder()
.botID(Config.getInstance().getBotID())
.voiceID(Config.getInstance().getVoiceID())
.build();
roomInfoTemp = cozeCli.audio().rooms().create(req);
说明
创建房间后返回的 Token,其默认有效期为 3 分钟,如果 3 分钟内没有用户加入房间,或者用户静音 3 分钟,智能体将自动退出房间,房间随即被释放,后续若要再次和智能体对话,则需重新创建房间。
调用 createRTCVideo() 方法,使用扣子编程平台返回的房间信息初始化音视频引擎。
// 创建引擎
rtcVideo = RTCVideo.createRTCVideo(
getApplicationContext(), // 使用ApplicationContext而不是Activity
roomInfo.getAppID(),
new IRTCVideoEventHandler() {
@Override
public void onWarning(int warn) {
Log.w(TAG, "RTCVideo warning: " + warn);
}
@Override
public void onError(int err) {
Log.e(TAG, "RTCVideo error: " + err);
}
},
null,
null
);
// 创建引擎
rtcVideo = RTCVideo.createRTCVideo(
getApplicationContext(), // 使用ApplicationContext而不是Activity
roomInfo.getAppID(),
new IRTCVideoEventHandler() {
@Override
public void onWarning(int warn) {
Log.w(TAG, "RTCVideo warning: " + warn);
}
@Override
public void onError(int err) {
Log.e(TAG, "RTCVideo error: " + err);
}
},
null,
null
);
调用 setLocalVideoCanvas() 方法设置本端视频渲染视图。
// 设置本地预览窗口
TextureView localTextureView = new TextureView(MainActivity.this);
localViewContainer.removeAllViews();
localViewContainer.addView(localTextureView);
VideoCanvas videoCanvas = new VideoCanvas();
videoCanvas.renderView = localTextureView;
videoCanvas.renderMode = VideoCanvas.RENDER_MODE_HIDDEN;
// 设置本地视频渲染视图
rtcVideo.setLocalVideoCanvas(StreamIndex.STREAM_INDEX_MAIN, videoCanvas);
// 设置本地预览窗口
val localTextureView = TextureView(this@MainActivity)
localViewContainer.removeAllViews()
localViewContainer.addView(localTextureView)
VideoCanvas().apply {
renderView = localTextureView
renderMode = VideoCanvas.RENDER_MODE_HIDDEN
// 设置本地视频渲染视图
rtcVideo?.setLocalVideoCanvas(StreamIndex.STREAM_INDEX_MAIN, this)
}
调用 createRTCRoom 方法创建 RTC 房间,调用 joinRoom() 方法加入房间。
// 第创建RTC房间
rtcRoom = rtcVideo.createRTCRoom(roomInfo.getRoomID());
rtcRoom.setRTCRoomEventHandler(rtcRoomEventHandler);
// 用户信息
UserInfo userInfo = new UserInfo(roomInfo.getUid(), "");
// 设置房间配置
RTCRoomConfig roomConfig = new RTCRoomConfig(
ChannelProfile.CHANNEL_PROFILE_CHAT_ROOM,
true, true, true);
// 第四步,加入房间
rtcRoom.joinRoom(roomInfo.getToken(), userInfo, roomConfig);
// 创建RTC房间
rtcRoom = rtcVideo?.createRTCRoom(roomInfo?.roomID)?.apply {
setRTCRoomEventHandler(rtcRoomEventHandler)
// 用户信息
val userInfo = UserInfo(roomInfo?.uid, "")
// 设置房间配置
val roomConfig = RTCRoomConfig(
ChannelProfile.CHANNEL_PROFILE_CHAT_ROOM,
true, true, true
)
// 加入房间
joinRoom(roomInfo?.token, userInfo, roomConfig)
}
在 IRTCRoomEventHandler 回调中重写onUserMessageReceived方法,解析接收到的用户消息,根据消息中的 event_type 进行不同处理,调用 updateMessage() 方法将消息内容更新到界面上。
@Override
public void onUserMessageReceived(String uid, String message) {
try {
Map<String, Object> messageMap = mapper.readValue(message, new TypeReference<Map<String, Object>>() {});
Log.d(TAG, "接收到原始消息: " + messageMap);
Map<String, String> jsonMap = new HashMap<>();
for (Map.Entry<String, Object> entry : messageMap.entrySet()) {
try {
if (entry.getValue() instanceof String) {
jsonMap.put(entry.getKey(), (String) entry.getValue());
continue;
}
String jsonValue = mapper.writeValueAsString(entry.getValue());
jsonMap.put(entry.getKey(), jsonValue);
} catch (JsonProcessingException e) {
Log.e(TAG, "序列化value失败: " + entry.getKey(), e);
}
}
if (ChatEventType.CONVERSATION_MESSAGE_DELTA.getValue().equals(jsonMap.get("event_type"))){
Message msg = mapper.readValue(jsonMap.get("data"), Message.class);
updateMessage(msg.getContent());
} else if (ChatEventType.CONVERSATION_MESSAGE_COMPLETED.getValue().equals(jsonMap.get("event_type"))) {
updateMessage("\n");
}
} catch (JsonProcessingException e) {
Log.e(TAG, "解析消息失败", e);
}
}
override fun onUserMessageReceived(uid: String, message: String) {
try {
val messageMap = mapper.readValue<Map<String, Any>>(
message,
object : TypeReference<Map<String, Any>>() {}
)
Log.d(TAG, "接收到原始消息: $messageMap")
val jsonMap = messageMap.mapValues { (_, value) ->
when (value) {
is String -> value
else -> try {
mapper.writeValueAsString(value)
} catch (e: Exception) {
Log.e(TAG, "序列化value失败: ${e.message}")
null
}
}
}
when (jsonMap["event_type"]) {
ChatEventType.CONVERSATION_MESSAGE_DELTA.value -> {
val msg = mapper.readValue(
jsonMap["data"] as String,
Message::class.java
)
updateMessage(msg.content)
}
ChatEventType.CONVERSATION_MESSAGE_COMPLETED.value -> {
updateMessage("\n")
}
}
} catch (e: Exception) {
Log.e(TAG, "解析消息失败", e)
}
}
监听按钮事件,根据 isVideoEnabled 的状态,调用 stopVideoCapture() 或 startVideoCapture() 方法,实现摄像头的启用或禁用功能。
private void initVideoControl() {
btnVideo.setOnClickListener(v -> {
if (rtcVideo != null) {
if (isVideoEnabled) {
stopVideo();
} else {
startVideo();
}
}else{
ToastUtil.showAlert(this, "请先连接");
}
});
}
private void stopVideo(){
rtcVideo.stopVideoCapture();
btnVideo.setText("打开视频");
isVideoEnabled = !isVideoEnabled;
}
private void startVideo(){
rtcVideo.startVideoCapture();
btnVideo.setText("关闭视频");
isVideoEnabled = !isVideoEnabled;
}
private fun initVideoControl() {
btnVideo.setOnClickListener {
if (rtcVideo != null) {
if (isVideoEnabled) {
stopVideo()
} else {
startVideo()
}
} else {
ToastUtil.showAlert(this, "请先连接")
}
}
}
private fun stopVideo() {
rtcVideo?.stopVideoCapture()
btnVideo.text = "打开视频"
isVideoEnabled = !isVideoEnabled
}
private fun startVideo() {
rtcVideo?.startVideoCapture()
btnVideo.text = "关闭视频"
isVideoEnabled = !isVideoEnabled
}
监听按钮事件,根据 isAudioEnabled 的状态调用 stopAudioCapture() 或 startAudioCapture() 方法,从而实现麦克风的启用或静音功能。
private void initAudioControl() {
btnAudio.setOnClickListener(v -> {
if (rtcVideo != null) {
if (isAudioEnabled) {
stopVoice();
} else {
startVoice();
}
}else {
ToastUtil.showAlert(this, "请先连接");
}
});
}
private void startVoice(){
rtcVideo.startAudioCapture();
btnAudio.setText("静音");
isAudioEnabled = !isAudioEnabled;
}
private void stopVoice(){
rtcVideo.stopAudioCapture();
btnAudio.setText("打开声音");
isAudioEnabled = !isAudioEnabled;
}
private fun initAudioControl() {
btnAudio.setOnClickListener {
if (rtcVideo != null) {
if (isAudioEnabled) {
stopVoice()
} else {
startVoice()
}
} else {
ToastUtil.showAlert(this, "请先连接")
}
}
}
private fun startVoice() {
rtcVideo?.startAudioCapture()
btnAudio.text = "静音"
isAudioEnabled = !isAudioEnabled
}
private fun stopVoice() {
rtcVideo?.stopAudioCapture()
btnAudio.text = "打开声音"
isAudioEnabled = !isAudioEnabled
}
leaveRoom()、destroy() 方法离开并销毁房间。stopVoice()和stopVideo() 方法停止音视频采集。destroyRTCVideo() 方法销毁引擎。private void disconnect(){
if (rtcRoom != null) {
rtcRoom.leaveRoom();
rtcRoom.destroy();
}
if (rtcVideo != null){
stopVoice();
stopVideo();
RTCVideo.destroyRTCVideo();
rtcVideo = null;
}
ToastUtil.showAlert(this, "断开连接成功");
}
private fun disconnect() {
rtcRoom?.apply {
leaveRoom()
destroy()
}
rtcVideo?.apply {
stopVoice()
stopVideo()
RTCVideo.destroyRTCVideo()
rtcVideo = null
}
ToastUtil.showAlert(this, "断开连接成功")
}
说明
在实现音视频通话后,如遇无声音、无画面、视频卡顿等问题时,你可以使用诊断工具快速排查和定位异常房间及用户,并获取异常根因分析、处理建议、分析报告等。
智能语音信令事件包括上行事件和下行事件。每个事件有 ID 和 EventType, 通过 EventType 可以区分具体的事件类型,每个事件类型对应的 Payload 在 Data 中,开发者可以按需去提取需要的内容。
sendUserMessage 发送,如果是嵌入式设备集成音视频,可通过 Realtime SDK 的 byte_rtc_rts_send_message 发送。详细信息可参考Realtime 上行事件。onUserMessageReceived回调,如果是嵌入式设备集成音视频,可订阅 Realtime SDK 的 on_message_received回调,接收下行事件。应用程序需要解析下行事件,并根据业务需求进行下一步操作。详细信息可参考Realtime 下行事件。智能语音信令事件的公共参数如下:
| 参数名称 | 类型 | 描述 |
|---|---|---|
| id | String | 事件 ID,也就是事件的唯一标识。由客户端或服务端生成,在故障排查场景下用于定位具体的事件,便于排查问题。 |
| event_type | String | 事件的类型。 |
| data | JSON | 事件的详细信息,其中包含具体事件的业务字段。 |
信令事件的使用流程如下:
session.created 事件,确认房间初始化完成。conversation.message.create)向智能体发送消息。conversation.message.delta)获取智能体的增量回复。conversation.chat.requires_action 后,执行插件操作并通过事件 conversation.chat.submit_tool_outputs 提交结果。error 事件捕获和处理异常情况。使用示例如下:
// 通过重载 onUserMessageReceived 方法即可监听所有的下行事件,可以通过 event_type 过滤需要处理的事件
@Override
public void onUserMessageReceived(String uid, String message) {
try {
Map<String, Object> messageMap = mapper.readValue(message, new TypeReference<Map<String, Object>>() {});
Log.i(TAG, "接收到原始消息: " + messageMap);
Map<String, String> jsonMap = new HashMap<>();
for (Map.Entry<String, Object> entry : messageMap.entrySet()) {
try {
if (entry.getValue() instanceof String) {
jsonMap.put(entry.getKey(), (String) entry.getValue());
continue;
}
String jsonValue = mapper.writeValueAsString(entry.getValue());
jsonMap.put(entry.getKey(), jsonValue);
} catch (JsonProcessingException e) {
Log.e(TAG, "序列化value失败: " + entry.getKey(), e);
}
}
// 根据你需要监听的 event,解析成对应的结构
if (ChatEventType.CONVERSATION_MESSAGE_DELTA.getValue().equals(jsonMap.get("event_type"))){
Message msg = mapper.readValue(jsonMap.get("data"), Message.class);
updateMessage(msg.getContent());
} else if (ChatEventType.CONVERSATION_MESSAGE_COMPLETED.getValue().equals(jsonMap.get("event_type"))) {
updateMessage("\n");
}
} catch (JsonProcessingException e) {
Log.e(TAG, "解析消息失败", e);
}
}
// 通过重载 onUserMessageReceived 方法即可监听所有的下行事件,可以通过 event_type 过滤需要处理的事件
override fun onUserMessageReceived(uid: String, message: String) {
try {
val messageMap = mapper.readValue<Map<String, Any>>(
message,
object : TypeReference<Map<String, Any>>() {}
)
Log.d(TAG, "接收到原始消息: $messageMap")
val jsonMap = messageMap.mapValues { (_, value) ->
when (value) {
is String -> value
else -> try {
mapper.writeValueAsString(value)
} catch (e: Exception) {
Log.e(TAG, "序列化value失败: ${e.message}")
null
}
}
}
when (jsonMap["event_type"]) {
// 根据你需要监听的 event,解析成对应的结构
ChatEventType.CONVERSATION_MESSAGE_DELTA.value -> {
val msg = mapper.readValue(
jsonMap["data"] as String,
Message::class.java
)
updateMessage(msg.content)
}
ChatEventType.CONVERSATION_MESSAGE_COMPLETED.value -> {
updateMessage("\n")
}
}
} catch (e: Exception) {
Log.e(TAG, "解析消息失败", e)
}
}
在处理信令事件时,建议采用以下思路:
示例代码如下:
// 调用 RTCRoom 对象的 sendUserMessage 方法即可发送上行事件,指定其中的 userId 为聊天的 bot_id 即可将事件发送给指定的 bot,下面以处理端插件事件为例
@Override
public void onUserMessageReceived(String uid, String message) {
try {
Map<String, Object> messageMap = mapper.readValue(message, new TypeReference<Map<String, Object>>() {});
Log.i(TAG, "接收到原始消息: " + messageMap);
Map<String, String> jsonMap = new HashMap<>();
for (Map.Entry<String, Object> entry : messageMap.entrySet()) {
try {
if (entry.getValue() instanceof String) {
jsonMap.put(entry.getKey(), (String) entry.getValue());
continue;
}
String jsonValue = mapper.writeValueAsString(entry.getValue());
jsonMap.put(entry.getKey(), jsonValue);
} catch (JsonProcessingException e) {
Log.e(TAG, "序列化value失败: " + entry.getKey(), e);
}
}
}else if(ChatEventType.ERROR.getValue().equals(jsonMap.get("event_type"))){
Log.i(TAG, jsonMap.toString());
}
} catch (JsonProcessingException e) {
Log.e(TAG, "解析消息失败", e);
}
}