好家伙 | 自己写一个视频字幕提取工具

好家伙 | 自己写一个视频字幕提取工具

免责声明

本文旨在记录技术学习过程,无意破坏试验APP,工具不可使用于商业和个人其他意图,若使用不当,均由个人承担。

0x1、唠下嗑

胖友,还记得写完没多久的年终总结吗?有没有开始着手去实施清单上的一些事情呢?顺带提醒下:2021年已过7.6%

上一年,一直想写的两个工具:Markdown转换工具视频字幕提取工具,前者在12月底肝出来:

《公号文章排版利器》

后者顺延到了今年,前段时间突发奇想,开始折腾,无奈事情实在太多,一直搁置,零零星星凑时间,总算肝完,当然依旧简陋~

接着说说为啥要做这个工具~

主线剧情

一直觉得自己并不能很好的控制自己的情绪,易怒,很多人情世故大大道理也不懂,以致于妻子常说:你这个人奇奇怪怪的

意识到问题,肯定要想办法改正,于是开始搜索学习这方面的资料,机缘巧合下,在B站搜到了曾老的视频:

如获至宝,看完一集后就喜欢上了这个风趣幽默的小老头子,尔后挤1号线早高峰都会带上耳机闭目听上一集。

每次听完都如沐春风,啧啧啧,妙啊,但这种收益的半衰期往往很短,没过多久就忘了。

人生哲理是要反复咀嚼的,温故知新的道理谁都懂,但每次温习要重新听上30分钟,在这个知识爆炸的时代,有点不切实际。

而实际上,记住道理知道故事 就好,于是乎我开始边听边做记录,手敲码字到便签中,下班回家整理。

但,这种 拙劣 的记录手法,让这件原本有趣的事情变得黯然无光:

听两句 → 暂停下视频 → 码字 → 码完切回去继续听,如此循环往复,有时没听清的还要划回去听几遍,以往一趟早高峰能听完一集的,现在要花上2-3趟。

而实际上,搞到字幕 就好,毕竟字幕就是记录他讲的话,网上搜下字幕?

现实是视频年代久远,很难找到字幕文件,要么是别人边听边做笔记的,内容不全。

得想办法另辟蹊径,一个「动手能力较强」的开发仔脑海闪过这样一个念头:我自己写个字幕提取工具

立马想到两个大方向:OCR文字识别音频转文字,简单说下大体思路:

OCR文字识别

  • 1、按秒进行帧提取;
  • 2、裁剪图片特定区域(字幕文字一般固定显示在某个区域内);
  • 3、识别前的图像处理(转灰度处理、二值化处理等);
  • 4、利用开源库或第三方API进行OCR文字识别;
  • 5、将多张图片的识别结果拼接得出字幕文件;

音频转文字

  • 1、将视频转换为wav格式(大部分识别库和第三方API支持此格式);
  • 2、按照特定时长将音频分割为多个小片段(有些SDK支持60s以内的音频识别);
  • 3、利用开源库或第三方API进行音频识别;
  • 4、将多段音频的识别结果拼接得出字幕文件;

逻辑思路非常清晰,当然说是这么说,实现起来肯定是踩坑不断的。

* 隐藏剧情

上面这两种让字幕无中生有的方法,不改叫字幕提取,而该叫字幕生成了,说到这里,我又想到了一件事:

我有个朋友,在观摩霓虹爱情动作大片时,语言不通,只能通过激烈的肢体碰撞去领会导演想表达的意图,但也导致了他在尚未了解清楚剧情的情况下,就草草了事。

有了这个工具,就不存在不知道在说啥的问题了,在观摩对抗的同时,也可以进一步揣测演员的心理活动,妙啊

本节就以B站视频为例,开发一个字幕提取工具。

0x2、视频下载

在开始视频提取前,先要把视频搞到本地,通用的方法有如下四种:

  • 1、用各种下载器直接下,如闪豆视频下载器,bilibili视频下载器,唧唧down,油条脚本等;
  • 2、老牌Python网站下载工具 you-get 直接下;
  • 3、破解B站视频规则,获取视频源URL进行下载;
  • 4、取巧,模拟请求第三方解析网站,爬取解析结果获取源地址;

此处只对有点技术含量的方法2、3进行实践。

1、you-get库

you-get 支持油管、推特、汤不热、B站等站点的视频下载,更多支持站点及其他说明自行查阅:

官方中文Wiki

pip命令直接装:

pip install you-get
复制代码

如遇下载很慢,可以试试添加镜像源,如:

pip install you-get -i https://pypi.tuna.tsinghua.edu.cn/simple
复制代码

后面装库的时候慢都可以这样操作,安装完后,随便找个B站视频链接,打开命令行键入:

you-get -i 链接地址
复制代码

可查看视频的所有可用画质及格式,标有DEFAULT的为默认画质:

除了可以在命令行下载外,也可以在Python中调用 you-get.common.any_download() 来下载:

import os
from you_get import common as you_get

url = "https://www.bilibili.com/video/BV1eh41127Ma"
you_get.any_download(url=url, info_only=False, output_dir=os.getcwd(), merge=True)
复制代码

运行后,控制台会输出视频的下载进度:

接着有些视频(如新番),需要大会员才能观看下载,直接下载会报错:

如果你有B站大会员,可在下载时设置下Cookie,此处使用Chrome插件 Get cookies.txt 插件导出Cookie文件。登录完你的B站账号后,用它来导出Cookies:

