1. 训练软硬件条件

1.1 硬件

CPU GPU Memory
i7 14700KF 28核心 RTX 3090Ti 24GB 64GB

1.2 软件

OS CUDA Python Torch
Ubuntu 24.04 12.8 3.10.16 2.7.0+cu128

2. 数据集准备

一共有500小时河南话和约100小时普通话,一共分为三个集合,训练集,验证集和测试集,其中训练集占比90%,验证集和测试集分别占比5%。

2.1 整理河南话数据集

这里采购的河南话数据集是pcm格式的音频,需要统一转码为wav格式,并且是单声道,16k采样率,位深为16bit的小端音频。可以使用下面的python代码统一转码处理。

# !/usr/bin/env python
# _*_ coding utf-8 _*_
# @Time: 2026/2/23 22:48
# @Author: Luke Ewin
# @Blog: https://blog.lukeewin.top
import os
import sys
import wave


def convert_pcm_to_wav(pcm_path, wav_path):
    """
    将单个 PCM 文件转换为 WAV 文件。
    参数:
        pcm_path: 输入的 PCM 文件路径
        wav_path: 输出的 WAV 文件路径
    """
    try:
        # 以二进制方式读取整个 PCM 数据
        with open(pcm_path, 'rb') as f:
            pcm_data = f.read()

        # 创建并写入 WAV 文件
        with wave.open(wav_path, 'wb') as wav_file:
            wav_file.setnchannels(1)          # 单声道
            wav_file.setsampwidth(2)           # 16 位 = 2 字节
            wav_file.setframerate(16000)        # 采样率 16kHz
            wav_file.writeframes(pcm_data)

        print(f"转换成功: {pcm_path} -> {wav_path}")
        return True
    except Exception as e:
        print(f"转换失败 {pcm_path}: {e}", file=sys.stderr)
        return False


def process_directory(root_dir, output_wav_dir):
    """
    遍历目录及其子目录,找到所有 .pcm 文件并转换。
    """
    for dirpath, _, filenames in os.walk(root_dir):
        for filename in filenames:
            if filename.lower().endswith('.pcm'):
                pcm_full = os.path.join(dirpath, filename)
                filename_noext = os.path.splitext(filename)[0]
                wav_full = os.path.join(output_wav_dir, filename_noext + '.wav')
                convert_pcm_to_wav(pcm_full, wav_full)


def main():
    root_dir = input("请输入要处理的pcm文件路径")
    output_wav_dir = input("请输入要输出的wav文件路径")
    if not os.path.isdir(root_dir) or not os.path.isdir(output_wav_dir):
        print(f"错误:'{root_dir}或{output_wav_dir}' 不是有效的目录。")
        sys.exit(1)

    process_directory(root_dir, output_wav_dir)


if __name__ == '__main__':
    main()

同时采购的这个河南话数据集中标注文档也需要处理为FunASR要求的格式,采购的数据集提供的是一个.scp文件,内容结构如下:

99000010001.pcm	废话 老王那么潇洒
99000010002.pcm	等下你不是要去发货吗
99000010003.pcm	你们是不是只是网店
99000010004.pcm	不过不严重 没啥事
99000010005.pcm	明天上班儿 你来上班儿
99000010006.pcm	没人在家都上学咧
99000010007.pcm	我看咱俩儿挺近的离的
99000010008.pcm	我以为你用电脑上的嘞

从上面的scp文件中可以看到,对方是把标点符号替换为空格了,这里需要把这些内容中多余的空格去掉。可以使用vscode工具,先把".pcm “替换为“|”,然后替换掉全部的空格,这种方式最快捷。当然你也可以编写python脚本来处理。这里为了快速方便,就直接使用vscode进行替换,把空格替换为空(这里指的是没有任何内容,包括空格也不要输入),最后还要记得把”.pcm "给替换回来。

2.2 整理普通话数据集

为了使得训练后的模型既可以识别河南方言,也可以识别普通话,这里加入了大约100小时的普通话一起训练,这个普通话数据集,我是从huggingface中下载的,如何下载可以使用下面的python代码。

