實驗介紹
Chiptune是不少80,90后的童年回憶,說Chiptune的名字應該很多人比較陌生,不過它有另外一個名字:8-bit。所謂的Chiptune也就是由老式家用電腦、錄像游戲機和街機的芯片(也就是所謂的CHIP)發出的聲音而寫作的曲子。嚴格說來其實Chiptune不僅僅只有8bit,不過都是追求復古顆粒感的低比特率。本實驗中,我們也來實現一款復古“八音”盒。
涉及知識點
樂譜編碼 PWM與蜂鳴器
開發環境準備
硬件
開發用電腦一臺
HAAS EDU K1 開發板一塊
USB2TypeC 數據線一根
軟件
開發環境的搭建請參考《AliOS Things集成開發環境使用說明之搭建開發環境》,其中詳細的介紹了AliOS Things 3.3的IDE集成開發環境的搭建流程。
本案例的代碼下載請參考《AliOS Things集成開發環境使用說明之創建工程》。
> 選擇解決方案:“HaaS EDU K1教育開發案例合集”
> 選擇開發板:haaseduk1 board configure
-- 編譯固件可參考《AliOS Things集成開發環境使用說明之編譯固件》。
-- 燒錄固件可參考《AliOS Things集成開發環境使用說明之燒錄固件》。
蜂鳴器
蜂鳴器是一種非常簡單的發聲器件,和播放使用的揚聲器不同,蜂鳴器只能播放較為簡單的頻率。 從驅動原理上區分,蜂鳴器可以分為無源蜂鳴器和有源蜂鳴器。這里的“源”,指的就是有無驅動源。無源蜂鳴器,顧名思義,就是沒有自己的內置驅動源。只有為音圈接入交變電流后,其內部的電磁鐵與永磁鐵相吸或相斥而推動振膜發聲,而接入直流電后,只能持續推動振膜而無法產生聲音,只能在接通或斷開時產生聲音。而有源驅動器相反,只要接入直流電,其內部的驅動源會以一個固定的頻率驅動振膜,直接發聲。 在本實驗中,推薦大家使用無源蜂鳴器,因為它只由PWM驅動,聲音會更清脆純凈。使用有源蜂鳴器時,也能實現類似的效果,不過由于疊加了有源蜂鳴器自己的震動頻率,聲音會略顯嘈雜。
驅動電路
蜂鳴器的 1端 連接到VCC,2端 連接到三極管。這里的三極管由PWM0驅動,來決定蜂鳴器的 2端 是否和GND連通,進而引發一次振蕩。通過不斷翻轉IO口,即可以驅動蜂鳴器發聲。
驅動代碼
為了實現IO口按特定頻率翻轉,我們可以使用PWM(脈沖寬度調制)功能。關于PWM的詳細介紹可以參看z第三章資源PWM部分。 在本實驗中,我們實現了tone和noTone兩個方法。其中,tone方法用于驅動蜂鳴器發出特定頻率的聲音,也就是“音調”。noTone方法用于關閉蜂鳴器。 值得注意的是,在tone方法中,pwm的占空比固定設置為0.5,這代表在一個震動周期內,蜂鳴器的振膜總是一半時間在上,一半時間在下。在這里改變占空比并不會改變蜂鳴器的功率,所以音量大小不會改變。
// solutions/eduk1_demo/k1_apps/musicbox/musicbox.c
void tone(uint16_t port, uint16_t frequency, uint16_t duration)
{
pwm_dev_t pwm = {port, {0.5, frequency}, NULL}; // 設定pwm 頻率為設定頻率
if (frequency > 0) // 頻率值合法才會初始化pwm
{
hal_pwm_init(&pwm);
hal_pwm_start(&pwm);
}
if (duration != 0)
{
aos_msleep(duration);
}
if (frequency > 0 && duration > 0) // 如果設定了 duration,則在該延時后停止播放
{
hal_pwm_stop(&pwm);
hal_pwm_finalize(&pwm);
}
}
void noTone(uint16_t port)
{
pwm_dev_t pwm = {port, {0.5, 1}, NULL}; // 關閉對應端口的pwm輸出
hal_pwm_stop(&pwm);
hal_pwm_finalize(&pwm);
}
從音調到音樂
完成了蜂鳴器的驅動,可以讓蜂鳴器發出我們想要頻率的聲音了。接下來,我們需要做的就是把這些頻率組合起來,形成音樂。
定義音調
目前我們只能指定發聲的頻率,卻不知道頻率怎么對應音調。而遵循音調,才能拼接出音樂。如果把蜂鳴器看作我們要驅動的器件,那么頻率與音調的對應關系就是通訊協議,而音樂就是理想的器件輸出。 我們采用目前對常用的音樂律式——十二平均律。采用維基百科的定義,可以計算如下: 將主音設為a1(440Hz),來計算所有音的頻率,結果如下(為計算過程更清晰,分數不進行約分):
這樣就得到了頻率與音調的關系,我們將它記錄在頭文件中。
// solutions/eduk1_demo/k1_apps/musicbox/pitches.h
#define NOTE_B0 31
#define NOTE_C1 33
#define NOTE_CS1 35
#define NOTE_D1 37
#define NOTE_DS1 39
... ...
#define NOTE_B7 3951
#define NOTE_C8 4186
#define NOTE_CS8 4435
#define NOTE_D8 4699
#define NOTE_DS8 4978
這樣,我們就可以采用tone方法來發出對應的音調。
tone(0, NOTE_B7, 100)
// 使用pwm0對應的蜂鳴器播放 NOTE_B7 持續100ms
生成樂譜
接下來,我們就可以開始譜曲了,這里我們選用一首非常簡單的兒歌——《兩只老虎》,來為大家演示如何譜曲。 我們的tone方法有兩個需要關注的參數:frequency決定了播放的音調,duration決定了該音調播放的時長,也就是節拍。因此我們在讀簡譜時,也需要關注這兩個參數。 關于簡譜的一些基礎知識,感興趣的同學可以參考wikipedia-簡譜。本實驗只會使用到非常簡單的方法,因此也可以直接往下閱讀。
以《兩只老虎》這張簡譜為例。
音符
音符用數字1至7表示。這7個數字就等于大調的自然音階。 左上角的 1 = C 表示調號,代表這張簡譜使用C大調,加上音名,就會是這樣:
1 = C | |||||||
音階 | C | D | E | F | G | A | B |
唱名 | do | re | mi | fa | sol | la | Si |
數字 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
代碼 | NOTE_C4 | NOTE_D4 | NOTE_E4 | NOTE_F4 | NOTE_G4 | NOTE_A4 | NOTE_B4 |
如果 左上角的定義 1 = D,那么就從D開始重新標注,如下表:
1 = D | |||||||
音階 | D | E | F | G | A | B | C |
唱名 | do | re | mi | fa | sol | la | Si |
數字 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
代碼 | NOTE_D4 | NOTE_E4 | NOTE_F4 | NOTE_G4 | NOTE_A4 | NOTE_B4 | NOTE_C4 |
八度
如果是高一個八度,就會在數字上方加上一點。如果是低一個八度,就會數字下方加上一點。在中間的那一個八度就什么也不用加。如果要再高一個八度,就在上方垂直加上兩點(如:);要再低一個八度,就在下方垂直加上兩點(如:),如此類推。
自然大調
1 = C | 自然大調 | ||||||
數字 | 5 | ||||||
代碼 | NOTE_G7 | NOTE_G6 | NOTE_G5 | NOTE_G4 | NOTE_G3 | NOTE_G2 | NOTE_G1 |
自然小調
1 = C | 自然小調 | ||||||
數字 | 5 | ||||||
代碼 | NOTE_GS7 | NOTE_GS6 | NOTE_GS5 | NOTE_GS4 | NOTE_GS3 | NOTE_GS2 | NOTE_GS1 |
了解了音符和八度后,我們可以開始填寫音調數組,這個數組里的每個元素對應 tone 方法的 frequency 參數。
static int liang_zhi_lao_hu_Notes[] = {
NOTE_C4, NOTE_D4, NOTE_E4, NOTE_C4, NOTE_C4, NOTE_D4, NOTE_E4, NOTE_C4,
// 兩 只 老 虎 兩 只 老 虎
NOTE_E4, NOTE_F4, NOTE_G4, NOTE_E4, NOTE_F4, NOTE_G4,
// 跑 得 快 跑 得 快
NOTE_G4, NOTE_A4, NOTE_G4, NOTE_F4, NOTE_E4, NOTE_C4,
// 一 只 沒 有 眼 睛
NOTE_G4, NOTE_A4, NOTE_G4, NOTE_F4, NOTE_E4, NOTE_C4,
// 一 只 沒 有 尾 巴
NOTE_D4, NOTE_G3, NOTE_C4, 0,
// 真 奇 怪
NOTE_D4, NOTE_G3, NOTE_C4, 0};
// 真 奇 怪
拍號和音長
左上角的 2/4 表示拍號。這里的4代表4分音符為一拍,2代表每一個小節里共有兩拍。 通常只有數字的是四分音符。數字下加一條橫線,就可令四分音符的長度減半,即成為八分音符;兩條橫線可令八分音符的長度減半,即成為十六分音符,以此類推;數字后方的橫線延長音符,每加一條橫線延長一個四分音符的長度。 因此我們可以得到節拍數組,這個數組里的每個元素對應 tone 方法的 duration 參數。
static int liang_zhi_lao_hu_NoteDurations[] = {
8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 4, 8, 8, 4,
16, 16, 16, 16, 4, 4,
16, 16, 16, 16, 4, 4,
8, 8, 4, 4,
8, 8, 4, 4};
結構體定義
接下來,我們將得到的樂譜信息填入結構體當中。
// solutions/eduk1_demo/k1_apps/musicbox/musicbox.c
typedef struct
{
char *name; // 音樂的名字
int *notes; // 音符數組
int *noteDurations; // 節拍數組
unsigned int noteLength; // 音符數量
unsigned int musicTime; // 音樂總時長 由播放器處理 用于界面顯示 用戶不需要關心
} music_t; // 音樂結構體
typedef struct
{
music_t **music_list; // 音樂列表
unsigned int music_list_len; // 音樂列表的長度
int cur_music_index; // 當前第幾首音樂
unsigned int cur_music_note; // 當前音樂的第幾個音符
unsigned int cur_music_time; // 當前的播放時長 由播放器處理 用于界面顯示 用戶不需要關心
unsigned int isPlaying; // 音樂是否播放/暫停 由播放器處理 用戶不需要關心
} player_t;
static music_t liang_zhi_lao_hu = {
"liang_zhi_lao_hu",
liang_zhi_lao_hu_Notes,
liang_zhi_lao_hu_NoteDurations,
34
};
music_t *music_list[] = {
&liang_zhi_lao_hu_Notes, // 將音樂插入到音樂列表中
};
player_t musicbox_player = {music_list, 1, 0, 0, 0, 0}; // 初始化音樂播放器
實現播放音樂
while (1)
{
// 如果當前音調下標小于這首音樂的總音調 即尚未播放完
if (musicbox_player.cur_music_note < cur_music->noteLength)
{
// 通過節拍計算出當前音符需要的延時 1000ms / n分音符
int noteDuration = 1000 / cur_music->noteDurations[musicbox_player.cur_music_note];
// 對于附點音符 我們用讀數來標記 加有一個附點后音符的音長比其原來的音長增加了一半,即原音長的1.5倍。
noteDuration = (noteDuration < 0) ? (-noteDuration * 1.5) : noteDuration;
// 得到當前的音調
int note = cur_music->notes[musicbox_player.cur_music_note];
// 使用 tone 方法播放音調
tone(0, note, noteDuration);
// 延時一段時間 讓音調轉換更清晰
aos_msleep((int)(noteDuration * NOTE_SPACE_RATIO));
// 計算當前的播放時間
musicbox_player.cur_music_time += (noteDuration + (int)(noteDuration * NOTE_SPACE_RATIO));
// 準備播放下一個音調
musicbox_player.cur_music_note++;
}
}
繪制播放器
作為一位有理想有追求的開發者,僅僅能播放音樂肯定沒法滿足我們的創造欲。所以我們再來實現一個播放器,可以做到 暫停/播放, 上一首/下一首, 還能顯示歌曲名和進度條。 實現這些需要的信息,我們在結構體中都已經完成了相關的定義,只需要根據按鍵操作完成對應的音樂播放控制即可。
void musicbox_task()
{
while (1)
{
// 清除上一次繪畫的殘留
OLED_Clear();
// 獲取當前音樂的指針
music_t *cur_music = musicbox_player.music_list[musicbox_player.cur_music_index];
// 獲取當前音樂的名字并且繪制
char show_song_name[14] = {0};
sprintf(show_song_name, "%-13.13s", cur_music->name);
OLED_Show_String(14, 4, show_song_name, 16, 1);
// 如果當前播放器并未被暫停(正在播放)
if (musicbox_player.isPlaying)
{
// 如果還沒播放完
if (musicbox_player.cur_music_note < cur_music->noteLength)
{
int noteDuration = 1000 / cur_music->noteDurations[musicbox_player.cur_music_note];
noteDuration = (noteDuration < 0) ? (-noteDuration * 1.5) : noteDuration;
printf("note[%d] = %d\t delay %d ms\n", musicbox_player.cur_music_note, cur_music->noteDurations[musicbox_player.cur_music_note], noteDuration);
int note = cur_music->notes[musicbox_player.cur_music_note];
tone(0, note, noteDuration);
aos_msleep((int)(noteDuration * NOTE_SPACE_RATIO));
musicbox_player.cur_music_time += (noteDuration + (int)(noteDuration * NOTE_SPACE_RATIO));
musicbox_player.cur_music_note++;
}
// 如果播放完 切換到下一首
else
{
noTone(0);
aos_msleep(1000);
next_song(); // musicbox_player.cur_music_index++ 播放器的指向下一首音樂
}
OLED_Icon_Draw(54, 36, &icon_pause_24_24, 1); // 播放器處于播放狀態時 繪制暫停圖標
}
else
{
OLED_Icon_Draw(54, 36, &icon_resume_24_24, 1); // 播放器處于暫停狀態時 繪制播放圖標
aos_msleep(500);
}
// 繪制一條直線代表進度條 直線的長度是 99.0(可繪畫區域的最大長度) * (musicbox_player.cur_music_time(播放器記錄的當前音樂播放時長) / cur_music->musicTime(這首歌的總時長))
OLED_DrawLine(16, 27, (int)(16 + 99.0 * (musicbox_player.cur_music_time * 1.0 / cur_music->musicTime)), 27, 1);
// 繪制上一首和下一首的圖標
OLED_Icon_Draw(94, 36, &icon_next_song_24_24, 1);
OLED_Icon_Draw(14, 36, &icon_previous_song_24_24, 1);
// 將繪制的信息顯示在屏幕上
OLED_Refresh_GRAM();
}
}