GoでVTT字幕ファイルの途切れたテキストを整形する


元記事: https://zenn.dev/jordan/articles/e3083892e682b6

今回の記事の内容

https://github.com/lll-lll-lll-lll/webvtt-reader

今回はYoutubeの字幕ファイルをいじってみたいと思い、いろいろ調べていたら、どうやら.vttという拡張子ファイルがあるとわかったので、ファイルのテキストを綺麗にするコードを書いてみました。

VTTファイルとは

「.vtt」のファイルは、「Web Video Text Tracks(WebVTT)」というテキストを時間ごとに表示させることのできるテキストデータファイルのこと。 具体例はこんな感じ

WEBVTT
Kind: captions

00:00:00.350 --> 00:00:01.530 position:63% line:0%
- Yo what is going on guys,

00:00:01.530 --> 00:00:02.770 position:63% line:0%
welcome back to the channel.

フォーマットは決まっており、テキストのスタート時間と終了時間の間に話した内容がその一行下に書かれており、また一行改行を開けることで次の字幕が表示されるという仕組み 詳しくはwikiでみてもらった方がわかりやすいと思います。 要は、下のようなパターンが繰り返されています。

スタート時間 --> 終了時間 position:〇〇% line:〇〇%
テキスト
// 改行
スタート時間 --> 終了時間 position:〇〇% line:〇〇%
テキスト

こういった途切れたテキストを綺麗にしてやりたい

Before

WEBVTT
Kind: captions

00:00:00.350 --> 00:00:01.530 position:63% line:0%
- Yo what is going on guys,

00:00:01.530 --> 00:00:02.770 position:63% line:0%
welcome back to the channel.

00:00:02.770 --> 00:00:05.240 position:63% line:0%
My name's Sonny and today
I'm gonna teach you all about

00:00:05.240 --> 00:00:06.730 position:63% line:0%
the useEffect Hook

00:00:06.730 --> 00:00:08.840 position:63% line:0%
and why it has transformed.

00:00:08.840 --> 00:00:11.110 position:63% line:0%
the way that we use
functional components and why

00:00:11.110 --> 00:00:12.158 position:63% line:0%
you need to know it.
♪ I know ♪

After

WEBVTT
Kind: captions

00:00:00.350 --> 00:00:02.770 position:63% line:0%
- Yo what is going on guys, welcome back to the channel.

00:00:02.770 --> 00:00:08.840 position:63% line:0%
My name's Sonny and todayI'm gonna teach you all about the useEffect Hook and why it has transformed.

00:00:08.840 --> 00:00:12.158 position:63% line:0%
the way that we usefunctional components and why you need to know it.♪ I know ♪

今回やりたいこと

  1. ピリオドや時間の問題で途中で途切れてしまったテキストを一文にする
  2. for文だとテキストの結合にズレが出るので他の実装が必要

上記の項目を設定した理由は以下 1点目は、中途半端に途切れたテキストは中途半端な翻訳に繋がると思いました。何かサービスに利用するためということではないんですが、綺麗にしたいという欲求が勝ってしまったから

2点目はforループだと正しく結合できない 正確にはforループでも実装はできると思います。今回はピリオドが見つかったテキストを1つ前の要素につなげるといった実装をforループ以外で探りました。

前提として、今回は「スタート時間 —> 終了時間 position:〇〇% line:〇〇% テキスト」を1つの要素という単位で構造体に落とし込んでいます。

// 最初の2行のヘッダーの部分
type Header struct {
	Head string `json:"head"`
	Note string `json:"note"`
}

type Element struct {
	StartTime string `json:"start_time"`
	EndTime   string `json:"end_time"`
	Position  string `json:"position"`
	Line      string `json:"line"`
	Text      string `json:"text"`
	Separator string `json:"separator"`
}

2つ以上の要素をつなぐことができる場合、普通にループを回すとズレが生じます。 ピリオドを見つけた時点では、3つ目の要素を2つ目に追加したとしても2つ目の要素は本来1つ目の要素に 追加されていなければならないので、正しくありません。

00:00:02.770 --> 00:00:05.240 position:63% line:0%
My name's Sonny and today
I'm gonna teach you all about

00:00:05.240 --> 00:00:06.730 position:63% line:0%
the useEffect Hook

00:00:06.730 --> 00:00:08.840 position:63% line:0%
and why it has transformed.

どういう実装を取ったのか

vttファイルの読み込みと構造体への落とし込み

  • ①ファイルの読み込みは拡張子のチェックとファイルの読み込みを行なっています。
  • ②スキャナーとヘッダーの初期化を行なっています
  • ③WebVttレシーバにScanLinesメソッドを用意しており、ここで一行ずつ読み込み構造体へ落とし込んでいます。 ScanSplitFuncメソッドをScanLinesメソッドで使用します。 ScanSplitFuncは一行ずつ読み込んだテキストをif文で正規表現のいずれかにマッチした場合空白区切りでトークン化した文字列を返すメソッドです。ScanLinesメソッドの各ケースごとにトークン化した文字列とマッチしたケースがある場合のWebVTT構造体のいずれかに代入します。 どれにも当てはまらない場合はTextに代入します。 改行はトークンが空白で返ってくるので、WebVTT構造体に要素を追加し、初期化しています。

type WebVttString string

func ReadVTT(filename string) (WebVttString, error) {
	ext := filepath.Ext(filename)
	if ext != ".vtt" {
		return "", errors.New("your input file extension is not `.vtt`. check your file extension")
	}

	bytesFile, err := os.ReadFile(filename)
	if err != nil {
		return "", err
	}
	if string(bytesFile) == "" {
		return "", errors.New("file content is empty")
	}
	return WebVttString(bytesFile), nil
}

