tts-doubao.mjs 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. #!/usr/bin/env node
  2. /**
  3. * tts-doubao.mjs · 豆包语音 TTS(火山引擎 openspeech)
  4. *
  5. * ⚠️ 可选云能力:本脚本会把待配音文本发送到字节跳动官方 TTS 接口(openspeech.bytedance.com),
  6. * 使用你自己的 key,endpoint 强制校验域名白名单。首次调用需 --yes 或 HUASHU_CLOUD_OK=1
  7. * 显式确认。数据流向声明见仓库根 SECURITY.md。
  8. *
  9. * 用法:
  10. * node scripts/cloud/tts-doubao.mjs --text "你好" --out demo.mp3 --yes
  11. * node scripts/cloud/tts-doubao.mjs --text-file script.txt --out out.mp3 --speed 1.0 --yes
  12. * node scripts/cloud/tts-doubao.mjs --text "你好" --out demo.mp3 --timestamps --yes # 附带字级时间戳
  13. *
  14. * 输出:
  15. * - mp3 文件写到 --out 路径
  16. * - stdout 打印一行 JSON: {"path":"...","duration":12.34,"bytes":54321}
  17. * - 带 --timestamps 时额外含 words: [{text,start,end,confidence}](秒,相对本段音频开头)
  18. * 注意:时间戳文本是 TN 后文本(如 "2025" 会变成 "二零二五"),标点附在前一个字上;
  19. * 需要 2.0 资源(seed-tts-2.0 / seed-icl-2.0),仅中英文。
  20. *
  21. * 依赖:Node 18+(自带 fetch/crypto)、ffprobe(测时长,brew install ffmpeg)
  22. *
  23. * env(自动从 skill 根目录 .env 读取,也可走 process.env 覆盖):
  24. * DOUBAO_TTS_API_KEY 可选(新版 API Key 鉴权)
  25. * DOUBAO_APP_ID 可选(控制台 App ID,与 DOUBAO_ACCESS_KEY 配套)
  26. * DOUBAO_ACCESS_KEY 可选(控制台 Access Token,与 DOUBAO_APP_ID 配套)
  27. * DOUBAO_TTS_VOICE_ID 必填(音色 id)
  28. * DOUBAO_TTS_RESOURCE_ID 可选(默认按音色自动推断)
  29. * DOUBAO_TTS_ENDPOINT 默认 https://openspeech.bytedance.com/api/v3/tts/unidirectional
  30. */
  31. import fs from 'node:fs';
  32. import path from 'node:path';
  33. import { execFileSync } from 'node:child_process';
  34. import { fileURLToPath } from 'node:url';
  35. import { randomUUID } from 'node:crypto';
  36. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  37. const SKILL_ROOT = path.resolve(__dirname, '..', '..');
  38. function loadEnv() {
  39. const envPath = path.join(SKILL_ROOT, '.env');
  40. if (!fs.existsSync(envPath)) return;
  41. const text = fs.readFileSync(envPath, 'utf8');
  42. for (const line of text.split('\n')) {
  43. const trimmed = line.trim();
  44. if (!trimmed || trimmed.startsWith('#')) continue;
  45. const idx = trimmed.indexOf('=');
  46. if (idx < 0) continue;
  47. const key = trimmed.slice(0, idx).trim();
  48. let val = trimmed.slice(idx + 1).trim();
  49. if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
  50. val = val.slice(1, -1);
  51. }
  52. if (!(key in process.env)) process.env[key] = val;
  53. }
  54. }
  55. loadEnv();
  56. function parseArgs(argv) {
  57. const args = { speed: '1.0', encoding: 'mp3' };
  58. for (let i = 2; i < argv.length; i++) {
  59. const a = argv[i];
  60. if (a === '--text') args.text = argv[++i];
  61. else if (a === '--text-file') args.textFile = argv[++i];
  62. else if (a === '--out') args.out = argv[++i];
  63. else if (a === '--speed') args.speed = argv[++i];
  64. else if (a === '--voice') args.voice = argv[++i];
  65. else if (a === '--encoding') args.encoding = argv[++i];
  66. else if (a === '--timestamps') args.timestamps = true;
  67. else if (a === '--yes') args.yes = true;
  68. else if (a === '--help' || a === '-h') args.help = true;
  69. }
  70. return args;
  71. }
  72. function usage() {
  73. console.error(`
  74. tts-doubao.mjs · 豆包语音 TTS
  75. --text <str> 要合成的文本
  76. --text-file <path> 从文件读取文本(与 --text 二选一)
  77. --out <path> 输出 mp3 路径(必填)
  78. --speed <float> 语速倍率,默认 1.0(0.5-2.0)
  79. --voice <voice_id> 覆盖 .env 里的音色 id
  80. --encoding <ext> mp3 / wav / pcm,默认 mp3
  81. --timestamps 请求字级时间戳(enable_subtitle),结果 JSON 多一个 words 数组
  82. --yes 确认将文本发送到豆包 TTS 官方接口(或设 HUASHU_CLOUD_OK=1)
  83. `.trim());
  84. process.exit(1);
  85. }
  86. function getDuration(filePath) {
  87. try {
  88. const out = execFileSync('ffprobe', [
  89. '-v', 'error',
  90. '-show_entries', 'format=duration',
  91. '-of', 'default=noprint_wrappers=1:nokey=1',
  92. filePath,
  93. ], { encoding: 'utf8' });
  94. return parseFloat(out.trim());
  95. } catch (e) {
  96. return null;
  97. }
  98. }
  99. function inferResourceId(voiceId) {
  100. // 复刻音色默认走 2.0:本账号只开通了 seed-icl-2.0(1.0 会 403 resource not granted),
  101. // 且字级时间戳(enable_subtitle)只有 2.0 资源支持。
  102. if (voiceId.startsWith('S_')) return 'seed-icl-2.0';
  103. if (voiceId.includes('uranus')) return 'seed-tts-2.0';
  104. return 'seed-tts-1.0';
  105. }
  106. function speedToSpeechRate(speed) {
  107. const ratio = parseFloat(speed);
  108. if (!Number.isFinite(ratio)) return 0;
  109. return Math.max(-50, Math.min(100, Math.round((ratio - 1) * 100)));
  110. }
  111. function buildAuthHeaders({ requestId, resourceId }) {
  112. const apiKey = process.env.DOUBAO_TTS_API_KEY;
  113. const appId = process.env.DOUBAO_APP_ID;
  114. const accessKey = process.env.DOUBAO_ACCESS_KEY;
  115. const headers = {
  116. 'Content-Type': 'application/json',
  117. 'X-Api-Resource-Id': resourceId,
  118. 'X-Api-Request-Id': requestId,
  119. };
  120. if (apiKey) {
  121. headers['X-Api-Key'] = apiKey;
  122. return headers;
  123. }
  124. if (!appId) throw new Error('缺 DOUBAO_TTS_API_KEY 或 DOUBAO_APP_ID(检查 .env)');
  125. if (!accessKey) throw new Error('缺 DOUBAO_ACCESS_KEY(检查 .env)');
  126. headers['X-Api-App-Id'] = appId;
  127. headers['X-Api-Access-Key'] = accessKey;
  128. return headers;
  129. }
  130. async function readV3Audio(res) {
  131. const text = await res.text();
  132. const chunks = [];
  133. const words = []; // 字级时间戳(enable_subtitle 开启时服务端按句返回 sentence.words)
  134. let finalCode = null;
  135. let finalMessage = '';
  136. for (const line of text.split(/\r?\n/)) {
  137. const trimmed = line.trim();
  138. if (!trimmed) continue;
  139. let json;
  140. try {
  141. json = JSON.parse(trimmed);
  142. } catch (e) {
  143. throw new Error(`API 响应行不是 JSON:${trimmed.slice(0, 200)}`);
  144. }
  145. const code = json.code ?? 0;
  146. if (code === 20000000) {
  147. finalCode = code;
  148. finalMessage = json.message || '';
  149. break;
  150. }
  151. if (code !== 0) {
  152. throw new Error(`API 返回错误 code=${code} msg=${json.message || JSON.stringify(json)}`);
  153. }
  154. if (json.data) chunks.push(Buffer.from(json.data, 'base64'));
  155. if (json.sentence && Array.isArray(json.sentence.words)) {
  156. for (const w of json.sentence.words) {
  157. words.push({
  158. text: w.word,
  159. start: w.startTime,
  160. end: w.endTime,
  161. confidence: w.confidence,
  162. });
  163. }
  164. }
  165. }
  166. if (!chunks.length) {
  167. const detail = finalCode ? `结束码 ${finalCode} ${finalMessage}` : text.slice(0, 500);
  168. throw new Error(`API 响应无音频数据:${detail}`);
  169. }
  170. return { audio: Buffer.concat(chunks), words };
  171. }
  172. // endpoint 域名白名单:key 和文本只允许发往字节官方域名,防 .env 被篡改后重定向
  173. const ALLOWED_ENDPOINT_HOSTS = /(^|\.)(bytedance\.com|volces\.com)$/;
  174. async function tts({ text, voice, speed, encoding, timestamps }) {
  175. const endpoint = process.env.DOUBAO_TTS_ENDPOINT || 'https://openspeech.bytedance.com/api/v3/tts/unidirectional';
  176. const host = new URL(endpoint).hostname;
  177. if (!ALLOWED_ENDPOINT_HOSTS.test(host)) {
  178. throw new Error(`DOUBAO_TTS_ENDPOINT 域名 ${host} 不在白名单(*.bytedance.com / *.volces.com),拒绝发送`);
  179. }
  180. const voiceId = voice || process.env.DOUBAO_TTS_VOICE_ID || process.env.DOUBAO_SPEAKER;
  181. const resourceId = process.env.DOUBAO_TTS_RESOURCE_ID || inferResourceId(voiceId || '');
  182. const requestId = randomUUID();
  183. if (!voiceId) throw new Error('缺 DOUBAO_TTS_VOICE_ID(检查 .env 或用 --voice 传)');
  184. const body = {
  185. user: { uid: 'huashu-design' },
  186. req_params: {
  187. text,
  188. speaker: voiceId,
  189. audio_params: {
  190. format: encoding,
  191. sample_rate: 24000,
  192. speech_rate: speedToSpeechRate(speed),
  193. // 字级时间戳:仅 2.0 资源(seed-tts-2.0 / seed-icl-2.0)支持,中英文 only
  194. ...(timestamps ? { enable_subtitle: true } : {}),
  195. },
  196. },
  197. };
  198. const res = await fetch(endpoint, {
  199. method: 'POST',
  200. headers: buildAuthHeaders({ requestId, resourceId }),
  201. body: JSON.stringify(body),
  202. });
  203. if (!res.ok) {
  204. const errText = await res.text();
  205. throw new Error(`HTTP ${res.status}: ${errText.slice(0, 500)}`);
  206. }
  207. return readV3Audio(res);
  208. }
  209. async function main() {
  210. const args = parseArgs(process.argv);
  211. if (args.help) usage();
  212. let text = args.text;
  213. if (!text && args.textFile) {
  214. text = fs.readFileSync(args.textFile, 'utf8').trim();
  215. }
  216. if (!text) {
  217. console.error('错:缺 --text 或 --text-file');
  218. usage();
  219. }
  220. if (!args.out) {
  221. console.error('错:缺 --out');
  222. usage();
  223. }
  224. if (!args.yes && process.env.HUASHU_CLOUD_OK !== '1') {
  225. const host = new URL(process.env.DOUBAO_TTS_ENDPOINT || 'https://openspeech.bytedance.com').hostname;
  226. console.error(
  227. `[云能力确认] 本次将把约${text.length}字文本发送到 ${host}(豆包TTS官方接口,使用你自己的key合成语音)。\n` +
  228. `确认无误请重跑并加 --yes,或设置环境变量 HUASHU_CLOUD_OK=1。数据流向声明见 SECURITY.md。`,
  229. );
  230. process.exit(2);
  231. }
  232. const outPath = path.resolve(args.out);
  233. fs.mkdirSync(path.dirname(outPath), { recursive: true });
  234. const { audio, words } = await tts({
  235. text,
  236. voice: args.voice,
  237. speed: args.speed,
  238. encoding: args.encoding,
  239. timestamps: args.timestamps,
  240. });
  241. fs.writeFileSync(outPath, audio);
  242. const duration = getDuration(outPath);
  243. const result = {
  244. path: outPath,
  245. bytes: audio.length,
  246. duration,
  247. text_chars: text.length,
  248. };
  249. if (args.timestamps) result.words = words;
  250. console.log(JSON.stringify(result));
  251. }
  252. main().catch((err) => {
  253. console.error(`TTS 失败:${err.message}`);
  254. process.exit(1);
  255. });