接着把导出的文件放到代码文件的同一目录下,此处笔者把文件重命名为bilibili.txt,然后下载前设置下即可:

import os
from you_get import common as you_get

url = "https://www.bilibili.com/bangumi/play/ss5978"
you_get.load_cookies("bilibili.txt")
you_get.any_download_playlist(url=url, cookies="bilibili.txt", info_only=False, output_dir=os.getcwd(), merge=True)
复制代码

运行后,大会员番剧也能正常下载了:

Tips:顺带吐槽网上一堆互相copy没有校验过直接传cookies参数误人子弟的教程。

2、获取视频源地址 + IDM下载

you-get 其实够用的,不过前几天不知道咋回事,下一个视频只有几十k的速度,所以写下备用方案。

IDM(Internet Download Manager) Windows 下载神器,不用怎么介绍了吧,没听过的自行百度,Mac玩家可用Aria2代替。

这里要做的事情就是获取B站视频的源地址,然后用IDM进行下载。

① 获取视频下载源地址

F12打开开发者工具,切换到Network选项卡,过滤Doc类型的请求,刷新下:

用requests库模拟请求一波,请求头只设置下User-Agent:

import requests as r

url = "https://www.bilibili.com/video/BV1mE411R71f"
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
                  'Chrome/83.0.4103.97 Safari/537.36'
}

if __name__ == '__main__':
    resp = r.get(url=url, headers=headers)
    print(resp.text)
复制代码

控制台输出信息如下:

可以,接着定位下视频,点击播放视频,Network选项卡输出一堆这个:

查看其中一个请求的URL:

https://xy113x100x172x188xy.mcdn.bilivideo.cn:4483/upgcxcode/93/92/120309293/120309293-1-30216.m4s?expires=1610969512&platform=pc&ssig=DPtz9VyD7TVRwOtle4ARXw&oi=2005480736&trid=d8bdd82a47d34a1e914b7de07cc20946u&nfc=1&nfb=maPYqpoel5MI3qOUX6YpRA==&mcdnid=1001203&mid=67402819&orderid=0,3&agrr=1&logo=A0000001
复制代码

看到这里的 .m4s 没,它存储的是MP4视频的片段,用于组成HTML5视频播放器的视频流。

点开其中一个,看下请求详情:

em?请求头中的 range 字段指定了视频段的范围,响应头中的 Content-Range 字段可以拿到总的视频总长度。

能不能模拟请求先拿到视频总长度,再range=0-视频总长度,以此拿到完整视频?

等下再验证下这个猜测,接着看下哪里能拿到视频源的URL,此处请求另外一个地址(覆盖尽可能多的清晰度):

https://www.bilibili.com/video/BV1554y1s7qG
复制代码

模拟请求一波,把响应HTML复制到PyCharm中,格式化下,搜索.m4s,直接定位到了:

复制下这段Json,放到格式化工具中,搜下视频URL,定位到了如下两个字段:

值一样却有两种命名方式的字段,估计是新旧接口兼容,video 下这样的数据有好几个,对应不同清晰度的视频。

外层 support_formats 中的 quality 清晰度对应此视频id,除此之外另一个字段 audio 引起了我的注意:

B站音频和视频是分开的,早有耳闻,难道真的是这样吗?用idm简单验证下:

哦吼,视频果真有两段:

打开后果真如此,小问题,直接用 ffmpeg 合成一波即可。另外,如果不想自行合成,可以下载 Flv格式 的视频。将播放器切换到Flash播放器:

用idm简单验证下,下载到本地后打开,有声音:

接着搜下.flv,在如下链接:

https://api.bilibili.com/x/player/playurl?cid=143182635&fnver=0&otype=json&bvid=BV1RJ41177XR&player=1&fnval=0&qn=0&avid=83697364
复制代码

找到了资源的url:

接着拆上面的url,bvid 对应 bv号avid 对应 av号,就不用说了,然后到这个cid,在html搜索这个值:

好家伙,这里可以拿到三个参数,接着看下 qn,猜测是 清晰度,改成80试试看:

改回0看看:

好吧,果然是清晰度,0就是默认的,其他的参数默认就好,接着尝试下直接浏览器打开这个URL:

请求后403,百度了一波,发现可能是为了防盗播,需要为请求设置 referrer 请求头,指向播放页地址即可。

用requests写个代码试试:

import requests as r

if __name__ == '__main__':
    download_url = "https://upos-sz-mirrorkodo.bilivideo.com/upgcxcode/35/26/143182635/143182635_da2-1-64.flv?e" \
                   "=ig8euxZM2rNcNbKBhwdVhoMM7WdVhwdEto8g5X10ugNcXBlqNxHxNEVE5XREto8KqJZHUa6m5J0SqE85tZvEuENv" \
                   "No8g2ENvNo8i8o859r1qXg8xNEVE5XREto8GuFGv2U7SuxI72X6fTr859r1qXg8gNEVE5XREto8z5JZC2X2gkX5L5F" \
                   "1eTX1jkXlsTXHeux_f2o859IB_&uipk=5&nbs=1&deadline=1611047620&gen=playurl&os=kodobv&oi=20054" \
                   "80449&trid=95df7eeb4c384372bf7a106860d1aa3eu&platform=pc&upsig=4e2a26137f1fe3a58cc3255cf3e" \
                   "c25cc&uparams=e,uipk,nbs,deadline,gen,os,oi,trid,platform&mid=67402819&orderid=0,3&agrr=" \
                   "1&logo=80000000"
    referer_url = "https://www.bilibili.com/video/BV1RJ41177XR"
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
                      'Chrome/83.0.4103.97 Safari/537.36',
        'Referer': referer_url,
        'Origin': 'https://www.bilibili.com'
    }
    resp = r.get(url=download_url, headers=headers)
    with open("video.flv", "wb+") as f:
        f.write(resp.content)
        f.close()
        print("视频保存成功")
