Skip to content

TTS

🕒 Published at:

需求

背景:tts 老版代码是用 flash 写的,源码也找不到了。由于各大浏览器开始禁用 flash 功能,所以需要用 H5 重构

tts 是什么:就是一个富文本编辑器,通过格式化编辑文本,调用后端文字转语音的接口生成听力材料并播放。

  • 第一版要求功能的复制,并修复一些 bug
  • 后续要求功能的优化,并增加一些功能
  • img

技术选型

  • 外围的界面由于 UI 简单故使用原生 JS 和 Css 编写。Mizar 在技术选型的时候,正是 Vue3 正在推广的时候,由于依赖关系比较单一,所以进行了 Vue3 的尝鲜。
  • 考虑到 UI 组件比较多,所以使用了 ELEMENT-Plus(不用 Ant Design Vue(Less))
  • 一开始使用 Vue-CLI 生成模板,后续因为几项 Bug,而且 VueCli 的黑箱机制,定位问题比较困难,所以参考 VueCli 的源码重写了一套模板配置。(由于文档不全,出了问题也找不到方案,最初是在 Vue form 里提问 然后有人帮解决的)
  • 打包工具用的 webpack v5,项目里配置了 eslint 和 prettier 的规则,用的规范是 alloy-config

方案

  1. 为了保证外部调用,抽出编辑器部分 Mizar,并把接口调用都抛出来。外部 UI 放在 Proxima,通过 UMD 的形式调用 Mizar。

  2. 编辑区域实际就是一个富文本编辑器,因为有标签的存在,所以不能用 textArea。参考了一些主流的富文本编辑器的实现,选用

html
contenteditable="true"
  1. 编辑器需要获取选区,使用浏览器的 selection api。
  2. 编辑器需要插入和删除 dom,使用浏览器的 range.insert 和 range.deleteContents api 。

项目结构

image-20210413231642326

核心功能的实现

鼠标光标的缓存

在编辑器里,鼠标选择内容后,并不会立即插入标签,后续会点击弹窗、下拉框,在点击的时候就会导致 selection 对象改变。当用户拖选内容的时候,如果鼠标脱离编辑器,那此时的 selection 对象就会是空。如果用户用键盘操作光标,selection 对象不会更新。

参考了一些开源文本编辑器的实现,这里都是做了光标缓存的处理。slection 事件触发的时候,记录用户单击或者拖选的 selection。监听鼠标离开事件,离开时就记录离开时的 selection。监听键盘事件,重置 selection

内容校验

内容校验有 3 个入口:初始化页面,获取页面内容的时候;保存/播放的时候;粘贴内容的时候。

  1. 初始化页面:由于由许多 flash 编辑的听力材料数据,所以需要把 falsh 的数据转换为 H5 支持的数据格式。目前的逻辑是,编辑器支持编辑老的数据,但是 h5 编辑器编辑过的数据,flash 版的编辑器就不再支持。这里过滤主要用正则白名单过滤,把 flash 的信息和一些样式属性,class,不支持的标签都过滤掉。匹配换行符和换行标签,还有块标签,根据标签分组,每一行都用 p 标签包裹。行内正则匹配文本(用没有<>的文字和符号),每一段文本都用 span 标签包裹,给图片标签都添加唯一标识。因为 flash 图片的 source 不能用于 H5,格式不一致,所以需要根据图片 id,查本地的表,获取图片 url。
  2. 保存/播放:基本逻辑与初始化一致
  3. 粘贴内容:如粘贴功能所述

粘贴功能

线上的粘贴文本在粘贴 word 或者网页的时候会有 bug。

此处编辑器 H5 的实现是用了 contenteditable,所以理论上是可以粘贴 html。如果不禁止默认粘贴事件,那么编辑器内可以粘贴任意来源会被存到剪贴板的内容。但由于接口生成音频需要匹配对应的 html 格式,同时不能带有白名单之外的内容,所以必须做内容过滤。

  • 粘贴功能第一版做了简单的处理,粘贴的时候只粘贴纯文本。实现的方案就是判断文本有无换行,没有换行就插入<span>标签包裹的文本。有换行,就根据换行分组,分别插入<p>标签包裹的文本,每次插入后都会调用 range.collapse(false)收缩光标至插入内容之后(chrome56 以下不一致)。
  • 由于会有许多在当前编辑器粘贴内容,或者是打开过去编辑的内容,粘贴到新的编辑器之类的需求。所以做了区分来源的处理,剪贴板无法直接判断来源,但可以根据内容来判断来源。我会根据标签的 id 来判断,只要有不是我 id 的内容,就会被当作纯文本粘贴。都是我 id 的内容,会被当作 html 粘贴。
  • 用户在当前位置粘贴,还是选择内容后粘贴,这里做了处理。如果是选择内容,会调用 deleteContents 方法删除内容后,再插入标签。最后会调用 selection.collapseToEnd 收缩光标至末尾。

标签错误提示闪烁

需求是有标签缺失或者错位的时候,需要闪烁提示错误的标签

错误提示

  1. 标签错误提示这里,有两种提示的方式。一种是用户编辑的时候提示,一种是用户播放/保存的时候提示。这里使用的是第二种。

  2. 错误检测这里,使用的方法是用 selection api 先获取要检测内容的 html 字符串,为了定位错误标签,给所有的标签都加上了 guid 唯一标识。(这里第一版做的时候,单标签插入的是唯一标识,双标签插入的是相同的标识,错误检测的时候检测到双标签标识的缺失非常容易。但会有一个问题,当检测出错误标签后,用户不删除当前错误标签,选择插入一对相同的标签,再删除新增的那项。由于这对标签不是同时生成,故 guid 不同,导致错误检测出 bug)

    目前用的是一个栈的思想,维护一个错误栈和一个左栈,遇到一个左标签就 push 进栈,遇到一个右标签就比较最后一个元素,如果不匹配,把右标签 push 进错误栈,如果匹配,左标签 pop 出栈,最后把剩余的左栈加入错误栈,所有元素就是错误的项。

