听书阁:让你在 AR 眼镜上也可以读小说
戴上眼镜之后我才发现,"翻页"是个需要用手完成的动作。
手机上有触屏,滑动、点按、长按选词,一整套肌肉记忆。到了 AR 眼镜上这些全没了:视野只有一小块,手上可能还拎着东西,能稳定输入的只剩嘴和眼镜腿上的几颗按键。把手机阅读器原样搬过来没有意义,得重新想一遍"读一本书"在眼镜上该拆成哪几个动作。
听书阁就是这次重想的产物。它跑在 Rokid Craft 上,用 AIUI 的 Ink 写页面,四个页面加九个 lib 模块,五千多行。功能一句话能说完:说书名打开,说朗读就念,说下一页就翻,问人物、问剧情、问生词它拿当前章节回答。书都是本地 txt,不联网抓取,也没有任何下载入口。
目标用户不是"所有人"。是那些手机里已经存了一堆 txt、通勤或睡前想接着读,但懒得掏手机的人。

一、先把约束写死
我做的第一件事不是画界面,而是把硬件约束变成常量,因为后面每个设计都要围着它们转:
-
单页正文 320 字,硬上限 350;
-
没有触屏,输入只有语音和按键;
-
音频通道只有一条,TTS 在播的时候麦克风拿不到数据;
-
面板可以跟着头走,也可以钉在空间里,但视野就那么大。
320 不是拍脑袋定的。14px 字号、20px 行高,面板可用高度大概 300px,一屏十五六行,一行二十字出头,乘起来三百多。再长就得滚动,而眼镜上没有"滚动"这个手势。这条约束最后落成了 novel.js 里的 HARD_PAGE_LIMIT,分页、面板协议、模型回答三条路径都要过这一关。
顺带说一句版权。项目里没有搜索、没有爬虫、没有资源站链接,正文只有两个来源:内置的原创短篇,和用户自己拍照或粘贴进来的文本。这是产品边界,也是我不想碰的麻烦。
二、用户不会说"下一页",但会说"往下翻一点"
用户唤醒听书阁,通常是三种情况之一:手里有本纸质书想录进来,说"导入",进拍照流程;昨天读到一半想接着听,说"打开雨夜来客",它回到上次的章节和字符位置;读到某段没懂,问"陈文是谁""总结本章""觊觎什么意思"。三种情况对应三条完全不同的链路,所以第一件事是把意图分对。
语音转写进来的第一站是 handleUtterance,这里做了两级路由。
第一级是纯正则,lib/commands.js 里二十多条规则按顺序排。顺序本身就是逻辑:三条书签规则必须按"列出 → 跳转 → 添加"排,否则"列出所有书签"会先撞上 ADD_BOOKMARK 的 /书签/;"打开"这条只能放最后,不然"打开书签"会被当成打开一本书。
{ intent: LIST_BOOKMARKS, pattern: /列出(?:所有)?书签|查看书签|书签列表/ },
{ intent: JUMP_BOOKMARK, pattern: /跳.{0,4}书签|回到书签/ },
{ intent: ADD_BOOKMARK, pattern: /书签/ },
// ...翻页、翻章、语速、字号、面板模式...
{ intent: OPEN_BOOK, pattern: /打开|来一?本|看一?本/ },
匹配之前先做归一化:去掉空格和全部中英文标点。"下一章。"和"下一章"必须落到同一条规则上,而语音转写特别喜欢在句尾补标点。
规则只负责高频短句,长句和口语变体交给第二级:parseIntentWithLLM 调 LanguageModel 做意图分类。这里有两个工程约束。一是超时,8 秒拿不到就返回 null,页面回一句"没听懂,可以说下一章、下一页",不能让用户对着空气等。二是模块可能根本不存在,所以用动态 import() 加载,失败不阻塞模块图,页面照常渲染。
模型返回的是自由文本,先正则抠出 {...},再过一层白名单:intent 必须落在 23 个枚举值里,书名截 24 字、人物名 12 字、章节号取整且大于 0。校验不过就当没识别到,脏数据不进 dispatch。
意图和能力的对应关系大致是这样:
|
用户说 |
意图 |
用到的能力 |
|---|---|---|
|
打开雨夜来客 |
openBook |
本地存储读正文 |
|
朗读 / 别念了 / 继续读 |
read / pause / resume |
语音合成 |
|
下一页 / 下一章 / 跳到第三章 |
nextPage / nextChapter / jumpChapter |
纯逻辑分页与章节定位 |
|
语速快一点 / 字体放大 |
speedUp / fontBigger |
本地存储 + TTS 变速 / 重渲染 |
|
屏幕跟着我动 |
followHead |
面板协议 |
|
添加书签伏笔 / 跳到书签伏笔 |
addBookmark / jumpBookmark |
本地存储 |
|
陈文是谁 / 总结本章 / 觊觎什么意思 |
whoIs / summary / explainWord |
大模型 |
|
退出阅读 |
exit |
清面板 + 清阅读态 |
三、方案:一份契约,四个页面,三个状态
智能体的行为先写在 AGENTS.md 里:角色、硬件约束、能力清单、配置项(PAGE_SIZE=320、语速 0.5~2.0、面板默认跟随头部),以及 <<AR_PANEL>> 的输出格式。页面代码是照着这份契约实现的,改了协议先改契约,再改解析器,最后改页面,顺序不能反。
app.json 声明四个页面和三项权限:
{
"pages": ["pages/index/index", "pages/import/import",
"pages/bookmarks/bookmarks", "pages/bookshelf/bookshelf"],
"permissions": ["RECORD_AUDIO", "CAMERA", "INTERNET"]
}
|
页面 |
干什么 |
怎么进 |
怎么出 |
|---|---|---|---|
|
index |
正文渲染、朗读、语音指令、按键 |
默认入口 |
双击返回 |
|
bookshelf |
选书 |
首页"打开" |
选中后返回 |
|
import |
拍照 OCR 导入 |
首页"导入" |
导入完成自动返回 |
|
bookmarks |
书签列表、跳转、删除 |
首页"书签" |
双击返回 |
页面之间不直接传对象。Ink 的页面栈没提供可靠的返回值,所以用了一个土办法:往持久化状态里写 pendingBookId,目标页返回后,首页 onShow 读到这个字段就去加载对应书籍。书架选书、书签跳转、导入完成三条路径共用这一个通道。它不优雅,但足够稳,页面被回收再进来也不会丢。
应用状态只有三个:idle、listening、speaking。看起来简单,迁移点却很密:
-
进聆听前无条件
speech.stop(),因为 TTS 占着音频通道时麦克风拿不到数据; -
停聆听时不立刻置 idle,因为
writer.close()是异步的,final 结果可能晚几百毫秒才到,提前改状态会让onFinal里的判断失效; -
Enter 在三个状态下是三件事:聆听时停录音,朗读时停播报并转入聆听,空闲时执行选中的按钮。
四、几个真正花时间的实现
4.1 分页:页码会骗人,偏移量不会
第一版分页是固定字数切片,一句话经常被拦腰截断,朗读第一段只有半句,TTS 断得很别扭。第二版改成优先在换行处断,但很多 txt 整章没有换行,又退化成硬切。
现在的实现是三级:先在段落边界累加,超了就落页;单段本身超长时交给 splitLongParagraph,按 。!?!?…;; 切句再累加;单句还是超长才硬切。
const flush = () => {
if (!parts.length) return;
pages.push({ text: parts.join('\n'), start: pageStart, end: pageEnd });
parts = [];
};
start 和 end 是这一页在章节正文里的绝对字符偏移,不是页号。这个选择救过一次命:改过一版 PAGE_SIZE 之后,老用户如果存的是页号,回来会整体错位到别的段落;存偏移量就天然幂等。
export function locatePage(pages, offset) {
const target = Math.max(0, Number(offset) || 0);
for (let i = 0; i < pages.length; i += 1) {
if (target < pages[i].end) return i;
}
return pages.length - 1;
}
配套的是 normalizeText:统一换行、删控制字符和零宽字符、压缩连续空行。它必须在分页之前跑,否则算出来的偏移和原文对不上。
章节识别是另一个容易翻车的地方。isChapterHeading 同时跑中文序号、特殊标题(序章、楔子、尾声、番外)、英文标题三条正则。中文那条加了个额外判断:标题后面如果还跟着文字,只要出现句读就判为正文。"第三章他很生气,转身就走"这种句子如果被当成标题,整本书的结构立刻崩掉。宁可漏判一章,也不能误判一句。
4.2 朗读:难点不是播,是停
SpeechController 的核心问题只有一个:怎么可靠地停下来。
宿主平台的 onend 回调不保证触发,所以每个片段除了回调,还挂了一个按字数估算的定时器:max(900, len / 4.5 * 1000 / rate)。谁先到谁结算,用 settled 标志防重复。
中断用自增的 seq 令牌:
speak(text) {
this.stop(); // 旧队列作废
this.seq += 1;
const seq = this.seq;
this._run(seq, options);
}
// _run 的循环每轮开头
if (this.seq !== seq) return;
这样即使上一轮已经进了 await,回来也会发现自己过期,不会往队列里插旧内容。
平台没有可用的原生 pause/resume,所以暂停只能做到句段级:pause 记住当前片段下标并取消播放,resume 从那个下标重新进循环。片段由 buildChunks 切,上限 90 字,超长句硬切。90 这个数是试出来的,再长宿主会排队卡顿,听起来像卡带。
播放器优先用 speechSynthesis,不是因为它音质好,而是因为它能 cancel()。wx.speech.playTTS 是发出去就不管的接口,停不掉,播放期间还会占着音频通道让 ASR 起不来,只能当降级路径。
4.3 语音识别:AudioContext 那条路走不通
AsrSession 换过一次技术路线,值得单独说。
最初的实现是 AudioContext + AnalyserNode 手工采 PCM,再往 session.audio.getWriter() 里写。预览环境里一直识别出空白,日志里只有一句 AudioContext.resume is unavailable while InkView target is _current:context 停在 suspended,分析器读出来的全是零。
后来改成 MediaRecorder:getUserMedia 开麦,ondataavailable 每 250ms 产一个 Blob,直接写进 writer。停止时 recorder.stop() 触发最后一次 dataavailable,等写入队列排空再 writer.close(),由它触发 final。整条链路不碰 AudioContext,那个 resume 问题就绕过去了。
MIME 协商是个容易被忽略的点。MediaRecorder 支持的格式和 SpeechRecognitionSession.getCapabilities() 返回的 audioFormats 不一定有交集,所以要逐个试,两边都接受才用:
const candidates = ['audio/ogg;codecs=opus', 'audio/wav', 'audio/webm;codecs=opus'];
for (const candidate of candidates) {
if (!MediaRecorder.isTypeSupported(candidate)) continue;
if (!supported.some((f) => f.mimeType === candidate)) continue;
return candidate;
}
return null; // 直接报"没有共同支持的格式",不硬试
麦克风约束也留了回退链:从 microphoneMode: 'wearer' 加关回声消除开始,只在 OverconstrainedError 时逐级放宽,其他错误直接抛给用户。
写入是一条 Promise 链,run.queue = run.queue.then(() => writer.write(blob)),防止并发写乱序。并发控制靠 _run 对象加 _generation 计数,每个回调进来先比对自己是不是当前那一轮,过期回调直接丢弃。stop 要分三种情况:还没初始化完就停,直接中止,不报错;已经 started 就走正常收尾;20 秒还没结果就强制结束。
4.4 存储:正文和索引必须在同一个后端
这个 bug 花的时间比写功能还多。表现是导入成功、书架里也有这本书,点开却是空的。
原因是两套存储各写各的:writeBookText 直接调 wx.setStorageSync,而索引走的是 store.js 里的 getStorage(),它优先返回 localStorage。结果索引在 A 后端,正文在 B 后端,页面读正文自然读不到。
改法是抽一个 getUnifiedStorage(),用和 store.js 完全一样的优先级链,正文和索引都走它。加完还补了两道验证:写入后立刻回读比对长度,导入完成后再读一次正文打日志。多花几十毫秒,省掉的是"看起来成功了其实没存进去"这种最难查的故障。
读回来的数据一律先洗一遍。原则是宁可丢字段,也不能让页面白屏:
return {
chapterIndex: Math.max(0, Math.floor(Number(raw.chapterIndex) || 0)),
ttsSpeed: clampNumber(raw.ttsSpeed, 0.5, 2.0, 1.0),
follow: raw.follow !== false,
bookmarks: raw.bookmarks.map(sanitizeBookmark).filter(Boolean).slice(0, 60),
};
进度按书隔离,bookProgress[bookId] = { chapterIndex, offset }。早期是全局一条进度,切一本书就把上一本的位置冲掉了,这个改动是补得最值的。
4.5 面板协议:先拼再解析,不是为了绕路
正文进面板之前,会先按 AGENTS.md 的格式拼成一段文本,再用同一个解析器读回来:
<<AR_PANEL>>
follow:true
font_size:medium
content:
【第三章 借书人】
沈砚写:林生,借《长夜行》一册,未还。
<</AR_PANEL>>
看起来多此一举,但这条协议是模型输出和页面渲染的公共入口。模型回答、书签列表、正文分页三种内容都走它,字段只有三个,非法值一律回落默认。formatPanel 和 parsePanel 互逆,单测里直接断言往返结果,任何一侧改坏了都会立刻红。
五、交互上几个必须处理的细节
主界面就三块:顶上书名和章节进度,中间正文面板,底下六个按钮。没有滚动条,没有侧边栏,也没有设置页。字号、语速、面板模式全靠语音调,在眼镜上做一个设置页纯属给自己找麻烦。
滑动手势会派发两次。 一次物理滑动,系统会同时给出 ArrowUp 和 ArrowLeft(下滑是 ArrowDown 加 ArrowRight)。不去重的话,一次滑动选中项跳两格。去重窗口定在 200ms,而且只对"别名对"生效,不按相同 code 去重:连续两次同方向滑动是合法操作,吞掉就成了 bug。
覆盖层只在第一次进入时保存快照。 问完"陈文是谁"接着问"总结本章",如果每次都存当前 pageText,第二次存的就已经是上一个答案了,退出时回到的是答案而不是正文。所以 showOverlay 先判断 overlay 标志,只有从正文进覆盖层才记 savedPageText。
反馈要短。 动作类指令的语音反馈控制在六个字以内:"下一页""语速调快""已添加书签伏笔"。用户可能正在走路或者过马路,一句"好的,已经为您切换到下一章节"是很糟糕的体验。
选中态不能只靠颜色。 眼镜在户外强光下颜色区分度很差,所以选中按钮用 2px 实线边框加背景色,两个维度同时变。面板的"跟随头部"和"固定空间"也用实线、虚线区分,不只改文字。
六、怎么验证
分三层。
第一层是 AIUI Studio 预览。<<AR_PANEL>> 块在控制台里是可读的,字段对不对、content 有没有超 350 字,肉眼就能看。这一层主要抓协议拼装错误,比如 content 里混进了没过滤的空行。
Studio 的静态校验还会报一批 AIUI_IMPORT_NOT_FOUND。一开始以为 import 路径写错了,逐个对了一遍才发现是校验快照里没有 lib/ 目录,属于误报,不影响运行。
第二层是 node --test lib/test/,5 个文件 54 个用例,全在纯逻辑层:分页边界(单字成页、单句超长、连续空行、中英混排)、章节误判样本、中文数字(十、二十、一百零五、三万五)、书名匹配的三级顺序、parsePanel/formatPanel 的往返、状态清洗和书签增删。这一层的价值是改分页规则时不用每次都戴眼镜试。
第三层是真机。按键、翻页、朗读、退出重进的进度、字号语速即时生效、书签跳回原位,逐项过。
真机上修掉的问题,基本都记在这张表里:
|
现象 |
根因 |
改法 |
|---|---|---|
|
拍照一直转圈到超时 |
|
补齐参数 |
|
OCR 永远失败 |
|
改用全局 |
|
OCR 报 prompt 格式错误 |
|
改成消息数组 |
|
导入的书打不开 |
正文和索引落在不同存储后端 |
统一 |
|
书签跳转被吞 |
|
先写 |
|
问完人物再问剧情,答案串了 |
意图识别和阅读辅助共用一个会话 |
拆成两个会话池 |
|
字号切换不生效 |
只改了 |
补 |
导入页本身是个小状态机:photo → recognizing → result → confirm → done,每个失败分支退回 error 并留重试入口。拍照 15 秒、OCR 30 秒各有一道超时兜底,页面不会停在"识别中"不动。
七、复盘
现在的状态是:开书、分页、朗读、翻章、调速、调字号、书签增删跳转、三类问答、拍照导入都能跑,进度持久化稳定,内置两本原创短篇可以直接读。
做对的几件事,回头看比较明确。
一是逻辑和宿主分开。九个 lib 模块里有五个(分页、指令、面板、朗读、存储)能在 Node 里裸跑,所以改分页规则、加指令说法这些最高频的迭代,不用每次都开模拟器。
二是正则优先、模型兜底。高频短指令不进网络,延迟预算留给真正需要语义理解的长句。这个顺序如果反过来,翻页会变成一秒起步的操作,完全没法用。
三是所有跨层数据都带偏移量和校验。重排、切书、脏数据都不会让阅读位置失效,这是"能一直用下去"和"能演示"的区别。
没做好的也说清楚:
-
暂停后继续读会回到本页开头。
stopReading用pause()保住了队列,但紧接着那句"暂停朗读"的反馈走的是speak(),它开头就stop(),把队列清空了。改法其实简单,反馈语音绕开speak或者干脆不播,只是一直没排上。 -
书名模糊匹配的调用点传错了参数。
novel.js里的matchTitle单测是过的,但resolveBook里传的是字符串不是数组,所以"打开雨夜"找不到《雨夜来客》,必须说全名。这是写这篇文章时回头读代码才发现的。 -
"固定空间"目前只是样式。 面板边框从实线改成虚线,真正的空间锚定要等宿主开放对应 API,
changeFollow里已经留好了位置。 -
OCR 在弱光和页面倾斜下准确率不够。 现在只能靠"重拍"兜底,没有做预处理。
接下来按优先级排:先补 OCR 的预处理(去噪、二值化、倾斜矫正),再给模型加流式返回,把首字延迟从几秒压到一秒内,然后做离线 TTS 包保证无网可用。双书并行和更多字号档位放在最后,属于锦上添花。
还有一个想试但没想清楚方向的东西:让智能体在阅读过程中主动开口,比如某个人物再次出场时提示一句"他前面出现过"。技术难点不在模型,在于判断"什么时候不该说话"。这个分寸比"该说什么"难拿捏得多。
更多推荐



所有评论(0)