复制代码

运行后静待片刻可以看到视频已经下载到本地了:

同样试试两个**.m4s**能否这样操作:

好家伙,都不用设置下载视频段的范围了,直接是完整视频,然后就是一些URL提取细节的东西,然后把音视频用ffmpeg命令合并一波即可,比较简单零碎,就不一步步讲解了,直接给出完整代码:

# -*- coding: utf-8 -*-
# !/usr/bin/env python
"""
-------------------------------------------------
   File     : bilibli_video_download.py
   Author   : CoderPig
   date     : 2021-01-18 23:58 
   Desc     : B站视频下载
-------------------------------------------------
"""
import requests as r
import http.cookiejar
import cp_utils
import re
import json
import time
import subprocess

# 提取视频信息的正则
play_info_pattern = re.compile(r'window\.__playinfo__=(\{.*?\})</script>', re.MULTILINE | re.DOTALL)
initial_state_pattern = re.compile(r'window\.__INITIAL_STATE__=(\{.*?\});', re.MULTILINE | re.DOTALL)

# 请求头
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
                  'Chrome/83.0.4103.97 Safari/537.36',
    'Origin': 'https://www.bilibili.com'
}

# Cookies文件
cookies_file = 'bilibili.txt'
flv_player_playurl = 'https://api.bilibili.com/x/player/playurl'


# B站视频类
class BVideo:
    def __init__(self, title=None, cid=None, bvid=None, avid=None, quality=0, flv_url=None, mp4_url=None, wav_url=None,
                 merge_video=None):
        self.title = title
        self.cid = cid
        self.bvid = bvid
        self.avid = avid
        self.quality = quality
        self.flv_url = flv_url
        self.mp4_url = mp4_url
        self.wav_url = wav_url
        self.merge_video = merge_video


# 获取mp4资源数据
def fetch_mp4_data(url):
    if headers.get('Referer') is not None:
        headers.pop('Referer')
    b_video = BVideo()
    resp = r.get(url=url, headers=headers, cookies=cookies)
    print("请求:", resp.url)
    play_info_result = play_info_pattern.search(resp.text)
    if play_info_result is not None:
        data_json = json.loads(play_info_result.group(1))
        b_video.mp4_url = data_json['data']['dash']['video'][0]['baseUrl']
        b_video.wav_url = data_json['data']['dash']['audio'][0]['baseUrl']
    initial_result = initial_state_pattern.search(resp.text)
    if play_info_result is not None:
        data_json = json.loads(initial_result.group(1))
        video_data = data_json['videoData']
        b_video.title = video_data['title']
        b_video.avid = video_data['aid']
        b_video.bvid = video_data['bvid']
        b_video.cid = video_data['cid']
    return b_video


# 获取flv资源数据
def fetch_flv_data(b_video):
    params = {
        'qn': 0,
        'fnval': 0,
        'player': 1,
        'fnver': 0,
        'otype': 'json',
        'avid': b_video.avid,
        'bvid': b_video.bvid,
        'cid': b_video.cid
    }
    resp = r.get(flv_player_playurl, params=params, headers=headers, cookies=cookies)
    print("请求:", resp.url)
    if resp is not None:
        resp_json = resp.json()
        if 'data' in resp_json:
            b_video.flv_url = resp_json['data']['durl'][0]['url']
    return b_video


# 普通方式下载资源
def download_normal(url, referer_url, file_type):
    headers['Referer'] = referer_url
    print("下载:", url)
    resp = r.get(url=url, headers=headers)
    file_name = '{}.{}'.format(str(int(round(time.time() * 1000))), file_type)
    with open(file_name, "wb+") as f:
        f.write(resp.content)
        print("下载完成:", resp.url)
    return file_name


# 合并音视频
def merge_mp4_wav(video_path, audio_path, output_path):
    print("音视频合并中~")
    cmd = f'ffmpeg -i {video_path} -i {audio_path} -acodec copy -vcodec copy {output_path}'
    subprocess.call(cmd, shell=True)
    print("合并完毕~")


if __name__ == '__main__':
    cookies = http.cookiejar.MozillaCookieJar(cookies_file) \
        if cp_utils.is_dir_existed(cookies_file, mkdir=False) else None
    video_url = input("请输入想下载的视频链接:\n")
    print("提取视频信息...")
    video = fetch_flv_data(fetch_mp4_data(video_url))
    user_choose = input("\n请输入下载资源的序号:\n1、mp4 \n2、flv\n")
    if user_choose == '1':
        mp4_path = download_normal(video.mp4_url, video_url, 'mp4')
        wav_path = download_normal(video.wav_url, video_url, 'wav')
        print("音视频下载完毕,开始合并资源")
        merge_mp4_wav(mp4_path, wav_path, "after_{}.mp4".format(str(int(round(time.time() * 1000)))))
    elif user_choose == '2':
        flv_path = download_normal(video.mp4_url, video_url, 'flv')
    else:
        print("错误输入")
        exit(0)
复制代码

运行后,随便贴入一个B站视频url的链接,然后按需下载视频即可。

② 调用IDM下载视频

使用requests下载有点慢,而且进度无法感知,一些高清的长视频有点慢,如:

