SuperAPI AI 工具集

audio-fade-separate-gain-line

编程与开发

查看项目

更新于 10-17 16:16

技能介绍

用途

该技能用于在音频播放中实现程序化淡出,典型场景包括睡眠定时器(到点前渐弱至静音再暂停)、闹钟渐强、以及闪避(duck)。核心规则是:淡出必须拥有自己独立的增益线,而不是直接改写用户音量。

主要能力与核心规则

淡出拥有自己的增益因子(0f..1f),在数值到达音频路径时与用户音量相乘。资料给出的接口示意:

interface MediaPlayerInterface {
    var volume: Float           // the user's level — reported back to the UI
    var sleepFadeFactor: Float  // 0f..1f, the fade's own line
}

Android(Media3)中可在链路中放置一个小型 AudioProcessor,每个播放器一个实例,全部读取同一个 @Volatile 字段:arrayOf(crossfadeFilter, sleepFade)。

桌面端引擎通常暴露两个层级——设备混音器层级与软件层级。把淡出放在其中一个、把交叉渐变放在另一个,二者相乘,彼此无需知道对方存在。

常见陷阱

  1. 渐变用户音量:这是最直观的实现,但错了两处。该值会通过音量变化回调上报,用户会看到滑块爬向零;更糟的是,若进程在淡出中途结束,持久化的音量已接近零,应用重启后静音但滑块看起来是满的,且没有代码路径能恢复,因为没人知道淡出被中断。用户音量必须是淡出永不写入的东西。

  2. 渐变到零的瞬间就暂停:增益施加在有缓冲的输出级之前。当渐变结束时,仍有数百毫秒已衰减但未静音的音频在队列中,暂停会在明显可闻的电平上切断它。应在暂停前保持一小段静音尾巴:

delay(remaining - fadeMs - tailMs)
fadeOutForSleep(fadeMs)     // ramps to 0
delay(tailMs)               // let the tail drain
player.pause()

尾巴时长应针对平台自身的输出缓冲来调整,在边界处用耳朵测量,而不是照抄一个数字。淡出与尾巴都必须被限制在剩余时间内(fadeMs.coerceAtMost(remaining),然后 tailMs.coerceAtMost(remaining - fadeMs)),否则“当前歌曲结束”定时器会越过歌曲结尾并在下一首中暂停。

  1. 在 pause() 之后从调用方恢复增益:这是最微妙的一个。pause() 是异步的,会在中途挂起(提交交叉渐变会加入一个 job)。因此单线程调度器无法保证“先暂停、再恢复”的顺序:挂起释放了线程,排队的恢复先运行,混音器在最后一段音频上重新打开——淡出之后出现一个短促的隆起。恢复应放在播放器适配器自己的完成路径中:
override fun pause() {
    scope.launch {
        try { /* commit any fade, then pause */ }
        finally { internalSleepFadeFactor = 1f }   // playback has genuinely stopped here
    }
}
  1. 把恢复放在正常路径而非 finally 中:上述代码块可能抛出或被取消——提交交叉渐变会加入一个可能自身被取消的 job。任何跳过恢复的路径都会让进程余下的每个采样乘以约 0,而音量滑块仍是满的且无法恢复。finally 在这里不是防御性风格,而是唯一正确的位置。

  2. 遗漏 pause() 的提前返回分支:向远程播放会话的交接通常会在协程启动前就返回。该分支必须内联清除因子,否则衰减会超出远程会话:处理器仍在本地链路中,播放恢复时会继续乘以约 0。

  3. 定时器自身的 finally 重复恢复:调用方仍需要为取消路径提供恢复(用户关闭定时器、作用域在淡出中途消失)。应加保护,使完成路径把 job 留给适配器:

finally { if (!stoppedPlayback) player.sleepFadeFactor = 1f }
  1. 捕获增益而非每次重新读取:处理器必须在每个缓冲区上读取该字段,而不是在配置时读取一次——否则在交叉渐变中途开始或持续推进的淡出永远不会到达输出。同理,桌面端 setter 应在排队任务内部重新读取字段,而不是捕获参数:如果队列积压,待处理的写入会合并到最新值,而不是重放一个过期的渐变。

  2. 在全增益时让处理器变为非活动:报告 isActive = false 的处理器会被从链路中移除,直到下一次 flush 才会被重新咨询——而那时定时器已经停止了播放。应让它始终保持活动,并把全增益情况做成批量拷贝:

override fun isActive() = true

override fun queueInput(input: ByteBuffer) {
    val out = replaceOutputBuffer(input.remaining())
    val g = gain()
    if (g >= 1f) { out.put(input); out.flip(); return }   // every buffer, all playback
    input.order(ByteOrder.nativeOrder())
    while (input.remaining() >= 2) out.putShort((input.short * g).toInt().toShort())
    while (input.hasRemaining()) out.put(input.get())     // consume ALL of it
    out.flip()
}

哪怕留下一个字节未被消费,管线就会永远重新提供同一个缓冲区,毫无进展。

  1. 线性渐变与未限制的步数:应使用与交叉渐变相同的等功率(余弦)曲线——cos(progress * PI / 2)——因为响度是按对数感知的,线性渐变听起来会先早早下降然后拖尾。并且要把步数限制在时长内:50 步、1 毫秒下限,无论请求的时长是多少都只花 50 毫秒,于是 1 秒的淡出会悄悄变成瞬间完成。
val steps = nominalSteps.toLong().coerceAtMost(durationMs).toInt()
val delayPerStep = (durationMs / steps).coerceAtLeast(1L)

验证方式

  • 观察整个淡出过程中的音量滑块:它必须不动。
  • 在淡出中途杀掉进程,重新启动,按播放:立即以用户音量播放。
  • 让定时器完成,然后按播放:满电平,无隆起,无残留衰减。
  • 在淡出中途取消定时器:电平在一两步内恢复。
  • 如果平台报告已施加的增益,在渐变期间每秒记录一次,确认它在暂停前达到 0、暂停后达到 1,且从不在中间值。

适用场景与限制

适用于需要在播放器层实现淡出、渐强或闪避的音频应用,尤其是 Android(Media3)与桌面端引擎。资料未提供具体平台版本要求、依赖库、安装命令或完整可运行示例;上述代码为资料中给出的示意片段,实际集成需结合具体播放器适配器。