YYPOST群发软件 发表于 前天 07:48

语音合成核心模块AIWROK 单文件版

语音合成核心模块AIWROK 单文件版

/**
* ============================================================================
*AIWROK TTS CORE - 语音合成核心模块(AIWROK 单文件版)
* ============================================================================
*/

// ==================== 导入 Java 类(AIWROK 内置) ====================

importClass(Packages.java.lang.Thread);
importClass(Packages.java.io.File);
importClass(Packages.java.io.FileOutputStream);
importClass(Packages.java.io.BufferedOutputStream);
importClass(Packages.java.security.MessageDigest);
// 注意:Array 和 Byte 不做 importClass,会和 JS 全局对象冲突
// 代码里直接用完整类名:java.lang.reflect.Array / java.lang.Byte
importClass(Packages.okhttp3.OkHttpClient);
importClass(Packages.okhttp3.Request);
importClass(Packages.okhttp3.RequestBody);
importClass(Packages.okhttp3.WebSocketListener);
importClass(Packages.okio.ByteString);
importClass(Packages.okio.Buffer);
importClass(Packages.java.util.concurrent.TimeUnit);

// ==================== 音色列表 ====================

var EDGE_VOICES = [
    { id: "zh-CN-XiaoxiaoNeural",   name: "晓晓 (甜美女声)" },
    { id: "zh-CN-XiaoyiNeural",   name: "晓伊 (软萌少女)" },
    { id: "zh-CN-XiaohanNeural",    name: "晓涵 (知性御姐)" },
    { id: "zh-CN-XiaomengNeural",   name: "晓梦 (温柔治愈)" },
    { id: "zh-CN-XiaomoNeural",   name: "晓墨 (成熟稳重)" },
    { id: "zh-CN-XiaoxuanNeural",   name: "晓萱 (活泼可爱)" },
    { id: "zh-CN-XiaoyanNeural",    name: "晓颜 (新闻女主播)" },
    { id: "zh-CN-XiaochenNeural",   name: "晓辰 (温暖女声)" },
    { id: "zh-CN-XiaoshuangNeural", name: "晓双 (儿童女声)" },
    { id: "zh-CN-XiaoruiNeural",    name: "晓睿 (老年女声)" },
    { id: "zh-CN-YunxiNeural",      name: "云希 (年轻男声)" },
    { id: "zh-CN-YunjianNeural",    name: "云健 (成熟男声)" },
    { id: "zh-CN-YunyangNeural",    name: "云扬 (新闻播音)" },
    { id: "zh-CN-YunxiaNeural",   name: "云夏 (少年男声)" },
    { id: "zh-CN-YunyeNeural",      name: "云野 (故事讲述)" },
    { id: "zh-CN-YunfengNeural",    name: "云枫 (老年男声)" },
    { id: "zh-TW-HsiaoChenNeural",name: "曉臻 (台湾女声)" },
    { id: "zh-TW-YunJheNeural",   name: "雲哲 (台湾男声)" },
    { id: "zh-HK-HiuMaanNeural",    name: "曉曼 (粤语女声)" },
    { id: "zh-HK-WanLungNeural",    name: "雲龍 (粤语男声)" }
];

var DOUBAO_VOICES = [
    { id: "zh_female_taozi_conversation_v4_wvae_bigtts",name: "桃子 (温柔女声)" },
    { id: "zh_male_wennuanahu_radar_v4_wvae_bigtts",      name: "温暖 (男声)" },
    { id: "zh_female_qingxinahu_radar_v4_wvae_bigtts",    name: "清新 (女声)" },
    { id: "zh_male_zhiyu_radar_v5_wvae_bigtts",         name: "治愈 (男声)" },
    { id: "zh_female_wanwanxiaohe_moon_bigtts",         name: "弯弯小贺 (女童)" },
    { id: "zh_female_linjiaxiaoyu_moon_bigtts",         name: "邻家小语 (少女)" }
];

var DEFAULT_EDGE_VOICE = "zh-CN-XiaoxiaoNeural";
var DEFAULT_DOUBAO_VOICE = "zh_female_taozi_conversation_v4_wvae_bigtts";

// ==================== 通用工具函数 ====================

/**
* 生成 UUID v4(Rhino ES5 兼容)
*/
function uuid() {
    var chars = '0123456789abcdef';
    var s = [];
    for (var i = 0; i < 36; i++) s = chars;
    s = '4';
    s = chars[(parseInt(s, 16) & 0x3) | 0x8];
    s = s = s = s = '-';
    return s.join('');
}

/** XML 转义 */
function escapeXml(str) {
    return String(str)
      .replace(/&/g, '&')
      .replace(/</g, '<')
      .replace(/>/g, '>')
      .replace(/"/g, '"')
      .replace(/'/g, ''');
}

/**
* 把 JS 的 ArrayBuffer / Uint8Array / number[] 转成 Java byte[]
* Rhino 里 ByteString.of() 和 Java IO 都需要原生 byte[]
*/
function toJavaBytes(data) {
    var view;
    if (data instanceof ArrayBuffer) {
      view = new Uint8Array(data);
    } else if (data instanceof Uint8Array) {
      view = data;
    } else if (data && typeof data.length === 'number') {
      view = new Uint8Array(data.length);
      for (var i2 = 0; i2 < data.length; i2++) view = data;
    } else {
      return java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, 0);
    }
    var jBytes = java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, view.length);
    for (var k = 0; k < view.length; k++) {
      var v = view & 0xff;
      jBytes = (v > 127) ? (v - 256) : v;
    }
    return jBytes;
}

/**
* 把 Uint8Array / number[] / ArrayBuffer 写入文件
* @param {string} path
* @param {Uint8Array|number[]|ArrayBuffer} data
* @return {boolean}
*/
function writeBinaryFile(path, data) {
    try {
      var jBytes = toJavaBytes(data);
      if (!jBytes || jBytes.length === 0) return false;

      // 确保父目录存在
      var f = new java.io.File(path);
      var parent = f.getParentFile();
      if (parent && !parent.exists()) parent.mkdirs();

      var fos = new java.io.FileOutputStream(f);
      var out = new java.io.BufferedOutputStream(fos);
      out.write(jBytes);
      out.flush();
      out.close();
      fos.close();
      return true;
    } catch (e) {
      print.log(" 失败: " + e);
      return false;
    }
}

