tts-tool 的多编码文件系统:BOM 检测与 UTF-8/GBK/UTF-16LE 自动识别
导入一份从旧版 Windows 导出的台词脚本,加载后中文全部变成问号。排查发现,文件是 UTF-16 LE 编码,而代码直接用 fs.readFile(path, 'utf-8') 读取。Node.js 的 utf-8 参数不会报错,它只是按 UTF-8 解析字节流,遇到不匹配的字节就静默替换。
MiMo TTS Studio 是一个配音工具,用户会从各种地方拷贝台词文件进来。Word 导出的 txt、记事本另存为的编码、甚至从聊天记录里复制粘贴的文本,编码五花八门。工具不能要求用户手动转换编码。
BOM 检测:先看文件头
大多数带 BOM 的文件,前几个字节就能确定编码:
1 | 字节序列 编码 |
tts-tool 的 FileController 用 fsPromises.readFile 读取原始 Buffer,然后逐个检查字节:
1 | async readTextFile(args = {}) { |
buffer.toString('utf-8', 3) 的第二个参数是起始偏移,跳过了 BOM 本身。如果不跳过,EF BB BF 会被当作有效内容解码成 。
UTF-16 的判断只看前两个字节。FF FE 是小端序(Windows 默认),FE FF 是大端序。判断后用对应的解码器处理,偏移 2 跳过 BOM。
无 BOM 时的试探策略
没有 BOM 的文件最难处理。代码的策略是:先假设 UTF-8,验证失败再回退 GBK。
1 | // Try UTF-8 first |
UTF-8 解码失败时,Node.js 会用替换字符 \uFFFD(显示为 �)填充无效字节。代码检查解码结果里有没有这个字符。没有就说明 UTF-8 解码正常,直接返回。有的话说明字节序列不符合 UTF-8 规范,大概率是 GBK。
GBK 回退依赖 iconv-lite。这个包不是 tts-tool 的硬依赖,没装的话走 catch 返回 UTF-8 结果(虽然可能有乱码,但至少不崩溃)。
为什么先试 UTF-8 再试 GBK
顺序不能反过来。GBK 解码器遇到 UTF-8 的 3 字节中文序列,会把它拆成”2字节 + 1字节”分别解码,结果是乱码但不会报错。反过来,UTF-8 解码器遇到 GBK 的 2 字节序列,会触发替换字符。
所以:UTF-8 验证失败 → 大概率不是 UTF-8 → 试 GBK。GBK 如果也解码失败,至少 UTF-8 的结果还能凑合看。
IPC 调用链:后端检测 → 前端消费
readTextFile 是 Electron 后端的方法,前端通过 IPC 调用:
1 | // frontend/src/api/index.js |
前端拿到的永远是 UTF-8 字符串。编码检测完全封装在后端,对业务代码透明。
characterStore.js 加载角色时用 readTextFile 读取 script.txt:
1 | async function loadCharacterFromDisk(dirPath, name) { |
脚本文件按行分割,每行一条台词。如果 readTextFile 返回 null(文件不存在或读取失败),回退到旧版 JSON 格式。这是向后兼容的处理。
iconv-lite 的可选依赖
iconv-lite 是 tts-tool 的可选依赖。package.json 里没有硬性声明,代码用 require('iconv-lite') 动态加载。装了就用,没装就跳过。
这个决策的原因:GBK 主要用于处理旧版 Windows 中文文件。现代系统基本都用 UTF-8,GBK 的需求场景越来越少。为了一个低频场景引入一个 C++ 绑定的原生模块不值得,iconv-lite 是纯 JS 实现,体积小,按需加载。
编码问题的本质是:字节流是客观的,解码方式是主观的。同一个
0xB4 0xED 0xBB 0xA4序列,UTF-8 解码是乱码,GBK 解码是”编译”。工具要做的不是猜对编码,而是让用户能正常工作。
下一篇会讲 tts-tool 如何用 Electron 的 IPC 机制实现前后端通信,以及为什么选择 ee-core 框架。