假期之不务正业—— Qt+FFmpeg+百度api进行视频的语音识别

假期之不务正业——Qt+FFmpeg+百度api进行视频的语音识别

一、前言

现在语音识别技术逐渐发展,先有siri开个好头,现在有各种小度小爱什么的轮番上阵。王者荣耀有语音识别以后,祖安起来也省事多了。我看一些视频教程的时候,对一些讲的不错的,也有记笔记的习惯。可是每次都是把视频暂停,然后一句一句敲出word,说实话,也没见学习效果有多好,反而效率变得低到不行。想来想去,咱也不能一直停留在这么笨比的方式,总是想整点活。

其实网上就有一些提取字幕的、或是语音识别的应用,应该效果也不错(我没试),但是要钱(emmmmm)。所以暂时先放弃这个方案,而且如果自己做一个那不是快乐加倍?于是利用假期时间,自己找了一些资料借(chao)鉴(xi)了一下,也是算是自己从零开始做的垃圾。

先放一下目前做到的:

假期之不务正业—— Qt+FFmpeg+百度api进行视频的语音识别
假期之不务正业—— Qt+FFmpeg+百度api进行视频的语音识别
假期之不务正业—— Qt+FFmpeg+百度api进行视频的语音识别
我主要选择了4个B站的视频来测试运行结果,顺便一提,B站用手机端下载视频后,会在缓存文件里发现audio.m4s和vedio.m4s。实际上,FFmpeg可以直接打开m4s格式,因此如果仅仅是为了对音频进行处理,不需要将两个文件合流为一个(合流的方法也很简单,尤其是使用FFmpeg,可以直接百度)。

我选择的4个视频分别是冰冰vlog、卢本伟17张牌名场面、小潮院长的不要做挑战和吴恩达老师的机器学习课程,链接放在下文,我这里就夹带私货安利一波。下面是识别结果:

假期之不务正业—— Qt+FFmpeg+百度api进行视频的语音识别
假期之不务正业—— Qt+FFmpeg+百度api进行视频的语音识别
假期之不务正业—— Qt+FFmpeg+百度api进行视频的语音识别
假期之不务正业—— Qt+FFmpeg+百度api进行视频的语音识别

简要回顾:识别结果还可以,中文标点符号比英文看起来更干净。显然,速度越慢,语音越标准,识别效果就越好(这是胡说八道)。你可以看到,当你在小潮面前认真谈论游戏规则时,识别结果是可以接受的,识别结果与你的嘴唇不一致。它对以中等速度说话的视频(特别是视频教程)很有帮助,但如果是一个小视频(特别是快节奏的视频),就别提了。

[En]

A brief review: the recognition results are OK, and it looks cleaner to put punctuation in Chinese than in English. Obviously, the recognition effect is better when the speed is slower and the speech is more standard (this is nonsense). You can see that when you seriously talk about the rules of the game in front of the neap tide, the recognition results are acceptable, and the identification results are out of line with your lips. It can be helpful for videos that speak at a moderate pace (especially for purpose: video tutorials), but if it’s a small video (especially one with a fast pace), forget it.

总体思路就是:Qt做个外壳,FFmpeg提取视频里的音频,百度api进行语音识别。由于百度开放的免费接口要求时长在1分钟以内,所以对于超过一分钟时长的音频,需要进行分段(顺便一提,免费接口使用量是中文普通话5w次,英文2w次)。下面对于各个部分的内容和遇到的(包括未处理完的)问题简单做一下记录。

以下是指向此实施的主要参考资料的链接:

[En]

The following are links to the main references for this implementation:

1、提供FFmpeg相关操作流程:
《使用 FFmpeg 进行音视频操作》,这个CSDN博客介绍了FFmpeg的主要模块、音视频解码与重采样等内容,主要都是文字介绍,具体代码实现也有一部分,有一定的参考价值(后面的记录仅写一些我的工作和问题吧,这个博客的内容不会转载的)。放下链接:
https://gitchat.csdn.net/activity/5d08d7d44ea36e699ecac739
2、提供百度API相关操作流程:
《Qt语音识别 | 百度语音识别应用》,这个B站视频介绍百度API的接口、使用Qt来调用百度API的方法,我的相关操作全部参考这个视频(因此后面的记录里代码部分不会太多,引用也经过老师同意),有兴趣的直接看视频吧。放下链接:
https://www.bilibili.com/video/BV19K411V79h

