创建MIDI直通录音机

2020-12-25 12:42:47

如果您曾经在计算机上使用过音频软件,则可能知道MIDI存在:一种信号协议,允许控制器控制诸如合成器之类的重要乐器。它也是真实音频硬件与每个设备进行通讯所使用的协议,您可以将其视为设备谈论其工作而不是生成声音的语言。

因此,有两种方法可以记录乐器(真实的或虚拟的):记录它们发出的声音,或记录导致该声音产生的MIDI事件,这就是使事情变得有趣的地方。

记录音频的方法有很多,从麦克风到线路监视器再到音频接口,但是记录MIDI事件的方法并不多。本质上:除非您正在运行监视MIDI事件的软件,否则实际上没有任何方法可以记录MIDI。因此,我开始进行更改:以与您可以挂接音频现场记录器(例如Tuscan DR-05)相同的方式,将其放置在生成音频的音频输出和音频生成器的音频输入之间应该收听该音频,并将其以.wav或.mp3等格式写入SD卡,我建立了一个MIDI“现场录音机”,您可以在MIDI输出和某些MIDI输入之间插入,不加区别地记录每个MIDI该事件通过网络以.mid文件的形式发送到SD卡。

您可能会认为这已经是您可以购买的产品了。令人惊讶的是,事实并非如此。因此,如果您也想要一个,则必须构建一个,如果您想要构建一个,则这篇文章可能对您有用!

为此,我们将基本上构建一个标准的基于Arduino的MIDI直通接口,并连接一个SD卡电路,以便我们保存流过的数据。要构建所有内容,我们需要一些特殊的组件:

当然,几乎任何Arduino入门套件都会带给您的点点滴滴:

我们在Arduino RX< -0引脚上设置了MIDI-In,而MIDI-Thru也直接敲入了要发送到RX< -0的信号。唯一棘手的一点是,MIDI信号通过一个光耦合器与其余电路隔离(通过将其运行通过一个LED来传输信号的方式来解决接地环路问题,LED会以光的形式发出电信号,然后通过由光电晶体管拾取,将光转换回电信号)。放置和连接光耦合器时,确保您知道哪个引脚是引脚1非常重要:引脚1旁边会有一个小标记(通常在芯片盒上有一个点),告诉您该侧有引脚1。从上到下依次从4到4,另一侧从下到上的5到8针。另请注意,此电路未使用引脚1和4:只有引脚2和3连接到MIDI-In连接器,而引脚5至8连接到各种arduino引脚。

(我知道,“直言不讳!”,但这就是MIDI规范所说的,所以英语在这里排在了后面……)

SD卡电路实际上只是“将引脚连接到引脚”的问题,唯一的奇怪的是引脚的排列不够好以至于只能将SD卡模块直接插入Arduino。

但是请注意,您的SD卡模块可能具有不同的引脚布局,因此请务必在接线之前仔细检查!

另外,我们将添加一个小压电扬声器和一个按钮,我们可以按此按钮打开(或关闭)播放与正在播放的MIDI音符相对应的音符,主要是作为视觉调试的音频。这里几乎没有任何工作:我们将8号脚和地面之间的“扬声器”和2号脚的按钮连接起来。

设置好电路之后,让我们开始编写程序,着重于在其自己的部分中处理每个电路。

我们的基本程序将需要导入标准SD库和MIDI库(可能需要先安装)。

请注意,如果您不想“遵循”,而只想要代码,则可以将在midi-recorder.ino中找到的代码复制粘贴到Arduino IDE中。

#include< SD.h> #include< MIDI.h> MIDI_CREATE_DEFAULT_INSTANCE(); void setup(){//我们将在下一节中放置更多代码} void loop(){//我们将在下一节中放置更多代码}

当然,这还没有做任何事情,所以我们也添加其余的代码。

对于MIDI处理,我们需要为MIDI事件设置侦听器,并确保在程序循环期间轮询该数据:

这将在所有MIDI通道上设置MIDI侦听(其中有16个通道,我们不想猜测哪个通道处于活动状态),并从RX< -0读出MIDI数据-您可能已经注意到我们没有显式设置波特率:MIDI规范仅允许每秒31,250位,因此Arduino MIDI库自动确保为我们设置正确的轮询率。

