zoologist 发表于 2023-4-13 15:17:13

FireBeetle制作打击乐指示器

凡用打、击方式发声的乐器(打弦乐器除外)统称为打击乐(器)。显而易见的是相对于弹奏和吹奏乐器,打击乐演奏更加简单。比如,著名的成语典故“渑池之会”就记述了秦王现学现卖击缶的故事:“秦王派使臣告诉赵王,打算与赵王和好,在西河外渑池相会。赵王害怕秦王,想不去。廉颇、蔺相如商量说:“大王不去,显得赵国既软弱又怯懦。”赵王于是动身赴会,蔺相如随行。廉颇送到边境,跟赵王辞别时说:“大王这次出行,估计一路行程和会见的礼节完毕,直到回国,不会超过三十天。如果大王三十天没有回来,就请允许我立太子为王,以便断绝秦国要挟赵国的念头。”赵王同意廉颇的建议,就和秦王在渑池会见。秦王喝酒喝得高兴时说:“我私下听说赵王喜好音乐,请赵王弹弹瑟吧!”赵王就弹起瑟来。秦国的史官走上前来写道:“某年某月某日,秦王与赵王会盟饮酒,命令赵王弹瑟。”蔺相如走向前去说:“赵王私下听说秦王善于演奏秦地的乐曲,请允许我献盆缶给秦王,请秦王敲一敲,借此互相娱乐吧!”秦王发怒,不肯敲缶。在这时蔺相如走上前去献上一个瓦缶,趁势跪下请求秦王敲击。秦王不肯敲击瓦缶。蔺相如说:“如大王不肯敲缶,在五步距离内,我能够把自己颈项里的血溅在大王身上!”秦王身边的侍从要用刀杀蔺相如,蔺相如瞪着眼睛呵斥他们,他们都被吓退了。于是秦王很不高兴,为赵王敲了一下瓦缶。蔺相如回头召唤赵国史官写道:“某年某月某日,秦王为赵王击缶。”秦国的众大臣说:“请赵王用赵国的十五座城为秦王祝寿。”蔺相如也说:“请把秦国的都城咸阳送给赵王祝寿。”直到酒宴结束,秦王始终未能占赵国的上风。赵国又大量陈兵边境以防备秦国入侵,秦军也不敢轻举妄动。渑池会结束后,回到赵国,因为蔺相如功劳大,赵王任命他做上卿,位在廉颇之上。“【参考1】1999年HanseulSoft公司制作过一款名为《VOS》(VirtualOrchestra Studio)的音乐游戏。其游戏操作界面是7个钢琴式的按键,当显示屏幕中的“音符”下落至游戏界面下方的准线时,玩家按下对应的按键,便可弹奏出该“音符”。这款叫做《VOS》的游戏,考验的是玩家对音乐节奏的把握以及手部反应能力。
铝板琴为法国米斯泰尔发明。最初的发音体用一系列音叉,亦称钢叉琴。1886年正式命名为钢片琴(又名铝板琴),实际的铝板琴音条和共鸣管均以铝制作。铝板琴外形如小形簧风琴,声源体为金属板条,以类似钢琴的击弦机击奏、有踏板制音器控制音响的长短,和键盘钢条琴相似,但每一钢音条下方附有共鸣管,放大音量,并使音色清晰纯净。钢片琴音域一般为C?C(4),2个半八度。【参考2】
这次制作的目标是制作一款能够指示用户敲击铝板琴演奏音乐的设备。硬件方面选择 DFRobot 的FireBeetleESP32 ,搭配WCH 的 CH423来实现。设计的理念非常简单:FireBeetleESP32 通过串口和PC相连,后者发送控制信息,之后FireBeetleESP32 控制 CH423,再由CH423控制 15*8个LED 提示用户操作。从上面也可以看出来,这个设计还可以修改为:通过无线发送数据进行控制,或者事先将要演奏的内容存储在 FireBeetle ESP32中,然后使用充电宝供电这样就彻底摆脱了电脑。

1.   为了布线方便,对 FireBeelte 进行了简化,只留下了必要的引脚;2.   使用 CH423S封装芯片,关于这个芯片的使用可以在【参考3】看到;3.   为了以后使用充电宝预留了一个电流消耗电路