如果你跟笔者一样安装了IDM,可以pip装一波 idm 模块

pip install idm
复制代码

接着代码中调用一波,具体用法可参见文档:idm

调用idm下载的函数如下:

# IDM方式下载
def download_idm(url, referer_url, file_type):
    print("下载:", url)
    file_name = '{}.{}'.format(str(int(round(time.time() * 1000))), file_type)
    downloader = IDMan()
    downloader.download(url, output=file_name, referrer=referer_url)
    print("下载完成:", url)
    return file_name
复制代码

运行后输入视频下载,会直接调用IDM进行下载,简直不要太爽~

Tips:本代码只适用于普通视频下载,不适用于大会员番剧下载,感兴趣的可自行破解相关规则~

视频下载部分就说到这,接着开始执行字幕提取方案中的 OCR文字识别 ~

0x3、OCR文字识别

关于OCR文字识别,以前写过一篇《小猪的Python学习之旅 —— 13.文字识别库pytesseract初体验》,这里就不BB那么多了,直接开搞。

1、提取视频帧

此处用到opencv,pip直接装cv2,速度一般较慢,建议使用镜像源方式下载:

pip install opencv-python
复制代码

比较简单,直接上代码:

# -*- coding: utf-8 -*-
# !/usr/bin/env python
"""
-------------------------------------------------
   File     : video_frame_extract.py
   Author   : CoderPig
   date     : 2021-01-24 10:21
   Desc     : 视频帧提取
-------------------------------------------------
"""
import cv2
import os
import cp_utils

video_dir = os.path.join(os.getcwd(), "video")
frame_extract_dir = os.path.join(os.getcwd(), "frame_extract")


def save_image(save_dir, image, num):
    save_path = os.path.join(save_dir, "{}.jpg".format(num))
    cv2.imwrite(save_path, image)


def extract_frame(file):
    save_dir = os.path.join(frame_extract_dir, file.split(os.path.sep)[-1][:-4])
    cp_utils.is_dir_existed(save_dir)
    video_capture = cv2.VideoCapture(file)
    success, frame = video_capture.read()  # 读取第一帧
    i = 0
    j = 0
    while success:
        i += 1
        # 获取视频帧率 → 每秒多少帧,此处每秒保存一次视频帧
        if i % video_capture.get(cv2.CAP_PROP_FPS) == 0:
            j = j + 1
            save_image(save_dir, frame, j)
            print("保存图片:{}".format(j))
        success, frame = video_capture.read()


if __name__ == '__main__':
    cp_utils.is_dir_existed(video_dir)
    cp_utils.is_dir_existed(frame_extract_dir)
    # 获取视频列表
    mp4_list = cp_utils.filter_file_type(video_dir, ".mp4")
    flv_list = cp_utils.filter_file_type(video_dir, ".flv")
    video_list = mp4_list + flv_list
    print("请选择处理视频的序号:\n{}\n".format("=" * 64))
    for index, value in enumerate(video_list):
        print("{}.{}".format(index, value))
    file_choose = input("\n{}\n请输入序号:\n".format("=" * 64))
    file_choose_int = int(file_choose)
    if 0 <= file_choose_int < len(video_list):
        extract_frame(video_list[file_choose_int])
复制代码

运行下:

也可以打开文件管理器查看:

2、裁剪图片 & OCR前的处理

视频帧提取完,接着到图片裁剪了,没必要拿一整张图片去ocr,裁剪字幕区域即可,此处使用PIL模块进行处理。先要获取裁剪区域,直接用Windows自带的画图工具比一比:

得出起始坐标(12,244),结束坐标(12+247,244+28)即(259,272),随便找个图片代码裁剪一波试试看:

import os

from PIL import Image

frame_extract_dir = os.path.join(os.getcwd(), "frame_extract")

if __name__ == '__main__':
    im = Image.open(frame_extract_dir + "/102.jpg")
    box = (12, 244, 259, 272)
    region = im.crop(box)
    region.save("test.jpg")
复制代码

打开图片看下效果:

可以,nice,接着补上转灰度和二值化,代码如下:

import os

from PIL import Image
import tesserocr

frame_extract_dir = os.path.join(os.getcwd(), "frame_extract")

if __name__ == '__main__':
    im = Image.open(frame_extract_dir + "/102.jpg")
    box = (12, 244, 259, 272)
    # 转灰度处理
    im = im.convert('L')
    # 二值化处理
    table = []
    for i in range(256):
        if i < 150:
            table.append(0)
        else:
            table.append(1)
    im = im.point(table, "1")
    region = im.crop(box)
    region.save("test.jpg")
复制代码

处理后的图片:

3、Tesseract OCR识别

tesseract-ocr是Google提供的免费OCR文字识别引擎:

Github仓库地址

这东西,如果你不自己训练字库的话,中文识别率可谓是低得可怕,此处直接用自带的中文字库尝试一波。

① 安装tesseract

安装完后,配置下PATH环境变量,新增刚刚的安装路径,如:

接着打开终端,键入下述命令试试识别处理后的图片:

tesseract test.jpg result -l chi_sim
复制代码

打开生成的result.txt:

什么鬼?字库有有问题吗?我又试了另外一张图片:

识别结果是正常的:

tesserocr都不用装了,直接放弃,试试第三方OCR SDK,此处用的百度OCR。

4、百度OCR识别

from aip import AipOcr

