练歌镜:让眼镜听懂“循环这一句”

0 阅读16分钟

一、项目简介:练歌时,手不该被手机占着

练歌镜是跑在 Rokid 眼镜上的 AIUI 智能体,目标用户是业余练歌的人和声乐初学者。它不解决“唱得好不好”,也不做评分,只处理练歌过程中那些必须停下来操作的事:倒回一句、调字幕、记难点、换歌。

传统练歌流程里,这些动作都要经过手机:解锁、找 App、拖进度条、点收藏。眼镜的优势不是屏幕更大,而是它就在眼前,麦克风也一直开着。把操作压成一句话,练歌的连续性才保得住。

当前版本内置 100 首用于开发验证的演示曲目,只保留歌词片段,共 2519 句歌词、300 条预设难点标记。三个页面:练歌主页、曲库、搜索。界面是 480×352 的单色绿屏,输入只有上下滑动、单击和语音。

这里需要先说明哪些是真的,哪些是演示:

  • 实际跑通的:LRC 解析、循环区间与调速、标记增删查、StorageSync 持久化、语音识别、TTS 播报、曲库切换、当前歌曲记忆。

  • 演示部分:内置曲目没有音频文件,播放进度默认由 200ms 定时器模拟推进;音频播放器分支已经写好,接入真实音频后读 player.currentTime,循环用 seek 回跳。

  • 还没做的:伴奏/原唱分轨、空间方位音频路由、网络曲库。这些需要配套播放服务,当前只做到状态和界面层。

把边界写清楚不是为了免责,是因为页面必须在“有播放服务”和“没有播放服务”两种环境下都能开发、能测试。这个双分支后面会具体讲。

二、场景与用户意图:用户说的不是命令,是当前的动作

我把高频场景拆成了五类,每一类都对应一个明确的状态变化:

场景用户说页面动作反馈
副歌反复练“循环这一句”“慢一点”锁定当前句 [start, end),速度减 0.05歌词下方出现 ◉ 循环中 0.95×,TTS 播“速度0.95倍”
歌词看不清“字幕调大”“字幕调淡”“字幕上移”改字号、透明度、垂直偏移界面立即重排,TTS 播“字幕已调大”
老在同一个字破音“这里标记换气”以当前播放时间存一条用户标记歌词旁出现 ▽ 换气,下次播放到 ±500ms 再出现
想换歌“下一首”“打开曲库”切到下一首或打开曲库页TTS 播“已切到《歌名》”,曲库页写入当前歌曲 ID
不知道能说什么没匹配到任何规则不猜TTS 播“你可以调节字幕、循环句子、标记难点、切换歌曲”

最后一行很重要:听不懂时不假装听懂。眼镜端没有多少屏幕空间去解释“我理解成了什么”,承认没听清,比给一个错误操作更安全。

这些句子的共同点是:它们不是聊天,是操作。用户不需要助手理解“我今天嗓子状态不好”,只需要它把“循环这一句”翻译成 loopStart、loopEnd 和界面状态。所以主链路我没有做成自由对话,而是做成了命令表加状态机。

原因有三个:

  1. 延迟。练歌时说“慢一点”,期望的是 200ms 内听到“速度0.95倍”,不是一段解释。

  2. 确定性。播放、循环、标记都是明确状态,命令映射足够;生成式回复反而会引入不确定性。

  3. 离线可用。关键词匹配和状态机不依赖网络往返,在眼镜上更稳。

AIUI 在这里承担的是“理解意图、组织界面”,不是陪聊。智能体的行为边界和能力表写在 AGENTS.md:能做什么、不能做什么、每条指令对应什么动作、听不懂时说什么。页面只负责执行。

三、方案设计

3.1 一条链路

语音唤醒
   ↓
SpeechRecognition(只取 final 结果)
   ↓
parseCommand(text) → { action: 'loop_start' }
   ↓
executeCommand()
   ├─ setData()       改界面
   ├─ speak()         给声音反馈
   └─ StorageSync     下次还能恢复

这条链路里没有“先想想怎么回答”的环节。识别结果进来,规则匹配出动作,动作直接改状态。界面和 TTS 都是状态的投影。

3.2 三个页面

页面职责关键内容
pages/practice练歌主页面歌词、播放进度、字幕调节、循环、标记、主菜单
pages/songs曲库100 首歌曲、当前歌曲标记、首行“搜索歌曲”入口
pages/search本地搜索按歌名/歌手过滤曲库,选中后写入当前歌曲 ID
曲库页没有把 100 首一次性渲染出来。它只保留选中项上下各 5 行,共 11 行,配一个滚动占位。QuickJS 环境里少渲染 89 行,按键响应会明显稳一些。

3.3 状态机

主页面只有四个 phase:

idle | playing | subtitleMenu | markMenu

idle 只在曲库为空时出现;playing 是主菜单;另外两个是一级子菜单,不再往下套。之所以只做一层,是因为 480×352 的屏幕上,层级一深,用户就不知道自己在哪。

主菜单不是写死的。开始/暂停、循环句子开/关都从当前状态推导:

getMainMenuItems() {
  return [
    { action: 'play_toggle', label: this.data.isPlaying ? '暂停' : '开始' },
    { action: 'subtitle_menu', label: '字幕调节' },
    { action: 'loop_toggle', label: this.data.looping ? '循环句子●' : '循环句子○' },
    { action: 'mark_menu', label: '标记难点' },
    { action: 'song_menu', label: '选择歌曲' },
  ];
}

这样不会出现“界面显示暂停、实际已经在播”的重复状态。切歌时重置播放进度、歌词索引和循环状态,但保留字幕设置和循环速度。这是刻意的:换一首歌,用户对字号和速度的偏好通常不会变。

3.4 一屏只做一件事

主页面从上到下四块:

  • 顶栏:歌名、歌手、当前时间 ▸ 总时长、播放/字幕/循环三个状态 chip;

  • 中间:上一句、当前句、下一句,当前句下方挂标记和循环指示;

  • 底部:五个主操作,横向排开;

  • 最底:一行按键提示。

没有用卡片。卡片适合信息多、需要展开的场景,但眼镜上只有一条线性焦点链:上下滑动选项,单击确认。卡片堆叠会挤掉歌词,也会让“当前选中项”变得不明显。把信息压成“一屏歌词 + 一条操作栏”,反而更符合戴在脸上的使用方式。

3.5 能力调用

能力AIUI 接口在链路里的位置
语音识别SpeechRecognition入口,只处理 final 结果
语音合成speechSynthesis + SpeechSynthesisUtterance出口,单槽覆盖式播报
本地存储wx.getStorageSync / wx.setStorageSync当前歌曲、字幕设置、用户标记
音频播放AudioPlayer有 audioSrc 时读真实进度,循环时 seek
按键onKeyDown / onKeyUp移动焦点、确认、返回
页面跳转wx.navigateTo / wx.navigateBack曲库、搜索

四、AIUI 实现

4.1 Ink 页面骨架

每个页面就是一个带生命周期函数的对象,没有 DOM 查询,所有显示都是 data 的映射:

<script def>
{ "navigationBarTitleText": "练歌镜" }
</script>
​
<script setup>
export default {
  data: { phase: 'idle', isPlaying: false, looping: false, /* ... */ },
  onLoad() { /* 读 StorageSync,恢复当前歌曲和字幕设置 */ },
  onKeyDown(e) { /* 只负责移动选中项 */ },
  onKeyUp(e) { /* Enter 执行,Backspace 返回上一层 */ },
};
</script>

改状态就是改 data,再 setData。听起来很朴素,但这条约束让页面逻辑和渲染彻底分开,后面把 LRC、时间轴、标记规则抽成纯函数时,页面几乎不用改。

4.2 LRC 解析:先把歌词变成可计算的数据

LRC 的坑比想象中多。我遇到过一行多个时间戳、[offset:]、1 到 3 位毫秒、乱序行、重复行、BOM 和 CRLF。解析器如果只按“一行一个时间戳”写,换一个歌词文件就会漏句。

核心正则只有一条:

const TIME_TAG_RE = /\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/g;

时间戳统一转成毫秒,offset 按常见约定处理:正值让歌词提前,最后落到:

lines.push({
  time: Math.max(0, entries[i].time - offset),
  text: entries[i].text,
});

解析完还会做三件事:检测乱序并稳定排序、按“时间 + 文本”去重、把异常记进 warnings。这些警告不会阻止播放,但会进测试断言。曲库体检时,任何一首歌出现 bad-time、unsorted、duplicate-line,测试都会红。

歌曲时长不强制写。没有 duration 时,按末句时间加 8 秒推导:

const derived = lastTime > 0 ? lastTime + 8000 : 0;
const duration = Math.max(Number(raw.duration) || 0, derived);

8 秒是留给末句的收尾。太短,最后一句刚唱完歌词就没了;太长,循环末句时会多出一段空白。

解析器放在 utils/lrc.js,不依赖 wx,所以可以直接在 Node 里跑测试。tools/lrc2song.mjs 也复用它:给一个 LRC 文件,生成歌曲模块、校验时间戳、打印句数和最大空档,加 --register 还能自动登记到曲库清单。新增歌曲不需要手写数组。

4.3 时间轴:循环和调速都是纯函数

播放进度每 200ms 推进一次。当前歌词索引用线性扫描:

for (let i = 0; i < lines.length; i++) {
  if (lines[i].time <= time) idx = i;
  else break;
}