这是LED 点阵 8*15个LED。这个设计最大的挑战来自布线,因为PCB需要和铝板琴进行对应,因此尺寸上远超10*10cm,为了尽量降低成本,我们必须努力压缩面积(这也是为什么前面要把 FireBeetle ESP32 的符号修改的如此奇怪的原因)。
最终的尺寸是46.2*38.9cm,嘉立创打样价格是 60元。






首先编写Arduino 代码:
#include <Wire.h>

#define   LEDCOUNTER 15

// CH423接口定义
#define   CH423_I2C_ADDR1   0x20         // CH423的地址
#define   CH423_I2C_MASK      0x3E         // CH423的高字节命令掩码

#define CH423_SYSON1    0x0417    //开启自动扫描显示

unsigned char CH423_buf;    //定义16个数码管的数据映象缓存区

void CH423_Write( uint32_t cmd )    // 写命令
{
//Serial.print("Address ");
//Serial.print(( unsigned char )(cmd >> 8), HEX);
//Serial.print("command");
//Serial.print(( unsigned char ) (cmd & 0xff), HEX);
Wire.beginTransmission (( unsigned char )(cmd >> 8));
Wire.write( ( unsigned char ) (cmd & 0xff) );// 发送数据
// 结束总线
if (Wire.endTransmission() == 0) {
    //Serial.println(" I2C Success!");
} else {
    //Serial.println("I2C error!");
}
}

// 向CH423输出数据或者操作命令,自动建立数据映象
void CH423_buf_write( uint32_t cmd )
{
if ( cmd & 0x1000 )
{ // 加载数据的命令,需要备份数据到映象缓冲区
    CH423_buf[ (unsigned char)( cmd >> 8 ) & 0x0F ] = (unsigned char)( cmd & 0xFF );    // 备份数据到相应的映象单元
}
CH423_Write( cmd );    // 发出
}

void setup() {
Serial.begin (115200);
Wire.begin (21, 22);   // sda= GPIO_21 /scl= GPIO_22.
/* INTENS
   OD_EN 使能开漏
   X_INT 0x08
   DEC_H 0x04
   DEC_L 0x02
   IO_OE 0x01
*/
CH423_buf_write( 0x2417 );
/* OC_L_DATOC7-OC0 电平控制
*/
CH423_buf_write( 0x2200 );
/* OC_H_DATOC15-OC8 电平控制
*/
CH423_buf_write( 0x2300 );

// 初始化时保持全灭
uint32_t i;
for (i = 0; i < LEDCOUNTER; i++) {
    CH423_buf_write(((0x30 + i) << 8) + 0x00);
}
}

byte Buf;
byte Counter = 0;
long int Elsp = 0;

void loop() {
if (Serial.available() > 0) {
    Buf = Serial.read();
    Counter++;
    // 收到第一个开始计时
    if (Counter == 1) {
      Elsp = millis();
    }
    // 收到第16个就进行处理
    if (Counter == LEDCOUNTER) {
      for (int i=0;i<LEDCOUNTER;i++) {
          Serial.print(Buf,HEX);
          Serial.print("");
          CH423_buf_write( ((0x30 + i) << 8) + Buf);
      }
      Serial.println("");
      //重新计数
      Counter = 0;
      //计时器归零
      Elsp = 0;
    }
}
// 如果超过200ms没有收到,那么计数器归零
if ((Elsp!=0)&&(millis()-Elsp>200)) {
      //重新计数
      Counter = 0;
      //计时器归零
      Elsp = 0;
    }
}

上位机会通过串口发送当前要点亮的第一个LED的位置,然后通过计时器移动点亮的LED,一直到最后的三个。上位机代码是 c#编写,运行时会需要先打开乐谱文件。

主要代码如下:using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.IO;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.IO.Ports;    //串口控件
using System.Threading;

namespace CSI_MIPI_CLIENT
{
    public unsafe partial class Form1 : Form
    {
      public const int LEDCOUNTER=15;// 共有15组 LED
      public int NoteIndex = 0;
      public int Elsp = 0;
      // 发送给FireBeetle 的数据 Buffer
      public byte[] Note = new byte;
      // MIDI 数据对应LED 序列号
      public int[] NoteToOrder = new int
                              { 91,89,88,86,84,       // 5# 4# 3# 2# 1#
                                  83,81,79,77,76,74,72, // 7 6 5 4 3 2 1
                                  71,69,67            // #7 #6 #5
      };
      public const int ONEDELAY = 150;// 一个延时150ms
      // 记录音乐的结构体
      public struct nodeStruct
      {
            public int time;      //绝对时间,比如: 1200
            public int note;      //音符值, MIDI 的音符值
            public int delayunit;   //延时数量, 比如:3, 表示从上一个到这个音符延迟3个单位
      };
      // 记录整首音乐
      public List<nodeStruct> NoteList = new List<nodeStruct>();

