h3.decided { font: 700 15px/1.3 var(--body); color: var(--accent); margin: 26px 0 12px; }
myMusic · 设计方案 · 2026-10-29 · 律动 · 方向已确认,未实现

律动踩在拍上

打开“律动”后,光团会跟着音乐起伏,但看不出它在跟节奏。原因主要在时间上,不在动画上:App 分析的声音,要大约半秒以后才会被听到;每一拍能看到的变化,又被压到了几个百分点。这份方案分三步改:先把分析结果按播放进度对齐,再加一层节拍跟踪,最后由封面、光晕和流速来表现拍子。整屏亮度不跟着闪。

原因 画面比声音早 0.5–0.8 秒 改后 仿真偏差中位 −11 ~ −21ms 设置 多一项“跟拍校准”,强度加一档“强烈” 依据 Media3 1.11.1 · 现有 RhythmAnalysis

对齐时间

每个分析帧带上它在歌里的位置,等真正播到才交给画面。只做这一步,现有的效果就不再错拍。

跟踪拍子

根据最近 8 秒的起拍估出速度和相位,按拍子网格起拍。弱拍、漏检不影响网格;没有固定拍子的音乐不硬凑。

换个表现方式

每一拍让封面弹一下、光晕亮一下、流光推一下,外轮廓照旧匀速转。光团亮度不跟单拍走,三档都守住整屏亮度每帧 3% 的约束。

用上提前量

分析本来就比播放早半秒,界面要表现的那一拍其实已经分析过了。所以可以在拍前 50ms 起势,峰值正好落在拍上。

01 · 诊断

为什么看不出跟拍

一共四个原因,第一个最主要:画面上的反应比声音早了半秒以上,而且早多少还随设备变化。

声音 解码 Tee 取样 · 分析30 帧/秒 AudioTrack 缓冲固定 500ms 设备输出扬声器 / 蓝牙 / HDMI 耳朵 0.5 秒 + 设备延迟 30–300ms 现在 立刻上屏 画面比耳朵早 0.5–0.8 秒 RhythmFeed 收到就交给 FlowFieldMotion.feed 改后 带 pts · 节拍跟踪分析线程 按 pts 排队约 2 秒的环形缓冲 播到才上屏拍前 50ms 起势 此刻听到的位置(ExoPlayer 已扣掉缓冲和设备上报的延迟)
  1. ①
    画面早了半秒以上RhythmAnalyzer 通过 TeeAudioProcessor 取样,取的是还没写进 AudioTrack 的 PCM。Media3 1.11.1 的 DefaultAudioTrackBufferSizeProvider 把 PCM 缓冲定死在 500ms(targetPcmBufferDurationUs = 500000,已从依赖的 aar 里核对过),正常播放时这块缓冲一直是满的,再加上设备输出延迟,分析出的结果比耳朵听到的早 0.5–0.8 秒。上一版方案页里的原型分析的是“正在听到”的那一段,所以原型里对得上,App 里对不上。
  2. ②
    早多少不固定,偶尔还会恰好对上一拍的长度是 60/BPM 秒。提前量正好接近一拍时(比如 104 BPM 配手机扬声器),看起来只差几十毫秒;换一首速度不同的歌,或者戴上蓝牙耳机,脉冲就落到两拍中间了。ITU-R BT.1359 给的可察觉门限是:声音比画面早 45ms,或比画面晚 125ms。下表算的是画面上的一次反应离最近的拍点有多远,正数表示比拍点晚:
  1. ③
    每一拍的变化太小上一轮为了不让整屏闪,几处跟拍的量都压低了。现在能随单拍变化的只剩下表里这些,上面还压着一层 12%–84% 的固定遮罩:
元素跟着什么一拍里能看到的变化(标准档)
光团半径低频慢包络 swell(0.3 秒起、1 秒落)不随单拍变;鼓点额外胀 5%
光团光量逐帧低频 ×0.03,鼓点 ×0.02在 0.16–0.36 的底上多 0.02–0.05
鼓点光环鼓点峰值不透明度 0.06,0.12 秒淡入、0.9 秒散完
封面层、光晕swell ×0.05不随单拍变
进度条波幅0.3 + swell,指示器自带 500ms 补间不随单拍变(补间进行中不接新值)
  1. ④
    鼓点检测本身没有规律检测只看 0–690Hz 的谱通量,贝斯音符也会触发;不应期是 280ms。遇到“底鼓在正拍、贝斯在反拍”的电子乐,常常会锁到反拍上:仿真用的 128 BPM 电子曲里,56 次检测有 33 次落在反拍。另外检测没有“拍子”的概念,漏一下、多一下都会直接出现在画面上,人眼看不出规律。
02 · 原型

边听边看

两台手机播的是同一段声音。左边按 App 现在的做法:分析结果立刻上屏,幅度用现在的值。右边是改后的拍子时钟。

点“播放声音”可以听到;静音时画面照常按同一条时钟走。也可以选一首本机音乐来试。

内置曲目带有每一拍的真实时刻,下面的时间线和统计数字就是拿它算的。

曲目
声音
输出设备(决定左边早多少)
律动强度(右边)
跟拍校准(右边,正数让画面晚一点)
0 ms
现在(App 实际)早 0.53 s
改后按播放位置对齐
真实拍点(高的是底鼓,中等的是军鼓)现在:画面闪的时刻改后:画面起拍的时刻(大圈是小节首拍)已经分析过、还没播到的拍

看什么

对着底鼓看左边的光环和光团:它们总在鼓声之前就开始了。右边封面的弹跳和光晕,会和底鼓、军鼓同时落下。

换设备

换成“蓝牙耳机”,左边的提前量从 0.53 秒变成 0.72 秒,在律动曲(104 BPM)里会从“稍微晚一点”变成“明显早”。右边不受影响,因为改后用的是 ExoPlayer 的播放位置。

换曲目

慢歌(76 BPM)的一拍有 0.79 秒,左边差不多落在两拍正中间。“氛围”这首没有固定拍子:右边认出这一点后不按网格起拍,只有低频事件时以一半力度轻轻回应一下。

03 · 机制

先对齐,再跟拍

对齐解决“早半秒”;跟踪解决“没规律”。两者都在分析线程上完成,界面每帧只需要查一次网格。

已经分析、还没播到(提前量 0.53 s) 此刻听到 分析走到这里 起拍曲线 拍子网格 画面起势 拍前 50ms 起势 →
  1. 1
    每个分析帧带上 pts新增 RhythmAudioSink,它是包住 DefaultAudioSink 的 ForwardingAudioSink。它在 handleBuffer 里拿到每个缓冲的 presentationTimeUs,分析器再按样本数推出每个分析窗口末尾的 pts。
  2. 2
    知道“此刻听到哪儿”渲染线程每次调用 getCurrentPositionUs 时,记下(位置、nanoTime、速度);界面线程按 nanoTime 往后推算。这个位置 ExoPlayer 已经扣掉了 AudioTrack 缓冲和设备上报的延迟。pause、flush 时推算冻结。
  3. 3
    排队,播到才用分析帧放进 64 帧(约 2 秒)的环形缓冲,界面每帧取走 pts 不晚于“此刻”的那些。原来的 StateFlow 只保留最新一帧,做不到这一点。段落呼吸(swell)也跟着对齐了,鼓点进来、过门安静下来,不会再提前半秒。
  4. 4
    估速度先把最近 8 秒的起拍曲线(低频谱通量 + 0.3×中频谱通量,都按频点数平均,再减去滑动基线)做一次高斯平滑。然后在 60–180 BPM 之间每隔 0.25 BPM 取一个候选,算错开一拍和两拍的自相关,乘上一个偏向 100 BPM 的先验,取峰值后再插值。平滑这一步是仿真里发现的:不平滑的话,30 帧/秒下整数帧的间隔会占便宜,比如 150 BPM 正好 12 帧,会把 76 BPM 的慢歌认成两倍速。
  5. 5
    找相位在最近 4 秒里找一组等间隔的位置,让落在上面的起拍最多,越近的拍权重越大。网格每 0.27 秒向测量值靠 35%,拍号保持连续。
  6. 6
    判断有没有拍置信度由两部分相乘:一是起拍曲线错开一拍(或两拍,重拍轻拍交替时)后与自己的相关程度;二是相位梳的峰有多突出。置信度到 0.5 以上才按网格起拍。仿真里的氛围曲,置信度始终是 0。
  7. 7
    不乱换速度新速度要连续 3 次都比当前好 12% 以上才换;差距在 3% 以内的只做微调。刚锁上的前 6 秒历史还短,换速度的门槛放低一些。
  8. 8
    小节首拍每一拍附近的低频起拍强度,按拍号除以 4 的余数分别累加,最重的一组当首拍。四拍一样重(四拍底鼓)的歌没有明显的首拍,这时光环仍然每 4 拍出现一次,但不保证落在第一拍上。
  9. 9
    跳转和切歌同一首歌里拖进度条、单曲循环回到开头时,保留速度,1.5 秒后重新对相位。切到另一首则全部清掉,新歌前 4 秒只有段落呼吸。无缝衔接时,分析会先进入下一首:跟踪器从分界那一帧重新开始,界面在播到分界之前继续用旧网格。
  10. 10
    没有网格时置信度低的时候,改用对齐过时间的低频起拍,力度减半;连低频起拍也没有,就只剩段落呼吸。

