日本語版wav2vec2でforced-alignment

# はじめに
pythonのtransformersライブラリからロードできる、日本語版wav2vec2モデル（[reazon-research/japanese-wav2vec2-base-rs35kh](https://huggingface.co/reazon-research/japanese-wav2vec2-base-rs35kh)）を用いて、forced-alignmentを試みます。

forced-alignmentは、音声とそのトランスクリプトが与えられた際に、トランスクリプト内の各トークンが発生したタイムスタンプを特定する技術です。応用先として、動画への自動字幕付与などがあります。

[前回](https://qiita.com/shimajiroxyz/items/ccbef2f851b15252cfa6)は、torchaudioのバンドルにパッケージングされたモデルを用いてforced-alignmentを行う方法を試し、簡単かつそこそこ精度に実行できることを確認しました。今回は、バンドルと比べると多少ローレベルなAPIであるtorchaudio.functional.forced-align関数を直接使用し、transformers経由で利用可能な日本語版wav2vec2モデル`reazon-research/japanese-wav2vec2-base-rs35kh`を用いてforced-alignmentを実施します。

参考：これまでの試行錯誤
[【試行錯誤】OpenAI Whisperを活用した日本語歌詞のforced-alignment リンクまとめ](https://qiita.com/shimajiroxyz/items/c91c5074fd24608ea5b1)
# 方針
torchaudioのチュートリアル「[CTC forced alignment API tutorial](https://pytorch.org/audio/stable/tutorials/ctc_forced_alignment_api_tutorial.html)」を参考に実装します。

[torchaudio.functional.forced_align](https://pytorch.org/audio/main/generated/torchaudio.functional.forced_align.html#torchaudio.functional.forced_align)は、音声波形の各フレームに対するトークンの確率を示す行列log_probsと、エンコードされたトランスクリプト（targets）を入力として受け取ります。これらの入力をtransformersのモデルで生成します。

これを踏まえ以下の手順で進めます。

1. transformersを使用してモデルとトーカナイザをロードします。
2. モデルでlog_probsを取得し、トーカナイザでtargetsを生成します。
3. 生成したデータをforced_align関数に入力します。
4. 結果を後処理し、確認します。

# 環境
- ハードウェア: M1 macOS
- ソフトウェア
  - python v3.12.4
  - 使用ライブラリ（pipでインストール）
    - torch 2.2.2
    - torchaudio 2.2.2
    - numpy 1.26.4
    - transformers 4.46.2
    - mecab-python3 1.0.10
    - ipadic 1.0.0
  - 実行環境: jupyter notebook

# 実装
## サンプル音源
JVSコーパスのサンプル音源（[sample_jvs001](https://sites.google.com/site/shinnosuketakamichi/research-topics/jvs_corpus)）を使用します。リンク先からダウンロードし、`jvs001_VOICEACTRESS100_001.wav`というファイル名で保存してください。

## log_probs
[japanese-wav2vec2-base-rs35kh](https://huggingface.co/reazon-research/japanese-wav2vec2-base-rs35kh)のUsageを参考にして、log_probsを取得します。


```Python
import librosa
import numpy as np
from transformers import AutoProcessor, Wav2Vec2ForCTC
import torch
import torchaudio

model_name = "reazon-research/japanese-wav2vec2-base-rs35kh"
model_sampling_rate = 16000 # modelのconfig等から取得はできなさそうだったのでハードコーディング。wav2vec2は基本16000らしい
audio_filepath = "jvs001_VOICEACTRESS100_001.wav"

device = "cuda" if torch.cuda.is_available() else "cpu"
model = Wav2Vec2ForCTC.from_pretrained(
    model_name,
    torch_dtype=torch.bfloat16,
    #attn_implementation="flash_attention_2", # flash attentionはcpuでは使えないので、今はmacで実行しているので
).to(device)
processor = AutoProcessor.from_pretrained(model_name)

audio, _ = librosa.load(audio_filepath, sr=model_sampling_rate) # モデルのサンプリングレートに合わせる
audio = np.pad(audio, pad_width=int(0.5 * model_sampling_rate))  # Recommend to pad audio before inference
input_values = processor(
    audio,
    return_tensors="pt",
    sampling_rate=model_sampling_rate
).input_values.to(device).to(torch.bfloat16)

with torch.inference_mode():
    logits = model(input_values).logits.cpu()
log_probs = torch.nn.functional.log_softmax(logits, dim=-1)
```

元のUsageから以下の点を変更しています。

- Wav2Vec2ForCTC.from_pretrainedのattn_implementationはコメントアウトしています。これは、MacではCPUしか使用できないためです。GPUおよびflash_attetnionを使用できる環境ではそのままで良いと思います。
- 最終行でlogitsをlog_softmaxに変換しています。

log_probsの内容は以下のようになっています。バッチサイズ、時間方向の系列長（フレーム数）、トークンの種類数の3次元のテンソルです。

```Python
print(log_probs.shape)
print(log_probs)
```

```
torch.Size([1, 480, 3003])
tensor([[[  0.0000, -15.3125, -41.5000,  ..., -41.0000, -41.5000, -41.2500],
         [  0.0000, -15.8750, -42.2500,  ..., -41.5000, -42.5000, -42.0000],
         [  0.0000, -16.7500, -43.5000,  ..., -43.0000, -43.7500, -43.5000],
         ...,
         [  0.0000, -14.6250, -40.0000,  ..., -39.5000, -40.0000, -40.0000],
         [  0.0000, -10.5625, -39.5000,  ..., -39.2500, -39.2500, -39.5000],
         [ -6.0000,   0.0000, -33.7500,  ..., -34.0000, -33.7500, -33.7500]]],
       dtype=torch.bfloat16)
```

## トランスクリプト
モデルのトークナイザーを使用して、トランスクリプトをトークンIDの系列に変換します。
トークナイザーが日本語に対応しているため、トランスクリプトもローマ字化せず漢字仮名交じりのままにします。

```Python
from transformers import Wav2Vec2CTCTokenizer
tokenizer = Wav2Vec2CTCTokenizer.from_pretrained(model_name)

text_raw = "また当時のように五大明王と呼ばれる主要な明王の中央に配されることも多い"

targets = tokenizer(text_raw, return_tensors="pt").input_ids
```

targetsの内容は以下の通りです。バッチサイズとトークン系列長を持つ2次元のテンソルです。

```Python
print(targets.shape)
print(targets)
```

```
torch.Size([1, 26])
tensor([[ 247, 1130,    3,  233, 1368,   53,  372,  855,   11, 1143,  119,  341,
          423,  897,   13,  372,  855,    3,   50, 1637,    5,  634,  553,   68,
           12,  788]])
```


## forced-align
ここからtorchaudioのチュートリアルを参考にしています。
forced_alignのラッパーとしてalign関数を定義し、log_probsとtargetsを入力します。元のalign関数からtransformersのtokenizerを想定して少しだけ書き換えています。

```Python
def align(emission, tokens):
    # transformersの出力を使用する場合はネスト不要
    #targets = torch.tensor([tokens], device=device) 
    targets = torch.tensor(tokens, device=device)
    alignments, scores = torchaudio.functional.forced_align(
        emission.to(torch.float32),
        targets,
        blank=0
    )
    
    alignments, scores = alignments[0], scores[0] # remove batch dimension for simplicity
    scores = scores.exp() # convert back to probability
    return alignments, scores

aligned_tokens, alignment_scores = align(log_probs, targets)
for i, (ali, score) in enumerate(zip(aligned_tokens, alignment_scores)):
    if ali != 0:
        print(f"{i:3d}:\t{ali:2d} [{tokenizer.decode(ali)}], {score:.2f}")
```

```
 50:	247 [また], 1.00
 93:	1130 [当時], 0.22
115:	 3 [の], 1.00
133:	233 [ように], 1.00
166:	1368 [五], 0.70
176:	53 [大], 0.44
189:	372 [明], 0.00
200:	855 [王], 0.94
208:	11 [と], 1.00
217:	1143 [呼], 0.99
224:	119 [ば], 1.00
229:	341 [れる], 1.00
263:	423 [主], 0.98
276:	897 [要], 0.99
288:	13 [な], 0.98
295:	372 [明], 0.33
307:	855 [王], 0.98
318:	 3 [の], 1.00
327:	50 [中], 1.00
344:	1637 [央], 1.00
355:	 5 [に], 0.99
361:	634 [配], 0.13
381:	553 [される], 1.00
391:	68 [こと], 0.98
404:	12 [も], 1.00
411:	788 [多い], 0.99
```

全体的に良い結果が得られていますが、すべてのトークンが1フレームに収まっている点が少し気になります。おそらく終了フレームが予測できていません。今回は一旦このまま進めます。


## トークンレベルのアライメント
torchaudio.functional.merge_tokensを使用して、各フレームのトークン系列をトークンのスパンに変換します。

```Python
token_spans = torchaudio.functional.merge_tokens(aligned_tokens, alignment_scores)

print("Token\tTime\tScore")
for s in token_spans:
    print(f"{tokenizer.decode(s.token)}\t[{s.start:3d}, {s.end:3d})\t{s.score:.2f}")
```

```
Token	Time	Score
また	[ 50,  51)	1.00
当時	[ 93,  94)	0.22
の	[115, 116)	1.00
ように	[133, 134)	1.00
五	[166, 167)	0.70
大	[176, 177)	0.44
明	[189, 190)	0.00
王	[200, 201)	0.94
と	[208, 209)	1.00
呼	[217, 218)	0.99
ば	[224, 225)	1.00
れる	[229, 230)	1.00
主	[263, 264)	0.98
要	[276, 277)	0.99
な	[288, 289)	0.98
明	[295, 296)	0.33
王	[307, 308)	0.98
の	[318, 319)	1.00
中	[327, 328)	1.00
央	[344, 345)	1.00
に	[355, 356)	0.99
配	[361, 362)	0.13
される	[381, 382)	1.00
こと	[391, 392)	0.98
も	[404, 405)	1.00
多い	[411, 412)	0.99
```

トークンの開始と終了がまとめられ、より扱いやすくなりました。

## 単語レベルのアライメント
トークンレベルでは、耳で聞いて結果を確認するのが難しいため、単語レベルでさらにアライメントをマージします。
英語の場合はスペースで分割するだけで済みますが、日本語の場合は形態素解析が必要です。
wav2vec2のトークナイザの分割単位と形態素解析の分割単位が微妙に異なるため、工夫が必要です。今回は、両者の分割単位にずれがある場合、ちょうど一致するところまでスパンを延長する方法を試みます。

```Python
import MeCab
mecab = MeCab.Tagger("-Owakati")

def get_word_lengths(text):
    words = mecab.parse(text).strip().split()
    lengths = [len(word) for word in words]
    return lengths

def get_token_lengths(text, wav2vec_tokenizer):
    lengths = []
    for token_id in wav2vec_tokenizer.encode(text):
        token_text = wav2vec_tokenizer.decode(token_id)
        lengths.append(len(token_text))
    return lengths

def get_combined_lengths(word_lengths, token_lengths):
    lengths = []
    word_index = 0
    token_index = 0
    while word_index < len(word_lengths) and token_index < len(token_lengths):
        current_word_length = word_lengths[word_index]
        current_token_length = token_lengths[token_index]
        token_num = 1
        while current_word_length != current_token_length:
            if current_word_length < current_token_length:
                word_index += 1
                if word_index >= len(word_lengths):
                    break
                current_word_length += word_lengths[word_index]
            elif current_word_length > current_token_length:
                token_index += 1
                if token_index >= len(token_lengths):
                    break
                token_num += 1
                current_token_length += token_lengths[token_index]
        lengths.append(token_num)
        word_index += 1
        token_index += 1
    # Check if there are remaining tokens that were not processed
    while token_index < len(token_lengths):
        lengths[-1] += 1
        token_index += 1
    return lengths

def unflatten(list_, lengths):
    assert len(list_) == sum(lengths)
    i = 0
    ret = []
    for l in lengths:
        ret.append(list_[i : i + l])
        i += l
    return ret

def get_word_spans(token_spans, text, wav2vec_tokenizer):
    word_lengths = get_word_lengths(text)
    token_lengths = get_token_lengths(text, wav2vec_tokenizer)
    combined_lengths = get_combined_lengths(word_lengths, token_lengths)
    word_spans = unflatten(token_spans, combined_lengths)
    words_for_spans = []
    current_token_pos = 0
    current_text_pos = 0
    for length in combined_lengths:
        word_span_length = sum(token_lengths[current_token_pos:current_token_pos + length])
        words_for_spans.append(text[current_text_pos:current_text_pos + word_span_length])
        current_token_pos += length
        current_text_pos += word_span_length
    return word_spans, words_for_spans

print(get_word_spans(token_spans, text_raw, tokenizer))
```

```
([[TokenSpan(token=247, start=50, end=51, score=1.0)], [TokenSpan(token=1130, start=93, end=94, score=0.21626517176628113)], [TokenSpan(token=3, start=115, end=116, score=1.0)], [TokenSpan(token=233, start=133, end=134, score=1.0)], [TokenSpan(token=1368, start=166, end=167, score=0.7035878896713257), TokenSpan(token=53, start=176, end=177, score=0.4351644515991211), TokenSpan(token=372, start=189, end=190, score=0.00360656320117414), TokenSpan(token=855, start=200, end=201, score=0.941249668598175)], [TokenSpan(token=11, start=208, end=209, score=1.0)], [TokenSpan(token=1143, start=217, end=218, score=0.9922482371330261), TokenSpan(token=119, start=224, end=225, score=1.0)], [TokenSpan(token=341, start=229, end=230, score=1.0)], [TokenSpan(token=423, start=263, end=264, score=0.9846166372299194), TokenSpan(token=897, start=276, end=277, score=0.9922482371330261)], [TokenSpan(token=13, start=288, end=289, score=0.9846166372299194)], [TokenSpan(token=372, start=295, end=296, score=0.3323513865470886), TokenSpan(token=855, start=307, end=308, score=0.9846166372299194)], [TokenSpan(token=3, start=318, end=319, score=1.0)], [TokenSpan(token=50, start=327, end=328, score=1.0), TokenSpan(token=1637, start=344, end=345, score=1.0)], [TokenSpan(token=5, start=355, end=356, score=0.9922482371330261)], [TokenSpan(token=634, start=361, end=362, score=0.1271357387304306), TokenSpan(token=553, start=381, end=382, score=1.0)], [TokenSpan(token=68, start=391, end=392, score=0.9846166372299194)], [TokenSpan(token=12, start=404, end=405, score=1.0)], [TokenSpan(token=788, start=411, end=412, score=0.9922482371330261)]], ['また', '当時', 'の', 'ように', '五大明王', 'と', '呼ば', 'れる', '主要', 'な', '明王', 'の', '中央', 'に', '配される', 'こと', 'も', '多い'])
```

良い感じです。

word_spansに対応する区間を再生する関数を作成し、確認します。

```Python
import IPython
# Compute average score weighted by the span length
def _score(spans):
    return sum(s.score * len(s) for s in spans) / sum(len(s) for s in spans)


def preview_word(waveform, spans, num_frames, transcript, sample_rate=model_sampling_rate):
    ratio = waveform.size(1) / num_frames
    x0 = int(ratio * spans[0].start)
    x1 = int(ratio * spans[-1].end)
    print(f"{transcript} ({_score(spans):.2f}): {x0 / sample_rate:.3f} - {x1 / sample_rate:.3f} sec")
    segment = waveform[:, x0:x1].to(torch.float32)
    return IPython.display.Audio(segment.numpy(), rate=sample_rate)

word_spans, words_for_spans = get_word_spans(text_raw, tokenizer)
num_frames = log_probs.size(1)
word_index = 0
preview_word(input_values, word_spans[word_index], num_frames, words_for_spans[word_index])
```

```
また (1.00): 1.002 - 1.022 sec
# notebookで実行した場合、Audio再生も合わせて表示
```

音声を確認すると、開始時刻は適切ですが、終了時刻が早すぎることがわかります。forced-alignmentの結果を見た際に懸念された、各トークンが1フレーム以内に収まるように検出される問題が影響しているようです。つまり、このモデルはトークンの開始は予測できても、終了は予測できない可能性があります。forced-alignmentには、専用のモデルを使用する方が良いかもしれません。

一時的な解決策としては以下の方法が考えられます。

- 次のトークンの開始時刻を現在のトークンの終了時刻とする
- トークンの持続時間を一定時間延長する

今回は、前者の方法を試してみます。

```Python
def preview_word_until_next(waveform, spans, num_frames, transcript, sample_rate=model_sampling_rate
                 , next_spans = None):
    ratio = waveform.size(1) / num_frames
    x0 = int(ratio * spans[0].start)
    if next_spans is not None:
        x1 = int(ratio * next_spans[0].start)
    else:
        x1 = int(ratio * spans[-1].end)
    print(f"{transcript} ({_score(spans):.2f}): {x0 / sample_rate:.3f} - {x1 / sample_rate:.3f} sec")
    segment = waveform[:, x0:x1].to(torch.float32)
    return IPython.display.Audio(segment.numpy(), rate=sample_rate)

word_spans, words_for_spans = get_word_spans(token_spans, text_raw, tokenizer)
num_frames = log_probs.size(1)
word_index = 0
preview_word_until_next(input_values, word_spans[word_index], num_frames, words_for_spans[word_index], next_spans=word_spans[word_index + 1] if word_index + 1 < len(word_spans) else None)
```

```
また (1.00): 1.002 - 1.864 sec
# notebookで実行した場合、Audio再生も合わせて表示
```

音声を確認すると、全体的に良い感じがします。ただし、「ように」などの長いトークンの場合、開始時刻が少しずれているように感じました。また、今回の音源は一息で文章が読まれているため問題ありませんが、息継ぎがある場合、この方法では終了時刻が逆に遅くなりすぎる可能性があります。

# おわりに

transformersのWav2Vec2モデルとtorchaudioのforced-align関数を組み合わせて、forced-alignmentを試しました。日本語テキストで学習されたwav2vec2モデルを使用したため、トーカナイザが日本語に対応しており、ローマ字化や復元が不要で便利でした。しかし、トークンの終了時刻のタイムスタンプが正確に取得できないという課題がありました。モデルによるかもしれませんが、現時点ではtorchaudioのマルチリンガルモデル（MMS_FA）のほうが使いやすいかもしれません。今後、日本語のforced-alignmentに特化したモデルが登場した場合、精度を比較してみたいです。

# 付録（試行錯誤メモ）
実装の過程で悩んだ点や試行錯誤した内容をメモします。

## torchaudio.functional.forced_align
[torchaudio.functional.forced_align](https://pytorch.org/audio/main/generated/torchaudio.functional.forced_align.html#torchaudio.functional.forced_align)を使用するためには、その関数がどのような形式の入力を受け付けるのか理解する必要があります。ドキュメントによれば以下の通りです。特にlog_probsやtargetsの形状、input_lengthsとtarget_lengthsの役割について理解に時間がかかったため、メモを残します。

```Python
torchaudio.functional.forced_align(
    log_probs: Tensor,
    targets: Tensor, 
    input_lengths: Optional[Tensor] = None, 
    target_lengths: Optional[Tensor] = None, 
    blank: int = 0) → Tuple[Tensor, Tensor]
```

log_probsは、音声データの各フレームがどのトークンに対応するかの確率を示します。これはwav2vec2モデルに音声波形の行列を入力することで得られます。形状は(B, T, C)で、Bはバッチサイズを表し、1つの音源をforced-alignmentする場合は1です。Tは時間方向の系列長（フレーム数）で、音源の長さに依存します。Cはトークンの種類数で、モデルによりますが、今回使用したreazon-research/japanese-wav2vec2-base-rs35khでは3003です。

targetsは、トランスクリプトに対応するトークンIDの系列です。形状は(B, L)で、Bはバッチサイズを表し、1つの音源を処理する場合は1です。Lはトークンの系列長です。

input_lengthsは、log_probsの一部のみを解析対象としたい場合に指定できます。形状は(B,)で、バッチごとの長さのリストです。Noneの場合、全体が使用されます。通常はNoneで問題ありません。

target_lengthsは、targetsの一部のみを解析対象としたい場合に指定します。形状は(B,)で、バッチごとの長さのリストです。Noneの場合、全体が使用されます。通常はNoneで問題ありません。

blankは、空白（無音）を表すトークンのIDです。もし0がblank以外のトークンに対応するモデルを使用している場合は、変更が必要です。

torchaudio.pipelineのバンドルを使用する場合、modelやtokenizerの戻り値をforced_align関数に入力できます。transformersなどの独自モデルと組み合わせる場合は、log_probsやtargetsが対応する形式になっていることを確認し、必要に応じて整形する必要があります。

## トークンの種類
reazon-research/japanese-wav2vec2-base-rs35khのトーカナイザが認識できる文字の種類を確認するために、出力を確認しました。

適当な文字列をトークン化してみます。

```Python
from transformers import Wav2Vec2CTCTokenizer
tokenizer = Wav2Vec2CTCTokenizer.from_pretrained(model_name)

text = "Hello, world! こんにちは。"
input_tokens = tokenizer(text, return_tensors="pt").input_ids
print(input_tokens)
print(input_tokens.shape)
```

```
tensor([[   0,  493, 1223, 1223,  487,    0,    0, 1386,  487,  714, 1223,  978,
           22,    0,  113,   31,    5,  146,    7,    1]])
torch.Size([1, 20])
```

漢字仮名交じりの文字列の入力がtoken_idの系列に正しく変換されていることが確認できます。

tokenizerのトークン分割単位を確認するために、decodeを試してみます。

```Python
decoded_tokens = tokenizer.decode(input_tokens[0], skip_special_tokens=False, spaces_between_special_tokens=True)
print(decoded_tokens)
```

```
<unk> e l o <unk> w o r l d! <unk> こ ん に ち は 。
```

トークンごとにスペースで分かち書きされることを期待していましたが、連続する文字（l）が一つにまとめられていたり、dと!の間にスペースがなかったりして、少し見にくい形式です。ただし、連続する文字がまとめられることは、音声認識結果のデコーダーとしては理にかなった挙動だと思います。

見にくいので、個別にデコードしてみます。

```Python
decoded_tokens = " ".join([tokenizer.decode(token) for token in input_tokens[0]])
print(decoded_tokens)
```

```
<unk> e l l o <unk> <unk> w o r l d ! <unk> こ ん に ち は 。
```

大文字のアルファベットとカンマが<unk>として扱われています。!や。はトークン化されていますが、forced-alignmentの目的を考慮すると、発音しない要素なので、前処理で除去するのが適切かもしれません。

## log_probsの確認

終了時刻の予測が非常に難しいかどうかを確認するため、log_probsを直接確認します。
各フレームでblankを除いた最大スコアのトークンを求め、そのスコアがしきい値以上の場合に出力します。
結果は以下の通りです。


```Python
threshold = 0.0001
# log_probsの中でsoftmaxの最大帯がthreshold以上かつblankでないtoken_idを出力
for i, frame in enumerate(log_probs[0]):
    sorted_indices = torch.argsort(frame, descending=True)
    for j in sorted_indices:
        if j != 0 and frame[j].exp().item() > threshold:
            print(f"Frame {i}, Token ID {j}, Probability {frame[j].exp().item():.2f}, Decoded Text: {tokenizer.decode(j)}")
            break
```

```
Frame 50, Token ID 247, Probability 1.00, Decoded Text: また
Frame 65, Token ID 8, Probability 0.00, Decoded Text: 、
Frame 66, Token ID 8, Probability 0.05, Decoded Text: 、
Frame 67, Token ID 8, Probability 0.01, Decoded Text: 、
Frame 93, Token ID 2853, Probability 0.55, Decoded Text: 杜
Frame 108, Token ID 1041, Probability 0.68, Decoded Text: 氏
Frame 115, Token ID 3, Probability 1.00, Decoded Text: の
Frame 133, Token ID 233, Probability 1.00, Decoded Text: ように
Frame 139, Token ID 8, Probability 0.00, Decoded Text: 、
Frame 140, Token ID 8, Probability 0.00, Decoded Text: 、
Frame 141, Token ID 8, Probability 0.00, Decoded Text: 、
Frame 142, Token ID 8, Probability 0.00, Decoded Text: 、
Frame 166, Token ID 1368, Probability 0.70, Decoded Text: 五
Frame 176, Token ID 303, Probability 0.52, Decoded Text: 代
Frame 189, Token ID 379, Probability 0.95, Decoded Text: 名
Frame 192, Token ID 535, Probability 0.00, Decoded Text: ょ
Frame 193, Token ID 535, Probability 0.00, Decoded Text: ょ
Frame 194, Token ID 535, Probability 0.00, Decoded Text: ょ
Frame 195, Token ID 6, Probability 0.00, Decoded Text: を
Frame 196, Token ID 6, Probability 0.00, Decoded Text: を
Frame 197, Token ID 6, Probability 0.00, Decoded Text: を
Frame 198, Token ID 26, Probability 0.00, Decoded Text: お
Frame 199, Token ID 6, Probability 0.00, Decoded Text: を
Frame 200, Token ID 855, Probability 0.94, Decoded Text: 王
Frame 202, Token ID 46, Probability 0.00, Decoded Text: う
Frame 203, Token ID 46, Probability 0.09, Decoded Text: う
Frame 204, Token ID 46, Probability 0.30, Decoded Text: う
Frame 205, Token ID 46, Probability 0.13, Decoded Text: う
Frame 206, Token ID 46, Probability 0.00, Decoded Text: う
Frame 208, Token ID 11, Probability 1.00, Decoded Text: と
Frame 217, Token ID 1143, Probability 0.99, Decoded Text: 呼
Frame 223, Token ID 119, Probability 0.00, Decoded Text: ば
Frame 224, Token ID 119, Probability 1.00, Decoded Text: ば
Frame 229, Token ID 341, Probability 1.00, Decoded Text: れる
Frame 238, Token ID 8, Probability 0.00, Decoded Text: 、
Frame 239, Token ID 8, Probability 0.00, Decoded Text: 、
Frame 240, Token ID 8, Probability 0.00, Decoded Text: 、
Frame 241, Token ID 8, Probability 0.00, Decoded Text: 、
Frame 242, Token ID 8, Probability 0.00, Decoded Text: 、
Frame 263, Token ID 423, Probability 0.98, Decoded Text: 主
Frame 270, Token ID 1044, Probability 0.00, Decoded Text: ゅ
Frame 271, Token ID 1044, Probability 0.00, Decoded Text: ゅ
Frame 273, Token ID 46, Probability 0.00, Decoded Text: う
Frame 275, Token ID 897, Probability 0.00, Decoded Text: 要
Frame 276, Token ID 897, Probability 0.99, Decoded Text: 要
Frame 277, Token ID 897, Probability 0.22, Decoded Text: 要
Frame 283, Token ID 46, Probability 0.00, Decoded Text: う
Frame 284, Token ID 46, Probability 0.00, Decoded Text: う
Frame 288, Token ID 13, Probability 0.98, Decoded Text: な
Frame 295, Token ID 1863, Probability 0.43, Decoded Text: 妙
Frame 296, Token ID 58, Probability 0.00, Decoded Text: み
Frame 299, Token ID 1157, Probability 0.00, Decoded Text: ョ
Frame 300, Token ID 535, Probability 0.00, Decoded Text: ょ
Frame 301, Token ID 535, Probability 0.00, Decoded Text: ょ
Frame 302, Token ID 6, Probability 0.00, Decoded Text: を
Frame 303, Token ID 6, Probability 0.00, Decoded Text: を
Frame 304, Token ID 25, Probability 0.00, Decoded Text: ン
Frame 305, Token ID 25, Probability 0.00, Decoded Text: ン
Frame 306, Token ID 855, Probability 0.00, Decoded Text: 王
Frame 307, Token ID 855, Probability 0.98, Decoded Text: 王
Frame 308, Token ID 855, Probability 0.00, Decoded Text: 王
Frame 311, Token ID 46, Probability 0.00, Decoded Text: う
Frame 312, Token ID 46, Probability 0.00, Decoded Text: う
Frame 313, Token ID 46, Probability 0.00, Decoded Text: う
Frame 314, Token ID 46, Probability 0.00, Decoded Text: う
Frame 317, Token ID 3, Probability 0.00, Decoded Text: の
Frame 318, Token ID 3, Probability 1.00, Decoded Text: の
Frame 327, Token ID 50, Probability 1.00, Decoded Text: 中
Frame 328, Token ID 50, Probability 0.00, Decoded Text: 中
Frame 338, Token ID 46, Probability 0.00, Decoded Text: う
Frame 339, Token ID 46, Probability 0.00, Decoded Text: う
Frame 344, Token ID 1637, Probability 1.00, Decoded Text: 央
Frame 354, Token ID 5, Probability 0.00, Decoded Text: に
Frame 355, Token ID 5, Probability 0.99, Decoded Text: に
Frame 361, Token ID 1770, Probability 0.69, Decoded Text: 廃
Frame 370, Token ID 1373, Probability 0.00, Decoded Text: 医
Frame 381, Token ID 553, Probability 1.00, Decoded Text: される
Frame 391, Token ID 68, Probability 0.98, Decoded Text: こと
Frame 404, Token ID 12, Probability 1.00, Decoded Text: も
Frame 411, Token ID 788, Probability 0.99, Decoded Text: 多い
Frame 419, Token ID 26, Probability 0.00, Decoded Text: お
Frame 420, Token ID 26, Probability 0.00, Decoded Text: お
Frame 421, Token ID 26, Probability 0.00, Decoded Text: お
Frame 477, Token ID 24, Probability 0.00, Decoded Text: です
Frame 478, Token ID 24, Probability 0.00, Decoded Text: です
Frame 479, Token ID 1, Probability 1.00, Decoded Text: 。
```

結果を見ると、ひらがなが複数のフレームにわたってしきい値以上のスコアを持つスパンがあることがわかります。
したがって、漢字仮名交じりではなく、ひらがなのトランスクリプトに対してforced-alignmentを行えば、終了時刻の予測が改善する可能性があります。
機会があれば検証してみたいです。