基于kaldi+GStreamer搭建web版实时语音识别系统

本文将会主要介绍怎么结合kaldi语音识别工具和两个GStreamer插件件(gst­-kaldi­-nnet2­-onlinekaldi­-gstreamer-server)以及dictate.js来搭建线上的实时语音识别系统。

本人配置环境:腾讯云服务器、ubuntu 18.04。

一.kaldi

Kaldi是当前最流行的开源语音识别工具(Toolkit),它使用WFST来实现解码算法。Kaldi的主要代码是C++编写,在此之上使用bash和python脚本做了一些工具。而实时识别系统的好坏取决于语音识别的性能,语音识别包含特征提取、声学模型、语言模型、解码器等部分。Kaldi工具箱集成了几乎所有搭建语音识别器需要用到的工具。因不涉及GPU的使用,因此不用配置CUDA。kaldi的训练我将再另外一个专栏进行介绍。

1.kaldi安装

1.下载kaldi最新文件

1
git clone https://github.com/kaldi-asr/kaldi.git

2.安装kaldi,首先进入tools目录下,检查依赖性,没有的包按照指令安装,详细过程参见tools目录下的INSTALL文件。

1
2
3
4
5
6
cd kaldi-trunk/tools
cat INSTALL
#查看安装教程
extras/check_dependencies.sh
#检查依赖性,没有的包按照指令安装
make or make -j 4(多线程加快进度)

3.编译kaldi源文件,详细过程参见src目录下的INSTALL文件。

1
2
3
4
5
6
cd ../src
cat INSTALL
#查看安装教程
./configure --shared
make depend -8
make -8

安装过程遇上问题,需要停止安装并按照提示检查错误并解决错误,此处为kaldi官方文档的编译过程。(我遇到的问题GCC版本太低)
4.检查是否安装成功

跑一个例子yesno

1
2
cd ../egs/yesno/s5
./run.sh

输出显示
image (6)

表示安装成功

2.模型的训练

模型训练将在kaldi专栏中进行介绍。本文将基于nnet3模型搭建实时语音识别系统,gst-kaldi-nnet-online插件需要的文件包括:final.mdl,frame_subsampling_factor,HCLG.fst,phones.txt,tree,words.txt;conf文件包括:ivector_extractor.conf,mfcc.conf, online_cmvn.conf,online.conf,pitch.conf(如果使用音高),splice.conf;ivector_extractor文件包括:final.dubm,final.ie,final.mat,global_cmvn.stats,online_cmvn.conf,splice_opts。有了上面这些文件,我们就可以基于gst-kaldi-nnet-online插件搭建实时语音识别了,再结合kaldi­-gstreamer­-server和dictate.js就可以实现web端语音识别系统。

二.GStreamer插件

1.安装gst-kaldi-nnet-online插件

首先安装gstreamer-1.0:

1
sudo apt-get install gstreamer1.0-plugins-bad  gstreamer1.0-plugins-base gstreamer1.0-plugins-good  gstreamer1.0-pulseaudio  gstreamer1.0-plugins-ugly  gstreamer1.0-tools libgstreamer1.0-dev

Ubuntu14.04版本以下,在执行上述sudo apt-get install之前,需要使用backport ppa来获取gstreamer的1.0版本:

1
2
sudo add-apt-repository ppa:gstreamer-developers/ppa
sudo apt-get update

接下来就是安装 Jansson 库开发包(2.7 或更高版本),用于将结果编码为 JSON:

1
sudo apt-get install libjansson-dev

接下来就是编译安装gst-kaldi-nnet-online插件了。

1
2
3
4
5
6
git clone https://github.com/alumae/gst­kaldi­nnet2­online.git
#如果无法下载就自己去github下载上传到服务器
cd gst-kaldi-nnet2-online/src
KALDI_ROOT=/path/of/kaldi-trunk make depend
KALDI_ROOT=/path/of/kaldi-trunk make
#编译指定kaldi的根目录

整个编译如果没有出现错误,那么应该会在src目录下产生libgstkaldinnet2onlinedecoder.so
image (7)

设置环境变量