      public Form1()
      {
            InitializeComponent();
      }

      private void Form1_Load(object sender, EventArgs e)
      {
            if (System.IO.Ports.SerialPort.GetPortNames().Length != 0)
            {

                // List COM all ports in comboxBox1
                comboBox1.Items.AddRange(System.IO.Ports.SerialPort.GetPortNames());
                comboBox1.SelectedIndex = 0;

                serialPort1.BaudRate = 115200;
                serialPort1.DataBits = 8;
                serialPort1.Parity = 0;
                serialPort1.StopBits = (StopBits)1;
                serialPort1.Encoding = System.Text.Encoding.GetEncoding(28591);

                System.Windows.Forms.Control.CheckForIllegalCrossThreadCalls = false;

                // Show Application version in Form Title
                this.Text = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version.ToString() + "\n";

            }
            else
            {
                textBox1.AppendText(" No COM ports are found!");
                button1.Enabled = false;
            }
      }


      // 打开串口
      private void button1_Click(object sender, EventArgs e)
      {
            // 如果当前串口已经打开,那么就关闭之
            if (serialPort1.IsOpen)
            {
                //停止定时器
                timer1.Enabled = false;
                // 更改按钮名称
                button1.Text = "Open Port";
                // 输出Log
                textBox1.AppendText("Close Port at " + DateTime.Now.ToString() + "\r\n");
                serialPort1.Close();
            }
            else
            {
                // 如果串口未打开
                // 打开串口
                try
                {
                  serialPort1.PortName = comboBox1.SelectedItem.ToString();
                  serialPort1.Open();
                }
                catch (Exception Ex)
                {
                  textBox1.AppendText("Open Port at" + comboBox1.SelectedItem.ToString() + " error \r\n");
                  textBox1.AppendText(Ex.Message + " \r\n");
                  return;
                }

                // Serial port is NOT opened
                button1.Text = "Close Port";
                textBox1.AppendText("Start at " + DateTime.Now.ToString() + "\r\n");
            }
      }

      // 读取音乐文件
      private void button2_Click_1(object sender, EventArgs e)
      {
            nodeStruct newValue = new nodeStruct();

            if (openFileDialog1.ShowDialog() == DialogResult.OK)
            {

                FileStream fs = new FileStream(openFileDialog1.FileName, FileMode.Open, FileAccess.Read);

                StreamReader sr = new StreamReader(fs);
                //使用StreamReader类来读取文件
                sr.BaseStream.Seek(0, SeekOrigin.Begin);
                // 从数据流中读取每一行,直到文件的最后一行
                string tmp = sr.ReadLine();
                int MiniDelay = 65535;

                while (tmp != null)
                {
                  // 只有带有 Note_on_c 字样的才包含数据信息
                  if (tmp.IndexOf("Note_on_c") > 0)
                  {
                        // 数据使用“,” 分割,这里拆解成字符串
                        string[] arr = tmp.Split(',');

                        // 只记录敲击发生的数据,不记录停止的数据
                        if (Convert.ToInt32(arr) != 0)
                        {
                            newValue.time = Convert.ToInt32(arr);

                            // 转化为 LED 的位置号
                            for (int i = 0; i < LEDCOUNTER; i++) {
                              if (Convert.ToInt32(arr) == NoteToOrder) {
                                    newValue.note = i;
                                    break;
                              }
                            }
                           
                            if (NoteList.Count > 0)
                            {
                              MiniDelay = Math.Min(MiniDelay, newValue.time - NoteList.time);
                              if (MiniDelay == 0) {
                                    Thread.Sleep(100);
                              }
                            }
                            if (NoteList.Count == 0) {
                              newValue.delayunit = 0;
                            }
                            NoteList.Add(newValue);
                        }

                        

                  }
                  tmp = sr.ReadLine();
                }
                //关闭此StreamReader对象
                sr.Close();
                fs.Close();

                textBox1.AppendText("Total " + NoteList.Count.ToString() + "/Mininal delay " + MiniDelay.ToString() + "\r\n");


                // 给歌曲信息赋值间隔数据
                for (int i = 1; i < NoteList.Count; i++) {
                  nodeStruct tmpNode = NoteList;
                  tmpNode.delayunit = (NoteList.time - NoteList.time) / MiniDelay;
                  if (tmpNode.delayunit > 10) { tmpNode.delayunit = 9; }
                  NoteList = tmpNode;
                }

                for (int i = 0; i < NoteList.Count;i++) {
                  textBox1.AppendText(i.ToString() + "" +
                                        NoteList.time.ToString() + "   "+
                                        NoteList.note.ToString()+" "+
                                        NoteList.delayunit.ToString()+"\r\n"
                                        );
                }
                  textBox1.AppendText("File loaded\r\n");
               

            }
      }

