空耳歌詞（〇〇で歌ってみた）の自動生成 その１

# 概要
「〇〇で歌ってみたシリーズ」の歌詞を自動生成するWebサイトを趣味で開発しています。
https://soramimic.com/

上記サイトのために開発した空耳歌詞自動生成プログラムの解説をしていきます。
シリーズものになる予定です。

今回は、とりあえず非常にシンプルな仕組みでなんとなく動くものを作ります。

# 背景
「〇〇で歌ってみたシリーズ」は歌詞を、特定ジャンルの単語だけでなるべく発音が近くなるように変換して、歌われた替え歌です。「野球選手名で歌ってみた」「駅名で歌ってみた」など様々なジャンルがあります。

例えば、童謡「ふるさと」を野球選手の名前で替え歌すると以下のようになります。

```
宇佐美  大井  鹿野  陽  マン
うさぎ追いしかの山

小塚  辻功  河
こぶな釣りしかの川

シューメーカー  岩郷  出口  陳
夢は今もめぐりて

夏目隆司  古田  荘
わすれがたきふるさと
```

替え歌歌詞は人名が並んでいるだけですがなんとなくふるさとの歌詞っぽく聞こえるのではないでしょうか。
このような歌詞をプログラムで自動生成する方法を考えていきます。

# 方針
今回やりたいことを改めて言語化すると以下のようになります。

- テキストと単語リストが与えられたときに、単語リストの単語のみを用いて、テキストと発音が近くなる単語列を作ること

これを実現するためには、以下を考える必要があります。

- 発音の近さをどう定義するか
- 最適な単語列を見つけるために元の文章をどう区切るか

なぜ上記を考えればよいかというと、テキストを適切に区切って、各分割ごとに最も発音の近い単語を求めれば、目的の単語列が得られるからです。

上記に対し、今回は第一歩として、以下の方針で実装します。

- 発音の近さはカナ表記の編集距離で定量化する。編集距離が短いほど発音が近いとみなす。
- 元の文章は文節で区切る

発音の近さについては、日本語の場合、発音とカナ表記はほぼ同じなので、発音の近さをカナ表記の近さで代替する発想は自然です。編集距離は文字列同士の距離を測るメジャーな指標です。文字列Aと文字列Bの編集距離とは、文字列Aを文字列Bに変換するために必要な編集回数の最小値と定義されます。編集とはここでは、挿入、削除、置換のいずれかを意味します。文字列同士の距離の指標はほかにもありますが、編集距離は比較的メジャーなので、まずはこれを出発点とします。

文章の区切り方は、歌いやすさの観点から、元の文章の文節の切れ目と、替え歌歌詞の単語の切れ目が一致していると、望ましい印象があるため、まずはこれを出発点とします。

# 実装

ソースコードを以下に置きました。
https://github.com/jiroshimaya/soramimic-tutorial/tree/main/code00

以下、解説していきます。
## 単語リスト
サンプルとして国名のリストを用意します。

1行に１つずつ国名が書かれています。冒頭１０行は以下のような感じです。

```csv:sample_wordlist.csv
アフガニスタン
アルバニア
アルジェリア
アンドラ
アンゴラ
アルゼンチン
アルメニア
オーストラリア
オーストリア
アゼルバイジャン
```
## PhraseTokenizer
mecabを使って、テキストを文節に分割したり、テキストをカナ文字列（発音）に変化するためのクラスです。
mecabのインスタンスを使い回せるように、クラスにしています。

