ReedenReeden

动态音源 (JS 脚本)

通过 JavaScript 脚本自定义 TTS 合成逻辑,支持 HTTP、WebSocket 等任意协议

动态音源允许您通过编写 JavaScript 脚本来实现完全自定义的 TTS 合成逻辑。相比自定义 HTTP 合成,动态音源支持多步骤请求、WebSocket 流式合成、认证签名等复杂场景。

快速开始

  1. 进入 设置 → TTS 引擎
  2. 点击右上角菜单 → 新建动态音源
  3. 填写名称,选择一个内置模板
  4. 根据需要修改脚本和配置项
  5. 保存,试听验证

脚本协议

脚本必须定义 synthesize(ctx) 函数,完成音频合成后将结果保存到 ctx.outputPath

async function synthesize(ctx) {
  // ctx.text       - 待合成的文本
  // ctx.voice      - 语音标识
  // ctx.rate       - 语速 (0.5 ~ 2.0)
  // ctx.config     - 配置项
  // ctx.outputPath - 音频保存路径
}

可用函数

网络请求

$http(options) — 发起 HTTP 请求,获取文本响应

var resp = await $http({
  url: 'https://api.example.com/token',
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ key: 'value' })
});
// resp.status  - 状态码
// resp.body    - 响应内容

$download(options) — 下载音频文件

await $download({
  url: 'https://api.example.com/tts',
  method: 'POST',
  headers: { 'Authorization': 'Bearer token' },
  body: JSON.stringify({ text: ctx.text }),
  savePath: ctx.outputPath
});

WebSocket

var ws = new $WebSocket('wss://api.example.com/tts', {
  headers: { 'X-Api-Key': 'your-key' }
});

ws.onopen = function() {
  ws.send(JSON.stringify({ text: ctx.text }));
};

ws.onmessage = function(event) {
  // event.type: 'text' 或 'binary'
  // event.data: 文本内容或 base64 编码的二进制数据
  if (event.type === 'binary') {
    $writeBinary(ctx.outputPath, event.data);
  }
};

ws.onclose = function() { };
ws.onerror = function(err) { };
ws.send(data);
ws.close();

文件写入

  • $writeAudio(path, base64Data) — 写入 base64 音频数据
  • $writeBinary(path, base64Data) — 写入 base64 二进制数据
  • $writeSubtitle(audioPath, subtitles) — 保存字幕(用于逐字高亮)

字幕格式:

$writeSubtitle(ctx.outputPath, [
  { audioOffset: 0, duration: 500, text: '你好', textOffset: 0, boundaryType: 'WordBoundary' },
  { audioOffset: 500, duration: 300, text: '世界', textOffset: 2, boundaryType: 'WordBoundary' }
]);

工具函数

  • $uuid() — 生成 UUID
  • $md5(str) — MD5 哈希
  • $hmacSha256(key, data) — HMAC-SHA256 签名
  • $base64Encode(str) / $base64Decode(str) — Base64 编解码
  • $timestamp() — 当前时间戳(秒)
  • $log(msg) — 输出日志(试听时可查看)

示例

简单 HTTP

适用于返回音频二进制的 API:

async function synthesize(ctx) {
  await $download({
    url: ctx.config.apiUrl,
    method: 'POST',
    headers: {
      'Authorization': 'Bearer ' + ctx.config.apiKey,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      text: ctx.text,
      voice: ctx.voice,
      speed: ctx.rate
    }),
    savePath: ctx.outputPath
  });
}

配置项:apiUrlapiKey

带认证(多步骤)

先获取 Token,再合成音频:

async function synthesize(ctx) {
  var resp = await $http({
    url: ctx.config.authUrl,
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      appKey: ctx.config.appKey,
      secret: ctx.config.secret
    })
  });
  var token = JSON.parse(resp.body).access_token;

  await $download({
    url: ctx.config.ttsUrl,
    method: 'POST',
    headers: { 'Authorization': 'Bearer ' + token },
    body: JSON.stringify({ text: ctx.text, voice: ctx.voice }),
    savePath: ctx.outputPath
  });
}

配置项:authUrlappKeysecretttsUrl

WebSocket 流式合成

适用于 WebSocket 推送音频流的服务:

