关键词触发、语音合成与浏览器音频:夸夸插件的工程拆解
本文目录
这个小插件把文字编辑、关键词匹配、语音和视觉反馈串成一条即时体验:用户开启监听后,编辑器把文档文本变化交给插件;插件识别新增的关键词,显示一条鼓励文案并播放本地音频,必要时再请求在线语音合成。真正值得学习的不是“匹配字符串然后播声音”,而是监听生命周期、差分语义、浏览器播放权限、在线降级和隐私边界。
把宿主、UI 与音频服务分层
样本分为三个职责层:宿主桥接层只负责发送编辑变化信号并按需提供当前文本;React UI 负责检测开关、模式、音量、声音选择、提示弹窗和最近记录;音频模块负责本地音频播放、提示音以及可选的 TTS 请求。昂贵或依赖浏览器 DOM 的逻辑留在 UI 中,宿主回调尽量轻量,避免每次键入都让主编辑进程执行复杂处理。本轮本地修正后,UI 收到信号再读取最新文本快照,并丢弃过期的异步读取结果。
两种模式共用关键词匹配和弹窗,但当前音源选择有明确条件:离线模式只播放本地预置音频;在线模式遇到有本地音频且选择默认角色时仍播放本地文件,只有缺少本地音频或选择其他角色时才请求在线合成。样本有 200 多条文案、约 127 个本地音频,在线模式因此能覆盖更多关键词和语音角色。最近本地修复了在线请求失败时不尝试已有本地音频的问题:自动播放与重播共用回退函数,请求或合成音频播放失败且条目带有本地音频时回退到该文件;没有本地音频或本地播放也失败时显示明确提示。停止监听、切换模式、新触发、关闭提示或卸载组件时会取消当前请求;主动取消不会被当成网络故障,也不会触发离线回退。播放器在主动停止时会收尾等待中的 Promise,并区分正常结束、播放失败与主动停止。Blob URL 在结束、错误、拒绝播放和停止时释放。自动化测试覆盖状态分支,但真实浏览器的自动播放权限和设备差异仍未验收。
全文词频差不是精确的编辑差分
当前实现分别统计编辑前后每个关键词的出现次数,只要新文本中的次数更高,就认为该词“新增”。这是一种实现简单的启发式,但不等于知道用户刚刚插入了哪个范围。例如,用户可以粘贴整段内容、撤销再重做,或在文档另一个位置新增相同词;只看总计数无法恢复具体编辑位置。对短文本来说它够轻,对长文和相近关键词则可能产生令人意外的触发。
逐字匹配还会带来边界问题:短词可能出现在更长单词或固定表达内部;“部署成功”可能同时包含“部署”;大小写归一化会让英文缩写与普通文本共享同一匹配空间。样本虽然对完全相同的大小写形式去重,却没有统一的最长词优先、词边界或上下文规则。关键词库越大,越应把边界和优先级当成产品规则,而非靠数组顺序碰运气。
一个可控的变更事件契约
如果宿主能提供插入范围,就直接根据增量匹配;若宿主只提供全文快照,则先计算可靠的文本差异,并在无法唯一定位时退化为不触发或提示用户。不要把全文差异伪装成精确的键入事件。可以把内部事件规范化成这样的类型:
type TextChange = {
documentId: string;
revision: number;
insertedText: string;
source: "typing" | "paste" | "undo" | "unknown";
};
function findMatches(change: TextChange, rules: Rule[]) {
if (change.source === "undo" || !change.insertedText) return [];
return rules
.filter((rule) => rule.enabled)
.filter((rule) => matchesWholeTerm(change.insertedText, rule))
.sort((a, b) => b.keyword.length - a.keyword.length);
}
这只是应用层接口示意;matchesWholeTerm 应按中文分词、英文边界、标点和产品语境单独定义。真实宿主若不暴露可靠的 insertedText,可以用文本 diff 库并保存版本,但不要在编辑器每次事件中反复扫描超长全文而不做节流、长度上限或可取消处理。
监听生命周期与状态重置
原实现有一个已复现的状态边界:监听状态保存上一次全文,模式切换时把“已看过文本”重置为空字符串,但监听仍然可以处于开启状态。随后任意一次编辑会拿当前整篇文档和空字符串比较,文档里原有的关键词可能一起被当成新内容触发。本轮已在本地插件代码中修正为只重置计数、不清空全文基线;开始监听时再用最新快照初始化基线。
项目类型定义中的事件发送方法只接受事件名,原先携带第二个 payload 参数会让当前 TypeScript 检查失败。本轮改为发送轻量通知,UI 再调用暴露的文本读取方法;同一服务会话里的宿主编辑钩子只注册一次,UI 订阅通过一个可清理的监听集合管理。静态检查已通过,但插件宿主运行时和跨文档切换尚未验证,不能据此声称已在宿主环境上线。
浏览器音频要以用户手势启动
浏览器可能阻止没有用户交互的脚本播放。样本让用户点击“开启监听”时先解锁音频,这是正确方向;播放 HTMLAudioElement.play() 仍返回 Promise,自动播放限制、格式不支持、系统静音和设备切换都可能让它失败。UI 应将失败显示为“声音未播放”,保留文案并提供手动重播,而不是把失败当作成功结束。MDN 也明确建议根据播放 Promise 更新状态,不要假设调用成功。
下面的控件调用浏览器语音合成 API,朗读由读者点击启动;本博客不会上传这段文本,但部分系统语音可能联网合成,敏感内容应先确认语音源与设备设置。不同系统和浏览器提供的声音列表可能不同。
如果同时使用 Web Audio 提示音和 HTMLAudioElement 语音,最好由单一播放控制器管理队列、音量、静音、取消与重播。新结果到达时要明确策略:打断当前音频、排队播放,还是只更新可见提示;不要让多条长语音叠在一起。弹窗关闭、切换模式、离开页面时都要停止当前节点并释放监听器。
在线 TTS 的网络与内存边界
在线合成应有超时、取消、响应状态检查和空响应处理。样本把 TTS 返回的 Blob 转为 Object URL 并放进内存缓存;Object URL 不是永久资源。当前本地实现最多缓存 64 项且音频总量不超过 32 MiB,命中会更新最近使用顺序,淘汰最久未使用项时调用 URL.revokeObjectURL;组件卸载时先停止播放,再清空 TTS 缓存并释放缓存 URL。缓存键包含文本、声音、语速和音调,避免配置变化后错误复用媒体资源。停止检测时保留缓存以便重复播放;该预算只约束缓存,不代表整个插件的总内存上限,仍需在低内存移动设备上验证。
仓库里的 Python TTS 服务已经实现了多层资源限制:请求体最多 8 KiB、文本最多 1600 字符、语音限定为 4 个允许值、合成最多并发 4 个、单客户端每分钟最多 60 次、单次合成超时 25 秒、音频响应最多 8 MiB;HTTP 连接总并发上限为 8。合成成功返回 audio/mpeg 且设置 no-store,服务端日志不记录合成正文。客户端也有 30 秒默认超时、音频体积检查和有限 Object URL 缓存。
服务用例测试通过替身 edge_tts 验证了限流器窗口、并发请求、超大请求体、超长文本、未允许语音、参数转发和合成超时;这些测试证明本地处理分支按预期响应,不代表上游 TTS 在生产网络稳定,也不覆盖真实代理、证书续期、系统服务和公网负载。源码当前对跨域响应使用 Access-Control-Allow-Origin: *,若公开部署,应按实际插件来源收紧跨域策略。
限流按服务观察到的客户端 IP 分桶;只有连接对端是 loopback 时才读取 X-Real-IP。因此反向代理必须覆盖而不是透传访客提交的同名请求头,并且 TTS 进程应只监听受信任的本机/内网地址。否则攻击者可能伪造分桶身份,削弱每 IP 限流。当前仓库源码和模拟测试不能证明线上 Nginx/代理确实这样配置,部署前要单独核对。
此样本的在线请求携带的是插件生成的固定夸奖文案,而不是整篇文档;如果以后改成朗读用户选中的文段,就必须在开启在线模式前清楚告知文本会被发送给服务端,并提供本地/不联网选项。
一个带超时、可取消并正确释放音频 URL 的客户端可以这样组织。这里的 audioQueue 代表需要由组件卸载清理的资源管理器:
async function synthesize(text: string, signal: AbortSignal) {
const response = await fetch("/api/tts", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text }),
signal,
});
if (!response.ok) throw new Error(`TTS failed: ${response.status}`);
const blob = await response.blob();
if (blob.size === 0) throw new Error("empty audio");
const url = URL.createObjectURL(blob);
audioQueue.track(url);
return url;
}
function disposeAudio(url: string) {
audioQueue.stop(url);
URL.revokeObjectURL(url);
audioQueue.forget(url);
}
可访问的反馈比动画更重要
语音和全屏弹窗可能打断正在写作的人。默认应关闭自动监听,由用户主动开启;静音必须真正静音,音量要能调节,减少动态效果偏好应关闭彩纸等非必要动画。弹窗要支持键盘关闭、焦点管理和清楚的文本;视觉提示不能只靠颜色表达。自动播放结束或失败都要有可理解的状态,长时间在线请求要能取消。
夸奖文案还需要语境审校。对“加班”“颜值”“涨薪”等词的自动正向回应未必适合所有团队或场景。可以按主题启用规则、屏蔽不喜欢的词,并允许完全关闭自动弹窗与语音。插件的语气要由使用者控制,不能把“鼓励”变成强制打扰。
回归测试清单
- 模式切换后保留文档基线,已有关键词不会在下一次编辑时全部重播。
- 对“光”与更长表达、“部署”与“部署成功”验证重叠匹配和优先级。
- 粘贴、多段输入、删除、撤销、重做、大小写和重复关键词都有明确策略。
- 快速连续触发时,播放队列按产品规则打断或排队;停止/静音后没有残余声音。
- 拒绝播放、断网、TTS 超时、非 2xx、空音频、缓存淘汰和组件卸载均有可恢复结果。
- 监听开关多次后没有重复回调;宿主提供注销句柄时确认已清理。
- 编辑信号快速连续到达时,只接受最新快照;停止监听后迟到的读取不会触发语音。
- 在移动设备上验证触摸开启、系统静音、后台标签页和低内存行为。
小结
离线音频优先、在线语音补充、文案与音频分层,是这类小插件很实用的结构。本轮已在本地修正模式切换基线、事件参数类型、重复服务监听、异步快照竞态,并补上在线请求取消、播放 Promise 收尾、失败状态反馈和组件卸载时的音频/缓存清理。播放器现在区分正常结束、播放失败与主动停止;在线音源失败且条目带有本地音频时会尝试回退,所有可用音源均失败时会给出明确提示。仍需把全文词频启发式升级为可解释的编辑增量,并在真实浏览器和原宿主验证自动播放权限、系统静音及设备差异。插件尚未在原宿主安装验证,用户仍应拥有关闭、静音、取消和重播的控制权。
官方资料
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。