音频播放

音频播放这里,会有播放单个音频和顺序播放音频数组的情况,接口为了减少单词请求量,把请求数据按 p 标签分组。

自己封装了 ttsAudio

播放音频数组,每一段音频结束后都会有 0.5s 左右的暂停时间,这段时间并没有在音频里生成。所以需要前端手动暂停,前端用的 setTimeOut 实现的暂停。

这里会有一个问题就是,播放音频数组,当一段音频刚好结束,用户点击播放暂停,去调用 audio.pause()方法会失效。

这里解决的办法是,调用暂停的时候就判断是播放时暂停,还是结束时暂停。如果是播放时暂停就正常暂停,后续可以续播。如果是一段结束时暂停,每段播放结束,数组就 shift,暂停时就清除掉 audio 对象,续播时新建 audio 播放剩余数组

插入标签

插入标签分为 3 种,插入单标签,插入双标签,插入对话标签

插入对话

这里做了选择内容的校验:

  1. 如果用户鼠标是光标聚焦状态,此时可以仅可以在光标位置插入单标签,其余会给弹窗提示
  2. 如果用户选择了内容,且内容不是对话的格式,此时仅可以在选择内容两端插入双标签,其余项置灰不可点击(还有根据选择内容的中英文,过滤插入标签的选择项)
  3. 如果用户选择了内容,且是对话的格式,此时可以插入双标签,也可以插入对话标签

插入标签这里,有两种方案,第一种是 document.execCommand,另一种是操作字符串。

比较简单的方式肯定是操作字符串,但是初期调研的时候。苦于修改字符串后对位替换会有各种 bug...(sel 对象获取的 Html string 于 innnderHtml 获取的不一致,会有标签缺失(最后一个 br),字符转义 nbsp;...)

所以初期都使用了 document.execCommand 插入标签,这也是许多富文本编辑器使用的方式,但是在插入对话标签的时候会有问题 selection.modify

因为会有一个人一次性说多句话导致换行的情况,modify api 没有合适的参数,期间尝试用 extend character 去试探检测冒号,但是在许多边角情况出现了死循环,解决了许多边角情况,但因为输入的不确定性,所以处理很困难,期间出了许多 bug。

因为许多富文本编辑器并没有类似的,同时插入多行标签的情况,所以没有找到参考的代码,相关文章也很少。

后面在 github(app)刷到了一个项目,用了 range.deleteContents 方法删除了选中内容并在当前位置聚焦,跑通了方案二的逻辑。

所以目前插入单双标签用的方案一,插入对话标签用的方案二。

关于对话标签,有一个难点就是对话内容的检测,用什么来判断是一段话。因为会有多句话导致换行的情况,所以使用了:分割,:到非空的字符是人名,冒号后面一直到非空字符是句子。这样适配了出现换行的情况,但这样也会有一个问题就是,当对话内容里出现了冒号,比如 6:30,这种场景是比较高频的。

所以比较适中的方案就是,检测主动换行,只要主动换行,就算作一段。

调用 cloneContents 复制 fragment 对象,Fragment 是文档碎片,他不能当作普通的对象使用,可以用获取他的 innerHtml 字符串,再过滤一道。但最好的方式,就是新建一个空 div,然后使用 appendChild 录入当前 fragment,注意使用后的 fragment 会被清空。

注意的点

  • 为了减小包的体积,剔除了 Vue3,在 Proxima 里引用 Vue

  • webpack 打包生产环境压缩 js 和 css 并自动删除注释

  • sass-loder 用了 dart-sass,但是目前 dart-sass 配合 element-plus 使用的时候,会有图标样式缺失的情况(查看 issue 发现是 dart-sass 在编译的时候,默认会转换一遍 unicode 明文,会有双节字符乱码的情况)

    javascript
    对于图标编译之后图标的content呈现乱码有dart-sass编译的原因dart-sass编译时会将对应的unicode编码转换成对应unicode明文所以通过伪元素来展示的图标如el-icon-arrow:before{ content: "\e6df"}编译之后就变成了el-icon-arrow:before{ content: ""},“”便是一个双字节字符
    正常情况我们会在meta标签上设置<meta charset="utf-8" >但这只对HTML内容解析有效对于css内容中(外部样式表下)的双字节字符如中文解析并没有作用的所以如果浏览器请求回来的css资源的HTTP响应头里的Content-Type未指明"charset=utf-8"的话浏览器根据自身的嗅探机制来决定采用哪一种编码解析结果就会概率出现双字节字符乱码的情况
    解决方案
    1使用 @charset
    2使用 css-unicode-loader
  • 项目里 Vue3 的写法,我用了 setup 和 methods 组合的写法,官方更推荐都写进 setup 里,我为了好看一点,把 methods 单独拿出来放在了外面,主要是单文件的东西有点太多了,都放在 setup 里比较长。

  • 根组件只有一个 APP,子组件有 3 个 Alert、Button、SecondAlert。这里有一些可以优化的地方,比如 App 和 Alert 的代码就比较长(不算样式文件 APP 超过 1600 行,Alert 超过 1000 行)。这里算是有一些历史原因,最初想把弹窗 Alert 封装成 vue 方法,通过 this.method 调用,一些过滤方法,在 proxima 里也有重复的代码,可以提出来封装到导出的 mizar 类里,也可以减少一些代码量。