概要
Whisperを活用した日本語歌詞のforced-alignmentの試行錯誤 その3です。
whisperによる音声区間抽出の精度を高めるために、別の音声区間検出ライブラリを組み合わせることを検討します。inaSpeechSegmenterという音声区間検出ライブラリを使ってみます。
シリーズ一覧は以下
【試行錯誤】OpenAI Whisperを活用した日本語歌詞のforced-alignment リンクまとめ
背景
OpenAI Whisperは2022年10月現在では最高クラスの認識精度をもつオープンソースの音声認識モデルです。Whisperに音楽の歌唱区間を検出させると、BGMのみの部分も発話区間に含まれる問題があります。forced-alignmentをするためにはなるべく実際の発話区間をピッタリ検出できることが望ましいです。
そこで音声区間検出に特化した別ライブラリの結果と組み合わせることで、検出精度向上を試みます。
inaSpeechSegmenterはCNNベースの音声区間検出モデルです。2018年のモデルで少し古いですが、まあまあよさそうだったのと、pythonから手軽に使えそうなライブラリでは他に選択肢がなかったので、使ってみることにしました。
参考:
inaSpeechSegmenterによる音声区間検出
Pythonの音声区間検出ライブラリ inaSpeechSegmenterを試してみた話
実装
音声区間検出
前回までと同様、YOASOBI「夜に駆ける」でテストします。
Colabで実行しています(コード参考)。
!pip install inaSpeechSegmenter
from inaSpeechSegmenter import Segmenter
# 入力のwavファイルのパスを指定
#input_file = 'yorunikakeru.wav'
input_file = 'yorunikakeru_vocals.wav' #音源分離でボーカルだけ取り出したファイル
# 'smn' は入力信号を音声区間(speeech)、音楽区間(music)、
# ノイズ区間(noise)にラベル付けしてくれる
# detect_genderをTrueにすると、音声区間は男性(male) / 女性(female)のラベルに
# 細分化される
#seg = Segmenter(vad_engine='smn', detect_gender=False)
seg = Segmenter(vad_engine='sm', detect_gender=False)
# 区間検出実行(たったこれだけでOK)
segmentation = seg(input_file)
# ('区間ラベル', 区間開始時刻(秒), 区間終了時刻(秒))というタプルが
# リスト化されているのが変数 segmentation
print(segmentation)
segmentationの中身は以下のような感じです。
[('noEnergy', 0.0, 1.1), ('speech', 1.1, 6.16), ('noEnergy', 6.16, 8.46), ('music', 8.46, 15.120000000000001), ...
前回までと同様に、音楽と一緒にラベルを字幕として流すと、結果のチェックがしやすそうです。
そこで、segmentationをsrt形式に直します。
# srtファイルの各要素を作成
srtrows = []
for i, (label, start, end) in enumerate(segmentation):
startmsec, endmsec = int(start*1000), int(end*1000)
start_hour = startmsec//3600000
start_minute = startmsec%3600000 // 60000
start_second = startmsec%60000 // 1000
start_msec = startmsec%1000
end_hour = endmsec//3600000
end_minute = endmsec%3600000//60000
end_second = endmsec%60000//1000
end_msec = endmsec%1000
#以下みたいな形式にする
"""
1
00:00:00,000 --> 00:00:02,000
ネコ1 ふむ
2
00:00:02,000 --> 00:00:04,000
ネコ2 むー
"""
row = "{}\n{}:{}:{},{} --> {}:{}:{},{}\n{}".format(i+1, str(start_hour).zfill(2), str(start_minute).zfill(2), str(start_second).zfill(2), str(start_msec).zfill(3), str(end_hour).zfill(2), str(end_minute).zfill(2), str(end_second).zfill(2), str(end_msec).zfill(3), label)
srtrows.append(row)
with open("ina_segment.srt", "w") as f:
f.write("\n\n".join(srtrows))
あまり使いませんが、csv形式で出力するなら以下です。
output = ["label,start,end"]
for s in segmentation:
output.append(",".join(list(map(str,s))))
with open("ina_sengment.csv", "w") as f:
f.write("\n".join(output))
結果
入力ファイル、オプション指定の候補として以下があります。
- 入力ファイル:音源分離したボーカルのみ音声か、オリジナルか
- Segmenterの検出ラベルオプション:'sm'(speech, musicのみ)か、'smn'(speech, music, noise)
どれがよさそうかを字幕をつけたビデオの目視で確認しました。
まず入力ファイルは事前に音源分離したほうがよかったです。
オリジナルだと非常に長い区間が音声として検出されてしまいます。
label,start,end
noEnergy,0.0,1.12
music,1.12,190.46
noEnergy,190.46,191.20000000000002
music,191.20000000000002,258.8
noEnergy,258.8,275.02
上記ではmusicが1.12秒から190.46秒までとなっており、使えなさそうです。
音源分離後の音声ファイルを入力した結果は以下のような感じです。
label,start,end
noEnergy,0.0,1.1
music,1.1,3.54
noise,3.54,6.16
noEnergy,6.16,8.46
music,8.46,13.22
noise,13.22,15.120000000000001
noEnergy,15.120000000000001,31.080000000000002
speech,31.080000000000002,49.78
music,49.78,53.94
speech,53.94,73.38
...
オリジナル音源に比べて細かく区間検出ができていそうです。
実際にこれを字幕ファイルに直して動画に重畳してみたところ、noEnergyは無音区間(音源分離前のBGMのみの区間)に比較的きれいに対応していそうでした。一方、music, speech, noiseはどういう基準で使い分けられているのかよくわかりませんでした。
おそらくですが、speechやnoiseは非音楽のデータを想定したときのラベルで、音楽データの場合にはあまり区別がなくなってしまうような気がします(人の声であってもメロディがついていたらmusicと認識されてしまう、など)
Segmenterのオプションについては、smnでもsmでもどちらでも同じに思えました。前述の通り、音楽データに対してnoEnergy以外のラベルの区別があまり意味がなさそうだったので。どっちでもいいなら下手に細かくしなくてよいかなということで、smを使うことにしようと思います。
Whisperの結果との統合
inaSpeechSegmenterの結果を「noEnergy」か「それ以外」かで区別した場合、「それ以外」、つまり有音の区間はwhisperのsegmentに比べると長めについていることがわかります。Whisperは1拍では文章、フレーズの単位でsegmentが別れているのに対して、inaSpeechSegmenterでは1拍程度の休符は無視されることが多いようです。
よって
- 歌詞が長く続く部分ではWhisperの結果を優先
- noEnergyの部分はinaSpeechSegmenterを優先
という方針が良さそうな気がしました。
これはWhisperとinaSpeechSegmenterの音声区間のANDをとることに相当します。
まずinaSpeechSegmenterの結果からnoEnergyのstart, endだけを取り出します。
import pandas as pd
# inaの結果をcsv(label, start, end列)に変換したもの
ina_result_path = "ina_segment_yorunikakeru_vocals_sm.csv"
ina_result = pd.read_csv(ina_result_path).to_dict(orient="records")
# noEnergyだけ残す
ina_result = [(v["start"], v["end"]) for v in ina_result if v["label"] == "noEnergy"]
# (start, end) のリスト
print(ina_result)
[(0.0, 1.1), (6.16, 8.46), (15.12, 31.08), ...
次にwhisperが検出した区間に、inaの非音声区間(noEnergy)がかぶっているかをチェックし、かぶっていれば、その分だけ音声区間を短く更新します。
まずWhisperのtranscribe関数の結果から必要な部分を取り出します。その1のやり方と同じです。
!pip install romkan
!pip install mecab-python3 unidic-lite
!pip install pydub num2words
import MeCab
import romkan
import json
#whisperの認識結果情報を取得
with open("largeresult_yorunikakeru.json") as f:
result = json.load(f)
#漢字かな交じりをカタカナに変換
mecab = MeCab.Tagger()
def get_yomi(text):
tokens = mecab.parse(text).splitlines()[:-1]
yomi = ""
for token in tokens:
yomi += token.split("\t")[1]
return yomi
#get_yomi("吾輩は猫である")
# 入力テキストをローマ字に変換
segments = []
for seg in result["segments"]:
text = seg["text"]
yomi = get_yomi(seg["text"]) #カタカナに直したもの
roma = romkan.to_roma(yomi) #ローマ字に直したもの
romasep = romkan.to_roma("".join("|".join(list(yomi.replace("ー",""))))) #あとでカナ単位に復元しやすいようにローマ字の間にseparatorをいれたもの
obj = {
"start": seg["start"]
, "end": seg["end"]
, "text": text
, "yomi": yomi
, "roma": roma
, "romasep": romasep
}
segments.append(obj)
print(segments)
作ったsegmentsの各要素に対して処理を実施します。
updated = []
for seg in segments:
whisper_start, whisper_end = seg["start"], seg["end"]
# inaがwhisperを包含するとき、updatedに含めない
ina_includes_whisper = False
for ina_start, ina_end in ina_result:
# inaがwhisperを包含
if ina_start <= whisper_start and whisper_end <= ina_end:
ina_includes_whisper = True
break
# inaがwhisperより完全に未来
if whisper_end <= ina_start:
break
if ina_includes_whisper:
# updatedへの追加をスキップ
continue
# whisperのstart, endをinaで更新
updated_seg = seg.copy()
for ina_start, ina_end in ina_result:
# inaがwhisperより完全に未来
if whisper_end <= ina_start:
break
# whisperの区間が完全に前のとき(区間のかぶりがないとき)、continue
elif whisper_end <= ina_start:
pass
# ina_endがwhisper_startよりも遅いときwhisper_startをina_endに合わせる
elif whisper_start <= ina_end and ina_end <= whisper_end :
updated_seg["start"] = ina_end
# ina_startがwhisper_endよりも遅いときwhisper_endをina_startに合わせる
elif ina_start <= whisper_end and whisper_start <= ina_start:
updated_seg["end"] = ina_start
# それ以外の関係性は想定しない(スキップ)
else:
pass
updated.append(updated_seg)
print(updated[1])
{'start': 8.46, 'end': 15.12, 'text': '二人だけの空が広がる夜に', 'yomi': 'フタリダケノソラガヒロガルヨルニ', 'roma': 'futaridakenosoragahirogaruyoruni', 'romasep': 'fu|ta|ri|da|ke|no|so|ra|ga|hi|ro|ga|ru|yo|ru|ni'}
segments[1]はもともとBGM区間も含んで検出されていたため、endが30秒になっていましたが、より正確そうなendに置き換わっていることがわかります。
なお上記のコードはwhisperの区間がinaのnoEnergy区間を包含する場合処理をスキップしており、厳密なAND条件にはなっていません。本来はそのような場合は、whisperの検出区間を2つに分ける必要があるのですが、その場合、歌詞(text)をどう分割するかが自明でなく、難しい問題になるので、ここでは処理をスキップしています。
先に確認したように、音声区間の検出単位は基本的にwhisperのほうが細かいので、whisperがinaのnoEnergyを包含するケースは殆ど起こらないと思われます。ただし100%ではないので、より厳密な処理が必要な音楽データに遭遇した場合、やり方を検討しようと思います。
srtファイルを作って、目視で結果を確認したところ、whisperだけの検出区間に比べ、BGMを過剰に検出せず、より正解に近い区間検出になっていると思われました。
さらにこの音声検出区間に基づいてwav2vecによるforced-alignmentを実施したところ、音素レベルのalignmentの精度も向上しているように思われました。まだ荒いところは少なくないものの、ある程度実用レベルになった気もします。
めでたしです。
おわりに
inaSpeechSegmenterの音声区間検出結果を組み合わせることで、whisperだけによる音声区間検出よりも精度を高めることができました。
振り返ると、結局のところ、inaSpeechSegmenterの結果でもちいたのはボーカル分離後の音声ファイルに対するnoEnergy区間、つまり無音区間のデータだけだったので、そうであれば、単にwavの波形をみて振幅の小さい区間を検出すればよかったのでは? ということを思ったりもしました。しかしその場合でも、少なくとも検出区間の最小幅はしきい値として自分で設定しなければならず、他にも考慮することが色々出てくる可能性がありそうだったので、今回は、あるものは使おうの精神で、多少オーバーキルではありますが、inaSpeechSegmenterを使いました。結果としては楽できたと思います。
alignmentの精度を高めることに関しては、とりあえず迷わずに手を動かせそうと想定していた検証はここまでです。ある程度の精度が出てよかったです。
細かい改良すべき点はいくつもあるので、引き続き気が向いたときにトライしてみたいと思います。
参考
inaSpeechSegmenterによる音声区間検出
Pythonの音声区間検出ライブラリ inaSpeechSegmenterを試してみた話