上表来自 node 仿真:用的是内置的合成曲目、App 同款分析参数,提前量 0.53 秒,每首播两遍,统计的是第二遍(包含循环回开头后重新锁定的那几秒)。命中指的是真实拍点 ±60ms 内有一次起拍。

04 · 表现

拍子落在哪里

拍子主要靠形状和运动来表现,局部的光只是辅助,大面积的光团亮度不跟单拍走。歌词完全不跟拍,保证一直看得清。

元素现在改后 · 标准改后 · 柔和改后 · 强烈
封面外轮廓 40 秒一圈匀速转每拍弹一下(放大 3%,拍前 50ms 起势、160ms 回落);外轮廓照旧 40 秒一圈匀速转不弹每拍放大 5%
光晕0.62 + 0.05 × swell每拍不透明度 +0.18、放大 2.5%,280ms 回落每拍 +0.10每拍 +0.26、放大 3.5%
流光半径跟 swell;鼓点胀 5%、亮 0.02半径照旧跟 swell;每拍把流速推到 3.6 倍,再在 400ms 内落回。光量不跟单拍推到 2.4 倍推到 4.4 倍
光环鼓点时从封面中心散开,峰值 0.06小节首拍时,从封面轮廓往外散开一圈同形状的光,峰值 0.16,1.1 秒散完无每拍都散一圈:首拍 0.16,其余拍 0.10
进度条波幅跟 swell波幅照旧;波速改成每拍走一个波长(waveSpeed = wavelength × BPM ÷ 60)同标准同标准
歌词不动不动不动不动

亮度约束不变

上一轮定下的“逐帧亮度变化不超过约 3%”仍然有效。拍子用在封面大小和流速上,这两样都不改变整屏亮度;光晕和光环只占封面周围一圈。原型里每台手机下方显示的“整屏亮度逐帧最大变化”,就是按这条约束实时测出来的。

外轮廓不踩拍

已确认:外轮廓保持 40 秒一圈匀速转,不改成每拍转一格。拍子只落在封面大小上,转动一直是平滑的,看久了不累。

强烈档

弹跳、光晕和流速推力都加大,光环从“只在小节首拍”改成“每拍都有”,首拍那一圈更亮。原型里切到“强烈”可以看到,整屏亮度逐帧变化仍在 3% 以内,因为光环只占封面外一圈。

进度条只锁速度

