找回密码
 立即注册
搜索

应用 AssemblyAI 在 PyTorch 中建立端到端的语音辨认模型




作者 | Comet

译者 | 天道酬勤,责编 | Carol

出品 | AI 科技大本营(ID:rgznai100)

这篇文章是由AssemblyAI的机器学习研讨工程师Michael Nguyen撰写的。AssemblyAI运用Comet记录、可视化和了解模型开发流程。

深度学习经过引入端到端的模型改变了语音辨认的规则。这些模型接收音频,并直接输入转录。目前最盛行的两种端到端模型是百度的Deep Speech和谷歌的Listen Attend Spell(LAS)。Deep Speech和LAS都是基于递归神经网络(RNN)的体系结构,对语音辨认停止建模有着不同方法。

Deep Speech运用衔接时态分类(CTC)损失函数来预测语音记录。LAS运用序列对网络架构停止预测。

这些模型经过应用深度学习系统从大型数据集中学习的才能,简化了语音辨认通道。从实际上讲,有了足够的数据,你就可以构建一个超级弱小的语音辨认模型,该模型可以处理语音中的一切纤细差别,并且不需求花费大量工夫和精神手工设计声学特性或处理复杂的通道(例如,老式的GMM-HMM模型架构)。

深度学习是一个疾速发展的范畴,而Deep Speech和LAS风格的体系结构曾经过时。你可以在下面的“最新停顿”部分中了解该行业的发展方向。



如何在PyTorch中构建本人的端到端语音辨认模型

让我们逐一引见如何在PyTorch中构建本人的端到端语音辨认模型。我们构建的模型遭到了Deep Speech 2(百度对其著名模型的第二次修订)的启示,并对结构停止了一些个人改进。

模型的输入是字符的概率矩阵,我们运用该概率矩阵来解码音频中最有能够出现的字符。你可以找到残缺的代码,还可以在Google Colaboratory上的GPU支持下运转它。

预备数据管道

数据是语音辨认中最重要的方面之一。我们获取原始音频波,并将其转换为Mel频谱图。



你可以在这篇优秀的文章中阅读更多关于这种转变的细节。对于本文,你可以将Mel频谱图视为声响的图片。



为了处理音频数据,我们将运用一个非常有用的工具,被称为torchaudio,它是PyTorch团队专门为音频数据创建的一个库。我们将在LibriSpeech的一个子集上停止训练,该子集是从有声读物中获得的阅读英语语音数据的语料库,包括100个小时的转录音频数据。你可以运用torchaudio轻松下载此数据集:
import torchaudio train_dataset = torchaudio.datasets.LIBRISPEECH("./", url="train-clean-100", download=True)
test_dataset = torchaudio.datasets.LIBRISPEECH("./", url="test-clean", download=True)
数据集的每个样本都包含波形、音频采样率、话语/标签,以及样本上更多的元数据。你可以在此处从源代码中查看每个示例。

数据扩大– SpecAugment

数据扩大是一种用于人为添加数据集多样性来添加数据集大小的技术。当数据稀少或模型过度拟合时,此策略特别有用。对于语音辨认,你可以执行标准的加强技术,比如更改音高,速度,注入噪声以及向音频数据添加混响。

我们发现频谱图加强(SpecAugment)是一种更简单、更有效的方法。SpecAugment,最早是在论文SpecAugment:一种用于自动语音辨认的简单数据加强方法中引见的,在文中作者发现,简单地裁剪延续的工夫和频率维度的随机块可以分明提高模型的泛化才能。



在PyTorch中,你可以运用torchaudio函数FrequencyMasking来掩盖频率维度,并运用TimeMasking来度量工夫维度。
torchaudio.transforms.FrequencyMasking

torchaudio.transforms.TimeMasking