/**
* 简易 sleep(不抛异常)
*/
function sleep(ms) {
    try { java.lang.Thread.sleep(ms); } catch (e) {}
}

/**
* 字符串左侧补齐(Rhino 不支持 padStart)
*/
function padStart(s, len, ch) {
    s = String(s);
    while (s.length < len) s = ch + s;
    return s;
}

// ==================== Protobuf 编解码(豆包用) ====================

function encodeVarint(v) {
    var out = [];
    v = v >>> 0;
    while (v > 0x7f) {
      out.push((v & 0x7f) | 0x80);
      v = v >>> 7;
    }
    out.push(v & 0x7f);
    return out;
}

function encodeBytesField(fieldNum, data) {
    var bytes;
    if (typeof data === 'string') {
      // UTF-8 编码(不能用 unescape,中文会乱码)
      var u8 = unescape(encodeURIComponent(data));
      bytes = [];
      for (var i = 0; i < u8.length; i++) bytes.push(u8.charCodeAt(i) & 0xff);
    } else if (data && data.length !== undefined) {
      bytes = [];
      for (var j = 0; j < data.length; j++) bytes.push(data & 0xff);
    } else {
      bytes = [];
    }
    var tag = (fieldNum << 3) | 2;
    return encodeVarint(tag).concat(encodeVarint(bytes.length), bytes);
}

/**
* 构造完整的 VoiceGenie 请求帧,返回 ArrayBuffer
*/
function buildRequestFrame(event, payload, sessionId) {
    var frame = [];
    frame = frame.concat(encodeBytesField(2, 'GOqQpfo1fO7slHv8'));
    frame = frame.concat(encodeBytesField(3, 'VoiceGenie'));
    frame = frame.concat(encodeBytesField(5, event));
    frame = frame.concat(encodeBytesField(6, payload));
    if (sessionId) {
      frame = frame.concat(encodeBytesField(8, sessionId));
    }
    // number[] → ArrayBuffer
    var ab = new ArrayBuffer(frame.length);
    var v = new Uint8Array(ab);
    for (var i = 0; i < frame.length; i++) v = frame;
    return ab;
}

/**
* 解析服务器返回的 protobuf 帧
*/
function parseResponseFrame(buf) {
    var a = (buf instanceof Uint8Array) ? buf : new Uint8Array(buf);
    var fields = [];
    var i = 0;

    function readVarint() {
      var v = 0, s = 0;
      while (i < a.length) {
            var b = a;
            v |= (b & 0x7f) << s;
            if (!(b & 0x80)) return v >>> 0;
            s += 7;
            if (s > 35) break;
      }
      return v >>> 0;
    }

    while (i < a.length) {
      var tag;
      try { tag = readVarint(); } catch (e) { break; }
      if (tag === 0) break;
      var fieldNum = tag >> 3;
      var wireType = tag & 7;
      var field = { fieldNum: fieldNum, wireType: wireType };

      if (wireType === 0) {
            field.value = readVarint();
      } else if (wireType === 2) {
            var len = readVarint();
            // 兼容 Rhino Uint8Array.subarray/slice
            var sliceArr = [];
            for (var kk = 0; kk < len; kk++) sliceArr.push(a);
            field.data = sliceArr; // number[]
            i += len;
            if (len <= 8000) {
                try {
                  // 转 Java byte[] 再 UTF-8 解码(中文安全)
                  var jb = java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, len);
                  for (var m = 0; m < len; m++) {
                        var bv = a & 0xff;
                        jb = (bv > 127) ? (bv - 256) : bv;
                  }
                  field.asString = String(new java.lang.String(jb, "UTF-8"));
                } catch (e) {}
            }
      } else if (wireType === 5) {
            i += 4;
      } else if (wireType === 1) {
            i += 8;
      } else {
            break;
      }
      fields.push(field);
    }

    var result = { eventType: null, payload: null, audioData: null, seq: -1, sessionId: null };
    for (var f = 0; f < fields.length; f++) {
      var fld = fields;
      if (fld.fieldNum === 1) result.sessionId = fld.asString || null;
      if (fld.fieldNum === 4) result.eventType = fld.asString || null;
      if (fld.fieldNum === 7) result.payload = fld.data || null;
      if (fld.fieldNum === 8) result.audioData = fld.data; // number[]
      if (fld.fieldNum === 9) result.seq = fld.value;
    }
    return result;
}

// ==================== 豆包 Cookie / UID 工具 ====================

function cookieObjToString(cookies) {
    if (typeof cookies === 'string') return cookies;
    if (cookies && typeof cookies === 'object') {
      // ES5 没有 Object.entries
      var parts = [];
      for (var k in cookies) {
            if (cookies.hasOwnProperty(k)) {
                parts.push(k + '=' + cookies);
            }
      }
      return parts.join('; ');
    }
    return '';
}

function getCookieFromStr(cookieStr, name) {
    if (!cookieStr) return null;
    var m = cookieStr.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]+)'));
    return m ? m : null;
}

function getUid(cookieStr) {
    var uidCookie = getCookieFromStr(cookieStr, 'uid_tt') || getCookieFromStr(cookieStr, 'uid');
    if (uidCookie) {
      try {
            // 用 Java BASE64 解码(Rhino 没有 atob)
            var decoded = String(java.util.Base64.getDecoder().decode(uidCookie));
            if (/^\d+$/.test(decoded)) return decoded;
      } catch (e) {}
      if (/^\d+$/.test(uidCookie)) return uidCookie;
    }
    return String(Date.now() % 1000000000000);
}

function buildDoubaoWsUrl(cookieStr) {
    var deviceId = getCookieFromStr(cookieStr, 'device_id') ||
                   getCookieFromStr(cookieStr, 's_v_web_id') ||
                   (String(Date.now()) + padStart(String(Math.floor(Math.random() * 1e6)), 6, '0'));
    var webId = getCookieFromStr(cookieStr, 'web_id') ||
                getCookieFromStr(cookieStr, 's_v_web_id') ||
                deviceId;

    var params = [
      'api_app_key=GOqQpfo1fO7slHv8',
      'namespace=VoiceGenie',
      'version_code=20800',
      'language=zh',
      'device_platform=web',
      'pkg_type=release_version',
      'pc_version=3.30.5',
      'region=CN',
      'sys_region=CN',
      'samantha_web=1',
      'use-olympus-account=1',
      'doubao_device_platform=web',
      'aid=497858',
      'real_aid=497858',
      'device_id=' + deviceId,
      'doubao_pc_version=3.30.5',
      'web_id=' + webId,
      'tea_uuid=' + webId,
      'web_platform=browser',
      'web_tab_id=' + uuid()
    ];
    return 'wss://frontier-audio-web-ws.doubao.com/api/v2/sami/voicegenie?' + params.join('&');
}