#define NOTE_OFF_EVENT 0x81#define NOTE_ON_EVENT 0x91#define CONTROL_CHANGE_EVENT 0xB1#define PITCH_BEND_EVENT 0xE1 void handleNoteOff(字节通道,字节音高,字节速度){writeToFile(NOTE_OFF_EVENT,音高,速度); } void handleNoteOn(字节通道,字节音调,字节速度){writeToFile(NOTE_ON_EVENT,音调,速度); } void handleControlChange(字节通道,字节控制​​器,字节值){writeToFile(CONTROL_CHANGE_EVENT,控制器,值); } void handlePitchBend(byte channel,int bent_value){//首先,我们需要" recenter"弯曲值,//因为在MIDI中,弯曲值是一个正值,//处于0x0000-0x3FFF范围内,其中0x2000被视为//中性"中点:bend_value + = 0x2000; //然后,按照MIDI规范,bend_value是14位,//需要被编码为两个7位字节,//被编码为//第一个字节中的最低7位,和//字节中的最高7位。第二个字节:字节lowBits =(字节)(bend_value& 0x7F);字节highBits =(字节)((bend_value>> 7)& 0x7F); writeToFile(PITCH_BEND_EVENT,lowBits,highBits); }

(请注意,我们忽略了通道字节:我们将创建一个“简单”格式的0 MIDI文件,为了在将数据导入DAW中时获得最大的可用性,我们将所有事件都放在了通道2上)

这是一个很好的开始,但是MIDI事件仅仅是这样:事件和事件在“特定时间”发生,我们仍然需要捕获。 MIDI事件不依赖于基于某种实时时钟的绝对时间(这对我们来说是有好处的,因为Arduino没有内置RTC!)而是依靠计算“时间增量”:它标记事件自上一个事件以来的“ MIDI时钟滴答”次数,事件流中的第一个事件的显式时间增量为零。

所以:让我们编写一个getDelta()函数,我们可以使用它获取自上次事件(=自上次调用getDelta()以来)以来的MIDI滴答声的数量,以便我们拥有准备开始编写MIDI所需的所有数据提交:

无符号长startTime = 0;无符号长lastTime = 0; int getDelta(){if(startTime == 0){startTime = millis(); lastTime = startTime;返回0; } unsigned long now = millis(); unsigned int delta =(现在-lastTime); lastTime =现在;返回三角洲; }

该函数似乎比必须的要大:我们可以在草图开始时启动时钟,在setup()中设置lastTime = millis(),然后在getDelta中仅具有timeDelta计算和lastTime更新,但这将是明确的在MIDI文件的开头编码“没有很多”:相对于启动程序,我们将对第一个事件的滴答声进行计数,而不是将第一个事件视为从零滴答声开始。因此,我们将第一个事件发生的时间明确编码为startTime,然后开始相对于该时间的增量计算。

void handleNoteOn(字节通道,字节间距,字节速度){... writeToFile(...,getDelta()); } void handleNoteOff(字节通道,字节间距,字节速度){... writeToFile(...,getDelta()); } void handleControlChange(字节通道,字节controller_code,字节值){... writeToFile(...,getDelta()); } void handlePitchBend(byte channel,int bent_value){... writeToFile(...,getDelta()); }

这意味着我们可以继续将MIDI数据实际写入.mid文件!

SD库使使用SD卡非常容易,但是当然我们仍然必须编写所有代码来创建文件句柄并将二进制数据写入其中。因此,首先进行一些设置:

#define CHIP_SELECT 9字符串文件名;文件文件;无效设置(){pinMode(CHIP_SELECT,OUTPUT); if(SD。begin(CHIP_SELECT)){findNextFilename(); if(file){createMidiFile(); }}}} void findNextFilename(){for(int i = 1; i< 1000; i ++){filename =" file-" ;如果(i< 10)文件名+ =" 0" ;如果(i< 100)文件名+ =" 0" ;文件名+ =字符串(i); filename + =字符串(" .mid");如果(!SD。存在(文件名)){file = SD。打开(filename,FILE_WRITE);回报; }}}

我们的初始设置非常简单:我们告诉SD库,我们将使用引脚9与SD卡通信,然后尝试创建一个新文件来写入。我们可以通过多种方式来执行此操作,但是最简单的方法是“构建文件名,查看文件名是否存在;如果不存在,请使用该文件名”。在这种情况下,我们使用文件文件xxx.mid创建一个文件名,其中xxx的范围从001到999,我们只选择第一个可用的文件名。做到这一点的另一种方法是使用Arduino的EEPROM来存储一个值,以便每次Arduino启动时我们都能获得有保证的新值,这也意味着如果我们擦除SD卡并打开Arduino,我们将不是从001开始,而是一些随机数字,坦率地说这很愚蠢。

