一、前言
1.1 项目由来
前些天,在b站上看到有人分享单片机模拟NS手柄,在《精灵宝可梦》、《异度之刃》等游戏中实现自动操作的视频。我是个有着多年“鼓龄”的太鼓达人玩家,于是产生想法,将该方案用于自动完成太鼓达人曲目,实现类似TAS的效果。经过一周的实验,取得了一定的成果:开发板通过USB TypeB转TypeC转接线连接至Switch游戏机的USB接口上,系统能够在操作者手动给出曲目开始信号(按下按键模块上的特定按键)的情况下,自动完成鬼难度6星的一首曲目,获得了“全良”的成绩,验证了方案的可行性。展示视频如下:
[自制] 单片机模拟Switch控制器_半自动完成太鼓达人曲目《恋》_20200224
下面将简单介绍我的完成过程
1.2 前期调研工作
项目参考了以下链接中的内容:
“当单片机取代橡皮筋——解放双手,放飞双眼,我的宝可梦自动化成果”
“在 Switch上使用 Arduino Uno R3 开发板模拟连续 A 键”
此类项目基本是基于:https://github.com/progmem/Switch-Fightstick 二次开发而成。其中,单片机模拟USB功能的实现,主要依靠LUFA开源框架的使用,它可以让AVR单片机模拟成想要的USB设备。移植工作并不复杂,初始化函数无需自己修改;只需要自行编写业务代码即可。
1.3 测试曲目选择
测试曲目的选择方面:
- 不能过于简单,否则达不到验证的目的。曲目在“鬼”难度中选择;
- 为方便验证,简化编码工作,曲目速度不能过快或存在过多变化。最好是一个速度从头到尾;
- 所选曲目不可过长,否则一方面会带来很多编码工作,另一方面也会受到单片机存储空间的限制。
综合考虑,选择了鬼难度的《恋》这首曲目。这首曲目是日剧《逃避可耻但有用》的片尾曲,官方难度为鬼6星,速度为BPM158,全曲不变。曲目基本涵盖了常用的节奏型,复杂度适中,适合验证使用。
二、项目实现
2.1 硬件平台
硬件平台为ARDUINO UNO R3开发板。特别要指出的是,此次开发中使用的是开发板上用作USB接口芯片的ATmega16U2(下图中红框圈出),并不是常规情况下用来开发的核心芯片ATMEGA328P。为避免干扰,减少功耗,可以将其从开发板上去除(下图中绿框圈出)。
完成搭建的测试系统如图所示:
2.2 工具链及开发环境
单片机程序的编译环境为WinAVR-20100110,在windows和Linux下均可使用。为开发方便,本项目选择在Windows系统下进行开发。
本工程没有现成的集成开发环境(如Keil、Visual Studio等)可以使用,必须手写makefile,通过终端执行make指令进行编译。
于是,本项目的软件开发工作选择宇宙第一的文本编辑器——微软Visual Studio Code进行。不但代码编辑、makefile编辑非常方便,还自带终端,可以随时进行生成(make)、清理(clean)等操作,如下图。
项目进行make(生成)操作后,会生成用于执行的hex文件。下载hex文件使用Flip软件,通过USB线连接开发板和电脑,之后手动短接单片机的RESET引脚和GND(地)(如下图),待系统将设备枚举成功后,再进行下载,如下图。
2.3 软件实现
2.3.1 软件流程设计
拟定软件工作流程如下:
- 初始化(IO初始化、USB初始化等)。完成初始化后,循环执行以下 2 3 两项工作:
- 单片机持续检测按键模块上的SW1按键(连接至单片机PB2引脚),如果按下,则向Switch输出“A键按下”指令。即,用按键模块的SW1按键,模拟了Switch的A键。安排此功能,主要用于在菜单中进行曲目和难度的确认;以及演奏完毕的成绩确认。
- 单片机持续检测按键模块上的SW4按键(连接至单片机PB1引脚),如果按下,则立即调用“演奏”函数,按照程序的设计,向Switch有序发送按键信号,进行曲目的演奏。
接下来介绍软件实现过程中的几个关键点
2.3.2 音符时长标定
·音符和休止符
曲目的完成,实际上就是按照谱面演奏音符和休止符。具体到该项目,进行设计如下:
- 音符的演奏为,输出相应的按键信号(鼓心“咚”音色输出B键、鼓边“咔”音色输出A键),之后,等待(延时)一定的时间,使得时值完整;
- 休止符的演奏为,按照正确的时值,等待(延时)一定的时间。 为实现上述功能,我们首先要得到“一次输出动作”的最短时间。
·单次输出动作实验结果
我们在只使用B键用于输出“咚”音色,只使用A键用于输出“咔”音色的情况下,要完成一次完整、无误判的按键信号输出动作,需要进行以下步骤(以输出一次B键为例):
HID_Task(B);
USB_USBTask();
_delay_ms(10);
_delay_ms(10);
HID_Task(PAUSE);
USB_USBTask();
_delay_ms(10);
_delay_ms(10);
首先调用一次HID_Task(B)及USB_USBTask()函数进行输出,之后延时20毫秒;之后调用HID_Task(PAUSE)及 USB_USBTask()输出一个“PAUSE”信号,再延时20毫秒。总共需要40毫秒左右。
·使用逻辑分析仪进行音符时长标定
在明确了上述信息后,我们就可以对曲目中使用的音符、休止符进行标定了。曲目速度为BPM158,4/4拍。经过计算,得到如下结果:
- 四分音符、休止符时长为379.7毫秒(一拍)
- 八分音符、休止符时长为189.87毫秒
- 十六分音符、休止符时长为93.9毫秒
- 三十二分音符、休止符时长为47.46毫秒。曲目中的滚奏(“黄条”)暂定使用三十二分音符演奏
为减少编码量,节约存储空间,本项目中,使用毫秒作为最小计时单位。以演奏四分音符的“咚”音色为例,我们应当进行的操作如下:
- 首先输出一次B键,约40毫秒;
- 再延时379.7-40 ≈ 340毫秒。
演奏四分休止符时,应进行的操作如下:
- 延时379.7-40 ≈ 340毫秒。
由于毫秒级延时精度有限,我们需要对实际的输出时间进行测量,以得到不同音符的误差,便于进行补偿。由于家中条件所限,没有示波器,我们使用逻辑分析仪进行标定工作,用以确定“演奏音符”操作中,完成按键输出后,需要延时的毫秒数;以及“演奏空拍”操作中,需要延时的毫秒数。完成连接的硬件如下图所示:
经过测试,我们可以得到各音符、休止符演奏时的实际时间,如下表所示(单位 毫秒)。我们根据测量结果,首先对部分延时的毫秒数进行修正,并记录下修正完毕依然残留的误差。
2.3.3 曲谱数据建立及演奏函数设计
进行如下设计:用16位整数(unsigned short int)组成的数组来描述曲目。
数字“1”表示演奏“咚”音色,数字2表示演奏“咔”音色,数字“5”表示曲目结束。其余数字则表示延时相应的毫秒数,如378表示将会调用_delay_ms()函数延时378毫秒。
为方便编码,设计宏定义如下:
//休止符
#define R4 (378)
#define R8 (189)
#define R16 (95)
#define R32 (47)
//演奏音符 1为演奏don 2为演奏ka
#define PLAY_DON 1
#define PLAY_KA 2
#define HITDUR (40)
#define D4 PLAY_DON,(R4-HITDUR+1) //修正
#define D8 PLAY_DON,(R8-HITDUR)
#define D16 PLAY_DON,(R16-HITDUR)
#define D32 PLAY_DON,(R32-HITDUR)
#define K4 PLAY_KA,(R4-HITDUR)
#define K8 PLAY_KA,(R8-HITDUR)
#define K16 PLAY_KA,(R16-HITDUR)
曲目方面,图片格式的曲谱可以在太鼓达人wiki上获得,如图所示:
以前四小节为例,图中所示为:
const unsigned short music[] PROGMEM =
{
//M1
D4, K4, D4, K4,
//M2
D4, K4, D8, K16,K16,K8, D8,
//M3
D4, D4, D8, K8, K8, D8,
//M4
R8, D4, D4, K8, K16,K16,K16,K16,
代码中为方便查看,以小节为单位分行编写。依次类推,完成整首曲目的编码。要注意的是,由于单片机的内存空间极其有限(仅512Byte),所以该数组不能像普通变量一样放在RAM中,而必须存放在FLASH中。数组定义时需要加入PROGMEM宏进行标志。
演奏函数设计为:在遇到结束标志之前,从数组中依次取出数字,判断数字并做出相应的动作。代码如下:
void play(void)
{
unsigned short i = 0;
while(1)
{
if( pgm_read_word(&music[i]) == PLAY_DON)
{
PLAY_DON_B(); //通过B键演奏“咚”音色
}
else if(pgm_read_word(&music[i])== PLAY_KA )
{
PLAY_KA_A(); //通过A键演奏“咔”音色
}
else if (pgm_read_word(&music[i]) == MUS_END)
{
break;
}
else
{
_delay_ms(pgm_read_word(&music[i]));
}
i++;
}//while(1)
}//play()
为了访问FLASH中的数组数据,必须使用pgm_read_word()函数。相应的音色演奏函数如下(以PLAY_DON_B()函数为例):
void PLAY_DON_B(void)
{
RXLED_ON;
HID_Task(B);
USB_USBTask();
_delay_ms(10);
_delay_ms(10);
HID_Task(PAUSE);
USB_USBTask();
_delay_ms(10);
_delay_ms(10);
RXLED_OFF;
}
在演奏时,通过宏定义RXLED_ON、RXLED_OFF,操作IO口,使用了LED进行指示。演奏“咚”时,使用的是RXLED;相应的,在PLAY_KA_A()函数中,使用的是TXLED。
2.3.4 时间误差补偿
由于选择的最小时间单位为仅为毫秒,误差会随着演奏过程不断积累,导致后半段演奏出现问题。所以,应当统计各段落的误差,进行补偿。
曲目中各音符单独导致的误差是已知的,我们将各个片段的音符进行统计,计算总误差,再拟定各段落中补偿修正的毫秒数,将累计残余误差控制在1毫秒以内。如下表所示(单位 毫秒):
const unsigned short music[] PROGMEM =
{
//M1
D4, K4, D4, K4,
//M2
D4, K4, D8, K16,K16,K8, D8,
//M3
D4, D4, D8, K8, K8, D8,
//M4
R8, D4, D4, K8, K16,K16,K16,K16,
//M5
D4, R4-2, R8, D4, R8,
//M6
D4, R8, D4, R8, K4,
//M7
D4, R4-1, R8, D4, R8,
//M8
D4, R8, D4, R8, K4,
以此类推,完成整首曲目的误差修正。
2.3 实验验证
完成开发后进行验证实验。先在游戏的“曲目选择”中,将光标放到待选曲目上,连接开发板和Switch游戏机,待USB设备枚举完成后,按动按键模块上的SW1,代替Switch的A键,进行曲目和难度确定。进入曲目后,在合适的时机,手动按动SW4按键,启动输出流程。经过测试,只要首个音符能够抓出“良”,那么整首曲目基本可以保证全良。测试成绩如图所示:
三、后记
后续可以改进的点(咕咕咕):
- 描述曲目用的数据结构可以优化。为减少误差,可改成使用两个unsigned short int变量表示一次延时,分别用来表示毫秒级延时和微秒级延时,同时相对应的修改演奏函数。启用微秒级的延时,可大大减少误差,减小误差补偿方面的工作量。
- 理论上可实现为全自动系统。将Switch的视频信号通过采集卡采集至上位机,上位机编写实时图像处理软件,用以检测第一个音符,在合适的时机通过串口向开发板发送开始信息,以代替人手操作。已有大神在《精灵宝可梦》中实现该技术方案。
项目完成后,我颇有些感慨。太鼓达人这款游戏,给予了我太多,改变了我太多。还记得上大学时,经常在街机厅忘我地练习,一有时间就和同好们愉快地交流、竞技。战胜过自己,也取得过成绩。甚至因为太鼓,开始与音乐结缘。它让我知道了自己的可能性,也让我了解了自己的天花板。曾几何时,也曾希望自己能够像传说中的大神们那样,鲜衣怒马,全良数百首曲目,功德圆满。怎奈天资不足,又没有条件保持一定强度的训练,只好安心当一名娱乐玩家。现在,通过自己完成的单片机系统,代替自己来实现全良的梦想,也算是对自己的一种交代。
欢迎交流
联系方式
gurn@tju.edu.cn
617442575@qq.com