以下是上图所示的原始视频链接:

[En]

The following is the original video link shown above:

1、【冰冰vlog.001】带大家看看每个冬天我必去的地方
https://www.bilibili.com/video/BV1vy4y1i7bS
2、【名场面】17张牌你能秒我?你能秒杀我?你今天17张牌把卢本伟秒了,我当场就把这个电脑屏幕吃掉!
https://www.bilibili.com/video/BV1W4411r7ue
3、不要”做”挑战 ?
https://www.bilibili.com/video/BV1x7411Z7VA
4、[中英字幕]吴恩达机器学习系列课程
https://www.bilibili.com/video/BV164411b7dx

; 二、FFmpeg进行音频提取和重采样

关于FFmpeg的介绍、使用,可以直接看前言的链接,或者找其他教程,这里也直接梳理一下我们需要做的事情和以及整个过程:
1.对于视频文件,需要解封装,即分离出音频流或者视频流或者其他乱七八糟的东西。得到音频流参数,如声道数、采样率、采样格式等等。
2.解封装后的音频流,再进行解码,得到音频的实际采样数据。
3.设置重采样参数,分配存储重采样的数据空间。对于重采样参数,需要配合百度API的要求:单声道、采样率16000Hz、16bit量化。
4.读取原数据,将重采样后得到的数据,并将数据写入文件,建议直接pcm文件,简单粗暴。
5.释放之前申请的资源。

对于这部分,我们可以考虑封装成一个类ExtractAudio(请不要吐槽我的命名品味,真不会),方便调用和后续的查看,最开始调试时我就是直接全写在一个函数里面的,省事是省事,但是太长了会看得累。以下是代码(.cpp)部分:

void ExtractAudio::init()
{

    in_nb_samples = 1024;
    out_channel_layout = AV_CH_LAYOUT_MONO;
    out_sample_rate = SAMPLE_RATE;
    out_sample_fmt = AV_SAMPLE_FMT_S16;
}

AVFormatContext *ExtractAudio::open(QString inpath)
{
    av_register_all();
    AVDictionary *opts = NULL;
    AVFormatContext *format = avformat_alloc_context();

    QByteArray ba = inpath.toLocal8Bit();
    char* cpath = ba.data();

    int re = avformat_open_input(&format, (const char*)cpath, 0, &opts);
    if (re != 0)
        return NULL;
    else
        return format;
}

AVCodecContext *ExtractAudio::decodec(AVFormatContext *format)
{

    int re = avformat_find_stream_info(format, 0);
    if (re < 0)
        return NULL;

    for (int i = 0; i < format->nb_streams; i++)
    {
        AVStream *as = format->streams[i];

        if (as->codecpar->codec_type == AVMEDIA_TYPE_AUDIO)
        {
            audioStream = i;
            break;
        }
    }

    AVCodec *acodec = avcodec_find_decoder(format->streams[audioStream]->codecpar->codec_id);
    if (!acodec)
        return false;

    AVCodecContext *avctx = avcodec_alloc_context3(acodec);
    avcodec_parameters_to_context(avctx, format->streams[audioStream]->codecpar);
    avctx->thread_count = 8;
    re = avcodec_open2(avctx, 0, 0);
    if (re != 0)
        return NULL;

    return avctx;
}

SwrContext *ExtractAudio::initswr(AVCodecContext *avctx, uint8_t **out_data)
{

    SwrContext *swr = swr_alloc();
    in_channel_layout = avctx->channel_layout;
    in_sample_rate = avctx->sample_rate;
    in_sample_fmt = avctx->sample_fmt;

    av_opt_set_int(swr, "in_channel_layout", in_channel_layout, 0);
    av_opt_set_int(swr, "out_channel_layout", out_channel_layout, 0);
    av_opt_set_int(swr, "in_sample_rate", in_sample_rate, 0);
    av_opt_set_int(swr, "out_sample_rate", out_sample_rate, 0);
    av_opt_set_sample_fmt(swr, "in_sample_fmt", in_sample_fmt, 0);
    av_opt_set_sample_fmt(swr, "out_sample_fmt", out_sample_fmt, 0);
    swr_init(swr);
    if (!swr_is_initialized(swr))
        return NULL;

    out_nb_samples = av_rescale_rnd(in_nb_samples, out_sample_rate, in_sample_rate, AV_ROUND_UP);

    out_nb_channels = av_get_channel_layout_nb_channels(out_channel_layout);
    int re = av_samples_alloc_array_and_samples(&out_data, &out_linesize, out_nb_channels,
        out_nb_samples, out_sample_fmt, 0);
    if (re < 0)
        return NULL;

    return swr;
}