# 新建一个AipOcr对象
config = {
    'appId': 'XXX',
    'apiKey': 'YYY',
    'secretKey': 'ZZZ'
}
client = AipOcr(**config)

def bd_ocr(file):
    with open(file, 'rb') as f:
        image = f.read()
    result = client.basicGeneral(image)
    if 'words_result' in result:
        return '\n'.join([w['words'] for w in result['words_result']])
        
# 调用处:
print(bd_ocr("test.jpg"))
复制代码

识别结果:

突然想到,字幕图片远古画质,可能不做二值化和灰度处理反而更好,去掉那部分的代码再试试:

果真如此,再把basicGeneral改为basicAccurate使用高精度识别:

识别结果差不多了,不过高精度识别一天只能白嫖500次:


0x4、音频转文字

上面的百度OCR高精度识别结果基本够用,就是不能白嫖,有次数限制。另外,OCR文件识别有个最大的缺点就是不能一劳永逸:

每个视频都可能需要自己去计算,字幕截取的区域,再进行文字识别。

而语音识别则可以摆脱这一囧境,只要说话,就能整出字幕。

1、视频转wav音频片段

此处直接使用Python音频处理库 pydub,在此之前要下载安装ffmpeg并配置环境变量:

Github仓库

获取下视频时长,然后每60s为一段,比较简单,直接上代码:

# -*- coding: utf-8 -*-
# !/usr/bin/env python
"""
-------------------------------------------------
   File     : audio_text_extract.py
   Author   : CoderPig
   date     : 2021-01-26 10:11 
   Desc     : 视频转音频提取字幕
-------------------------------------------------
"""
from pydub import AudioSegment
import os
import time
import cp_utils
import math

audio_after_dir = os.path.join(os.getcwd(), "audio_after_dir")


def video_to_wav(file_path):
    part_duration = 60000  # 每隔60s切割一段
    if file_path.endswith(".flv"):
        video = AudioSegment.from_flv(file_path)
    else:
        video = AudioSegment.from_file(file_path, format=file_path[-3:])
    wav_save_dir = os.path.join(audio_after_dir, str(int(round(time.time() * 1000))))
    cp_utils.is_dir_existed(wav_save_dir)
    video_duration = int(video.duration_seconds * 1000)  # 获取视频时长
    part_count = math.ceil(video_duration / part_duration)  # 录音段数,
    last_start = video_duration - video_duration % part_duration
    print("待处理视频时长为:{},裁剪为:{} 段".format(video_duration, part_count))
    for part in range(0, part_count - 1):
        start = part * part_duration
        end = (part + 1) * part_duration - 1
        wav_part = video[start: end]
        print("导出时间段:{} - {}".format(start, end))
        wav_part.export(os.path.join(wav_save_dir, "{}.wav".format(part)), format="wav")
    # 剩下一段
    wav_part = video[last_start: video_duration]
    print("导出时间段:{} - {}".format(last_start, video_duration))
    wav_part.export(os.path.join(wav_save_dir, "{}.wav".format(part_count)), format="wav")
    return wav_save_dir


if __name__ == '__main__':
    cp_utils.is_dir_existed(audio_after_dir)
    wav_dir = video_to_wav(os.path.join(os.getcwd(), "1.flv"))
    print("flv转wav完成,输出文件目录为:", wav_dir)

复制代码

运行后:

输出目录下也能看到对应的wav片段:

音频片段提取就到这里,接着语音转文字环节,先试下语音识别库 Speech_Recognition

2、Speech_Recognition 库识别音频

库的相关介绍可自行移步到:

直接pip命令安装:

pip install SpeechRcognition
复制代码

此处使用recognize_sphinx()语音识别器,可以离线识别,但必须安装 pocketsphinx库:

pip install pocketsphinx
复制代码

安装过程报错:

error: command 'swig.exe' failed: No such file or directory...
复制代码

解决方法:官网下载swig解压,然后配置环境变量

swig官方下载地址

新增环境变量:

Path环境变量添加此变量:

此时再pip装pocketsphinx,报错:

装Visual C++ 14.0 是不可能的,装完多两个2G,我的C盘哪顶得住啊

找下whl,whl文件本质上是一个压缩包,里面包含了py文件,以及经过编译的pyd文件。

使得可以在不具备编译环境的情况下,选择合适自己的Python环境进行安装,免去了当前系统环境中必须满足编译环境的烦恼。

那就找cp38-win32的whl了,可在Pypi:

pocketsphinx Download files

没找着,别说3.8了,连3.7都没有,我擦,难道只能降级回3.6么?灵机一动,谷歌搜了下:

pocketsphinx-0.1.15-cp38-win32

好家伙,在下述链接找到了:pypi.bartbroe.re/pocketsphin…

如愿以偿地装上了

然后我们识别的是中文(普通话),还需要另外安装中文语言、声学模型:

下载地址

解压后:

来到Python安装目录下的:Lib\site-packages\speech_recognition

新建一个 zh-CN 的文件夹,进入这个文件夹,把刚下的东西都丢进去,然后改名:

  • zh_cn.cd_cont_5000acoustic-model
  • zh_cn.lm.binlanguage-model.lm.bin
  • zh_cn.dicpronounciation-dictionary.dict

弄完就可以玩耍了,识别代码比较简单:

import speech_recognition as sr

r = sr.Recognizer()
test = sr.AudioFile("1.wav")
start_timestamp = time.time()
with test as source:
    audio = r.record(source)
    try:
        text = r.recognize_sphinx(audio, language='zh-CN')
        print(text)
    except sr.UnknownValueError as e:
        print(e)