有了数据后,我们需求将音频转换为Mel频谱图,并将每个音频样本的字符标签映射为整数标签:
class TextTransform:
"""Maps characters to integers and vice versa"""
def __init__(self):
char_map_str = """
' 0
<SPACE> 1
a 2
b 3
c 4
d 5
e 6
f 7
g 8
h 9
i 10
j 11
k 12
l 13
m 14
n 15
o 16
p 17
q 18
r 19
s 20
t 21
u 22
v 23
w 24
x 25
y 26
z 27
"""
self.char_map = {}
self.index_map = {}
for line in char_map_str.strip.split('\n'):
ch, index = line.split
self.char_map[ch] = int(index)
self.index_map[int(index)] = ch
self.index_map[1] = ' '
def text_to_int(self, text):
""" Use a character map and convert text to an integer sequence """
int_sequence =
for c in text:
if c == ' ':
ch = self.char_map['']
else:
ch = self.char_map[c]
int_sequence.append(ch)
return int_sequence
def int_to_text(self, labels):
""" Use a character map and convert integer labels to an text sequence """
string =
for i in labels:
string.append(self.index_map)
return ''.join(string).replace('', ' ')
train_audio_transforms = nn.Sequential(
torchaudio.transforms.MelSpectrogram(sample_rate=16000, n_mels=128),
torchaudio.transforms.FrequencyMasking(freq_mask_param=15),
torchaudio.transforms.TimeMasking(time_mask_param=35)
)
valid_audio_transforms = torchaudio.transforms.MelSpectrogram
text_transform = TextTransform
def data_processing(data, data_type="train"):
spectrograms =
labels =
input_lengths =
label_lengths =
for (waveform, _, utterance, _, _, _) in data:
if data_type == 'train':
spec = train_audio_transforms(waveform).squeeze(0).transpose(0, 1)
else:
spec = valid_audio_transforms(waveform).squeeze(0).transpose(0, 1)
spectrograms.append(spec)
label = torch.Tensor(text_transform.text_to_int(utterance.lower))
labels.append(label)
input_lengths.append(spec.shape[0]//2)
label_lengths.append(len(label))
spectrograms = nn.utils.rnn.pad_sequence(spectrograms, batch_first=True).unsqueeze(1).transpose(2, 3)
labels = nn.utils.rnn.pad_sequence(labels, batch_first=True)
return spectrograms, labels, input_lengths, label_lengths



定义模型-DeepSpeech 2

我们的模型将相似于Deep Speech 2结构。该模型将具有两个次要的神经网络模块——学习相关的音频特征的N层残差卷积神经网络(ResCNN),以及应用学习后的ResCNN音频特征的一组双向递归神经网络(BiRNN)。该模型的顶部是一个全连通层,用于按工夫步长对字符停止分类。



卷积神经网络(CNN)擅长提取笼统特征,我们会将相反的特征提取才能运用于音频频谱图。我们选择运用残差的CNN层,而不只是普通的CNN层。残差衔接(又称为跳过衔接)是在“用于图像辨认的深度残差学习”一文中初次引入。作者发现,假如将这些衔接添加到CNN中,可以建立真正的深度网络,并获得较高的准确性。

添加这些残差衔接有助于模型更快地学习和更好地推行。这篇可视化神经网络的损失图景的论文表明,具有残留衔接的网络具有一个“平整的”损失面,使模型更容易描画损失状况,并找到一个更低且更通用的最小值。



递归神经网络(RNN)擅长处理序列建模成绩。RNN会逐渐处理音频特征,在运用前一帧的上下文的同时对每一帧停止预测。我们运用BiRNN是由于我们不只需求每个步骤之前框架的上下文,还希望得到它之后框架的上下文。

这可以协助模型做出更好的预测,由于音频中的每一帧在停止预测之前都会有更多信息。我们运用RNN的门控递归单元(GRU)变种,由于它比LSTM需求的的计算资源更少,并且在某些状况下工作效果也一样。

该模型为输入字符的概率矩阵,我们将运用该矩阵将其输入到解码器中,提取模型以为是概率最高的字符。
class CNNLayerNorm(nn.Module):
"""Layer normalization built for cnns input"""
def __init__(self, n_feats):
super(CNNLayerNorm, self).__init__
self.layer_norm = nn.LayerNorm(n_feats)
def forward(self, x):
# x (batch, channel, feature, time)
x = x.transpose(2, 3).contiguous # (batch, channel, time, feature)
x = self.layer_norm(x)
return x.transpose(2, 3).contiguous # (batch, channel, feature, time)
class ResidualCNN(nn.Module):
"""Residual CNN inspired by https://arxiv.org/pdf/1603.05027.pdf
except with layer norm instead of batch norm
"""
def __init__(self, in_channels, out_channels, kernel, stride, dropout, n_feats):
super(ResidualCNN, self).__init__
self.cnn1 = nn.Conv2d(in_channels, out_channels, kernel, stride, padding=kernel//2)
self.cnn2 = nn.Conv2d(out_channels, out_channels, kernel, stride, padding=kernel//2)
self.dropout1 = nn.Dropout(dropout)
self.dropout2 = nn.Dropout(dropout)
self.layer_norm1 = CNNLayerNorm(n_feats)
self.layer_norm2 = CNNLayerNorm(n_feats)
def forward(self, x):
residual = x # (batch, channel, feature, time)
x = self.layer_norm1(x)
x = F.gelu(x)
x = self.dropout1(x)
x = self.cnn1(x)
x = self.layer_norm2(x)
x = F.gelu(x)
x = self.dropout2(x)
x = self.cnn2(x)
x += residual
return x # (batch, channel, feature, time)
class BidirectionalGRU(nn.Module):
def __init__(self, rnn_dim, hidden_size, dropout, batch_first):
super(BidirectionalGRU, self).__init__
self.BiGRU = nn.GRU(
input_size=rnn_dim, hidden_size=hidden_size,
num_layers=1, batch_first=batch_first, bidirectional=True)
self.layer_norm = nn.LayerNorm(rnn_dim)
self.dropout = nn.Dropout(dropout)
def forward(self, x):
x = self.layer_norm(x)
x = F.gelu(x)
x, _ = self.BiGRU(x)
x = self.dropout(x)
return x
class SpeechRecognitionModel(nn.Module):
"""Speech Recognition Model Inspired by DeepSpeech 2"""
def __init__(self, n_cnn_layers, n_rnn_layers, rnn_dim, n_class, n_feats, stride=2, dropout=0.1):
super(SpeechRecognitionModel, self).__init__
n_feats = n_feats//2
self.cnn = nn.Conv2d(1, 32, 3, stride=stride, padding=3//2) # cnn for extracting heirachal features
# n residual cnn layers with filter size of 32
self.rescnn_layers = nn.Sequential(*[
ResidualCNN(32, 32, kernel=3, stride=1, dropout=dropout, n_feats=n_feats)
for _ in range(n_cnn_layers)
])
self.fully_connected = nn.Linear(n_feats*32, rnn_dim)
self.birnn_layers = nn.Sequential(*[
BidirectionalGRU(rnn_dim=rnn_dim if i==0 else rnn_dim*2,
hidden_size=rnn_dim, dropout=dropout, batch_first=i==0)
for i in range(n_rnn_layers)
])
self.classifier = nn.Sequential(
nn.Linear(rnn_dim*2, rnn_dim), # birnn returns rnn_dim*2
nn.GELU,
nn.Dropout(dropout),
nn.Linear(rnn_dim, n_class)
)
def forward(self, x):
x = self.cnn(x)
x = self.rescnn_layers(x)
sizes = x.size
x = x.view(sizes[0], sizes[1] * sizes[2], sizes[3]) # (batch, feature, time)
x = x.transpose(1, 2) # (batch, time, feature)
x = self.fully_connected(x)
x = self.birnn_layers(x)
x = self.classifier(x)
return x



选择合适的优化器和调度器–具有超交融的AdamW

优化器和学习率调度器在使模型收敛到最佳点方面起着非常重要的作用。选择合适的的优化器和调度器还可以节省计算工夫,并有助于你的模型更好运用到实践案例中。

对于我们的模型,我们将运用AdamW和一个周期学习率调度器。Adam是一种广泛运用的优化器,可协助你的模型更快地收敛,节省计算工夫,但由于没有推行性,和随机梯度下降(SGD)一样臭名昭著。

AdamW最后是在“去耦权重衰减正则化”中引入的,被以为是对Adam的“修复”。该论文指出,原始的Adam算法权重衰减的完成上存在错误,AdamW试图处理该成绩。这个修复程序有助于处理Adam的推行成绩。

单周期学习率调度算法最早是在《超收敛:大学习率下神经网络的疾速训练》一文中引入的。本文表明,你可以运用一个简单的技巧,在保持其可推行才能的同时,将神经网络的训练速度提高一个数量级。

末尾时学习率很低,逐渐上升到一个很大的最大学习率,然后线性衰减到最后末尾时的地位。



最大学习率比最低学习率要高很多,你可以获得一些正则化好处,假如数据量较小,可以协助你的模型更好地推行。

运用PyTorch,这两种方法曾经成为软件包的一部分。
optimizer = optim.AdamW(model.parameters, hparams['learning_rate'])
scheduler = optim.lr_scheduler.OneCycleLR(optimizer,
max_lr=hparams['learning_rate'],
steps_per_epoch=int(len(train_loader)),
epochs=hparams['epochs'],
anneal_strategy='linear')



CTC损失功能–将音频与文本对齐

我们的模型将接受训练,预测输入到模型中的声谱图中每一帧(即工夫步长)字母表中一切字符的概率分布。



传统的语音辨认模型将要求你在训练之前将文本与音频对齐,并且将训练模型来预测特定帧处的特定标签。

CTC损失功能的创新之处在于它允许我们可以跳过这一步。我们的模型将在训练过程中学习对齐文本本身。关键在于CTC引入的“空白”标签,该标签使模型可以表明某个音频帧没有产生字符。你可以在这篇出色的文章中看到有关CTC及其工作原理的更详细阐明。

PyTorch还内置了CTC损失功能。
criterion = nn.CTCLoss(blank=28).to(device)



语音模型评价

在评价语音辨认模型时,行业标准运用的是单词错误率(WER)作为度量标准。错误率这个词的作用就像它说的那样——它获取你的模型输入的转录和真实的转录,并测量它们之间的误差。

你可以在此处查看它是如何完成。另一个有用的度量标准称为字符错误率(CER)。CER测量模型输入和真实标签之间的字符误差。这些目的有助于衡量模型的功能。

在本教程中,我们运用“贪心”解码方法将模型的输入处理为字符,这些字符可组合创建文本。“贪心”解码器接收模型输入,该输入是字符的最大概率矩阵,对于每个工夫步长(频谱图帧),它选择概率最高的标签。假如标签是空白标签,则将其从最终的文本中删除。
def GreedyDecoder(output, labels, label_lengths, blank_label=28, collapse_repeated=True):
arg_maxes = torch.argmax(output, dim=2)
decodes =
targets =
for i, args in enumerate(arg_maxes):
decode =
targets.append(text_transform.int_to_text(labels[:label_lengths].tolist))
for j, index in enumerate(args):
if index != blank_label:
if collapse_repeated and j != 0 and index == args[j -1]:
continue
decode.append(index.item)
decodes.append(text_transform.int_to_text(decode))
return decodes, targets



运用Comet.ml训练和监测实验

Comet.ml提供了一个平台,允许深度学习研讨人员跟踪、比较、解释和优化他们的实验和模型。Comet.ml提高了AssemblyAI的工作效率,我们激烈建议团队运用这个平台停止任何类型的数据迷信实验。

Comet.ml非常容易设置。仅需几行代码即可工作。
initialize experiment object
experiment = Experiment(api_key=comet_api_key, project_name=project_name)
experiment.set_name(exp_name)
track metrics
experiment.log_metric('loss', loss.item)

Comet.ml为你提供了一个非常高效的仪表板,你可以查看和跟踪模型的进度。



你可以运用Comet来跟踪目的、代码、超参数、模型图等。Comet提供的一项非常方便的功能,可以将你的实验与许多其他实验停止比较。



Comet具有丰富的功能集,我们在这里不会全部引见,但是我们激烈建议您运用它来提高消费率和健全性。

下面是我们训练脚本的其他部分。
class IterMeter(object):
"""keeps track of total iterations"""
def __init__(self):
self.val = 0
def step(self):
self.val += 1
def get(self):
return self.val
def train(model, device, train_loader, criterion, optimizer, scheduler, epoch, iter_meter, experiment):
model.train
data_len = len(train_loader.dataset)
with experiment.train:
for batch_idx, _data in enumerate(train_loader):
spectrograms, labels, input_lengths, label_lengths = _data
spectrograms, labels = spectrograms.to(device), labels.to(device)
optimizer.zero_grad
output = model(spectrograms) # (batch, time, n_class)
output = F.log_softmax(output, dim=2)
output = output.transpose(0, 1) # (time, batch, n_class)
loss = criterion(output, labels, input_lengths, label_lengths)
loss.backward
experiment.log_metric('loss', loss.item, step=iter_meter.get)
experiment.log_metric('learning_rate', scheduler.get_lr, step=iter_meter.get)
optimizer.step
scheduler.step
iter_meter.step
if batch_idx % 100 == 0 or batch_idx == data_len:
print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
epoch, batch_idx * len(spectrograms), data_len,
100. * batch_idx / len(train_loader), loss.item))
def test(model, device, test_loader, criterion, epoch, iter_meter, experiment):
print('\nevaluating…')
model.eval
test_loss = 0
test_cer, test_wer = ,
with experiment.test:
with torch.no_grad:
for I, _data in enumerate(test_loader):
spectrograms, labels, input_lengths, label_lengths = _data
spectrograms, labels = spectrograms.to(device), labels.to(device)
output = model(spectrograms) # (batch, time, n_class)
output = F.log_softmax(output, dim=2)
output = output.transpose(0, 1) # (time, batch, n_class)
loss = criterion(output, labels, input_lengths, label_lengths)
test_loss += loss.item / len(test_loader)
decoded_preds, decoded_targets = GreedyDecoder(output.transpose(0, 1), labels, label_lengths)
for j in range(len(decoded_preds)):
test_cer.append(cer(decoded_targets[j], decoded_preds[j]))
test_wer.append(wer(decoded_targets[j], decoded_preds[j]))
avg_cer = sum(test_cer)/len(test_cer)
avg_wer = sum(test_wer)/len(test_wer)
experiment.log_metric('test_loss', test_loss, step=iter_meter.get)
experiment.log_metric('cer', avg_cer, step=iter_meter.get)
experiment.log_metric('wer', avg_wer, step=iter_meter.get)
print('Test set: Average loss: {:.4f}, Average CER: {:4f} Average WER: {:.4f}\n'.format(test_loss, avg_cer, avg_wer))
def main(learning_rate=5e-4, batch_size=20, epochs=10,
train_url="train-clean-100", test_url="test-clean",
experiment=Experiment(api_key='dummy_key', disabled=True)):
hparams = {
"n_cnn_layers": 3,
"n_rnn_layers": 5,
"rnn_dim": 512,
"n_class": 29,
"n_feats": 128,
"stride": 2,
"dropout": 0.1,
"learning_rate": learning_rate,
"batch_size": batch_size,
"epochs": epochs
}
experiment.log_parameters(hparams)
use_cuda = torch.cuda.is_available
torch.manual_seed(7)
device = torch.device("cuda" if use_cuda else "cpu")
if not os.path.isdir("./data"):
os.makedirs("./data")
train_dataset = torchaudio.datasets.LIBRISPEECH("./data", url=train_url, download=True)
test_dataset = torchaudio.datasets.LIBRISPEECH("./data", url=test_url, download=True)
kwargs = {'num_workers': 1, 'pin_memory': True} if use_cuda else {}
train_loader = data.DataLoader(dataset=train_dataset,
batch_size=hparams['batch_size'],
shuffle=True,
collate_fn=lambda x: data_processing(x, 'train'),
**kwargs)
test_loader = data.DataLoader(dataset=test_dataset,
batch_size=hparams['batch_size'],
shuffle=False,
collate_fn=lambda x: data_processing(x, 'valid'),
**kwargs)
model = SpeechRecognitionModel(
hparams['n_cnn_layers'], hparams['n_rnn_layers'], hparams['rnn_dim'],
hparams['n_class'], hparams['n_feats'], hparams['stride'], hparams['dropout']
).to(device)
print(model)
print('Num Model Parameters', sum([param.nelement() for param in model.parameters()]))
optimizer = optim.AdamW(model.parameters, hparams['learning_rate'])
criterion = nn.CTCLoss(blank=28).to(device)
scheduler = optim.lr_scheduler.OneCycleLR(optimizer, max_lr=hparams['learning_rate'],
steps_per_epoch=int(len(train_loader)),
epochs=hparams['epochs'],
anneal_strategy='linear')
iter_meter = IterMeter
for epoch in range(1, epochs + 1):
train(model, device, train_loader, criterion, optimizer, scheduler, epoch, iter_meter, experiment)
test(model, device, test_loader, criterion, epoch, iter_meter, experiment)
训练功能可在整个数据周期内训练模型。在每个时期之后,测试功能都会根据测试数据评价模型。它获取test_loss以及模型的cer和wer。你如今可以在Google合作实验室的GPU支持下末尾运转训练脚本。



如何提高准确性

语音辨认需求大量数据和计算资源。这个示例是在LibriSpeech(100小时的音频)的一个子集和一个单独的GPU上停止训练的。为了获得最先进的结果,你需求对数千小时的数据停止分布式训练,并且需求在许多计算机上分布数十个GPU。

提高准确性的另一种方法是运用言语模型和CTC波束搜索算法对CTC概率矩阵停止解码。CTC类型模型非常依赖此解码过程来获得良好的结果。这里有一个方便的开源库允许你这样做。

本教程的运用范围更广,与BERT(3.4亿个参数)相比,它是一个相对较小的模型(2300万个参数)。虽然收益递减,但似乎你的网络规模越大,它的功能就越好。正如OpenAI的研讨“ Deep Double Descent”证明的那样,一个更大的模型并不总是等同于更好的功能。

该模型具有3个CNN残差层和5个双向GRU层,允许你在具有至少11GB内存的单个GPU上训练合理的批处理大小。你可以调整main函数中的一些超级参数,减少或添加你的用例和计算可用性的模型大小。



基于深度学习的语音辨认的最新停顿

深度学习是一个疾速发展的范畴。似乎你一个星期都不能没有新技术得到最先进的结果。以下是在语音辨认范畴中值得探求的几个方面。

转换器

转换器席卷了自然言语处理世界。首先在论文中引见了“无可或缺的留意力”,转换器曾经出现和修正,几乎击败一切现有的NLP义务,取代了RNN的类型体系结构。转换器查看序列数据残缺上下文的才能也可以转转移到语音中。

无人监督的预训练

假如你亲密关注深度学习,你能够听说过BERT,GPT和GPT2。这些Transformer模型首先用于运用未标记文本数据的言语建模义务,并在各种NLP义务上停止了微调,获得了最新的结果。在预训练时期,该模型学习了一些言语统计方面的基础知识,并应用该才能在其他义务上表现出色。我们置信这项技术在语音数据方面也具有广阔的前景。

词块模型

我们的模型在下面定义了输入字符。这样做的一些好处是,在停止语音推理时,模型不必担心词汇量不足。对于单词c h a t,每个字符都有本人的标签。运用字符的缺陷是效率低,由于你一次只能预测一个字符,该模型更容易出现错误。

运用整个单词作为标签曾经探求了,在一定程度上获得了成功。运用这种方法,整个单词chat将成为标签。假如运用整个单词,你就必须对一切能够的词汇停止索引来才能停止预测,这会使内存效率低,在预测过程中能够会遇到词汇量不足的状况。最有效的方法是运用单词片段或子单词单位作为标签。

你可以将单词分割成子单词单元,运用这些子单词作为标签,即ch at,而不是单个标签的字符。这不只处理了词汇量不足的成绩,并且效率更高,与运用字符相比,它需求更少的步骤来停止解码,而且不需求对一切能够的单词停止索引。词块已成功用于许多NLP模型(如BERT),自然可以处理语音辨认成绩。

原文链接:https://hackernoon.com/building-an-end-to-end-speech-recognition-model-in-pytorch-with-assemblyai-5o8s3yry

本文由 AI 科技大本营翻译,转载请注明出处。



    重构ncnn,腾讯优图开源新一代移动端推理框架TNN

    墨奇科技汤林鹏:如何用 AI 技术颠覆指纹辨认?

    功能超越最新序列引荐模型,华为诺亚方舟提出记忆加强的图神经网络

    研发的将来在哪里?Serverless 云开发来了!

    真惨!连各大编程言语都摆起地摊了!
    国外小伙怒喷加密货币行业:入行两年,我受够了!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有帐号?立即注册

x
回复

使用道具 举报

大神点评3

巨蟹LYY987 2020-6-13 19:01:16 来自手机 显示全部楼层
这帖子写的不错
回复

使用道具 举报

冰凉怡夏 2020-6-14 19:25:10 显示全部楼层
顶顶更健康
回复

使用道具 举报

章鱼不都是哥 2020-6-15 18:33:32 来自手机 显示全部楼层
专业抢沙发的!哈哈
回复

使用道具 举报

高级模式
B Color Image Link Quote Code Smilies