PEG Parserで.srtと.vttのパーサーを書いてみた話

こんにちは、エンジニアの渡辺(@mochi_neko_7)です。

今回は映像の字幕テキストを扱うためのフォーマットである SubRip Subtitle (.srt) と WebVTT (.vtt) のパーサーを PEG (Parsing Expression Grammar) を用いて Rust で実装してみた話を紹介します。

唐突ですが、OpenAI の audio/transcriptions APIresponse_format の説明に

The format of the transcript output, in one of these options: json, text, srt, verbose_json, or vtt.

とありますよね。

json, text,verbose_json はすぐに分かりますが、srtvtt を見たことがなかったので調べてみるとそれぞれ、SubRip Subtitle、WebVTT と呼ばれる映像の字幕テキストのフォーマットと知りました。

en.wikipedia.org

www.w3.org

crates.io で検索してみるとそれっぽい crate がいくつかあるものの、分かりやすくメジャーに使われているものがなく、自分でパーサーを書く経験をしてみたいことから、自分で crate を書くことにしました。

SubRip Subtitle と WebVTT は別のフォーマットではありますが、WebVTT が SubRip Subtitle の発展として作成されたものらしく構造が似ているため、二つを同時に実装してもそれほど手間は変わりませんでした。

テキストファイルのパーサーを自分で書きたい方、特に PEG Parser の使用例として参考になれば幸いです。

環境

  • Rust v1.75.0
  • rust-peg v0.8.2
  • subtp v0.2.0

また本記事で紹介する実装は下記の Repository もしくは crate で利用可能ですので併せてご覧ください。

github.com

https://crates.io/crates/subtpcrates.io

PEG Parserとは

PEG (Parsing Expression Grammar) に関しては下記記事で詳しく解説されています。

zenn.dev

Rust 向けには rust-peg (パッケージ名は peg) という crate を利用できます。

docs.rs

マクロベースの API で一見とっつきづらいように見えますが、Result ベースのエラーハンドリングも対応されていて、小さい単位の rule を使いまわせる点がプログラミングっぽいです。

一点、 rule を module を跨いで再利用できないのがちょっと使いづらいくらいでしょうか。

今回はこちらの rust-peg を使用して各フォーマットのパーサーを書いていきます。

SubRip Subtitle (.srt)

SubRip Subtitle の正式な仕様書が見つからなかったのでいくつかのページを参照しつつ、パーサーを定義していきます。

典型的には、次の Subtitle ブロック

1
00:00:01,000 --> 00:00:06,000
Hello, world!

を空行を挟んで複数記述したもの、例えば

1
00:00:00,498 --> 00:00:02,827
- Here's what I love most
about food and diet.

2
00:00:02,827 --> 00:00:06,383
We all eat several times a day,
and we're totally in charge

3
00:00:06,383 --> 00:00:09,427
of what goes on our plate
and what stays off.

といったものが SubRip Subtitle の基本フォーマットです。

上記サンプルは こちら からお借りしました。

ブロックの構造は上から連番の番号、開始と終了のタイムスタンプ、複数行可能な字幕テキスト、なので例えば

pub struct SrtSubtitle {
    pub sequence: u32,
    pub start: SrtTimestamp,
    pub end: SrtTimestamp,
    pub text: Vec<String>,
}

pub struct SrtTimestamp {
    pub hours: u8,
    pub minutes: u8,
    pub seconds: u8,
    pub milliseconds: u16,
}

のようにデータ構造を定義できます。(ここでは簡単のために derive とコメントは省略しています。)

肝心のパーサーの定義は、peg::parser! マクロを使って定義をします。

peg::parser! {
    grammar srt_parser() for str {
    
    // rules

    }
}

PEG では要素分解して rule を記述し再利用できますので、まず細かいパーツのパーサーから一つずつ定義していきます。

先頭の連番は1以上の整数ですが、例えば u32 の範囲を想定して下記のように rule を書けます。

rule number() -> u32
    = n:$(['0'..='9']+) {?
        n.parse().or(Err("number in u32"))
    }

構文としては、rule {rule名} ({引数}) -> {戻り値の型} = {rule定義} { {パース処理} } のような形式で記述をします。

rule 定義の n:$(['0'..='9']+) は、'0'..='9' の文字(つまり数字)が一つ以上のものを変数名 n と定義する、という意味です。

この変数 n をパース処理内で利用することができ、今回は n.parse()u32 にパースしています。

{? ... }?{ ... } 内でエラーを扱いたい場合、つまりパース処理の戻り値を Result にしたい場合に使用します。

パースに失敗する、例えば数字以外や u32 の範囲を超える場合には Err を返します。

同様に二桁、三桁の数字の rule を定義し、その組み合わせのタイムスタンプを定義しましょう。

rule two_number() -> u8
    = n:$(['0'..='9']['0'..='9']) {?
        n.parse().or(Err("two-digit number"))
    }

rule three_number() -> u16
    = n:$(['0'..='9']['0'..='9']['0'..='9']) {?
        n.parse().or(Err("three-digit number"))
    }

rule timestamp() -> SrtTimestamp
    = hours:two_number() ":" minutes:two_number() ":" seconds:two_number() "," milliseconds:three_number()
    {
        SrtTimestamp {
            hours,
            minutes,
            seconds,
            milliseconds,
        }
    }

hours:two_number() のように記述することで自分で定義した rule を利用することができます。

ちなみに SubRip Subtitle のミリ秒の区切り文字が . ではなく , なのはフランス由来のもののようで、WebVTT では . が使用されることに注意します。

次にテキスト部分のパース処理も定義していきますが、その前に空白や改行コードの rule を定義しておくと便利です。

