用途
该技能用于在音乐播放器中实现自动的、类似 DJ 的交叉淡入淡出过渡。给定即将退出的曲目和即将进入的曲目的速度(BPM)与调性,它可以推导出三件事:
- 混合(blend)应该持续多久;
- 需要将退出曲目的速度弯曲多少,使两者在重叠期间共享节拍;
- 需要将其音高偏移多少,使两个调性不冲突。
所有调整仅应用于退出曲目,并随其结束而消失,因此进入曲目从第一个独奏时刻起就以自然的速度和音高播放。
主要能力
时长推导
基础时长来自当前速度(较慢的曲目 → 更长的混合),乘以速度差距因子(差距越大 → 越长,以隐藏差距),再乘以调性差距因子(和声距离越远 → 越长,以掩盖冲突),然后对齐到整数拍并限制在合理范围内。
速度归一化
在比较速度之前,必须对半速/倍速进行归一化。70 和 140 是相同的律动;原始比率会显示 2.0,导致所有下游决策将其视为最大可能的差距。需要先将比率折叠到 1 附近的窗口内,并在循环前防范非正值,否则零速度会导致无限循环:
if (currentBpm <= 0 || nextBpm <= 0) return 1.0f
var ratio = nextBpm.toFloat() / currentBpm.toFloat()
while (ratio > 1.5f) ratio /= 2f
while (ratio < 0.67f) ratio *= 2f
循环按顺序运行,因此坏的一对不会使它们交替。非互逆边界实际影响的是对称性:一个方向被折叠到与另一个方向不同的窗口,(70, 140) 不再与 (140, 70) 一致。
未知调性处理
未知不能读作兼容。无法解析的调性和完全缺失的调性必须返回相同的回退值,且该回退值必须大于 1.0,即“因为一无所知所以混合更久”。为无法解析的调性返回 1.0 意味着在你信息最少的曲目上混合反而更短,这完全反了:
val currentCode = keyToWheel(currentKey, currentScale) ?: return UNKNOWN_GAP_DEFAULT_FACTOR
单侧弯曲
只弯曲一侧,而不是两侧。调整进入曲目会使它在过渡后处于错误的速度,并需要第二次斜坡来撤销;只调整退出曲目是自清洁的——混合完成时即释放。比率是 nextTempo / currentTempo,应用于退出播放器,使其有效速度落在进入曲目的速度上。
速度斜坡前置
线性斜坡到终点只有在没人能听到时才匹配速度。使用等功率音量曲线时,退出曲目在混合进行到 70% 时已经远低于可听范围;在 100% 才达到目标的速度斜坡没有匹配任何可听内容。需要前置加载,然后保持:
val linear = if (rampPortion <= 0f) 1f else (progress / rampPortion).coerceAtMost(1f)
val ramp = linear * linear * (3f - 2f * linear) // smoothstep: slow → fast → slow
val outSpeed = lerp(1.0f, targetSpeedRatio, ramp)
其中 rampPortion 约为 0.6——在混合进行到 60% 时达到目标并保持,这样大部分可听重叠都是节拍对齐的。
量化与去重
量化速度和音高步进,并且只推送变化的值。在一次过渡中重写播放参数五十次,即使差异小于百分之一,也会重新触发重采样器,每次重新触发都是可听的咔嗒声。对齐到步进(2% 可行)并与上次推送的值比较:
fun quantize(v: Float) = Math.round(v / SPEED_PITCH_STEP) * SPEED_PITCH_STEP
val q = quantize(rawSpeed * userSpeed)
if (q != lastPushed) { player.playbackParameters = PlaybackParameters(q, qPitch); lastPushed = q }
注意 rawSpeed * userSpeed:自动比率乘以用户自己的选择;去掉它,用户的设置会在每次过渡期间被静默丢弃。
安全范围拒绝
拒绝超出安全范围的比率,而不是钳制它。超过大约 ±25% 后,弯曲听起来像故障而不是混音。钳制值是两者中最糟的——仍然可听地拉伸,且仍然不匹配。返回 1.0,让过渡成为普通的淡入淡出。
时长量化
将时长量化为整数拍,然后钳制。即使其他所有数字都正确,在乐句中间结束的混合听起来也像错误。从音乐拍数列表中选择最接近的,然后钳制到 UI 可以接受的范围:
val beatMs = 60_000.0 / currentBpm
val beats = BEAT_COUNT_OPTIONS.minByOrNull { abs(it * beatMs - adjustedTargetMs) } ?: DEFAULT_BEATS
return (beats * beatMs).toInt().coerceIn(MIN_DURATION_MS, MAX_DURATION_MS)
调性距离计算
调性距离是循环的,外加一个调式项。在 DJ 软件使用的十二位置轮上,相邻数字和同一数字的关系大小调是兼容的。距离是绕圆环的较短路径加上调式不同时的一:
val diff = abs(a.number - b.number)
return minOf(diff, 12 - diff) + if (a.isMinor != b.isMinor) 1 else 0
距离 ≤ 1 不需要偏移——在计算任何音高比率之前提前返回。
半音是比率
半音是比率,不是偏移。2^(n/12),即 exp(ln(2) * n / 12)。从最小偏移(-1, +1, -2, +2)向外搜索,取第一个使距离 ≤ 1 的;超过全音后,偏移比它修复的冲突更明显,所以放弃并返回 1.0。
调性名称归一化
在匹配之前归一化调性名称。来源会拼写出临时记号——FSharp、CFlat——而不是使用符号,且大小写不一。归一化后映射到半音;对任何无法识别的返回哨兵值(-1,或轮查找返回 null),绝不返回 0,因为 0 是真实调性。
元数据延迟处理
元数据到达很晚,或者根本不到。在计算前惰性加载并按曲目 ID 缓存,并让每个派生值干净地回退——固定回退时长、比率 1.0——这样没有分析的曲目仍然可以过渡,只是没有匹配。
使用方式
该技能以代码实现形式提供,核心逻辑位于 core/media/media3/src/main/java/com/maxrave/media3/exoplayer/CrossfadeExoPlayerAdapter.kt。使用方式为在音乐播放器项目中集成该适配器,并确保曲目的 BPM 和调性元数据可用(来自流媒体源元数据、本地分析或标签)。
验证方法
从仓库根目录针对上述文件运行:
grep -n "UNKNOWN_GAP_DEFAULT_FACTOR\|lastOutgoingSpeed\|playbackParameters =" \
core/media/media3/src/main/java/com/maxrave/media3/exoplayer/CrossfadeExoPlayerAdapter.kt
python3 -c "
for r in (140/70, 70/140):
while r > 1.5: r /= 2
while r < 0.67: r *= 2
print(r)"
通过条件:因子大于 1.0(此处为 1.25)并在四个列出的路径上按名称重用;playbackParameters = 写入位于 if (qOutSpeed != lastOutgoingSpeed || …) 之后;折叠对两个比率都打印 1.0。
手动验证:排队两首速度差距大且调性兼容的曲目,开启 DJ 交叉淡入淡出。在过渡进行到约 60% 时聆听——退出曲目的节拍应该已经匹配,而不是仍在滑动。
适用场景
- 音乐播放器中的自动 DJ 过渡;
- 需要根据曲目元数据动态计算交叉淡入淡出参数的场景;
- 对过渡质量有要求、希望避免可听故障的播放器实现。
限制
- 依赖曲目的 BPM 和调性元数据;元数据缺失时只能回退到普通淡入淡出;
- 速度弯曲超过约 ±25% 时会被拒绝,无法实现大幅速度匹配;
- 调性偏移超过全音时放弃,无法修复远距离调性冲突;
- 实现细节与具体播放器框架(Media3 ExoPlayer)绑定。