Whisperで楽曲データ文字起こし→からのPythonでカラオケする

前回までで、Pythonで動くオーディオプレイヤーを自作してみました（ちゃちいです）

https://qiita.com/mebi/items/ad36c2cb2ff71cabfdff

これにカラオケ機能をつけたいと思います。

---

# 歌詞データの用意

OpenAI社のWhisperという文字起こしモデルを使います。


    pip install -U openai-whisper

whisperはtorchで動きます。
下記のコードを実行してGPUで動くかどうか確認しましょう。

    import torch
    print(torch.__version__, torch.cuda.is_available())


```
2.3.1+cu118 True
```
みたいに表示されればOKです。

※torchを入れとかないと、CUDA＋CUDNN設定した環境で、Whisperモデル動かしてもCPUで動きます。
※私は上でハマりました。


```python

import whisper
model = whisper.load_model("large")

result = model.transcribe(
    file_path,
    verbose=True,
    word_timestamps=True,
    language='ja'
)

import json
with open(file_path + '_result.json', 'w', encoding='utf-8') as f:
    json.dump(result, f, ensure_ascii=False)
```

word_timestampsのオプションをTrueに指定することで、
おおよそ１文字ずつの文字おこし結果が出力されます。
(jsonで保存しておきます）

きっとカラオケシステムのために用意されているに違いありません...（違う
※このオプション見つけたので、今回のネタをおもいついました。

---
# Whisperの出力

こんなJSONです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3812996/6e94e529-6606-7a97-4c96-4c9f366a61fa.png)

* text ... 文字起こし全テキストが入っています。
* segments ... 分割された文字起こしテキスト（1節）が入っています。
* words ... 文字起こしテキストのおおよそ1文字？が入っています。1文字ずつはいるかとおもっていましたが「しく」みたいに2文字入っているwordsもあるみたいです。
* start ... データの開始秒数
* end ... データの終了秒数


---

# オーディオプレイヤーにカラオケ表示機能を盛り込む

JSONの出力を見ていただけると、だいたいどんな機能を作ればよいか見えてきます。

1. 歌詞情報のJSONロード
1. segmentsでループ
1. segmentsのtextをdisableカラー（灰色とか）で出力
1. wordsでループ
1. wordのtextをenableカラー（緑とか）で出力
1. endの時間がきたら、次のwordやsegmentに移動

---

# 実行コード

文字カラーの部分的に色を変えるっていう要件があったので、
tkのテキストウィジェットで実現することとなりました。

tkのテキストウィジェットでつかえるタグを利用して、
segmentタグと、highlightタグを用意しました。
タグによって色を変えます。

```python
        self.lyric_text = tk.Text(self.tk_root, font=("Arial", 15, "bold"), wrap="word", height=4, width=45)
        self.lyric_text.pack(pady=5)
        self.lyric_text.tag_configure("segment", foreground="gray")
        self.lyric_text.tag_configure("highlight", foreground="lightgreen")
```

こんな感じで、tag_add()を使って、タグを付与します。
またtkのindexは
"行数(縦).文字数(横)"
のテキストで指定するようです。
（数字入れようとしてハマった）

```python
        tk_start_index = f"1.{start_index}"
        tk_end_index = f"1.{end_index}"
        self.lyric_text.tag_add("highlight", tk_start_index, tk_end_index)
```

描画更新処理はゴリッとかいてしまいました。

```python

    def start_lyric(self):
        def update_lyric():
            self.segments_index = 0
            self.words_index = 0
            self.word_count = 0
            while pygame.mixer.music.get_busy():
                self.display_lyric(AudioPlayer.LYRIC_UPDATE_SEC)
                time.sleep(AudioPlayer.LYRIC_UPDATE_SEC)
            self.level_meter_canvas.delete("all")

        Thread(target=update_lyric, daemon=True).start()

    def display_lyric(self, search_len_sec):
        current_sec = pygame.mixer.music.get_pos() / 1000.0  # ミリ秒から秒に変換
        current_segment = self.lyric_dict["segments"][self.segments_index]

        # segmentのエンドに到達したら次のsegmentへ。
        if (float(current_segment["end"]) < current_sec) and (self.segments_index < len(self.lyric_dict["segments"])):
            self.segments_index += 1
            self.words_index = 0
            self.word_count = 0
            current_segment = self.lyric_dict["segments"][self.segments_index]
            self.lyric_text.delete("1.0", tk.END)

        # segmentの表示条件。segmentのstartに到達した初回のみ。
        should_show_new_segment = ((float(current_segment["start"]) <= current_sec) and
                                   (float(current_segment["start"]) + search_len_sec > current_sec))
        if should_show_new_segment:
            self.lyric_text.insert(tk.END, current_segment["text"], "segment")

        # wordsテキストを時間経過によって緑色でオーバーラップ
        current_word = current_segment["words"][self.words_index]

        # wordのエンドに到達したら次のwordへ。
        if (float(current_word["end"]) < current_sec) and (self.words_index < len(current_segment["words"])):
            self.words_index += 1
            current_word = current_segment["words"][self.words_index]

        # segmentと同じかんじでwordの表示条件。
        should_show_new_word = ((float(current_word["start"]) <= current_sec) and
                                (float(current_word["start"]) + search_len_sec > current_sec))

        if should_show_new_word:
            start_index = self.word_count
            end_index = start_index + len(current_word["word"])
            # tkのテキストウィジェットのindexに変換
            tk_start_index = f"1.{start_index}"
            tk_end_index = f"1.{end_index}"
            # タグを設定して、通過済みの歌詞をハイライト表示
            self.lyric_text.tag_add("highlight", tk_start_index, tk_end_index)
            self.word_count = end_index
```

# 実行結果

![Screenshot from 2024-07-07 22-40-44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3812996/a9e43f94-2351-45ac-9506-0310113c2c91.png)

スクショじゃわかりませんが
オーディオデータの進行に沿って、1文字ずつ（たまに2文字）緑色に変わっていきます


これで世の中のあらゆる楽曲を適当にWhisperにぶち込んでカラオケ再生することができそうです、やったね！


最終的にはmp3のメタテキストとして保存できると、ファイルにまとまりがあってよさそうです。