从零开发吉他和弦查询APP:Canvas绘图与音乐理论的完美结合

当技术遇上音乐,用代码实现一个吉他手必备工具


写在前面

作为一个吉他爱好者兼程序员,我一直想做一个能随身携带的和弦查询工具。市面上虽然有各种APP,但要么广告太多,要么指法图不够清晰。既然自己会写代码,何不自己动手实现一个?

本文记录了我使用 HarmonyOS NEXT 开发「吉他和弦查询器」的完整过程,重点分享两个核心问题:

  1. 如何用 Canvas 绘制专业的吉他指板图?
  2. 变调夹移调计算背后的音乐理论是什么?

一、项目功能一览

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 关键技术点

踩坑记录:

  1. Canvas 尺寸时机

    Canvas(this.context)
      .onReady(() => {
        // 只有在这里 ctx.width/height 才有效!
        this.drawFretboard();
      })
    
  2. 延迟重绘

    @Prop @Watch('onChordChange') chord: Chord;
    
    onChordChange(): void {
      // 延迟执行,确保布局已更新
      setTimeout(() => {
        this.drawFretboard();
      }, 50);
    }
    
  3. 圆角矩形函数

    // 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)

十二、运行效果

在这里插入图片描述
在这里插入图片描述


十三、后续扩展

  1. 音频播放:点击和弦播放对应声音
  2. 节拍器:内置节拍器功能
  3. 调音器:通过麦克风识别音高
  4. 更多和弦:扩展爵士和弦、九和弦、十一和弦
  5. 曲谱模式:显示常用歌曲和弦进行
  6. 自定义和弦:用户在指板上点击创建

总结

这个项目让我深入理解了:

  1. Canvas 绘图:从布局计算到绑定重绘,每一步都需要精确计算
  2. 音乐理论:12平均律、移调计算,代码与艺术的结合
  3. 数据抽象:如何用数组描述复杂的和弦指法
  4. 交互设计:分类筛选、搜索、卡片推荐等多种交互模式

对于一个吉他爱好者来说,这是最有成就感的项目——它不是 CRUD,不是 TODO List,而是真正能用在日常练习中的工具。

希望这篇文章对喜欢音乐的开发者有所启发。代码已在文中完整呈现,欢迎交流讨论!


技术栈:HarmonyOS NEXT + ArkTS + Canvas 2D
开发环境:DevEco Studio 5.0+
和弦数量:60+

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