1
2
3
vim ~/.bashrc
#按GG到最后一行插入下面这行命令
export GST_PLUGIN_PATH=your gst­kaldi­nnet2­online installation directory/src

测试GStreamer 是否可以访问插件:

1
GST_PLUGIN_PATH=. gst-inspect-1.0 kaldinnet2onlinedecoder

输出应列出所有插件属性及其默认值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Factory Details:
Rank none (0)
Long-name KaldiNNet2OnlineDecoder
Klass Speech/Audio
Description Convert speech to text
[...]
name : The name of the object
flags: readable, writable
String. Default: "kaldinnet2onlinedecoder0"
parent : The parent of the object
flags: readable, writable
Object of type "GstObject"
silent : Silence the decoder
flags: readable, writable
Boolean. Default: false
model : Filename of the acoustic model
flags: readable, writable
String. Default: "models/final.mdl"
[...]
max-nnet-batch-size : Maximum batch size we use in neural-network decodable object, in cases where we are not constrained by currently available frames (this will rarely make a difference)
flags: readable, writable
Integer. Range: -2147483648 - 2147483647 Default: 256

Element Signals:
"partial-result" : void user_function (GstElement* object,
gchararray arg0,
gpointer user_data);
"final-result" : void user_function (GstElement* object,
gchararray arg0,
gpointer user_data);

2.测试语音识别系统

安装完gst-kaldi-nnet-online插件后,配合kaldi语音识别工具箱,就可以实现实时语音识别了,在demo提供了两个案例,下面将详细介绍这两个案例。

1.案例一

首先要下载基于DNN的英语模型。

1
2
cd demo
./prepare-models.sh

将会下载三个文件夹models、conf、ivector_extractor:
![image (8)](../images/基于kaldi-GStreamer搭建web版实时语音识别系统/image (8).png)

IMG_20220404_105214020

image (9)

有了以上文件后,直接运行transcribe­audio.sh这个脚本:

1
./transcribe-audio.sh dr_strangelove.mp3

得到以下结果:

1
2
3
4
5
LOG ([5.5.201~1-36f6d]:ComputeDerivedVars():ivector-extractor.cc:183) Computing derived variables for iVector extractor
LOG ([5.5.201~1-36f6d]:ComputeDerivedVars():ivector-extractor.cc:204) Done.
huh i hello this is hello dimitri listen i i can't hear too well do you support you could turn the music down just a little
ha ha that's much better yet not yet
...

查看transcribe-audio.sh可以看出怎么配置gst-kaldi-nnet2-online,因为后面­kaldi-gstreamer-­server会涉及到,以下是该脚本核心配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
GST_PLUGIN_PATH=../src gst-launch-1.0 --gst-debug="" -q filesrc location=$audio ! decodebin ! audioconvert ! audioresample ! \
kaldinnet2onlinedecoder \
use-threaded-decoder=true \
model=models/final.mdl \
fst=models/HCLG.fst \
word-syms=models/words.txt \
phone-syms=models/phones.txt \
word-boundary-file=models/word_boundary.int \
num-nbest=3 \
num-phone-alignment=3 \
do-phone-alignment=true \
feature-type=mfcc \
mfcc-config=conf/mfcc.conf \
ivector-extraction-config=conf/ivector_extractor.fixed.conf \
max-active=7000 \
beam=11.0 \
lattice-beam=5.0 \
do-endpointing=true \
endpoint-silence-phones="1:2:3:4:5:6:7:8:9:10" \
chunk-length-in-secs=0.2 \
! filesink location=/dev/stdout buffer-mode=2

小声哔哔1:在运行这步的时候出错了

1
2
INTEL MKL ERROR: /opt/intel/mkl/lib/intel64/libmkl_avx2.so: undefined symbol: mkl_sparse_optimize_bsr_trsm_i8.
Intel MKL FATAL ERROR: Cannot load libmkl_avx2.so or libmkl_def.so.

出现上述错误貌似是MKL没有安装好,但是kaldi我能正常运行,我经过下述操作解决了该问题

