SuperAPI AI 工具集

beatmatched-automix

设计与创作

查看项目

更新于 10-17 16:16

技能介绍

用途

该技能用于在音乐播放器中实现自动的、类似 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)绑定。