function buildDoubaoSessionPayload(opts) {
    var replyUniqueKey = uuid();
    var queryLocalMessageId = uuid();
    var uid = getUid(opts.cookieStr);

    var payload = {
      business: 1,
      conversation_id: opts.conversationId,
      chat: {
            bot_id: '7338286299411103781',
            conversation_id: opts.conversationId,
            question_id: opts.questionId,
            message_id: opts.messageId,
            uid: uid,
            new_conversation: false,
            chat_next: '1',
            enable_toast_reading: 'true',
            extra: { chat_next: '1', reply_unique_key: replyUniqueKey, query_local_message_id: queryLocalMessageId },
            reply_unique_key: replyUniqueKey,
            section_id: opts.sectionId,
            local_message_id: uuid(),
            query_local_message_id: queryLocalMessageId
      },
      request_type: 4,
      enable_text_reading: true,
      interrupt_type: 0,
      query_mode: 2,
      tts: {
            speaker: opts.speaker,
            audio_config: opts.audioConfig,
            extra: {
                chat_next: '1',
                query_local_message_id: queryLocalMessageId,
                post_process: { pitch: opts.pitch || 0, speech_rate: opts.speechRate || 1.0 }
            }
      },
      extra: {
            enable_toast_reading: 'true',
            chat_next: '1',
            uid: uid,
            section_id: opts.sectionId,
            reply_unique_key: replyUniqueKey,
            query_local_message_id: queryLocalMessageId,
            enable_text_simple_report: true,
            enable_section_report: true,
            enable_card_simple_report: true,
            enable_image_asr_ctx: true,
            disable_markdown_filter: true,
            enable_asr_audio_context: false,
            is_server_error_retry: false
      }
    };
    return JSON.stringify(payload);
}

// ==================== Edge TTS 核心(1:1 对应 Python rany2/edge-tts 官方算法) ====================

var EDGE_TTS_WS_URL = 'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1';
var EDGE_TTS_TOKEN = '6A5AA1D4EAFF4E9FB37E23D68491D6F4';

// 完全对齐官方 constants.py CHROMIUM_FULL_VERSION
var EDGE_CHROMIUM_FULL_VERSION = '143.0.3650.75';
var EDGE_CHROMIUM_MAJOR_VERSION= '143';
var EDGE_TTS_GEC_VERSION = '1-' + EDGE_CHROMIUM_FULL_VERSION;
var EDGE_WIN_EPOCH_SECONDS = 11644473600;

/**
* 全局 DRM 时钟偏差(单位秒,正数=服务器比本地快)
* 官方:DRM.clock_skew_seconds,403失败时从服务器 Date 头校准后自动重试
*/
var EDGE_CLOCK_SKEW_SECONDS = 0.0;

/**
* 解析 RFC 2616 GMT 日期(RFC_1123_DATE_TIME 格式)
* 优先使用 Java DateTimeFormatter,避免自己正则解析出错。
*
* @param {string|null|undefined} dateStr 例如 "Sat, 08 Aug 2026 07:20:58 GMT"
* @returns {number|null} UTC 秒数(float),失败返回 null
*/
function parseRfc2616Date(dateStr) {
    if (!dateStr) return null;
    var s = String(dateStr).trim();
    try {
      // === 方案1:Java 8+ java.time(安卓 7.0+ 支持,AIWROK 运行环境一般都有) ===
      var formatter = java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
      var temporal = formatter.parse(s);
      var zdt = java.time.ZonedDateTime.from(temporal);
      var epochSec = zdt.toEpochSecond();// long
      // 注意:RFC 2616 日期不提供毫秒,只到秒
      return parseFloat(String(epochSec));
    } catch (e1) {
      try {
            // === 方案2:老安卓 SimpleDateFormat ===
            var sdf = new java.text.SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", java.util.Locale.US);
            sdf.setTimeZone(java.util.TimeZone.getTimeZone("GMT"));
            var d = sdf.parse(s);
            return parseFloat(String(d.getTime())) / 1000.0;
      } catch (e2) {
            // === 方案3:最后兜底 Rhino JS 原生 Date.parse ===
            try {
                var fb = Date.parse(s);
                if (!isNaN(fb)) return fb / 1000.0;
            } catch (e3) {}
            return null;
      }
    }
}

/**
* 生成 MUID(32位随机大写HEX)
* 官方 DRM.generate_muid → headers_with_muid 塞到 Cookie 里
*/
function generateEdgeMuid() {
    var hex = '0123456789ABCDEF';
    var sb = '';
    for (var i = 0; i < 32; i++) {
      sb += hex.charAt(Math.floor(Math.random() * 16));
    }
    return sb;
}