1
2
3
4
cd ~/.bashrc
#(GG)跳到最后一行
export LD_PRELOAD=/opt/intel/mkl/lib/intel64/libmkl_def.so:/opt/intel/mkl/lib/intel64/libmkl_avx2.so:/opt/intel/mkl/lib/intel64/libmkl_core.so:/opt/intel/mkl/lib/intel64/libmkl_intel_lp64.so:/opt/intel/mkl/lib/intel64/libmkl_intel_thread.so:/opt/intel/lib/intel64_lin/libiomp5.so:/opt/intel/mkl/lib/intel64/libmkl_sequential.so
#本来只要添加两个,但是后面还有同类错误,所有全部添加了

小声哔哔2:解决第一个问题后又出现问题

1
2
3
4
5
6
7
8
9
10
(base) ubuntu@VM-4-17-ubuntu:~/gst-kaldi-nnet2-online/demo$ ./transcribe-audio.sh dr_strangelove.mp3
LOG ([5.5.201~1-36f6d]:ComputeDerivedVars():ivector-extractor.cc:183) Computing derived variables for iVector extractor
LOG ([5.5.201~1-36f6d]:ComputeDerivedVars():ivector-extractor.cc:204) Done.
ERROR: from element /GstPipeline:pipeline0/GstDecodeBin:decodebin0: Your GStreamer installation is missing a plug-in.
Additional debug info:
gstdecodebin2.c(4640): gst_decode_bin_expose (): /GstPipeline:pipeline0/GstDecodeBin:decodebin0:
no suitable plugins found:
Missing decoder: ID3 tag (application/x-id3)

ERROR: pipeline doesn't want to preroll.

当时查了很多资料无法解决该问题,因为装了anaconda3,当时是在base环境下,后面conda deactivate该问题消失。
2.案例二

1
python2 gui-demo.py

运行这个基本需要用python2,运行完成后将会弹出一个小框框,点击Speak按钮后开始实时语音识别。
在运行这个引入包的时候出错了

1
from gi.repository import GObject, Gst, Gtk, Gdk

解决方法忘了,自己解决吧。
以下是对gui-demo.py程序代码,注解参考李健的热心分享

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
#!/usr/bin/env python
#
# Copyright (c) 2013 Tanel Alumae
# Copyright (c) 2008 Carnegie Mellon University.
#
# Inspired by the CMU Sphinx's Pocketsphinx Gstreamer plugin demo (which has BSD license)
#
# Licence: BSD

import sys
import os
import gi
gi.require_version('Gst', '1.0')#
from gi.repository import GObject, Gst, Gtk, Gdk
GObject.threads_init()
Gdk.threads_init()

Gst.init(None)

class DemoApp(object):
"""GStreamer/Kaldi Demo Application"""
def __init__(self):
"""Initialize a DemoApp object"""
self.init_gui()
def init_gui(self):
"""Initialize the GUI components"""
self.window = Gtk.Window()
self.window.set_border_width(10)
vbox = Gtk.VBox()
self.text = Gtk.TextView()
self.textbuf = self.text.get_buffer()
self.text.set_wrap_mode(Gtk.WrapMode.WORD)
vbox.pack_start(self.text, True, True, 1)
self.button = Gtk.Button("Speak")
self.button.connect('clicked', self.button_clicked)
vbox.pack_start(self.button, False, False, 5)
self.window.add(vbox)
self.window.show_all()

def quit(self, window):
Gtk.main_quit()

def init_gst(self):
"""Initialize the speech components"""
self.pulsesrc = Gst.ElementFactory.make("pulsesrc", "pulsesrc")
if self.pulsesrc == None:
sys.exit()
self.audioconvert = Gst.ElementFactory.make("audioconvert", "audioconvert")
self.audioresample = Gst.ElementFactory.make("audioresample", "audioresample")
self.asr = Gst.ElementFactory.make("kaldinnet2onlinedecoder", "asr")
self.fakesink = Gst.ElementFactory.make("fakesink", "fakesink")