print("解析耗时:", time.time() - start_timestamp)
复制代码

输出结果:

这解析结果也差太远了吧,而且耗时快三分钟...不行,在不自己训练字库的情况下还是试试第三方吧。

3、百度语音识别

非企业认证可以白嫖5w次,有效期180天,这里不知道为啥我有15W次:

改下官方Demo直接来:

# -*- coding: utf-8 -*-
# !/usr/bin/env python
"""
-------------------------------------------------
   File     : bd_asr_test.py
   Author   : CoderPig
   date     : 2021-01-26 15:04 
   Desc     : 百度语音识别测试
-------------------------------------------------
"""
import requests as r

API_KEY = 'kVcnfD9iW2XVZSMaLMrtLYIz'
SECRET_KEY = 'O9o1O213UgG5LFn0bDGNtoRN3VWl2du6'
CUID = '123456PYTHON'
RATE = 16000  # 固定值
DEV_PID = 1537  # 普通话
ASR_URL = 'http://vop.baidu.com/server_api'
TOKEN_URL = 'http://openapi.baidu.com/oauth/2.0/token'


def fetch_token():
    data = {
        'grant_type': 'client_credentials',
        'client_id': API_KEY,
        'client_secret': SECRET_KEY
    }
    resp_json = r.post(TOKEN_URL, data=data).json()
    return resp_json['access_token']


def wav_conversion(token, file_path):
    with open(file_path, 'rb') as speech_file:
        speech_data = speech_file.read()
    length = len(speech_data)
    params = {'cuid': CUID, 'token': token, 'dev_pid': DEV_PID}
    headers = {
        'Content-Type': 'audio/' + 'wav' + '; rate=' + str(RATE),
        'Content-Length': str(length)
    }
    resp = r.post(ASR_URL, headers=headers, params=params, data=speech_data)
    print(resp.text)


if __name__ == '__main__':
    bd_token = fetch_token()
    wav_conversion(bd_token, '13.wav')
复制代码

运行后稍等片刻,接口却返回了下述信息:

我透,不是录音文件时长不超过60s即可的吗,重新裁剪下音频,改为30s也不行,最后改成10s一段:

这...还是得自己上传音频和文本,训练模型,要不我自己来训练个把,接着当我想开通服务时:

然后这个包的价格,劝退

4、付费APP转换接口破解

想识别率高,又不想自己训练模式,还不想花太多钱,天底下哪有这么好的事情:

还真有,只要99块/2年,随便用,百度搜了下:录音转文字,然后找到了一款排名靠前的转换APP,没看到有试用功能,于是手动@客服帮忙转下文件试试康:

没多久就收到答复邮件:

看着还行啊,99块2年随便用可还行,入了一波VIP,接着丢个60s的录音文件试一试:

识别准确率和速度还可以,支持大文件:

最重要是支持霓虹语:

啧啧啧,完美,接着抓包看下请求,丢个30分钟的录音试试康,请求流程如下:

接着就来研究下这五个接口的规律。

结合请求参数与响应结果,不难看出接口①是 音频校验接口,而且上传的文件后缀不是.wav而是.mp4,为啥?

肯定是做了转换啊,转换的动机也很简单:

转换后的mp3体积比wav的体积少10几倍!

不信?本地ffmpeg转换下:

ffmpeg -i 0.wav -f mp3 -acodec libmp3lame -y test.mp3
复制代码

转换后的结果:

不过转换后的结果还是比上传的音频文件大,你可能会问:你又知道?简单,把接口②中所有的Content-Lenght相加就知道了:

相加结果:

这里其实可以不用理怎么转,不过喜欢刨根问底的我,肯定是想弄清怎么转的,那就反编译下源码康康咯~

看了下app,360非企业版加固,无脑导出dex,怎么导可以看我以前写的教程。

Android音视频转换大都走的 FFmpeg 命令行,而转换成mp3格式有个必选的参数 libmp3lame,直接全局搜:

转成Java,定位到下述代码:

难道是这里?命令行模仿着拼凑下参数:

ffmpeg -i 0.wav -vn -ar 16000 -acodec libmp3lame test2.mp3
复制代码

转换后的mp3文件:

右键查看详情:

果真如此:

弄懂音频是怎么转换的,接着看下接口②:

接口名memprofile → Member Profile → 会员资料,获取用户信息的接口咯,主要是取这里的usertoken。

一般单点登录顶号才会出现token要更新的情况,直接跳过吧,接口③是文件分块上传:

用到了右侧接口①中的一些响应参数,整合下:

  • tasktag:上传任务tag
  • tasktoken: 上传任务token
  • timestamp:建立上传任务时的时间戳
  • fileindex: 文件偏移,固定为0
  • chunks:文件块数
  • chunk:当前第几块

接着接口④:

不难看出这个接口是用来查询翻译状态的,每隔1s请求一次,轮询确认是否翻译完成,直到:

此时再请求接口⑤,获取翻译结果:

downurl 字段的值就是翻译结果文件的url,下载文件,然后解析拼接即可。

一次完整的音频上传就是这样,接着要破解一个参数的构造 → datasign,基本上每个接口都离不开它,而且是动态变化的。直接全局搜datasign:

跟:

普通md5加密,盐都没加,再跟hashmap转换成string的方法:

噢,就是请求参数用&=拼接,最后加上hUuPd20171206LuOnD,md5一下,就是datasign的值了。

规则知道后,就很好写代码了,限于篇幅就不一一讲解了,直接肝出完整代码

