阿里云 NUI 流式语音播报(TTS)集成说明
阿里云 NUI 流式语音播报(TTS)集成说明
本文汇总 LegacyApp 中与「把文字读出来」相关的链路:流式输入语音合成(Stream Input TTS)、与 ASR 的互斥、前端组件与排障。
- 阿里 ASR:ASR语音识别集成.md
- 腾讯 ASR(无 TTS):../tencent/ASR语音识别集成.md
- 总览:../README.md
一、整体架构
前端 Vue(ChatTtsMessageButton.vue 等)
↓ GET /bus-module/ai/asr/token(与 ASR 共用同一签发接口,返回 appKey + token)
↓ plugin.callPlugin.startStreamInputTts({ appKey, token, text, ttsOptions })
↓ cordova.plugins.FirstPlugin.coolMethod({ type: 33, arg0, arg1, arg2, arg3 }, ...)
Android FirstPlugin.java type=33 / 34
↓
StreamInputTtsPlugin.java
↓ NativeNui MODE_STREAM_INPUT_TTS + asyncPlayStreamInputTts
↓ PCM → AudioTrack 播放
↓ 结束时 success({ event: 'complete' }) 或取消 cancelled
- Token:与 ASR 相同,仍走
AiAsrController的 NLS CreateToken,前端只消费token+appKey。 - 合成参数:音色、采样率等由 前端
ttsOptions下发(便于发版调整),见下文「合成默认值」。
二、Android 端约定
1. Cordova type 与参数(与 H5 一致)
| type | 含义 |
|---|---|
33 | 开始一次流式 TTS 播报(可排队/打断策略以 StreamInputTtsPlugin 为准) |
34 | 停止播报(H5 需能收到 success,与 stopStreamInputTts 对应) |
FirstPlugin 在收到 type=30(开始 ASR) 时,会先尝试 静默停止 正在进行的 TTS,避免双实例争用。
2. coolMethod 业务参数(type=33)
与 StreamInputTtsPlugin 注释一致(索引从插件实际解析为准,常见为):
| 字段 | 含义 |
|---|---|
arg0 | app_key |
arg1 | token |
arg2 | 待朗读纯文本 |
arg3 | 可选,合成参数 JSON 字符串(音色 voice、sample_rate 等);空则走原生侧默认 |
H5 封装:LegacyApp/src/plugin/cordovaPlugin.js → startStreamInputTts / stopStreamInputTts。
3. 源码位置(云检工程)
.../FirstPlugin.java(30/31ASR,33/34TTS).../StreamInputTtsPlugin.java(NUI 流式 TTS + AudioTrack)
4. 排障日志
adb logcat -s LegacyTTS:E ALI_STREAM_TTS:E
(具体 tag 以 StreamInputTtsPlugin 内常量为准。)
三、前端:合成默认值与业务组件
1. 合成参数默认值(前端维护)
路径:LegacyApp/src/utils/streamTtsDefaults.js
导出 STREAM_TTS_SYNTH_DEFAULTS(如 voice、format、sample_rate、volume 等),在调用 startStreamInputTts 时展开传入 ttsOptions。说明与官方 Stream Input TTS 文档对齐,见文件头 @see 链接。
2. 业务组件:消息朗读按钮
路径:LegacyApp/src/components/voice/ChatTtsMessageButton.vue
- 默认导出:仅在 最后一条有正文的 AI 消息 上展示「朗读 / 停止」按钮。
- 命名导出(页面级配合):
chatTtsState:顶栏「AI 回复后自动朗读」开关等;toggleAutoTtsOnAiReply();maybeAutoSpeakLastAi(vm, { messages, sessionStatus, SESSION_STATUS, submitted }):新 AI 气泡出现后按需自动播;disposeChatTts():离开页面前停止播报并清状态。
完整串联示例:src/views/maintenance/planDay/aiPlanDayChat.vue。
3. JS 回调约定(与 Vue 侧解析一致)
原生经 Cordova success 回传时,常见为带 event 字段的对象,例如:
event === 'complete':本轮合成结束;event === 'cancelled':被停止或打断。
ChatTtsMessageButton.vue 内对字符串/对象两种形态做了兼容解析。
四、接口与配置(与 ASR 文档一致)
- GET
/bus-module/ai/asr/token - 配置项:
ai.nls.access-key-id、access-key-secret、app-key - 权限与账号要求与 ASR 相同(NLS 开通、AppKey、AccessKey 权限等)。
五、自检清单(TTS)
六、与 ASR 文档的关系
| 能力 | 文档 |
|---|---|
| 说话 → 文字(ASR) | ASR语音识别集成.md |
| 文字 → 声音(TTS) | 本文 |
公共组件清单与 props 表(给产品/前端查阅)还可同步维护业务仓库:LegacyApp/docs/components/公共组件说明.md。
七、核心源码内嵌与离线备份
备份目录:
aliyun/backup/(见 backup/README.md)。公共 H5、凭证见 ../common/backup/。本节内嵌 TTS 默认参数与StreamInputTtsPlugin摘录;阿里 ASR 见 ASR语音识别集成.md。
7.1 前端 streamTtsDefaults.js(完整)
/**
* 阿里云 NUI「流式输入语音合成」(Stream Input TTS) 的合成参数默认值。
*
* 设计说明:
* - 音色、语速等由**前端**维护并随每次播报传给原生,便于发版升级,无需改后端或发 App 热修(仅当参数结构变化时才需同步改 Java 解析)。
* - 发音人列表与说明见阿里云文档(StreamInputTts / CosyVoice 等)。
*
* @see https://help.aliyun.com/zh/isi/stream-input-tts-sdk-quick-start
*/
export const STREAM_TTS_SYNTH_DEFAULTS = Object.freeze({
/** 发音人 ID,如 zhixiaoxia、xiaoyun 等,以当前账号与产品线文档为准 */
voice: 'zhixiaoxia',
/** 音频编码:pcm / wav / mp3 等;与 enable_audio_decoder 配合见官方说明 */
format: 'pcm',
/**
* 是否由 SDK 内解码为 PCM 再回调;pcm 且本地 AudioTrack 播放时一般为 false
*/
enable_audio_decoder: false,
/** 采样率 Hz,需与播放器侧一致(当前原生使用 AudioTrack 按此采样率播放) */
sample_rate: 24000,
/** 音量 0~100 */
volume: 50,
/** 语速 -500~500 */
speech_rate: 0,
/** 语调 -500~500 */
pitch_rate: 0,
/** 是否返回字级时间戳(一般播报可关) */
enable_subtitle: false
})
7.2 H5 Cordova 桥(cordovaPlugin_voice_asr_tts.js,含 ASR+TTS)
/**
* 摘自 LegacyApp/src/plugin/cordovaPlugin.js
* 仅保留:流式 ASR(type 30/31)与流式 TTS(type 33/34)的 Cordova 桥接。
* 完整工程另有 netRequest、getToken 等,此处不重复。
*/
export const voiceAsrTtsBridge = {
startStreamVoiceRecognize(options, success, fail) {
if (!(window.cordova && window.cordova.plugins && window.cordova.plugins.FirstPlugin)) {
return fail && fail('当前环境不支持语音识别')
}
try {
var params = {
type: 30,
arg0: options.appKey,
arg1: options.token
}
cordova.plugins.FirstPlugin.coolMethod(params, success, fail, null)
} catch (e) {
fail && fail(e)
}
},
stopStreamVoiceRecognize(options, success, fail) {
try {
var params = { type: 31 }
cordova.plugins.FirstPlugin.coolMethod(params, success || function () {}, fail || function () {}, null)
} catch (e) {
fail && fail(e)
}
},
startStreamInputTts(options, success, fail) {
try {
var ttsArg = ''
if (options.ttsOptions != null && options.ttsOptions !== '') {
ttsArg = typeof options.ttsOptions === 'string' ? options.ttsOptions : JSON.stringify(options.ttsOptions)
}
var params = {
type: 33,
arg0: options.appKey,
arg1: options.token,
arg2: options.text,
arg3: ttsArg
}
cordova.plugins.FirstPlugin.coolMethod(params, success, fail, null)
} catch (e) {
fail && fail(e)
}
},
stopStreamInputTts(options, success, fail) {
try {
var params = { type: 34 }
cordova.plugins.FirstPlugin.coolMethod(params, success || function () {}, fail || function () {}, null)
} catch (e) {
fail && fail(e)
}
}
}
7.3 Android StreamInputTtsPlugin.java(完整)
package cordova.plugin.first.plugin;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioTrack;
import android.os.Looper;
import android.text.TextUtils;
import android.util.Log;
import com.alibaba.idst.nui.Constants;
import com.alibaba.idst.nui.INativeStreamInputTtsCallback;
import com.alibaba.idst.nui.NativeNui;
import org.apache.cordova.CallbackContext;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
/**
* 阿里云智能语音交互(NUI)——<b>流式输入语音合成</b>(Stream Input TTS)的 Cordova 桥接实现。
* <p>
* 与 {@link VoiceRecognizePlugin}(流式 ASR)类似,本类在独立 {@link NativeNui} 实例上以
* {@code Constants.ModeType.MODE_STREAM_INPUT_TTS} 模式工作,通过
* {@code NativeNui.asyncPlayStreamInputTts(...)} 将整段文本交给云端合成,并在
* {@code INativeStreamInputTtsCallback.onStreamInputTtsDataCallback} 中接收 PCM 数据后用 {@link AudioTrack} 播放。
* </p>
* <p>
* <b>Cordova 入参约定({@code coolMethod} 的 {@code JSONArray},与 {@code FirstPlugin} type=33 一致):</b>
* </p>
* <ul>
* <li>{@code args[0]}:type 字符串 {@code "33"}(由 {@code FirstPlugin} 解析;本类从索引 1 起读业务参数)</li>
* <li>{@code args[1]}:{@code app_key}</li>
* <li>{@code args[2]}:{@code token}</li>
* <li>{@code args[3]}:待朗读正文(纯文本)</li>
* <li>{@code args[4]}:<b>可选</b>,前端下发的合成参数 JSON 字符串(音色、采样率等),为空则使用内置默认值;
* 字段名需与前端 {@code streamTtsDefaults.js} 及 {@link #buildSynthParams(JSONObject)} 保持一致</li>
* </ul>
* <p>
* <b>依赖:</b>NUI SDK 须包含流式 TTS 相关 API(与官方 Demo「StreamInputTtsBasicActivity」同级或兼容版本)。
* </p>
* <p>
* <b>线程安全:</b>{@link NativeNui#asyncPlayStreamInputTts} 可能长时间阻塞;<b>禁止</b>在持有本类
* {@code synchronized(this)} 监视器时调用,否则 NUI 在后台线程回调 {@link #handleTtsEvent} 时无法进入
* 同一把锁,会导致网关 IDLE_TIMEOUT、界面卡死。
* </p>
*
* @see com.alibaba.idst.nui.NativeNui
*/
public class StreamInputTtsPlugin {
private static final String TAG = "ALI_STREAM_TTS";
/** 与业务排查一致:请用 `adb logcat -s LegacyTTS:E` 即可看到合成全链路(含 ret、各事件) */
private static final String UI_LOG = "LegacyTTS";
/** 当前 SDK 合成与本地播放统一使用的默认采样率(Hz),当前端未传 sample_rate 时采用 */
private static final int DEFAULT_SAMPLE_RATE = 24000;
private final android.app.Activity activity;
/** 流式 TTS 专用 NativeNui;与语音识别分属不同实例。业务上由 FirstPlugin 保证:用户点击语音输入(type=30)时会先停止本播报。 */
private NativeNui mTts;
/** 流式 PCM 播放;在 {@link INativeStreamInputTtsCallback.StreamInputTtsEvent#STREAM_INPUT_TTS_EVENT_SYNTHESIS_STARTED} 后创建 */
private AudioTrack mAudioTrack;
/** 本次 asyncPlay 的 Cordova 回调,结束时必须释放且只成功/失败一次 */
private CallbackContext mPlayCallback;
/** 是否仍处于一次「从开始播放到收到完成/失败」的业务会话中,用于过滤取消后的迟滞事件 */
private volatile boolean mPlaying;
/**
* 实际播放采样率,取自前端 JSON {@code sample_rate},未传则 {@link #DEFAULT_SAMPLE_RATE}。
* 须与 {@link #buildSynthParams(JSONObject)} 中下发给 SDK 的 sample_rate 一致。
*/
private int mPlaybackSampleRate = DEFAULT_SAMPLE_RATE;
/**
* @param activity Cordova Activity,用于读取 {@code ANDROID_ID} 等上下文
*/
public StreamInputTtsPlugin(android.app.Activity activity) {
this.activity = activity;
this.mTts = null;
}
private synchronized NativeNui getOrCreateTts() {
if (mTts == null) {
mTts = new NativeNui(Constants.ModeType.MODE_STREAM_INPUT_TTS);
Log.e(UI_LOG, "NativeNui MODE_STREAM_INPUT_TTS instantiated");
}
return mTts;
}
/**
* Cordova {@code type=33}:在后台线程执行 {@link #playAsync},避免阻塞 UI 与 WebView。
*
* @param executor 一般为 {@code cordova.getThreadPool()}
*/
public void enqueuePlayAsync(Executor executor, JSONArray args, CallbackContext callbackContext) {
if (executor == null || args == null || callbackContext == null) {
return;
}
executor.execute(() -> {
try {
playAsync(args, callbackContext);
} catch (Throwable t) {
Log.e(UI_LOG, "enqueuePlayAsync worker", t);
runOnUi(() -> {
try {
callbackContext.error(t.getMessage() != null ? t.getMessage() : "TTS 启动异常");
} catch (Throwable ignored) {
}
});
}
});
}
/**
* Cordova {@code type=34}:在主线程执行 {@link #cancel},并向本次 JS 调用的 {@link CallbackContext} 回传 {@code success("ok")}。
* <p>与 {@link #stopPlaybackSilentlyBlocking()} 的区别:本方法用于 <b>H5 主动停止</b> 且需要 Cordova 回调收尾;
* 后者用于 <b>被语音识别等打断</b>,{@code cancel(null)} 不向「本次停止操作」再回 success。</p>
*/
public void postCancelAckToJs(CallbackContext callbackContext) {
if (callbackContext == null) {
return;
}
runOnUi(() -> {
try {
cancel(callbackContext);
} catch (Throwable t) {
Log.e(UI_LOG, "postCancelAckToJs", t);
try {
callbackContext.error(t.getMessage() != null ? t.getMessage() : "停止 TTS 异常");
} catch (Throwable ignored) {
}
}
});
}
/**
* 使用当前 token 对整段文本发起异步流式合成并播放(对应 FirstPlugin {@code type=33})。
* <p>若上一次播放未结束,会先 {@link NativeNui#cancelStreamInputTts} 并释放 {@link AudioTrack}。</p>
*
* @param args Cordova 传入数组,见类注释
* @param callbackContext 成功结束时 {@code success({event:"complete"})};用户取消见 {@link #cancel(CallbackContext)}
*/
public void playAsync(JSONArray args, CallbackContext callbackContext) {
synchronized (this) {
stopNativeAndReleaseAudio();
}
String appKey = optArg(args, 1);
String token = optArg(args, 2);
String text = optArg(args, 3);
String ttsOptsJson = optArg(args, 4);
if (TextUtils.isEmpty(text)) {
runOnUi(() -> callbackContext.error("朗读文本为空"));
return;
}
if (TextUtils.isEmpty(appKey) || TextUtils.isEmpty(token)) {
runOnUi(() -> callbackContext.error("缺少 appKey 或 token"));
return;
}
JSONObject ttsOptsFromH5 = parseTtsOptionsJson(ttsOptsJson);
final int sampleRate = resolveSampleRate(ttsOptsFromH5);
synchronized (this) {
mPlaybackSampleRate = sampleRate;
mPlayCallback = callbackContext;
mPlaying = true;
}
try {
String initTicket = buildInitParams(appKey, token);
String synthParams = buildSynthParams(ttsOptsFromH5);
Log.e(UI_LOG, "asyncPlayStreamInputTts begin textLen=" + text.length() + " sr=" + mPlaybackSampleRate);
int ret = getOrCreateTts().asyncPlayStreamInputTts(
new INativeStreamInputTtsCallback() {
@Override
public void onStreamInputTtsEventCallback(
INativeStreamInputTtsCallback.StreamInputTtsEvent event,
String task_id,
String session_id,
int ret_code,
String error_msg,
String timestamp,
String all_response) {
handleTtsEvent(event, ret_code, error_msg);
}
@Override
public void onStreamInputTtsDataCallback(byte[] data) {
writePcm(data);
}
@Override
public void onStreamInputTtsLogTrackCallback(Constants.LogLevel level, String log) {
if (level != null && log != null) {
Log.d(TAG, "[nui] " + log);
}
}
},
initTicket,
synthParams,
text,
"",
Constants.LogLevel.toInt(Constants.LogLevel.LOG_LEVEL_VERBOSE),
false);
Log.e(UI_LOG, "asyncPlayStreamInputTts returned ret=" + ret + " (SUCCESS=" + Constants.NuiResultCode.SUCCESS + ")");
if (ret != Constants.NuiResultCode.SUCCESS) {
failAndClear("TTS 启动失败,错误码: " + ret);
}
} catch (Throwable t) {
Log.e(UI_LOG, "playAsync throwable", t);
failAndClear(t.getMessage() != null ? t.getMessage() : "TTS 异常");
}
}
/**
* Cordova 回调须在 UI 线程触发,否则在部分机型上由 NUI 工作线程直接 success/error 会导致 WebView 闪退或卡死。
*/
private void runOnUi(Runnable r) {
if (activity == null) {
return;
}
if (activity.isFinishing()) {
return;
}
if (android.os.Build.VERSION.SDK_INT >= 17 && activity.isDestroyed()) {
return;
}
activity.runOnUiThread(r);
}
/**
* 取消云端合成并停止本地播放(对应 FirstPlugin {@code type=34})。
* <p>若存在进行中的 {@link #mPlayCallback},会先对其 {@code success({event:"cancelled"})},再响应本次 {@code callbackContext}。</p>
* <p>{@code callbackContext == null} 时仅停止播报、不向 Cordova 回传本次「停止操作」的 success(例如语音识别开始前打断播报,见 {@link #stopPlaybackSilentlyBlocking})。</p>
*
* @param callbackContext 可为 null;非 null 时最终 {@code success("ok")}
*/
public synchronized void cancel(CallbackContext callbackContext) {
mPlaying = false;
stopNativeAndReleaseAudio();
CallbackContext playCb = mPlayCallback;
mPlayCallback = null;
if (playCb != null) {
runOnUi(() -> {
try {
playCb.success(new JSONObject().put("event", "cancelled"));
} catch (JSONException e) {
playCb.success("cancelled");
}
});
}
if (callbackContext != null) {
runOnUi(() -> callbackContext.success("ok"));
}
}
/**
* 停止播报(内部为 {@link #cancel}(null)),用于与语音识别等场景衔接:不依赖 H5 发 type=34。
* <p>与 {@link #postCancelAckToJs(CallbackContext)} 的区别:不向「本次调用」回传 Cordova success;
* 非主线程调用时会 <b>阻塞等待</b> 最多 3 秒直到主线程执行完 {@link #cancel},便于紧接启动 ASR。</p>
*/
public void stopPlaybackSilentlyBlocking() {
if (activity == null) {
try {
cancel(null);
} catch (Throwable t) {
Log.e(UI_LOG, "stopPlaybackSilentlyBlocking no-activity", t);
}
return;
}
if (android.os.Build.VERSION.SDK_INT >= 17 && activity.isDestroyed()) {
return;
}
if (activity.isFinishing()) {
return;
}
if (Looper.myLooper() == Looper.getMainLooper()) {
try {
cancel(null);
} catch (Throwable t) {
Log.e(UI_LOG, "stopPlaybackSilentlyBlocking main", t);
}
return;
}
final CountDownLatch latch = new CountDownLatch(1);
activity.runOnUiThread(() -> {
try {
cancel(null);
} catch (Throwable t) {
Log.e(UI_LOG, "stopPlaybackSilentlyBlocking ui", t);
} finally {
latch.countDown();
}
});
try {
latch.await(3, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
/**
* 处理 NUI 合成生命周期事件;在部分机型上由 native 线程回调,内部与 {@link #writePcm} 使用同一把监视器锁。
*/
private void handleTtsEvent(INativeStreamInputTtsCallback.StreamInputTtsEvent event, int ret_code, String error_msg) {
Log.e(UI_LOG, "onStreamInputTtsEvent event=" + String.valueOf(event) + " ret_code=" + ret_code + " msg=" + error_msg);
synchronized (this) {
if (!mPlaying) {
Log.w(UI_LOG, "handleTtsEvent ignored (mPlaying=false) event=" + event);
return;
}
if (event == INativeStreamInputTtsCallback.StreamInputTtsEvent.STREAM_INPUT_TTS_EVENT_SYNTHESIS_STARTED) {
Log.e(UI_LOG, "SYNTHESIS_STARTED -> start AudioTrack");
Log.i(TAG, "SYNTHESIS_STARTED");
startAudioTrack();
} else if (event == INativeStreamInputTtsCallback.StreamInputTtsEvent.STREAM_INPUT_TTS_EVENT_SYNTHESIS_COMPLETE) {
Log.e(UI_LOG, "SYNTHESIS_COMPLETE -> notify JS");
Log.i(TAG, "SYNTHESIS_COMPLETE");
// 合成结束:勿再调用 cancelStreamInputTts,仅结束本地播放(与官方 Demo 中 isFinishSend 语义一致)
releaseAudioTrackOnly();
successAndClear("complete");
} else if (event == INativeStreamInputTtsCallback.StreamInputTtsEvent.STREAM_INPUT_TTS_EVENT_TASK_FAILED) {
Log.e(UI_LOG, "TASK_FAILED code=" + ret_code + " msg=" + error_msg);
Log.e(TAG, "TASK_FAILED code=" + ret_code + " msg=" + error_msg);
releaseAudioTrackOnly();
failAndClear(TextUtils.isEmpty(error_msg) ? ("合成失败 " + ret_code) : error_msg);
} else {
Log.w(UI_LOG, "unhandled TTS event (SDK may still be OK): " + event);
}
}
}
/**
* 在收到 STARTED 事件后,按 {@link #mPlaybackSampleRate} 创建 STREAM 模式 {@link AudioTrack} 并开始播放。
*/
private void startAudioTrack() {
synchronized (this) {
releaseAudioTrackOnly();
int minBuf = AudioTrack.getMinBufferSize(
mPlaybackSampleRate,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT);
if (minBuf <= 0) {
Log.e(UI_LOG, "getMinBufferSize failed: " + minBuf);
failAndClear("播放器缓冲计算失败");
return;
}
try {
mAudioTrack = new AudioTrack(
AudioManager.STREAM_MUSIC,
mPlaybackSampleRate,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
minBuf * 4,
AudioTrack.MODE_STREAM);
mAudioTrack.play();
} catch (Exception e) {
Log.e(UI_LOG, "AudioTrack create/play", e);
failAndClear("AudioTrack 异常");
}
}
}
/**
* 将 SDK 回调的 PCM 块写入 {@link AudioTrack};在 track 未就绪时直接丢弃(理论上极少发生)。
*/
private void writePcm(byte[] data) {
if (data == null || data.length == 0) {
return;
}
final AudioTrack track;
synchronized (this) {
track = mAudioTrack;
}
if (track == null) {
return;
}
int offset = 0;
try {
while (offset < data.length) {
int w = track.write(data, offset, data.length - offset);
if (w <= 0) {
break;
}
offset += w;
}
} catch (Exception e) {
Log.e(TAG, "writePcm", e);
}
}
/** 调用 {@link NativeNui#cancelStreamInputTts} 并释放本地播放器,不通知 JS */
private void stopNativeAndReleaseAudio() {
try {
if (mTts != null) {
mTts.cancelStreamInputTts();
}
} catch (Throwable ignored) {
}
releaseAudioTrackOnly();
}
/** 仅释放 {@link AudioTrack},不调用 NUI cancel */
private void releaseAudioTrackOnly() {
synchronized (this) {
if (mAudioTrack != null) {
try {
mAudioTrack.stop();
} catch (Exception ignored) {
}
try {
mAudioTrack.release();
} catch (Exception ignored) {
}
mAudioTrack = null;
}
}
}
/** 向 H5 返回成功 JSON 并清空回调引用 */
private synchronized void successAndClear(String event) {
mPlaying = false;
CallbackContext cb = mPlayCallback;
mPlayCallback = null;
if (cb == null) {
return;
}
runOnUi(() -> {
try {
JSONObject o = new JSONObject();
o.put("event", event);
cb.success(o);
} catch (JSONException e) {
cb.success(event);
}
});
}
/** 向 H5 返回错误并清空回调引用 */
private synchronized void failAndClear(String msg) {
mPlaying = false;
CallbackContext cb = mPlayCallback;
mPlayCallback = null;
if (cb != null) {
final String err = msg != null ? msg : "TTS 失败";
runOnUi(() -> cb.error(err));
}
}
/**
* 构建 NUI 初始化鉴权 JSON(与 ASR 侧网关、字段风格保持一致)。
*
* @param appKey 阿里云项目 appkey
* @param token 临时 token
*/
private String buildInitParams(String appKey, String token) throws JSONException {
JSONObject params = new JSONObject();
params.put("app_key", appKey);
params.put("token", token);
params.put("device_id", android.provider.Settings.Secure.getString(
activity.getContentResolver(),
android.provider.Settings.Secure.ANDROID_ID));
params.put("url", "wss://nls-gateway.cn-shanghai.aliyuncs.com:443/ws/v1");
params.put("service_mode", Constants.ModeFullCloud);
params.put("save_wav", "false");
return params.toString();
}
/**
* 构建传给 {@link NativeNui#asyncPlayStreamInputTts} 的「用户参数」JSON 字符串。
* <p>优先采用前端 {@code args[4]} 解析出的字段;未给出的项使用本方法内与前端默认文件一致的兜底值。</p>
*
* @param fromH5 可为 null;非 null 时按 key 覆盖默认(voice、volume、sample_rate 等)
*/
private String buildSynthParams(JSONObject fromH5) throws JSONException {
JSONObject root = new JSONObject();
root.put("voice", optString(fromH5, "voice", "zhixiaoxia"));
root.put("format", optString(fromH5, "format", "pcm"));
boolean enableDecoder = optBoolean(fromH5, "enable_audio_decoder", false);
root.put("enable_audio_decoder", enableDecoder);
root.put("sample_rate", optInt(fromH5, "sample_rate", DEFAULT_SAMPLE_RATE));
root.put("volume", optInt(fromH5, "volume", 50));
root.put("speech_rate", optInt(fromH5, "speech_rate", 0));
root.put("pitch_rate", optInt(fromH5, "pitch_rate", 0));
root.put("enable_subtitle", optBoolean(fromH5, "enable_subtitle", false));
return root.toString();
}
/**
* 解析前端下发的 {@code args[4]} JSON;非法或空串返回 {@code null},表示完全使用默认合成参数。
*/
private static JSONObject parseTtsOptionsJson(String json) {
if (TextUtils.isEmpty(json)) {
return null;
}
try {
return new JSONObject(json.trim());
} catch (JSONException e) {
Log.w(TAG, "ignore invalid ttsOptions json: " + json, e);
return null;
}
}
private static int resolveSampleRate(JSONObject fromH5) {
int sr = optInt(fromH5, "sample_rate", DEFAULT_SAMPLE_RATE);
if (sr <= 0) {
return DEFAULT_SAMPLE_RATE;
}
return sr;
}
private static String optString(JSONObject o, String key, String def) {
if (o == null || !o.has(key)) {
return def;
}
String v = o.optString(key, "");
return TextUtils.isEmpty(v) ? def : v;
}
private static int optInt(JSONObject o, String key, int def) {
if (o == null || !o.has(key)) {
return def;
}
return o.optInt(key, def);
}
private static boolean optBoolean(JSONObject o, String key, boolean def) {
if (o == null || !o.has(key)) {
return def;
}
return o.optBoolean(key, def);
}
/**
* 安全读取 Cordova {@link JSONArray} 元素,避免 null / 越界。
*
* @param args Cordova 参数数组
* @param index 下标(与 {@code FirstPlugin} 传入顺序一致)
*/
private static String optArg(JSONArray args, int index) {
if (args == null || index >= args.length()) {
return "";
}
Object o = args.opt(index);
if (o == null || o == JSONObject.NULL) {
return "";
}
return String.valueOf(o);
}
}
7.4 Vue 与其它
- 消息朗读按钮:
backup/frontend/ChatTtsMessageButton.vue - 与 ASR 共用的 Token 后端:
backup/java/AiAsrController.java(正文亦见 ASR 文档 7.1)