导入一份从旧版 Windows 导出的台词脚本,加载后中文全部变成问号。排查发现,文件是 UTF-16 LE 编码,而代码直接用 fs.readFile(path, 'utf-8') 读取。Node.js 的 utf-8 参数不会报错,它只是按 UTF-8 解析字节流,遇到不匹配的字节就静默替换。

MiMo TTS Studio 是一个配音工具,用户会从各种地方拷贝台词文件进来。Word 导出的 txt、记事本另存为的编码、甚至从聊天记录里复制粘贴的文本,编码五花八门。工具不能要求用户手动转换编码。

BOM 检测:先看文件头

大多数带 BOM 的文件,前几个字节就能确定编码:

1
2
3
4
5
字节序列                    编码
EF BB BF UTF-8 with BOM
FF FE UTF-16 LE
FE FF UTF-16 BE
无 BOM → 试探

tts-tool 的 FileController 用 fsPromises.readFile 读取原始 Buffer,然后逐个检查字节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async readTextFile(args = {}) {
const buffer = await fsPromises.readFile(args.filePath);
if (!buffer || buffer.length === 0) return '';

// BOM detection
if (buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) {
return buffer.toString('utf-8', 3); // 跳过 3 字节 BOM
}
if (buffer[0] === 0xFF && buffer[1] === 0xFE) {
return buffer.toString('utf-16le', 2);
}
if (buffer[0] === 0xFE && buffer[1] === 0xFF) {
return buffer.toString('utf-16be', 2);
}
// ... 后续逻辑
}

buffer.toString('utf-8', 3) 的第二个参数是起始偏移,跳过了 BOM 本身。如果不跳过,EF BB BF 会被当作有效内容解码成 。

UTF-16 的判断只看前两个字节。FF FE 是小端序(Windows 默认),FE FF 是大端序。判断后用对应的解码器处理,偏移 2 跳过 BOM。

无 BOM 时的试探策略

没有 BOM 的文件最难处理。代码的策略是:先假设 UTF-8,验证失败再回退 GBK。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Try UTF-8 first
const utf8Str = buffer.toString('utf-8');
// Check for replacement character (U+FFFD) which indicates invalid UTF-8
if (!utf8Str.includes('\uFFFD')) {
return utf8Str;
}

// Fallback: try GBK using iconv-lite
try {
const iconv = require('iconv-lite');
return iconv.decode(buffer, 'gbk');
} catch (e) {
logger.warn('[FileController] iconv-lite not available, returning UTF-8 attempt');
return utf8Str;
}

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
2
3
4
5
6
7
8
9
10
11
12
13
// frontend/src/api/index.js
readTextFile: 'controller/file/readTextFile',

// frontend/src/views/mimo/store/utils.js
export async function readTextFile(filePath) {
if (!isElectron) return null
try {
return await ipc.invoke(ipcApiRoute.readTextFile, { filePath })
} catch (e) {
console.warn(`[store] readTextFile failed: ${filePath}`, e.message)
return null
}
}

前端拿到的永远是 UTF-8 字符串。编码检测完全封装在后端,对业务代码透明。

characterStore.js 加载角色时用 readTextFile 读取 script.txt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async function loadCharacterFromDisk(dirPath, name) {
const scriptText = await readTextFile(dirPath + '/script.txt')
let linesData = []

if (scriptText != null) {
linesData = scriptText.split('\n')
.map(t => t.trim())
.filter(t => t.length > 0)
.map(t => ({ text: t }))
} else {
// 回退到旧格式
const linesRaw = await readFile(dirPath + '/lines.json')
linesData = linesRaw ? JSON.parse(linesRaw) : []
}
}

脚本文件按行分割,每行一条台词。如果 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 框架。