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

2020-02-26 14:00:41 浏览数 (2)

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

0 人点赞