      private void timer1_Tick_1(object sender, EventArgs e)
      {

            // 处理最后几个下落
            //LEDCOUNTER
            for (int i = 0; i < LEDCOUNTER; i++)
            {
                // 如果移动之前最低3Bits有1,那么清除之
                if ((Note & 0b00000111) != 0)
                {
                  Note = (byte)(Note & 0b11111000);
                }
                Note = (byte)((Note & 0xFF) >> 1);
                //如果移动之后Bit2为1,那么最后3位置起来
                if ((Note & 0b00000100) != 0)
                {
                  Note = (byte)(Note | 0b00000111);
                }
            }
            serialPort1.Write(Note, 0, LEDCOUNTER);
            string s = NoteIndex.ToString()+" ";
            for (int i = 0; i < LEDCOUNTER; i++)
            {
                s = s + Note.ToString("X2") + "   ";
            }
            textBox1.AppendText(s + "\r\n");

            // 如果当前已经是最后一个,那么就停止
            if (NoteIndex == NoteList.Count) {
                int v = 0;
                for (int i = 0; i < LEDCOUNTER; i++) {
                  v = v + Note;
                }
                if (v==0)
                {
                  textBox1.AppendText("End of the song\r\n");
                  timer1.Enabled = false;
                  return;
                }
                return;
            }

            // 如果到达时间
            if (Elsp >= NoteList.delayunit)
            {
                Note.note] = (byte)(Note.note]|0x80);
                serialPort1.Write(Note, 0, LEDCOUNTER);
                NoteIndex++;
                Elsp = 0;
            }
            else
            {
                Elsp++;
            }

      }

      private void button3_Click(object sender, EventArgs e)
      {
            if (timer1.Enabled)
            {
                timer1.Enabled = false;
                button3.Text = "Start Send";
            }
            else
            {
                for (int i = 0; i < LEDCOUNTER; i++) {
                  Note = 0xFF;
                  serialPort1.Write(Note, 0, LEDCOUNTER);
                  Thread.Sleep(100);
                }
                Thread.Sleep(200);

                for (int i = LEDCOUNTER-1; i >-1 ; i--)
                {
                  Note = 0x00;
                  serialPort1.Write(Note, 0, LEDCOUNTER);
                  Thread.Sleep(100);
                }
                button3.Text = "Stop Send";
                timer1.Interval = ONEDELAY;
                timer1.Enabled = true;
                NoteIndex = 0;
                Elsp = 0;
            }

      }
    }
}




另外还有一个关键的问题:如何制作软件使用的数据。我是用的是midicsv这个软件,它能够提取MID文件的数据信心保存在 csv文件中,这个软件的使用方法在之前的文章中有介绍过【参考3】。
参考:1.   https://baike.baidu.com/item/%E6%B8%91%E6%B1%A0%E4%B9%8B%E4%BC%9A/336018?fr=Aladdin2.   https://baike.baidu.com/item/%E9%93%9D%E6%9D%BF%E7%90%B4/2010684?fr=aladdin3.   https://mc.dfrobot.com.cn/forum.php?mod=viewthread&tid=3148654.   https://mc.dfrobot.com.cn/thread-312139-1-1.html



zoologist 发表于 2023-4-13 15:26:03

本文提到的应用程序代码:


本文提到的 midi 提取工具

zoologist 发表于 2023-4-13 15:27:31

https://www.bilibili.com/video/BV1Fs4y1J7pm/?vd_source=cf6121716e06cb669a27c10276f9c920
工作的测试视频

若晗 发表于 2023-5-4 14:34:04

学习了,感谢分享
页: [1]
查看完整版本: FireBeetle制作打击乐指示器