async function synthesize(ctx) {
  await new Promise(function(resolve, reject) {
    var ws = new $WebSocket(ctx.config.wsUrl, {
      headers: { 'X-Api-Key': ctx.config.apiKey }
    });

    ws.onopen = function() {
      ws.send(JSON.stringify({
        text: ctx.text,
        voice: ctx.voice,
        format: 'mp3'
      }));
    };

    ws.onmessage = function(event) {
      if (event.type === 'binary') {
        $writeBinary(ctx.outputPath, event.data);
        return;
      }
      var data = JSON.parse(event.data);
      if (data.done) ws.close();
    };

    ws.onclose = function() { resolve(); };
    ws.onerror = function(err) { reject(err); };
  });
}

配置项:wsUrlapiKey

Azure TTS(WebSocket + 字幕)

使用 Azure 认知服务,支持逐字高亮:

async function synthesize(ctx) {
  var subscriptionKey = ctx.config.subscriptionKey;
  var region = ctx.config.region || 'eastus';
  var voice = ctx.voice || 'zh-CN-XiaoxiaoNeural';
  var lang = voice.substring(0, 5);

  var wsUrl = 'wss://' + region
    + '.tts.speech.microsoft.com/cognitiveservices/websocket/v1'
    + '?Ocp-Apim-Subscription-Key=' + subscriptionKey;

  var text = ctx.text
    .replace(/&/g, '&amp;').replace(/</g, '&lt;')
    .replace(/>/g, '&gt;').replace(/"/g, '&quot;');

  var ssml = "<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis'"
    + " xml:lang='" + lang + "'>"
    + "<voice name='" + voice + "'>" + text + "</voice></speak>";

  var subtitles = [];

  await new Promise(function(resolve, reject) {
    var ws = new $WebSocket(wsUrl, {
      headers: { 'X-ConnectionId': $uuid().replace(/-/g, '') }
    });

    ws.onopen = function() {
      ws.send(
        'X-Timestamp:' + new Date().toISOString() + '\r\n'
        + 'Content-Type:application/json; charset=utf-8\r\n'
        + 'Path:speech.config\r\n\r\n'
        + '{"context":{"synthesis":{"audio":{"metadataoptions":'
        + '{"sentenceBoundaryEnabled":false,"wordBoundaryEnabled":true},'
        + '"outputFormat":"audio-24khz-48kbitrate-mono-mp3"}}}}'
      );
      ws.send(
        'X-RequestId:' + $uuid().replace(/-/g, '') + '\r\n'
        + 'Content-Type:application/ssml+xml\r\n'
        + 'X-Timestamp:' + new Date().toISOString() + '\r\n'
        + 'Path:ssml\r\n\r\n' + ssml
      );
    };

    ws.onmessage = function(event) {
      if (event.type === 'binary') {
        $writeBinary(ctx.outputPath, event.data);
        return;
      }
      var parts = event.data.split('\r\n\r\n');
      if (parts.length < 2) return;

      if (parts[0].indexOf('Path:audio.metadata') >= 0) {
        try {
          var items = JSON.parse(parts[1]).Metadata || [];
          for (var i = 0; i < items.length; i++) {
            var d = items[i].Data || items[i];
            subtitles.push({
              audioOffset: Math.floor((d.Offset || 0) / 10000),
              duration: Math.floor((d.Duration || 0) / 10000),
              text: (d.text && d.text.Text) || '',
              textOffset: (d.text && d.text.Index) || 0,
              boundaryType: items[i].Type
            });
          }
        } catch(e) { $log('解析错误: ' + e); }
      } else if (parts[0].indexOf('Path:turn.end') >= 0) {
        if (subtitles.length > 0) $writeSubtitle(ctx.outputPath, subtitles);
        ws.close();
      }
    };

    ws.onclose = function() { resolve(); };
    ws.onerror = function(err) { reject(err); };
  });
}

配置项:subscriptionKeyregion

配置项

配置项是脚本中通过 ctx.config.xxx 访问的键值对,在编辑页面中添加。适合存放 API Key、服务地址等需要灵活修改的参数。

当同一分组下有多个动态音源使用相同的配置项时,可以通过引擎选择器旁的 ⚡ 按钮统一配置

加密分享

支持加密导出,保护脚本不被查看:

  • 导出时设置密码,生成加密文件
  • 接收方输入密码后导入,声音可正常使用但不可编辑
  • 配置项的值不包含在导出文件中,接收方需自行填写
  • 加密导入的声音不可二次导出

调试

  • 使用 $log('消息') 输出日志
  • 试听时,错误信息会包含完整的执行日志
  • 出错时点击复制按钮可复制完整日志