尝试用神经网络生成音乐游戏的谱面

3,851 阅读8分钟

写在前面

一直喜欢玩儿音乐游戏,从最早玩儿的节奏大师,再到cytus2,malody,lanota等等。但音游的谱面很难做,需要对音,配键型等等,很麻烦,于是想到了用AI来生成。

因为数据量比较少,而且都不算标注的数据,所以这只算是一个尝试。

效果视频:www.bilibili.com/video/av885…

(顺便B站求波关注)

音乐游戏有很多种,这里以Malody的4K下落模式来尝试。

关于Malody

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。

mc_data["meta"]

歌曲基本信息

mc_data["time"]

这个主要是BPM虽时间的一个变化的信息,因为malody是有变速的,这里就不考虑变速了,只看第一个就行,可以看到这首歌的BPM是180。

mc_data["note"]

note也就是我们的按键了,有两种类型,一种就是一个note,还有long note,也就是长条。

可以看到这下面如果是有endbeat就是有长条。

再来看下这个beat,一共由三个数字组成,这里以[27, 2, 4]为例。27代表的是第28个节拍(从0开始算),这个节拍需要通过BPM来算,这首歌BMP是180,也就是一分钟180个节拍,也就是\frac{60}{180} = \frac{1}{3}秒一个节拍,那么这个27就是从\frac{27}{3}秒到\frac{28}{3}这段时间了。

再来看这个[27, 2, 4]中的2和4的含义:最后一个4其实代表的是一个节拍里面的小拍,这里一共是4小拍,这样把一个节拍又分成了四份,而2则代表第三个小拍(从0开始算)。

所以这个[27, 2, 4]代表的是\frac{27}{3}+\frac{2}{3*4} = \frac{55}{6} \approx 9.167秒这个时间点。

mc_data["note"][-1]

在note的最后一个元素中,很特殊,它会加载音乐,并设置offset。

offset表示使谱面节拍线对齐音乐节拍的最小前进量,单位为毫秒。这个其实就是一个对齐的数值。详情可以参考:www.bilibili.com/read/cv1869…

这里的offset是315ms,那么之前的[27, 2, 4]代表的其实就是9.167-0.315=8.852秒这个点。

mc_data["extra"]

这个貌似没有用,就不管了。

数据集构建

因为要用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种:

    1. 空,没有打击
    1. note,也就是打击点
    1. long note start,长条的开始
    1. long note continue,长条的连续

在特征中,可以用0,1,2,3表示这三种情况。

这里简化一下,我们把long note start和continue当成一个键,这样输出的结果就0,1,2三种情况。

键型编码

因为是4-Key的音游,所以每个位置有3种情况:空,打击,长条。

所以一共有3^4=81中情况,可以通过一个4位的3进制数来表示,换算成10进制就是0到80。

比如下面这个键形:

用三进制表示就是:1101=3^3+3^2 +3^0=37

再比如下面这个:

用三进制表示就是:1222=3^3+2*3^2+2*3^1 +2*3^0=53

数据格式

综上,需要经过很多复杂的数据解析。

然后每一个输入是一个40*64的矩阵。(40为序列长度,64为特征维度)

每一个输出则是40*1的列表。

模型设计

上面的数据明显算是一个seq2seq的问题,可以用encoder-decoder这个模型。找一个机器翻译的代码就OK。不过有一点区别就是,这里我们的encoder的输入不需要经过embedding,因为我们已经用mfcc提取到特征了。

Encoder

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传播过程

对于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)

Decoder

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解码过程

对于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

模型2.0

在经过我的测试之后,上面的效果并不好,生成出来的键非常不稳定。我想到的原因是数据实在是太少了,只有24首歌,而且数据噪声会比较大。

这里就不细说了,代码和上面是一样的,不过是多copy了几份。下面大概说一下我做了哪几件事情。

    1. 为了降低复杂度,我把长条和打击点分开,这样从之前的3^4=81类就变成了2^4=16类,大大减少了复杂性。
    1. 从一个模型变成两个模型。之前是直接判断键型,但很多时候0会比较多,所以效果不好。现在我加了一个分类器,先判断是否有长条或者打击点,再判断键型。这样如果有打击点或者长条,键型模型就会一定生成一个键型,而不会直接是空的了。
    1. 简化操作,先生成打击点,再生成长条。如果重合,长条会覆盖掉打击点。不过概率肯定不大,不影响大效果。

最后的效果看我B站视频就好了,一共生成了三首歌:China-P惊蛰春分。感觉总体效果还是可以的。

本文的代码:github.com/nladuo/AI_b…

(这个代码写的非常的乱,我感觉一般人也不会再去研究这个把,所以也就没有整理优化了。基本代码就是一个seq2seq,然后有一堆的数据构建解析的代码。)