# !/usr/bin/env python
# _*_ coding utf-8 _*_
# @Time: 2026/4/10 17:57
# @Author: Luke Ewin
# @Blog: https://blog.lukeewin.top
"""
从 huggingface 中下载 parquet 格式的数据集
"""
import os
os.environ["HTTP_PROXY"] = "http://127.0.0.1:10809"
os.environ["HTTPS_PROXY"] = "http://127.0.0.1:10809"
from datasets import load_dataset

dataset = load_dataset("urarik/free_st_chinese_mandarin_corpus", cache_dir=r"E:\Datasets\Mandarin")

print(dataset)

需要注意:这里使用了代理,如果你没有代理,可以使用huggingface的国内镜像,但是我电脑上试过,不太稳定,下载中途会中断,所以使用了代理。

下载好的是列存储的数据,也就是你在huggingface中看到的后缀为parquet的文件。那可以使用下面的python代码将数据提取出来,保存为16k单声道pcm_s16le的wav格式的音频和对应的funasr框架要求的scp和txt文件。

# !/usr/bin/env python
# _*_ coding utf-8 _*_
# @Time: 2026/4/10 23:35
# @Author: Luke Ewin
# @Blog: https://blog.lukeewin.top
import os
os.environ["HTTP_PROXY"] = "http://127.0.0.1:10809"
os.environ["HTTPS_PROXY"] = "http://127.0.0.1:10809"
from datasets import load_dataset
import soundfile as sf
from tqdm import tqdm

# ======================
# 1. 配置路径
# ======================
output_dir = r"E:\Datasets\Mandarin\funasr_format"
wav_dir = os.path.join(output_dir, "wav")

os.makedirs(wav_dir, exist_ok=True)

wav_scp_path = os.path.join(output_dir, "wav.scp")
text_path = os.path.join(output_dir, "text")

# ======================
# 2. 加载数据集
# ======================
dataset = load_dataset(
    "urarik/free_st_chinese_mandarin_corpus",
    cache_dir=r"E:\Datasets\Mandarin"
)

train_set = dataset["train"]

# ======================
# 3. 写文件
# ======================
with open(wav_scp_path, "w", encoding="utf-8") as f_wav, \
     open(text_path, "w", encoding="utf-8") as f_txt:

    for i, sample in enumerate(tqdm(train_set)):
        utt_id = f"utt_{i:08d}"

        audio = sample["audio"]
        text = sample["sentence"].strip()

        # 跳过空文本
        if not text:
            continue

        wav_path = os.path.join(wav_dir, f"{utt_id}.wav")

        # ======================
        # 4. 保存音频
        # ======================
        sf.write(
            wav_path,
            audio["array"],
            audio["sampling_rate"]
        )

        # ======================
        # 5. 写scp和text
        # ======================
        f_wav.write(f"{utt_id} {wav_path}\n")
        f_txt.write(f"{utt_id} {text}\n")

print("数据导出完成!")
print(f"wav.scp: {wav_scp_path}")
print(f"text: {text_path}")

注意:cache_dir的值填写你自己下载保存这个数据集的绝对路径。

2.3 打乱数据

把河南话数据和普通话数据放到一起,然后使用下面的python脚本打乱数据,并且分为三个集合,训练集,验证集和测试集,其中训练集占90%,验证集和测试集各占5%。

#!/usr/bin/env python3
"""
打乱并分割 scp 和 text 文件(保持行对应)
使用示例:
    python shuffle_split.py wav.scp text.txt --output_dir ./data
"""

import argparse
import random
import os
from pathlib import Path

def read_lines(file_path):
    """读取文件所有行,去除末尾换行符"""
    with open(file_path, 'r', encoding='utf-8') as f:
        return [line.rstrip('\n') for line in f]

def write_lines(file_path, lines):
    """将列表写入文件,每行加换行符"""
    with open(file_path, 'w', encoding='utf-8') as f:
        for line in lines:
            f.write(line + '\n')