最长的一首不到 200 句,线性扫描足够。真要优化可以二分,但现在没必要。

循环区间取“当前句起点到下一句起点”。末句没有下一句,就给 10 秒固定尾长;区间小于 400ms 时强制撑到 400ms,避免两句挨得太近导致循环抖动:

const start = lines[index].time;
let end = index < lines.length - 1 ? lines[index + 1].time : start + 10000;
if (duration > 0) end = Math.min(end, duration);
if (end - start < 400) end = start + 400;

调速的核心只有一行:

return loopStart + (elapsed + tick * speed) % span;

速度范围 0.75× 到 1.25×,步长 0.05,每次加减后四舍五入到两位小数。不这么做的话,0.95 加 0.05 会得到 1.0000000000000002,界面上就会出现“1.00× 但实际不是 1.00”的脏数据。

播放分两条分支:

  • 没有音频:用 advanceTime() 模拟推进,速度直接乘在 tick 上;

  • 有音频:读 player.currentTime,循环时用 player.seek(bounds.start / 1000) 回跳。

第二条分支目前没有改 playbackRate。这是已知限制:真实音频的变速需要播放服务支持,当前只做了 seek 回绕。代码里留着这个分支,是为了接入音频后不用重写页面。

4.4 标记:preset 和 user 分开存

标记分两类:

  • preset:写在歌曲模块的 presetMarks 里,作为示范提示,不随“清除标记”删除;

  • user:用户语音打点,存在 practice_marks_{songId},可以清空。

判重和显示用了两个不同的容差:

const DUPLICATE_TOLERANCE_MS = 400; // 同类型 400ms 内算重复,不重复存
const DEFAULT_TOLERANCE_MS = 500;   // 播放到 ±500ms 时显示在歌词旁

400ms 是防手抖:用户连着说两次“标记高音”,只留一条。500ms 是给显示留一点窗口,让标记在歌词旁多停一会儿。两个值分开,避免“刚存进去就被判重”或者“标记一闪而过”。

标记读取走内存缓存,写入后立即更新缓存。播放时每 200ms 调一次 getMarksAtTime(),如果每次都读 StorageSync,页面会卡。这个优化不复杂,但很必要。

4.5 语音:关键词匹配也要讲顺序

指令表就是一组“关键词 → 动作”:

const RULES = [
  { keywords: ['循环这一句', '重复这句'], action: 'loop_start' },
  { keywords: ['放慢速度', '慢一点'], action: 'loop_slower' },
  { keywords: ['标记高音', '这里标记高音'], action: 'mark_high' },
  { keywords: ['下一首', '换一首'], action: 'song_next' },
  { keywords: ['打开曲库', '歌单'], action: 'song_menu' },
];

规则按“具体 → 笼统”排。比如“打开曲库”必须排在字幕规则之前;如果先放一个宽泛的“打开”,用户说“打开曲库”就会被字幕规则吃掉。现在规则还少,顺序问题不明显,但每加一条指令都要检查会不会抢走已有指令。

语音识别只处理 final 结果,interimResults = false,maxAlternatives = 1。中间结果会反复触发,导致同一句话执行多次;只要最终结果,链路才干净。

TTS 是单槽覆盖式播报。每次说话前先 cancel(),再设置 lang = 'zh-CN',最后 speak(utterance, 'immediate')。练歌时上一句提示还没播完,下一句指令就来了,排队播报会让反馈延迟好几秒,所以宁可打断。

这里踩过一个坑:调速后马上播报,不能在 setData 之后读 this.data.loopSpeed。先把新速度算进局部变量,再同时用于 setData 和 TTS,否则某些运行时下会播成旧速度。代码不长,但这种“状态回读”问题在页面里很容易漏。

4.6 按键:keydown 移动,keyup 执行

宿主在 keyup 阶段还有默认的滚动和激活行为,所以页面必须在这一阶段拦掉:

onKeyDown(e) {
  if (e.code === 'ArrowUp') this.move(-1);
  if (e.code === 'ArrowDown') this.move(1);
},
onKeyUp(e) {
  if (e.code === 'Enter') {
    e.preventDefault();
    this.activate();
  }
  if (e.code === 'Backspace') {
    e.preventDefault();
    this.back();
  }
}

方向键和 Enter 都 preventDefault(),否则一次按键会走两条路径:页面移动了焦点,宿主又滚动了一次。

曲库页还加了 200ms 去重。眼镜的滑动一次可能派发多个同向事件,不去重就会“一滑跳三格”。这个值不是拍脑袋:太短挡不住重复,太长连续滑动会粘手,200ms 在真机上比较跟手。

五、交互与体验

