手把手帮你视频转文本(1-视频转音频)

本系列将介绍如何一步步实现将mp4视频中的语音对话,自动转换为文本,并输出到word文档中。这里第一篇,先完成视频转音频处理。本项目全部代码也已经全部开源到码云(https://gitee.com/coolpine/thomas),可直接下载试用。

总体技术架构

下图是整体转换流程:


image-20200608233112583
  1. 先将mp4视频文件,通过ffmpeg工具库,批量转换为pcm音频文件(语音识别服务仅支持该格式)
  2. 基于百度云的技术,将pcm文件上传到百度对象存储BOS中,并将日志等记录到本地mysql数据库。
  3. pcm文件上传完毕后,调用免费的语音识别(录音转写)服务,创建离线录音转写任务。
  4. 查询转写成功的任务,并将相关转写结果存储到本地mysql库中。
  5. 基于docx4j库,将数据库中的录音转写结果,导出为规范化的word文档。

转换结果示例

我们这里实现的是将 《托马斯和他的朋友们第18季》20集MP4视频,最终转换为一个word故事文档:

Image 1591634632

下面是第一集具体对话文本表格:

Image 1591634654

视频转音频

视频转音频基于ffmpeg库来实现。ffmpeg是一个强大的跨平台音视频记录、转换方案(官网说法:A complete, cross-platform solution to record, convert and stream audio and video)

ffmpeg主要是以命令行模式来实现音视频转换和处理,我们这里实现的功能有:

  • 将mp4文件中片头和片尾音乐剔除,截取中间片段。
  • 将截取后的mp4文件,转换为pcm文件。
  • 基于ffplay验证pcm可播放情况。

截取mp4文件中间片段的命令基本格式为:

ffmpeg -ss [start] -i [input] -t [duration] -c copy [output]
ffmpeg -ss [start] -i [input] -to [end] -c copy [output]

# 例如,以下是将t1801.mp4文件,截取从第30秒开始,截止到524秒,并保存为c1-1801.mp4文件:
ffmpeg -y -ss 30 -i t1801.mp4 -to 524 -c copy c1-1801.mp4

将mp4文件转换为pcm音频文件命令参数:

-i 输入文件
-an 去除音频流
-vn 去除视频流
-acodec 设置音频编码
-f 强制指定输入或输出文件的编码
-ac 设置音频轨道数
-ar 设置音频采用频率
-y 不经过确认,直接覆盖同名文件

# 例如,以下是将t1801.mp4文件,去除视频流并用pcm_s16le进行音频编码,输出文件也采用s16le编码,同时音轨为1且采样频率为16000:
ffmpeg -i t1801.mp4 -vn -acodec pcm_s16le -f s16le -ac 1 -ar 16000 t1801.pcm

用ffplay播放pcm文件:

ffplay -ar 16000 -ac 1 -f s16le -i t1801.pcm

更多ffmpeg命令使用,参见官方文档:https://ffmpeg.org/ffmpeg.html

Java音视频处理

以上只是验证了在命令行模式下,基于ffmpeg进行基本音视频操作。因为要进行批量处理,我们还需要用编程的方式来调用ffmpeg:

  1. 基于org.bytedeco的ffmpeg和ffmpeg-platform来实现用java调用ffmpeg。
  2. 因为每集视频的片头和片尾歌曲时长基本固定,但每集视频总时长不一样,通过org.mp4parser的isoparser库实现读取每集总时长,动态拼装转换命令。

以下是引入的基本依赖:

<!--实现对视频文件读取-->
<dependency>
    <groupId>org.mp4parser</groupId>
    <artifactId>isoparser</artifactId>
    <version>1.9.41</version>
</dependency>
<!--实现对ffmpeg的操作-->
<dependency>
    <groupId>org.bytedeco</groupId>
    <artifactId>ffmpeg</artifactId>
    <version>4.2.2-1.5.3</version>
</dependency>
<dependency>
    <groupId>org.bytedeco</groupId>
    <artifactId>ffmpeg-platform</artifactId>
    <version>4.2.2-1.5.3</version>
</dependency>

以下是基于isoparser,读取MP4文件的总时长(秒数):

public long readDuration(Path mp4Path) {
    if (Files.notExists(mp4Path) || !Files.isReadable(mp4Path)) {
        log.warn("文件路径不存在或不可读 {}", mp4Path);
        return 0;
    }
    try {
        IsoFile isoFile = new IsoFile(mp4Path.toFile());
        long duration = isoFile.getMovieBox().getMovieHeaderBox().getDuration();
        long timescale = isoFile.getMovieBox().getMovieHeaderBox().getTimescale();
        return duration / timescale;
    } catch (IOException e) {
        log.error("读取MP4文件时长出错", e);
        return 0;
    }
}

以下是将MP4文件进行截取,并转换为PCM文件:

/**
 * 将单个PM4文件进行片头和片尾歌曲删除后,转换为PCM文件
 *
 * @param mp4Path
 * @param pcmDir
 * @return 转换完成后的pcm文件路径
 */
public Optional<String> convertMP4toPCM(Path mp4Path, Path pcmDir) {
    long seconds = readDuration(mp4Path);
    if (seconds == 0) {
        log.warn("文件总时长为0");
        return Optional.empty();
    }
    String ffmpeg = Loader.load(org.bytedeco.ffmpeg.ffmpeg.class);
    String endTime = String.valueOf(seconds - 100 - 30);
    File src = mp4Path.toFile();
    //在当前源mp4文件目录下生成临时文件
    String mp4TempFile = src.getParent() + "\\" + System.currentTimeMillis() + ".mp4";
    //基于ffmpeg进行截取
    ProcessBuilder cutBuilder = new ProcessBuilder(ffmpeg, "-ss", "30", "-i", mp4Path.toAbsolutePath().toString(),
            "-to", endTime, "-c", "copy", mp4TempFile);
    try {
        cutBuilder.inheritIO().start().waitFor();
    } catch (InterruptedException | IOException e) {
        log.error("ffmpeg截取MP4文件出错", e);
        return Optional.empty();
    }
    // 基于ffmpeg进行pcm转换
    // 基于输入路径的md5值来命名,也可以基于系统时间戳来命名
    String pcmFile = pcmDir.resolve(DigestUtils.md5Hex(mp4Path.toString()) + ".pcm").toString();
    ProcessBuilder pcmBuilder = new ProcessBuilder(ffmpeg, "-y", "-i", mp4TempFile, "-vn", "-acodec", "pcm_s16le",
            "-f", "s16le", "-ac", "1", "-ar", "16000", pcmFile);
    try {
        //inheritIO是指将 子流程的IO与当前java流程的IO设置为相同
        pcmBuilder.inheritIO().start().waitFor();
    } catch (InterruptedException | IOException e) {
        log.error("ffmpeg将mp4转换为pcm时出错", e);
        return Optional.empty();
    }
    // 删除MP4临时文件
    try {
        Files.deleteIfExists(Paths.get(mp4TempFile));
    } catch (IOException e) {
        log.error("删除mp4临时文件出错", e);
    }
    //返回pcm文件路径
    return Optional.of(pcmFile);
}

调用上述单个文件的处理方法,实现批量文件处理和转换:

/**
 * 批量将MP4文件转换为PCM文件
 *
 * @param rootDir
 * @param pcmDir
 * @return 成功转换的PCM文件数
 */
public int batchConvertMP4toPCM(Path rootDir, Path pcmDir) {
    if (Files.notExists(rootDir) || !Files.isDirectory(rootDir)) {
        log.warn("mp4文件目录{}不存在", rootDir);
        return 0;
    }

    if (Files.notExists(pcmDir)) {
        //级联创建目录
        try {
            Files.createDirectories(pcmDir);
        } catch (IOException e) {
            log.error("创建文件夹出错", e);
        }
    }
    AtomicInteger pcmCount = new AtomicInteger(0);
    //遍历rootdir,获取所有目录下子目录和文件
    try {
        Files.list(rootDir).forEach(path -> {
            if (Files.isDirectory(path)) {
                //递归遍历下级目录
                pcmCount.getAndAdd(batchConvertMP4toPCM(path, pcmDir));
            }
            if (Files.isRegularFile(path) && Files.isReadable(path) && path.getFileName()
                    .toString()
                    .endsWith("mp4")) {
                Optional<String> pcmFile = this.convertMP4toPCM(path, pcmDir);
                if (pcmFile.isPresent()) {
                    pcmCount.getAndIncrement();
                }
            }
        });
    } catch (IOException e) {
        log.error("批量将MP4文件转换为PCM文件出错", e);
    }

    return pcmCount.get();
}

单个文件转换调用测试:

@Test
void cutTest() {
    String file = "D:\\dev2\\project\\thomas\\local\\videos\\t1801.mp4";
    String pcmdir = "D:\\dev2\\project\\thomas\\local\\videos\\pcm";
    Path path = Paths.get(file);
    util.convertMP4toPCM(path, Paths.get(pcmdir));
}

批量文件转换测试:

@Test
void batchTest() {
    Path root = Paths.get("D:\\dev2\\project\\thomas\\local\\videos\\第18季");
    Path pcmDir = Paths.get("D:\\dev2\\project\\thomas\\local\\videos\\pcm");
    int pcmFiles = util.batchConvertMP4toPCM(root, pcmDir);
    log.info("转换出PCM文件数{}", pcmFiles);
}

至此,读取mp4文件,转换为pcm文件并剔除片头和片尾,就基本完成了,接下来将为你介绍如何基于百度云SDK和API实现语音转录。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,919评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,567评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,316评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,294评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,318评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,245评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,120评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,964评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,376评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,592评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,764评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,460评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,070评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,697评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,846评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,819评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,665评论 2 354