M3 的 LinearWavyProgressIndicator 不开放波的相位,所以只能让波速跟上拍子,波峰不一定正好落在拍点上。BPM 变化超过 2% 才更新 waveSpeed,避免动画反复重启。

减少动画、电视

开启“减少动画”(或系统移除动画)后,和现在一样定格,不做分析。电视上帧循环限制在 30 帧,起势和回落都按真实时间计算,最多晚一帧(33ms),仍在门限之内。

05 · 联动

海报墙也跟拍

海报墙方案里的大卡和播放页共用同一份拍子信号。只在歌词背景选了“律动”时才跟拍;选“动态”或“关闭”时,海报墙完全静止。

  1. 1
    大卡上的播放键Cookie 形状的播放键跟播放页封面一样每拍弹一下,外形照旧匀速转;柔和档不弹。
  2. 2
    大卡的光晕每拍亮一下,用的是和播放页光晕同一条包络。
  3. 3
    亮度波标准档在小节首拍、强烈档每拍,亮度从大卡往外一圈圈传开,每隔一格晚 60ms;首拍提亮 6%,其余拍 4%,离大卡三格以外不受影响。
  4. 4
    不受影响的部分镜头、电视空闲漂移、格子大小和焦点框都不跟拍。分析器只在海报墙或播放页显示时运行。
06 · 设置

设置:多一项,多一档

新增“跟拍校准”,律动强度从两档变三档,都在 播放页 › 律动 里,只在歌词背景选了“律动”时显示。跟其他播放页设置一样,只保存在本机,不参与同步。

播放页

律动
歌词背景
关闭动态律动
律动强度
柔和标准强烈新
柔和:只有光晕和流光跟拍。标准:再加上封面弹跳和小节首拍的光环。强烈:弹得更明显,每拍都有光环。
跟拍校准新
0 ms
画面和声音对不上时再调。大多数耳机和音箱会自己上报延迟,保持 0 就行。

跟拍校准

AppStore 新增 rhythm_offset_ms,范围 −200 到 +200,步长 10,默认 0;正数让画面晚一点。少数蓝牙设备不上报延迟,或者电视的“声音模式”另外加了处理延迟,这时画面会早一点,用这一项来补。

拖动滑杆时如果正在播放,播放页背景会立刻按新的偏移跟拍,不用另做试音界面。

强度加一档“强烈”

RhythmIntensity 从 Soft, Standard 变成 Soft, Standard, Strong,默认仍是标准;旧值照常读出。设置页的 ConnectedToggleGroup 从两段变三段。

强度档的说明文字改为

“柔和:只有光晕和流光跟拍。标准:再加上封面弹跳和小节首拍的光环。强烈:弹得更明显,每拍都有光环。”原来的说明是“柔和只改流速和光量;标准还让光团随低频胀缩,鼓点时散开一圈光环”,要换掉。

07 · 工程

改哪些文件

新增 4 个文件,改 7 个文件。所有计算都放在分析线程上,界面每帧只做一次网格查询和几次 exp 运算。

