作为一个喜欢听书的人,市面上虽然有不少阅读软件,但在桌面端往往难以找到一款轻量、免费且发音自然顺滑的本地听书工具。为此,我开发了 EdgeTTSPlayer —— 一款基于 edge-ttspygame 构建的本地有声书播放器。

近期,为了对付体量庞大、排版混乱的网文 EPUB,以及彻底解放打包发布的双手,我对项目进行了一次大刀阔斧的重构。在此记录下这次迭代中踩过的坑与技术解决方案。


1. 驯服混乱的 EPUB:智能章节标题提取

痛点

在解析像《剑来》这样的网文 EPUB 时,我发现原有的章节解析算法完全失效,下拉列表里全变成了干瘪的“第 N 章”,甚至是一片空白。
深入扒开原生的 HTML 代码后,我发现了令人窒息的排版:

<div class="header1"><h2><b>第</b><b>一</b><b>章</b></h2></div>
<div class="part"></div>
<div class="header1"><h2><b>惊</b><b>蛰</b></h2></div>
<div class="part"><p>二月二,龙抬头...</p></div>

“第一章” 和 “惊蛰” 被硬生生拆分进了两个互相独立的 <h2> 标签,部分书源甚至连 <title><h> 标签都没有,全靠 <b> 加粗。

解决方案

我放弃了原本只匹配第一个 <h> 标签的简陋做法,重写了一套兼顾“规范排版”与“野生排版”的降维打击式提取算法:

  1. 多头合并:通过 BeautifulSoup 抓取前 3 个 h1-h3 标签,清洗后将它们用空格强行拼接,完美复原 第一章 惊蛰
  2. 正文嗅探(针对极简标题):利用正则 ^第[零一二...]+[章回]$ 进行探测。如果只提取到了“第一章”,则继续切分正文的段落(Paragraph)。只要“第一章”下一段的字数少于 20 个字,就判定其为副标题并强行抓取过来。
  3. 终极兜底:如果真的是没有任何标题的“三无”文件,程序会直接抽取前两个段落的内容,过滤掉所有换行与大段空白后,截取前 40 个字作为该章概要(例如 陈平安看着远处的山峰...)。

经过这套组合拳,即使是排版再糟糕的书源,也能在软件的侧边栏呈现出美观连贯的目录树。


2. 状态持久化与“无缝”交互体验

随播随记与全局记忆

听书最怕的不是报错,而是突然断电后“找不到上次听到哪儿了”。
为此,我设计了一套基于 JSON 的细粒度持久化方案:

  • 全局偏好:你的专属发音人、语速、音量会被独立记录。下次打开任何新书,都会优先加载这些偏好设置。这里曾踩过一个小坑:UI 下拉框里显示的是带详细介绍的字符串(如 zh-CN-XiaoxiaoNeural (女)),保存时一定要剥离出纯净的 ShortName,否则下次启动底层引擎会识别失败。
  • 随播随记:后台每播放完一个 Chunk(碎片文本),就会把 chunk_index 和联动的 chapter_index 写进缓存。我也在 UI 上新增了 [💾 存进度] 按钮以备不时之需。配合 cache_version 强刷新机制,完美解决了电子书解析缓存导致的脏数据问题。

实时介入:动态音量与语速感知

由于我的播放机制是“双缓冲架构”(一边播放当前句子,一边后台异步请求 Edge-TTS 预生成下一句),调整语速和发音人曾经需要“停止-重新播放”才能生效。

  • 音量直连:我将界面的音量滑块直接绑定了 pygame.mixer.music.set_volume(),实现了完全实时的音量升降。
  • 动态窥探:在后台线程每次准备生成下一个 Chunk 之前,我会通过 getattr 动态抓取当前主线程中选择的发音人和语速。这样,当你嫌主角语速太慢而拉动滑块时,当前这半句话读完,下一句话会直接无缝切换成新语速,完全不需要暂停或打断体验!

3. 解放双手:GitHub Actions 跨平台全自动发版

随着功能完善,每次更新都要自己跑一次 PyInstaller 实在太折磨了。更何况身为 Windows 用户,想给 Mac 和 Linux 朋友提供可执行文件几乎不可能。
因此,我搭建了基于 GitHub Actions 的 CI/CD 自动化流水线。

核心实现:

on:
  release:
    types: [published]

permissions:
  contents: write

jobs:
  build:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        include:
          - os: windows-latest
          - os: macos-latest
          - os: ubuntu-latest

踩坑经验:

  1. 触发机制的羁绊:最开始想用打 Tag 的方式触发,但后来发现将触发条件改为 release: types: [published] 最符合直觉。我们在 GitHub 网页端点击 Draft a new release 并发布的一瞬间,动作即被拉起,最后利用 softprops/action-gh-release@v2 就可以将编译产物自动挂载到刚创建的那个 Release 下,不需要自己去写复杂的 Release 创建脚本。
  2. 环境权限陷阱:GitHub 近期收紧了 Actions 默认权限。务必记得在 Workflow 顶部加上 permissions: contents: write,否则编译完的 .exe 根本没有权限上传到 Releases 页面里(会报 HttpError: Resource not accessible by integration 的错)。
  3. 跨平台依赖:在 Linux (Ubuntu) 虚拟服务器上跑 Tkinter 是缺少图形环境的,必须在脚本里手动执行 sudo apt-get install -y python3-tk 补充依赖。此外,MacOS 编译出的并非单文件,而是 xxx.app 目录,需要单独针对 Mac 编写 zip -r 压缩指令。

现在,我只需要在 GitHub 随便点两下发布一个版本,系统就会拉起三台机器,几分钟后自动把 Windows版、macOS版、Linux版 打包压缩好呈现在眼前。这就是自动化的魅力!


开源地址maifeipin/EdgeTTSPlayer
image