使用 shell 脚本拼接 srt 字幕文件 (srtcat)

背景

前段时间迷上了做 B 站视频,主要是摩托车方面的知识分享。做的也比较粗糙,就是几张图片配上语音和字幕进行解说。尝试过自己解说,发现录制视频对节奏的要求还是比较高的,这里面水太深把握不住。好在以 "在线 免费 文字转语音" 作为关键字搜索一番,发现一个好用的网站——字幕说。好用的语音合成工具千千万,为什么我对这个情有独钟呢?原来它将文字底稿转换为语音的同时,还输出了字幕文件 (srt),这个在 B 站的云编辑器中就可以直接导入了,非常方便:

最终效果就会在视频下方与语音同步播出字幕:

感觉比自动识别的字幕准确率高的多。

白嫖字幕说

像大多数免费工具一样,免费只是揽客的招牌,毕竟天底下没有免费的午餐,字幕说限制一次转换不超过 1000 个汉字:

上面虽然标明 2000 字,实际上超过 1000 字已经开始要点数了:

大概是 1 点 10 字的兑换方式,初始账户大概有 200 点,只能超 2000 字,而且这 2000 字也得遵守一次不超 2000 字的限制,如果文稿有 3000 字,仍得分两次生成语音和字幕。

作为白嫖用户,别说花钱买点数,就是用点数也是不乐意的,每次免费的不是限制 1000 字吗,那就按这个限制将文稿切分一下:

哈哈,果然白嫖成功,点立即提交后就可以跳转到任务查询界面了:

转换完成后可以选择对应的音频和字幕文件进行下载,下载后的 srt 文件长这个样子:

1
00:00:00,000 --> 00:00:04,600
本次给大家分享一下在北京自助给二手摩托车上牌的流程

2
00:00:04,600 --> 00:00:08,680
里面只包含私户/外地车/第二辆车上牌的方法

3
00:00:08,680 --> 00:00:12,560
关于北京摩托车上牌流程B站上已经有一些教程了

4
00:00:12,560 --> 00:00:17,120
这里主要补充说明二手外地车在北方检测场上牌的过程

...

每段字幕之间以空行分隔,分为三行内容,分别是序号、播放时间、文字内容。对于文稿中一些比较长的行,后台会自动拆分为多个字幕段落。

srt 文件拼接

下面将拆分后的音频和字幕导入 B 站云剪辑中。音频比较简单,上传文件后一段段拖到合成的视频中就可以了;字幕就麻烦了,云剪辑只支持一次导入一个字幕文件,导入新的字幕会自动清空之前的内容,因此需要将切分后的字幕文件拼接成一整个文件导入。

一开始用了 cat,生成的文件确实包含了所有内容,但是导入后发现只有最后一部分字幕生效了,末尾还保留了一部分前面的字幕,全乱套了:

原来,不调整字幕中的序号和播放时间,会导致前面的被后面同序号的字幕所覆盖。看起来需要找一个字幕文件拼接工具了,经过一番百度,主要找到下面几个工具

SrtEdit

这个是一个专门对字幕文件做各种处理的工具,打开字幕文件后,直接追加即可实现文件的拼接:

追加时还可以选择新文件的起始时间:

默认是上一个文件结尾时间加 1 秒。追加后就可以直接另存为拼接后的文件。

Srt Sub Master

打开第一个文件后选择:文件->合并导入->按顺序合成,在弹出的选项框中进行设置:

选择要合并的文件后就可以了:

不过最终效果好像是将多条字幕合并到一个时间段上了,貌似是用来整合中英文字幕的。翻了一下应用提供的其它功能菜单,没发现直接拼接两个字幕文件的功能,pass

Subtitle Workshop

打开软件后直接选择:工具->合并字幕

在弹出的选择框中选择文件后合并:

最后保存合并后的文件。

这里字幕中的汉字显示为乱码,一开始以为是从字幕说导出 srt 文件时没有选择带 BOM 的 utf-8 格式所致:

切换到带 bom 格式后仍不行:

但同样的乱码问题,对于 Srt Sub Master 却可以用上面的办法解决:

一时半会儿没弄明白 Subtitle Workshop 是个什么情况,pass

横评

经过一番对比,Sub Srt Master 没有找到对应的功能,Subtitle Workshop 在汉字编码上存在一些问题,最后选择了 SrtEdit。因为当时比较急,就用选定的这个工具生成的字幕文件导入到 B 站云剪辑去生成视频了。

srtcat

GUI 工具固然好用,然而有两个问题:

  • 依赖某些平台,例如 windows,这对 mac 用户非常不友好
  • IDE 形式的图形工具一般是包罗万象的,而我的场景非常单一,安装了许多不必要的功能。

第二点对 SrtEdit 还不明显,看看其它两个,有些还和视频文件耦合在一起,字幕只是其功能中的一小部分。其实 unix 的哲学就是提供 tool 的集合,而非做一个包罗万象的平台,工具的生命周期远远大于平台,因为你永远无法预测将来的用户会怎么使用。提供单一功能的工具供用户去选择来集成在他们的场景中是最好的方式。

基于这个想法,再加上拼接 srt 文件的功能并不复杂,主要是序号和时间上的处理,所以决定使用 shell 脚本手搓一个,名字就叫 srtcat 吧:

> sh srtcat.sh
Usage: srtcat [-t timespan] file1 file2 ...

在使用上非常简单,参数列表为要拼接的 srt 文件,内容都从序号 1 开始,第一个文件的起始时间需要从 00:00:00,000 开始;-t 选项指定文件间的时间间隔,默认 1000 毫秒。拼接结果将打印到 stdout,可以重定向到新文件。错误和警告将打印到 stderr 防止污染 stdout 内容。

项目地址:https://github.com/goodpaperman/srtcat

这个工具只包含一个 shell 脚本 srtcat.sh,230 多行,比较好读,这里不逐行解说了,只说明一下重点功能的方案选型。

拼接过程中时间的处理是个重点,按处理的时序又分为拆分、去零,下面分别说明。

拆分

形如 hh:mm:ss,xxx 格式的时间,首先需要从字符串提取时、分、秒、毫秒四个部分,这部分主要想说一下拆分时间字符串的三种方案。

cut

最直观的方式就是使用 cut 命令挨个截取:

hour=$(echo "${line}" | cut -b 1-2)
min=$(echo "${line}" | cut -b 4-5)
sec=$(echo "${line}" | cut -b 7-8)
msec=$(echo "${line}" | cut -b 10-12)

调用 cut 的命令来处理字符串的缺点是效率比较低,一个时间处理就要启动 4 个子进程,大量的这种字符串操作,绝对会拖慢脚本效率,替代的方案是 shell 自己的字符串截取:

hour=${line:0:2}
min=${line:3:2}
sec=${line:6:2}
msec=${line:9:3}

这样虽然可以避免上面的性能问题,但也是基于固定长度来截取,这是基于时分秒占用 2 位、毫秒占用 3 位的假设,如果 hour 占用超过 2 位的话 (hour > 99),就全对不上了,考虑到拓展性,方案 1 这种固定长度的方式就 pass 了。

awk

不使用固定长度,那就按关键字符分割。首先想到的是 awk 命令,可以通过 -F 选项指定多个分隔符:

line="00:01:02,003 --> 04:05:06,007"
echo "${line}" | awk -F':|,| ' '{ for (i=1; i<=NF; i++) { print $i }}'

注意多个字符间通过 | 分隔,效果如下:

> sh awk.sh
00
01
02
003
-->
04
05
06
007

那如何将分割的字符串赋值给 shell 变量呢?有很多方法,这里用到了 eval: 

line="00:01:02,003 --> 04:05:06,007"
val=$(echo "${line}" | awk -F':|,| ' '{print "hour1="$1";min1="$2";sec1="$3";msec1="$4";hour2="$6";min2="$7";sec2="$8";msec2="$9";"}')  
echo "${val}"
eval "${val}"
echo "${hour1}:${min1}:${sec1},${msec1}"
echo "${hour2}:${min2}:${sec2},${msec2}"

运行效果如下:

> sh awk.sh
hour1=00;min1=01;sec1=02;msec1=003;hour2=04;min2=05;sec2=06;msec2=007;
00:01:02,003
04:05:06,007

eval 后就可以使用 shell 变量hour1/min1/sec1/msec1引用第一个时间、使用hour2/min2/sec2/msec2引用第二个时间,这里变量名可以任意设置。

IFS

awk 虽然直观,但是仍要调起一个子进程,有没有更高效的方法呢?网上搜到一篇文章,说可以用 shell 自带的 IFS 分隔符设置来处理日期拆分,感觉还蛮符合我这个场景的,拿来试验一下:

#! /bin/sh

line="00:01:02,003 --> 04:05:06,007"
OLD_IFS="${IFS}"
IFS=":, "
arr=(${line})
IFS="${OLD_IFS}"

for var in "${arr[@]}"
do
    echo "${var}"
done

IFS 字符串的每个字符就是一个分割符。运行上面这段脚本,得到:

> sh ifs.sh
00
01
02
003
-->
04
05
06
007

使用 ${arr[0]}:${arr[1]}:${arr[2]},${arr[3]} 引用第一个时间,${arr[5]}:${arr[6]}:${arr[7]},${arr[8]} 引用第二个时间。

横评

从性能上讲,IFS 方式是最优解,shell 字符截取次之,awk+eval 次之,cut 最末;从可拓展性角度讲 (hour > 99),IFS、awk 方式优于 shell 字符截取和 cut;从直观性上讲,awk+eval 最优、shell 字符截取和 cut 次之,IFS  (使用 arr[N] 引用) 最末。考虑到脚本以后使用场景,面对比较大的 srt 文件,性能将成为一个瓶颈,因此选择 IFS 来尽量提升脚本性能,虽然牺牲了直观性,不过保留了可拓展性。

去零

拆分后的时间变量是字符串,有前导零时,直接参与加法运算时,偶尔会出现下面的错误:

srtcat.sh: line 8: 080: value too great for base (error token is "080")

原因是将毫秒 080 识别为八进制 (前缀 0 为八进制,前缀 0x 为十六进制) ,而八进制中最大的数字是 7,遇到超过 7 的数字就会报错。

下面介绍几种解决方案:

${var##0*}

一开始是想用 shell 字符串截取,通过 ## 实现从左向右最长匹配,通过0*匹配全零串,但是发现这个方案不行:

> var=080
> echo ${var##0*}

> echo ${var#0*} 
80
> var=007
> echo ${var##0*}

> echo ${var#0*}
07

主要是 shell 将##0*理解为了匹配所有数字,直到遇到符号或字母时才会停止匹配,导致匹配非零数字。pass

sed

然后想到的就是 sed 的正则匹配及数字提取:

> var=007
> echo $var | sed -n 's/0*\([0-9]*\)/\1/p' 
7
> var=080
> echo $var | sed -n 's/0*\([0-9]*\)/\1/p'
80
> var=123
> echo $var | sed -n 's/0*\([0-9]*\)/\1/p'
123

通过0*匹配前导零、[0-9]*匹配剩下的数字。这个方案缺陷很明显,时间的每个分量需要启动一个单独的 sed 子进程,和之前的 cut 一样,性能肯定好不了。

$(())

参考网上的一篇文章,使用了一个 shell 运算符的奇技淫巧:

> var=080
> echo $((1${var}-1000))
80
> var=007               
> echo $((1${var}-1000))
7
> var=123               
> echo $((1${var}-1000))
123

就是在明确字符串数字位数后,加一个前导 1 使其成为 1xxxx 的形式,此时转换为数字不会报错,再减去因为加前缀 1 导致的数字增长值 (例如对于 3 位数字是 1000),就还原成了原本的数字,且前导零也去除了。这个方法的缺陷也很明显,需要事先知道数字字符串位数,拓展性 (hour>99) 不好。

awk

之前在对比拆分方案时曾经介绍过 awk,如果使用 awk+eval 方案,则将前导零删除就是顺手的事儿:

line="00:01:02,003 --> 04:05:06,007"
val=$(echo "${line}" | awk -F':|,| ' '{print "hour1="$1/1";min1="$2/1";sec1="$3/1";msec1="$4/1";hour2="$6/1";min2="$7/1";sec2="$8/1";msec2="$9/1";"}')  
echo "${val}"
eval "${val}"
echo "${hour1}:${min1}:${sec1},${msec1}"
echo "${hour2}:${min2}:${sec2},${msec2}"

和之前对比,仅仅在 awk 命令内部构造赋值表达式时为每个字段增加了一个除 1 操作 (/1),awk 就自动将字符串转换为数字了:

> sh awk.sh
hour1=0;min1=1;sec1=2;msec1=3;hour2=4;min2=5;sec2=6;msec2=7;
0:1:2,3
4:5:6,7

实测乘 1 (*1) 也可,这也太方便了。

横评

将拆分和去零结合起来,有以下几种搭配:

  • $((var:0:2)) + sed
  • $((var:0:2)) + $((1$var-100))
  • awk+eval
  • IFS + sed
  • IFS + $((1$var-100))

由于 cut 方案明显不如 shell 字符串截取性能好,这里统一使用 $((var:0:2)) 代替 cut,它形成了前两种方案,明显第二种更优;awk+eval 本身就能删除前导零,就没有再和 sed 或 $((1$var-100)) 去做组合;IFS 方案也有两种组合,明显第二种更优。这样一精简,就只剩三个最终备选方案了:

  • $((var:0:2)) + $((1$var-100))
  • awk+eval
  • IFS + $((1$var-100))

方案 1 和方案 3 差别不大,优势都是性能高、缺陷都是拓展性差;方案 2 的优势是拓展性好、可读性高,缺陷是性能差。

再缩小我的应用场景,一般字幕文件再大,也很少有 hour > 99 的情况,而文件内容多的时候,成千上万行却是轻轻松松,对性能要求比较高,对拓展性要求比较小。综合考虑,决定牺牲拓展性,追求性能,方案 2 pass。方案 1 和方案 3 均可,目前工具使用的是方案 3。

结语

当时因为制作视频急用,没有用到这个工具,直接使用了 SrtEdit 的输出。这个工具能 run 以后,特地找之前的文件做验证,发现拼接后的文件与 SrtEdit 生成的完全一样,下次再做类似视频,应该可以不用离开 mac 平台了,哈哈。

目前 srtcat 工具支持 mac、linux、windows 三种平台 (windows 需要 git bash),总之能运行 shell 的系统都支持。

之前在做方案选择时一直强调性能取向,那 srtcat 目前采用的方案真的有更强的性能吗?下面做个试验,选择三个测试文件,总计 500 多行:

> wc -l 220808*
  211 220808-114030.srt
  183 220808-114613.srt
  135 220808-114838.srt
  532 220808.txt
 1061 total

选取两种方案,一种是 awk+eval,另一种是 IFS+$((1$var-100)),先看第一种方案的性能:

> time sh srtcat.awk.sh 220808-114*.srt > 220808.txt
...

real	0m1.826s
user	0m0.822s
sys	0m1.186s

总耗时 1.826 s。再看第二种方案:

> time sh srtcat.ifs.sh 220808-114*.srt > 220808.txt
...

real	0m1.539s
user	0m0.669s
sys	0m1.037s

总耗时 1.539 s,快了 0.287 s,提速约 1.2 倍。cut 和 sed 的方案没有试,因为那个肯定慢的离谱。

参考

[1]. 字幕说

[2]. sed 提取固定间隔行

[3]. [爱幕] 一个在线字幕编辑器 

[4]. 【Linux】Shell命令 getopts/getopt用法详解

[5]. shell脚本报错 value too great for base

[6]. srtsubmaster用户手册字幕编辑视频字幕音频字幕(精品)

[7]. 使用Subtitle Workshop把几个srt 字幕文件合并

[8]. shell去除字符串前所有的0

[9]. shell 脚本去掉月份和天数的前导零

[10]. 详细解析Shell中的IFS变量

[11]. shell脚本实现printf数字转换N位补零

[12]. SRT字幕格式

posted @ 2023-02-14 10:17  goodcitizen  阅读(543)  评论(2编辑  收藏  举报