if self.asr:
model_file = "models/final.mdl"
if not os.path.isfile(model_file):
print >> sys.stderr, "Models not downloaded? Run prepare-models.sh first!"
sys.exit(1)
self.asr.set_property("nnet-mode",3)
self.asr.set_property("fst", "models/HCLG.fst")
self.asr.set_property("model", model_file)
self.asr.set_property("word-syms", "models/words.txt")
self.asr.set_property("feature-type", "mfcc")
self.asr.set_property("mfcc-config", "conf/mfcc.conf")
self.asr.set_property("ivector-extraction-config", "conf/ivector_extractor.fixed.conf")
self.asr.set_property("max-active", 7000)
self.asr.set_property("beam", 10.0)
self.asr.set_property("lattice-beam", 6.0)
self.asr.set_property("do-endpointing", True)
self.asr.set_property("endpoint-silence-phones", "1:2:3:4:5:6:7:8:9:10")
self.asr.set_property("use-threaded-decoder", False)
self.asr.set_property("chunk-length-in-secs", 0.2)
else:
print >> sys.stderr, "Couldn't create the kaldinnet2onlinedecoder element. "
if os.environ.has_key("GST_PLUGIN_PATH"):
print >> sys.stderr, "Have you compiled the Kaldi GStreamer plugin?"
else:
print >> sys.stderr, "You probably need to set the GST_PLUGIN_PATH envoronment variable"
print >> sys.stderr, "Try running: GST_PLUGIN_PATH=../src %s" % sys.argv[0]
sys.exit();

# initially silence the decoder
self.asr.set_property("silent", True)

self.pipeline = Gst.Pipeline()
for element in [self.pulsesrc, self.audioconvert, self.audioresample, self.asr, self.fakesink]:
self.pipeline.add(element)
self.pulsesrc.link(self.audioconvert)
self.audioconvert.link(self.audioresample)
self.audioresample.link(self.asr)
self.asr.link(self.fakesink)

self.asr.connect('partial-result', self._on_partial_result)
self.asr.connect('final-result', self._on_final_result)
self.pipeline.set_state(Gst.State.PLAYING)



def _on_partial_result(self, asr, hyp):
"""Delete any previous selection, insert text and select it."""
Gdk.threads_enter()
# All this stuff appears as one single action
self.textbuf.begin_user_action()
self.textbuf.delete_selection(True, self.text.get_editable())
self.textbuf.insert_at_cursor(hyp)
ins = self.textbuf.get_insert()
iter = self.textbuf.get_iter_at_mark(ins)
iter.backward_chars(len(hyp))
self.textbuf.move_mark(ins, iter)
self.textbuf.end_user_action()
Gdk.threads_leave()

def _on_final_result(self, asr, hyp):
Gdk.threads_enter()
"""Insert the final result."""
# All this stuff appears as one single action
self.textbuf.begin_user_action()
self.textbuf.delete_selection(True, self.text.get_editable())
self.textbuf.insert_at_cursor(hyp)
if (len(hyp) > 0):
self.textbuf.insert_at_cursor(" ")
self.textbuf.end_user_action()
Gdk.threads_leave()



def button_clicked(self, button):
"""Handle button presses."""
if button.get_label() == "Speak":
button.set_label("Stop")
self.asr.set_property("silent", False)
else:
button.set_label("Speak")
self.asr.set_property("silent", True)



if __name__ == '__main__':
app = DemoApp()
Gdk.threads_enter()
Gtk.main()
Gdk.threads_leave()

三.Kaldi­-gstreamer­-server插件

该插件下所有python脚本需用python2运行

1.安装依赖

1
2
3
4
5
6
7
#Tornado 4, 见 http://www.tornadoweb.org/en/stable/
#ws4py (0.3.0 .. 0.3.2)
#YAML
#JSON
sudo pip install tornado
sudo pip install ws4py==0.3.2
sudo pip install pyyaml

测试是否满足所有依赖关系
image (10)

没有出现no module错误表示安装正确,出错缺啥装啥

2.安装Kaldi­-gstreamer­-server插件

1
2
3
cd ~
#我安装在家目录下
git clone https://github.com/alumae/kaldi­gstreamer­server.git

