乐于分享
好东西不私藏

iOS实时音高检测FFT与YIN算法

iOS实时音高检测FFT与YIN算法

作者:阿波罗的光 | 独立开发者


大家好,我是阿波罗的光。今天来聊一个我在开发 PianoMate 钢琴陪练 App 过程中踩过的最大的技术坑:如何在 iOS 上实现实时、准确的音高检测。

一、问题定义

钢琴陪练 App 的核心功能,是听懂用户弹了什么。用户按下琴键,App 要立刻告诉用户:刚才弹的是哪个音,准不准。

这是一个硬核信号处理问题:

• 钢琴最低音 A0 频率 27.5 Hz,最高音 C8 频率 4186 Hz,范围跨越 7 个八度
• 用户弹奏时会同时按多个键产生和弦
• 钢琴声音会自然衰减,采样窗口有限
• 背景噪音也会干扰识别

二、技术选型

我调研了三条路:

方案 A:预训练神经网络模型
缺点:延迟高(>200ms),误识别率高,不适合钢琴调校

方案 B:Chromaprint 指纹库匹配
缺点:需要大量钢琴指纹库,体积大,无法实时检测单音

方案 C:FFT + 音高检测算法
优点:延迟低(<50ms),精确到音分数,可本地运行

最终选了方案 C。

三、技术方案设计

1. 音频采集

用 AVAudioEngine 做实时录音。采样率必须是 44100 Hz,否则高频信息丢失:

let audioEngine = AVAudioEngine()
let inputNode = audioEngine.inputNode
let format = inputNode.outputFormat(forBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 4096, format: format) { buffer, time in
    // 在这里拿到原始 PCM 数据
}

2. FFT 变换

用 Accelerate 框架的 vDSP,计算快且省电。加 Hanning 窗减少频谱泄漏:

import Accelerate
let fftSetup = vDSP_create_fftsetup(log2n, FFTRadix(kFFTRadix2))!
vDSP_hann_window(hannWindow, vDSP_Length(bufferSize), Int32(vDSP_HANN_NORM))
vDSP_vmul(inputBuffer, 1, hannWindow, 1, windowedBuffer, 1, vDSP_Length(bufferSize))
vDSP_fft_zrip(fftSetup, &splitComplex, log2n, FFTDirection(FFT_FORWARD))

3. YIN 音高检测算法

用 YIN 算法做基频检测。核心思路:比较信号与自身平移版本的自相关性,找出自相关最小值对应的延迟:

func yinPitch(samples: [Float], sampleRate: Double) -> Double? {
    let threshold: Float = 0.1
    let halfLen = samples.count / 2
    var diff = [Float](repeating: 0, count: halfLen)
    for tau in 0..<halfLen {
        var sum: Float = 0
        for j in 0..<halfLen { sum += pow(samples[j] - samples[j+tau], 2) }
        diff[tau] = sum
    }
    var cmdf = [Float](repeating: 0, count: halfLen)
    var runningSum: Float = 0
    for tau in 1..<halfLen {
        runningSum += diff[tau]
        cmdf[tau] = diff[tau] * Float(tau) / runningSum
    }
    for tau in Int(2 * sampleRate / 20)..<halfLen {
        if cmdf[tau] < threshold { return sampleRate / Double(tau) }
    }
    return nil
}

4. 音符匹配

let midi = 12 * log2(frequency / 440.0) + 69
let noteNames = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"]
let noteName = noteNames[midi % 12]
// 输出:"C4"、"F#5" 等

四、踩过的坑

坑1:采样率不对,高音消失

第一次用 8000 Hz 采样率,Nyquist 只有 4000 Hz,C8(4186 Hz)完全丢失。改成 44100 Hz 解决。

坑2:频谱泄漏导致误判

直接 FFT 导致 A440 旁边大量假峰。加 Hanning 窗解决。

坑3:iOS 17 API 崩溃

AVAudioApplication.requestRecordPermission 是 iOS 17 only,在 iOS 16 上崩溃。加 #available(iOS 17, *) 条件判断解决。

坑4:录音权限拒绝无降级

用户拒绝麦克风权限后 App 直接白屏。修复:判断拒绝场景,显示引导页。

坑5:双 @main 冲突

XcodeGen 生成的 target 下有两个 @main 文件导致编译失败。在 exclude 规则中排除子目录解决。

五、完成效果

PianoMate 音高检测模块:

• 延迟小于 50ms
• A440 参考音准确率超过 95%
• A0 ~ C8 全部 88 键支持
• CPU 占用小于 5%

FFT + YIN 算法是在 iOS 上实现钢琴音高检测的黄金组合。核心在于:正确的采样率、合适的窗函数、YIN 阈值调优,以及对 iOS 版本差异的兼容处理。代价是踩坑无数,但这不正是开发的乐趣所在吗?


如果你在做类似的音频类 App,欢迎留言交流。
也欢迎关注公众号「阿波罗的光」,一起聊聊独立开发那些事。