如何通过AudioKit实现无抖动的任意频率周期性纯音播放?
解决AudioKit振荡器精准调度抖动问题的方案
你遇到的10ms抖动问题,核心原因是UI线程(CADisplayLink)和音频线程的调度不同步。AKOscillator的play()/stop()或者振幅修改都是在UI线程执行的,而音频渲染是在独立的高优先级音频线程中进行,两者的时间基准不一致,就会导致控制指令的执行时机出现延迟偏差。预生成WAV用AKPlayer播放准确,是因为Player的播放进度完全和音频时钟绑定,所有调度都在音频线程内完成。
想要兼顾任意频率的灵活性和毫秒级精准调度,把振荡器封装成MIDI乐器是非常高效的方案,下面给你具体的实现思路和代码示例:
核心思路:用音频线程内的回调控制振荡器
AudioKit提供了AKCallbackInstrument,它可以接收带时间戳的MIDI事件,并且所有回调逻辑都运行在音频线程中,完美解决线程同步问题。我们可以用它来触发振荡器的频率切换、启停,所有操作都和音频时钟严格对齐,不会有抖动。
完整代码实现
import AudioKit class PreciseTonePlayer { private let engine = AudioEngine() private let oscillator = AKOscillator(waveform: AKTable(.sine)) private let callbackInstrument = AKCallbackInstrument() private let sequencer: AKSequencer init() { // 连接音频节点 oscillator.connect(to: engine.output) // 配置MIDI回调:在音频线程内处理声音启停和频率切换 callbackInstrument.callback = { status, note, velocity in switch status { case .noteOn: // 把MIDI音符转换成对应频率,直接在音频线程修改振荡器参数 self.oscillator.frequency = MIDINoteNumber(note).frequency self.oscillator.amplitude = Double(velocity) / 127.0 self.oscillator.start() case .noteOff: self.oscillator.amplitude = 0.0 self.oscillator.stop() default: break } } // 初始化 sequencer 用于调度音频事件 sequencer = AKSequencer() let track = sequencer.addTrack() track.setMIDIOutput(callbackInstrument.midiIn) do { try engine.start() } catch { print("Audio Engine启动失败: \(error.localizedDescription)") } } // 动态调度任意频率的声音:指定频率、开始时间(秒)、持续时间(秒) func scheduleTone(frequency: Double, startTime: Double, duration: Double) { let noteNumber = MIDINoteNumber(frequency: frequency) let startPosition = AKDuration(seconds: startTime) let noteDuration = AKDuration(seconds: duration) sequencer.tracks[0].add( noteNumber: noteNumber, velocity: 127, position: startPosition, duration: noteDuration ) } // 示例:循环播放400Hz(50ms) → 静音(100ms) → 600Hz(50ms) func startLoopSequence() { let soundDuration = 0.05 let silenceDuration = 0.1 var currentTime = 0.0 // 添加第一组声音事件 scheduleTone(frequency: 400, startTime: currentTime, duration: soundDuration) currentTime += soundDuration + silenceDuration // 添加第二组声音事件 scheduleTone(frequency: 600, startTime: currentTime, duration: soundDuration) currentTime += soundDuration + silenceDuration // 设置循环播放 sequencer.setLoopInfo(AKDuration(seconds: currentTime), numberOfLoops: -1) sequencer.play() } // 停止所有播放 func stop() { sequencer.stop() oscillator.stop() } } // 扩展:在频率和MIDI音符之间互相转换 extension MIDINoteNumber { init(frequency: Double) { self = MIDINoteNumber(69 + 12 * log2(frequency / 440.0)) } var frequency: Double { return 440.0 * pow(2.0, (Double(self) - 69.0) / 12.0) } }
代码说明
- 线程同步:所有振荡器的参数修改都在
AKCallbackInstrument的回调中执行,这个回调运行在音频线程,完全和音频渲染时钟同步,不会有延迟。 - 灵活调度:通过
scheduleTone方法可以随时添加任意频率、任意时间点的声音事件,不需要预生成音频文件。 - 循环控制:用
AKSequencer来管理事件序列,支持循环播放,时间精度基于音频采样率(通常44.1kHz或48kHz),误差远小于1ms。
替代方案:自定义音频节点渲染
如果不想用MIDI,也可以直接用AKOperationGenerator在音频渲染的核心逻辑中直接控制声音:
class CustomPrecisePlayer: AKNode { init(sampleRate: Double) { let generator = AKOperationGenerator { _ in // 用计数器跟踪音频样本数,计算当前播放阶段 let counter = AKOperation.counter() let soundSamples = AKOperation.scalar(0.05 * sampleRate) let silenceSamples = AKOperation.scalar(0.1 * sampleRate) let cycle = soundSamples + silenceSamples // 判断当前是否在播放阶段 let playFirstTone = counter % cycle < soundSamples let playSecondTone = counter % cycle >= (soundSamples + silenceSamples) // 切换频率和振幅 let frequency = AKOperation.select(playFirstTone, 400, playSecondTone ? 600 : 0) let amplitude = AKOperation.select(playFirstTone || playSecondTone, 1, 0) return AKOperation.sineWave(frequency: frequency) * amplitude } super.init(avAudioNode: generator.avAudioNode) generator.start() } }
这个方案适合固定序列的播放,但动态修改频率和时长的灵活性不如MIDI乐器方案。
总结
- 放弃用CADisplayLink在UI线程控制振荡器,所有音频相关的控制逻辑必须放在音频线程内
- 封装成MIDI乐器(
AKCallbackInstrument)是兼顾灵活性和精度的最优解 - 用
AKSequencer或直接发送带时间戳的MIDI事件来调度声音,完全对齐音频时钟
内容的提问来源于stack exchange,提问作者bienqueda
相关产品推荐
相关产品推荐

