Swan's Quest, Chapter 2: A time for tones
Developer Tools 入门 8m

天鹅任务第二章:音调时刻

Swan's Quest, Chapter 2: A time for tones

2020年6月23日

在 Apple 官方观看视频

一句话判断

Swan’s Quest 第二章把方向反转——你需要用代码让设备「发出」特定频率的声音来解锁机关,在第一章「听」的基础上加上「说」,让你完整理解 AVFoundation 的音频输入输出管道。

这场 Session 讲了什么

第二章的挑战是:用代码生成特定频率的音调(tone),通过设备扬声器播放出来。和第一章的「检测声音」不同,这一章要求你「生成声音」。

你需要用 AVAudioEngine 构建一个音频输出管道,用 AVAudioPlayerNode 或自定义的 AVAudioSourceNode 生成正弦波信号,然后播放指定频率和时长的音调。

挑战的趣味在于:你需要按照特定的频率序列播放音调(像密码锁一样),播放正确序列后才能解锁下一关。这涉及到定时控制(每个音调的起止时间)和频率精度。

值得深挖的点

AVAudioSourceNode 的使用

AVAudioSourceNode 是 iOS 13 引入的,允许你用闭包实时生成音频数据。你不需要预先录制音频文件,而是用数学公式(正弦函数)实时计算每一帧的采样值。这个能力在游戏音效生成、音乐创作工具等场景中非常有用。

代码片段

import AVFoundation

// 生成指定频率的正弦波音调
class ToneGenerator {
    let engine = AVAudioEngine()
    let sampleRate: Double
    var phase: Double = 0
    
    init() {
        sampleRate = engine.outputNode.outputFormat(forBus: 0).sampleRate
    }
    
    func playTone(frequency: Double, duration: TimeInterval) {
        let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)!
        
        // 创建音频源节点,实时生成正弦波
        let sourceNode = AVAudioSourceNode(format: format) { [weak self] _, _, frameCount, audioBufferList in
            guard let self = self else { return noErr }
            
            let bufferList = UnsafeMutableAudioBufferListPointer(audioBufferList)
            let buffer = bufferList[0]
            let frames = UnsafeMutableBufferPointer<Float>(buffer)
            
            // 生成正弦波采样数据
            for i in 0..<Int(frameCount) {
                let sample = sin(2.0 * .pi * self.phase)
                frames[i] = Float(sample) * 0.5  // 音量 50%
                self.phase += frequency / self.sampleRate
                
                // 保持 phase 在 0-1 范围内,避免精度问题
                if self.phase > 1.0 { self.phase -= 1.0 }
            }
            
            return noErr
        }
        
        // 连接音频图
        engine.attach(sourceNode)
        engine.connect(sourceNode, to: engine.mainMixerNode, format: format)
        
        try? engine.start()
        
        // 播放指定时长后停止
        DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in
            self?.engine.stop()
        }
    }
    
    // 按序列播放多个音调
    func playSequence(frequencies: [Double], noteDuration: TimeInterval) {
        for (index, freq) in frequencies.enumerated() {
            DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * noteDuration * 1.5) {
                self.playTone(frequency: freq, duration: noteDuration)
            }
        }
    }
}

最佳实践

  1. 控制 phase 变量的精度。长时间运行时 phase 的累积误差会导致频率偏移。每次超过 1.0 时要取模重置。
  2. 音量不要设太大AVAudioSourceNode 生成的音频是原始浮点数据,直接全量输出可能爆音。乘以 0.3-0.5 的系数更安全。

还有什么值得关注

  • 完成第二章需要和第一章配合——你发出的音调可能需要被另一台设备检测到,这也暗示了设备间通信的可能性。
  • AVAudioSourceNode 的闭包在实时音频线程中执行,不能做内存分配或调用 Objective-C/Swift 的运行时方法。
  • 第三章会转向完全不同的技术领域,不需要完成前两章也能独立挑战。
WWDC 2020