TTS 的全称是 Text to Speech,里面的核心其实是深度学习,需要一定的 Python 基础,笔者的 Python 也还停在基础阶段,没有深入学习训练模型库,大家要是感兴趣的话可以关注一些 Mozalia 的 TTS 项目。所以我们使用别人提供的免费 API,比如讯飞和百度的,这里我们使用百度的为例。
首先到 AI 百度 注册账号,进入控制台的 [人工智能] -> [百度语音] ,然后创建一个应用,然后可以获得 AppID
API Key
Secret Key
,对于老油条看文档就够了,假如你对它们其他产品比较感兴趣,它们还提供了 接入教程 。
使用百度的 API 有一个坏处就是还需要自己分割,一次只能处理 500 多个文字。大家其实也可以用讯飞的 API,其他开发者也封装了一个库,同样也是免费的。笔者也封装了一个百度 API 命令行的库 ,通过硬件接口播放音频的,即解码 Mp3,转化成模拟信号,输出到声音驱动上。
因为我封装的库里面有 c++ 扩展接口,为了解码 mp3 的库,使用这个库会把它编译进来,我们还是把部分代码复制过来好了。
npm install baidu-aip-sdk --save-dev
使用 ffmpeg ,不过需要提前安装 ffmpeg ,MAC 用户使用 brew install ffmpeg
安装, 这样会输出一些小片段文件,这种方式不是特别好。
ffmpeg -i 'concat:0.mp3|1.mp3|2.mp3|3.mp3|4.mp3|5.mp3' -acodec copy all.mp3
所以我们使用 mp3-concat 这个库,不过同样需要安装 mp3cat 这个命令,他接受的是数据,所以通过重定向工作,不过 npm 包则通过 stream 工作。
mp3cat - - < infile.mp3 > outfile.mp3
文档给的是复用转换流,我尝试了发现会丢失一些音频,并且不会触发 close 事件,最后还是决定不复用,使用 close 事件来结束 Promise 实例。
brew install mp3cat // 连接 mp3 文件,只适用 unix 环境
npm install mp3-concat from2 --save // js 的版本,它通过 child_process 掉用命令行的版本
npm install @types/from2 --save-dev
创建 helper.ts
function ready() {
let resolveFN, rejectFN
let promise = new Promise(
(resolve, reject) => ([resolveFN, rejectFN] = [resolve, reject])
)
return [resolveFN, rejectFN, promise]
}
export { ready }
在们下面新建 types 文件,把所有定义文件放到这里面综合管理。修改 tsconfig.json 的 pathRoot 为该目录
"typeRoots": ["./src/main/types"],
新建 types/baidu-aip-sdk.d.ts
declare module 'baidu-aip-sdk' {
class AipSpeechClient {
constructor(...args: any[])
text2audio(text: string, opts: any): { data: Buffer }
}
export { AipSpeechClient as speech }
}
新建 types/mp3-concat.d.ts
,修改 shim.d.ts
为 electron-util.d.ts
declare module 'mp3-concat' {
const all: any
export default all
}
新建 main/TTS.ts
import { speech } from 'baidu-aip-sdk'
import from2 from 'from2'
import { createWriteStream } from 'fs'
import concatstream from 'mp3-concat'
import { ensureDir, readJson } from 'fs-extra'
import { resolve } from 'path'
import { ready } from './helper'
import log from './statusLog'
准备必要的 key ,你可以通过环境变量取得,也可以直接赋值,最后我们会从设置里面去读取。
const APP_ID = process.env.BAIDU_READER_APP_ID
const API_KEY = process.env.BAIDU_READER_API_KEY
const SECRET_KEY = process.env.BAIDU_READER_SECRET_KEY
新建客户端
const client = new speech(APP_ID, API_KEY, SECRET_KEY)
分割文字
const splitText = (text: string) => {
// 每次只接受 1024 字节,所以要分割。
const length = text.length
const datas = []
let index = 0
while (index <= length) {
let currentText = text.substr(index, 510)
index += 510
datas.push(currentText)
}
return datas
}
保存文件,先将数组通过 from2 转换为流,再传入到合并流里面,最后输出。
const saveFile = (datas: Buffer[], path: string) => {
const saveStream = createWriteStream(path) // 创建可写流
const [yes, _, promise] = ready()
from2(datas) // 将数据传入 from2 构建流
.pipe(concatstream()) // 通过 mp3 concat 连接
.pipe(saveStream) // 保存
.on('close', yes)
return promise
}
拉取 mp3 数据
const getMp3Data = async (text: string, opts: any = {}) => {
const textArr = splitText(text)
return Promise.all(
textArr.map(async chunk => {
const { data } = await client.text2audio(chunk, opts)
return data
})
)
}
导出转换函数
const transform = async (pathRoot: string) => {
const chaptersPath = resolve(pathRoot, 'chapters.json')
try {
const chapters = await readJson(chaptersPath)
let i = 0
const len = chapters.length
while (i < len) {
let chapter = chapters[i]
const chapterPath = resolve(
pathRoot,
'text',
`${i}-${chapter.title}.json`
)
const chapterSavePath = resolve(pathRoot, 'audio')
await ensureDir(chapterSavePath)
const text = await readJson(chapterPath)
const datas = await getMp3Data(text, client)
await saveFile(
datas,
resolve(chapterSavePath, `${i}-${chapter.title}.mp3`)
)
log({
title: `${i}-${chapter.title}.mp3`,
type: 'audio',
percent: Math.ceil((i / len) * 100)
})
i += 1
}
} catch (error) {
log({ type: 'audio', step: 'error', message: error.message })
}
}
export default transform