界面只需要用户记住三个状态:在播、字幕开、循环中。顶栏三个 chip 分别对应它们,当前句下方再补一个 ◉ 循环中 0.95×。用户不用听完 TTS 才知道状态变了。

主菜单五项:开始/暂停、字幕调节、循环句子、标记难点、选择歌曲。字幕子菜单七项,标记子菜单四项。每一项都是一个动作,不套二级页面。Backspace 只负责退回上一层,主页面退出交给宿主处理。

几个具体的取舍:

  • 字幕参数有边界:字号 14–22px,透明度 0.24–1.0,垂直偏移 -20–20px。眼镜屏幕小,参数开放给用户自由输入,最后只会得到一个不可恢复的界面。

  • 只有循环中才能调速。没开始循环就说“慢一点”,播报“还没有开始循环这一句”,不自动开启循环。隐藏状态会让用户以为自己在调速,实际什么都没发生。

  • 清除标记的文案是“已清除自建标记”,不是“已清除标记”。歌曲自带的示范标记还在,文案必须先说清楚。

  • 切歌保留字幕和速度,但重置进度和循环。换歌是换内容,不是换偏好。

还有一点:不是每个意图都要新开页面。调字号、调透明度、开关循环,都只是改一个 chip 或一行字。好的意图界面不是每句话都弹窗,而是让状态变化可见。

六、调试与验证

调试顺序分三层。

6.1 纯函数测试

node --test "tests/*.test.js"

目前 5 个测试文件、46 个用例,覆盖 LRC 边界、时间轴、标记规则、指令解析、曲库体检。LRC 解析、循环区间、速度收敛、标记判重都不依赖宿主 API,所以测试不需要 mock 整个 AIUI 环境,直接在 Node 里跑。

这里有个小坑:package.json 里原来写的是 node --test tests/,在 Node 24 下会把目录当成模块路径,报 MODULE_NOT_FOUND。改成 glob 才跑得起来。

曲库体检还抓到一个真实问题:demo_004 的转音标记在 137 秒,但那首歌的 LRC 末句只有 113 秒,推导时长 121 秒,标记落在时长之外。这种问题靠试听很难发现,必须让数据自己说话。正式曲库接入真实 duration 后,所有标记都应该落在时长内。

6.2 页面预览

先在 AIUI Studio 里看 480×352 的折行和焦点:歌名太长会不会挤掉时间、七项字幕菜单会不会溢出、标记多的时候会不会把歌词顶走。这些问题在编辑器里看代码看不出来,必须看渲染。

曲库页重点看滚动:11 行窗口加占位,快速上下滑动时选中项和滚动位置不能脱节。

6.3 真机联调

真机上按这张清单走:

  1. 唤醒后依次说七条指令,检查 TTS 和界面是否同步;

  2. 循环中调速,退出页面再进,检查速度是否恢复;

  3. 标记后切歌再切回,检查用户标记还在、preset 标记也在;

  4. 从曲库选一首歌,返回主页,检查当前歌曲和歌词是否切换;

  5. 播放中打开曲库再返回,检查进度和音频是否恢复;

  6. 说一句无关的话,检查是否回到能力提示,而不是执行错误动作。

眼镜端最容易出问题的不是功能本身,是状态恢复和按键重复。前者靠 StorageSync 和 onShow,后者靠 keyup 拦截和去重。

七、成果与复盘

现在能跑通的是一条完整的练歌控制链:选歌、播放、看歌词、循环当前句、调速、标记难点、调字幕、切歌。LRC 解析和标记持久化都是真实逻辑,不依赖网络。

还没做的也很明确:

  • 伴奏/原唱分轨和空间方位音频路由,需要配套播放服务;

  • 真实音频下的循环片段锁定和变速,当前只对模拟时钟生效,AudioPlayer 分支只做了 seek 回绕;

  • 网络曲库没接,搜索只查本地 100 首,apiConfig 目前是预留配置,不在主链路上;

  • 内置演示曲目没有音频,播放进度是模拟的。

下一步我优先做两件事:接真实音频,把 duration、循环和变速落到播放器上;然后把演示歌词换成有授权的曲库内容。空间音频要等播放服务确认能力后再做,不想先画一个开关,最后发现底层没有路由。

这次开发有三条经验值得留下:

  1. 垂直场景的智能体,命令表加状态机比自由对话更可靠。 用户要的是“这句话被执行”,不是“这段话说得漂亮”。

  2. 纯逻辑一定要抽出宿主。 LRC、时间轴、标记规则能单测,改起来才敢改;页面只负责状态和渲染。

  3. 眼镜屏幕是状态显示器,不是聊天记录。 一屏只回答一个问题:现在唱到哪、这一句怎么处理、下一步能做什么。

练歌镜现在还不完整。但它至少把一件事跑通了:在眼镜上,“循环这一句”可以不动手。