在与 AI 对话过程中,系统会自动生成用户和 AI 的对话文本。您可以通过客户端或服务端实时获取该数据,用于实时 UI 展示、存储或根据字幕状态触发新一轮对话等。

应用场景
场景 | 描述 |
|---|
实时字幕展示 | 将真人用户和 AI 的语音实时转为文字,并在应用终端界面上展示。 |
业务存储分析 | 记录并存储对话文本,用于后续的业务数据分析、服务质量监控或模型优化。 |
交互逻辑控制 | 利用字幕的特定状态(如整句结束信号)作为触发器,手动开启新一轮对话或执行业务指令,实现灵活交互。 |
客户端实现
适用于需要在终端界面实时展示字幕的场景。数据通过客户端 SDK 回调获取。
步骤 1:开启配置
调用 StartVoiceChat 在启动 AI 时,添加 Config.SubtitleConfig 配置:
配置参数 | 说明 |
|---|
DisableRTSSubtitle | 设置为 false,开启客户端字幕回调。 |
SubtitleMode | 0 :对齐音频时间戳。字幕来源于 TTS,与 AI 实际播报的音频精准对齐。但字幕生成稍慢,部分特殊符号可能被转义而无法显示。1 :不对齐音频时间戳。字幕来源于 LLM 原始回复。字幕生成速度较快,能保留所有原始文本(如表情符号),但与音频无时间关联。
注意 当使用以下模型或服务时,SubtitleMode 仅支持设置为 1(不对齐音频时间戳):语音合成大模型 2.0、声音复刻大模型 2.0、数字人服务、端到端实时语音大模型。若设置为 0,字幕将无法正常返回。 |
配置示例
// 仅展示字幕配置
"SubtitleConfig": {
"DisableRTSSubtitle": false,
"SubtitleMode": 0
}
步骤 2:接收并解析字幕
- 监听回调:通过 ByteRTC SDK 的 onRoomBinaryMessageReceived(嵌入式硬件场景使用
on_message_received)接收字幕消息(二进制格式)。 - 解析数据:字幕以二进制消息格式
message(magic number 为 subv)回调给客户端,收到消息后需解析 message。
message 格式:参见字幕数据格式。
message 解析示例:
//定义结构体
struct SubtitleMsgData {
bool definite;
std::string language;
bool paragraph;
int sequence;
std::string text;
std::string userId;
};
//回调事件
void onRoomBinaryMessageReceived(const char* uid, int size, const uint8_t* message) {
std::string subtitles;
bool ret = Unpack(message, size, subtitles);
if(ret) {
ParseData(subtitles);
}
}
//拆包校验
bool Unpack(const uint8_t *message, int size, std::string& subtitles) {
int kSubtitleHeaderSize = 8;
if(size < kSubtitleHeaderSize) {
return false;
}
// magic number "subv"
if(static_cast<uint32_t>((static_cast<uint32_t>(message[0]) << 24)
| (static_cast<uint32_t>(message[1]) << 16)
| (static_cast<uint32_t>(message[2]) << 8)
| static_cast<uint32_t>(message[3])) != 0x73756276U) {
return false;
}
uint32_t length = static_cast<uint32_t>((static_cast<uint32_t>(message[4]) << 24)
| (static_cast<uint32_t>(message[5]) << 16)
| (static_cast<uint32_t>(message[6]) << 8)
| static_cast<uint32_t>(message[7]));
if(size - kSubtitleHeaderSize != length) {
return false;
}
if(length) {
subtitles.assign((char*)message + kSubtitleHeaderSize, length);
} else {
subtitles = "";
}
return true;
}
//解析
void ParseData(const std::string& msg) {
// 解析 JSON 字符串
nlohmann::json json_data = nlohmann::json::parse(msg);
// 存储解析后的数据
std::vector<SubtitleMsgData> subtitles;
// 遍历 JSON 数据并填充结构体
for (const auto& item : json_data["data"]) {
SubtitleMsgData subData;
subData.definite = item["definite"];
subData.language = item["language"];
subData.paragraph = item["paragraph"];
subData.sequence = item["sequence"];
subData.text = item["text"];
subData.userId = item["userId"];
subtitles.push_back(subData);
}
}
//数据格式
public class SubtitleMsgData {
public boolean definite;
public String language;
public boolean paragraph;
public int sequence;
public String text;
public String userId;
@Override
public String toString() {
return "SubtitleMsgData{" +
"definite=" + definite +
", language='" + language + ''' +
", paragraph=" + paragraph +
", sequence=" + sequence +
", text='" + text + ''' +
", userId='" + userId + ''' +
'}';
}
}
//回调
public void onRoomBinaryMessageReceived(String uid, ByteBuffer buffer) {
StringBuilder subtitles = new StringBuilder();
boolean ret = unpack(buffer, subtitles);
if (ret) {
parseData(subtitles.toString());
}
}
// 拆包校验
public static boolean unpack(ByteBuffer message, StringBuilder subtitles) {
final int kSubtitleHeaderSize = 8;
if (message.remaining() < kSubtitleHeaderSize) {
return false;
}
// 魔法数字 "subv"
int magicNumber = (message.get() << 24) | (message.get() << 16) | (message.get() << 8) | (message.get());
if (magicNumber != 0x73756276) {
return false;
}
int length = message.getInt();
if (message.remaining() != length) {
return false;
}
// 读取字幕内容
byte[] subtitleBytes = new byte[length];
message.get(subtitleBytes);
subtitles.append(new String(subtitleBytes, StandardCharsets.UTF_8));
return true;
}
// 解析字幕消息
public static void parseData(String msg) {
try {
// 解析 JSON 字符串
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonData = objectMapper.readTree(msg);
// 存储解析后的数据
List<SubtitleMsgData> subtitles = new ArrayList<>();
// 遍历 JSON 数据并填充结构体
for (JsonNode item : jsonData.get("data")) {
SubtitleMsgData subData = new SubtitleMsgData();
subData.definite = item.get("definite").asBoolean();
subData.language = item.get("language").asText();
subData.paragraph = item.get("paragraph").asBoolean();
subData.sequence = item.get("sequence").asInt();
subData.text = item.get("text").asText();
subData.userId = item.get("userId").asText();
subtitles.add(subData);
}
} catch (Exception e) {
e.printStackTrace();
}
}
//数据格式
@interface SubtitleMsgData : NSObject
@property (nonatomic, assign) BOOL definite;
@property (nonatomic, copy) NSString *language;
@property (nonatomic, assign) BOOL paragraph;
@property (nonatomic, assign) NSInteger sequence;
@property (nonatomic, copy) NSString *text;
@property (nonatomic, copy) NSString *userId;
@end
@implementation SubtitleMsgData
@end
//回调
- (void)rtcRoom:( ByteRTCRoom *_Nonnull)rtcRoom onRoomBinaryMessageReceived:(NSString *_Nonnull)uid message:(NSData *_Nonnull)message {
NSString *subtitles = unpack(message);
if (subtitles) {
parseData(subtitles);
}
}
// 大端序转换
uint32_t swapUInt32(uint32_t value) {
return ((value & 0x000000FF) << 24) |
((value & 0x0000FF00) << 8) |
((value & 0x00FF0000) >> 8) |
((value & 0xFF000000) >> 24);
}
//拆包校验
NSString *unpack(NSData *data) {
const int kSubtitleHeaderSize = 8;
NSUInteger size = data.length;
if (size < kSubtitleHeaderSize) {
return nil;
}
const uint8_t *message = data.bytes;
// Check magic number "subv"
uint32_t magic = (message[0] << 24) | (message[1] << 16) | (message[2] << 8) | message[3];
if (magic != 0x73756276) {
return nil;
}
// Get length
uint32_t length = (message[4] << 24) | (message[5] << 16) | (message[6] << 8) | message[7];
if (size - kSubtitleHeaderSize != length) {
return nil;
}
// Get subtitles
NSString *subtitles = nil;
if (length > 0) {
subtitles = [[NSString alloc] initWithBytes:message + kSubtitleHeaderSize length:length encoding:NSUTF8StringEncoding];
} else {
subtitles = @"";
}
return subtitles;
}
//解析
void parseData(NSString *msg) {
NSError *error = nil;
NSDictionary *json_data = [NSJSONSerialization JSONObjectWithData:[msg dataUsingEncoding:NSUTF8StringEncoding] options:0 error:&error];
if (error) {
NSLog(@"JSON Parse Error: %@", error);
return;
}
NSMutableArray<SubtitleMsgData *> *subtitles = [NSMutableArray array];
for (NSDictionary *item in json_data[@"data"]) {
SubtitleMsgData *subData = [[SubtitleMsgData alloc] init];
subData.definite = [item[@"definite"] boolValue];
subData.language = item[@"language"];
subData.paragraph = [item[@"paragraph"] boolValue];
subData.sequence = [item[@"sequence"] integerValue];
subData.text = item[@"text"];
subData.userId = item[@"userId"];
[subtitles addObject:subData];
}
}
服务端实现
适用于需要在服务端进行对话记录、存储和分析的场景。数据通过 HTTP(S) POST 请求推送到业务服务器。
步骤 1:开启配置并接收字幕
调用 StartVoiceChat 在启动 AI 时,在 Config 中添加 SubtitleConfig 配置:
配置参数 | 说明 |
|---|
ServerMessageUrl
| 您的后端服务地址,用于接收字幕消息。必须是公网可访问的 URL,且能正确处理无 Content-Type 的 HTTP POST 请求。 |
ServerMessageSignature
| 自定义认证密钥。此字符串会在回调请求中原样返回,用于校验请求合法性。 |
SubtitleMode
| 0 :对齐音频时间戳。字幕来源于 TTS,与 AI 实际播报的音频精准对齐。但字幕生成稍慢,部分特殊符号可能被转义而无法显示。1 :不对齐音频时间戳。字幕来源于 LLM 原始回复。字幕生成速度较快,能保留所有原始文本(如表情符号),但与音频无时间关联。
注意 当使用以下模型或服务时,SubtitleMode 仅支持设置为 1(不对齐音频时间戳):语音合成大模型 2.0、声音复刻大模型 2.0、数字人服务、端到端实时语音大模型。若设置为 0,字幕将无法正常返回。 |
配置示例
// 仅展示字幕配置
"SubtitleConfig": {
"ServerMessageUrl": "https://example-domain.com/vertc/subtitle",
"ServerMessageSignature": "b46ab****ad6a",
"SubtitleMode": 0 // 0: 对齐音频时间戳
}
步骤 2:解析字幕
字幕消息会通过 HTTP(S) POST 请求发送给您指定的 URL 地址。
回调内容:
参数名 | 类型 | 描述 |
|---|
message | String | 字幕数据(Base 64 编码的二进制消息),格式详见字幕数据格式。 |
signature | String | 鉴权签名。可与StartVoiceChat接口中传入的ServerMessageSignature字段值进行对比以进行鉴权验证。 |
message 解析示例:
const (
subtitleHeader = "subv"
exampleSignature = "example_signature"
)
type RtsMessage struct {
Message string `json:"message"`
Signature string `json:"signature"`
}
type Subv struct {
Type string `json:"type"`
Data []Data `json:"data"`
}
type Data struct {
Definite bool `json:"definite"`
Paragraph bool `json:"paragraph"`
Language string `json:"language"`
Sequence int `json:"sequence"`
Text string `json:"text"`
UserID string `json:"userId"`
}
func HandleSubtitle(c *gin.Context) {
msg := &RtsMessage{}
if err := c.BindJSON(&msg); err != nil {
fmt.Printf("BindJson failed,err:%v\n", err)
return
}
if msg.Signature != exampleSignature {
fmt.Printf("Signature not match\n")
return
}
subv, err := Unpack(msg.Message)
if err != nil {
fmt.Printf("Unpack failed,err:%v\n", err)
return
}
fmt.Println(subv)
//业务逻辑
c.String(200, "ok")
}
func Unpack(msg string) (*Subv, error) {
data, err := base64.StdEncoding.DecodeString(msg)
if err != nil {
return nil, fmt.Errorf("DecodeString failed,err:%v", err)
}
if len(data) < 8 {
return nil, fmt.Errorf("Data invalid")
}
dataHeader := string(data[:4])
if dataHeader != subtitleHeader {
return nil, fmt.Errorf("Header not match")
}
dataSize := binary.BigEndian.Uint32(data[4:8])
if dataSize+8 != uint32(len(data)) {
return nil, fmt.Errorf("Size not match")
}
subData := data[8:]
subv := &Subv{}
err = json.Unmarshal(subData, subv)
if err != nil {
return nil, fmt.Errorf("Unmarshal failed,err:%v\n", err)
}
return subv, nil
}
func main() {
r := gin.Default()
r.POST("/example_domain/vertc/subtitle", HandleSubtitle)
r.Run()
}
字幕数据格式

参数名 | 类型 | 描述 |
|---|
magic number | binary | 消息标识。固定为 subv,表示字幕数据。 |
length | binary | 字幕消息长度,单位为 bytes,大端序存储。 |
subtitle_message | binary | 字幕内容的 JSON 字符串,UTF-8 编码。格式参见 subtitle_message。 |
subtitle_message
参数名 | 类型 | 描述 |
|---|
type | String | 消息类型,固定为 subtitle,表示消息为字幕。 |
data | data | 字幕详细信息,包含字幕的文本、语言、说话人 ID 等具体信息。 |
data
参数名 | 类型 | 描述 |
|---|
text | String | 字幕文本内容。 |
language | String | 字幕语言。 |
userId | String | 字幕来源者 ID。若字幕来源为真人用户,则该值为真人用户 UserId。若来源为 AI ,则该值为 AI 的 UserId。 |
sequence | Int | 字幕序号,在单轮对话中递增。 |
definite | Boolean | 字幕是否为完整的分句。 |
paragraph | Boolean | 字幕是否为完整的一句话。 - true:是。表示说话者此轮完整的发言结束。
- false:否。
|
roundId | Int | 对话轮次。 |
voiceprintName | String | (若启用)声纹名称。 |
voiceprintId | String | (若启用)声纹 ID。 |
subtitle_message 的示例
以下展示的是两次连续收到的字幕消息内容,用于说明 sequence、definite 和 text 字段的变化。
// 第一次收到的消息 (分句未结束)
{
"type": "subtitle",
"data": [{
"text": "上海天气炎热。气温为",
"language": "zh",
"userId": "bot1",
"sequence": 1,
"definite": false,
"paragraph": false,
"roundId": 1,
"voiceprintName": "xx",
"voiceprintId": "uuid"
}]
}
// 第二次收到的消息 (分句结束)
{
"type": "subtitle",
"data": [{
"text": "上海天气炎热。气温为 30 摄氏度。",
"language": "zh",
"userId": "bot1",
"sequence": 2,
"definite": true,
"paragraph": false,
"roundId": 1,
"voiceprintName": "xx",
"voiceprintId": "uuid"
}]
}
字幕特性
字幕的来源和返回方式,会因其接收方式(客户端回调 vs. 服务端回调)和归属方(真人用户 vs. AI )的不同而有差异。
接收方式 | 用户字幕 | AI 字幕 |
|---|
客户端接收 | 字幕来源:ASR 模块语音识别内容 返回方式:流式返回,新字幕是之前内容的累加。 示例:用户说 您好,查询一下上海天气。,您会依次收到 您好, 您好,查询 您好,查询一下上海天气。
字幕状态: - 说话过程中:
definite:始终为 false。paragraph:始终为 false。
- 说话结束时:
definite:始终为 true。paragraph:始终为 true。
| 字幕来源:LLM 文本或 TTS 朗读内容(SubtitleMode决定) 返回方式:流式返回。区分分句和整句,新字幕会覆盖旧的。 示例: AI 说 天气炎热。气温为 30 摄氏度。,您会先收到分句:天气炎热。和 天气炎热。气温为 30 摄氏度。。
字幕状态: - 说话过程中:
definite:可能为 false(分句未结束)或 true(分句已结束,但整句话未完)。paragraph:始终为 false,表示整句话未结束。
- 说话结束时:
definite:为 true,表示分句结束。paragraph:为 true,表示整句话结束。
|
服务端接收 | 字幕来源:ASR 模块语音识别内容 返回方式:仅在识别到完整分句后才返回。每个分句都作为一条独立的消息发送,新的分句不包含上一个已结束的分句。 示例:用户说 您好。查询一下上海天气。,您将依次收到字幕:您好。 和 查询一下上海天气。
字幕状态: - 说话过程中:
definite:始终为 true。paragraph:始终为 false。
- 说话结束时 :
definite :为 true。paragraph :为 true。
| 字幕来源:LLM 文本或 TTS 朗读内容(SubtitleMode决定) 返回方式:仅在识别到完整分句后才返回。每个分句都作为一条独立的消息发送,新的分句不包含上一个已结束的分句。 示例: AI 说 天气炎热。气温为 30 摄氏度。,您会依次收到分句:天气炎热。和气温为 30 摄氏度。。
字幕状态: - 说话过程中 :
definite:始终为 true,表示分句已结束。paragraph:始终为 false,表示一整句还未结束。
- 说话结束时 :
definite :为 true ,表示分句已结束。paragraph :为 true ,表示一整句已结束。
|
字幕处理实践
实时 UI 展示
字段组合状态 | UI 处理 |
|---|
paragraph: false、definite: false
| 用序号大的字幕覆盖序号小的。 |
paragraph: false、definite: true
| 重新开始新的一句话,覆盖前一句话。 |
paragraph: true
| 此时如果继续解析显示字幕,字幕会重复显示。 |
存储字幕
仅在解析到 paragraph: true 且 definite: true 时存储,减少存储的数据量,并确保保存的字幕是完整的。
说明
将字幕存储至火山 Viking 记忆库时,需将解析到的 text 按照 userId 映射为 user 或 assistant 角色,再通过 添加会话-AddSession 接口写入 Viking 记忆库。
触发新一轮对话
利用字幕状态作为业务逻辑的触发器。例如,在需要用户完整说完一句话后再响应的场景:
- 通过服务端回调监控用户的字幕。
- 当监测到来自用户的消息满足
paragraph: true 时,判定用户已结束发言。 - 调用业务逻辑(如向 LLM 发起请求、执行特定指令等)。
处理多轮对话字幕
使用 roundId 字段对字幕进行分组,在复杂的对话流中,准确区分和关联每一轮对话的字幕。
FAQ
- Q1:是否支持在用户和 AI 说话之前就将字幕返回给业务端?
不支持。 - Q2:字幕是否支持接收图片?
不支持。 - Q3:微信小程序端是否支持字幕功能?
支持。 - Q4:收到的字幕与实际内容相比有很多错误同音字。
不同模型对于同音字的识别结果不同。您可以通过提升语音识别的准确性,来提升字幕准确度。具体操作,请参见提升语音识别准确性。 - Q5:字幕是否支持返回超链接内容?
不支持。