作者:阿波罗的光 | 独立开发者
大家好,我是阿波罗的光。今天来聊一个我在开发 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,欢迎留言交流。
也欢迎关注公众号「阿波罗的光」,一起聊聊独立开发那些事。
夜雨聆风