一直喜欢玩儿音乐游戏,从最早玩儿的节奏大师,再到cytus2,malody,lanota等等。但音游的谱面很难做,需要对音,配键型等等,很麻烦,于是想到了用AI来生成。
因为数据量比较少,而且都不算标注的数据,所以这只算是一个尝试。
效果视频:www.bilibili.com/video/av885…
(顺便B站求波关注)
音乐游戏有很多种,这里以Malody的4K下落模式来尝试。
Malody是一款音游模拟器,有很多的模式,玩过节奏大师的话,就会知道节奏大师有4K,5K,6K三个模式,这里我将只尝试4K模式,或者叫4K Mania模式。
Malody相比节奏大师来说,长条是不会换轨道的,也就是没有滑条。但是Malody会出现三押、四押的情况,而节奏大师一般只要两个手指就可以玩儿,而Malody的4k模式必须要用四个手指才行。
malody中有很多的键形,以下举例:
还有一些别的键型,这里就不举例了。
malody的谱面是以mcz后缀结尾的,其实它实际上是一个zip压缩包。
选取一个谱面通过python的zipfile解压mcz文件后,可以看到有三个文件。
jpg
是谱面的背景图片,mc
格式的是谱面,而ogg
则是音乐。
这里主要看一下mc这个文件。
这个文件其实就是json格式的,把它转换成python字典,可以看到这个字典一共有4个key。
歌曲基本信息
这个主要是BPM虽时间的一个变化的信息,因为malody是有变速的,这里就不考虑变速了,只看第一个就行,可以看到这首歌的BPM是180。
note也就是我们的按键了,有两种类型,一种就是一个note,还有long note,也就是长条。
可以看到这下面如果是有endbeat就是有长条。再来看下这个beat,一共由三个数字组成,这里以[27, 2, 4]为例。27代表的是第28个节拍(从0开始算),这个节拍需要通过BPM来算,这首歌BMP是180,也就是一分钟180个节拍,也就是秒一个节拍,那么这个27就是从秒到这段时间了。
再来看这个[27, 2, 4]中的2和4的含义:最后一个4其实代表的是一个节拍里面的小拍,这里一共是4小拍,这样把一个节拍又分成了四份,而2则代表第三个小拍(从0开始算)。
所以这个[27, 2, 4]代表的是秒这个时间点。
在note的最后一个元素中,很特殊,它会加载音乐,并设置offset。
offset表示使谱面节拍线对齐音乐节拍的最小前进量,单位为毫秒。这个其实就是一个对齐的数值。详情可以参考:www.bilibili.com/read/cv1869…
这里的offset是315ms,那么之前的[27, 2, 4]代表的其实就是秒这个点。
这个貌似没有用,就不管了。
因为要用AI做谱,这里首先要搞出一部分数据,这里选了20几个malody中Lv20-Lv25的谱面(都是些非常简单的歌曲)作为训练数据。
这里我们的输入的是连续的音频,输出的是四个轨道的note,所以其实是一个序列输入到序列输出的情况。
关于音乐方面的特征提取的基本知识可以看我之前的文章:使用Python对音频进行特征提取
这里序列拆分将会根据时间来,每个节拍可以分为四个时间点。也就是每个节拍内最多有4个打击点。
以BMP是180为例,那么一个节拍就是60/180=0.333秒。每个打击点之间的时间间隔就是0.333/4=0.08333秒。
然后打击点的特征也就是这0.08333秒的特征,这里通过mfcc来提取,并把0.08333秒分成两个部分,分别抽取mfcc特征,然后再拼在一起,当成音频的特征。
# x为音乐的时域信息,也就是一个列表 # sr为音频的采样频率 # position为第几个打击点 # offset为谱面的偏移 def get_audio_features(x, sr, bpm, position, offset): one_beat = 60 / bpm beat = position * one_beat / 4 - offset/1000 start = beat end = start + one_beat / 8 end2 = start + one_beat / 4 if start < 0: start = 0 start_index = int(sr * start) end_index = int(sr * end) end_index2 = int(sr * end2) features = [] mfcc1 = librosa.feature.mfcc(y=x[start_index:end_index], sr=sr, n_mfcc=32) mfcc2 = librosa.feature.mfcc(y=x[end_index:end_index2], sr=sr, n_mfcc=32) features += [float(np.mean(e)) for e in mfcc1] features += [float(np.mean(e)) for e in mfcc2] return features 复制代码
这里因为一个谱子会比较长,会有上千个打击点的判断,所以要把判断点切分开,每一轮50个。
输出可以分为4种:
在特征中,可以用0,1,2,3表示这三种情况。
这里简化一下,我们把long note start和continue当成一个键,这样输出的结果就0,1,2三种情况。
因为是4-Key的音游,所以每个位置有3种情况:空,打击,长条。
所以一共有中情况,可以通过一个4位的3进制数来表示,换算成10进制就是0到80。
比如下面这个键形:
用三进制表示就是:再比如下面这个:
用三进制表示就是:综上,需要经过很多复杂的数据解析。
然后每一个输入是一个40*64的矩阵。(40为序列长度,64为特征维度)
每一个输出则是40*1的列表。
上面的数据明显算是一个seq2seq的问题,可以用encoder-decoder这个模型。找一个机器翻译的代码就OK。不过有一点区别就是,这里我们的encoder的输入不需要经过embedding,因为我们已经用mfcc提取到特征了。
class EncoderRNN(nn.Module): def __init__(self, hidden_size): super(EncoderRNN, self).__init__() self.hidden_size = hidden_size self.gru = nn.GRU(Feature_DIM, hidden_size) def forward(self, input_, hidden): input_ = input_.view(1, 1, -1) output, hidden = self.gru(input_, hidden) return output, hidden def initHidden(self): return torch.zeros(1, 1, self.hidden_size, device=device) hidden_size = 128 encoder = EncoderRNN(hidden_size).to(device) 复制代码
这里的encoder就是一层简单的GRU,因为输入是特征,所以相比机器翻译的seq2seq的encoder不需要加embedding。
对于encoder,这里需要跑一遍循环,然后拿到最后一个元素的hidden参数。
x1 = torch.from_numpy(np.array(X1[index])).to(device).float() # 输入特征 y1 = torch.from_numpy(np.array(Y1[index])).to(device).long() # label encoder_hidden = encoder.initHidden() for ei in range(max_length): _, encoder_hidden = encoder( x1[ei], encoder_hidden) 复制代码
class DecoderRNN(nn.Module): def __init__(self, embedding, hidden_size, output_size): super(DecoderRNN, self).__init__() hidden_size = hidden_size # self.embedding = nn.Embedding(output_size, hidden_size) self.embedding = embedding self.gru = nn.GRU(50, hidden_size) self.out = nn.Linear(hidden_size, output_size) self.softmax = nn.LogSoftmax(dim=1) self.dropout = nn.Dropout(0.2) def forward(self, input_, hidden): output = self.embedding(input_).view(1, 1, -1) output = self.dropout(output) # output = F.relu(output) output, hidden = self.gru(output, hidden) output = self.softmax(self.out(output[0])) return output, hidden hidden_size = 128 decoder = DecoderRNN(embedding, hidden_size, 81).to(device) 复制代码
这里decoder也同样是一个rnn,对每一个输出,都会有一个结果输出,也就是键型的类别了。
这里注意到在decoder里有一个embedding,这其实和NLP的embedding是非常类似的意思。词向量其实表达的就是一个词和什么词最接近。
这里的键型Embedding同样,什么样的键型更容易出现在一起。再加上RNN,学习的其实就是在已知前面的键型,和当前的音符特征的时候,下一个键型最可能是什么。
也就NLP里的的语言模型了。
对于decoder的解码过程其实也是跑一遍循环,不过输入的第一个hidden不是0,而是encoder的hidden。
decoder_input = torch.tensor([[0]], device=device) decoder_hidden = encoder_hidden for di in range(max_length): decoder_output, decoder_hidden = decoder( decoder_input, decoder_hidden) target = y1[di].view(-1) # print(decoder_output) # print(target) loss += F.nll_loss(decoder_output, target) decoder_input = target # Teacher forcing 复制代码
在经过我的测试之后,上面的效果并不好,生成出来的键非常不稳定。我想到的原因是数据实在是太少了,只有24首歌,而且数据噪声会比较大。
这里就不细说了,代码和上面是一样的,不过是多copy了几份。下面大概说一下我做了哪几件事情。
最后的效果看我B站视频就好了,一共生成了三首歌:China-P,惊蛰,春分。感觉总体效果还是可以的。
本文的代码:github.com/nladuo/AI_b…
(这个代码写的非常的乱,我感觉一般人也不会再去研究这个把,所以也就没有整理优化了。基本代码就是一个seq2seq,然后有一堆的数据构建解析的代码。)