本文面向 Java 开发人员,介绍如何使用 ConvAI Voice SDK 接入实时语音合成服务,实现流式文本输入、流式音频接收、音频文件保存、PCM 实时播放、字级时间戳和任务取消。
适用版本:
JDK:8 及以上
SDK:
com.aliyun.convai:convai-voice-sdk:0.1.0(请使用最新版本)协议:WebSocket(SDK 内部管理,业务无需手工拼装协议帧)
1. 接入前准备
接入前请向服务方获取:
Voice Studio 签发的 API Key,形如
sk-xxxxxxxx。可通过控制台获取创建 API Key。平台音色 ID,即
ListConsumableVoices返回的voiceVersionId。可通过控制台获取音色ID。
1.1 服务地址
wss://convai-platform.aliyuncs.com/api-ws/v1/inference
1.2 Maven 依赖
在 pom.xml 中加入:
<dependency>
<groupId>com.aliyun.convai</groupId>
<artifactId>convai-voice-sdk</artifactId>
<version>0.1.0</version>
</dependency>
<!-- 可选:SDK 使用 slf4j-api,应用可自行选择日志实现 -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.6</version>
<scope>runtime</scope>
</dependency>2. SDK 核心对象
对象 | 作用 | 生命周期建议 |
| 管理鉴权、WebSocket 连接、连接池、心跳和重连 | 重对象,一个应用进程创建一个并全局复用 |
| 描述一次合成的音色、格式、采样率等参数 | 每种合成配置可复用 |
| 一轮流式合成会话 | 每轮对话创建一个;单生产者线程调用 |
| 接收音频分片、句级事件和任务事件 | 回调内只做快速写盘或入队 |
| 合成结束结果,包含计费字符、首包延迟和链路 ID |
|
推荐调用流程:
创建全局 VoiceRealtimeClient
↓
为本轮合成创建 SynthesisSession
↓
write(text) 可调用多次,SDK 流式返回音频
↓
finish() 等待所有尾部音频发送完毕
↓
关闭 session
↓
应用退出时关闭 client3. 快速开始:短文本合成并保存 WAV
以下示例使用 synthesize() 一次性发送短文本,阻塞等待完整音频并保存为可直接播放的 WAV。
import com.convai.voice.sdk.AudioFormat;
import com.convai.voice.sdk.SynthesisAudio;
import com.convai.voice.sdk.SynthesisOptions;
import com.convai.voice.sdk.realtime.VoiceRealtimeClient;
import java.nio.file.Files;
import java.nio.file.Paths;
public class ShortTextWavExample {
public static void main(String[] args) throws Exception {
String apiKey = System.getenv("CONVAI_API_KEY");
String voiceId = System.getenv("CONVAI_VOICE_ID");
VoiceRealtimeClient client = VoiceRealtimeClient.builder()
.endpoint("wss://convai-platform.aliyuncs.com/api-ws/v1/inference")
.apiKey(apiKey)
.build();
try {
SynthesisOptions options = SynthesisOptions.builder()
.voice(voiceId)
.format(AudioFormat.WAV)
.sampleRate(22050)
.build();
SynthesisAudio result = client.synthesize(
options, "你好,欢迎使用实时语音合成服务。");
Files.write(Paths.get("output.wav"), result.audio());
System.out.println("音频字节数=" + result.size());
System.out.println("计费字符=" + result.result().characters());
System.out.println("首包延迟=" + result.result().firstPackageDelayMillis() + " ms");
System.out.println("requestUuid=" + result.result().lastRequestUuid());
} finally {
client.close();
}
}
}运行前设置:
export CONVAI_API_KEY="sk-你的VoiceStudioApiKey"
export CONVAI_VOICE_ID="你的平台音色ID"
mvn -q compile org.codehaus.mojo:exec-maven-plugin:3.1.0:java \
-Dexec.mainClass=ShortTextWavExample4. 完整参数列表
4.1 VoiceRealtimeClient.Builder 客户端参数
Builder 方法 | 必填 | 默认值 | 支持状态与约束 |
| 是 | 无 | 实时语音合成 WebSocket 服务地址 |
| 二选一 | 无 | 固定 API Key;请勿写死在代码或提交到代码仓库 |
| 二选一 | 无 | 适合配置中心或临时凭证;鉴权失败时可刷新凭证 |
| 否 |
| 开启 TN;握手级配置,同一个 client 的所有会话共享 |
| 否 |
| 建议设置应用名称和版本,方便服务端排障 |
| 否 | 无 | 添加自定义握手请求头;不要覆盖鉴权或平台保留头 |
| 否 |
| WebSocket 建连超时,单位毫秒 |
| 否 |
| 等待任务启动确认的超时,单位毫秒 |
| 否 |
|
|
| 否 |
| 连接空闲、任务停顿和心跳保护;生产环境建议保持默认 |
| 否 |
| 建连失败及并发超限的重试退避策略 |
| 否 |
| 网络中断后的处理策略,详见第 10 节 |
默认保护策略:
WebSocket 心跳:20 秒。
空闲连接回收:45 秒。
流式文本停顿保护:18 秒;SDK 会在服务端超时前正常收口当前 task。
建连最大尝试次数:5 次,采用随机/指数退避。
4.2 SynthesisOptions.Builder 合成参数
Builder 方法 | 必填 | 默认值 | 支持状态与约束 |
| 是 | 无 | 平台音色 ID( |
| 否 |
|
|
| 否 |
|
|
| 否 |
| 范围 |
| 否 |
| 语速,范围 |
| 否 |
| 音调,范围 |
| 否 | 引擎默认 | 范围 |
| 否 | 无 | 范围 |
| 否 | 空 | 语言提示,如 |
| 否 | 无 | 方言、情感或角色指令,最长 2000 字符;具体支持取决于音色绑定模型 |
| 否 |
| 开启后一个任务只允许调用一次 |
| 否 |
| 在句末事件返回字级时间戳;不能与 |
| 否 | 无 | 自定义发音和文本替换;具体支持取决于音色绑定模型 |
| 否 |
| 合成前过滤 Markdown 标记;具体支持取决于模型 |
| 否 |
| 在音频中嵌入 AIGC 标识 |
| 否 | 无 | 需先设置 |
| 否 | 无 | 需先设置 propagator;用于标识传播行为 |
| 否 | 无 | 透传 SDK 尚未建模的新参数;不能覆盖 SDK 已管理字段 |
| 否 | 无 | 批量设置额外参数 |
SDK 不提供 model 参数。模型由平台根据音色 ID 的绑定关系自动确定,客户无需感知或传入模型名。
SDK 的格式默认值是MP3,本文所有文件保存示例均显式设置为AudioFormat.WAV,不会生成 MP3。
4.3 音频格式建议
格式 | 特点 | 推荐场景 |
MP3 | 体积小、兼容性高,可按顺序拼接落盘 | 通用文件保存、网络传输 |
PCM | 延迟最低;16-bit、小端、单声道,无文件头 | 实时播放、电话或 RTC 音频管道 |
WAV | 自带文件头,便于直接播放 | 收完后播放、测试验收 |
OPUS | 压缩率高 | 带宽受限场景 |
PCM 下行分片长度不固定,不能假设每个 WebSocket 音频分片都是固定 20 ms。应将其视为连续字节流,按收到顺序写入播放缓冲区。
5. SDK 方法说明
5.1 VoiceRealtimeClient
方法 | 说明 |
| 创建流式会话;第一次 |
| 短文本便捷接口,阻塞返回完整音频 |
| 关闭全部会话、连接池和后台线程;应用退出时调用 |
5.2 SynthesisSession
方法 | 说明 |
| 提前建连并启动任务,用于 fail-fast;通常无需调用 |
| 增量发送文本,可调用多次 |
| 发送文本;第二个参数为 |
| 强制合成当前缓存残句;与字级时间戳互斥 |
| 正常结束并阻塞到所有音频接收完成,返回 |
| 用户打断时停止后续音频并结束会话 |
| 若会话未结束,会尽力执行取消并释放资源 |
文本限制:单次 write() 不超过 20,000 字符,单个上游任务累计不超过 200,000 字符。中文长文本建议按完整句子拆分,并保留句末标点。
6. 场景示例二:LLM 流式文本合成并保存 WAV
import com.convai.voice.sdk.*;
import com.convai.voice.sdk.realtime.*;
import java.io.*;
import java.util.concurrent.atomic.AtomicReference;
public class LlmStreamingWavExample {
public static void main(String[] args) throws Exception {
VoiceRealtimeClient client = VoiceRealtimeClient.builder()
.endpoint("wss://convai-platform.aliyuncs.com/api-ws/v1/inference")
.apiKey(System.getenv("CONVAI_API_KEY"))
.finishTimeoutMillis(10 * 60 * 1000L)
.build();
SynthesisOptions options = SynthesisOptions.builder()
.voice(System.getenv("CONVAI_VOICE_ID"))
.format(AudioFormat.WAV)
.sampleRate(22050)
.build();
final AtomicReference<IOException> writeError = new AtomicReference<IOException>();
try (final OutputStream out = new BufferedOutputStream(new FileOutputStream("stream.wav"))) {
SynthesisListener listener = new SynthesisListener() {
public void onAudio(byte[] audio) {
try {
out.write(audio);
} catch (IOException e) {
writeError.compareAndSet(null, e);
}
}
public void onSentence(SentenceEvent event) {
if (event.type() == SentenceEvent.Type.SENTENCE_END) {
System.out.println("已合成:" + event.originalText());
}
}
};
try (SynthesisSession session = client.newSession(options, listener)) {
// 实际业务中,这些字符串可直接来自大模型流式输出。
session.write("今天的天气非常不错,");
session.write("适合出门散步。");
session.write("记得带上水杯。");
SynthesisResult result = session.finish();
out.flush();
if (writeError.get() != null) throw writeError.get();
System.out.println("计费字符=" + result.characters());
System.out.println("requestUuid=" + result.lastRequestUuid());
}
} finally {
client.close();
}
}
}注意:
onAudio在 WebSocket 读线程执行,只做快速写盘或入队。不要在
onAudio中调用write()、flush()、finish()或执行网络请求。文本尽量按完整句子发送;无句末标点的残句可在适当时机调用
flush()。
7. 场景示例三:PCM 边合成、边播放、边保存
下面的完整示例使用 JDK 8 自带 Java Sound 播放 PCM,并同步保存裸 PCM;合成结束后自动补 WAV 头生成 output.wav。PCM 参数必须与请求一致:16-bit、小端、单声道、24000 Hz。
import com.convai.voice.sdk.AudioFormat;
import com.convai.voice.sdk.SynthesisOptions;
import com.convai.voice.sdk.SynthesisResult;
import com.convai.voice.sdk.realtime.SynthesisListener;
import com.convai.voice.sdk.realtime.SynthesisSession;
import com.convai.voice.sdk.realtime.VoiceRealtimeClient;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.DataLine;
import javax.sound.sampled.SourceDataLine;
import java.io.BufferedOutputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class PcmStreamingPlaybackExample {
private static final byte[] END = new byte[0];
public static void main(String[] args) throws Exception {
final int sampleRate = 24000;
final BlockingQueue<byte[]> queue = new LinkedBlockingQueue<byte[]>();
javax.sound.sampled.AudioFormat javaSoundFormat =
new javax.sound.sampled.AudioFormat(sampleRate, 16, 1, true, false);
final SourceDataLine speaker = (SourceDataLine) AudioSystem.getLine(
new DataLine.Info(SourceDataLine.class, javaSoundFormat));
speaker.open(javaSoundFormat);
speaker.start();
final OutputStream recording = new BufferedOutputStream(
new FileOutputStream("output.pcm"));
Thread consumer = new Thread(new Runnable() {
public void run() {
try {
while (true) {
byte[] pcm = queue.take();
if (pcm == END) break;
recording.write(pcm);
speaker.write(pcm, 0, pcm.length);
}
recording.flush();
speaker.drain();
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
try { recording.close(); } catch (IOException ignored) { }
speaker.stop();
speaker.close();
}
}
}, "pcm-playback");
consumer.start();
VoiceRealtimeClient client = VoiceRealtimeClient.builder()
.endpoint("wss://convai-platform.aliyuncs.com/api-ws/v1/inference")
.apiKey(System.getenv("CONVAI_API_KEY"))
.build();
SynthesisOptions options = SynthesisOptions.builder()
.voice(System.getenv("CONVAI_VOICE_ID"))
.format(AudioFormat.PCM)
.sampleRate(sampleRate)
.build();
try {
SynthesisListener listener = new SynthesisListener() {
public void onAudio(byte[] pcm) {
queue.offer(pcm); // 回调只入队,避免扬声器写入阻塞 WS 读线程
}
};
try (SynthesisSession session = client.newSession(options, listener)) {
session.write("你好,现在正在边合成边播放 PCM 音频。");
session.write("完整音频也会同步保存到本地文件。");
SynthesisResult result = session.finish();
System.out.println("requestUuid=" + result.lastRequestUuid());
}
} finally {
client.close();
queue.offer(END);
consumer.join();
}
pcmToWav("output.pcm", "output.wav", sampleRate);
System.out.println("WAV 录音已保存:" + Paths.get("output.wav").toAbsolutePath());
}
private static void pcmToWav(String pcmPath, String wavPath, int sampleRate)
throws Exception {
byte[] pcm = Files.readAllBytes(Paths.get(pcmPath));
ByteBuffer header = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN);
header.put("RIFF".getBytes("US-ASCII"));
header.putInt(36 + pcm.length);
header.put("WAVEfmt ".getBytes("US-ASCII"));
header.putInt(16).putShort((short) 1).putShort((short) 1);
header.putInt(sampleRate).putInt(sampleRate * 2);
header.putShort((short) 2).putShort((short) 16);
header.put("data".getBytes("US-ASCII")).putInt(pcm.length);
try (OutputStream out = Files.newOutputStream(Paths.get(wavPath))) {
out.write(header.array());
out.write(pcm);
}
}
}裸 PCM 播放验证:
ffplay -f s16le -ar 24000 -ac 1 output.pcm示例结束后会生成可直接播放的 output.wav。生产环境处理超长音频时,建议使用 RandomAccessFile 边写 PCM,并在关闭时回填 WAV 文件头长度,避免一次性把完整 PCM 读入内存。
生产播放建议:
使用独立播放线程和有界抖动缓冲,不要在 SDK 回调线程直接播放。
播放缓冲欠载时输出静音或舒适噪声,不要反复播放最后一个有声帧。
不要按 WebSocket 分片大小推断音频时长;PCM 时长为
总字节数 / 2 / 采样率。
8. 场景示例四:文本归一化与字级时间戳
文本归一化(TN)是 client 级配置;字级时间戳是会话参数。开启 TN 后,时间戳偏移基于归一化后的文本。
import com.convai.voice.sdk.*;
import com.convai.voice.sdk.realtime.*;
import java.io.*;
public class WordTimestampExample {
public static void main(String[] args) throws Exception {
VoiceRealtimeClient client = VoiceRealtimeClient.builder()
.endpoint("wss://convai-platform.aliyuncs.com/api-ws/v1/inference")
.apiKey(System.getenv("CONVAI_API_KEY"))
.textNormalization(true)
.build();
SynthesisOptions options = SynthesisOptions.builder()
.voice(System.getenv("CONVAI_VOICE_ID"))
.format(AudioFormat.WAV)
.sampleRate(22050)
.wordTimestampEnabled(true)
.build();
try (final OutputStream out = new FileOutputStream("timestamp.wav")) {
SynthesisListener listener = new SynthesisListener() {
public void onAudio(byte[] audio) {
try { out.write(audio); } catch (IOException e) { throw new RuntimeException(e); }
}
public void onSentence(SentenceEvent event) {
if (event.type() != SentenceEvent.Type.SENTENCE_END) return;
System.out.println("句子:" + event.originalText());
for (Word word : event.words()) {
System.out.printf("%s: %d-%d ms%n",
word.text(), word.beginTime(), word.endTime());
}
}
};
try (SynthesisSession session = client.newSession(options, listener)) {
session.write("今天是2026年9月24日,订单金额是128.5元。");
session.finish();
}
} finally {
client.close();
}
}
}字级时间戳开启后不能调用 flush() 或 write(text, true);SSML 与字级时间戳也不能同时开启。
9. 场景示例五:用户打断并取消合成
当用户开始说话、切换页面或主动停止播放时,应调用 cancel(),不要直接断开底层 WebSocket。
import com.convai.voice.sdk.*;
import com.convai.voice.sdk.realtime.*;
public class CancelSynthesisExample {
public static void main(String[] args) throws Exception {
VoiceRealtimeClient client = VoiceRealtimeClient.builder()
.endpoint("wss://convai-platform.aliyuncs.com/api-ws/v1/inference")
.apiKey(System.getenv("CONVAI_API_KEY"))
.build();
SynthesisOptions options = SynthesisOptions.builder()
.voice(System.getenv("CONVAI_VOICE_ID"))
.format(AudioFormat.PCM)
.sampleRate(24000)
.build();
try {
SynthesisListener listener = new SynthesisListener() {
public void onAudio(byte[] audio) {
// 实际业务中把音频放入播放器队列。
System.out.println("收到 PCM: " + audio.length + " bytes");
}
};
try (SynthesisSession session = client.newSession(options, listener)) {
session.write("这是一段较长的合成文本,用于演示用户主动打断。"
+ "用户开始说话后,客户端应立即取消后续合成。");
Thread.sleep(500); // 模拟用户在播放过程中触发打断
SynthesisResult result = session.cancel();
System.out.println("已取消,cancelled=" + result.cancelled());
}
} finally {
client.close();
}
}
}取消会丢弃尚未合成的缓存文本,并停止后续音频输出;已输出的音频无法撤回。取消不等同于“不产生用量”。
10. 错误处理与重试
SDK 异常均继承 VoiceSdkException,属于非受检异常。
异常 | 含义 | 建议处理 |
| 本地参数校验失败 | 修改参数,不要重试 |
| API Key 无效、过期或音色越权 | 刷新凭证或检查音色权限 |
| 并发连接数超限,SDK 退避后仍失败 | 降低并发或稍后重试 |
| 服务端返回任务失败 | 读取错误码判断是否可重试 |
| 网络异常导致合成中途被打断 | 根据已出声边界决定是否补发 |
| 建连、发送或等待超时 | 检查网络并进行有限次数重试 |
推荐错误处理:
try {
SynthesisResult result = session.finish();
} catch (com.convai.voice.sdk.error.TaskFailedException e) {
System.err.println("errorCode=" + e.rawErrorCode());
System.err.println("taskId=" + e.taskId());
System.err.println("requestUuid=" + e.requestUuid());
System.err.println("sessionId=" + e.sessionId());
if (e.errorCode().retryable()) {
// 由业务进行有上限、带退避的重试。
}
} catch (com.convai.voice.sdk.error.TaskInterruptedException e) {
System.err.println("已完成合成句数=" + e.spokenSentences());
System.err.println("待续传文本=" + e.unspokenText());
}这里的“已完成合成句数”表示 SDK 已收到相应句子的完成事件,不代表音频已经通过扬声器播放。实时播放场景应等待播放队列消费完毕,并调用 SourceDataLine.drain() 后,才能确认音频播放完成。
ResumePolicy:
FAIL_FAST(默认):网络中断时抛出TaskInterruptedException,由业务决定是否重放。RESUME_UNSPOKEN:SDK 自动重发未确认出声的文本。可能出现少量重复、缺失或韵律变化,开启 TN 时边界估算也可能漂移,仅适合“连续性优先于听感一致性”的场景。
11. 结果与可观测信息
SynthesisResult 提供:
方法 | 说明 |
| 本次会话累计计费字符数 |
| SDK 自动切段后的各段结果 |
| 是否由取消结束 |
| 第一段从首帧文本发送到首帧音频到达的延迟 |
| 最后一段服务端链路 ID,报障时必须提供 |
每个 Segment 提供:taskId()、requestUuid()、sessionId()、characters()、autoSegmented() 和 firstPackageDelayMillis()。
SDK 为避免任务内文本停顿超时,可能将一个业务会话自动拆成多个上游 task。因此:
segments().size()可能大于 1,属于正常行为。句子
index在每个 task 内从 0 开始,应结合SentenceEvent.taskId()区分。报障时建议提交全部 segment 的
taskId、requestUuid和sessionId。
12. 连接与并发最佳实践
一个应用进程只创建一个
VoiceRealtimeClient,不要每次合成都重新创建。每轮对话新建一个
SynthesisSession,不要跨线程并发调用同一个 session。正常结束必须调用
finish();用户打断调用cancel()。必须等
finish()返回后再关闭录音文件,否则会丢失尾部音频。回调线程只做快速写盘或入队;播放、转码、上传等耗时操作放到独立线程。
并发会话会占用并发连接额度;出现并发超限时不要密集重连。
心跳只能维持网络链路,不代表业务连接永久有效;连接生命周期由 SDK 自动管理。
API Key 不得写入客户端安装包、浏览器代码、日志或代码仓库,应通过服务端配置或密钥系统注入。
13. 常见问题
13.1 为什么没有立即出声?
引擎通常按句合成。请保留句末标点,或在允许的场景调用 flush()。频繁 flush 会让语音断句变碎,不建议对每个 token 调用。
13.2 为什么音频尾部缺失?
必须调用 finish() 并等待其返回。finish() 返回前仍可能持续收到音频分片。
13.3 为什么音色名返回不存在?
SDK 的 voice() 只接受平台音色 ID,即 voiceVersionId,不接受供应商音色名。
13.4 PCM 播放出现变速、变调或噪音怎么办?
确认播放器参数与请求完全一致:
16-bit signed PCM
little-endian
mono
采样率等于
sampleRate()
不要将 PCM 当成 8-bit、big-endian、双声道或错误采样率播放。
13.5 为什么收到多个 taskId?
当 LLM 输出长时间停顿时,SDK 会在服务端超时前自动正常收口当前 task,下一次 write() 自动创建新 task。业务会话仍是同一个,最终结果通过 segments() 汇总。
13.6 发生问题需要提供什么?
请提供:
问题发生的准确时间。
requestUuid、taskId、sessionId。SDK 版本和 JDK 版本。
音频格式、采样率和音色 ID。
完整异常类型、错误码及错误消息。
若为音频问题,请提供 SDK 原始落盘文件,不要只提供经过播放器、RTC 或电话系统转码后的录音。