阿里云 NUI 流式语音播报(TTS)集成说明

lishihuan大约 13 分钟

阿里云 NUI 流式语音播报(TTS)集成说明

本文汇总 LegacyApp 中与「把文字读出来」相关的链路:流式输入语音合成(Stream Input TTS)、与 ASR 的互斥、前端组件与排障。


一、整体架构

前端 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 注释一致(索引从插件实际解析为准,常见为):

字段含义
arg0app_key
arg1token
arg2待朗读纯文本
arg3可选,合成参数 JSON 字符串(音色 voicesample_rate 等);空则走原生侧默认

H5 封装:LegacyApp/src/plugin/cordovaPlugin.jsstartStreamInputTts / stopStreamInputTts

3. 源码位置(云检工程)

  • .../FirstPlugin.java30/31 ASR,33/34 TTS)
  • .../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(如 voiceformatsample_ratevolume 等),在调用 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-idaccess-key-secretapp-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)