func New(file WebVttString) *WebVtt {
	f := string(file)
	scanner := bufio.NewScanner(strings.NewReader(f))
	header := NewHeader()
	return &WebVtt{File: f, Scanner: scanner, Header: header}
}

// ScanLines 一行ずつ読み込んで構造体を作成するメソッド
func (wv *WebVtt) ScanLines(splitFunc bufio.SplitFunc) {
	vttElement := wv.NewElement()
	wv.Scanner.Split(splitFunc)
	var vttElementFlag int

	for wv.Scanner.Scan() {
		line := wv.Scanner.Text()
		switch {
		case sub.CheckHeader(line):
			if wv.Header.Head != "" && wv.Header.Note != "" {
				continue
			}
			if line == "WEBVTT" {
				wv.Header.Head = line
			} else {
				wv.Header.Note = line
			}
		case sub.CheckStartOrEndTime(line):
			if vttElementFlag == 0 {
				vttElementFlag++
				vttElement.StartTime = line
			} else {
				vttElement.EndTime = line
				vttElementFlag--
			}

		case sub.CheckSeparator(line):
			vttElement.Separator = line

		case sub.CheckPosition(line):
			vttElement.Position = line

		case sub.CheckLine(line):
			vttElement.Line = line

		case line == "":
			wv.AppendElement(vttElement)
			vttElement = wv.NewElement()
		default:
			vttElement.Text += line
		}
	}

	if err := wv.Scanner.Err(); err != nil {
		fmt.Fprintln(os.Stderr, "reading standard input:", err)
	}
	// Skip head element header
	wv.Elements = wv.Elements[1:]
}

func ScanSplitFunc(data []byte, atEOF bool) (advance int, token []byte, err error) {
	advance, token, err = bufio.ScanLines(data, atEOF)
	tokenStr := string(token)
	// CheckTimeRegexpFlagでtrueが走るとその行を空白で単語区切りにする。トークン区切りになった他の`-->`や`position...`を他のフラグで検索
	if sub.CheckStartOrEndTime(tokenStr) || sub.CheckSeparator(tokenStr) || sub.CheckPosition(tokenStr) || sub.CheckLine(tokenStr) {
		{
			advance, token, err = bufio.ScanWords(data, atEOF)
			return
		}
	}
	return
}

テキストの結合をする

ここが今回のポイントなのかなと思っています。

  1. ①RecursiveSearchTerminalPointメソッドで、各要素のテキストに「.」か「?」を含んでいるかをチェックしています。含まれている場合はその要素までのどれだけ進めばいいかの値を返しています(untilTerminalPointCnt)。含まれていない場合、untilTerminalCntに値を足し、次の要素をチェック

// UnifyText `.` か `?`を含んでいたら1つ前の構造体にTextを渡し、EndTimeを更新するメソッド
func UnifyText(webVtt *WebVtt) *WebVtt {
	ves := webVtt.Elements
	for i := 0; i < len(ves)-1; i++ {

		// どこまでのテキストを繋げてよいかを表す値を取得
		untilTerminalPointCnt := RecursiveSearchTerminalPoint(ves, i)
		for j := untilTerminalPointCnt; j > i; j-- {
			t := ves[j].Text
			e := ves[j].EndTime
			ves[j-1].Text += " " + t
			ves[j-1].EndTime = e
			ves[j].Text = ""
		}
		// 文末を表現するトークンを見つけた位置まで移動
		if untilTerminalPointCnt > 0 {
			i = untilTerminalPointCnt
		}
	}
	webVtt.Elements = ves
	return webVtt
}

// RecursiveSearchTerminalPoint SearchTerminalTokenRegexp メソッドで文末トークンが見つかるまでの構造体の個数を返す
func RecursiveSearchTerminalPoint(vs []*Element, untilTerminalCnt int) int {
	//最後の要素までたどり着いた場合
	if untilTerminalCnt == len(vs)-1 {
		return untilTerminalCnt
	}
	e := vs[untilTerminalCnt].Text
	locs := sub.SearchTerminalToken(e)
	//含まれていない場合True
	f := func(locs []int) bool {
		return len(locs) == 0
	}
	//含まれていないので次の要素へ
	if f(locs) {
		untilTerminalCnt++
		return RecursiveSearchTerminalPoint(vs, untilTerminalCnt)
	}
	return untilTerminalCnt
}
// SearchTerminalToken 「.」か「?」を含んでいるか
func SearchTerminalToken(token string) []int {
	r, _ := regexp.Compile("[.?]")
	locs := r.FindStringIndex(token)
	return locs
}

空のテキストを持つ要素の削除

  1. DeleteElementOfEmptyTextメソッドでからのテキストを削除しきるまでループします。 forループだとインデックスエラーになるので少し工夫が必要。 要素のテキストが空の場合、削除した後に1つ前のインデックスに戻します。 その後に再度インデックスを足してあげることでインデックスエラーを避けています。 最後までたどり着いたら、ループを抜けるような実装にしています。
2
func DeleteElementOfEmptyText(webVtt *WebVtt) {
	var i int
	f := true
	es := webVtt.Elements
	// 空のテキストを持つ構造体を削除し切るまでループ
	for f {
		// ここで空のテキストを持つ構造体を削除
		if es[i].Text == "" {
			es = append(es[:i], es[i+1:]...)
			i--
		}
		i++
		if len(es) == i {
			f = false
		}
	}
	webVtt.Elements = es
}

まとめ

久しぶりに自分の書いたコードを振り返って、なんでこんな書き方してんだろうとか第三者目線で振り返ってしまい 書き方を統一化させることやコメントって大事だなと実感しました

← Works 一覧へ戻る