文件改动
playback/RhythmAudioSink.kt 新包住 DefaultAudioSink 的 ForwardingAudioSink:handleBuffer 记下每个缓冲的 pts;getCurrentPositionUs 记下播放位置;play / pause / flush / setPlaybackParameters 更新播放时钟;setOutputStreamOffsetUs 之后收到的第一个缓冲处记一个分界(无缝衔接到下一首)。RhythmRenderersFactory.buildAudioSink 里套上这一层。
playback/PlaybackClock.kt 新用 @Volatile 保存一组(位置、nanoTime、速度、是否在播),提供 audibleUs(nowNanos)。只读不锁,界面线程每帧调用。
playback/BeatTracker.kt 新速度图、相位、置信度、小节首拍、跳转后快速重新锁定、无缝衔接的分界。由分析线程调用;结果以不可变的 BeatGrid 发布,同时保留分界前的一份旧网格。
ui/player/BeatClock.kt 新纯 Kotlin,不依赖 Compose:每帧根据听到的位置和网格决定要不要起拍,并提供 pop / glow / shove 三条包络。
playback/RhythmAnalyzer.kt给分析帧加上 pts;把 StateFlow 换成 64 帧的环形缓冲 RhythmTimeline(单写多读);分析完顺手推给跟踪器;reset 区分换歌和同一首里的跳转。
playback/RhythmAnalysis.ktRhythmFrame 增加 ptsUs、fluxBass、fluxMid(按频点数平均)。原有的检测流程不变。
ui/player/FlowFieldMotion.kt分析帧改为按听到的位置取用;去掉鼓点让光团胀亮的部分(bloom);增加流速推力;光环形状跟封面一致,标准档只在小节首拍触发、强烈档每拍触发。FlowIntensity 三档重新取值。
ui/player/LyricsBackdrop.ktBackdropPulse 增加 beatPop、beatGlow、bpm、locked,只在绘制阶段读;RhythmFeed 改成按帧去取;光环按封面形状的路径来画。
ui/player/CoverStage.kt形状封面在 graphicsLayer 里读 beatPop 做缩放;外轮廓旋转不变;光晕读 beatGlow。
ui/components/Progress.ktWavySeekBar 增加 waveSpeed 参数;播放页在拍子锁定时传入 wavelength × BPM ÷ 60。
data/AppStore.kt、ui/settings/LyricsSettingsScreen.ktrhythm_offset_ms 设置项和滑杆;RhythmIntensity 加 Strong,强度选择改成三段,三档的说明文字。

测试

BeatTrackerTest:用四种合成起拍序列,检查速度误差在 1% 以内、4 秒内锁定、跳转后 2 秒内重新锁定、氛围曲置信度低于 0.3、不会锁成两倍速或一半速。BeatClockTest:网格微调时不重复起拍;往回跳时不漏拍。PlaybackClockTest:推算、暂停、变速。RhythmAudioSinkTest:缓冲只被消费了一部分时,pts 也能对上。

开销

每 8 帧估一次速度:481 个候选 × 240 个历史点,约 23 万次乘加,在分析线程上不到 1ms。界面每帧只做 O(1) 的计算,也不新分配对象。

倍速

pts 和播放位置都用媒体时间,所以倍速播放时网格照样对得上。界面上显示的 BPM 要乘以播放速度。

调试叠层

开发者选项里加一个开关,在播放页角落显示 BPM、置信度、第几拍、提前量(getAudioTrackBufferSizeUs)和最近 10 拍的偏差,方便在真机和电视上核对。

08 · 分期

四步做完

第一步单独就有用:哪怕不加跟踪,现有效果也不再错拍。后面每一步都可以单独验收。

B1

对齐时间

  • RhythmAudioSink、PlaybackClock、RhythmTimeline
  • RhythmFeed 改成按听到的位置取帧,现有效果原样保留
  • 调试叠层(提前量、对齐后的偏差)
B2

节拍跟踪

  • BeatTracker、BeatGrid,以及跳转和无缝衔接的处理
  • BeatClock 和单元测试
  • 调试叠层加上 BPM、置信度和首拍
B3

表现

  • 封面弹跳、光晕、流速推力、形状光环、进度条波速
  • 三档强度重新取值(含新的“强烈”),在真机上核对亮度约束
  • 三端核对(手机、平板、电视 30 帧)
B4

设置和海报墙

  • 跟拍校准滑杆、说明文字
  • 海报墙的大卡跟拍;排在海报墙 P2(放大和跟随)之后

已确认(2026-10-29)

1. 外轮廓不踩拍保持 40 秒一圈匀速转,标准档只保留封面弹跳。
2. 加一档“强烈”弹跳 5%、光晕 +0.26、流速推到 4.4 倍、每拍都有光环;整屏亮度仍守住每帧 3%。
3. 跟拍校准放进设置播放页 › 律动 里的滑杆,−200 到 +200ms,只在选了“律动”时显示。