You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何通过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)
    }
}

代码说明

  1. 线程同步:所有振荡器的参数修改都在AKCallbackInstrument的回调中执行,这个回调运行在音频线程,完全和音频渲染时钟同步,不会有延迟。
  2. 灵活调度:通过scheduleTone方法可以随时添加任意频率、任意时间点的声音事件,不需要预生成音频文件。
  3. 循环控制:用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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.28 04:23:02