使用whisper语音转文字
XiaoMa 博士生

本项目所用到的硬件:

  • jetson orin NX 16G

项目需求

做一个语音转文字的小项目练手:上传一段 mp3 格式的音频,转成文字。

项目路线

  1. 使用 whisper.cpp 进行转录。由 C++ 实现,适合 jetson 这种 ARM 设备。
  2. 使用 Docker 来做,不污染本机环境。
  3. 使用 Flask 做接口。

whisper.cpp

这是 OpenAI 的 Whisper 自动语音识别模型的 C/C++ 移植版本。

特点是纯 C/C++ 实现,无外部依赖项。

Docker

jetson 内置了 Docker 24.0.5,是一个开源平台,它通过容器化技术,将应用程序及其所有依赖项打包到一个轻量级、可移植的容器中。

需要先创建一个 image(镜像),在这个镜像上创建一个 container(容器)。

Flask 是一个轻量级的 Python Web 框架。用来做一个网页上传 mp3 的服务。

容器里负责三件事:编译 whisper.cpp,提供一个 Web 接口,调用 ffmpeg 把音频转成 16k 单声道 wav,再调用 whisper.cpp 的 main 做转写(踩坑一:main 已经被弃用了,改成了 whisper-cli)。模型文件建议用宿主机目录挂载进容器,避免每次重建镜像都重新下载。

第一步

在 jetson 上建一个目录:~/stt-whisper-docker,里面放三个文件:Dockerfileapp.pyrequirements.txt

每个文件如下所示:

requirements.txt

1
2
flask==3.0.0
gunicorn==21.2.0

app.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
import os
import uuid
import subprocess
from flask import Flask, request, jsonify
from typing import List, Tuple # python 3.8 不兼容 Python 3.9,所以要加这句,用到下面,之前下面是 list 和 tuple,现在要改成大写才对

APP = Flask(__name__)

WHISPER_BIN = os.environ.get("WHISPER_BIN", "/opt/whisper/build/bin/whisper-cli") # 这里也改了,原来是 /opt/whisper/main,main 已经弃用了,这里踩了不少坑
MODEL_PATH = os.environ.get("MODEL_PATH", "/models/ggml-base.bin")
LANG = os.environ.get("LANG", "auto") # auto 或 zh 或 en

UPLOAD_DIR = "/tmp/uploads"
os.makedirs(UPLOAD_DIR, exist_ok=True)

def run(cmd: List[str]) -> Tuple[int, str, str]:
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
return p.returncode, p.stdout, p.stderr

@APP.post("/transcribe")
def transcribe():
if "file" not in request.files:
return jsonify({"error": "missing file field"}), 400

f = request.files["file"]
if not f.filename:
return jsonify({"error": "empty filename"}), 400

ext = os.path.splitext(f.filename)[1].lower()
if ext not in [".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"]:
return jsonify({"error": "unsupported file type"}), 400

job_id = str(uuid.uuid4())
in_path = os.path.join(UPLOAD_DIR, f"{job_id}{ext}")
wav_path = os.path.join(UPLOAD_DIR, f"{job_id}.wav")
out_txt = os.path.join(UPLOAD_DIR, f"{job_id}.txt")

f.save(in_path)

code, _, err = run([
"ffmpeg", "-y", "-i", in_path,
"-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le",
wav_path
])
if code != 0:
return jsonify({"error": "ffmpeg failed", "detail": err[-2000:]}), 500

cmd = [WHISPER_BIN, "-m", MODEL_PATH, "-f", wav_path, "-otxt", "-of", out_txt[:-4]]
if LANG != "auto":
cmd += ["-l", LANG]

code, out, err = run(cmd)
if code != 0:
return jsonify({"error": "whisper failed", "detail": (err or out)[-2000:]}), 500

try:
with open(out_txt, "r", encoding="utf-8") as fp:
text = fp.read().strip()
except Exception as e:
return jsonify({"error": "read output failed", "detail": str(e)}), 500
finally:
for p in [in_path, wav_path]:
try:
os.remove(p)
except:
pass