/**
* 生成 Sec-MS-GEC 签名(严格对齐官方 drm.py DRM.generate_sec_ms_gec + clock_skew)
*
* 官方 Python 流程:
*   ticks = dt.now(tz.utc).timestamp() + DRM.clock_skew_seconds
*   ticks += WIN_EPOCH
*   ticks -= ticks % 300
*   ticks *= 1e7
*   str = f"{ticks:.0f}{TOKEN}"
*   return sha256(str.encode("ascii")).hexdigest().upper()
*/
var _EDGE_GEC_LOG_ONCE = false;// 只打印一次避免刷屏
function generateSecMsGec() {
    try {
      var unixSecFloat = (java.lang.System.currentTimeMillis() / 1000.0) + EDGE_CLOCK_SKEW_SECONDS;
      var ticks = unixSecFloat + EDGE_WIN_EPOCH_SECONDS;
      // 对负数取模对齐 Python:Python % 永远非负,Rhino/JS 可能返回负数 → 手动归一化
      var mod = ticks % 300;
      if (mod < 0) mod += 300;
      ticks = ticks - mod;
      ticks = ticks * 10000000.0;
      var ticksLong = java.lang.Math.round(ticks);
      var ticksStr = String(ticksLong);
      var src = ticksStr + EDGE_TTS_TOKEN;
      var md = MessageDigest.getInstance('SHA-256');
      var srcBytes = new java.lang.String(src).getBytes('US-ASCII');
      var hash = md.digest(srcBytes);
      var sb = new java.lang.StringBuilder(hash.length * 2);
      for (var i = 0; i < hash.length; i++) {
            var h = hash & 0xff;
            // ============================================================
            // 修复 Rhino 平台大坑:
            // java.lang.Integer.toHexString() 返回的是 java.lang.String(不是 JS String!)
            // → java.lang.String 的 .length 是 METHOD/FUNCTION,不是 JS 数值属性 length
            // → 旧代码:if (s.length < 2) → NaN < 2 → 永远 FALSE!
            // → 结果:只要 byte < 0x10(高位为 0),补零就被跳过!
            // → 每少一个零就错位 4 bit,导致整个 GEC 和官方差好几个字符 → 微软 403
            // ============================================================
            var hexJava = java.lang.Integer.toHexString(h);
            // ① 强转为 JS 原生 String(此时 .length 就是数值属性)
            var hex = String(hexJava);
            // ② 也可以用 Java 方法 .length() 双重保险(任一方式都可以)
            if (hex.length < 2) sb.append('0');
            sb.append(hex);
      }
      var gec = String(sb.toString()).toUpperCase();

      // ===== DEBUG:每次生成 GEC 时,只在第一次 / 403 前后打印关键参数 =====
      // 用户可复制下面这行到 Python gec_verify.py 做 1:1 对撞
      if (!_EDGE_GEC_LOG_ONCE || EDGE_CLOCK_SKEW_SECONDS !== 0.0) {
            var dbgLine = ' ticks_input=' + java.lang.String.format("%.6f", unixSecFloat)
                        + ' skew=' + EDGE_CLOCK_SKEW_SECONDS.toFixed(4)
                        + ' mod300=' + mod.toFixed(6)
                        + ' ticksLong=' + ticksLong
                        + ' ticksStr_len=' + ticksStr.length
                        + ' src_preview=' + ticksStr.substring(0, Math.min(12, ticksStr.length)) + '...'
                        + ' gec=' + gec;
            print.log(dbgLine);
            _EDGE_GEC_LOG_ONCE = true;
      }

      return gec;
    } catch (e) {
      print.log(' 生成 Sec-MS-GEC 失败: ' + e);
      return null;
    }
}

function buildSSML(text, voice, rate, volume, pitch) {
    return "<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' " +
         "xmlns:mstts='https://www.w3.org/2001/mstts' xml:lang='zh-CN'>" +
         "<voice name='" + voice + "'>" +
         "<prosody rate='" + rate + "' volume='" + volume + "' pitch='" + pitch + "'>" +
         escapeXml(text) +
         "</prosody></voice></speak>";
}

