自动发帖软件
标题:
语音合成核心模块AIWROK 单文件版
[打印本页]
作者:
发帖软件
时间:
2 小时前
标题:
语音合成核心模块AIWROK 单文件版
语音合成核心模块AIWROK 单文件版
2.png
(908.29 KB, 下载次数: 0)
2 小时前
上传
/**
* ============================================================================
* 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[i] = chars[Math.floor(Math.random() * 16)];
s[14] = '4';
s[19] = chars[(parseInt(s[19], 16) & 0x3) | 0x8];
s[8] = s[13] = s[18] = s[23] = '-';
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[i2] = data[i2];
} 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[k] & 0xff;
jBytes[k] = (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("[writeBinaryFile] 失败: " + 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[j] & 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[i] = frame[i];
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[i++];
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[i + kk]);
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[i - len + m] & 0xff;
jb[m] = (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[f];
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[k]);
}
}
return parts.join('; ');
}
return '';
}
function getCookieFromStr(cookieStr, name) {
if (!cookieStr) return null;
var m = cookieStr.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]+)'));
return m ? m[1] : 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[i] & 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 = '[Edge TTS][GEC] 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('[Edge TTS] 生成 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('[Edge TTS][WS] ✅ 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('[Edge TTS][WS] 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[0] === '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[_k] & 0xff;
jBytes[_k] = (_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('[Edge TTS][bin] frame too small, len=' + len);
return;
}
// ========== 2. 按官方公式:header_length = 前 2 bytes big-endian ==========
var headerLength = ((jBytes[0] & 0xff) << 8) | (jBytes[1] & 0xff);
// 官方 get_headers_and_data: data[header_length+2:] (这里的 data 就是整个 jBytes)
// 所以 header_length 必须 <= len - 2(后面至少留 2 字节给 start index)
if (headerLength < 0 || (headerLength + 2) > len) {
print.log('[Edge TTS][bin] 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[2 : 2 + headerLength] = header bytes
var headerArr = java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, headerLength);
for (var _hi = 0; _hi < headerLength; _hi++) headerArr[_hi] = jBytes[2 + _hi];
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[_li];
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('[Edge TTS][bin] header parse error: ' + parseErr);
pathVal = null; contentTypeVal = null;
}
// 日志(不打每帧,只在首次 audio 成功和总计打,避免日志爆炸)
if (!state._loggedFirstFrame) {
print.log('[Edge TTS][bin] 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('[Edge TTS][bin] 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[kk2] = jBytes[audioStart + kk2] & 0xff;
state.audioChunks.push(chunk);
state.totalLen += audioLen;
if (!state._loggedFirstFrame) {
state._loggedFirstFrame = true;
var first2 = chunk[0].toString(16) + (chunk[1] < 16 ? '0' : '') + chunk[1].toString(16);
print.log('[Edge TTS][bin] first audio bytes: 0x' + first2.toUpperCase() + ' (expect MP3: FFF3/FFF2)');
}
} catch (e) {
try { print.log('[Edge TTS] onMessage uncaught error: ' + e); } catch(ee){}
}
},
onClosed: function (ws, code, reason) {
state.closed = true;
state.connected = false;
try { print.log('[Edge TTS][WS] 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('[Edge TTS][WS] ❌ 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('[Edge TTS][WS] 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('[Edge TTS][WS] ⏱️ 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('[Edge TTS][WS] 接收循环结束:等待=' + (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[ci];
for (var cj = 0; cj < chunk.length; cj++) merged[offset + cj] = chunk[cj];
offset += chunk.length;
}
if (output) {
if (!writeBinaryFile(output, merged)) {
return { ok: false, code: 'write', message: '写入文件失败: ' + output, serverDate: state.serverDate };
}
print.log('[Edge TTS] 已保存: ' + 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} [options.voice] - 音色ID,默认 zh-CN-XiaoxiaoNeural
* @param {string} [options.rate] - 语速 "+0%" / "-20%" / "+50%"
* @param {string} [options.volume] - 音量 "+0%" / "+50%"
* @param {string} [options.pitch] - 音调 "+0Hz" / "-10Hz"
* @param {string} [options.output] - 输出路径(.mp3),不填返回 Uint8Array
* @returns {string|Uint8Array|null} 成功返回路径或 Uint8Array,失败返回 null
*/
function generateEdge(options) {
var opts = options || {};
if (!opts.text || !String(opts.text).trim()) {
print.log('[Edge TTS] 缺少必填参数: 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('[Edge TTS][403] 收到服务器 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('[Edge TTS][403] 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('[Edge TTS] 校准时钟偏差: 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('[Edge TTS] 重试后仍失败: code=' + r2.code + ' msg=' + r2.message
+ (r2.serverDate ? ' serverDate=' + r2.serverDate : ''));
return null;
} else {
print.log('[Edge TTS][403] 日期解析失败!无法计算时钟偏差,放弃重试');
}
}
// 无 Date 头或非 403 错误,直接打印
print.log('[Edge TTS] 失败(' + 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[i] = jBytes[i] & 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[ci].data;
for (var cj = 0; cj < data.length; cj++) {
merged[offset + cj] = data[cj];
}
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('[1] 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('[2] 豆包 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[i].id + ' [' + EDGE_VOICES[i].name + ']');
}
print.log('');
print.log('豆包 TTS (' + DOUBAO_VOICES.length + ' 个):');
for (var j = 0; j < DOUBAO_VOICES.length; j++) {
print.log(' ' + DOUBAO_VOICES[j].id + ' [' + DOUBAO_VOICES[j].name + ']');
}
})();
exit();
复制代码
欢迎光临 自动发帖软件 (http://www.fatiegongju.com/)
Powered by Discuz! X3.2