# -*- coding: utf-8 -*-
# !/usr/bin/env python
"""
-------------------------------------------------
   File     : extract_text_by_api.py
   Author   : CoderPig
   date     : 2021-01-28 15:42 
   Desc     : 利用APP的API生成字幕
-------------------------------------------------
"""
import hashlib
import math
import os
import time

import requests as r
from pydub import AudioSegment

import cp_utils

host = 'app.xunjiepdf.com'
origin_video_dir = os.path.join(os.getcwd(), "origin_video")  # 原始视频的存放目录
video_to_wav_dir = os.path.join(os.getcwd(), "video_to_wav")  # 视频转音频的存放目录
wav_to_mp3_dir = os.path.join(os.getcwd(), "wav_to_mp3_dir")  # wav转mp3的存放目录

# API接口
base_url = 'https://{}/api/v4/'.format(host)
member_profile_url = base_url + "memprofile"
upload_par_url = base_url + "uploadpar"
upload_file_url = base_url + "uploadfile"
task_state_url = base_url + "taskstate"
task_down_url = base_url + "taskdown"

# 常量字段
device_id = '设备id'
product_info = 'F5030BB972D508DCC0CA18BDF7AE48E26717591F38906C09587358DAAC0092F0'
account = '账户'
user_token = '用户Token'
machine_id = '设备id'
software_name = '软件名'

# 普通请求头
okhttp_headers = {
    'Host': host,
    'User-Agent': 'okhttp/3.10.0'
}

# 上传文件请求头
upload_headers = {
    'Host': host,
    'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 8; Mi 20 Build/QQ3A.200805.001)',
    'Content-Type': 'application/octet-stream'
}


# 视频转换成多个wav文件
def video_to_wav(file_path, seconds):
    part_duration = seconds * 1000
    print(file_path)
    if file_path.endswith(".flv"):
        video = AudioSegment.from_flv(file_path)
    else:
        print(file_path[-3:])
        video = AudioSegment.from_file(file_path, format=file_path[-3:])
    wav_save_dir = os.path.join(video_to_wav_dir, str(int(round(time.time() * 1000))))
    cp_utils.is_dir_existed(wav_save_dir)
    video_duration = int(video.duration_seconds * 1000)  # 获取视频时长
    part_count = math.ceil(video_duration / part_duration)  # 裁剪录音段数
    last_start = video_duration - video_duration % part_duration
    print("待处理视频时长为:{},裁剪为:{} 段".format(video_duration, part_count))
    for part in range(0, part_count - 1):
        start = part * part_duration
        end = (part + 1) * part_duration - 1
        wav_part = video[start: end]
        print("导出时间段:{} - {}".format(start, end))
        wav_part.export(os.path.join(wav_save_dir, "{}.wav".format(part)), format="wav")
    # 剩下一段
    wav_part = video[last_start: video_duration]
    print("导出时间段:{} - {}".format(last_start, video_duration))
    wav_part.export(os.path.join(wav_save_dir, "{}.wav".format(part_count)), format="wav")
    return wav_save_dir


# 获取用户信息
def member_profile():
    data = {
        "deviceid": device_id,
        "timestamp": int(time.time()),
        "productinfo": product_info,
        "account": account,
        "usertoken": user_token
    }
    data_sign = md5(dict_to_str(data))
    data['datasign'] = data_sign
    resp = r.post(url=member_profile_url, headers=okhttp_headers, data=data)
    print(resp.json())


# 文件上传校验
def upload_par(file_path):
    file_name = file_path.split(os.path.sep)[-1]
    data = {
        "outputfileextension": "srt",
        "tasktype": "voice2text",
        "productid": "34",
        "isshare": 0,
        "softname": software_name,
        "usertoken": user_token,
        "filecount": 1,
        "filename": file_name,
        "machineid": machine_id,
        "fileversion": "defaultengine",
        "softversion": "4.3.2",
        "fanyi_from": "zh",
        "limitsize": "204800",
        "account": account,
        "timestamp": int(time.time())
    }
    data_sign = md5(dict_to_str(data))
    data['datasign'] = data_sign
    resp = r.post(url=upload_par_url, headers=okhttp_headers, data=data)
    print("请求:", resp.url)
    if resp is not None:
        resp_json = resp.json()
        print(resp_json)
        if resp_json['code'] == 10000:
            return TaskInfo(resp_json['tasktag'], resp_json['tasktoken'], resp_json['timestamp'])
        else:
            return resp_json


# 文件分块上传
def upload_file(upload_task, file_path):
    # 获得文件字节数
    file_size = os.path.getsize(file_path)
    # 计算文件块数
    chunks_count = math.ceil(file_size / 1048576)
    upload_params = {
        'tasktag': upload_task.task_tag,
        'timestamp': int(time.time()),
        'tasktoken': upload_task.task_token,
        'fileindex': 0,
        'chunks': chunks_count,
    }
    # 分段请求
    for count in range(chunks_count):
        upload_params['chunk'] = count
        start_index = count * 1048576
        with open(file_path, 'rb') as f:
            f.seek(start_index)
            content = f.read(1048576)
            resp = r.post(url=upload_file_url, headers=upload_headers, params=upload_params, data=content)
            print("请求:", resp.url)
            if resp is not None:
                print(resp.json())
            count += 1


