LoginSignup
1
2

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

Last updated at Posted at 2022-10-23

概要

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

シリーズ一覧は以下
【試行錯誤】OpenAI Whisperを活用した日本語歌詞のforced-alignment リンクまとめ

背景

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

音声認識結果にもとづく発話区間と正解文字列の対応付け【Python】

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

Whisperの認識結果と正解歌詞のalignment

準備

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

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

Whisperの認識結果はjsonでおいておきます。
今回はその2でつくった、音源分離によって得たボーカル音源をWhipserのlargeモデルで認識した結果を使います。

largeresult_yorunikakeru_vocal.json
{
  "text": "沈むように溶けてゆくように二人だけの空が広がる夜にさよならだけだった...",
  "segments": [
    {
      "id": 0,
      "seek": 0,
      "start": 0.0,
      "end": 6.0,
      "text": "沈むように溶けてゆくように",
      "tokens": [
        50364,
        3308,
        230,
        ...

対応付け

Whisperの認識結果と正解歌詞を1つのデータフレームにします。
find_correspondance関数はこの記事のものです。

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

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: ('「', 'さよなら', '」', 'だけ', 'だっ', 'た', 'その', '一言', 'で', '全て', 'が', '分かっ', 'た')
...

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

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をカナ(モウラ)およびローマ字に変換します。使っている関数などはこちらから参照してください。

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します。
その1とほぼ同じやり方です。
使い回せるようにphraseごとにalignmentして、separatorでマージする処理を関数化しました。

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してから入力します。

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}, ...

評価

その5で作った正解データで評価してみます。
まずローマ字をカナに直します。やり方は元データとの対応を取るだけです。

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を計算しているだけですが、「その5」を参照してください。

# 形式は違っても長さが同じで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

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

おわりに

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

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

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2