/**
* Edge TTS 单次握手尝试(内部函数,供 generateEdge 做自动重试)
* @returns {Object} { ok, code, message, serverDate, output }
*/
function tryEdgeOnce(sessionOpts) {
    var text = sessionOpts.text;
    var voice = sessionOpts.voice;
    var rate = sessionOpts.rate;
    var volume = sessionOpts.volume;
    var pitch = sessionOpts.pitch;
    var output = sessionOpts.output;

    var requestId = uuid().replace(/-/g, '');
    var gec = generateSecMsGec();
    var wsUrl = EDGE_TTS_WS_URL
            + '?TrustedClientToken=' + EDGE_TTS_TOKEN
            + '&ConnectionId=' + requestId;
    if (gec) {
      wsUrl += '&Sec-MS-GEC=' + gec
            +'&Sec-MS-GEC-Version=' + EDGE_TTS_GEC_VERSION;
    }

    var state = {
      connected: false,
      closed: false,
      err: null,
      httpCode: 0,
      serverDate: null,
      audioChunks: [],
      totalLen: 0,
      _loggedFirstFrame: false
    };

    var listener = new WebSocketListener({
      onOpen: function (ws, response) {
            state.connected = true;
            var code = 0;
            try { code = response ? response.code() : 0; } catch(e){}
            print.log(' ✅ onOpen 已触发,HTTP code=' + code + ' (期望 101 Switching Protocols)');
            try {
                var xts = (function isoZ() {
                  var d = new Date();
                  var pad = function (n, w) { n = String(n); while (n.length < w) n = '0' + n; return n; };
                  return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1, 2) + '-' + pad(d.getUTCDate(), 2)
                         + 'T' + pad(d.getUTCHours(), 2) + ':' + pad(d.getUTCMinutes(), 2) + ':' + pad(d.getUTCSeconds(), 2) + '.000Z';
                })();

                var configMsg = JSON.stringify({
                  context: {
                        synthesis: {
                            audio: {
                              metadataoptions: { sentenceBoundaryEnabled: false, wordBoundaryEnabled: false },
                              outputFormat: 'audio-24khz-48kbitrate-mono-mp3'
                            }
                        }
                  }
                });
                ws.send('X-Timestamp:' + xts + '\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n' + configMsg);

                var ssml = buildSSML(text, voice, rate, volume, pitch);
                ws.send('X-RequestId:' + requestId + '\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:' + xts + '\r\nPath:ssml\r\n\r\n' + ssml);
            } catch (e) {
                state.err = '发送失败: ' + e;
                print.log(' onOpen 内部发送失败: ' + e);
            }
      },

      onMessage: function (ws, message) {
            try {
                // ========== 1. 兼容 Rhino/OkHttp 多种消息类型 ==========
                if (message === null || message === undefined) return;
                // ----------- 文本消息:静默忽略(TEXT 帧是 turn.start / response / audio.metadata / turn.end 等控制消息) -----------
                var isJavaString = false;
                try {
                  if (typeof message === 'string') {
                        isJavaString = true;
                  } else if (message && message.getClass) {
                        var cls = message.getClass();
                        if (cls === java.lang.String.class) isJavaString = true;
                        // 再次兜底:如果是 okhttp 传过来的 String(比如某些版本会用
                        // java.lang.CharSequence impl),调用 toString() 之后再检查
                        // 是否全是可打印字符也可以 → 用 instanceof 更稳
                        else if (java.lang.String.class.isInstance(message)) isJavaString = true;
                  }
                } catch (e_ignore) {}
                if (isJavaString) return;

                // 二进制消息:可能是 okhttp3 ByteString (有 toByteArray()),可能是原生 byte[],可能是 Buffer/ByteString 其它
                var jBytes = null;
                try {
                  if (typeof message.toByteArray === 'function') {
                        jBytes = message.toByteArray();
                  } else if (typeof message.length === 'number') {
                        // 可能是 JS ArrayBuffer / Uint8Array? AIWROK Rhino 里 byte[] 也有 length 属性
                        var maybeLen = message.length;
                        if (maybeLen === 0 || (maybeLen > 0 && typeof message === 'number')) {
                            // JS Uint8Array 风格,转 Java byte[]
                            jBytes = java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, maybeLen);
                            for (var _k = 0; _k < maybeLen; _k++) {
                              var _bv = message & 0xff;
                              jBytes = (_bv > 127) ? (_bv - 256) : _bv;
                            }
                        } else if (maybeLen > 0 && message.getClass && message.getClass().isArray()) {
                            // 原生 Java byte[]
                            jBytes = message;
                        }
                  }
                } catch (convErr) {
                  // 转换失败通常是文本控制帧,静默忽略即可
                  return;
                }
                if (!jBytes) {
                  // 非二进制消息(文本/其他控制帧),静默忽略
                  return;
                }

                var len = jBytes.length;
                if (len < 2) {
                  print.log(' frame too small, len=' + len);
                  return;
                }
                // ========== 2. 按官方公式:header_length = 前 2 bytes big-endian ==========
                var headerLength = ((jBytes & 0xff) << 8) | (jBytes & 0xff);
                // 官方 get_headers_and_data: data (这里的 data 就是整个 jBytes)
                // 所以 header_length 必须 <= len - 2(后面至少留 2 字节给 start index)
                if (headerLength < 0 || (headerLength + 2) > len) {
                  print.log(' invalid: headerLength=' + headerLength + ' totalLen=' + len);
                  return;
                }
                var audioStart = headerLength + 2;   // 官方 1:1
                var audioLen = len - audioStart;   // 官方 1:1

                // ========== 3. 解析 header(官方:line.split(b":", 1),不 strip value) ==========
                var pathVal = null;
                var contentTypeVal = null;
                try {
                  // jBytes = header bytes
                  var headerArr = java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, headerLength);
                  for (var _hi = 0; _hi < headerLength; _hi++) headerArr = jBytes;
                  var headerStr = String(new java.lang.String(headerArr, 'US-ASCII'));
                  var lines = headerStr.split('\r\n');
                  for (var _li = 0; _li < lines.length; _li++) {
                        var ln = lines;
                        if (!ln) continue;
                        var colon = ln.indexOf(':');
                        if (colon === -1) continue;
                        var rawK = ln.substring(0, colon);
                        var rawV = ln.substring(colon + 1);
                        // Rhino String: 正常 key,也可能带 \x00\x80 前缀(如抓包中第一个 key = \x00\x80X-RequestId)
                        // 所以 trim() key 是安全的(去掉\x00这类控制符会更好),然后 toLowerCase
                        var k = rawK.replace(/[\x00-\x1f\x7f]/g, '').trim().toLowerCase();
                        var v = rawV.replace(/[\x00-\x1f\x7f]/g, '').trim().toLowerCase();
                        if (k === 'path') pathVal = v;
                        if (k === 'content-type') contentTypeVal = v;
                  }
                } catch (parseErr) {
                  print.log(' header parse error: ' + parseErr);
                  pathVal = null; contentTypeVal = null;
                }

                // 日志(不打每帧,只在首次 audio 成功和总计打,避免日志爆炸)
                if (!state._loggedFirstFrame) {
                  print.log(' first frame: total=' + len + ' headerLen=' + headerLength + ' audioLen=' + audioLen + ' Path=' + (pathVal||'<NULL>') + ' Content-Type=' + (contentTypeVal||'<NULL>'));
                }

                // ========== 4. 按官方判断:Path == 'audio' 且 (Content-Type == 'audio/mpeg' 或 NULL) ==========
                if (pathVal !== 'audio') {
                  return;// 非音频帧(例如 WebRTC 心跳等二进制帧)
                }
                if (contentTypeVal !== 'audio/mpeg') {
                  // Content-Type 为 NULL 且 data 为空 = 终止帧,直接跳过
                  if (contentTypeVal === null && audioLen === 0) return;
                  // 有 data 但没有 Content-Type = UnexpectedResponse(官方抛异常),这里直接记日志跳过
                  if (contentTypeVal === null) {
                        print.log(' WARN: Path=audio, no Content-Type but data exists, audioLen=' + audioLen);
                        return;
                  }
                  return;
                }
                if (audioLen <= 0) return;

                // ========== 5. 收集音频 ==========
                var chunk = new Array(audioLen);
                for (var kk2 = 0; kk2 < audioLen; kk2++) chunk = jBytes & 0xff;
                state.audioChunks.push(chunk);
                state.totalLen += audioLen;
                if (!state._loggedFirstFrame) {
                  state._loggedFirstFrame = true;
                  var first2 = chunk.toString(16) + (chunk < 16 ? '0' : '') + chunk.toString(16);
                  print.log(' first audio bytes: 0x' + first2.toUpperCase() + ' (expect MP3: FFF3/FFF2)');
                }
            } catch (e) {
                try { print.log(' onMessage uncaught error: ' + e); } catch(ee){}
            }
      },

      onClosed: function (ws, code, reason) {
            state.closed = true;
            state.connected = false;
            try { print.log(' onClosed 触发 code=' + code + ' reason=' + reason); } catch(e){}
      },

      onFailure: function (ws, t, response) {
            state.closed = true;
            state.connected = false;
            state.err = t ? String(t.getMessage()) : 'ws_error';
            try {
                if (response) {
                  try {
                        state.httpCode = response.code() || 0;
                  } catch (ee) {}
                  try {
                        var hd = response.header('Date');
                        if (hd) state.serverDate = String(hd);
                  } catch (ee) {}
                }
            } catch (e) {}
            try {
                print.log(' ❌ onFailure: err_msg=' + (state.err || '')
                        + ' HTTP_code=' + state.httpCode
                        + ' Date=' + (state.serverDate || '<none>')
                        + ' Throwable_type=' + (t ? String(t.getClass().getName()) : 'null'));
            } catch(e2) {}
      }
    });

    var client = new OkHttpClient.Builder()
      .pingInterval(10, TimeUnit.SECONDS)
      .connectTimeout(25, TimeUnit.SECONDS)
      .readTimeout(40, TimeUnit.SECONDS)
      .build();

    var muid = generateEdgeMuid();
    var edgeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
            + ' (KHTML, like Gecko) Chrome/' + EDGE_CHROMIUM_MAJOR_VERSION + '.0.0.0 Safari/537.36'
            + ' Edg/' + EDGE_CHROMIUM_MAJOR_VERSION + '.0.0.0';

    var request = new Request.Builder()
      .url(wsUrl)
      .addHeader('User-Agent', edgeUA)
      .addHeader('Accept-Encoding', 'gzip, deflate, br, zstd')
      .addHeader('Accept-Language', 'en-US,en;q=0.9')
      .addHeader('Pragma', 'no-cache')
      .addHeader('Cache-Control', 'no-cache')
      .addHeader('Origin', 'chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold')
      .addHeader('Sec-WebSocket-Version', '13')
      .addHeader('Cookie', 'muid=' + muid + ';')
      .build();

    var ws = client.newWebSocket(request, listener);

    print.log(' newWebSocket 已发起,等待 onOpen / onFailure / 25s timeout...');

    var w = 0;
    while (!state.connected && !state.err && w < 25000) {
      sleep(100);
      w += 100;
    }
    if (state.err) {
      try { ws.close(1000, 'err'); } catch (e) {}
      try { client.dispatcher().executorService().shutdown(); } catch (e) {}
      var code = 'error';
      if (state.httpCode === 403) code = '403';
      return { ok: false, code: code, message: state.err, serverDate: state.serverDate };
    }
    if (!state.connected) {
      try { ws.close(1000, 'timeout'); } catch (e) {}
      try { client.dispatcher().executorService().shutdown(); } catch (e) {}
      print.log(' ⏱️onOpen 等待超时(25秒)未触发,已等待' + (w/1000).toFixed(1) + '秒,err=' + (state.err || '<none>'));
      return { ok: false, code: 'timeout', message: 'WebSocket 连接超时(25秒)', serverDate: state.serverDate };
    }

    w = 0;
    var lastLen = 0;
    var noGrowthMs = 0;
    while (!state.closed && !state.err && w < 45000) {
      sleep(200);
      w += 200;
      if (state.totalLen !== lastLen) {
            lastLen = state.totalLen;
            noGrowthMs = 0;
      } else {
            noGrowthMs += 200;
            if (state.totalLen > 100 && noGrowthMs > 2000) break;
      }
    }
    print.log(' 接收循环结束:等待=' + (w/1000).toFixed(1) + '秒 audioBytes_total=' + state.totalLen + ' closed=' + state.closed + ' err=' + (state.err||'<none>'));

    try { ws.close(1000, 'done'); } catch (e) {}
    try { client.dispatcher().executorService().shutdown(); } catch (e) {}

    if (state.err) {
      var code2 = 'error';
      if (state.httpCode === 403) code2 = '403';
      return { ok: false, code: code2, message: state.err, serverDate: state.serverDate };
    }
    if (state.totalLen < 100) {
      return { ok: false, code: 'noaudio', message: '未获取到音频数据,请检查网络或参数', serverDate: state.serverDate };
    }

    var merged = new Uint8Array(state.totalLen);
    var offset = 0;
    for (var ci = 0; ci < state.audioChunks.length; ci++) {
      var chunk = state.audioChunks;
      for (var cj = 0; cj < chunk.length; cj++) merged = chunk;
      offset += chunk.length;
    }

    if (output) {
      if (!writeBinaryFile(output, merged)) {
            return { ok: false, code: 'write', message: '写入文件失败: ' + output, serverDate: state.serverDate };
      }
      print.log(' 已保存: ' + output + ' (' + state.totalLen + ' 字节)');
      return { ok: true, output: output, serverDate: state.serverDate };
    }
    return { ok: true, output: merged, serverDate: state.serverDate };
}

