【試行錯誤】OpenAI Whisperを活用した日本語歌詞のforced-alignment その５：正解データづくり

# 概要
OpenAI Whisperを活用した日本語歌詞のforced-alignmentの試行錯誤をしています。
今更ですが、いろんな手法の評価を定量的にするためのalignmentの正解データを作ります。

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

# 背景
forced-alignmentは書き起こされた内容の各単位（通常は音素）が音源データ上のどの位置（タイムスタンプ）にあるかを推定する技術です。これまで[音源分離をしたり]()、[２種類の音声区間検出結果を統合したりして]()、精度向上を試みてきました。試行錯誤を重ねるうちになんとなく良くなってきた気はするのですが、目視による主観評価しかできていなかったので、目視不要で定量的な評価ができるように、正解データを作ります。

最初から作っておけよという感じがしますが、ある程度自動抽出してから微調整するほうが手間が少ないので、自動でそこそこの精度が出るまで主観評価で我慢していました。

# 方針
一番細かい要素は音素ですが、日本語なのでカナの単位でalignmentできれば良しとします。
いきなりカナの正解データを作るのは大変なので、フレーズ、単語、カナの順番に少しずつ細かくしていきます。

正解データづくりにはアノテーションツールの[ELAN](https://tla.mpi.nl/tools/tla-tools/elan/)を使います。csvの読み込み、書き出し機能があり、自動生成の結果なども活用しながら少しずつ作業をしていくのに便利です。

# フレーズレベルのアノテーション

[その３](https://qiita.com/shimajiroxyz/items/de213cd333e7bf846781)で取得した、whisperの結果をinaSpeechSegmenterの結果で補正したデータを元データとします。動画で精度をチェックするために作ったsrtファイルがあったので、それをELANで読み込める形式に加工します。

```:segments.srt
1
00:00:01,100 --> 00:00:06,000
沈むように溶けてゆくように

2
00:00:08,460 --> 00:00:15,120
二人だけの空が広がる夜に

3
00:00:31,080 --> 00:00:37,000
さよならだけだったその一言で全てが分かった

...
```

これを以下のようなcsvにすることが目標です。
1列目、２列め、３列目がそれぞれstart, end, textになっています。

```:segments.csv
1.1,6.0,沈むように溶けてゆくように
8.46,15.12,二人だけの空が広がる夜に
31.08,37.0,さよならだけだったその一言で全てが分かった
...
```

srtファイルを上記csvにするコードの一例は以下です。

```Python
import pandas as pd

srt_path = "segments.srt"
csv_path = "segments.csv"

with open(srt_path) as f:
  srt_contents = f.read().split("\n\n")

csv_rows = []
for srt_content in srt_contents:
  srt_content_lines = srt_content.splitlines()
  startstr, endstr = str_content_lines[1].split(" --> ")
  starthour, startmin, startsec = map(int, startstr.split(",")[0].split(":"))
  startmsec = int(startstr.split(",")[1])
  start = starthour*3600 + startmin*60 + startsec + startmsec/1000

  endhour, endmin, endsec = map(int, endstr.split(",")[0].split(":"))
  endmsec = int(endstr.split(",")[1])
  end = endhour*3600 + endmin*60 + endsec + endmsec/1000

  text = srt_content_lines[2]

  csv_rows.append({"start":start, "end":end, "text: text})

pd.DataFrame(csv_rows).to_csv(csv_path, index=False, header=None)
```

作成した`segments.csv`をELANで読み込みます。
GUIの使い方の詳細は述べませんが、プロジェクトを新規作成後、音源分離済みのボーカル音声をロードします。そして、csvを新規注釈層に読み込みます。

参考：[- ELAN即席入門 - 他のソフトのデータを読み込む](http://12kai.com/elan/elan_import.html)

あとは読み込んだ結果を微調整していきます。以下備忘録です。
* ブレスをフレーズに含めるか迷いましたが、明らかにブレスのみの音は含めないようにしました。
* 正解歌詞にはありませんが、「ああー」と伸ばしている箇所があり、一応声がする場所ということで、アノテーションをしておきました。
* 明らかに形態素解析をミスしている箇所（「もう嫌だって疲れたんだってが」「むしゃらに差し伸べた僕の手を振り払う君」）は正しい分け方に修正しました。他にも違和感のある分け目がなくはなかったですが、一旦放置しました。
* whisperの音声認識歌詞の間違いもこのときに修正しておきます。[difff](https://difff.jp/)などを活用すると見つけやすいです。

微調整後、ELANからtsv形式で出力すると以下のようなファイルが得られます。

```:segment_annotation.tsv
Tier-0		1.48	6.07	沈むように溶けてゆくように
Tier-0		8.92	15.05	二人だけの空が広がる夜に
Tier-0		31.46	37.59	さよならだけだったその一言で全てが分かった
...
```

# カナレベルのアノテーション
次に、カナレベルのアノテーションを実施します。
フレーズレベルのアノテーション結果を使って、音素アラインメントをした結果を元データとして使うことで効率化します。

主な流れは以下です。
* フレーズ（漢字かな交じり）を発音（カタカナ）に変換（[参考](https://qiita.com/shimajiroxyz/items/81452afdedaed767f436)）
* 発音をモウラの単位に分割（[参考](https://qiita.com/shimajiroxyz/items/a133d990df2bc3affc12)）
* 各モウラをローマ字に変換し、機械的に微修正
* forced-alignment


[その１](https://qiita.com/shimajiroxyz/items/c019cfd60ba2953eb463)でも雑に似たようなことはやっていましたが、発音をモウラの単位に分割するなど、少し丁寧に処理をしています。手動修正の手間をなるべく減らしたいので。

## フレーズ（漢字かな交じり）を発音（カタカナ）に変換
[参考](https://qiita.com/shimajiroxyz/items/81452afdedaed767f436)のコード（`getPronunciation`）をそのまま使用しました。

## 発音をモウラの単位に分割
[参考](https://qiita.com/shimajiroxyz/items/a133d990df2bc3affc12)のコード（`getPronunciation`）をほぼそのまま使用しました。「その１」でやったように、単純に１文字ずつリストにするだけど「シャ」みたいな２文字で１音（モウラ）のカナが扱えないので、少し丁寧にやっています。

正規表現をグローバル変数で保持しておくのが嫌だったので、関数の中で都度宣言するようにしています。元コードのように関数外でコンパイルしておくと若干速度は上がると思うのですが、そんなに何度も登場する処理でもないので、許容しています。

```Python
import re

def mora_wakachi(kana_text):
    #各条件を正規表現で表す
    c1 = '[ウクスツヌフムユルグズヅブプヴ][ァィェォ]' #ウ段＋「ァ/ィ/ェ/ォ」
    c2 = '[イキシチニヒミリギジヂビピ][ャュェョ]' #イ段（「イ」を除く）＋「ャ/ュ/ェ/ョ」
    c3 = '[テデ][ィュ]' #「テ/デ」＋「ャ/ィ/ュ/ョ」
    c4 = '[ァ-ヴー]' #カタカナ１文字（長音含む）
    
    cond = '('+c1+'|'+c2+'|'+c3+'|'+c4+')'
    return re.findall(cond, kana_text)
```
## 各モウラをローマ字に変換し、機械的に微修正
`romkan`というライブラリでモウラをローマ字に変換します。
ただし「ッ」「ー」だけは１文字ずつ変換する関係上、実際の発音と少し異なるローマ字になってしまうので、機械的なルールで修正します。
具体的には「ッ」は単独だと「xtsu」に変換されるのですが、実際には直後のモウラの子音となるべきなのでそのようにします（実装上は直後のカナのローマ字表記の１文字目）。細かく考え出すと、ナ行や母音（aiueo）の直前に「ッ」が来ていた場合、同じルールを適用してよいのか悩ましかったりするのですが、レアケースだと思うので深く考えないことにします。末尾に「ッ」があった場合はなんとなく発音が近そうな「t」に置き換えることにします。
「ー」は「-」に変換されますが、直前のカナの母音に置き換わるほうが自然なのでそのようにします。「ー」が先頭に来ることは文法上はありえないのですが、万が一来ていたら「n」に変換することにします（なんとなく先頭の「ー」をどう発音するか想像したら「n」っぽい気がしたのでそうしました。レアケースと思うので適当です）

```Python
def kana_to_romaji(kana_list):
  romaji_org = [romkan.to_roma(kana) for kana in kana_list]
  romaji_fixed = []
  for i, roma in enumerate(romaji_org):
    # 「ッ」のとき
    if roma == "xtsu":
      # 末尾ならtにする
      if i == len(romaji_org)-1:
        roma = "t"
      # 末尾以外なら次の要素の1文字目にする
      else:
        roma = romaji_org[i+1][0]
    # 「ー」のとき
    elif roma == "-":
      # 先頭ならnにする（なんとなく）
      if i == 0:
        roma = "n"
      # 先頭以外なら直前の要素の母音にする
      else:
        roma = romaji_org[i-1][-1]
    
    romaji_fixed.append(roma)
  return romaji_fixed

print(kana_to_romaji(['ガ', 'ッ', 'キュ', 'ー', 'ホ', 'ー', 'カ', 'イ'])
```

```
['ga', 'k', 'kyu', 'u', 'ho', 'o', 'ka', 'i']
```

## forced-alignment
上記の関数を活用してカナ単位のローマ字に変換した発音の単位でalignmentをします。
まずELANで出力したphraseレベルのアノテーション結果を読み込み、注釈をカナ単位のローマ字に変換します。


```:segment_annotation.tsv
Tier-0		1.48	6.07	沈むように溶けてゆくように
Tier-0		8.92	15.05	二人だけの空が広がる夜に
Tier-0		31.46	37.59	さよならだけだったその一言で全てが分かった
...
```


```Python
import pandas as pd
import romkan
df = pd.read_csv("segment_annotation.tsv", sep="\t",names=["label", "unnamed", "start", "end", "text"])
df["kana"] = df["text"].map(lambda x: "".join(getPronunciation(x)))
df["roma"] = df["kana"].map(lambda x: " ".join(kana_to_romaji(mora_wakachi(x))))
df.to_csv("output/annotation/roma.csv",index=False)

print(df.head())
```

```
    label  unnamed  start    end                   text  \
0  Tier-0      NaN   1.48   6.07          沈むように溶けてゆくように   
1  Tier-0      NaN   8.92  15.05           二人だけの空が広がる夜に   
2  Tier-0      NaN  31.46  37.59  さよならだけだったその一言で全てが分かった   
3  Tier-0      NaN  37.79  41.94           日が沈み出した空と君の姿   
4  Tier-0      NaN  42.19  45.29           フェンス越しに重なってた   

                       kana                                               roma  
0            シズムヨーニトケテユクヨーニ           shi zu mu yo o ni to ke te yu ku yo o ni  
1          フタリダケノソラガヒロガルヨルニ    fu ta ri da ke no so ra ga hi ro ga ru yo ru ni  
2  サヨナラダケダッタソノヒトコトデスベテガワカッタ  sa yo na ra da ke da t ta so no hi to ko to de...  
3         ヒガシズミダシタソラトキミノスガタ  hi ga shi zu mi da shi ta so ra to ki mi no su...  
4             フェンスゴシニカサナッテタ                 fe n su go shi ni ka sa na t te ta 
```

あとは`df["roma"]`の各要素を同じ要領でforced-alignmentして、出力結果をカナの単位を目印に結合し、ELANで読み込めるようにcsvとして出力するだけです。
「その１」とほぼ同じ流れなので、コードは省略します。

# ELANで手動修正
出力したalignment結果のcsvをELANにインポートして手動で修正します。
作業中の画面は以下のような感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259353/5083ba37-6edb-3830-2909-4cca259c2ff1.png)

正直１文字ずつの始点と終点を細かくラベル付けすることは無理なので、明らかに間違って聴こえる場所を修正するだけにとどめました。歌詞（読み方）が間違えている部分もちらほらあったので気づいたら都度修正しています。

波形の振幅が大きい部分とカナが対応していることが多そうだったので、波形も見ながらやると少し効率がよいです。ただ、ELANでは選択部分だけを再生する機能があって便利なのですが、気を利かせてなのか前後数フレームも含めて再生されてしまっているような気がします。１文字単位でアノテーションしようとするとこの仕様（？）が非常にやっかいなので、正確なラベル付けは諦めました。

最終的にはなんとか、以下のようなcsvをELANから出力することができました。
疲れました。

```:yorunikakeru_mora.tsv
kana_org		1.6	1.761	シ
kana_org		1.841	2.041	ズ
kana_org		2.101	2.382	ム
kana_org		2.442	2.602	ヨ
kana_org		2.823	2.983	ー
kana_org		3.023	3.204	ニ
kana_org		3.244	3.424	ト
kana_org		3.444	3.605	ケ
...
```

# alignmentの精度チェック
正解データを作れたので、特定の手法でのalignmentの結果の精度を評価してみます。

手動で修正する前のmora単位alignmentと正解データ（つまり手動修正後）の差分を計算してみます。

評価指標には、テストデータと正解データの対応する文字における始点の差の２乗、終点の差の２乗の平均値（Mean Square Error; MSE）を使います。
テスト、正解データで文字の対応は完璧に取れている前提とします。test_moraのほうはその前提を満たさせるために少し手動修正しています。
その前提のもとで同じ位置にあるstart, endについてMSEを計算します。


```Python
import numpy as np

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

def calculate_mse(test_array, correct_array):
  return ( ( np.array(test_array) - np.array(correct_array) ) ** 2 ).mean()

start_mse = calculate_mse(test_df["start"], correct_df["start"])
end_mse = calculate_mse(test_df["end"], correct_df["end"])
print("start mse:", start_mse)
print("end mse:", end_mse)
print("total mse:", start_mse + end_mse)  

```

```
start mse: 0.0045925420770693386
end mse: 0.004582465964852732
total mse: 0.009175008041922072
```

ということで無事それっぽい数値が出てきました。
比較してみないとなんとも言えませんが、平方和なのでルートをとると単位が秒になります。
start mseのルートはだいたい0.02くらい。つまり１文字あたり20ミリ秒くらいの誤差がある感じでしょうか。平均なので一部には誤差が大きいところももちろんあるのでしょうが、全体的にはまあまあ良さそうな感じがしますね。

# おわりに
ということで正解データを作ってみました。今後、またいろんな手法を試すときの判断基準として活用できればと思います。
ただこれを使いやすくするには「テストと正解データの文字の対応が完璧に取れている」という前提が意外と満たされる場合が少ないという課題があります。例えばWhisperの認識結果そのままでalignmentしてしまうと音声認識のミスがあるので、正解データと微妙に文字がずれてしまいます。
これを解決するには、音声認識をそのまま使うのではなく、正解の歌詞をalignmentの入力にする必要があります。
すると、次に必要なのは、Whisperで認識した各segmentが元歌詞のどのフレーズと対応しているかを検出する作業です。手動だと簡単なのですが、プログラムでやろうと思うと意外と面倒そうな気もします。気が向いたらトライしてみようとおもいます。