rule whitespace() = [' ' | '\t']

rule newline() = "\r\n" / "\n" / "\r"

SubRip Subtitle の字幕テキストの部分は複数行の記述が可能なので、Vec<String> にパースする rule を定義します。

rule multiline() -> Vec<String>
    = !(whitespace() / newline()) lines:$(!(whitespace()+ newline()) (!newline() [_])+ newline()) ++ ()
    {
        lines
            .iter()
            .map(|l| l.to_string().trim().to_string())
            .collect()
    }

これまでより少し複雑に見えるかと思いますので分解して説明します。

先頭の !(whitespace() / newline()) は否定先読みで、文字の先頭に空白や改行が入らないよう制御しています。

!(whitespace()+ newline()) も同様に空白のみの行を入れないようにするための制御です。

!newline() [_] が改行を含まない任意の文字なので、(!newline() [_])+ は1文字以上の文字列に相当します。

すると (!newline() [_])+ newline() は何かしらの文字と改行のパターン、つまり文字が書かれている行に相当します。

x ++ y という構文は xy を区切りとして1つ以上繰り返すパターンなので、((!newline() [_])+ newline()) ++ () は文字の書かれている行を一行以上含む、という rule になります。

これに余計な空白を無視して Vec<String> にしているのがイテレータの処理部分です。

最後にこれらの組み合わせとして、Subtitle のブロックとその繰り返しのパーサーを定義します。

rule separator() = !(newline() newline()) (whitespace() / newline())+

rule subtitle() -> SrtSubtitle
    = sequence:number() separator()
        start:timestamp() separator()* "-->" separator()* end:timestamp() separator()
        text:multiline()
    {
        SrtSubtitle { sequence, start, end, text }
    }

rule srt() -> SubRip
    = (whitespace() / newline())*
        subtitles:subtitle() ** (newline()+)
        (whitespace() / newline())*
    {
        SubRip { subtitles, }
    }

Subtitle の各要素の区切りは空白でも改行でもよく、かつ余計な空白も許容するよう separator の rule を定義しています。

SubRip 全体のパーサーは SrtSubtitle の配列ですが、前後やブロック間の余計な空白や改行を許容するよう柔軟性を持たせています。

ブロックそれぞれも改行ではなくスペースだけで区切るケースや、--> の両端にスペースを入れない場合もあるらしいので、可能な限り柔軟性を持たせておきます。

最終的なコードは下記をご覧ください。

データ構造:

github.com

パーサー:

github.com

WebVTT (.vtt)

WebVTT は SubRip Subtitle をベースにしつつも、Sequence Number の自由度が高かったり、NOTE でコメントを記述できたり、表示方法に関する設定ができたりと細かい使い勝手を考慮して作られているようです。

その分 SubRip Subtitle と比較すると仕様は複雑ですが、幸いなことに丁寧な仕様書が公開されているのでこれに従って実装すれば困りません。

www.w3.org

オプションで許容されるパターンが多かったり、設定系の種類もオプションも多いので物量が多いですが、基本は SupRip Subtitle の時と同じ要領です。

全て解説すると長くなってしまうのでここでは割愛しますが、気になる方は下記のコードをご覧ください。

データ構造:

github.com

パーサー:

github.com

サンプル

実際に OpenAI の audio/transcriptions APIresponse_formatsrtvtt を指定した場合の結果のサンプルと、そのパース結果に対応するデータを紹介します。

データ構造にはオプションの要素が多いので、適宜 Default trait を利用すると綺麗に書けます。

解析する音源はたまたま手元にあった Style-Bert-VITS-2 の合成音声を利用しました。

SubRip Subtitle

1
00:00:00,000 --> 00:00:07,000
ずんだもん、ずんこに何度かずんだもちを食べさせられてきたけど、これって実はとも食いじゃーう

<->

use subtp::srt::{SubRip, SrtSubtitle, SrtTimestamp}

let subrip = SubRip {
    subtitles: vec![
        SrtSubtitle {
            sequence: 1,
            start: SrtTimestamp {
                seconds: 0,
                ..Default::default()
            },
            end: SrtTimestamp {
                seconds: 7,
                ..Default::default()
            },
            text: vec!["ずんだもん、ずんこに何度かずんだもちを食べさせられてきたけど、これって実はとも食いじゃーう".to_string()],
            ..Default::default()
        },
    ],
};

WebVTT

WEBVTT

00:00:00.000 --> 00:00:07.000
ずんだもん、ずんこに何度かずんだもちを食べさせられてきたけど、これって実はとも食いじゃーう

<->

use subtp::vtt::{WebVtt, VttCue, VttTimings, VttTimestamp}

let webvtt = WebVtt {                                                         
    blocks: vec![                                                
        VttCue {                                                 
            timings: VttTimings {                                
                start: VttTimestamp {                            
                    seconds: 0,                                  
                    ..Default::default()                         
                },                                               
                end: VttTimestamp {                              
                    seconds: 7,                                  
                    ..Default::default()                         
                },                                               
            },                                                   
            payload: vec!["ずんだもん、ずんこに何度かずんだもちを食べさせられてきたけど、これって実はとも食いじゃーう".to_string()],
            ..Default::default()                                 
        }                                                        
        .into(),                                                                                         
    ],                                                           
    ..Default::default()                                         
};

おわりに

SubRip Subtitle や WebVTT はこれまで触ったことのないファイルフォーマットでしたが、これらを通して PEG Parser の使い方を理解することができました。

PEG は直感的でない挙動もするという話も聞きますが、テストコードを書きながらデバッグしていけば PEG の仕様の理解も深まっていくと思います。

自分でテキストのパーサーを書いてみたい方に参考になれば幸いです。