1@page musicbox 复古八音盒
2# 实验介绍
3Chiptune是不少80,90后的童年回忆,说Chiptune的名字应该很多人比较陌生,不过它有另外一个名字:8-bit。所谓的所谓的Chiptune也就是由老式家用电脑、录像游戏机和街机的芯片(也就是所谓的CHIP)发出的声音而写作的曲子。严格说来其实Chiptune不仅仅只有8bit,不过都是追求复古颗粒感的低比特率。本实验中,我们也来实现一款复古“八音”盒。
4
5<div align=center>
6    <img src="https://img.alicdn.com/imgextra/i3/O1CN01B8CEvh1kaBmdtwajB_!!6000000004699-1-tps-1200-800.gif" style="zoom:50%;" />
7</div>
8
9# 涉及知识点
10乐谱编码
11PWM与蜂鸣器
12
13
14# 开发环境准备
15## 硬件
16    开发用电脑一台
17    HAAS EDU K1 开发板一块
18    USB2TypeC 数据线一根
19
20## 软件
21### AliOS Things开发环境搭建
22    开发环境的搭建请参考 @ref HaaS_EDU_K1_Quick_Start (搭建开发环境章节),其中详细的介绍了AliOS Things 3.3的IDE集成开发环境的搭建流程。
23
24### HaaS EDU K1 DEMO 代码下载
25    HaaS EDU K1 DEMO 的代码下载请参考 @ref HaaS_EDU_K1_Quick_Start (创建工程章节),其中,
26    选择解决方案: 基于教育开发板的示例
27    选择开发板: haaseduk1 board configure
28
29### 代码编译、烧录
30    参考 @ref HaaS_EDU_K1_Quick_Start (3.1 编译工程章节),点击 ✅ 即可完成编译固件。
31    参考 @ref HaaS_EDU_K1_Quick_Start (3.2 烧录镜像章节),点击 "⚡️" 即可完成烧录固件。
32
33
34
35# 蜂鸣器
36蜂鸣器是一种非常简单的发声器件,和播放播放使用的扬声器不同,蜂鸣器只能播放较为简单的频率。
37从驱动原理上区分,蜂鸣器可以分为无源蜂鸣器和有源蜂鸣器。这里的“源”,指的就是有无驱动源。无源蜂鸣器,顾名思义,就是没有自己的内置驱动源。只有为音圈接入交变电流后,其内部的电磁铁与永磁铁相吸或相斥而推动振膜发声,而接入直流电后,只能持续推动振膜而无法产生声音,只能在接通或断开时产生声音。而有源驱动器相反,只要接入直流电,其内部的驱动源会以一个固定的频率驱动振膜,直接发声。
38在本实验中,推荐大家使用无源蜂鸣器,因为它只由PWM驱动,声音会更清脆纯净。使用有源蜂鸣器时,也能实现类似的效果,不过由于叠加了有源蜂鸣器自己的震动频率,声音会略显嘈杂。
39## 驱动电路
40
41<div align=center>
42    <img src="https://img.alicdn.com/imgextra/i4/O1CN015ylEJz1X8cwWhJHxh_!!6000000002879-2-tps-758-516.png" style="zoom:50%;" />
43</div>
44
45蜂鸣器的 1端 连接到VCC,2端 连接到三极管。这里的三极管由PWM0驱动,来决定蜂鸣器的 2端 是否和GND连通,进而引发一次振荡。通过不断翻转IO口,即可以驱动蜂鸣器发声。
46## 驱动代码
47为了实现IO口按特定频率翻转,我们可以使用PWM(脉冲宽度调制)功能。关于PWM的详细介绍可以参看z第三章资源PWM部分。
48在本实验中,我们实现了tone和noTone两个方法。其中,tone方法用于驱动蜂鸣器发出特定频率的声音,也就是“音调”。noTone方法用于关闭蜂鸣器。
49值得注意的是,在tone方法中,pwm的占空比固定设置为0.5,这代表在一个震动周期内,蜂鸣器的振膜总是一半时间在上,一半时间在下。在这里改变占空比并不会改变蜂鸣器的功率,所以音量大小不会改变。
50```c
51// solutions/eduk1_demo/k1_apps/musicbox/musicbox.c
52
53void tone(uint16_t port, uint16_t frequency, uint16_t duration)
54{
55    pwm_dev_t pwm = {port, {0.5, frequency}, NULL};	// 设定pwm 频率为设定频率
56    if (frequency > 0)								// 频率值合法才会初始化pwm
57    {
58        hal_pwm_init(&pwm);
59        hal_pwm_start(&pwm);
60    }
61    if (duration != 0)
62    {
63        aos_msleep(duration);
64    }
65    if (frequency > 0 && duration > 0)		// 如果设定了 duration,则在该延时后停止播放
66    {
67        hal_pwm_stop(&pwm);
68        hal_pwm_finalize(&pwm);
69    }
70}
71
72void noTone(uint16_t port)
73{
74    pwm_dev_t pwm = {port, {0.5, 1}, NULL};	// 关闭对应端口的pwm输出
75    hal_pwm_stop(&pwm);
76    hal_pwm_finalize(&pwm);
77}
78```
79
80
81# 从音调到音乐
82完成了蜂鸣器的驱动,可以让蜂鸣器发出我们想要频率的声音了。接下来,我们需要做的就是把这些频率组合起来,形成音乐。
83## 定义音调
84目前我们只能指定发声的频率,却不知道频率怎么对应音调。而遵循音调,才能拼接出音乐。如果把蜂鸣器看作我们要驱动的器件,那么频率与音调的对应关系就是通讯协议,而音乐就是理想的器件输出。
85我们采用目前对常用的音乐律式——[十二平均律](https://zh.wikipedia.org/wiki/%E5%8D%81%E4%BA%8C%E5%B9%B3%E5%9D%87%E5%BE%8B)。采用维基百科的定义,可以计算如下:
86将主音设为a1(440Hz),来计算所有音的频率,结果如下(为计算过程更清晰,分数不进行约分):
87
88<div align=center>
89    <img src="https://img.alicdn.com/imgextra/i4/O1CN01N7U89x22PLUMFgOIr_!!6000000007112-2-tps-1105-563.png" />
90</div>
91
92
93这样就得到了频率与音调的关系,我们将它记录在头文件中。
94```c
95// solutions/eduk1_demo/k1_apps/musicbox/pitches.h
96
97#define NOTE_B0  31
98#define NOTE_C1  33
99#define NOTE_CS1 35
100#define NOTE_D1  37
101#define NOTE_DS1 39
102... ...
103#define NOTE_B7  3951
104#define NOTE_C8  4186
105#define NOTE_CS8 4435
106#define NOTE_D8  4699
107#define NOTE_DS8 4978
108```
109这样,我们就可以采用tone方法来发出对应的音调。
110```c
111tone(0, NOTE_B7, 100)
112// 使用pwm0对应的蜂鸣器播放 NOTE_B7 持续100ms
113```
114## 生成乐谱
115接下来,我们就可以开始谱曲了,这里我们选用一首非常简单的儿歌——《两只老虎》,来为大家演示如何谱曲。
116我们的tone方法有两个需要关注的参数:frequency决定了播放的音调,duration决定了该音调播放的时长,也就是节拍。因此我们在读简谱时,也需要关注这两个参数。
117关于简谱的一些基础知识,感兴趣的同学可以参考[wikipedia-简谱](https://zh.wikipedia.org/wiki/%E7%B0%A1%E8%AD%9C)。本实验只会使用到非常简单的方法,因此也可以直接往下阅读。
118
119
120<div align=center>
121    <img src="https://img.alicdn.com/imgextra/i2/O1CN01oV6Kmh1LRbyKDMM1y_!!6000000001296-2-tps-605-260.png">
122</div>
123
124
125以《两只老虎》这张简谱为例。
126### 音符
127音符用数字1至7表示。这7个数字就等于大调的自然音阶。
128左上角的 1 = C 表示调号,代表这张简谱使用C大调,加上音名,就会是这样:
129
130| 1 = C |  |  |  |  |  |  |  |
131| --- | --- | --- | --- | --- | --- | --- | --- |
132| **音阶** | C | D | E | F | G | A | B |
133| **唱名** | do | re | mi | fa | sol | la | Si |
134| **数字** | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
135| **代码** | NOTE_C4 | NOTE_D4 | NOTE_E4 | NOTE_F4 | NOTE_G4 | NOTE_A4 | NOTE_B4 |
136
137如果 左上角的定义 1 = D,那么就从D开始重新标注,如下表:
138
139| 1 = D |  |  |  |  |  |  |  |
140| --- | --- | --- | --- | --- | --- | --- | --- |
141| **音阶** | D | E | F | G | A | B | C |
142| **唱名** | do | re | mi | fa | sol | la | Si |
143| **数字** | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
144| **代码** | NOTE_D4 | NOTE_E4 | NOTE_F4 | NOTE_G4 | NOTE_A4 | NOTE_B4 | NOTE_C4 |
145
146### 八度
147如果是高一个八度,就会在数字上方加上一点。如果是低一个八度,就会数字下方加上一点。在中间的那一个八度就什么也不用加。如果要再高一个八度,就在上方垂直加上两点(如:![image.png](https://img.alicdn.com/imgextra/i1/O1CN01Vfw6WJ28KCjmlPLdh_!!6000000007913-2-tps-10-29.png));要再低一个八度,就在下方垂直加上两点(如:![image.png](https://img.alicdn.com/imgextra/i1/O1CN011wREwN1cGwCr6prnx_!!6000000003574-2-tps-10-35.png)),如此类推。
148
149#### 自然大调
150
151| 1 = C |  |  |  |  |  |  | **自然大调** |
152| --- | --- | --- | --- | --- | --- | --- | --- |
153| **数字** | ![image.png](https://img.alicdn.com/imgextra/i1/O1CN01VoVPgb22WfpMB6Ubs_!!6000000007128-2-tps-10-33.png) | ![image.png](https://img.alicdn.com/imgextra/i1/O1CN01Vfw6WJ28KCjmlPLdh_!!6000000007913-2-tps-10-29.png) | ![image.png](https://img.alicdn.com/imgextra/i4/O1CN01IMoAHk1XFUsxjJkfP_!!6000000002894-2-tps-10-24.png) | 5 | ![image.png](https://img.alicdn.com/imgextra/i2/O1CN01T2q6co1tfWwexm0eM_!!6000000005929-2-tps-10-35.png) | ![image.png](https://img.alicdn.com/imgextra/i1/O1CN011wREwN1cGwCr6prnx_!!6000000003574-2-tps-10-35.png) | ![image.png](https://img.alicdn.com/imgextra/i3/O1CN01M18RG023JgCjbLMew_!!6000000007235-2-tps-10-47.png) |
154| **代码** | NOTE_G7 | NOTE_G6 | NOTE_G5 | NOTE_G4 | NOTE_G3 | NOTE_G2 | NOTE_G1 |
155
156
157#### 自然小调
158
159| 1 = C |  |  |  |  |  |  | **自然小调** |
160| --- | --- | --- | --- | --- | --- | --- | --- |
161| **数字** | ![image.png](https://img.alicdn.com/imgextra/i1/O1CN01VoVPgb22WfpMB6Ubs_!!6000000007128-2-tps-10-33.png) | ![image.png](https://img.alicdn.com/imgextra/i1/O1CN01Vfw6WJ28KCjmlPLdh_!!6000000007913-2-tps-10-29.png) | ![image.png](https://img.alicdn.com/imgextra/i4/O1CN01IMoAHk1XFUsxjJkfP_!!6000000002894-2-tps-10-24.png) | 5 | ![image.png](https://img.alicdn.com/imgextra/i2/O1CN01T2q6co1tfWwexm0eM_!!6000000005929-2-tps-10-35.png) | ![image.png](https://img.alicdn.com/imgextra/i1/O1CN011wREwN1cGwCr6prnx_!!6000000003574-2-tps-10-35.png) | ![image.png](https://img.alicdn.com/imgextra/i3/O1CN01M18RG023JgCjbLMew_!!6000000007235-2-tps-10-47.png) |
162| **代码** | NOTE_GS7 | NOTE_GS6 | NOTE_GS5 | NOTE_GS4 | NOTE_GS3 | NOTE_GS2 | NOTE_GS1 |
163
164
165
166了解了音符和八度后,我们可以开始填写音调数组,这个数组里的每个元素对应 tone 方法的 frequency 参数。
167```c
168static int liang_zhi_lao_hu_Notes[] = {
169    NOTE_C4, NOTE_D4, NOTE_E4, NOTE_C4, NOTE_C4, NOTE_D4, NOTE_E4, NOTE_C4,
170//   两		   只		 老		  虎		 两		  只	  老		  虎
171    NOTE_E4, NOTE_F4, NOTE_G4, NOTE_E4, NOTE_F4, NOTE_G4,
172//	 跑		   得		 快 	  跑		 得		  快
173    NOTE_G4, NOTE_A4, NOTE_G4, NOTE_F4, NOTE_E4, NOTE_C4,
174//   一		   只		 没		  有		 眼		  睛
175    NOTE_G4, NOTE_A4, NOTE_G4, NOTE_F4, NOTE_E4, NOTE_C4,
176//	 一		   只		 没		  有		 尾		  巴
177    NOTE_D4, NOTE_G3, NOTE_C4, 0,
178// 	 真		   奇		 怪
179    NOTE_D4, NOTE_G3, NOTE_C4, 0};
180// 	 真		   奇		 怪
181```
182### 拍号和音长
183左上角的 2/4 表示拍号。这里的4代表4分音符为一拍,2代表每一个小节里共有两拍。
184通常只有数字的是[四分音符](https://zh.wikipedia.org/wiki/%E5%9B%9B%E5%88%86%E9%9F%B3%E7%AC%A6)。数字下加一条横线,就可令四分音符的长度减半,即成为[八分音符](https://zh.wikipedia.org/wiki/%E5%85%AB%E5%88%86%E9%9F%B3%E7%AC%A6);两条横线可令八分音符的长度减半,即成为[十六分音符](https://zh.wikipedia.org/wiki/%E5%8D%81%E5%85%AD%E5%88%86%E9%9F%B3%E7%AC%A6),以此类推;数字后方的横线延长音符,每加一条横线延长一个[四分音符](https://zh.wikipedia.org/wiki/%E5%9B%9B%E5%88%86%E9%9F%B3%E7%AC%A6)的长度。
185因此我们可以得到节拍数组,这个数组里的每个元素对应 tone 方法的 duration 参数。
186```c
187static int liang_zhi_lao_hu_NoteDurations[] = {
188    8, 8, 8, 8, 8, 8, 8, 8,
189    8, 8, 4, 8, 8, 4,
190    16, 16, 16, 16, 4, 4,
191    16, 16, 16, 16, 4, 4,
192    8, 8, 4, 4,
193    8, 8, 4, 4};
194```
195### 结构体定义
196接下来,我们将得到的乐谱信息填入结构体当中。
197```c
198// solutions/eduk1_demo/k1_apps/musicbox/musicbox.c
199
200typedef struct
201{
202    char *name;					// 音乐的名字
203    int *notes;					// 音符数组
204    int *noteDurations;			// 节拍数组
205    unsigned int noteLength;	// 音符数量
206    unsigned int musicTime;		// 音乐总时长 由播放器处理 用于界面显示 用户不需要关心
207} music_t;						// 音乐结构体
208
209typedef struct
210{
211    music_t **music_list;			// 音乐列表
212    unsigned int music_list_len;	// 音乐列表的长度
213    int cur_music_index;			// 当前第几首音乐
214    unsigned int cur_music_note;	// 当前音乐的第几个音符
215    unsigned int cur_music_time;	// 当前的播放时长 由播放器处理 用于界面显示 用户不需要关心
216    unsigned int isPlaying;			// 音乐是否播放/暂停 由播放器处理 用户不需要关心
217} player_t;
218
219static music_t liang_zhi_lao_hu = {
220    "liang_zhi_lao_hu",
221    liang_zhi_lao_hu_Notes,
222    liang_zhi_lao_hu_NoteDurations,
223    34
224};
225
226music_t *music_list[] = {
227    &liang_zhi_lao_hu_Notes,		// 将音乐插入到音乐列表中
228};
229
230player_t musicbox_player = {music_list, 1, 0, 0, 0, 0};	// 初始化音乐播放器
231```
232# 实现播放音乐
233```c
234while (1)
235{
236    // 如果当前音调下标小于这首音乐的总音调 即尚未播放完
237    if (musicbox_player.cur_music_note < cur_music->noteLength)
238    {
239        // 通过节拍计算出当前音符需要的延时 1000ms / n分音符
240        int noteDuration = 1000 / cur_music->noteDurations[musicbox_player.cur_music_note];
241        // 对于附点音符 我们用读数来标记 加有一个附点后音符的音长比其原来的音长增加了一半,即原音长的1.5倍。
242        noteDuration = (noteDuration < 0) ? (-noteDuration * 1.5) : noteDuration;
243		// 得到当前的音调
244        int note = cur_music->notes[musicbox_player.cur_music_note];
245        // 使用 tone 方法播放音调
246        tone(0, note, noteDuration);
247        // 延时一段时间 让音调转换更清晰
248        aos_msleep((int)(noteDuration * NOTE_SPACE_RATIO));
249        // 计算当前的播放时间
250        musicbox_player.cur_music_time += (noteDuration + (int)(noteDuration * NOTE_SPACE_RATIO));
251        // 准备播放下一个音调
252        musicbox_player.cur_music_note++;
253    }
254}
255```
256# 绘制播放器
257作为一位有理想有追求的开发者,仅仅能播放音乐肯定没法满足我们的创造欲。所以我们再来实现一个播放器,可以做到 暂停/播放, 上一首/下一首, 还能显示歌曲名和进度条。
258实现这些需要的信息,我们在结构体中都已经完成了相关的定义,只需要根据按键操作完成对应的音乐播放控制即可。
259```c
260void musicbox_task()
261{
262    while (1)
263    {
264        // 清除上一次绘画的残留
265        OLED_Clear();
266        // 获取当前音乐的指针
267        music_t *cur_music = musicbox_player.music_list[musicbox_player.cur_music_index];
268
269        // 获取当前音乐的名字并且绘制
270        char show_song_name[14] = {0};
271        sprintf(show_song_name, "%-13.13s", cur_music->name);
272        OLED_Show_String(14, 4, show_song_name, 16, 1);
273
274        // 如果当前播放器并未被暂停(正在播放)
275        if (musicbox_player.isPlaying)
276        {
277       		// 如果还没播放完
278            if (musicbox_player.cur_music_note < cur_music->noteLength)
279            {
280                int noteDuration = 1000 / cur_music->noteDurations[musicbox_player.cur_music_note];
281                noteDuration = (noteDuration < 0) ? (-noteDuration * 1.5) : noteDuration;
282                printf("note[%d] = %d\t delay %d ms\n", musicbox_player.cur_music_note, cur_music->noteDurations[musicbox_player.cur_music_note], noteDuration);
283                int note = cur_music->notes[musicbox_player.cur_music_note];
284                tone(0, note, noteDuration);
285                aos_msleep((int)(noteDuration * NOTE_SPACE_RATIO));
286                musicbox_player.cur_music_time += (noteDuration + (int)(noteDuration * NOTE_SPACE_RATIO));
287                musicbox_player.cur_music_note++;
288            }
289            // 如果播放完 切换到下一首
290            else
291            {
292                noTone(0);
293                aos_msleep(1000);
294                next_song();	// musicbox_player.cur_music_index++ 播放器的指向下一首音乐
295            }
296            OLED_Icon_Draw(54, 36, &icon_pause_24_24, 1);	// 播放器处于播放状态时 绘制暂停图标
297        }
298        else
299        {
300            OLED_Icon_Draw(54, 36, &icon_resume_24_24, 1);	// 播放器处于暂停状态时 绘制播放图标
301            aos_msleep(500);
302        }
303
304        // 绘制一条直线代表进度条 直线的长度是 99.0(可绘画区域的最大长度) * (musicbox_player.cur_music_time(播放器记录的的当前音乐播放时长) / cur_music->musicTime(这首歌的总时长))
305        OLED_DrawLine(16, 27, (int)(16 + 99.0 * (musicbox_player.cur_music_time * 1.0 / cur_music->musicTime)), 27, 1);
306
307        // 绘制上一首和下一首的图标
308        OLED_Icon_Draw(94, 36, &icon_next_song_24_24, 1);
309        OLED_Icon_Draw(14, 36, &icon_previous_song_24_24, 1);
310
311        // 将绘制的信息显示在屏幕上
312        OLED_Refresh_GRAM();
313    }
314}
315```
316