使用 shell 指令碼拼接 srt 字幕檔案 (srtcat)

2023-02-14 12:01:42

背景

前段時間迷上了做 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字幕格式