因此:虽然这也很愚蠢,但它却不那么愚蠢,我们正在努力。

接下来,当我们拥有一个可以使用的文件名时,我们以FILE_WRITE模式打开文件,这可能是反直觉的,这意味着我们将以APPEND模式打开文件:我们具有读/写访问权限,但文件指向“卡住了” ”放在文件末尾,我们写入的所有数据都会附加到已经存在的内容之后。对于本质上是事件流的MIDI文件,这正是我们所需要的,因此我们继续:我们需要在新文件中写入一些样板数据,然后我们才能开始处理记录的实际MIDI事件。在上一节中编写的MIDI处理程序中飞过。

void createMidiFile(){字节头[] = {0x4D,0x54,0x68,0x64,//" MThd"块0x00,0x00,0x00,0x06,//块长度(从此刻开始):6个字节0x00,0x00,//格式:0 0x00,0x01,//轨道数:1 0x01,0xC2 //数据速率:每四分音符/四分音符450音节};文件。写(header,14);字节轨道[] = {0x4D,0x54,0x72,0x6B,//" MTrk" chunk 0x00,0x00,0x00,0x00 //块长度占位符};文件。写(track,8); byte tempo [] = {0x00,//第一个MIDI事件的时间增量:零0xFF,0x51,0x03,// MIDI事件类型:" tempo"指令0x06,0xDD,0xD0 //速度值:每等分/四分音符为450,000μs};文件。写(tempo,7); }

我将向您介绍MIDI文件格式规范,而不是解释为什么我们需要此数据,但简短的版本是,如果我们要一个带有两个自定义值的单个事件流MIDI文件,则这就是所有样板字节码:

我们可以在标题中选择数据速率,然后每四分音符/四分音符需要450个滴答声,并且

我们还可以选择“弹奏速度”,即每四分之一/四分音符将其设置在半秒以内。

您可能还会注意到,我们已将轨道长度设置为零:通常,当您将.mid文件保存在计算机上时,此值将设置为轨道的字节长度,但是我们不知道该长度是多少尚未。实际上,我们永远不会弄清楚我们的代码:我们将编写一个小的Python脚本来仅在重要时帮助设置该值(例如,当您准备将数据导入到您拥有的任何音频应用程序中时)您想要将MIDI数据加载到的文件)。

这样一来,您就可以充分了解您阅读的全部原因:将传入MIDI信号写入我们文件的代码:让我们实现writeToFile:

void writeToFile(byte eventType,byte b1,byte b2,int delta){if(!file)返回;否则为false。 writeVarLen(delta);文件。写(eventType);文件。写(b1);文件。写(b2); }

就是……这不是很多代码。而且之所以没有太多代码,是因为MIDI在发送和读取/写入方面都非常小。唯一的麻烦部分是writeVarLen()函数,该函数将整数转换为相应的字节序列。值得庆幸的是,MIDI规范方便地提供了实现此目标所需的代码,因此我们只需在Arduino程序中采用它即可,我们很高兴:

#define HAS_MORE_BYTES 0x80 void writeVarLen(unsigned long value){unsigned long buffer =值& 0x7f; while(((value> == 7)> 0){buffer<< = 8;缓冲区| = HAS_MORE_BYTES;缓冲| =值& 0x7f; } while(true){文件。写((字节)(缓冲区& 0xff)); if(缓冲区& HAS_MORE_BYTES){缓冲区>> = 8; }其他{中断; }}}

它将分配4个字节,然后为每个字节以7位块的形式复制输入值,如果要再有一个字节,则该字节的最高位设置为0;如果这是最后一个字节,则设置为1。这会将输入值转换为一个缓冲区,该缓冲区具有按字节按MSBF排序的位,但按缓冲区按LSBF排序的字节。然后while(true)将这些字节反向写入文件,因此它们在文件中以MSBF顺序结束。没什么花哨的,只是花哨的足够快。

在处理完MIDI处理和文件写入后,一件很高兴的事就是能够确认您的MIDI事件处理正常,为此我们将使用“扬声器”和按钮。首先,我们设置代码,使我们可以决定是否发出蜂鸣声:

#定义AUDIO_DEBUG_PIN 2 int lastPlayState = 0;布尔玩=假;无效设置(){pinMode(AUDIO_DEBUG_PIN,INPUT); } void loop(){setPlayState(); } void setPlayState(){int playState = digitalRead(AUDIO_DEBUG_PIN);如果(playState!= lastPlayState){lastPlayState = playState;如果(playState == 1)播放=!玩; }}

唯一发生的事情是,当我们按下按钮时,我们希望程序知道它现在可以播放音频(或不可以播放音频),因此我们跟踪是否应该使用布尔值播放声音,然后在在程序循环中,我们检查是否有来自按钮的“高”信号。如果有,那么我们在按它,然后检查以前是否没有在按它。

我们这样做的原因,不仅仅是看到按钮发出的信号高时切换播放,还因为我们只想在按钮按下时切换,而不是在按住时切换。毕竟,如果我们这样做,则每次循环loop()时,播放状态都会在true和false之间翻转,只要按住该按钮,每秒的播放速度将为32,150次!

因此,在覆盖该部分的同时,让我们添加一些蜂鸣声,以便当我们在MIDI设备上按一个键时,可以在其原始的高质量压电蜂鸣音中听到相应的音符:

void handleNoteOn(字节CHANNEL,字节音调,字节速度){writeToFile(NOTE_ON_EVENT | CHANNEL,音调,速度,getDelta()); if(播放)音调(AUDIO,440 * pow(2,(音高-69.0)/ 12.0),100); }

同样,很少的代码,唯一令人惊讶的可能是tone()的第二个参数:MIDI音符虽然声称要发送音高值,但实际上是发送音高标识符,因此他们说的不是“音频”,而是“音调” “ 活跃。要将其转换为相应的音频,我们需要建立一些东西:

为了简单起见,因为我们只是为了一些调试(可能很有趣)而编写此代码,所以我们将使用标准的十二音调相等律性调音,其中每12个音符步长将可听频率加倍,从音符起等于对数步长请注意,A超过C的中间频率为440 Herz。当然,由于中间C的A下方有很多音符,因此我们需要为该键的MIDI音高值校正音高标识符,该值为69,因此得出以下公式:

因此,现在如果我们启动程序并按下按钮,则在MIDI设备上播放音符将使Arduino发出哔哔声以及正在播放的声音。当然,tone()函数一次只能播放一个音符,因此,如果我们弹奏和弦,声音听起来会很奇怪,但它会尽可能地发出蜂鸣声。

仍然缺少一项功能……请记住,此录音机的全部要点是记录MIDI输出设备所产生的MIDI事件,因为您正在使用它。但是我们不希望它做的是“记录一个小时的沉默,因为您停止了演奏,然后又去做其他事情了”!

为此,我们希望程序能够检测到您有一段时间(例如几分钟)没有播放任何东西,然后停止录制,并在再次开始播放时开始在新文件上录制。

碰巧的是,第一部分一直是正确的,因为我们只是在输入新数据时才写入文件,而且我们已经实现了第二部分:当您打开Arduino时,它会自动发生。我们唯一缺少的是一种检测一段时间是否没有任何输入的方法:

#define RECORDING_TIMEOUT 120000000 // 2分钟,以微秒为单位,无符号长lastLoopCounter = 0;无符号长loopCounter = 0;无效循环(){updateFile(); } void updateFile(){loopCounter = millis(); if(loopCounter-lastLoopCounter> 400){checkReset(); lastLoopCounter = loopCounter;文件。冲洗(); }} void checkReset(){if(!file)返回;如果(micros()-lastTime> RECORDING_TIMEOUT){文件。关 ();如果(startTime == 0)SD。删除(filename); resetArduino(); } void(* resetArduino)(void)= 0;

这可能比您想象的要多,所以让我们看看发生了什么。

首先,我们要检查程序循环过程中是否发生了任何MIDI活动,但我们不想每秒检查32,150次。因此,相反,我们设置了一些标准代码来每400毫秒检查一次,在那里我们检查lastTime(这是最后一个MIDI事件的微秒时间戳)与当前micros()值之间的差是否大于2分钟,以微秒为单位。如果是这样,那么我们只是空转,我们可以重新启动Arduino以启动新文件。但是,我们不想创建一百万个(或数百个,因为我们在该程序中仅允许999个文件)全部29个字节长的文件,因为它们包含样板MIDI代码,但没有别的,所以如果Arduino是在没有看到任何MIDI事件的情况下空转,我们首先删除当前打开的文件,以便在Arduino重新启动时,它将创建“相同的文件”,就像我们在当前文件上倒带一样,而不是重新启动。

另请注意,我们会在重置之前关闭文件:只要打开文件句柄,实际的磁盘状态是未知的,并且SD卡完全有可能 ......