本文来自 JiaYe 的投稿,原文地址:https://zhuanlan.zhihu.com/p/108104930
偶然看在网上看到了拇指琴这么一种乐器,觉得可好听了,但是一直没买。后来又冒出来一个想法:能不能用单片机来自动控制一些乐器来弹奏曲子呢?想了想发现有点难度,那就做一个简单点的硬件放在拇指琴上边,跟着它弹奏吧!于是立刻在淘宝下单,买了拇指琴、STM32F103和其他的一些模块,开搞。
硬件和电路图
一、音符的编码
17键拇卡林巴音阶对照图
为了节省内存,我们把上图的这些音符编码为相应的17个字符。
1, 2, 3, 4, 5, 6, 7 对应 C4~B4 c, d, e, f, g, a, b 对应 C5~B5 C, D, E 对应 C6~E6
休止符、增时线沿用简谱符号,因为在整个音符结束后才会关闭LED,因此这两个符号在代码中都视为单纯延时(严格来说,休止符播放时应关闭前一个音符的LED,目前代码中没这么处理,而是靠不同音符的时长来对齐)。
0 休止符 - 增时线
通常,简谱中最短的音符是八分之一拍,那就把减时线(下划线)替换成数字拼接在字符后面,代表音符长度为几个八分之一拍。举例说明:
x4 八分之四拍 (1减时线 二分之一拍) x6 八分之六拍 (1减时线、1附点 四分之三拍) x2 八分之二拍 (2减时线 四分之一拍) x3 八分之三拍 (2减时线、1附点) x1 八分之一拍 (3减时线) x12 八分之十二拍 (1附点, 一又二分之一拍, 实际使用时为了对其伴奏可能不会这么写) 其中x为具体音符,包括休止符、曾时线
二、乐谱的编码
明确了音符编码规则,就可以把音符拼起来组成整首乐谱了。类似简谱,乐谱字符串以“4,90”开头。其中“4”代表每小节4拍,代码中不对这个数字做处理,因为小节之间用“|”区分。“90”代表每分钟90拍(单位BPM),代码根据BPM来计算每个八分之一拍要延时多久。音符之间用逗号分割,开头、主题曲和每个伴奏曲之间用“_”分割。
《新年好》拇指琴简谱
编码举例《新年好》:
开头: 3,92_ 1~3节: 0, 0, c4,c4 | c, 5, e4,e4 | e, c, c4,e4 | 0, 0, 0 | 1, 0, 0 | 1, 0, 0 | 4~6节: g, g, f4,e4 | d, -, d4,e4 | f, f, e4,d4 | 1, 5, 0 | 2, 5, 0 | 2, 4, 0 | 7~9节: e, c, c4,e4 | d, 5, 74,d4 | c, - 1, 0, 0 | 2, 0, 0 | 1, 5
以上编码中,第一行是主题曲,第二行是伴奏,弹奏时两串音符一起播放。其中c4,e4、e4,e4、f4,e4、d4,e4、e4,d4、74d4几个是二分音符,每个都由两个二分之一拍组成。其他音符都是全音符。
《新年好》1~9节完整的乐谱:
3,92_0,0,c4,c4|c,5,e4,e4|e,c,c4,e4|g,g,f4,e4|d,-,d4,e4|f,f,e4,d4|e,c,c4,e4|d,5,74,d4|c,-_0,0,0|1,0,0|1,0,0|1,5,0|2,5,0|2,4,0|1,0,0|2,0,0|1,5
编码举例《渚》:
《渚》的简谱
《渚》的简谱
《渚》的简谱编码
可以看出,每个全音符时长中,所有音符后边数字加起来都等于8,或者两个全音符时长中的相加等于16。不支持的低音音符用休止符替代。
需要特殊注意的地方 1. 主题曲全音符长度和伴奏的全音符长度应该互相考虑,同一节中的主题、伴奏音符中,以短的为准,这样伴奏和主题不会混乱。 2. 如果有两个连续的相同音符,比如24,24,那么改为23,01,23,01,这样弹奏时可区分。
看起来,熟悉了规则,翻译一首简谱还是比较容易的。
三、乐谱的传送、接收和存放
手工写好乐谱的编码以后,就可以发送到STM32上进行解析、播放了。不同的乐谱是由微信小程序通过低功耗蓝牙连接发送到STM32上进行播放的(参考电路图),小程序的源码也一并提供在代码仓库里。
用小程序发送乐谱是为了简化操作,不用读写存储卡。想要更简单,也可以省去小程序发送、接收这块代码,把乐谱都硬编码到代码中,然后把电路图中的蓝牙模块替换成3.3v稳压就可以了。
微信小程序界面
通过蓝牙模块,从USART3设备(B10,B11两个IO口)接收乐谱字符串:
代码语言:javascript复制// 串口设备USART3
// 将pb10配置为push_pull输出,这将是tx引脚
let tx = gpiob.pb10.into_alternate_push_pull(&mut gpiob.crh);// 取得pb11控制权
let rx = gpiob.pb11;// 设置usart设备。通过USART寄存器和 tx/rx 引脚获得所有权。其余寄存器用于启用和配置设备。
let serial = Serial::usart3( device.USART3, (tx, rx), &mut afio.mapr, Config::default().baudrate(115200.bps()), clocks, &mut rcc.apb1,);// 将串行结构拆分为接收和发送部分
let (mut tx, mut rx) = serial.split();// 整个字符串用分段传输, 每段以“ ”结尾
// received中接收完每段之后将其push到music_str中缓存
let mut received = String::new();let mut music_str = String::new();let mut player = Player::new(chordes, delay).unwrap();// 注意!loop中如果有hprintln、delay等会导致串口接收失败
loop { // 程序主循环
//播放时无法接收蓝牙数据,因为play会有延迟
if !player.ended() { let _ = player.play(); } else { if player.get_theme().is_some() { player.reset(); } } // 从串口接收单个字符
if let Ok(c) = rx.read() { received.push(c as char); // 在数据接收的过程中不能写,否则会造成数据丢失。
if received.ends_with(" ") { // 替换掉多余字符
let musics = received.replace(" ", ""); received = String::new(); // 每段push到music_str中缓存
music_str.push_str(&musics); } // 如果是#结束,开始播放
if received.ends_with("#") { let musics = received.replace("#", ""); received = String::new(); music_str.push_str(&musics); // 现在musics存储的是完整的音乐字符串
let musics = music_str; // 开始播放!
player.set_song(musics); music_str = String::new(); writeln!(tx, "START").unwrap(); } }}
乐谱的解析、播放的主要代码逻辑都在player模块中。
解析好的乐谱的存放在Note、Song、Player结构体中,代码比较简单。
Note结构体存放起止节拍和琴键
代码语言:javascript复制/// 音符
#[derive(Debug, Clone)]pub struct Note{ /// 起始节拍(八分之一拍)
start_beat: u16, /// 终止节拍(八分之一拍)
end_beat: u16, /// 琴键
key: u8, // 键名 (为了节省内存,不使用)
// name: String
}
Song结构体存放乐谱的所有的音符和时长,以及播放状态的游标索引。
代码语言:javascript复制/// 曲子
#[derive(Debug)]pub struct Song{ /// 总共多少个八分之一拍
total_beat: u16, /// 所有音符
notes: Vec<Note>, /// 当前正在播放的音符
cursor: usize
}
Player结构体存储硬件设备、节拍计数器和节拍时间。主题曲和伴奏分开存储,实际上是没有必要的, 因为他们都是Song结构体(当时考虑的是只有一个主题和一个伴奏,后来发现可能有更多)。
代码语言:javascript复制/// 音符播放器
pub struct Player{ delay: Delay, chordes:ChordesIO, /// 节拍计数器
current_beat: u16, /// 每小节几拍
beat_per_group: u8, /// 每个八分之一拍多少微妙
time_per_beat: u32, ended: bool, /// 主题
theme: Option<Song>, /// 伴奏
accompanies: Vec<Option<Song>>,}
四、乐谱和音符的解析
乐谱解析逻辑并不复杂,根据乐谱开头BPM参数,计算出每拍时长,又可以计算出每八分之一拍时长,为了播放时延迟更精确,将时间转换为微妙。解析出来的时长存放到Player中备用。
代码语言:javascript复制 pub fn set_song(&mut self, songs:String) -> Option<()>{ //分离歌曲信息、主题和伴奏
let mut songs = songs.split("_"); //歌曲信息
let mut info = songs.next()?.split(","); let beat_per_group = parse::<u8>(info.next()?)?;//每小节几拍
let beat_per_min = parse::<f32>(info.next()?)?;//每分钟多少拍
// 计算每拍延迟(1ms=1000000us)
// 一个u32可存储4294967295纳秒,即4294.9毫秒,足够一个八分之一节拍延时使用
// 每拍毫秒数
let time_per_total_beat = 60000.0 / beat_per_min; //每八分之一拍毫秒数
let time_per_eighth_beat = time_per_total_beat / 8.0; //每八分之一拍微妙数
let time_per_beat = (time_per_eighth_beat*1000.0) as u32; //至少有主题曲
let theme_str = songs.next()?; self.theme = split_notes(theme_str); //解析所有伴奏曲
while let Some(data) = songs.next(){ self.accompanies.push(split_notes(data)); } self.current_beat = 0; self.beat_per_group = beat_per_group; self.time_per_beat = time_per_beat; self.ended = false; Some(()) }
音符解析归功于split函数,用“|”和“,”读取每个小节和音符。整个音乐时间段,是由N个八分之一拍的片段组成的。每个音符(包括增时线和休止符)占用N个八分之一拍,N由音符后边的数字来指定。常量是为了方便阅读定义的。
代码语言:javascript复制/// 解析音符
fn split_notes(data: &str) -> Option<Song>{ // 分别解析每小节的音符
let mut notes:Vec<Note> = Vec::new(); let parts = data.split("|"); //统计总共多少个八分之一拍
let mut total_beat_count = 0; for part in parts{ for note in part.split(","){ let mut symbols = note.chars(); let key_name = symbols.next()?; let key = match key_name{ NOTE_D6 => NOTE_D6_IO, NOTE_B5 => NOTE_B5_IO, NOTE_G5 => NOTE_G5_IO, NOTE_E5 => NOTE_E5_IO, NOTE_C5 => NOTE_C5_IO, NOTE_A4 => NOTE_A4_IO, NOTE_F4 => NOTE_F4_IO, NOTE_D4 => NOTE_D4_IO, NOTE_C4 => NOTE_C4_IO, NOTE_E4 => NOTE_E4_IO, NOTE_G4 => NOTE_G4_IO, NOTE_B4 => NOTE_B4_IO, NOTE_D5 => NOTE_D5_IO, NOTE_F5 => NOTE_F5_IO, NOTE_A5 => NOTE_A5_IO, NOTE_C6 => NOTE_C6_IO, NOTE_E6 => NOTE_E6_IO, _ => NOTE_NO_IO // 默认为255空引脚,即不亮灯。包括增时线(-)、休止符(0)
}; let mut beat_count = 8; //默认为整拍
if let Some(n) = symbols.next(){ //读取音符节拍数
beat_count = parse::<u16>(&n.to_string())?; } /*
假设第一个音符是1拍,第二个和第三个音符都是半拍,第四个音符又是1拍
那么第一个音符是从0开始,第二个音符是从8开始,第三个音符从12开始,
第四个音符从16开始
*/ notes.push(Note{ start_beat: total_beat_count, end_beat: total_beat_count beat_count-1, key, // name: key_name.to_string(),
}); //每个音符通常是8个八分之一拍,也可能多于8个,但一般不这么用
total_beat_count = beat_count; } } let song = Song{ notes, cursor: 0, total_beat: total_beat_count }; Some(song)}
五、乐谱的播放
乐谱的播放逻辑在Player结构体的play函数中,看起来很简单。整个音乐在main函数中的loop{}函数中播放的。
代码语言:javascript复制/// 播放一个音符
pub fn play(&mut self) -> Option<Note>{ // 播放结束不做任何操作,直接返回
if self.ended || self.theme.is_none(){ self.ended = true; return None; } // 获取主题曲的引用
let theme = self.theme.as_mut().unwrap(); // 检查主歌曲是否已结束
if self.current_beat == theme.total_beat{ self.ended = true; return None; } // 播放主题曲当前的音符
let theme_note = play_note(self.current_beat, theme, &mut self.chordes); // 播放所有伴奏曲当前的音符
let accompanies:&mut Vec<Option<Song>> = self.accompanies.as_mut(); for accompany in accompanies{ if let Some(accompany) = accompany{ let _ = play_note(self.current_beat, accompany, &mut self.chordes); } } // 延时us(即微妙)
self.delay.delay_us(self.time_per_beat); // 节拍计数器 1
self.current_beat = 1; // 返回正在播放的音符(暂无实际用途)
theme_note}/// 点亮音符对应的LED,返回开始的弹奏的音符
fn play_note(current_beat: u16, song:&mut Song, chordes: &mut ChordesIO) -> Option<Note>{ let mut current_note = song.notes.get(song.cursor)?; //如果当前节拍大于当前音符的结束拍,关闭音符对应的LED,并切换音符
if current_beat > current_note.end_beat{ let _ = chordes.turn_off(current_note.key); song.cursor = 1; current_note = song.notes.get(song.cursor)?; } //如果当前节拍等于当前音符的起始拍,点亮LED
if current_beat == current_note.start_beat{ if chordes.turn_on(current_note.key){ return Some(current_note.clone()); } } None}
每当从Song的notes中读取到下一个音符,如果时间到了,play_note函数就点亮对应的LED,这时候调用delay函数,延迟八分之一拍的时间,琴上对应位置的LED就会亮起来并维持一段时间。当时间过去音符对应的八分之一拍个数,play_note又会关闭这个LED。如此就可以跟着LED灯的亮灭一起弹奏了,如果两个灯同时亮了,就说明是有伴奏需要一起弹。如果弹出来声音不对,那就是乐谱有问题,去改乐谱吧!
由于播放使用delay延时机制,导致播放同时不能正常接收蓝牙数据,我都是关机开机来重新发送要练习的乐谱。这个地方如果改为异步延时方式,应该就可以播放的同时接收串口数据了。
上边的蓝灯管我本来想作为节拍指示器用的,但是后来发现在看着LED弹的时候,根本无暇顾及这个节拍灯,后来想就在音符开始时亮灯吧,但是我仍然注意不到,于是就放这里了,没啥用。
装上盖子
完整版请移步:https://zhuanlan.zhihu.com/p/108104930