TTS

TTS 的全称是 Text to Speech,里面的核心其实是深度学习,需要一定的 Python 基础,笔者的 Python 也还停在基础阶段,没有深入学习训练模型库,大家要是感兴趣的话可以关注一些 Mozalia 的 TTS文字转语音 - 图1 项目。所以我们使用别人提供的免费 API,比如讯飞和百度的,这里我们使用百度的为例。

注册 API

首先到 AI 百度文字转语音 - 图2 注册账号,进入控制台的 [人工智能] -> [百度语音] ,然后创建一个应用,然后可以获得 AppID API Key Secret Key ,对于老油条看文档文字转语音 - 图3就够了,假如你对它们其他产品比较感兴趣,它们还提供了 接入教程文字转语音 - 图4

使用百度的 API 有一个坏处就是还需要自己分割,一次只能处理 500 多个文字。大家其实也可以用讯飞的 API,其他开发者也封装了一个文字转语音 - 图5,同样也是免费的。笔者也封装了一个百度 API 命令行的库文字转语音 - 图6 ,通过硬件接口播放音频的,即解码 Mp3,转化成模拟信号,输出到声音驱动上。

因为我封装的库里面有 c++ 扩展接口,为了解码 mp3 的库,使用这个库会把它编译进来,我们还是把部分代码复制过来好了。

安装 TTS 依赖

  1. npm install baidu-aip-sdk --save-dev

连接音频

使用 ffmpeg ,不过需要提前安装 ffmpeg ,MAC 用户使用 brew install ffmpeg 安装, 这样会输出一些小片段文件,这种方式不是特别好。

  1. ffmpeg -i 'concat:0.mp3|1.mp3|2.mp3|3.mp3|4.mp3|5.mp3' -acodec copy all.mp3

所以我们使用 mp3-concat文字转语音 - 图7 这个库,不过同样需要安装 mp3cat文字转语音 - 图8 这个命令,他接受的是数据,所以通过重定向工作,不过 npm 包则通过 stream 工作。

  1. mp3cat - - < infile.mp3 > outfile.mp3

文档给的是复用转换流,我尝试了发现会丢失一些音频,并且不会触发 close 事件,最后还是决定不复用,使用 close 事件来结束 Promise 实例。

安装其他依赖

  1. brew install mp3cat // 连接 mp3 文件,只适用 unix 环境
  2. npm install mp3-concat from2 --save // js 的版本,它通过 child_process 掉用命令行的版本
  3. npm install @types/from2 --save-dev
  • from2 是创建流的一个工具,对于 stream 我也有录制过视频文字转语音 - 图9,不过这个不是免费的。

添加帮助方法

创建 helper.ts

  1. function ready() {
  2. let resolveFN, rejectFN
  3. let promise = new Promise(
  4. (resolve, reject) => ([resolveFN, rejectFN] = [resolve, reject])
  5. )
  6. return [resolveFN, rejectFN, promise]
  7. }
  8. export { ready }

重构一下定义文件

在们下面新建 types 文件,把所有定义文件放到这里面综合管理。修改 tsconfig.json 的 pathRoot 为该目录

  1. "typeRoots": ["./src/main/types"],

新建 types/baidu-aip-sdk.d.ts

  1. declare module 'baidu-aip-sdk' {
  2. class AipSpeechClient {
  3. constructor(...args: any[])
  4. text2audio(text: string, opts: any): { data: Buffer }
  5. }
  6. export { AipSpeechClient as speech }
  7. }

新建 types/mp3-concat.d.ts,修改 shim.d.tselectron-util.d.ts

  1. declare module 'mp3-concat' {
  2. const all: any
  3. export default all
  4. }

主逻辑

新建 main/TTS.ts

  1. import { speech } from 'baidu-aip-sdk'
  2. import from2 from 'from2'
  3. import { createWriteStream } from 'fs'
  4. import concatstream from 'mp3-concat'
  5. import { ensureDir, readJson } from 'fs-extra'
  6. import { resolve } from 'path'
  7. import { ready } from './helper'
  8. import log from './statusLog'

准备必要的 key ,你可以通过环境变量取得,也可以直接赋值,最后我们会从设置里面去读取。

  1. const APP_ID = process.env.BAIDU_READER_APP_ID
  2. const API_KEY = process.env.BAIDU_READER_API_KEY
  3. const SECRET_KEY = process.env.BAIDU_READER_SECRET_KEY

新建客户端

  1. const client = new speech(APP_ID, API_KEY, SECRET_KEY)

分割文字

  1. const splitText = (text: string) => {
  2. // 每次只接受 1024 字节,所以要分割。
  3. const length = text.length
  4. const datas = []
  5. let index = 0
  6. while (index <= length) {
  7. let currentText = text.substr(index, 510)
  8. index += 510
  9. datas.push(currentText)
  10. }
  11. return datas
  12. }

保存文件,先将数组通过 from2 转换为流,再传入到合并流里面,最后输出。

  1. const saveFile = (datas: Buffer[], path: string) => {
  2. const saveStream = createWriteStream(path) // 创建可写流
  3. const [yes, _, promise] = ready()
  4. from2(datas) // 将数据传入 from2 构建流
  5. .pipe(concatstream()) // 通过 mp3 concat 连接
  6. .pipe(saveStream) // 保存
  7. .on('close', yes)
  8. return promise
  9. }

拉取 mp3 数据

  1. const getMp3Data = async (text: string, opts: any = {}) => {
  2. const textArr = splitText(text)
  3. return Promise.all(
  4. textArr.map(async chunk => {
  5. const { data } = await client.text2audio(chunk, opts)
  6. return data
  7. })
  8. )
  9. }

导出转换函数

  1. const transform = async (pathRoot: string) => {
  2. const chaptersPath = resolve(pathRoot, 'chapters.json')
  3. try {
  4. const chapters = await readJson(chaptersPath)
  5. let i = 0
  6. const len = chapters.length
  7. while (i < len) {
  8. let chapter = chapters[i]
  9. const chapterPath = resolve(
  10. pathRoot,
  11. 'text',
  12. `${i}-${chapter.title}.json`
  13. )
  14. const chapterSavePath = resolve(pathRoot, 'audio')
  15. await ensureDir(chapterSavePath)
  16. const text = await readJson(chapterPath)
  17. const datas = await getMp3Data(text, client)
  18. await saveFile(
  19. datas,
  20. resolve(chapterSavePath, `${i}-${chapter.title}.mp3`)
  21. )
  22. log({
  23. title: `${i}-${chapter.title}.mp3`,
  24. type: 'audio',
  25. percent: Math.ceil((i / len) * 100)
  26. })
  27. i += 1
  28. }
  29. } catch (error) {
  30. log({ type: 'audio', step: 'error', message: error.message })
  31. }
  32. }
  33. export default transform