```Python
class PhraseTokenizer:
    def __init__(self):
        self.m = MeCab.Tagger('')  # MeCabのタグ付けオブジェクトを宣言

    def tokenize(self, text: str) -> list[dict]:
        """
        テキストをトークン化し、各トークンの詳細情報を含む辞書のリストを返します。
        
        引数:
        text (str): トークン化するテキスト
        
        戻り値:
        list[dict]: トークンの詳細情報を含む辞書のリスト
        """
        mecab_result = self.m.parse(text).splitlines()
        mecab_result = mecab_result[:-1]  # 最後の行は不要なので削除
        tokens = []
        word_id = 509800  # 単語のIDの開始番号
        for line in mecab_result:
            if '\t' not in line:
                continue
            parts = line.split('\t')
            word_surface = parts[0]  # 単語の表層形
            pos_info = parts[1].split(',')  # 品詞やその他の文法情報
            token = {
                'surface_form': word_surface,
                'pos': pos_info[0],
                'pos_detail_1': pos_info[1] if len(pos_info) > 1 else '*',
                'pos_detail_2': pos_info[2] if len(pos_info) > 2 else '*',
                'pos_detail_3': pos_info[3] if len(pos_info) > 3 else '*',
                'conjugated_type': pos_info[4] if len(pos_info) > 4 else '*',
                'conjugated_form': pos_info[5] if len(pos_info) > 5 else '*',
                'basic_form': pos_info[6] if len(pos_info) > 6 else word_surface,
                'reading': pos_info[7] if len(pos_info) > 7 else '',
                'pronunciation': pos_info[8] if len(pos_info) > 8 else ''
            }
            tokens.append(token)
        return tokens

    def split_text_into_phrases(self, text: str, consider_non_independent_nouns_as_breaks: bool = True) -> list[dict]:
        """
        テキストをフレーズに分割し、各フレーズの詳細情報を含む辞書のリストを返します。
        
        引数:
        text (str): フレーズに分割するテキスト
        
        戻り値:
        list[dict]: フレーズの詳細情報を含む辞書のリスト
        """
        tokens = self.tokenize(text)
        phrase_break_pos_tags = ['名詞', '動詞', '接頭詞', '副詞', '感動詞', '形容詞', '形容動詞', '連体詞']  # フレーズを区切る品詞のリスト
        segmented_text = []  # 分割されたテキストのリスト
        current_phrase = {'surface': '', 'pronunciation': ''}

        previous_token = None

        for token in tokens:
            word_surface = token['surface_form']
            word_pronunciation = token['pronunciation']
            pos_info = token['pos']
            pos_detail = ','.join([token['pos_detail_1'], token['pos_detail_2'], token['pos_detail_3']])

            # 現在の単語がフレーズを区切るか判断
            should_break = pos_info in phrase_break_pos_tags
            if not consider_non_independent_nouns_as_breaks:
                should_break = should_break and '接尾' not in pos_detail
                should_break = should_break and not (pos_info == '動詞' and 'サ変接続' in pos_detail)
                should_break = should_break and '非自立' not in pos_detail
                if previous_token:
                    previous_pos_info = previous_token['pos']
                    previous_pos_detail = ','.join([previous_token['pos_detail_1'], previous_token['pos_detail_2'], previous_token['pos_detail_3']])
                    should_break = should_break and previous_pos_info != '接頭詞'
                    should_break = should_break and not ('サ変接続' in previous_pos_detail and pos_info == '動詞' and token['conjugated_type'] == 'サ変・スル')

            if should_break:
                if current_phrase['surface']:
                    segmented_text.append(current_phrase)
                current_phrase = {'surface': '', 'pronunciation': ''}
            current_phrase['surface'] += word_surface
            current_phrase['pronunciation'] += word_pronunciation

            previous_token = token

        if current_phrase['surface']:  # 存在する場合は最後のフレーズを追加
            segmented_text.append(current_phrase)
        return segmented_text
    def get_pronunciation(self, text: str) -> str:
        """
        テキストの発音を取得します。
        
        引数:
        text (str): 発音を取得するテキスト
        
        戻り値:
        str: テキストの発音
        """
        tokens = self.tokenize(text)
        pronunciation = ''.join(token['pronunciation'] for token in tokens if token['pronunciation'])
        return pronunciation
```

### 文節分割
`split_text_into_phrases`はテキストを文節単位に分割するメソッドです。

文節への分割ルールは以下を参考にしています。
https://qiita.com/shimajiroxyz/items/e44058af8b036f5354aa

以下のような分割結果が得られます。

```Python
tokenizer = PhraseTokenizer()
print(tokenizer.split_text_into_phrases("今日はよく寝ました"))
```

```
[{'surface': '今日は', 'pronunciation': 'キョーワ'}, {'surface': 'よく', 'pronunciation': 'ヨク'}, {'surface': '寝ました', 'pronunciation': 'ネマシタ'}]
```

### 発音取得
`get_pronunciation`はテキストの発音（カナ表記）を取得する関数です。
以下のような出力が得られます。

```Python
tokenizer = PhraseTokenizer()
print(tokenizer.get_pronunciation("今日はよく寝ました"))
```

```
キョーワヨクネマシタ
```


### 発音の近さの計算
２つの単語の発音の近さを計算する関数（calculate_distance）と、発音の近さに基づいて単語リストを並び替える関数（sort_by_distance）を作ります。

