前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【用Rust玩嵌入式】用STM32做一个拇指琴音符指示器

【用Rust玩嵌入式】用STM32做一个拇指琴音符指示器

作者头像
MikeLoveRust
发布2020-02-26 14:00:41
1.1K0
发布2020-02-26 14:00:41
举报
文章被收录于专栏:Rust语言学习交流

本文来自 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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2020-02-24,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Rust语言学习交流 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、音符的编码
  • 二、乐谱的编码
  • 三、乐谱的传送、接收和存放
  • 四、乐谱和音符的解析
  • 五、乐谱的播放
相关产品与服务
云开发 CloudBase
云开发(Tencent CloudBase,TCB)是腾讯云提供的云原生一体化开发环境和工具平台,为200万+企业和开发者提供高可用、自动弹性扩缩的后端云服务,可用于云端一体化开发多种端应用(小程序、公众号、Web 应用等),避免了应用开发过程中繁琐的服务器搭建及运维,开发者可以专注于业务逻辑的实现,开发门槛更低,效率更高。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档