下载完成后即可使用。
kaldi­-gstreamer­-server/kaldigstserver下存放的是核心程序。整个server包含两部分,第一个是master_server.py,master_server不进行语音识别,它的作用是接收和发送数据。第二个是worker.py,worker的作用是对接收的进行语音识别并发送识别结果。使用的是websocket全双工通信。因此识别流程是“客户端”发送数据到master_server,master_server将识别任务分配给worker(当有多个客户端请求时master_server可以把不同的任务分配给不同的worker),worker接收数据识别后将识别结果传回master_server,master_server再将识别结果返回给客户端。

下面展示如何对实现识别语音识别:

3.运行服务器

首先下载训练好的nnet2模型,用作测试使用

1
2
3
cd kaldi-gstreamer-server-master/test/models/
./download-multi_cn-nnet3.sh
cd ../..

运行master server,端口号为8888(可以自己随意设置)

1
python2 kaldigstserver/master_server.py --port=8888

接下来开启worker,worker负责语音识别部分,worker可以使用两种解码器

第一种:onlinegmmdecodefasterGStreamer,支持GMM,安装教程如下

1
2
3
4
5
6
7
8
9
10
11
12
13
cd kaldi/src
make ext
cd tools
./install_portaudio.sh
vim ~/.bashrc
加入path/kaldi/tools/portaudio
sudo ldconfig
cd src/gst-plugin/
KALDI_ROOT=/path/of/kaldi make depend
KALDI_ROOT=/path/of/kaldi make
然后会在src/gst-plugin中看到libgstonlinegmmdecodefaster.so
export GST_PLUGIN_PATH=pathkaldi/src/gst-plugin (可以把这个目录写入~/.bashrc中)
gst-inspect-1.0 onlinegmmdecodefaster

我们使用的是第二种较新的kaldinnet2onlinedecoder插件,支持DNN模型

1
python2 kaldigstserver/worker.py -u ws://localhost:8888/worker/ws/speech -c /home/ubuntu/kaldi-gstreamer-server-master/sample_chinese_nnet3.yam

该-u ws://localhost:8888/worker/ws/speech参数指定worker应连接到的master server所在的ip地址(本机为localhost)。并且确保worker使用的端口号与master server相同的端口(此处为8888),你可以同时启动任意数量的worker,只需要将上面命令再运行就可以了。
启动master server和woeker后就是客户端的使用了,客户端的示例kaldigstserver/client.py

,可以通过调用下面的命令测试安装

1
python2 kaldigstserver/client.py -r 8192 test/data/chinese_test.wav

没有问题的话应该会出现识别结果如下
image (11)

但是我当时出现问题,运行的时候出现错误并返回state1。

当时是将worker.py中422行代码修改成423行的样子解决的,刚想复现这个问题发现又不报错了,神奇。

image (12)

四.搭建web端实时语音识别系统

当上述步骤完成后,我们将借助dictate.js搭建web端实时语音识别系统。

1.安装nginx

apt-get安装nginx

1
sudo apt-get install nginx

查看nginx是否安装成功

1
nginx -V

启动nginx

1
service nginx start 

启动后,在浏览器输入ip地址,可以看到nginx的欢迎页面,表示nginx安装成功

2.申请域名

因为dictate.js调用麦克风需要https传输,而使用https传输不能使用ws,而是要使用wss(开始用apache配置wss差点人都去世了,后面转用nginx),配置wss需要域名(网上这么说的)。

打开xx云,搜索域名,然后购买9块钱一年。因为要备案,我用了个备了案的二级域名。

申请完域名后绑定服务器ip地址

3.申请并配置SSL

打开腾讯云(别的也一样),搜索ssl,点击“立即选购”,点击”自定义配置“,选择域名型免费版:

image (13)

然后按要求填就是了。

签发证书后在我的证书下载nginx版,下载完成后上传到服务器。

后续步骤参考这里

4.部署dictate.js

假设你的域名为kfc.zym

上述步骤完成后,应该可以在浏览器使用https://kfc.zym访问了

将dictate.js解压放在/var/www/html/文件夹下

修改/etc/nginx/sites-enabled/default