int ExtractAudio::resample(AVFormatContext *format, AVCodecContext *avctx,
    SwrContext *swr, uint8_t **out_data, AVFrame *frame, AVPacket *pkt)
{
    if (pkt->stream_index != audioStream)
        return 0;

    int gotFrame;
    if (avcodec_decode_audio4(avctx, frame, &gotFrame, pkt) < 0)
        return -1;
    if (!gotFrame)
        return 0;

    int frame_count = swr_convert(swr,
        out_data, out_nb_samples,
        (const uint8_t **)frame->data, in_nb_samples
    );
    if (frame_count < 0)
        return -1;

    out_bufsize = av_samples_get_buffer_size(&out_linesize, out_nb_channels, frame_count, out_sample_fmt, 1);
    av_packet_unref(pkt);
    av_frame_unref(frame);
    return out_bufsize;
}

void ExtractAudio::clear(AVFormatContext *format, AVCodecContext *avctx,
    SwrContext *swr, AVFrame *frame, AVPacket *pkt)
{

    avformat_close_input(&format);
    avcodec_close(avctx);
    swr_free(&swr);
    av_frame_free(&frame);
    av_packet_free(&pkt);
    av_free(frame);
    av_free(pkt);
}

但是这里虽然代码上释放了,占用空间并没有释放。我自己测试如果打开了一个2G的视频,即便将整个过程都跑完,引用计数也减了,free函数也用了,2G内存还是占着,吐血。所以每次感觉视频大小差不多了,就可以把应用关了重开吧。

三、对音频分段

得到重采样完的数据之后,就可以进行分段处理了。对于短语音识别,时长不能超过1分钟,我这里采用的方法就是,在从每段音频第30s处开始,一直到第60s前,计算1s以内采样值(绝对值)之和,和最小的地方,是我认为这个人声说话的停顿处。有几点补充就是,一是采样率已经默认好是16000Hz;二是每两次求和间的步进,我暂时默认为是0.01s,比如求完了第30s—第31s的和,下一次就求30.01s—31.01s的和。当然这个步进是可以进行变化的,但是个人认为没有必要使步进太小,计算次数变多后很慢(我做过步进是一个采样点的尝试,速度非常非常的慢)。

当然这个方法肯定并不是最优的,对于有BGM的视频来说,可能人不在说话,背景音乐还是有的,从一句话中间给掐断的可能性不是没有。另一个是参数的设置,这里面有很多参数是需要根据视频的情况的调整的,包括比如上面说的从第30s开始,可以换成别的数字;再比如计算1s以内的采样值之和,如果视频的节奏比较快(像小潮的一些视频)或者说话人语速感人,也可以调整;或者是步进等其他参数。但是我觉得我这里设置的参数还算中规中矩,也可以不变。对于这一部分,我们封装为SeparatePCM类。以下是代码(.cpp)部分:

#include "SeparatePCM.h"

#include <qdir.h>

#define SAMPLE_RATE 16000

SeparatePCM::SeparatePCM()
{

    QDir *folder = new QDir;
    folderStr = "D:\\temp\\temp\\";
    bool exist = folder->exists(folderStr);
    if (!exist)
    {
        folder->mkdir(folderStr);
    }
    delete folder;

    sample_rate = SAMPLE_RATE;
    sample_amount = 60 * sample_rate;
    start = 0;
    position = 0;
    best_position = 0;
    now_sum = 0;
    number = 1;

    step = 0.01 * sample_rate;
    threshold_len_silence = 1 * sample_rate;
    start_position = (long)sample_amount / 6 * 3;
}

SeparatePCM::~SeparatePCM()
{
}

bool SeparatePCM::open(QString inpath)
{
    filePath = inpath;
    QByteArray ba = filePath.toLocal8Bit();
    char* path = ba.data();

    FILE *file = fopen((const char*)path, "rb");
    if (!file)
        return false;

    fseek(file, 0, SEEK_END);

    fileLength = ftell(file);

    fclose(file);
    return true;
}

void SeparatePCM::execute()
{

    QByteArray ba = filePath.toLocal8Bit();
    char* path = ba.data();
    FILE *file = fopen((const char*)path, "rb");

    long bufferSize = fileLength / 2;

    if (bufferSize < sample_amount)
    {

        outpath = folderStr + pcmStr.arg(1);
        QFile::copy(filePath, outpath);
        fclose(file);
        return;
    }

    short *fileBuffer = new short[bufferSize];

    fread(fileBuffer, sizeof(short), bufferSize, file);

    short max_value = 0;
    for (long i = 0; i < bufferSize; i++)
    {
        if (abs(fileBuffer[i]) > max_value)
            max_value = abs(fileBuffer[i]);
    }

    min_sum = (long)threshold_len_silence * max_value;

    short *cutfileBuffer = new short[sample_amount];

    while (true)
    {

        for (position = start_position + start; position < (long)sample_amount + start - 1; position += step)
        {

            for (int i = 0; i < threshold_len_silence; i++)
            {
                now_sum = now_sum + (long)abs(fileBuffer[position - i]);
            }

            if (now_sum < min_sum)
            {
                min_sum = now_sum;

                best_position = position - (long)threshold_len_silence / 2;
            }
            now_sum = 0;
        }

        copyData_and_writeFile(fileBuffer, cutfileBuffer, best_position - start + 1);

        start = best_position + 1;
        number++;
        if (start > bufferSize - sample_amount)
        {

            copyData_and_writeFile(fileBuffer, cutfileBuffer, bufferSize - start + 1);
            break;
        }

        now_sum = 0;
        min_sum = (long)threshold_len_silence * max_value;
    }
    delete[] cutfileBuffer;
    delete[] fileBuffer;

    fclose(file);

    QFile fileTemp(filePath);
    fileTemp.remove();
    fileTemp.close();
}

void SeparatePCM::copyData_and_writeFile(short *fileBuffer, short *cutfileBuffer, int len_cut)
{
    short *pfile = NULL;

    pfile = fileBuffer + start;
    memcpy(cutfileBuffer, pfile, len_cut * 2);

    outpath = folderStr + pcmStr.arg(number);
    QByteArray qba = outpath.toLocal8Bit();
    char *cpath = qba.data();
    FILE *cfile = fopen((const char*)cpath, "wb");
    fwrite(cutfileBuffer, sizeof(short), len_cut, cfile);
    fclose(cfile);
}

四、百度api调用

这里也不再多说,请全部参考上文的B站视频吧,代码也不放了,基本是一模一样的。唯一的区别是我加上了”中文”或者”英文”的判断,在url里改变pid=1537或者1737。在这基础上,封装成了一个WriteText类。以下是代码(.cpp)部分:

#include "WriteText.h"
#include "Speech.h"
#include <qdir.h>
#include <qfile.h>
#include <qiodevice.h>

WriteText::WriteText()
{
}

WriteText::~WriteText()
{
}

void WriteText::execute(QString fileName, int id)
{
    QFile file(fileName);
    file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append);

    QStringList filter;

    filter << QString("*.pcm");

    QString folderStr = "D:\\temp\\temp\\";

    QDir dir(folderStr);
    dir.setNameFilters(filter);
    QFileInfoList fileInfoList = dir.entryInfoList(filter);
    int dir_count = fileInfoList.count();
    QString pcmFileName("%1.pcm");
    QString fullFileName;

    for (int i = 0; i < dir_count; i++)
    {

        fullFileName = folderStr + pcmFileName.arg(i + 1);

        Speech m_speech;
        QString str = m_speech.speechIdentify(fullFileName, id);

        QTextStream txtStream(&file);
        txtStream << str << "\n";

        QFile fileTemp(fullFileName);
        fileTemp.remove();
        fileTemp.close();
    }
    file.close();

    dir.removeRecursively();
}

另外在提醒一点就是,调用api之前,一定要先确保自己的免费额度已经领取(如下图),否则调用api失败的同时貌似还占用了次数(我也不太清楚),反正就是算是个坑吧,我就找了半天错误,查了好久才发现是这里出错了QAQ,错误码3304。

假期之不务正业—— Qt+FFmpeg+百度api进行视频的语音识别

五、Qt编程的一些补充

1、Qt在打开文件时,可能面对一些带有中文的字符串,我的方法是在需要支持中文的cpp最开始进行以下声明:


#if defined(_MSC_VER) && (_MSC_VER >= 1600)
pragma execution_character_set("utf-8")
#endif

然后在构造函数里添加:


QTextCodec *codec = QTextCodec::codecForName("GBK");
QTextCodec::setCodecForLocale(codec);

即可。
当然GBK是windows系统下的,如果跨平台的话还需要找其他编码。

2、整个流程执行下来速度不算慢,但是也需要等待,这个时候肯定是要把运算的流程放入运算线程里面防止界面卡死。创建自定义线程类MyThread,继承于QThread,重写run函数,并定义bool值判断线程结束与否。先放代码:
MyThread.h:

#ifndef MYTHREAD_H
#define MYTHREAD_H
#include <QThread>
#include <QFileInfo>
#include <QMessageBox>
#include <QTextCodec>
#include <QFile>

#include "ExtractAudio.h"
#include "SeparatePCM.h"
#include "WriteText.h"

class QString;

class MyThread : public QThread
{
    Q_OBJECT
public:
    MyThread();

    void setMessage(const QStringList &message);
    void setLanguage(int id);
    void stop();

protected:
    void run();

    void extracrAudio(QString strInPath, QString strOutPath);
    QString separatePCM(QString strInPath);
    void writeText(QString strInPath);

private:
    QStringList str_path_list;
    int languageId;
    volatile bool m_Stopped;

signals:
    void updateProgress(int);
    void updateLabel(QString);
};

#endif

MyThread.cpp:

#include "mythread.h"
#include <iostream>
using namespace std;

#if defined(_MSC_VER) && (_MSC_VER >= 1600)
pragma execution_character_set("utf-8")
#endif

MyThread::MyThread()
{
    m_Stopped = false;

    QTextCodec *codec = QTextCodec::codecForName("GBK");
    QTextCodec::setCodecForLocale(codec);
}

void MyThread::setMessage(const QStringList &message)
{
    str_path_list = message;
}

void MyThread::setLanguage(int id)
{
    languageId = id;
}

void MyThread::stop()
{
    m_Stopped = true;
}

void MyThread::run()
{
    while (!m_Stopped)
    {

        QString strShowLabel;
        for (int i = 0; i < str_path_list.size(); i++)
        {
            QString inPath = str_path_list[i];
            QFileInfo fileInfo = QFileInfo(inPath);
            QString file_name = fileInfo.fileName();
            QString fileSuffix = fileInfo.suffix();

            strShowLabel = "正在处理:" + file_name;
            emit updateLabel(strShowLabel);

            QString outPcmName = file_name.replace(fileSuffix, "pcm");
            QString outPcmPath = "D:\\temp\\" + outPcmName;
            QString outTextName = file_name.replace("pcm", "txt");
            QString outTextPath = "D:\\temp\\" + outTextName;

            extracrAudio(inPath, outPcmPath);
            QString temppath = separatePCM(outPcmPath);
            writeText(outTextPath);
            cout << endl;

            int v = 100 * (i + 1) / str_path_list.size();
            emit updateProgress(v);
        }
        str_path_list.clear();
        strShowLabel = tr("处理结束!");
        emit updateLabel(strShowLabel);
    }

    m_Stopped = false;
}