/**
* Edge TTS 核心生成(同步阻塞,关键字接口)—— 带官方 DRM 时钟偏移自动校准+重试
*
* @param {Object} options
* @param {string} options.text       - 要合成的文字(必填)
* @param {string}     - 音色ID,默认 zh-CN-XiaoxiaoNeural
* @param {string}    - 语速 "+0%" / "-20%" / "+50%"
* @param {string}    - 音量 "+0%" / "+50%"
* @param {string}     - 音调 "+0Hz" / "-10Hz"
* @param {string}    - 输出路径(.mp3),不填返回 Uint8Array
* @returns {string|Uint8Array|null}成功返回路径或 Uint8Array,失败返回 null
*/
function generateEdge(options) {
    var opts = options || {};
    if (!opts.text || !String(opts.text).trim()) {
      print.log(' 缺少必填参数: text');
      return null;
    }
    var sessionOpts = {
      text: opts.text,
      voice: opts.voice || DEFAULT_EDGE_VOICE,
      rate: opts.rate || '+0%',
      volume: opts.volume || '+0%',
      pitch: opts.pitch || '+0Hz',
      output: opts.output || null
    };

    // 第 1 次尝试
    var r = tryEdgeOnce(sessionOpts);
    if (r.ok) return r.output;

    // 官方 DRM.handle_client_response_error:
    // 若失败且带 Date 头 → 计算 skew = serverDate - localNow → 修正 skew → 自动重试
    if (r.code === '403' && r.serverDate) {
      print.log(' 收到服务器 Date 头: ' + String(r.serverDate));
      var serverTs = parseRfc2616Date(r.serverDate);
      var localTsNow = java.lang.System.currentTimeMillis() / 1000.0;
      if (serverTs !== null) {
            var skew = serverTs - localTsNow;
            print.log(' serverTs_parsed=' + serverTs.toFixed(3)
                     + ' localNow=' + localTsNow.toFixed(3)
                     + ' skew=' + skew.toFixed(3));
            var oldSkew = EDGE_CLOCK_SKEW_SECONDS;
            EDGE_CLOCK_SKEW_SECONDS += skew;
            print.log(' 校准时钟偏差: old_skew=' + oldSkew.toFixed(3)
                     + ' +delta=' + skew.toFixed(3)
                     + ' → new_skew=' + EDGE_CLOCK_SKEW_SECONDS.toFixed(3) + ' 秒(403 后自动重试)');
            var r2 = tryEdgeOnce(sessionOpts);
            if (r2.ok) return r2.output;
            print.log(' 重试后仍失败: code=' + r2.code + ' msg=' + r2.message
                  + (r2.serverDate ? ' serverDate=' + r2.serverDate : ''));
            return null;
      } else {
            print.log(' 日期解析失败!无法计算时钟偏差,放弃重试');
      }
    }

    // 无 Date 头或非 403 错误,直接打印
    print.log(' 失败(' + r.code + '): ' + r.message);
    return null;
}

