零基础学 ArkUI25:手把手教你开发一个乐器调音器 App
·
🎸 零基础学 ArkUI25:手把手教你开发一个乐器调音器 App
博主说: 前面我们做了录音器,今天做它的"进阶版"——乐器调音器!调音器通过麦克风采集声音,分析音频频率,告诉你当前音高是哪个音符,音准是偏高还是偏低。这个项目会涉及音频采集、频率分析(FFT)、Canvas 实时绘图等硬核知识。做完它,你就是 ArkUI 音频处理的专家了!
📱 应用场景
吉他手调弦、小提琴手校音、钢琴调律、尤克里里调音……不管什么乐器,调音都是基本功。我们要开发的调音器会实现:
- 麦克风实时采集音频
- 音频频率分析(检测基频)
- 显示当前音符(C4/D4/E4/F4/G4/A4/B4)
- 音准指示器(偏低 / 标准 / 偏高)
- 可视化波形显示
- 乐器模式切换(吉他/小提琴/尤克里里预设)
- 参考音播放(可选扩展)
⚙️ 运行环境要求
| 项目 | 版本要求 |
|---|---|
| 操作系统 | Windows 10/11、macOS 13+ 或 Ubuntu 22.04+ |
| DevEco Studio | 5.0.3.800 及以上 |
| HarmonyOS SDK | API 12(HarmonyOS 5.0.0)及以上 |
| 应用模型 | Stage 模型 |
| 权限要求 | ohos.permission.MICROPHONE(麦克风权限) |
| 核心 API | @ohos.multimedia.audio(音频录制)+ @ohos.multimedia.media |
| 开发语言 | ArkTS |
🛠️ 实战:从零搭建乐器调音器
Step 1:理解调音器工作原理
调音器的核心是 音频频率检测:
麦克风采集 → 音频数据(PCM) → FFT 频谱分析 → 检测基频
↓
显示器 ← 音准偏差计算 ← 对比标准音高 ← 确定最近音符
标准音高参考表(A4 = 440Hz):
| 音符 | 频率 (Hz) | 吉他弦 |
|---|---|---|
| C4 | 261.63 | — |
| D4 | 293.66 | — |
| E4 | 329.63 | ①弦(最细) |
| F4 | 349.23 | — |
| G4 | 392.00 | ③弦 |
| A4 | 440.00 | ②弦(基准音) |
| B4 | 493.88 | — |
| E3 | 164.81 | ⑥弦(最粗) |
| A3 | 220.00 | ⑤弦 |
| D3 | 146.83 | ④弦 |
Step 2:项目结构
com.example.tuner/
├── entry/src/main/ets/
│ ├── entryability/EntryAbility.ts
│ └── pages/
│ └── Index.ets ← 主页面
├── entry/src/main/module.json5 ← 配置麦克风权限
└── build-profile.json5
Step 3:完整代码
// pages/Index.ets — 乐器调音器主页面
import audio from '@ohos.multimedia.audio';
import abilityAccessCtrl from '@ohos.abilityAccessCtrl';
// 音符频率表(12 平均律,A4 = 440Hz)
const NOTE_FREQUENCIES: { name: string; freq: number; octave: number }[] = [
{ name: 'C', freq: 261.63, octave: 4 },
{ name: 'C#', freq: 277.18, octave: 4 },
{ name: 'D', freq: 293.66, octave: 4 },
{ name: 'D#', freq: 311.13, octave: 4 },
{ name: 'E', freq: 329.63, octave: 4 },
{ name: 'F', freq: 349.23, octave: 4 },
{ name: 'F#', freq: 369.99, octave: 4 },
{ name: 'G', freq: 392.00, octave: 4 },
{ name: 'G#', freq: 415.30, octave: 4 },
{ name: 'A', freq: 440.00, octave: 4 },
{ name: 'A#', freq: 466.16, octave: 4 },
{ name: 'B', freq: 493.88, octave: 4 },
];
// 吉他调音预设
const GUITAR_TUNING = [
{ string: '6弦', note: 'E3', freq: 164.81 },
{ string: '5弦', note: 'A3', freq: 220.00 },
{ string: '4弦', note: 'D3', freq: 146.83 },
{ string: '3弦', note: 'G3', freq: 392.00 },
{ string: '2弦', note: 'B3', freq: 493.88 },
{ string: '1弦', note: 'E4', freq: 329.63 },
];
@Entry
@Component
struct InstrumentTuner {
// ======== 状态变量 ========
@State detectedFreq: number = 0; // 检测到的频率
@State targetNote: string = '--'; // 最近音符名
@State targetFreq: number = 440.00; // 目标频率
@State centOffset: number = 0; // 音分偏差 (-50 ~ +50)
@State isListening: boolean = false; // 是否正在监听
@State permissionGranted: boolean = false;
@State instrumentMode: string = '吉他'; // 乐器模式
@State activeString: number = -1; // 高亮的弦
// 吉他弦状态(用于显示每根弦的调音状态)
@State strings: { label: string; note: string; tuned: boolean }[] = [
{ label: '6弦', note: 'E', tuned: false },
{ label: '5弦', note: 'A', tuned: false },
{ label: '4弦', note: 'D', tuned: false },
{ label: '3弦', note: 'G', tuned: false },
{ label: '2弦', note: 'B', tuned: false },
{ label: '1弦', note: 'E', tuned: false },
];
private audioRecorder!: audio.AudioRecorder;
private timerId: number = -1;
// ======== 生命周期 ========
aboutToAppear() {
this.requestPermission();
}
// ======== 权限申请 ========
async requestPermission() {
const atManager = abilityAccessCtrl.createAtManager();
try {
const grantStatus = await atManager.requestPermissionsFromUser(
getContext(this), ['ohos.permission.MICROPHONE']
);
this.permissionGranted = grantStatus[0] === 0; // 0 = granted
} catch (err) {
console.error('权限申请失败:', JSON.stringify(err));
this.permissionGranted = false;
}
}
// ======== 开始/停止监听 ========
startListening() {
if (!this.permissionGranted) {
AlertDialog.show({ message: '请先授予麦克风权限' });
return;
}
this.isListening = true;
// 启动音频录制(实际项目中用 AudioCapturer 获取 PCM 数据)
// 这里用 setInterval 模拟频率检测
this.simulateFrequencyDetection();
}
stopListening() {
this.isListening = false;
if (this.timerId > -1) {
clearInterval(this.timerId);
this.timerId = -1;
}
}
// ======== 模拟频率检测(真机用 FFT) ========
simulateFrequencyDetection() {
this.timerId = setInterval(() => {
// 模拟检测到的频率(在目标频率附近随机波动)
const target = this.targetFreq;
const random = (Math.random() - 0.5) * 20;
this.detectedFreq = target + random;
this.calculatePitch();
}, 200);
}
// ======== 音高计算 ========
calculatePitch() {
// 找到最近的音符
let minDiff = Infinity;
let nearestNote = NOTE_FREQUENCIES[0];
for (const note of NOTE_FREQUENCIES) {
const diff = Math.abs(this.detectedFreq - note.freq);
if (diff < minDiff) {
minDiff = diff;
nearestNote = note;
}
}
this.targetNote = nearestNote.name + nearestNote.octave;
this.targetFreq = nearestNote.freq;
// 计算音分偏差(1 个半音 = 100 音分)
// cent = 1200 * log2(f / f0)
const cent = 1200 * Math.log2(this.detectedFreq / nearestNote.freq);
this.centOffset = Math.round(cent);
// 判断每根弦是否已调准(偏差 < 5 音分)
this.strings.forEach((s, i) => {
const target = GUITAR_TUNING[i]?.freq || 440;
s.tuned = Math.abs(this.detectedFreq - target) < 3;
});
}
// ======== 选择目标弦 ========
selectString(index: number) {
this.activeString = index;
const tuning = GUITAR_TUNING[index];
if (tuning) {
this.targetFreq = tuning.freq;
this.targetNote = tuning.note;
}
}
// ======== 获取音准指示器颜色 ========
getTunerColor(): string {
const abs = Math.abs(this.centOffset);
if (abs < 5) return '#34C759'; // 绿色 = 准
if (abs < 20) return '#FF9500'; // 橙色 = 微偏
return '#FF3B30'; // 红色 = 偏很多
}
// ======== 获取音准指示器位置 ========
getIndicatorOffset(): number {
// -50 ~ +50 音分映射到 0% ~ 100%
const clamped = Math.max(-50, Math.min(50, this.centOffset));
return ((clamped + 50) / 100) * 100;
}
// ======== UI 构建 ========
build() {
Column() {
// ---- 顶部标题 ----
Text('🎸 乐器调音器')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.padding(16)
// ---- 乐器模式切换 ----
Row() {
ForEach(['吉他', '小提琴', '尤克里里'], (mode: string) => {
Button(mode)
.backgroundColor(this.instrumentMode === mode ? '#007AFF' : '#F0F0F0')
.fontColor(this.instrumentMode === mode ? '#fff' : '#333')
.borderRadius(16)
.height(32)
.fontSize(14)
.onClick(() => { this.instrumentMode = mode; })
})
}
.width('92%')
.justifyContent(FlexAlign.SpaceEvenly)
.margin({ bottom: 16 })
// ---- 音符显示区 ----
Column() {
Text(this.targetNote)
.fontSize(64)
.fontWeight(FontWeight.Bold)
.fontColor(this.detectedFreq > 0 ? '#333' : '#ccc')
Text(this.detectedFreq > 0 ? `${this.detectedFreq.toFixed(1)} Hz` : '等待输入...')
.fontSize(16)
.fontColor('#888')
}
.padding(20)
// ---- 音准指示器 ----
Column() {
Text(`音准偏差: ${this.centOffset > 0 ? '+' : ''}${this.centOffset} 音分`)
.fontSize(14)
.fontColor(this.getTunerColor())
.fontWeight(FontWeight.Bold)
// 指示器滑块
Row() {
// 0% 位置 = 偏低
Text('偏低').fontSize(12).fontColor('#FF3B30')
Column() {
// 刻度线
Row() {
ForEach([0, 1, 2, 3, 4], () => {
Divider().vertical(true).height(12).color('#ddd')
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
// 指针
Divider()
.vertical(true)
.height(24)
.color(this.getTunerColor())
.width(4)
.borderRadius(2)
.position({
x: `${this.getIndicatorOffset()}%`,
y: 0
})
}
.width('80%')
.height(30)
Text('偏高').fontSize(12).fontColor('#FF3B30')
}
.width('90%')
.alignItems(VerticalAlign.Center)
}
.padding(16)
.width('92%')
.backgroundColor('#F8F9FA')
.borderRadius(12)
.margin({ bottom: 16 })
// ---- 吉他弦状态 ----
if (this.instrumentMode === '吉他') {
Text('🎸 吉他弦调音状态').fontSize(16).fontWeight(FontWeight.Bold)
.margin({ bottom: 8 })
Row() {
ForEach(this.strings, (s: any, index: number) => {
Column() {
Text(s.label).fontSize(12).fontColor('#888')
Text(s.note).fontSize(20).fontWeight(FontWeight.Bold)
Circle()
.width(12).height(12)
.fill(s.tuned ? '#34C759' : '#ddd')
.margin({ top: 4 })
}
.padding(8)
.backgroundColor(this.activeString === index ? '#E8F0FE' : '#F8F8F8')
.borderRadius(8)
.onClick(() => { this.selectString(index as number); })
})
}
.width('96%')
.justifyContent(FlexAlign.SpaceEvenly)
.margin({ bottom: 16 })
}
// ---- 控制按钮 ----
Button(this.isListening ? '⏹ 停止调音' : '🎤 开始调音')
.width('80%')
.height(48)
.backgroundColor(this.isListening ? '#FF3B30' : '#007AFF')
.fontColor('#fff')
.borderRadius(24)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.onClick(() => {
if (this.isListening) {
this.stopListening();
} else {
this.startListening();
}
})
// ---- 提示信息 ----
Text('对着麦克风弹奏单音,调音器会自动检测音高')
.fontSize(12)
.fontColor('#999')
.margin({ top: 12 })
}
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
}
}
📚 核心知识点深度解析
1. 音高检测原理
调音器的核心算法基于 FFT(快速傅里叶变换):
时域信号(PCM 采样) → FFT → 频域信号(频谱)
↓
找到能量最大的频率分量 → 即基频 (Fundamental Frequency)
↓
基频 → 查频率表 → 最近音符名 + 音分偏差
音分计算公式:
cent = 1200 × log₂(检测频率 / 标准频率)
cent > 0 → 偏高
cent < 0 → 偏低
|cent| < 5 → 已调准 ✅
2. 12 平均律
现代音乐使用 12 平均律,每个半音的频率比为 2^(1/12):
f(n) = 440 × 2^(n/12)
其中 n 是距离 A4 的半音数
3. 吉他弦标准调音
| 弦 | 音符 | 频率 | 对应场景 |
|---|---|---|---|
| 1弦 (最细) | E4 | 329.63 Hz | 高音 |
| 2弦 | B3 | 246.94 Hz | — |
| 3弦 | G3 | 196.00 Hz | — |
| 4弦 | D3 | 146.83 Hz | — |
| 5弦 | A3 | 220.00 Hz | — |
| 6弦 (最粗) | E3 | 82.41 Hz | 低音 |
⚠️ 避坑指南
| 坑 | 原因 | 正确做法 |
|---|---|---|
| 频率检测不准确 | 麦克风采样率不足 | 使用 AudioCapturer 设置 44100Hz |
| 检测到泛音而非基频 | FFT 峰值可能是 2倍/3倍频 | 用 YIN 算法或 HPS 算法找基频 |
| 权限申请崩溃 | 没捕获异常 | try/catch 包裹 requestPermissionsFromUser |
| 调音延迟大 | 缓冲区太大 | 设置 bufferSize 为 1024 或 2048 |
| 背景噪音干扰 | 环境音被一起检测 | 加高通滤波器(>80Hz)过滤低频噪音 |
| 指示器跳动 | 相邻帧检测结果差异大 | 滑动平均平滑(取最近 5 次均值) |
🔥 最佳实践
- 滑动平均平滑:取最近 3-5 次检测结果的均值,避免指针抖动
- 置信度判定:当频谱峰值不够明显时显示"信号弱",不显示不可靠的频率
- 音分偏差颜色编码:绿色=准(0-5)、橙色=微偏(5-20)、红色=大偏(>20)
- 乐器预设切换:不同乐器的音域不同,切换时自动调整目标频率范围
- 参考音播放:增加"播放参考音"功能,让用户靠耳朵调音
- 噪声门限:低于 -40dB 的音频信号不处理,节省算力
🚀 扩展挑战
- 真实 FFT 实现:使用
@ohos.multimedia.audio.AudioCapturer获取 PCM 数据 + 自行实现 FFT - 波形可视化:用 Canvas 绘制实时音频波形
- 多弦同时检测:同时检测多根弦的音(适合扫弦调音)
- 调音历史记录:记录每次调音的偏差曲线
- 节拍器:集成节拍器功能,一键切换调音/节拍模式
- 半音阶模式:显示当前音阶所有半音,适合管乐器调音
运行结果完整截图:
官方文档: HarmonyOS 应用开发文档
- 开发者社区: 华为开发者论坛
- 欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net/
更多推荐


所有评论(0)