```Python
def sort_by_distance(target_word: dict, wordlist: list[dict]) -> list[dict]:
    """
    対象単語の発音と単語リストの各単語の発音との編集距離に基づいて、単語リストをソートします。
    
    引数:
    target_word (dict): 編集距離の比較対象となる 'pronunciation' キーを含む辞書。
    wordlist (list of dict): 各々が少なくとも 'pronunciation' キーを含む辞書のリスト。
    
    戻り値:
    list of dict: 対象単語の発音に対する編集距離が増加する順にソートされた単語リスト。
    """
    # 発音に基づいて単語リストの各単語の編集距離を計算
    distances = [(word, calculate_distance(target_word, word)) for word in wordlist]
    # 編集距離（第二要素）に基づいてタプルのリストをソート
    sorted_distances = sorted(distances, key=lambda x: x[1])
    # タプルのリストからソートされた単語を抽出
    sorted_wordlist = [word for word, distance in sorted_distances]
    return sorted_wordlist

def calculate_distance(dict1: dict, dict2: dict) -> int:
    """
    2つの辞書から 'pronunciation' キーの値を取得し、その編集距離を計算して返します。

    引数:
    dict1 (dict): 'pronunciation' キーを含む辞書。
    dict2 (dict): 'pronunciation' キーを含む辞書。

    戻り値:
    int: 2つの発音の編集距離。
    """
    return ed.eval(dict1['pronunciation'], dict2['pronunciation'])
```

拡張しやすいように、入力はstrではなくdictにしています。
編集距離を求める関数をeditdistance.evalをラップしたものにしているのも、拡張性を持たせるためです。

calculate_distanceでは以下のような出力が得られます。

```Python
word1 = {"pronunciation": "ピカチュウ"}
word2 = {"pronunciation": "ライチュウ"}
print(calculate_distance(word1, word2))
```

```
2
```

sort_by_distanceでは以下のような出力が得られます。

```Python
word = {"pronunciation": "ピカチュウ"}
wordlist = [{"pronunciation": "ライチュウ"}, {"pronunciation": "ピカチュウ"}, {"pronunciation": "ピチュー"}]
print(sort_by_distance(word, wordlist))
```

```
[{'pronunciation': 'ピカチュウ'}, {'pronunciation': 'ライチュウ'}, {'pronunciation': 'ピチュー'}]
```

## 発音の近い単語列の取得
find_closest_words関数は以下の流れで、入力テキストに発音の近い単語列を求めます。

- textとwordlistの２つが入力
- textをPhraseTokenizer.split_text_into_wordsで、文節に分割すると同時に、各文節の発音も取得
- 各文節に最も発音の近い単語を求める

以下のような出力が得られます。

```Python
wordlist = load_wordlist("sample_wordlist.csv")
original_text = "海は広いな大きいな。月がのぼるし日が沈む"
closest_words = find_closest_words(original_text, wordlist)
    
for word in closest_words:
    print(word["original_phrase"]["pronunciation"], word["closest_word"]["pronunciation"])
```

```
ウミワ バハマ
ヒロイナ ボツワナ
オーキイナ。 オーストリア
ツキガ トンガ
ノボルシ ベラルーシ
ヒガ タイ
シズム シリア
```

いい感じです。

# 今後の課題
入力文を文節に区切って、各文節に編集距離の近い単語を当てはめることで、〇〇で歌ってみたシリーズっぽい単語の並びを生成することができました。

今後の改良点として以下があります。

- 発音の近さの定義
  - カナよりもモウラに基づいて編集距離を求めたほうがよいです。カナに基づく場合、「シャ」の「シ」と「ャ」は別の文字として編集距離が計算されますが、発音としては「シャ」の単位なので、「シャ」として扱ったほうがよいです。
  - 文字同士の類似度を0か1かではなく連続的に定義できたほうが良いです。今は文字が同じかどうかしか見ていませんが、文字が違っても母音が同じだとある程度音が近いように感じられるなどありそうですので、そのような機微を評価できたほうがよいです。
- 文章の区切り方
  - 全体としての編集距離を近づけるためには、文節以外の切れ目を採用したほうがいい可能性もあるので、より緩い条件のもとで区切り方を探索できたほうがよいです。
- その他
  - 単語の重複なしで替え歌歌詞を生成できたほうが良いです。今は、単語の重複が許されていますが、〇〇で歌ってみたシリーズでは同じ単語を一つの替え歌歌詞で同じ単語を２回使うことをよしとしないケースが多いです（意図的に使われることもありますが）。

# おわりに
〇〇で歌ってみたシリーズの歌詞を自動生成する取り組みの第一歩の実装について解説しました。
ここから少しずつ改良を加えていこうと思います。