void MyThread::extracrAudio(QString strInPath, QString strOutPath)
{

    uint8_t **out_data;
    int GroupSize = 1;
    int innerSize = 60 * 16000 * 2;
    int maxbufferSize = 0;
    out_data = (uint8_t**)malloc(sizeof(uint8_t*)*GroupSize);
    for (int i = 0; i < GroupSize; i++)
    {
        out_data[i] = (uint8_t*)malloc(sizeof(uint8_t)*innerSize);
    }

    ExtractAudio ea;
    ea.init();

    AVFormatContext *format = ea.open(strInPath);
    if (!format)
    {
        QMessageBox::warning(NULL, "提示", "打开文件失败!");
        return;
    }
    cout << "Open file successed!" << endl;

    AVCodecContext *avctx = ea.decodec(format);;
    if (!avctx)
    {
        QMessageBox::about(NULL, "提示", "解码失败!");
        return;
    }
    cout << "Decodec successed!" << endl;

    SwrContext *swr = ea.initswr(avctx, out_data);
    if (!swr)
    {
        QMessageBox::about(NULL, "提示", "音频重采样初始化失败!");
        return;
    }
    cout << "Initswr successed!" << endl;

    AVFrame *frame = av_frame_alloc();
    AVPacket *pkt = av_packet_alloc();
    int bufferSize = 0;

    QFile outFile(strOutPath);
    outFile.open(QIODevice::WriteOnly);

    while (av_read_frame(format, pkt) >= 0)
    {

        bufferSize = ea.resample(format, avctx, swr, out_data, frame, pkt);

        if (bufferSize > 0)
            outFile.write((const char*)out_data[0], bufferSize);
        else if (bufferSize == 0)
            continue;
        else
        {
            QMessageBox::about(NULL, "提示", "音频重采样失败!");
            break;
        }
    }
    outFile.close();

    ea.clear(format, avctx, swr, frame, pkt);
    cout << "ExtracrAudio Finish!" << endl;

    for (int i = 0; i < GroupSize; i++)
    {
        free(out_data[i]);
    }
    free(out_data);
}

QString MyThread::separatePCM(QString strInPath)
{
    SeparatePCM sp;
    bool flag = sp.open(strInPath);
    if (!flag)
    {
        QMessageBox::warning(NULL, "提示", "打开音频文件失败!");
        return NULL;
    }
    sp.execute();
    return sp.folderStr;
    cout << "SeparatePCM Finish!" << endl;
}

void MyThread::writeText(QString strInPath)
{
    WriteText wt;
    wt.execute(strInPath, languageId);
    cout << "WriteText Finish!" << endl;
}

线程函数里,两个信号void updateProgress(int)和void updateLabel(QString)用来更新界面的进度条和便签。在MyThread里面发送信号后,在界面连接信号和槽:

connect(&m_thread, SIGNAL(updateProgress(int)), this, SLOT(updateProgress(int)));
connect(&m_thread, SIGNAL(updateLabel(QString)), this, SLOT(updateLabel(QString)));

其中信号是MyThread的信号(signals),槽是界面的槽(slots)。
如果接口向线程发送参数,则直接调用线程中的函数。例如,界面中有两个单选按钮,提供选择中文或英文的功能,并将两者组合为一个组合:

[En]

If the interface sends parameters to the thread, the function in the thread is called directly. For example, there are two radio buttons in the interface to provide the function of selecting “Chinese” or “English”, and combine the two into one combination:


groupButton = new QButtonGroup(this);
groupButton->addButton(ui.rbtn_Chinese, 0);
groupButton->addButton(ui.rbtn_English, 1);
ui.rbtn_Chinese->setChecked(true);

当我们单击Start按钮时,我们需要确定选择了哪个单选按钮,并将结果传递给计算线程:

[En]

When we click the start button, we need to determine which radio button is selected and pass the result to the computing thread:

int id = groupButton->checkedId();
m_thread.setLanguage(id);

上述的void setLanguage(int id)是线程类里的一个公共函数,直接在界面里面调用即可。把界面所确定的文件列表传递给线程类也是同理。

六、结语

内容差不多就这些了,也都是一些很新手的东西,非常欢迎大佬们给出一些好的建议(尤其是FFmpeg释放内存那里,能连带解决方案就更好了),demo就不放出来了,弄了一个半成品再放出来就觉得很惭愧。

计划完成后,我们会利用各种假期时间,每年聚在一起做点小事,同时更新这一系列。做什么取决于你的大脑和情绪。不管怎么说,现在是放假的时候,你不用工作。如果你有什么好的想法,欢迎你一起学习和做。

[En]

After the plan, we will use all kinds of holiday time to get together every year to make a little thing, and update this series at the same time. What direction to do depends on your brain and mood. Anyway, it is the holiday time that you don’t do your work. If you have any good ideas, you are welcome to study and do together.

Original: https://blog.csdn.net/operation_x/article/details/113802403
Author: 卤蛋,冲锋!
Title: 假期之不务正业—— Qt+FFmpeg+百度api进行视频的语音识别

原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/515186/

转载文章受原作者版权保护。转载请注明原作者出处!

(0)

大家都在看

亲爱的 Coder【最近整理,可免费获取】👉 最新必读书单  | 👏 面试题下载  | 🌎 免费的AI知识星球