// ==================== 豆包 VoiceGenie TTS 核心 ====================

var DOUBAO_AUDIO_CONFIG = { bit_rate: 32000, format: 'ogg_opus', sample_rate: 24000 };

/**
* 豆包 VoiceGenie TTS 核心生成(同步阻塞,关键字接口)
*/
function generateDoubao(options) {
    var opts = options || {};
    var cookies = opts.cookies;
    var conversationId = opts.conversationId;
    var messageId = opts.messageId;
    var questionId = opts.questionId || null;
    var sectionId = opts.sectionId || '0';
    var speaker = opts.speaker || DEFAULT_DOUBAO_VOICE;
    var speechRate = opts.speechRate || 1.0;
    var pitch = opts.pitch || 0;
    var audioConfig = opts.audioConfig || DOUBAO_AUDIO_CONFIG;
    var output = opts.output || null;

    if (!cookies) { print.log('[豆包 TTS] 缺少必填参数: cookies'); return null; }
    if (!conversationId) { print.log('[豆包 TTS] 缺少必填参数: conversationId'); return null; }
    if (!messageId) { print.log('[豆包 TTS] 缺少必填参数: messageId'); return null; }

    var cookieStr = cookieObjToString(cookies);
    var qId = questionId || messageId;
    var sessionOpts = {
      conversationId: conversationId, questionId: qId, messageId: messageId,
      sectionId: sectionId, speaker: speaker, speechRate: speechRate,
      pitch: pitch, audioConfig: audioConfig, cookieStr: cookieStr
    };

    var state = {
      connected: false,
      closed: false,
      sessionId: null,
      gotTTSEnded: false,
      audioChunks: [],
      totalLen: 0,
      err: null
    };

    var wsUrl = buildDoubaoWsUrl(cookieStr);

    var listener = new WebSocketListener({
      onOpen: function (ws, response) {
            state.connected = true;
            try {
                ws.send(ByteString.of(toJavaBytes(buildRequestFrame('StartTask', '{}', null))));
                ws.send(ByteString.of(toJavaBytes(buildRequestFrame('Ping', '{}', null))));
            } catch (e) {
                state.err = '发送 StartTask 失败: ' + e;
            }
      },

      onMessage: function (ws, message) {
            try {
                if (typeof message === 'string') return;
                if (!message || !message.toByteArray) return;

                var jBytes = message.toByteArray();
                var u8 = new Uint8Array(jBytes.length);
                for (var i = 0; i < jBytes.length; i++) u8 = jBytes & 0xff;

                var parsed = parseResponseFrame(u8);
                var ev = parsed.eventType;

                if (ev === 'TaskStarted') {
                  var payload1 = buildDoubaoSessionPayload(sessionOpts);
                  ws.send(ByteString.of(toJavaBytes(buildRequestFrame('StartSession', payload1, null))));

                } else if (ev === 'SessionStarted') {
                  state.sessionId = uuid();
                  ws.send(ByteString.of(toJavaBytes(buildRequestFrame('Ping', '{}', state.sessionId))));
                  var payload2 = buildDoubaoSessionPayload(sessionOpts);
                  ws.send(ByteString.of(toJavaBytes(buildRequestFrame('StartTTS', payload2, state.sessionId))));

                } else if (ev === 'TTSResponse') {
                  if (parsed.audioData && parsed.audioData.length > 0) {
                        state.audioChunks.push({ seq: parsed.seq, data: parsed.audioData });
                        state.totalLen += parsed.audioData.length;
                  }

                } else if (ev === 'TTSEnded') {
                  state.gotTTSEnded = true;
                  try { ws.close(1000, 'done'); } catch (e) {}
                }
            } catch (e) {}
      },

      onClosed: function (ws, code, reason) {
            state.closed = true;
            state.connected = false;
      },

      onFailure: function (ws, t, response) {
            state.closed = true;
            state.connected = false;
            state.err = t ? String(t.getMessage()) : 'ws_error';
      }
    });

    var client = new OkHttpClient.Builder()
      .pingInterval(10, TimeUnit.SECONDS)
      .connectTimeout(15, TimeUnit.SECONDS)
      .readTimeout(60, TimeUnit.SECONDS)
      .build();

    var request = new Request.Builder()
      .url(wsUrl)
      .addHeader('Cookie', cookieStr)
      .addHeader('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36')
      .addHeader('Origin', 'https://www.doubao.com')
      .build();

    var ws = client.newWebSocket(request, listener);

    var w = 0;
    while (!state.connected && !state.err && w < 15000) {
      sleep(100);
      w += 100;
    }
    if (state.err) {
      print.log('[豆包 TTS] 连接失败: ' + state.err);
      try { ws.close(1000, 'err'); } catch (e) {}
      try { client.dispatcher().executorService().shutdown(); } catch (e) {}
      return null;
    }
    if (!state.connected) {
      print.log('[豆包 TTS] WebSocket 连接超时 (15秒)');
      try { ws.close(1000, 'timeout'); } catch (e) {}
      try { client.dispatcher().executorService().shutdown(); } catch (e) {}
      return null;
    }

    w = 0;
    var lastLen = 0;
    var noGrowthMs = 0;
    while (!state.closed && !state.gotTTSEnded && !state.err && w < 60000) {
      sleep(200);
      w += 200;
      if (state.totalLen !== lastLen) {
            lastLen = state.totalLen;
            noGrowthMs = 0;
      } else {
            noGrowthMs += 200;
            if (state.totalLen > 0 && noGrowthMs > 3000) break;
      }
    }

    try { ws.close(1000, 'done'); } catch (e) {}
    try { client.dispatcher().executorService().shutdown(); } catch (e) {}

    if (state.err) {
      print.log('[豆包 TTS] ' + state.err);
      return null;
    }
    if (state.audioChunks.length === 0) {
      print.log('[豆包 TTS] 未收到任何音频数据,请检查 cookies/对话参数');
      return null;
    }

    state.audioChunks.sort(function (a, b) { return a.seq - b.seq; });
    var merged = new Uint8Array(state.totalLen);
    var offset = 0;
    for (var ci = 0; ci < state.audioChunks.length; ci++) {
      var data = state.audioChunks.data;
      for (var cj = 0; cj < data.length; cj++) {
            merged = data;
      }
      offset += data.length;
    }

    if (output) {
      if (writeBinaryFile(output, merged)) {
            print.log('[豆包 TTS] 已保存: ' + output + ' (' + state.totalLen + ' 字节)');
            return output;
      }
      print.log('[豆包 TTS] 写入文件失败: ' + output);
      return null;
    }
    return merged;
}

