解析转写数据
Protobuf(全称 Protocol Buffers)是由 Google 开发的一种高效的、跨语言的序列化数据格式,支持多种编程语言,包括 C++、Java、Python、Go 等,可在不同系统间进行数据交换。具有高效、灵活和易于使用的特点。
声网实时转录翻译支持使用 Protobuf 或 JSON 格式通过数据流传输转写和翻译结果。默认使用 Protobuf 格式;如果调用 join 时将 enableJsonProtocol 设置为 true,则使用 JSON 格式。本文介绍接收端如何解析 Protobuf 和 JSON 数据,并从解析后的数据结构中提取具体的文本字段。
前提条件
请按照以下要求准备开发环境:
-
可以访问互联网的计算机。如果你的网络环境部署了防火墙,参考应对防火墙限制以正常使用声网服务。
-
已安装 Protobuf Compiler,详见 Protobuf Compiler Installation。
注意由于 Protobuf 的格式在不同版本会有差异,声网建议生成代码和客户端反序列化使用的 Protobuf SDK 版本保持一致。
-
一个有效的声网账号以及声网项目。请确保你的项目已开通实时转录翻译功能,并参考开通服务从声网控制台获得以下信息:
- App ID:声网随机生成的字符串,用于识别你的项目。
- 临时 Token:Token 也称为动态密钥,在客户端加入频道时对用户鉴权。临时 Token 的有效期为 24 小时。
-
作为主播加入频道发流。你可以参考实现纯语音互动来在频道中发流。
实现流程
本节介绍接收端如何使用 protoc 编译生成不同语言的示例代码来反序列化接收到的 Protobuf 数据。如果你使用 JSON 格式,可直接参考JSON 协议数据结构解析消息。
使用 protoc 生成源代码
参考下列步骤来编写一个脚本调用 protoc 编译器以生成不同语言的代码。
创建脚本并生成源码
你可以根据自己实际的业务需求,选择生成不同语言的代码。下文提供生成 Java、Objective-C、C#、JavaScript 语言的代码脚本。
- Java
- Objective-C
- C#
- JavaScript
创建一个 Shell 脚本,将其命名为 generate_code.sh,然后在其中添加如下代码:
#!/bin/sh
# 指定 protoc 编译器的路径,示例代码中使用的 Protobuf 版本为 21.12,你可以根据你的实际需求替换
PROTOC_PATH=./protoc-21.12-osx-aarch_64/bin/protoc
# 指定 .proto 文件的路径,文件中数据结构的详细描述见参考信息
PROTO_FILE=./SttMessage.proto
# 指定输出目录
JAVA_OUT_DIR=$(pwd)/code/java
# 创建输出目录(如果不存在)
mkdir -p $JAVA_OUT_DIR
# 生成 Java 代码
$PROTOC_PATH --java_out=$JAVA_OUT_DIR $PROTO_FILE
# 生成代码完成后输出提示信息
echo "Generate code finished."
如果你需要生成 Objective-C 代码,请确保在生成代码前已经安装 Protobuf 的相关依赖。你可以参考下列步骤来安装依赖,如果已有依赖,可跳过此步骤。
-
打开你的项目的
Podfile文件,并在其中添加如下代码:Ruby# 21.12 表示 Protobuf 版本,你可以根据实际需求选择合适的版本
pod "Protobuf", "3.21.12" -
在 Terminal 中进入到包含
Podfile文件的目录下,运行pod install命令,CocoaPods 会下载、安装指定的依赖库版本。
成功安装后,项目文件夹下会生成一个后缀为 .xcworkspace 的文件,通过 Xcode 打开该文件进行后续操作。
创建一个 Shell 脚本,将其命名为 generate_code.sh,然后在其中添加如下代码:
#!/bin/sh
# 指定 protoc 编译器的路径,示例代码中使用的 Protobuf 版本为 21.12,你可以根据你的实际需求替换
PROTOC_PATH=./protoc-21.12-osx-aarch_64/bin/protoc
# 指定 .proto 文件的路径,文件中数据结构的详细描述见参考信息
PROTO_FILE=./SttMessage.proto
# 指定输出目录
OBJC_OUT_DIR=$(pwd)/code/objective-c
# 创建输出目录(如果不存在)
mkdir -p $OBJC_OUT_DIR
# 生成 Objective-C 代码
$PROTOC_PATH --objc_out=$OBJC_OUT_DIR $PROTO_FILE
# 生成代码完成后输出提示信息
echo "Generate code finished."
创建一个 Shell 脚本,将其命名为 generate_code.sh,然后在其中添加如下代码:
#!/bin/sh
# 指定 protoc 编译器的路径,示例代码中使用的 Protobuf 版本为 21.12,你可以根据你的实际需求替换
PROTOC_PATH=./protoc-21.12-osx-aarch_64/bin/protoc
# 指定 .proto 文件的路径,文件中数据结构的详细描述见参考信息
PROTO_FILE=./SttMessage.proto
# 指定输出目录
CSHARP_OUT_DIR=$(pwd)/code/csharp
# 创建输出目录(如果不存在)
mkdir -p $CSHARP_OUT_DIR
# 生成 Objective-C 代码
$PROTOC_PATH --csharp_out=$CSHARP_OUT_DIR $PROTO_FILE
# 生成代码完成后输出提示信息
echo "Generate code finished."
如果你需要生成 JavaScript 代码,请确保在生成代码前已经安装 Protobuf 的相关依赖。你可以参考下列步骤来安装依赖,如果已有依赖,可跳过此步骤。
-
打开你的项目根目录,编辑
package.json文件,添加以下依赖项:JSON{
"dependencies": {
...
"protobufjs": "^7.2.5"
},
"devDependencies": {
...
"pbjs": "^0.0.14",
"protobufjs-cli": "^1.1.2"
}
}你可以根据实际需求指定 Protobuf 库和 protobufjs 命令行工具的版本。
-
在终端运行以下命令以安装依赖:
Shellnpm install
创建一个 Shell 脚本,将其命名为 generate_code.sh,然后在其中添加如下代码:
# 将 protobufjs-cli 的可执行文件路径添加到 PATH 环境变量
# 需要将 {absolute path of protobufjs-cli in your node_modules}/bin 替换为 protobufjs-cli 在 node_modules 中的绝对路径
export "PATH=$PATH:{absolute path of protobufjs-cli in your node_modules}/bin"
# 生成 javascript 的示例代码
pbjs -t json-module -w es6 ./SttMessage.proto > ./SttMessage_es6.js
echo "JavaScript code generation finished."
运行脚本
在终端中运行以下命令来运行脚本:
# 将脚本设置为可执行文件
chmod +x generate_code.sh
# 运行脚本
./generate_code.sh
反序列化数据
客户端接收到数据流时,SDK 会触发接收到数据流消息的回调。本节介绍如何对接收到的数据进行反序列化,将其转换回数据结构或对象。以下为不同语言的示例代码:
- Java
- C#
- JavaScript
- Objective-C
- Swift
// 加入频道,添加回调事件
rtcManager.joinChannel(roomName, localUid, agora_token, roleType.equals(ROLE_TYPE_BROADCAST), new RtcManager.OnChannelListener() {
...
// 接收到数据流消息的回调
@Override
public void onStreamMessage(int uid, int streamId, byte[] data) {
// 检查远端用户 ID 是否为指定的推流机器人 ID,如果是则解码数据流为文本对象
if (String.valueOf(uid).equalsIgnoreCase(RTC_UID_STT_STREAM)) {
AgoraSpeech2TextProtobuffer.Text text = STTManager.getInstance().parseTextByte(roomName, data);
// 将解析后的文本对象转换为 JSON 格式并打印日志
LogUtil.d(originLogName, mGson.toJson(text));
}
}
...
});
public AgoraSpeech2TextProtobuffer.Text parseTextByte(String channel, byte[] data) {
// 声明一个 AgoraSpeech2TextProtobuffer.Text 类型的变量,用于存储反序列化后的对象
AgoraSpeech2TextProtobuffer.Text textStream;
try {
// 将字节数组 data 反序列化为 AgoraSpeech2TextProtobuffer.Text 对象
textStream = AgoraSpeech2TextProtobuffer.Text.parseFrom(data);
} catch (Exception ex) {
notifyErrorHandler(new ErrorInfo("parseTextByte", "-1", "parseTextByte parseFrom error >> " + ex.toString()));
return null;
}
...
}
private void InitRtcEngine()
{ // 创建一个 RTC 引擎实例
RtcEngine = Agora.Rtc.RtcEngine.CreateAgoraRtcEngine();
// 创建一个事件处理类的实例
AgoraEventHandler handler = new AgoraEventHandler(this);
// 创建 RtcEngineContext 对象,并设置频道场景为直播
RtcEngineContext context = new RtcEngineContext(_appID, 0,
CHANNEL_PROFILE_TYPE.CHANNEL_PROFILE_LIVE_BROADCASTING,
AUDIO_SCENARIO_TYPE.AUDIO_SCENARIO_DEFAULT);
// 初始化引擎
RtcEngine.Initialize(context);
// 添加回调事件
RtcEngine.InitEventHandler(handler);
}
// 定义一个类用于处理 RTC 相关回调,继承自 IRtcEngineEventHandler
internal class AgoraEventHandler: IRtcEngineEventHandler
{ // 接收到数据流消息的回调
public override void OnStreamMessage(RtcConnection connection, uint remoteUid, int streamId, byte[] data, uint length, ulong sentTs)
{
// Debug.Log(String.Format("remoteUid: {0}", remoteUid));
// 如果远端用户 ID 等于指定的推流机器人 ID
if (remoteUid == {pusher bot uid}) {
// 解析 Protobuf 数据
AgoraSTTSample.Protobuf.Text t = ProtobufUtility.ParseProtobufData(data);
...
}
}
}
import AgoraRTC from "agora-rtc-sdk-ng"
import protoRoot from "@/protobuf/SttMessage_es6.js"
// 创建 RTC 客户端实例
this.rtc.client = AgoraRTC.createClient({ mode: "live", codec: "vp8", role: this.role })
// 监听数据流事件并绑定事件处理函数
this.rtc.client.on("stream-message", this.onStreamMessage.bind(this))
// 接收到数据流消息的回调
function onStreamMessage(uid, stream) {
// 检查远端用户 ID 是否为指定的推流机器人 ID,如果不是则直接返回,不进行后续处理
if (uid != {pusher bot uid}) {
return
}
// 使用 Protobuf 解码收到的数据流
let textstream = protoRoot.Agora.SpeechToText.lookup("Text").decode(data)
...
}
// Temp.h
#import "AgoraRtcKit/AgoraRtcKit.h"
#import "./Protobuff/SttMessage.pbobjc.h"
NS_ASSUME_NONNULL_BEGIN
@interface Temp : NSObject<AgoraRtcEngineDelegate>
@end
NS_ASSUME_NONNULL_END
// Temp.m
@implementation Temp
// 接收到数据流消息的回调
- (void)rtcEngine:(AgoraRtcEngineKit *)engine receiveStreamMessageFromUid:(NSUInteger)uid streamId:(NSInteger)streamId data:(NSData *)data {
// 检查远端用户 ID 是否为指定的推流机器人 ID,如果不是则直接返回,不进行后续处理
if (uid != pusherUid) {
return;
}
NSError* error;
// 解码收到的数据流
SttText* st = [SttText parseFromData: data error: &error];
...
}
@end
// 接收到数据流消息的回调
func rtcEngine(_ engine: AgoraRtcEngineKit, receiveStreamMessageFromUid uid: UInt, streamId: Int, data: Data) {
// 检查远端用户 ID 是否为指定的推流机器人 ID,如果不是则直接返回,不进行后续处理
guard uid == {puher bot uid} else {
return
}
// 解码收到的数据流
let text = try? SttText.parse(from: data)
...
}
参考信息
本节提供使用 Protobuf 反序列化数据的其他相关信息。
示例项目
声网提供了开源的实时转录翻译示例项目供你参考,你可以前往下载或查看其中的源代码。
SttMessage.proto 文件说明
声网提供的 SttMessage.proto 文件中定义了转写后的文本数据结构,各字段说明详见如下:
syntax = "proto3";
package Agora.SpeechToText;
option objc_class_prefix = "Stt";
option csharp_namespace = "AgoraSTTSample.Protobuf";
option java_package = "io.agora.rtc.speech2text";
option java_outer_classname = "AgoraSpeech2TextProtobuffer";
message Text {
reserved 1 to 3, 5, 7 to 9, 11, 17;
int64 uid = 4;
int64 time = 6;
repeated Word words = 10;
int32 duration_ms = 12;
string data_type = 13;
repeated Translation trans = 14;
string culture = 15;
int64 text_ts = 16;
OriginalTranscript original_transcript = 18;
int64 sentence_id = 19;
}
message Word {
reserved 2, 3, 5;
string text = 1;
bool is_final = 4;
}
message Translation {
bool is_final = 1;
string lang = 2;
repeated string texts = 3;
}
message OriginalTranscript {
string culture = 1;
repeated Word words = 2;
}
Text 消息类型及其字段说明
| 字段名称 | 类型 | 含义 |
|---|---|---|
uid | int64 | 文本所对应的用户 ID。 |
time | int64 | 该句段转写的起始时间。仅在 isFinal 为 true 时有值,其他时候为 0。 |
words | repeated | 转写结果的数组,详见 Word 消息类型。 |
duration_ms | int32 | 转写文本的时长,单位为毫秒。 |
data_type | string | 数据类型:
|
trans | repeated | 翻译结果的数组,详见 Translation 消息类型。 |
culture | string | 转写的源语言。 |
text_ts | int64 | 转写结果的时间戳,持续递增,用于实时翻译时原文和译文的对齐。 |
original_transcript | OriginalTranscript | 转写后的文本,用于翻译,详见 OriginalTranscript。 |
sentence_id | int64 | 字幕唯一 ID,字幕在数据流里的唯一标识,用于原文与译文字幕的精准对齐。 |
Word 消息类型及其字段说明
| 字段名称 | 类型 | 含义 |
|---|---|---|
text | string | 转写的结果。 |
is_final | bool | 该句是否为转写的最终结果:
true 时表明转写引擎认为该句的文字转写结果已经确定,无需再进行修改,但并不代表这句话在语义上已经结束。 |
Translation 消息类型及其字段说明
| 字段名称 | 类型 | 含义 |
|---|---|---|
is_final | bool | 该句是否为翻译的最终结果:
true 时表明翻译引擎认为该句的翻译结果已经确定,无需再进行修改,但并不代表这句话在语义上已经结束。 |
lang | string | 翻译的目标语言。 |
texts | repeated | 翻译的结果。 |
OriginalTranscript 消息类型及其字段说明
| 字段名称 | 类型 | 含义 |
|---|---|---|
culture | string | 转写的源语言。 |
words | repeated | 转写结果的数组,详见 Word 消息类型。 |
使用 Soniox 供应商时,单次 Protobuf Text 消息可能承载多个片段。例如,转写消息中的 words[] 或翻译消息中的 trans[] 长度可能大于 1。客户端不应假设一次数据流消息只包含一个转写或翻译片段。
JSON 协议数据结构
如果调用 join 时将 enableJsonProtocol 设置为 true,服务会使用 JSON 格式推送字幕数据。JSON 消息的顶层对象包含 transcript 或 translation 字段:
transcript:转写结果。translation:翻译结果。
使用 Soniox 供应商时,JSON 协议改为使用 results[] 显式承载一个或多个片段。单片段场景下 results[] 长度为 1;mixed 场景下,单条消息可能同时包含已稳定的前缀片段和仍会变化的后缀片段,此时 results[] 长度大于 1。
这是 JSON 客户端协议的不兼容变更。如果你的客户端启用了 JSON 协议,需要与服务端一起升级。升级后不要再假设一次数据流消息只对应一个转写或翻译片段,请遍历 results[] 处理每个片段,并兼容 results[] 为空数组的消息。
transcript 字段
| 字段 | 类型 | 含义 |
|---|---|---|
uid | Number | 用户的音频流 ID。 |
textTs | Number | 这批转写结果的主时间戳,取首个片段对应的 text_id。 |
offset | Number | 这批转写结果的起始时间,单位为毫秒。 |
duration | Number | 这批转写结果的时长,单位为毫秒。 |
language | String | 首个转写片段的源语言。 |
text | String | 转写文本。 |
isFinal | Boolean | 转写文本是否为最终结果。 |
sentenceId | Number | 句级聚合单元 ID,表示 results[] 中的转写片段归属于同一句或同一轮句级聚合单元。 |
results | Array | 转写片段数组。详见 transcript.results 字段。 |
transcript.results 字段
| 字段 | 类型 | 含义 |
|---|---|---|
text | String | 当前转写片段的文本。 |
isFinal | Boolean | 当前转写片段是否为最终结果。 |
offset | Number | 当前转写片段的起始时间,单位为毫秒。 |
duration | Number | 当前转写片段的时长,单位为毫秒。 |
translation 字段
| 字段 | 类型 | 含义 |
|---|---|---|
uid | Number | 用户的音频流 ID。 |
textTs | Number | 这批翻译结果的主时间戳,取首个翻译片段对应的 text_id。 |
offset | Number | 这批翻译结果的起始时间,单位为毫秒。 |
duration | Number | 这批翻译结果的时长,单位为毫秒。 |
isFinal | Boolean | 翻译结果是否为最终结果。 |
sentenceId | Number | 句级聚合单元 ID,表示 results[] 中的翻译片段归属于同一句或同一轮句级聚合单元。 |
results0 | Object | 兼容旧版本的翻译结果字段,结构保持不变。 |
results | Array | 翻译片段数组。详见 translation.results 字段。 |
original_transcript | Object | 原文转写结果。仅在开启原文返回时出现。 |
translation.results 字段
| 字段 | 类型 | 含义 |
|---|---|---|
language | String | 翻译的目标语言。 |
texts | Array | 当前翻译片段的译文数组。 |
isFinal | Boolean | 当前翻译片段是否为最终结果。 |
original_transcript 字段
| 字段 | 类型 | 含义 |
|---|---|---|
language | String | 原文的源语言。 |
text | String | 合并后的原文文本。 |
results | Array | 原文片段数组,字段包括 text 和 isFinal。 |
示例数据
本节提供 Protobuf 和 JSON 格式下 transcribe 和 translate 两种数据类型的示例。
transcribe
- protobuf
- json
- json mixed
time: 1753359518654
words {
text: "Hello, how are you?"
is_final: true
}
duration_ms: 770
data_type: "transcribe"
culture: "en-US"
text_ts: 1753359520754
sentence_id: 1753359518654
{
"transcript": {
"uid": 42,
"textTs": 1710000012345,
"offset": 1000,
"duration": 200,
"language": "zh-CN",
"text": "你好。",
"isFinal": true,
"sentenceId": 1710000012000,
"results": [
{
"text": "你好。",
"isFinal": true,
"offset": 1000,
"duration": 200
}
]
}
}
以下示例表示同一条 JSON 消息同时包含已稳定的前缀片段和仍会变化的后缀片段:
{
"transcript": {
"uid": 42,
"textTs": 1710000012345,
"offset": 1000,
"duration": 500,
"language": "zh-CN",
"text": "你好。",
"isFinal": true,
"sentenceId": 1710000012000,
"results": [
{
"text": "你好。",
"isFinal": true,
"offset": 1000,
"duration": 200
},
{
"text": "你",
"isFinal": false,
"offset": 1300,
"duration": 200
}
]
}
}
发送包含 results[] 的消息后,服务还会再发送一条 results 为空数组的消息:
{
"transcript": {
"uid": 42,
"textTs": 1710000012345,
"offset": 1000,
"duration": 500,
"language": "zh-CN",
"text": "你好。",
"isFinal": true,
"sentenceId": 1710000012000,
"results": []
}
}
translate
- protobuf
- json
time: 1753359518654
duration_ms: 770
data_type: "translate"
trans {
is_final: true
lang: "es-ES"
texts: "Hola, ¿cómo estás? "
}
text_ts: 1753359520754
sentence_id: 1753359518654
original_transcript {
culture: "en-US"
words {
text: "Hello, how are you?"
is_final: true
}
}
{
"translation": {
"uid": 42,
"isFinal": true,
"offset": 1000,
"duration": 500,
"textTs": 1710000012345,
"sentenceId": 1710000012000,
"results0": {
"language": "en-US",
"texts": [
"Hello."
]
},
"results": [
{
"language": "en-US",
"texts": [
"Hello."
],
"isFinal": true
},
{
"language": "en-US",
"texts": [
"World."
],
"isFinal": false
}
],
"original_transcript": {
"language": "zh-CN",
"text": "你好。",
"results": [
{
"text": "你好。",
"isFinal": true
},
{
"text": "世界",
"isFinal": false
}
]
}
}
}
发送包含 results[] 的消息后,服务还会再发送一条 results 为空数组的消息:
{
"translation": {
"uid": 42,
"isFinal": true,
"offset": 1000,
"duration": 500,
"textTs": 1710000012345,
"sentenceId": 1710000012000,
"results0": {
"language": "en-US",
"texts": [
"Hello."
]
},
"results": [],
"original_transcript": {
"language": "zh-CN",
"text": "你好。",
"results": []
}
}
}