概要
OpenAI Whisperを活用した日本語歌詞のforced-alignmentの試行錯誤をしています。
今更ですが、いろんな手法の評価を定量的にするためのalignmentの正解データを作ります。
シリーズ一覧は以下
【試行錯誤】OpenAI Whisperを活用した日本語歌詞のforced-alignment リンクまとめ
背景
forced-alignmentは書き起こされた内容の各単位(通常は音素)が音源データ上のどの位置(タイムスタンプ)にあるかを推定する技術です。これまで音源分離をしたり、2種類の音声区間検出結果を統合したりして、精度向上を試みてきました。試行錯誤を重ねるうちになんとなく良くなってきた気はするのですが、目視による主観評価しかできていなかったので、目視不要で定量的な評価ができるように、正解データを作ります。
最初から作っておけよという感じがしますが、ある程度自動抽出してから微調整するほうが手間が少ないので、自動でそこそこの精度が出るまで主観評価で我慢していました。
方針
一番細かい要素は音素ですが、日本語なのでカナの単位でalignmentできれば良しとします。
いきなりカナの正解データを作るのは大変なので、フレーズ、単語、カナの順番に少しずつ細かくしていきます。
正解データづくりにはアノテーションツールのELANを使います。csvの読み込み、書き出し機能があり、自動生成の結果なども活用しながら少しずつ作業をしていくのに便利です。
フレーズレベルのアノテーション
その3で取得した、whisperの結果をinaSpeechSegmenterの結果で補正したデータを元データとします。動画で精度をチェックするために作ったsrtファイルがあったので、それをELANで読み込める形式に加工します。
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列目、2列め、3列目がそれぞれstart, end, textになっています。
1.1,6.0,沈むように溶けてゆくように
8.46,15.12,二人だけの空が広がる夜に
31.08,37.0,さよならだけだったその一言で全てが分かった
...
srtファイルを上記csvにするコードの一例は以下です。
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即席入門 - 他のソフトのデータを読み込む
あとは読み込んだ結果を微調整していきます。以下備忘録です。
- ブレスをフレーズに含めるか迷いましたが、明らかにブレスのみの音は含めないようにしました。
- 正解歌詞にはありませんが、「ああー」と伸ばしている箇所があり、一応声がする場所ということで、アノテーションをしておきました。
- 明らかに形態素解析をミスしている箇所(「もう嫌だって疲れたんだってが」「むしゃらに差し伸べた僕の手を振り払う君」)は正しい分け方に修正しました。他にも違和感のある分け目がなくはなかったですが、一旦放置しました。
- whisperの音声認識歌詞の間違いもこのときに修正しておきます。difffなどを活用すると見つけやすいです。
微調整後、ELANからtsv形式で出力すると以下のようなファイルが得られます。
Tier-0 1.48 6.07 沈むように溶けてゆくように
Tier-0 8.92 15.05 二人だけの空が広がる夜に
Tier-0 31.46 37.59 さよならだけだったその一言で全てが分かった
...
カナレベルのアノテーション
次に、カナレベルのアノテーションを実施します。
フレーズレベルのアノテーション結果を使って、音素アラインメントをした結果を元データとして使うことで効率化します。
主な流れは以下です。
その1でも雑に似たようなことはやっていましたが、発音をモウラの単位に分割するなど、少し丁寧に処理をしています。手動修正の手間をなるべく減らしたいので。
フレーズ(漢字かな交じり)を発音(カタカナ)に変換
参考のコード(getPronunciation
)をそのまま使用しました。
発音をモウラの単位に分割
参考のコード(getPronunciation
)をほぼそのまま使用しました。「その1」でやったように、単純に1文字ずつリストにするだけど「シャ」みたいな2文字で1音(モウラ)のカナが扱えないので、少し丁寧にやっています。
正規表現をグローバル変数で保持しておくのが嫌だったので、関数の中で都度宣言するようにしています。元コードのように関数外でコンパイルしておくと若干速度は上がると思うのですが、そんなに何度も登場する処理でもないので、許容しています。
import re
def mora_wakachi(kana_text):
#各条件を正規表現で表す
c1 = '[ウクスツヌフムユルグズヅブプヴ][ァィェォ]' #ウ段+「ァ/ィ/ェ/ォ」
c2 = '[イキシチニヒミリギジヂビピ][ャュェョ]' #イ段(「イ」を除く)+「ャ/ュ/ェ/ョ」
c3 = '[テデ][ィュ]' #「テ/デ」+「ャ/ィ/ュ/ョ」
c4 = '[ァ-ヴー]' #カタカナ1文字(長音含む)
cond = '('+c1+'|'+c2+'|'+c3+'|'+c4+')'
return re.findall(cond, kana_text)
各モウラをローマ字に変換し、機械的に微修正
romkan
というライブラリでモウラをローマ字に変換します。
ただし「ッ」「ー」だけは1文字ずつ変換する関係上、実際の発音と少し異なるローマ字になってしまうので、機械的なルールで修正します。
具体的には「ッ」は単独だと「xtsu」に変換されるのですが、実際には直後のモウラの子音となるべきなのでそのようにします(実装上は直後のカナのローマ字表記の1文字目)。細かく考え出すと、ナ行や母音(aiueo)の直前に「ッ」が来ていた場合、同じルールを適用してよいのか悩ましかったりするのですが、レアケースだと思うので深く考えないことにします。末尾に「ッ」があった場合はなんとなく発音が近そうな「t」に置き換えることにします。
「ー」は「-」に変換されますが、直前のカナの母音に置き換わるほうが自然なのでそのようにします。「ー」が先頭に来ることは文法上はありえないのですが、万が一来ていたら「n」に変換することにします(なんとなく先頭の「ー」をどう発音するか想像したら「n」っぽい気がしたのでそうしました。レアケースと思うので適当です)
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レベルのアノテーション結果を読み込み、注釈をカナ単位のローマ字に変換します。
Tier-0 1.48 6.07 沈むように溶けてゆくように
Tier-0 8.92 15.05 二人だけの空が広がる夜に
Tier-0 31.46 37.59 さよならだけだったその一言で全てが分かった
...
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として出力するだけです。
「その1」とほぼ同じ流れなので、コードは省略します。
ELANで手動修正
出力したalignment結果のcsvをELANにインポートして手動で修正します。
作業中の画面は以下のような感じです。
正直1文字ずつの始点と終点を細かくラベル付けすることは無理なので、明らかに間違って聴こえる場所を修正するだけにとどめました。歌詞(読み方)が間違えている部分もちらほらあったので気づいたら都度修正しています。
波形の振幅が大きい部分とカナが対応していることが多そうだったので、波形も見ながらやると少し効率がよいです。ただ、ELANでは選択部分だけを再生する機能があって便利なのですが、気を利かせてなのか前後数フレームも含めて再生されてしまっているような気がします。1文字単位でアノテーションしようとするとこの仕様(?)が非常にやっかいなので、正確なラベル付けは諦めました。
最終的にはなんとか、以下のようなcsvをELANから出力することができました。
疲れました。
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と正解データ(つまり手動修正後)の差分を計算してみます。
評価指標には、テストデータと正解データの対応する文字における始点の差の2乗、終点の差の2乗の平均値(Mean Square Error; MSE)を使います。
テスト、正解データで文字の対応は完璧に取れている前提とします。test_moraのほうはその前提を満たさせるために少し手動修正しています。
その前提のもとで同じ位置にあるstart, endについてMSEを計算します。
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くらい。つまり1文字あたり20ミリ秒くらいの誤差がある感じでしょうか。平均なので一部には誤差が大きいところももちろんあるのでしょうが、全体的にはまあまあ良さそうな感じがしますね。
おわりに
ということで正解データを作ってみました。今後、またいろんな手法を試すときの判断基準として活用できればと思います。
ただこれを使いやすくするには「テストと正解データの文字の対応が完璧に取れている」という前提が意外と満たされる場合が少ないという課題があります。例えばWhisperの認識結果そのままでalignmentしてしまうと音声認識のミスがあるので、正解データと微妙に文字がずれてしまいます。
これを解決するには、音声認識をそのまま使うのではなく、正解の歌詞をalignmentの入力にする必要があります。
すると、次に必要なのは、Whisperで認識した各segmentが元歌詞のどのフレーズと対応しているかを検出する作業です。手動だと簡単なのですが、プログラムでやろうと思うと意外と面倒そうな気もします。気が向いたらトライしてみようとおもいます。