// ==================== 直接运行示例 ====================

(function runDemo() {
    print.log('=== AIWROK TTS CORE 单文件版 ===');
    print.log('时间: ' + new Date().toString());
    print.log('');

    // --- Edge TTS 示例 ---
    print.log(' Edge TTS 生成中...');
    var demoText = '庚子年后京城的买卖不好做,前门大街倒了三家茶馆,唯独德顺的王掌柜,日子反倒越过越舒展。'
      + '熟客们都打趣,说老王是祖坟冒了青烟,平白无故的好运往怀里撞。'
      + '王掌柜有个旁人瞧不上的小习惯:每天天刚蒙蒙亮,伙计还没上门,他先搬条凳坐柜台后头,'
      + '就着油灯的光,在麻纸本子上写半页字。写的不是什么圣贤文章,全是鸡零狗碎的闲事:'
      + '张二爷爱喝焖得酽的香片,李三赊了俩铜子儿的茶钱,驴打滚剩了八块卖不动,街对过杂货铺的东家爱喝茉莉双熏。'
      + '常来蹭茶喝的混混儿刘四笑他:“王掌柜,你这是要考秀才啊?开茶馆卖水得了,费那洋劲干嘛?”'
      + '王掌柜也不恼,嘿嘿一笑,把本子收进抽屉,第二天照旧写。'
      + '那年春上茶叶价涨得邪乎,别家茶馆要么涨价把客人挤走,要么换碎茶末子砸招牌。'
      + '唯独德顺,价没大涨,茶味还跟从前一样地道。满街的人都说老王运气好,提前囤着好茶了。'
      + '哪是运气?他那半页纸上,早记着“每年开春茶价必涨三成,头年秋里定货最稳”。'
      + '这不是他天生会算,是天天写、年年记,摸出来的老规矩。旁人年年临渴掘井,他早把井挖在了前头。'
      + '还有一回,有个南下的绸缎商来城里收贷,进茶馆歇脚。王掌柜一眼就想起,'
      + '前阵子本子上记过这位客官爱喝碧螺春,还托人打听过京里靠谱的绸缎庄。'
      + '他一边上茶一边顺嘴提了两家字号,连东家的脾气秉性都顺带说了。'
      + '那商人感激不尽,往后带了好几拨同行来,成了固定的大主顾。'
      + '旁人又说,老王会来事,平白撞上了贵人。其实哪是撞的?是他天天记着客人的喜好,机会递到跟前,伸手就接住了。'
      + '日子久了,德顺的生意越做越稳,别家倒闭的伙计都来投奔。'
      + '有人专门来讨教“转运”的法子,王掌柜就把那本麻纸本子翻出来给人看:'
      + '“哪有什么好运?我就是每天早起写半页纸,写着写着,账也清了,人也熟了,该避的坑避了,该抓的机会也没跑。”'
      + '私塾的陈先生呷了口茶,慢悠悠补了句:“这就叫拱心石的习惯。看着是每天半页纸的小事,'
      + '实则把记性、眼力、心思都串起来了。习惯成了本能,就不用天天费脑子琢磨运气。'
      + '提示是开门点灯,动作是记账识人,甜头是买卖顺当,日子一长,好运就成了自然而然的事儿。”'
      + '也有人不服气,买了本子回去学,没半个月就嫌麻烦,扔在了墙角。'
      + '照旧是稀里糊涂过日子,赔了赚了都不知道根由,遇着不顺心就怨命不好。'
      + '王掌柜后来常跟伙计说,天底下的好运,从来不是天上掉下来的。'
      + '就像这茶馆的门轴,天天擦着润着,开门就顺当;锈住了,再使劲也推不开。'
      + '每天半页纸,就是那点润轴的油,看着不起眼,日子久了,就能撑起整份日子的安稳。';
    var edgeOut = generateEdge({
      text: demoText,
      voice: DEFAULT_EDGE_VOICE,
      rate: '+0%',
      volume: '+0%',
      pitch: '+0Hz',
      output: '/sdcard/edge_tts_demo.mp3'
    });
    if (edgeOut) {
      print.log('    生成成功: ' + edgeOut);
    } else {
      print.log('    生成失败');
    }
    print.log('');

    // --- 豆包 TTS 示例(需真实参数,否则会失败) ---
    print.log(' 豆包 TTS 示例(需填入真实参数才能运行)...');
    var doubaoOut = generateDoubao({
      cookies: 'sessionid=YOUR_SESSIONID; session_ss=YOUR_SESSION_SS',
      conversationId: 'YOUR_CONVERSATION_ID',
      messageId: 'YOUR_MESSAGE_ID',
      speaker: DEFAULT_DOUBAO_VOICE,
      speechRate: 1.0,
      output: '/sdcard/doubao_tts_demo.ogg'
    });
    if (doubaoOut) {
      print.log('    生成成功: ' + doubaoOut);
    } else {
      print.log('    预期失败(参数未填)');
    }

    // --- 打印音色列表 ---
    print.log('');
    print.log('=== 可用音色 ===');
    print.log('Edge TTS (' + EDGE_VOICES.length + ' 个):');
    for (var i = 0; i < EDGE_VOICES.length; i++) {
      print.log('' + EDGE_VOICES.id + '[' + EDGE_VOICES.name + ']');
    }
    print.log('');
    print.log('豆包 TTS (' + DOUBAO_VOICES.length + ' 个):');
    for (var j = 0; j < DOUBAO_VOICES.length; j++) {
      print.log('' + DOUBAO_VOICES.id + '[' + DOUBAO_VOICES.name + ']');
    }
})();

exit();

页: [1]
查看完整版本: 语音合成核心模块AIWROK 单文件版