1
2
3
4
5
6
sudo vim /etc/nginx/sites-enabled/default
找到loaction /{}修改成:
location / {
root /var/www/html;
index index.html index.htm /web/;
}

此时通过https://kfc.zym/web/demos/mob.html
为了让wss能够传输,应在上述location/{}后面添加

1
2
3
4
5
6
7
location /api/ {# /api/为你代理转换的字符串
proxy_pass http://127.0.0.1:8888/;
#127.0.0.1为master server的ip地址,8888为master server设置的端口号
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}

这时应该修改mob.html中的wss

1
2
<option value="wss://abc.zym:443/api/client/ws/speech|wss://abc.zym:443/api/client/ws/status" selected="selected">普通话</option>
#abc.zym为你的域名,端口号为443,不能设为8888,/api/为上一步设置的跳转,nginx中是什么这里就是什么

5.测试web端语音识别系统是否能用

1
2
3
4
sudo service nginx restart #重启nginx
cd ~/kaldi-gstreamer-server-master/
python2 kaldigstserver/master_server.py --port=8888
python2 kaldigstserver/worker.py -u ws://localhost:8888/worker/ws/speech -c /home/ubuntu/kaldi-gstreamer-server-master/sample_chinese_nnet3.yaml

在浏览器进入https://kfc.zym/web/demos/mob.html
进行测试,如果能实时识别就说明成功了

6.自己训练的模型部署

参考下面文件夹所需配置

1
kaldi-gstreamer-server-master/test/models/chinese/multi_cn_chain_sp_online

仿照下面yaml填写自己的yaml

1
kaldi-gstreamer-server-master/sample_chinese_nnet3.yaml

如果只是使用了的mfcc,则没有大的变化
但是如果使用了音高则需要在yaml中添加pitch

以下为我的yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# You have to download multi_cn "online nnet3" chain model in order to use this sample
# Run download-multi_cn-nnet3.sh in 'test/models' to download them.
use-nnet2: True
decoder:
# All the properties nested here correspond to the kaldinnet2onlinedecoder GStreamer plugin properties.
# Use gst-inspect-1.0 ./libgstkaldionline2.so kaldinnet2onlinedecoder to discover the available properties
nnet-mode : 3
use-threaded-decoder: true
add-pitch: true
model : /home/ubuntu/tibetan_lasha/final.mdl
word-syms : /home/ubuntu/tibetan_lasha/words.txt
fst : /home/ubuntu/tibetan_lasha/HCLG.fst
mfcc-config : /home/ubuntu/tibetan_lasha/conf/mfcc.conf
online-pitch-config: /home/ubuntu/tibetan_lasha/conf/pitch.conf
ivector-extraction-config : /home/ubuntu/tibetan_lasha/conf/ivector_extractor.conf
max-active: 7000
beam: 15.0
lattice-beam: 8.0
acoustic-scale: 0.1
do-endpointing : true
endpoint-silence-phones : "1:2:3:4:5:6:7:8:9:10:11:12:13:14:15"
traceback-period-in-secs: 0.25
chunk-length-in-secs: 0.25
num-nbest: 1
out-dir: tmp
use-vad: False
silence-timeout: 10

# Just a sample post-processor that appends "." to the hypothesis
post-processor: perl -npe 'BEGIN {use IO::Handle; STDOUT->autoflush(1);} sleep(1); s/(.*)/\1./;'

#post-processor: (while read LINE; do echo $LINE; done)

# A sample full post processor that add a confidence score to 1-best hyp and deletes other n-best hyps
#full-post-processor: ./sample_full_post_processor.py

logging:
version : 1
disable_existing_loggers: False
formatters:
simpleFormater:
format: '%(asctime)s - %(levelname)7s: %(name)10s: %(message)s'
datefmt: '%Y-%m-%d %H:%M:%S'
handlers:
console:
class: logging.StreamHandler
formatter: simpleFormater
level: DEBUG
root:
level: DEBUG
handlers: [console]

五.展示效果

已经实现效果展示,可点击这里进行测试

image (14)

感谢“克维斯利姆·德里奥”和”奥雷里亚诺上校”的插图