# 查询翻译状态
def task_state(upload_task):
    data = {
        "ifshowtxt": "1",
        "productid": "34",
        "deviceos": "android10",
        "softversion": "4.3.2",
        "tasktag": upload_task.task_tag,
        "softname": software_name,
        "usertoken": user_token,
        "deviceid": device_id,
        "devicetype": "android",
        "account": account,
        "timestamp": int(time.time())
    }
    data_sign = md5(dict_to_str(data))
    data['datasign'] = data_sign
    while True:
        resp = r.post(url=task_state_url, headers=okhttp_headers, data=data)
        print("请求:", resp.url)
        if resp is not None:
            resp_json = resp.json()
            if resp_json['code'] == 10000:
                print(resp_json['message'])
                return resp_json['code']
            elif resp_json['code'] == 20000:
                print(resp_json['message'])
                time.sleep(1)
                continue
            else:
                return resp_json['code']


# 获取翻译结果
def task_down(upload_task):
    data = {
        "downtype": 2,
        "tasktag": upload_task.task_tag,
        "productinfo": product_info,
        "usertoken": user_token,
        "deviceid": device_id,
        "account": account,
        "timestamp": int(time.time())
    }
    data_sign = md5(dict_to_str(data))
    data['datasign'] = data_sign
    resp = r.post(url=task_down_url, headers=okhttp_headers, data=data)
    resp_json = resp.json()
    download_url = resp_json.get('downurl')
    print(download_url)
    if download_url is not None:
        download_resp = r.get(download_url)
        if download_resp is not None:
            file_name = download_url.split('/')[-1]
            with open(file_name, 'wb') as f:
                f.write(download_resp.content)
                return file_name


# 解析srt文件提取时间及内容列表
def analyse_srt(srt_file_path):
    time_list = []
    text_list = []
    time_start_pos = 1
    text_start_pos = 2
    with open(srt_file_path, 'rb') as f:
        for index, value in enumerate(f.readlines()):
            if index == time_start_pos:
                time_list.append(value.decode().strip()[0:8])
                time_start_pos += 4
            elif index == text_start_pos:
                text_list.append(value.decode().strip())
                text_start_pos += 4
    return time_list, text_list


# md5加密
def md5(content):
    md = hashlib.md5()
    md.update(content.encode('utf-8'))
    return md.hexdigest()


# 字典转字符串
def dict_to_str(data_dict):
    # 按键升序排列
    sorted_tuple = sorted(data_dict.items(), key=lambda d: d[0], reverse=False)
    content = ''
    for t in sorted_tuple:
        content += '&{}={}'.format(t[0], t[1])
    content += 'hUuPd20171206LuOnD'
    if content.startswith("&"):
        content = content.replace("&", "", 1)
    return content


class TaskInfo:
    def __init__(self, task_tag, task_token, timestamp):
        self.task_tag = task_tag
        self.task_token = task_token
        self.timestamp = timestamp


if __name__ == '__main__':
    cp_utils.is_dir_existed(origin_video_dir)
    cp_utils.is_dir_existed(video_to_wav_dir)
    cp_utils.is_dir_existed(wav_to_mp3_dir)
    flv_file_list = cp_utils.filter_file_type(origin_video_dir, '.flv')
    mp4_file_list = cp_utils.filter_file_type(origin_video_dir, '.mp4')
    flv_file_list += mp4_file_list
    if len(flv_file_list) == 0:
        print("待处理视频为空")
        exit(0)
    print("\n请选择要提取字幕的视频序号:")
    for pos, video_path in enumerate(flv_file_list):
        print("{} → {}".format(pos, video_path))
    file_choose_index = int(input())
    file_choose_path = flv_file_list[file_choose_index]
    input_duration = int(input("\n请输入分割长度,单位s,如输入60,代表音频切割为每60s一段: \n"))
    print("开始切割,请稍后...")
    wav_output_dir = video_to_wav(file_choose_path, input_duration)
    print("\n请选择要处理音频片段序号:")
    wav_file_list = cp_utils.filter_file_type(wav_output_dir, '.wav')
    for pos, wav_path in enumerate(wav_file_list):
        print("{} → {}".format(pos, wav_path))
    wav_choose_index = int(input())
    wav_choose_path = wav_file_list[wav_choose_index]
    output_mp3_path = os.path.join(wav_to_mp3_dir, '{}.mp3'.format(int(time.time())))
    # 文件转换
    os.system('ffmpeg -i {} -vn -ar 16000 -acodec libmp3lame {}'.format(wav_choose_path, output_mp3_path))
    # 文件校验
    task = upload_par(output_mp3_path)
    # 文件上传
    upload_file(task, output_mp3_path)
    # 查询翻译状态
    task_state(task)
    # 下载翻译结果文件
    srt_file_name = task_down(task)
    if srt_file_name is not None:
        result_txt_file = '{}.txt'.format(int(time.time()))
        with open(result_txt_file, 'w+', encoding='utf-8') as f:
            for text in analyse_srt(srt_file_name)[1]:
                f.writelines(text + '\n')
        print("文件写入完成:", result_txt_file)

复制代码

接着丢一个视频文件到origin_video目录下,运行下脚本,即可完成字幕提取:

打开生成的字幕文件:

可以,顺带提下,日文翻译,校验接口那里把 fanyi_from 的值设置为 jp 即可。


0x5、小结

都2w多字了,就不哔哔那么多了,代码有些乱,而且我发现了一些BUG,后续有空整理优化下丢Github上,感兴趣的可以先star下,也可以根据本文自己实践着写一个,望各位老司机节制,就酱,感谢~

VideoSubtitleExtractTool


分类:
Android
标签: