从零开发吉他和弦查询APP:Canvas绘图与音乐理论的完美结合
从零开发吉他和弦查询APP:Canvas绘图与音乐理论的完美结合
当技术遇上音乐,用代码实现一个吉他手必备工具
写在前面
作为一个吉他爱好者兼程序员,我一直想做一个能随身携带的和弦查询工具。市面上虽然有各种APP,但要么广告太多,要么指法图不够清晰。既然自己会写代码,何不自己动手实现一个?
本文记录了我使用 HarmonyOS NEXT 开发「吉他和弦查询器」的完整过程,重点分享两个核心问题:
- 如何用 Canvas 绘制专业的吉他指板图?
- 变调夹移调计算背后的音乐理论是什么?
一、项目功能一览
1.1 核心功能
🎸 吉他和弦查询器
│
├── 和弦库(60+ 常用和弦)
│ ├── 大三和弦(C, D, E, F, G, A, B)
│ ├── 小三和弦(Am, Dm, Em, Bm...)
│ ├── 属七和弦(C7, D7, E7...)
│ ├── 大七/小七/挂留/减/增/强力...
│ │
│ ├── 分类筛选(横向滚动标签)
│ ├── 模糊搜索(C, Am, G7...)
│ └── 详情页
│ ├── 指板图(Canvas绘制)
│ ├── 指法说明
│ ├── 组成音
│ └── 同类型推荐
│
└── 变调夹计算器
├── 选择目标调性
├── 选择品格位置
└── 计算实际按弦
1.2 项目结构
entry/src/main/ets/
├── components/
│ └── FretboardView.ets # 核心组件:Canvas指板绘制
├── model/
│ ├── ChordData.ets # 数据类型定义
│ └── ChordLibrary.ets # 和弦库数据(60+和弦)
└── pages/
├── Index.ets # 首页:和弦列表
├── ChordDetail.ets # 详情页
└── CapoTool.ets # 变调夹计算器
二、和弦数据的数学描述
2.1 一根弦的状态
吉他有6根弦,从粗到细编号为6→1弦。每根弦在某个和弦中可能有4种状态:
| 状态 | 符号 | 说明 |
|---|---|---|
| 不弹 | ✕ | 这根弦不参与发声 |
| 空弦 | ○ | 这根弦直接弹,不按任何品格 |
| 按1品 | 1 | 食指按住这根弦的第1品 |
| 按3品 | 3 | 无名指按住这根弦的第3品 |
2.2 和弦数据结构
用一个数组描述6根弦的品格位置:
interface Chord {
name: string; // 和弦名称
type: ChordType; // 类型(Major/Minor/7th...)
category: ChordCategory; // 分类(开放/横按)
frets: number[]; // 6根弦的品格:-1=不弹, 0=空弦, 1+=品格
fingers: number[]; // 6根弦的手指:0=不按, 1=食指, 2=中指, 3=无名指, 4=小指
barre?: BarreInfo; // 横按信息(可选)
}
示例:C 和弦
指板图:
○ ← 1弦空弦
1 ← 2弦1品(食指)
○ ← 3弦空弦
2 ← 4弦2品(中指)
3 ← 5弦3品(无名指)
✕ ← 6弦不弹
数据表示:
frets: [-1, 3, 2, 0, 1, 0]
fingers: [0, 3, 2, 0, 1, 0]
6弦 5弦 4弦 3弦 2弦 1弦
2.3 横按的处理
F 和弦、Bm 和弦需要用食指横按多根弦:
interface BarreInfo {
fret: number; // 横按在第几品
startString: number; // 从第几弦开始
endString: number; // 到第几弦结束
}
// F 和弦:食指横按1品,覆盖6弦到1弦
barre: { fret: 1, startString: 6, endString: 1 }
三、Canvas 指板图绘制
这是本项目的技术核心——用 Canvas 2D API 绘制专业级的吉他指板图。
3.1 指板图的结构
一个完整的指板图包含以下元素:
○ ○ ✕ ← 空弦/不弹标记
┌───┬───┬───┬───┬───┐
│ │ │ │ │ │ ← 第1品(上枕下方)
1 ├───┼───┼───┼───┼───┤
│ │ │ ● │ │ │ ← 按弦圆点 + 手指编号
2 ├───┼───┼───┼───┼───┤
│ │ ● │ │ │ │
└───┴───┴───┴───┴───┘
E A D G B e ← 音名
6弦 5弦 4弦 3弦 2弦 1弦
3.2 布局计算
// 画布尺寸
const canvasWidth = ctx.width;
const canvasHeight = ctx.height;
// 留白区域
const paddingLeft = 36; // 左侧留白(显示品号)
const paddingRight = 16;
const paddingTop = 42; // 顶部留白(显示O/X标记)
const paddingBottom = 20;
// 可绘制区域
const drawWidth = canvasWidth - paddingLeft - paddingRight;
const drawHeight = canvasHeight - paddingTop - paddingBottom;
// 间距计算
const stringSpacing = drawWidth / 5; // 6根弦,5个间隔
const fretSpacing = drawHeight / 5; // 5个品格
3.3 绘制步骤详解
Step 1:绘制背景和品丝
// 背景(米黄色,模拟木质指板)
ctx.fillStyle = '#FFF9F0';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
// 品丝(横线)
ctx.strokeStyle = '#888888';
ctx.lineWidth = 1.5;
for (let f = 0; f <= 5; f++) {
const y = paddingTop + f * fretSpacing;
ctx.beginPath();
ctx.moveTo(paddingLeft, y);
ctx.lineTo(paddingLeft + drawWidth, y);
ctx.stroke();
}
Step 2:绘制上枕
上枕是吉他指板顶部的白色横条,用粗线表示:
ctx.strokeStyle = '#555555';
ctx.lineWidth = 6; // 比普通品丝粗
ctx.beginPath();
ctx.moveTo(paddingLeft, paddingTop);
ctx.lineTo(paddingLeft + drawWidth, paddingTop);
ctx.stroke();
Step 3:绘制弦线
6根弦粗细不同,6弦最粗、1弦最细:
const stringWidths = [3.2, 2.8, 2.4, 2.0, 1.6, 1.2];
ctx.strokeStyle = '#999999';
for (let s = 0; s < 6; s++) {
const x = paddingLeft + s * stringSpacing;
ctx.lineWidth = stringWidths[s];
ctx.beginPath();
ctx.moveTo(x, paddingTop);
ctx.lineTo(x, paddingTop + drawHeight);
ctx.stroke();
}
Step 4:绘制按弦圆点
for (let s = 0; s < 6; s++) {
const fret = chord.frets[s];
if (fret <= 0) continue; // 跳过空弦和不弹
const x = paddingLeft + s * stringSpacing;
const y = paddingTop + (fret - startFret + 0.5) * fretSpacing;
// 圆点半径
const radius = Math.min(stringSpacing * 0.35, fretSpacing * 0.32);
// 根音用红色,其他用蓝色
ctx.fillStyle = isRootNote(s) ? '#D14334' : '#2B5F8A';
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
// 手指编号(白色数字)
if (chord.fingers[s] > 0) {
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(chord.fingers[s].toString(), x, y);
}
}
Step 5:绘制横按指示
横按用一个圆弧矩形表示:
if (chord.barre) {
const barre = chord.barre;
const sStart = 6 - barre.endString;
const sEnd = 6 - barre.startString;
const y = paddingTop + (barre.fret - startFret + 0.5) * fretSpacing;
const xStart = paddingLeft + sStart * stringSpacing;
const xEnd = paddingLeft + sEnd * stringSpacing;
const radius = stringSpacing * 0.38;
// 绘制圆弧矩形
ctx.fillStyle = '#2B5F8A';
ctx.beginPath();
roundRect(ctx, xStart - radius, y - radius,
xEnd - xStart + radius * 2, radius * 2, radius);
ctx.fill();
// 显示 "1"(食指横按)
ctx.fillStyle = '#FFFFFF';
ctx.fillText('1', (xStart + xEnd) / 2, y);
}
Step 6:绘制空弦/不弹标记
for (let s = 0; s < 6; s++) {
const x = paddingLeft + s * stringSpacing;
const y = paddingTop - 10;
if (chord.frets[s] === 0) {
// 空弦:空心圆 O
ctx.strokeStyle = '#2B5F8A';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y, 7, 0, Math.PI * 2);
ctx.stroke();
} else if (chord.frets[s] === -1) {
// 不弹:打叉 X
ctx.strokeStyle = '#333333';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x - 5, y - 5);
ctx.lineTo(x + 5, y + 5);
ctx.moveTo(x + 5, y - 5);
ctx.lineTo(x - 5, y + 5);
ctx.stroke();
}
}
Step 7:绘制音名
if (chord.notes) {
ctx.font = '10px sans-serif';
ctx.fillStyle = '#555555';
ctx.textAlign = 'center';
for (let s = 0; s < 6; s++) {
if (chord.notes[s] && chord.notes[s] !== '-') {
const x = paddingLeft + s * stringSpacing;
const y = paddingTop + drawHeight + 12;
ctx.fillText(chord.notes[s], x, y);
}
}
}
3.4 关键技术点
踩坑记录:
-
Canvas 尺寸时机
Canvas(this.context) .onReady(() => { // 只有在这里 ctx.width/height 才有效! this.drawFretboard(); }) -
延迟重绘
@Prop @Watch('onChordChange') chord: Chord; onChordChange(): void { // 延迟执行,确保布局已更新 setTimeout(() => { this.drawFretboard(); }, 50); } -
圆角矩形函数
// ArkUI Canvas 没有内置 roundRect,需自己实现 roundRect(ctx, x, y, w, h, r) { ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); }
四、变调夹计算:音乐理论
4.1 什么是变调夹?
变调夹(Capo)是一种夹在吉他指板上的装置,相当于一个"移动的上枕"。当变调夹夹在第 N 品时:
- 所有空弦音升高 N 个半音
- 原本按 C 和弦的指法,发出的声音变成了升高 N 个半音后的音
4.2 12 平均律
西方音乐使用 12 平均律,一个八度分为 12 个半音:
C → C# → D → D# → E → F → F# → G → G# → A → A# → B → C
+1 +1 +1 +1 +1 +1 +1 +1 +1 +1 +1
音名循环:C, C#, D, D#, E, F, F#, G, G#, A, A#, B
4.3 移调计算
公式:
实际按弦根音 = 目标音根音 - 变调夹品格数
示例:
| 目标音 | 变调夹 | 实际按弦 | 说明 |
|---|---|---|---|
| C | 3品 | A | C - 3 = A |
| C | 5品 | G | C - 5 = G |
| D | 2品 | C | D - 2 = C |
| G | 7品 | D | G - 7 = D |
4.4 算法实现
export function applyCapo(chordName: string, capoFret: number): string {
// 12个半音的音名
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F',
'F#', 'G', 'G#', 'A', 'A#', 'B'];
// 分离根音和后缀(Cmaj7 → C + maj7)
const match = chordName.match(/^([A-G][#b]?)(.*)$/);
if (!match) return chordName;
let root = match[1];
const suffix = match[2];
// 降号转升号(Bb → A#)
const flatToSharp = {
'Bb': 'A#', 'Db': 'C#', 'Eb': 'D#',
'Gb': 'F#', 'Ab': 'G#'
};
if (root.includes('b')) {
root = flatToSharp[root] || root;
}
// 找到根音在12平均律中的位置
const rootIndex = noteNames.indexOf(root);
if (rootIndex === -1) return chordName;
// 向后移动 N 个半音(模12取余)
const actualIndex = (rootIndex - capoFret + 12) % 12;
const actualRoot = noteNames[actualIndex];
return actualRoot + suffix;
}
五、搜索与筛选
5.1 分类标签
使用横向滚动的按钮组实现分类筛选:
Scroll() {
Row() {
// "全部"按钮
Text('All')
.backgroundColor(this.currentCategory === -1 ? '#D14334' : '#EEE')
.onClick(() => { this.currentCategory = -1; })
ForEach(this.chordGroups, (group: ChordGroup, index: number) => {
Text(group.title.split(' ')[0]) // "大三和弦 (Major)" → "大三和弦"
.backgroundColor(this.currentCategory === index ? '#2B5F8A' : '#EEE')
.onClick(() => { this.currentCategory = index; })
})
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
5.2 模糊搜索
支持按和弦名称或指法描述搜索:
export function searchChords(query: string): Chord[] {
const q = query.toLowerCase().trim();
if (!q) return [];
return getAllChords().filter(chord =>
chord.name.toLowerCase().includes(q) ||
chord.description?.toLowerCase().includes(q)
);
}
搜索 “C” → 匹配 C, C7, Cmaj7, Csus2…
六、和弦详情页
6.1 参数传递
// 列表页:跳转
router.pushUrl({
url: 'pages/ChordDetail',
params: {
chordName: item.name,
chordType: item.type
}
});
// 详情页:接收
aboutToAppear(): void {
const params = router.getParams() as Record<string, Object>;
this.chordName = params['chordName'] as string;
this.chordType = params['chordType'] as ChordType;
// 在和弦库中查找
this.chord = getAllChords().find(c =>
c.name === this.chordName && c.type === this.chordType
);
}
6.2 布局设计
详情页采用卡片式布局:
Scroll() {
Column() {
// 卡片1:和弦名称 + 类型标签
Row() {
Text(this.chord.name).fontSize(36)
Text(CHORD_TYPE_LABELS[this.chord.type])
.backgroundColor(分类颜色)
.borderRadius(10)
}
// 指板图组件
FretboardView({
chord: this.chord,
startFret: this.chord.barre?.fret || 1
})
// 卡片2:指法说明
Column() {
Text('🎯 指法说明')
Text(this.chord.description)
if (this.chord.barre) {
Text(`横按:食指按第${barre.fret}品`)
}
// 指法表
Row() {
ForEach(this.chord.frets, (fret, idx) => {
Column() {
Text(['6弦','5弦','4弦','3弦','2弦','1弦'][idx])
Text(fret符号)
}
})
}
}
// 卡片3:组成音
Column() {
Text('🎵 组成音')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.chord.notes, (note) => {
Text(note).backgroundColor('#EBF2FA')
})
}
}
// 卡片4:同类型推荐
Column() {
Text('📋 其他' + 类型名)
Scroll() {
Row() {
ForEach(this.relatedChords, (related: Chord) => {
Column() {
Text(related.name)
Text(简写)
}
.onClick(() => { /* 切换到该和弦 */ })
})
}
}
}
}
}
七、同类型推荐
7.1 查找逻辑
// 查找同类型的其他和弦,最多展示6个
this.relatedChords = getAllChords()
.filter(c => c.type === this.chord?.type && c.name !== this.chord?.name)
.slice(0, 6);
7.2 点击切换
点击推荐卡片时,不跳转页面,而是就地更新当前页面:
.onClick(() => {
this.chordName = related.name;
this.chordType = related.type;
// 重新查找
this.chord = getAllChords().find(c =>
c.name === related.name && c.type === related.type
);
// 更新推荐列表
this.relatedChords = getAllChords()
.filter(c => c.type === this.chord?.type && c.name !== this.chord?.name)
.slice(0, 6);
})
八、底部导航
8.1 自定义 Tab 栏
Row() {
// Tab 1: 和弦查询
Column() {
Text('🎸').fontSize(20)
Text('和弦查询').fontSize(11).fontColor('#D14334')
}
.layoutWeight(1)
// Tab 2: 变调夹计算
Column() {
Text('🎛️').fontSize(20)
Text('变调夹计算').fontSize(11).fontColor('#888')
}
.layoutWeight(1)
.onClick(() => {
router.pushUrl({ url: 'pages/CapoTool' });
})
}
.width('100%')
.height(56)
.backgroundColor('#FFF')
.borderRadius({ topLeft: 16, topRight: 16 })
.shadow({ radius: 8, color: '#10000000', offsetY: -2 })
九、变调夹计算器详解
9.1 交互设计
计算器分三个区域:
┌────────────────────────────────┐
│ 🎯 目标音和弦 │
│ 根音: [C][C#][D][D#][E]... │
│ 类型: [Major][Minor][7th]... │
│ 当前: C = 大三和弦 │
├────────────────────────────────┤
│ 📌 变调夹位置 │
│ 第 [3] 品 │
│ [1][2][3][4][5][6][7][8]... │
├────────────────────────────────┤
│ ✅ 计算结果 │
│ C → 夹3品 → 弹 A │
│ 变调夹夹在第3品时, │
│ 按A和弦指法,发出C和弦的声音 │
│ ┌───────────┐ │
│ │ 指板图 │ │
│ └───────────┘ │
└────────────────────────────────┘
9.2 实现代码
@Component
struct CapoTool {
@State selectedRoot: string = 'C';
@State selectedType: ChordType = ChordType.MAJOR;
@State capoFret: number = 3;
@State resultChordName: string = '';
@State resultChord: Chord | null = null;
// 根音列表
private rootNotes = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
aboutToAppear(): void {
this.calculateResult();
}
calculateResult(): void {
// 组合完整和弦名
const fullName = this.selectedRoot + this.getTypeSuffix(this.selectedType);
// 计算移调结果
this.resultChordName = applyCapo(fullName, this.capoFret);
// 查找对应和弦
this.resultChord = getAllChords().find(c => c.name === this.resultChordName);
}
// 响应式更新
onRootChange(root: string): void {
this.selectedRoot = root;
this.calculateResult();
}
onCapoChange(fret: number): void {
this.capoFret = fret;
this.calculateResult();
}
}
9.3 品格选择器
使用网格按钮代替 Slider,更直观:
Flex({ wrap: FlexWrap.Wrap }) {
ForEach([1,2,3,4,5,6,7,8,9,10,11,12], (fret: number) => {
Text(fret.toString())
.width(26)
.height(32)
.textAlign(TextAlign.Center)
.backgroundColor(fret === this.capoFret ? '#D14334' : '#F5F5F5')
.borderRadius(6)
.onClick(() => { this.onCapoChange(fret); })
})
}
十、项目配置
10.1 页面注册
resources/base/profile/main_pages.json:
{
"src": [
"pages/Index",
"pages/ChordDetail",
"pages/CapoTool"
]
}
10.2 应用配置
AppScope/app.json5:
{
"app": {
"bundleName": "com.example.chordfinder",
"versionCode": 1000000,
"versionName": "1.0.0",
"icon": "$media:layered_image",
"label": "$string:app_name"
}
}
十一、技术总结
11.1 Canvas 绘图要点
| 元素 | API | 关键参数 |
|---|---|---|
| 弦线 | moveTo + lineTo | 线宽递减模拟粗细 |
| 品丝 | moveTo + lineTo | 上枕用6px粗线 |
| 圆点 | arc | 根音红色/其他蓝色 |
| 横按 | arcTo(圆弧矩形) | 显示"1"表示食指 |
| 标记 | arc + moveTo/lineTo | O用stroke,X用两根斜线 |
| 文字 | fillText | 居中对齐 |
11.2 数据建模
Chord = frets[] + fingers[] + barre?
↓ ↓ ↓
品格位置 手指编号 横按信息
-1= 不弹0= 空弦1+= 按在第N品
11.3 变调夹算法
根音索引 - 品格数 = 实际根音索引(模12)
十二、运行效果


十三、后续扩展
- 音频播放:点击和弦播放对应声音
- 节拍器:内置节拍器功能
- 调音器:通过麦克风识别音高
- 更多和弦:扩展爵士和弦、九和弦、十一和弦
- 曲谱模式:显示常用歌曲和弦进行
- 自定义和弦:用户在指板上点击创建
总结
这个项目让我深入理解了:
- Canvas 绘图:从布局计算到绑定重绘,每一步都需要精确计算
- 音乐理论:12平均律、移调计算,代码与艺术的结合
- 数据抽象:如何用数组描述复杂的和弦指法
- 交互设计:分类筛选、搜索、卡片推荐等多种交互模式
对于一个吉他爱好者来说,这是最有成就感的项目——它不是 CRUD,不是 TODO List,而是真正能用在日常练习中的工具。
希望这篇文章对喜欢音乐的开发者有所启发。代码已在文中完整呈现,欢迎交流讨论!
技术栈:HarmonyOS NEXT + ArkTS + Canvas 2D
开发环境:DevEco Studio 5.0+
和弦数量:60+
更多推荐
所有评论(0)