def main():
    parser = argparse.ArgumentParser(description="打乱并分割 scp 和 text 文件(保持对应)")
    parser.add_argument("scp_file", type=str, help="输入的 .scp 文件路径")
    parser.add_argument("txt_file", type=str, help="输入的 .txt 文件路径")
    parser.add_argument("--output_dir", type=str, default=".", help="输出目录(默认为当前目录)")
    parser.add_argument("--seed", type=int, default=42, help="随机种子(默认42)")
    args = parser.parse_args()

    # 设置随机种子保证可复现
    random.seed(args.seed)

    # 读取两个文件
    scp_lines = read_lines(args.scp_file)
    txt_lines = read_lines(args.txt_file)

    # 检查行数是否一致
    if len(scp_lines) != len(txt_lines):
        raise ValueError(f"文件行数不一致: scp={len(scp_lines)}, txt={len(txt_lines)}")

    total = len(scp_lines)
    print(f"总行数: {total}")

    # 将对应行打包并打乱
    paired = list(zip(scp_lines, txt_lines))
    random.shuffle(paired)

    # 计算划分点(90% train, 5% vad, 5% test)
    train_end = int(total * 0.9)
    vad_end = train_end + int(total * 0.05)
    # 剩余自动归为 test(可能因为取整有1条误差,test取剩余全部)

    train_pairs = paired[:train_end]
    vad_pairs = paired[train_end:vad_end]
    test_pairs = paired[vad_end:]

    print(f"划分情况: train={len(train_pairs)}, vad={len(vad_pairs)}, test={len(test_pairs)}")

    # 创建输出目录(如果不存在)
    out_dir = Path(args.output_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    # 解包并写入对应文件
    for name, pairs in [("train", train_pairs), ("vad", vad_pairs), ("test", test_pairs)]:
        scp_out = out_dir / f"{name}_wav.scp"
        txt_out = out_dir / f"{name}_text.txt"

        scp_content = [p[0] for p in pairs]
        txt_content = [p[1] for p in pairs]

        write_lines(scp_out, scp_content)
        write_lines(txt_out, txt_content)

        print(f"已生成: {scp_out} 和 {txt_out}")

if __name__ == "__main__":
    main()

生成language, emo和event

def generate(text_file: str, save: str, type: str):
    """
    根据输入的 train_text.txt 文件生成 train_text_language.txt train_emo.txt train_event.txt
    :param text_file: 输入的 train_text.txt
    :param save: 保存文件
    :param type: 类型
    :return:
    """
    with open(text_file, 'r', encoding='utf-8') as r:
        lines = r.readlines()

    with open(save, 'w', encoding='utf-8') as w:
        for line in lines:
            tmp = line.strip().split(' ', 1)
            uuid = tmp[0]
            content = tmp[-1]
            if content is not None:
                w.write(f"{uuid} {type}\n")


text_file = r"D:\Works\Datasets\sichuan_datasets\sichuan.txt"
save = r"D:\Works\Datasets\sichuan_datasets\train_text_language.txt"
save_type = "<|zh|>"
generate(text_file, save, save_type)

注意需要将上面代码中的text_filesave替换为你自己的路径

2.4 生成训练所需的jsonl文件

使用下面的命令生成jsonl文件

生成train.jsonl文件

sensevoice2jsonl \
++scp_file_list='["/data/funasr/train_data/henan/label/train_wav.scp", "/data/funasr/train_data/henan/label/train_text.txt", "/data/funasr/train_data/henan/label/train_text_language.txt", "/data/funasr/train_data/henan/label/train_emo.txt", "/data/funasr/train_data/henan/label/train_event.txt"]' \
++data_type_list='["source", "target", "text_language", "emo_target", "event_target"]' \
++jsonl_file_out="/data/funasr/train_data/henan/label/train.jsonl"

生成val.jsonl文件

sensevoice2jsonl \
++scp_file_list='["/data/funasr/train_data/henan/label/val_wav.scp", "/data/funasr/train_data/henan/label/val_text.txt", "/data/funasr/train_data/henan/label/val_text_language.txt", "/data/funasr/train_data/henan/label/val_emo.txt", "/data/funasr/train_data/henan/label/val_event.txt"]' \
++data_type_list='["source", "target", "text_language", "emo_target", "event_target"]' \
++jsonl_file_out="/data/funasr/train_data/henan/label/val.jsonl"

生成train.jsonl过程截图如下:

image-20260411185746196

3. 开始训练

经过放上面的数据集准备,也就是现在已经有了train.jsonlval.jsonl两个文件,我们就可以开始训练了。首先需要修改finetune.sh,如下:

# 要记得修改为自己上面生成的那两个jsonl所在的绝对路径
train_data=/data/funasr/train_data/henan/label/train.jsonl
val_data=/data/funasr/train_data/henan/label/val.jsonl

# 这个是保存训练后的模型文件路径
output_dir="/data/funasr/train_data/henan/trained"
log_file="${output_dir}/log.txt"

torchrun $DISTRIBUTED_ARGS \
${train_tool} \
++model="${model_name_or_model_dir}" \
++train_data_set_list="${train_data}" \
++valid_data_set_list="${val_data}" \
++dataset_conf.data_split_num=1 \
++dataset_conf.batch_sampler="BatchSampler" \
++dataset_conf.batch_size=20000  \
++dataset_conf.sort_size=1024 \
++dataset_conf.batch_type="token" \
++dataset_conf.num_workers=28 \
++dataset_conf.max_token_length=3000 \
++train_conf.max_epoch=50 \
++train_conf.log_interval=1 \
++train_conf.resume=true \
++train_conf.validate_interval=2000 \
++train_conf.save_checkpoint_interval=2000 \
++train_conf.keep_nbest_models=20 \
++train_conf.avg_nbest_model=20 \
++train_conf.use_deepspeed=false \
++train_conf.deepspeed_config=${deepspeed_config} \
++train_conf.avg_keep_nbest_models_type="loss" \
++optim_conf.lr=0.0002 \
++output_dir="${output_dir}" &> ${log_file}

注意在执行这个finetune.sh脚本的时候,使用下面的命令,这样启动就不会因为退出shell远程导致训练中断。

nohup ./finetune.sh > log.txt 2>&1 &

注意不要写为下面的这样,下面这样启动,在断开远程后会中断训练。

nohup bash finetune.sh > log.txt 2>&1 &

然后还需要启动tensorboard查看训练的loss曲线变化情况,可以使用下面的命令启动。

nohup tensorboard --host 0.0.0.0 --port 6007 --logdir /path/to/your/tf-logs/direction > tensorboard.log 2>&1 &

注意:需要将--logdir后面的值修改为你自己训练保存这个tensorboard的绝对路径。

然后是在浏览器中访问你的局域网中的ip:6007就可以看到下面的内容了。一共训练了50轮次,耗时4.306天完成。下面是训练的loss变化曲线图。

image-20260424181356039

4. 评估字错率

训练前后做对照实验,看看降低了多少的字错率。

测试集 训练前 训练后
普通话和河南话混合测试集 CER = 0.12 CER = 0.06
普通话测试集 CER = 0.06 CER = 0.05
河南话测试集 CER = 0.13 CER = 0.06

image-20260424180500521

image-20260424174357711

从上面的实际测评可以得出,训练之后,对河南话数据集字错率下降了7%,训练后最终准确率是94%。可以从表中得出,训练前后基本上对原先普通话的识别能力并没有影响,甚至提升了1%的准确率。

5. 其它

如果你想要训练其它方言,但是没有数据集,可以自己采集,可以用我基于SpringBoot和Vue开发的ASR数据集采集系统来采集,可以从下面的百度网盘中下。

通过网盘分享的文件:ASR数据集采集系统
链接: https://pan.baidu.com/s/10CBrLy4vJprwVQHzIbJv5Q?pwd=7utf 提取码: 7utf

如果你想要训练其它的方言模型,并且你不知道如何训练,可以联系lukeewin01进行有偿代训练。

Q.E.D.


热爱生活,热爱程序