把GPT打造成听得见的完美助教
ChatGPT 网页版的“朗读”很好用,但有两个小遗憾:不能中途暂停思考,也不能自由拖动进度。
我的需求看起来很简单:
听到不懂的地方,能暂停;想通以后,继续。
需要重复的地方,可以随意的移动进度条,反复的揣摩。
最后,我做了一个 Chrome 插件。它不仅能暂停、恢复、快进、快退和拖动,还解决了流式音频后台下载、完整缓存和多次朗读串台的问题。
效果如下:

把GPT打造成听得见的完美助教
ChatGPT 网页版的“朗读”很好用,但有两个小遗憾:不能中途暂停思考,也不能自由拖动进度。
我的需求看起来很简单:
听到不懂的地方,能暂停;想通以后,继续。
需要重复的地方,可以随意的移动进度条,反复的揣摩。
最后,我做了一个 Chrome 插件。它不仅能暂停、恢复、快进、快退和拖动,还解决了流式音频后台下载、完整缓存和多次朗读串台的问题。
效果如下:
[图片上传中...(image-4b33d3-1787997223011-0)]
项目地址:https://github.com/STEPHENXING/chrome-GPT-voice-complete-player
第一个误会:Blob URL 不是音频下载地址
开发者工具里能看到这样的地址:
<pre class="hljs code__pre" style="color: #c9d1d9; background: #0d1117; font-size: 90%; overflow-x: auto; border-radius: 8px; line-height: 1.5; margin: 10px 8px; box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05); padding: 0 !important;">blob:https://chatgpt.com/...</pre>
它看起来像一个 URL,但它不是服务器上的 MP3 地址,更像浏览器内存里的一张“临时门牌号”。
继续观察日志后,我发现 ChatGPT 使用的是:
- •
MediaSource - •
SourceBuffer - •
audio/aac - • 分块
appendBuffer()
而且为了节省内存,ChatGPT 会不断调用 SourceBuffer.remove(),删除已经播放过的旧区间。
这解释了最早的 Bug:播放器显示可以回到 8 秒,但真正的缓存已经从 9 秒开始。游标能过去,声音回不去。
核心设计:不要让一个游标干两份工作
最初的方案只有一个播放器:
- • 暂停时把它静音,让下载继续;
- • 恢复时把
currentTime拉回暂停位置。
表面上像有两个游标,实际上还是同一个。用户一倒退,负责向 ChatGPT 请求后续音频的游标也跟着倒退,长音频就可能卡住。
最终方案是两个真正独立的播放器:
<pre class="hljs code__pre" style="color: #c9d1d9; background: #0d1117; font-size: 90%; overflow-x: auto; border-radius: 8px; line-height: 1.5; margin: 10px 8px; box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05); padding: 0 !important;">ChatGPT 原生播放器(Producer) │ │ 复制 AAC 数据块 ▼ 插件独立播放器(Consumer)</pre>
Producer 只做一件事:静音、持续向前,让 ChatGPT 把整段音频生成完。
Consumer 也只做一件事:面向用户播放,负责暂停、恢复、快进、快退和进度拖动。
这样,用户可以停在 20 秒慢慢思考,Producer 已经在后台跑到 60 秒;恢复时 Consumer 从 20 秒继续,Producer 完全不受影响。
这其实是一个很通用的工程原则:
数据生产和用户消费节奏不同,就不要强迫它们共用同一个状态。
Session 隔离:最难的不是播放,而是不串台
ChatGPT 会复用同一个 <audio> 元素,也可能在不同时间使用:
- • 新的 MediaSource;
- • 完整的 AAC Blob;
- • 已经结束的同一个音频再次播放。
因此,Session 不能简单等于“一个 DOM 元素”,也不能等于“一个 GPT 对话”。
插件最终把 Session 定义为:
用户发起的一次独立播放实例。
每次新播放都会:
- 1. 分配新的
sessionId; - 2. 清理旧 Consumer 和旧缓存;
- 3. 把进度重置为
0:00; - 4. 绑定当前音频源;
- 5. 阻止旧事件修改新 Session。
这里还遇到过一个很隐蔽的竞态:ChatGPT 复用了同一个 audio 元素,我们销毁旧 Session 时调用了 pause(),结果暂停的已经是新音频。
最终原则变成:
插件只管理自己的 Consumer,不替页面暂停 Producer。
另外几个值得保留的设计
1. 先成功,再复制
只有页面原生 appendBuffer() 成功后,数据块才进入插件 Consumer,避免保存页面自己都没有接受的数据。
2. 页面 URL 和插件 URL 分开
ChatGPT 可以随时 revoke 自己的 Blob URL。插件为 Consumer 创建独立 URL,并且只撤销自己拥有的 URL。
3. 进度条永远从零开始
原生流媒体可能只剩一个滚动缓存区间,例如 [5:08, 6:08]。插件展示的是自己的完整时间轴,因此左端固定为 0:00。
拖动时只更新预览,松手后执行一次 seek,避免鼠标每移动一个像素就干扰播放器状态。
4. 日志就是产品的一部分
每个版本和 Session 都有明确日志:
- •
buildVersion - •
sessionId - •
producerTime - •
consumerTime - •
copiedBytes - •
appendedBytes
很多问题不是“猜”出来的,而是通过事件顺序被证明的。
我真正学到的:自主学习不是让 AI 一次写完
这个项目的过程更像一个自主闭环:
<pre class="hljs code__pre" style="color: #c9d1d9; background: #0d1117; font-size: 90%; overflow-x: auto; border-radius: 8px; line-height: 1.5; margin: 10px 8px; box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05); padding: 0 !important;">观察现象 → 提出假设 → 加日志 → 收集证据 → 修改最小变量 → 真实验证 → 独立 Code Review</pre>
AI 可以写代码,但真正重要的是让它接受证据约束:
- • 不确定时先探测;
- • 日志不支持的结论不采用;
- • 每次修复都有可验证的成功标准;
- • 修改后再让独立 Agent 做审查。
从“我想要一个暂停键”,到理解 MediaSource、双播放器、Session 生命周期和事件竞态,这就是我喜欢的学习方式:从真实问题出发,把知识长在解决问题的过程中。
如果这个插件对你有用,欢迎在 GitHub 点一个 Star。你的 Star,也许就是下一个功能的需求票。
项目地址:https://github.com/STEPHENXING/chrome-GPT-voice-complete-player
第一个误会:Blob URL 不是音频下载地址
开发者工具里能看到这样的地址:
blob:https://chatgpt.com/...
它看起来像一个 URL,但它不是服务器上的 MP3 地址,更像浏览器内存里的一张“临时门牌号”。
继续观察日志后,我发现 ChatGPT 使用的是:
MediaSourceSourceBufferaudio/aac- 分块
appendBuffer()
而且为了节省内存,ChatGPT 会不断调用 SourceBuffer.remove(),删除已经播放过的旧区间。
这解释了最早的 Bug:播放器显示可以回到 8 秒,但真正的缓存已经从 9 秒开始。游标能过去,声音回不去。
核心设计:不要让一个游标干两份工作
最初的方案只有一个播放器:
- 暂停时把它静音,让下载继续;
- 恢复时把
currentTime拉回暂停位置。
表面上像有两个游标,实际上还是同一个。用户一倒退,负责向 ChatGPT 请求后续音频的游标也跟着倒退,长音频就可能卡住。
最终方案是两个真正独立的播放器:
ChatGPT 原生播放器(Producer)
│
│ 复制 AAC 数据块
▼
插件独立播放器(Consumer)
Producer 只做一件事:静音、持续向前,让 ChatGPT 把整段音频生成完。
Consumer 也只做一件事:面向用户播放,负责暂停、恢复、快进、快退和进度拖动。
这样,用户可以停在 20 秒慢慢思考,Producer 已经在后台跑到 60 秒;恢复时 Consumer 从 20 秒继续,Producer 完全不受影响。
这其实是一个很通用的工程原则:
数据生产和用户消费节奏不同,就不要强迫它们共用同一个状态。
Session 隔离:最难的不是播放,而是不串台
ChatGPT 会复用同一个 <audio> 元素,也可能在不同时间使用:
- 新的 MediaSource;
- 完整的 AAC Blob;
- 已经结束的同一个音频再次播放。
因此,Session 不能简单等于“一个 DOM 元素”,也不能等于“一个 GPT 对话”。
插件最终把 Session 定义为:
用户发起的一次独立播放实例。
每次新播放都会:
- 分配新的
sessionId; - 清理旧 Consumer 和旧缓存;
- 把进度重置为
0:00; - 绑定当前音频源;
- 阻止旧事件修改新 Session。
这里还遇到过一个很隐蔽的竞态:ChatGPT 复用了同一个 audio 元素,我们销毁旧 Session 时调用了 pause(),结果暂停的已经是新音频。
最终原则变成:
插件只管理自己的 Consumer,不替页面暂停 Producer。
另外几个值得保留的设计
1. 先成功,再复制
只有页面原生 appendBuffer() 成功后,数据块才进入插件 Consumer,避免保存页面自己都没有接受的数据。
2. 页面 URL 和插件 URL 分开
ChatGPT 可以随时 revoke 自己的 Blob URL。插件为 Consumer 创建独立 URL,并且只撤销自己拥有的 URL。
3. 进度条永远从零开始
原生流媒体可能只剩一个滚动缓存区间,例如 [5:08, 6:08]。插件展示的是自己的完整时间轴,因此左端固定为 0:00。
拖动时只更新预览,松手后执行一次 seek,避免鼠标每移动一个像素就干扰播放器状态。
4. 日志就是产品的一部分
每个版本和 Session 都有明确日志:
buildVersionsessionIdproducerTimeconsumerTimecopiedBytesappendedBytes
很多问题不是“猜”出来的,而是通过事件顺序被证明的。
我真正学到的:自主学习不是让 AI 一次写完
这个项目的过程更像一个自主闭环:
观察现象 → 提出假设 → 加日志 → 收集证据
→ 修改最小变量 → 真实验证 → 独立 Code Review
AI 可以写代码,但真正重要的是让它接受证据约束:
- 不确定时先探测;
- 日志不支持的结论不采用;
- 每次修复都有可验证的成功标准;
- 修改后再让独立 Agent 做审查。
从“我想要一个暂停键”,到理解 MediaSource、双播放器、Session 生命周期和事件竞态,这就是我喜欢的学习方式:从真实问题出发,把知识长在解决问题的过程中。
如果这个插件对你有用,欢迎在 GitHub 点一个 Star。你的 Star,也许就是下一个功能的需求票。