return jsonify({"text": text})

@APP.get("/")
def index():
return {
"ok": True,
"usage": "POST /transcribe with form-data field name 'file'",
"model": MODEL_PATH
}

if __name__ == "__main__":
APP.run(host="0.0.0.0", port=8000)

Dockerfile

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
FROM arm64v8/ubuntu:20.04

ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /opt

RUN apt-get update && apt-get install -y \
git build-essential ffmpeg python3 python3-pip cmake \ # 原本的还缺少依赖 cmake
&& rm -rf /var/lib/apt/lists/*

RUN git clone https://github.com/ggerganov/whisper.cpp /opt/whisper \
&& cd /opt/whisper \
&& make -j

WORKDIR /srv
COPY requirements.txt /srv/requirements.txt
RUN pip3 install --no-cache-dir -r /srv/requirements.txt

COPY app.py /srv/app.py

EXPOSE 8000
ENV WHISPER_BIN=/opt/whisper//build/bin/whisper-cli # 这里也要和 app.py 保持一致
ENV MODEL_PATH=/models/ggml-base.bin
ENV LANG=auto

CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:APP", "--workers", "1", "--threads", "4", "--timeout", "600"]

第二步

在宿主机上创建一个 models 文件夹 stt-models,并下载模型,这里就先用 base 模型。在终端里一次输入下面的命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mkdir -p ~/stt-models
cd ~/stt-models、

# 这里是使用的 whisper.cpp 自带的脚本 .sh 下载,更方便
## 先把模型的文件夹下载到 tmp 文件夹
git clone https://github.com/ggerganov/whisper.cpp ~/whispercpp_tmp
## 再用 whisper.cpp 项目自带的模型下载工具进行下载,指定 base 版本
bash ~/whispercpp_tmp/models/download-ggml-model.sh base

# 把下载的模型,复制到创建的文件夹中
cp ~/whispercpp_tmp/models/ggml-base.bin ~/stt-models/ggml-base.bin

# 下载结束,把 tmp 文件夹删掉
rm -rf ~/whispercpp_tmp

第三步

在项目文件夹中构建镜像

1
2
cd ~/stt-whisper-docker
docker build -t jetson-stt:whispercpp .

这个会运行较长一段时间,取决于计算机性能。运行成功后,可以查看镜像,命令如下:

1
2
3
4
sudo docker images
sudo docker images ls

# 这两个命令都可以

如果要删除镜像,就可以按名字或这文件的哈希值删除

1
2
sudo docker rmi jetson-stt:whispercpp
sudo docker rmi c3225d3aa7d6

第四步

运行 Docker,运行的同时把模型目录(即 stt-models )挂载进去。

1
2
3
4
docker run -d --name stt \
-p 8000:8000 \
-v ~/stt-models:/models:ro \
jetson-stt:whispercpp

第五步

浏览器打开 http://127.0.0.1:8000/ 看服务是否正常。如果能打开说明是正常的。

第六步

准备好的 mp3 文件上传。使用 curl 即可。

1
curl -F "file=@/path/to/test.mp3" http://127.0.0.1:8000/transcribe

等待返回 JSON,里面的 txt 字段就是转录的文字。

总结

看着挺简单,实际上花了一下午的时间。

第一坑:Dockerfile 中的依赖不全,这个查找到错误,然后改掉挺快的。

第二坑:main 文件不可用。找到要用 whisper-cli 用了非常久的时间。

第三坑:Docker 的各种命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 查看容器
sudo docker ps -a

# 启动容器
sudo docker strat stt
sudo docker restart stt

# 停止容器
sudo docker stop stt

# 删除容器
sudo docker rm stt

# 进入容器内部
sudo docker exec -it stt /bin/bash

# 退出容器
exit

第四坑: 理清楚 image(镜像)和 container(容器)之间的关系。

展望

app.py 返回的中文是一对 Unicode 编码,并非是汉字,需要解码。其实可以直接在 app.py 后面加上解码的功能。