章节内容
历史介绍
开发环境
代码封装
函数式
获取小说命令行版(上)
获取小说命令行版(中)
获取小说命令行版(下)
嵌入到应用中
插件机制
文字转语音
设置与信号中断
插件商店
下载与删除(上)
下载与删除(下)
通知栏程序
播放功能
美化界面
数据可视化(上)
数据可视化(中)
数据可视化(下)
嵌入到应用中
打包应用

# TTS

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

# 注册 API

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

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

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

# 安装 TTS 依赖

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
  • from2 是创建流的一个工具,对于 stream 我也有录制过视频 ,不过这个不是免费的。

# 添加帮助方法

创建 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