【試行錯誤】OpenAI Whisperを活用した日本語歌詞のforced-alignment その６：音声認識結果と正解歌詞の対応付け

# 概要
歌を入力したときのWhisperの認識結果の各segmentが、正解歌詞のどの部分に対応するか紐付ける方法を説明します。

シリーズ一覧は以下
[【試行錯誤】OpenAI Whisperを活用した日本語歌詞のforced-alignment リンクまとめ](https://qiita.com/shimajiroxyz/items/c91c5074fd24608ea5b1)

# 背景
これまでWhisperの認識結果をそのまま使ってforced-alignmentをしていましたが、Whisperの性能が高いものの認識ミスもままあるため、本来は、正解歌詞を使ってalignmentをするべきです。
一方で、正解歌詞を入力とするためには、検出結果の各segmentが正解歌詞のどの部分と対応するかを決定する必要があります。
この方法については、ある程度一般性がある内容のため、別記事で解説しました。

[音声認識結果にもとづく発話区間と正解文字列の対応付け【Python】](https://qiita.com/shimajiroxyz/items/68561dfa1153c33d96f6)

本記事では上記実装を踏まえて、具体的にWhisperの認識結果と正解歌詞を対応付けて、alignmentの入力とする方法を説明します。

# Whisperの認識結果と正解歌詞のalignment
## 準備
まず正解歌詞とWhisperの認識結果を用意します。
正解歌詞はなんらかの不正でない方法で入手し、テキストファイルとしておいておきます。

```:yorunikakeru_lyric.txt
沈むように 溶けてゆくように 
二人だけの空が広がる夜に 
 
「さよなら」だけだった 
その一言で全てが分かった 
日が沈み出した空と君の姿 
フェンス越しに重なっていた 
...
```

Whisperの認識結果はjsonでおいておきます。
今回は[その２](https://qiita.com/shimajiroxyz/items/6d253a0a6c53ca65027e)でつくった、音源分離によって得たボーカル音源をWhipserのlargeモデルで認識した結果を使います。

```:largeresult_yorunikakeru_vocal.json
{
  "text": "沈むように溶けてゆくように二人だけの空が広がる夜にさよならだけだった...",
  "segments": [
    {
      "id": 0,
      "seek": 0,
      "start": 0.0,
      "end": 6.0,
      "text": "沈むように溶けてゆくように",
      "tokens": [
        50364,
        3308,
        230,
        ...
```
 
## 対応付け
Whisperの認識結果と正解歌詞を１つのデータフレームにします。
`find_correspondance`関数は[この記事](https://qiita.com/shimajiroxyz/items/68561dfa1153c33d96f6)のものです。

単語の途中で分割されることを避けるため、単語列に直してから入力します。

```Python
import MeCab
import json
import pandas as pd

mecab = MeCab.Tagger("-Owakati")

# 正解文字列と認識結果の取得
with open("lyric/yorunikakeru_lyric.txt") as f:
  correct_text = "".join(f.read().split())
with open("soundrecognition/largeresult_yorunikakeru_vocal.json") as f:
  whisper_result = json.load(f)
segments = [ { k: seg[k] for k in ["text", "start", "end"] } for seg in whisper_result["segments"]]
df = pd.DataFrame(segments)

correct_text_wakachi = tuple(mecab.parse(correct_text).split())
test_segments_wakachi = tuple(df["text"].map(lambda x: tuple(mecab.parse(x).split())))
#print(test_segments)
dist, correspondance =  find_correspondance(correct_text_wakachi, test_segments_wakachi)
print("correct dist:", ed.eval(correct_text_wakachi, [v for row in test_segments_wakachi for v in row]))
print("result dist:", dist)
print("correspondance: ", correspondance)
display_correspondance(correct_text_wakachi, test_segments_wakachi, correspondance)
```

出力は以下です。correct distとresult distが同じなので、正しい分割ができています。

```
correct dist: 38
result dist: 38
correspondance:  [(0, 8), (8, 16), (16, 29), (29, 39), (39, 46), (46, 59), (59, 74), (74, 84), (84, 96), (96, 106), (106, 113), (113, 118), (118, 127), (127, 134), (134, 143), (143, 149), (149, 161), (161, 172), (172, 181), (181, 190), (190, 206), (206, 220), (220, 228), (228, 234), (234, 241), (241, 251), (251, 258), (258, 265), (265, 276), (276, 288), (288, 297), (297, 310), (310, 319), (319, 328), (328, 336), (336, 341), (341, 351), (351, 358), (358, 366), (366, 371), (371, 380), (380, 389), (389, 398), (398, 401), (401, 409), (409, 414), (414, 415)]
test: ('沈む', 'よう', 'に', '溶け', 'て', 'ゆく', 'よう', 'に')
correct: ('沈む', 'よう', 'に', '溶け', 'て', 'ゆく', 'よう', 'に')

test: ('二人', 'だけ', 'の', '空', 'が', '広がる', '夜', 'に')
correct: ('二人', 'だけ', 'の', '空', 'が', '広がる', '夜', 'に')

test: ('さよなら', 'だけ', 'だっ', 'た', 'その', '一言', 'で', '全て', 'が', '分かっ', 'た')
correct: ('「', 'さよなら', '」', 'だけ', 'だっ', 'た', 'その', '一言', 'で', '全て', 'が', '分かっ', 'た')
...
```

分割結果をもとのデータフレームに結合します。

```Python
df["correct_text"] = ["".join(correct_text_wakachi[s:e]) for s,e in correspondance]
print(df.head())
```


```
                    text  start   end             correct_text
0          沈むように溶けてゆくように    0.0   6.0            沈むように溶けてゆくように
1           二人だけの空が広がる夜に    6.0  30.0             二人だけの空が広がる夜に
2  さよならだけだったその一言で全てが分かった   30.0  37.0  「さよなら」だけだったその一言で全てが分かった
3           陽が沈み出した空と君の姿   37.0  41.0             日が沈み出した空と君の姿
4           フェンス越しに重なってた   41.0  45.0            フェンス越しに重なっていた
```

無事正解歌詞の対応付ができました。

# アライメント
正解歌詞と対応付けしたので、そのままalignmentまで行ってみます。

correct_textをカナ（モウラ）およびローマ字に変換します。使っている関数などは[こちら](https://qiita.com/shimajiroxyz/items/e1a1ba595c77688606d7)から参照してください。

```Python
df["kana"] = df["correct_text"].map(lambda x: mora_wakachi("".join(get_pronunciation(x))))
df["roma"] = df["kana"].map(kana_to_romaji)

print(df.head())
```

```
                    text  start   end             correct_text  \
0          沈むように溶けてゆくように    0.0   6.0            沈むように溶けてゆくように   
1           二人だけの空が広がる夜に    6.0  30.0             二人だけの空が広がる夜に   
2  さよならだけだったその一言で全てが分かった   30.0  37.0  「さよなら」だけだったその一言で全てが分かった   
3           陽が沈み出した空と君の姿   37.0  41.0             日が沈み出した空と君の姿   
4           フェンス越しに重なってた   41.0  45.0            フェンス越しに重なっていた   

                                                kana  \
0         [シ, ズ, ム, ヨ, ー, ニ, ト, ケ, テ, ユ, ク, ヨ, ー, ニ]   
1   [フ, タ, リ, ダ, ケ, ノ, ソ, ラ, ガ, ヒ, ロ, ガ, ル, ヨ, ル, ニ]   
2  [サ, ヨ, ナ, ラ, ダ, ケ, ダ, ッ, タ, ソ, ノ, ヒ, ト, コ, ト, ...   
3  [ヒ, ガ, シ, ズ, ミ, ダ, シ, タ, ソ, ラ, ト, キ, ミ, ノ, ス, ...   
4           [フェ, ン, ス, ゴ, シ, ニ, カ, サ, ナ, ッ, テ, イ, タ]   

                                                roma  
0  [shi, zu, mu, yo, o, ni, to, ke, te, yu, ku, y...  
1  [fu, ta, ri, da, ke, no, so, ra, ga, hi, ro, g...  
2  [sa, yo, na, ra, da, ke, da, t, ta, so, no, hi...  
3  [hi, ga, shi, zu, mi, da, shi, ta, so, ra, to,...  
4  [fe, n, su, go, shi, ni, ka, sa, na, t, te, i,...  
```

ローマ字を入力としてalignmentします。
[その１]()とほぼ同じやり方です。
使い回せるようにphraseごとにalignmentして、separatorでマージする処理を関数化しました。

```Python
def align_by_phrase(audio_file_path, starts, ends, texts):
    print("Starting to force alignment...")
    separator = "|"
    start_index = 0
    total_subs = []
    for i, (start, end, text) in enumerate(zip(starts, ends, texts)):
        audioSegment = AudioSegment.from_wav(SPEECH_FILE)[int(start*1000):int(end*1000)]

        audioSegment.export("output/tmp/"+str(i)+'.wav', format="wav") #Exports to a wav file in the current path.
        transcript=text.strip().replace(" ", separator)
        transcript = re.sub(r'[^\w|\s]', '', transcript)
        transcript = re.sub(r"(\d+)", lambda x: num2words.num2words(int(x.group(0))), transcript)
        print(start)
        subs = force_align("output/tmp/"+str(i)+'.wav', transcript.upper(), start_index, start)
        # 末尾にseparatorの音素を追加する（あとでカナ単位にスプリットするため）
        subs.append({
            "text": separator, "start": subs[-1]["end"], "end":subs[-1]["end"]
        })
        start_index += len(text)
        total_subs.extend(subs)
    #print(total_subs)
    
    # separatorの単位でmerge
    chars = []
    text, start, end = "", -1, -1
    for i,p in enumerate(total_subs):
        if text == "" and p["text"] != separator:
            text, start, end = p["text"], p["start"], p["end"]
            continue
        if p["text"] == separator or i == len(total_subs)-1:
            chars.append({
                "text":text
                , "start": start
                , "end":end
            })
            text, start, end = "", -1, -1
            continue
        text += p["text"]
        end = p["end"]
    return chars
```

関数を実行します。df["roma"]は各要素をスペースでjoinしてから入力します。

```Python
SPEECH_FILE = "audio/yorunikakeru_vocals.wav"

chars = align_by_phrase(SPEECH_FILE, df["start"], df["end"], df["roma"].map(" ".join) )

print(chars)
```


```
[{'text': 'SHI', 'start': 1.605312, 'end': 1.806}, {'text': 'ZU', 'start': 1.846125, 'end': 2.02675}, {'text': 'MU', 'start': 2.086937, 'end': 2.408}, ...
```

# 評価
[その５](https://qiita.com/shimajiroxyz/items/e1a1ba595c77688606d7)で作った正解データで評価してみます。
まずローマ字をカナに直します。やり方は元データとの対応を取るだけです。

```Python
align_df = pd.DataFrame(chars)
align_df["kana"] = [x for row in df["kana"] for x in row]
print(align_df.head())
```

```
  text     start       end kana
0  SHI  1.605312  1.806000    シ
1   ZU  1.846125  2.026750    ズ
2   MU  2.086937  2.408000    ム
3   YO  2.468188  2.588625    ヨ
4    O  2.829375  2.989937    ー
```

MSEを計算します。`calculate_mse`はMSEを計算しているだけですが、「その５」を参照してください。

```Python
# 形式は違っても長さが同じでstart, endという列があればよい
correct_df = pd.read_csv("annotation/yorunikakeru_mora.tsv",sep="\t",names=["label","start","end","text"])
test_df = align_df

start_mse = calculate_mse(test_df["start"], correct_df["start"])
end_mse = calculate_mse(test_df["end"], correct_df["end"])
center_mse = calculate_mse((test_df["start"]+test_df["end"])/2, (correct_df["start"]+correct_df["end"])/2, )
print("start mse:", start_mse)
print("end mse:", end_mse)
print("center mse:", center_mse)
print("total mse:", start_mse + end_mse)  
```

```
start mse: 3.4489604399925287
end mse: 3.423007557779132
center mse: 3.4328918919191187
total mse: 6.871967997771661
```

無事出力できました。
余談ですが、「その５」で計算した、手動でphraseのタイムスタンプを修正したときの評価が0.004とかなので、比較してかなり大きいことがわかります。ルートを取って単位を秒にすると1.8秒くらいでしょうか？
今回の検証データは、whisperの結果だけを使っていて、segmentのタイムスタンプが大幅にずれているのでその影響が大きいと思われます。

# おわりに
Whisperの音声認識結果と正解歌詞を対応付けた上でalignment、評価を実施するワンパスについて説明しました。
今まで作ってきた処理を並べただけですが、これでだいぶ、やりたいことに近づいてきました。

次は音源分離などもふくめてもう少し広い範囲のワンパスを通すことを目指したいと思います。