faster-whisperで漢字を抑制してひらがな、カタカナ出力する

## はじめに
faster-whisperを使用して音声認識結果に漢字を含めない方法を紹介します。
faster-whisper(whisper)にあるsuppress_tokensという項目があり、こちらに例えば漢字トークンを指定すると漢字が除外され、ひらがな、カタカナ、記号数字等で文字起こしができます。
suppress_tokensとは、指定したトークンIDの出力を抑制するためのパラメータです。指定したトークンが生成されそうになった場合に、それを無視するようモデルに指示できます。


## 手順

全体の流れは下記です：
①whisperトークンファイルをDL
②漢字のトークンファイル作成
③whisperのsuppress_tokensにトークンリストを指定してtranscribe
※①②はローカルPCでOKです

### ①whisperトークンファイルをDL
以下のリンクから、multilingual.tiktokenファイルをダウンロードします(日本語の場合)

https://github.com/openai/whisper/blob/main/whisper/assets/multilingual.tiktoken

ローカルPCの適当なディレクトリに保存しておきます。


### ②漢字のトークンファイル作成
次のPythonスクリプトを実行して、漢字を含むトークンIDのみを抽出し、ファイルに保存します。
```py:create_kanji_tokens.py
import base64

# multilingual.tiktoken ファイルのパス
token_file_path = "./whisper/multilingual.tiktoken"

# 漢字が含まれているか判定する関数
def contains_kanji(text):
    for char in text:
        code = ord(char)
        if (
            0x4E00 <= code <= 0x9FFF or  # CJK統合漢字（基本漢字） U+4E00～U+9FFF
            0x3400 <= code <= 0x4DBF or  # CJK統合漢字拡張A U+3400～U+4DBF
            0xF900 <= code <= 0xFAFF     # CJK互換漢字 U+F900～U+FAFF
        ):
            return True
    return False

# multilingual.tiktokenファイルを読み込み、デコードしてトークンIDを取得する
def load_tokens(file_path):
    tokens = []
    found_kanji = set()
    with open(file_path, 'r', encoding='utf-8') as file:
        for line in file:
            base64_token, token_id = line.strip().split()
            decoded_token = base64.b64decode(base64_token).decode('utf-8', errors='ignore')
            tokens.append((decoded_token, int(token_id)))
    return tokens


# 漢字を含むトークンIDだけを抽出
def get_kanji_only_token_ids(tokens):
    kanji_token_ids = []
    for token, token_id in tokens:
        if contains_kanji(token):
            kanji_token_ids.append(token_id)
    return kanji_token_ids

# トークンIDをファイルに書き出す
def save_tokens_to_file(tokens, output_file_path):
    with open(output_file_path, 'w', encoding='utf-8') as file:
        for token_id in tokens:
            file.write(f"{token_id}\n")

# トークンを読み込む
tokens = load_tokens(token_file_path)

# 漢字トークンだけ取得
kanji_token_ids = get_kanji_only_token_ids(tokens)

# ファイルに保存
save_tokens_to_file(kanji_token_ids, "kanji_tokens.txt")

print("漢字を含むトークンIDが 'kanji_tokens.txt' に保存されました。")
```

すると、whisper用漢字トークンtxtが出力されます
```text:kanji_tokens.txt
1541
1546
1654
1960
2131
2166
2257
2289
2412
2664
(中略)
50157
50181
50218
50255
```


### ③whisperのsuppress_tokensにトークンリストを指定
faster-whisperが動作する環境で上記txtをパースして実行します。
実行はa10上でlarge-v3モデルを利用します。

```py:mp3.py
from faster_whisper import WhisperModel
import numpy as np
from pydub import AudioSegment

# Whisperモデルのロード
whmodel = "large-v3"
model = WhisperModel(whmodel, device="cuda", compute_type="float32")
print(f"WhisperModel={whmodel}")

# suppress_tokensの読み込み
def load_suppress_tokens(file_path):
    with open(file_path, 'r', encoding='utf-8') as f:
        return [int(token) for token in f.read().split()]

suppress_tokens = load_suppress_tokens("/home/ubuntu/kanji_tokens.txt") 
print("suppress_tokens loaded")

# 文字起こし関数
def transcribe_mp3(file_path):
    print(f"\n▶️ Transcribing: {file_path}")
    audio = AudioSegment.from_mp3(file_path)
    audio = audio.set_channels(1).set_frame_rate(16000)
    pcm_data = np.array(audio.get_array_of_samples()).astype(np.float32) / 32768.0

    segments, _ = model.transcribe(
        pcm_data,
        language="ja",
        vad_filter=True,
        beam_size=5,
        suppress_tokens=suppress_tokens,
        # initial_prompt="ひらがなしゅつりょくしてください",
    )

    for segment in segments:
        print(f"[{segment.start:.2f} - {segment.end:.2f}] {segment.text}")

# 🔽 ここでファイルパスを配列で指定
mp3_files = [
    # "/home/ubuntu/001-sibutomo.mp3",
]

# 実行
for file_path in mp3_files:
    transcribe_mp3(file_path)
```

## 検証結果

```text:suppress_tokensでの出力ログ
(venv) ubuntu@test-instance-20240927-0907:~$ python mp3.py 
WhisperModel=large-v3
suppress_tokens loaded

▶️ Transcribing: /home/ubuntu/001-sibutomo.mp3
[1.12 - 3.86] ムテンカのシャボン玉せっけんなら、もうあんしん。
[4.98 - 10.24] てんねんのほしつせいぶんがふくまれるため、はだにうるおいをあたえ、すこやかにたもちます。
[11.40 - 16.68] おはだのことでおなやみのかたは、ぜひいちど、ムテンカシャボン玉せっけんをおためしください。
[18.04 - 22.40] おもとめは、0120-0055-95まで。

▶️ Transcribing: /home/ubuntu/g_03.mp3
[0.00 - 11.88] イスタンブールは、アジアとヨーロッパにまたがる町で、この2つのタイリクをわけているのがボスポラスカイキョウです。
[13.00 - 21.36] アジアとヨーロッパのあいだをすすんでいくそうだいなたいけんができるボスポラスカイキョウクルーズをたんのうしていただくよていです。

▶️ Transcribing: /home/ubuntu/g_23.mp3
[0.05 - 6.05] 「かいがいりょこうにいきたいけど、なかなかやすみもとれないし、はやりのみせにもあきちゃった。」
[6.05 - 12.05] 「おやこでアウトドアたいけんもいいけど、よういもひつよう。なかなかいっぽがふみだせない。」
[12.05 - 14.05] アンディはこまっていました。
[14.05 - 19.05] むすこのけんが、テレビでみたアメリカのバーベキューをたべたいというのです。
[19.05 - 26.05] 「アメリカのバーベキューはワイルドだ。じいちゃんがよくやってくれたと、おもいでばなしをしてたせいでしょうか。」
```

いい感じに漢字が抑制できてますが、若干「しゃぼん玉」が出てしまいました。
玉は文字コードU+7389のため、最初の除外文字コードに収まっていますが、おそらくwhisperモデル内部のトークン化・デコードプロセスの影響で完全に抑制できないケースがあると考えられます。
いずれにしてもほぼやりたい出力はできていました。

参考までにsuppress_tokensを外すと下記の通りきれいに漢字交じりで出力されます。
```text:通常の出力ログ
(venv) ubuntu@test-instance-20240927-0907:~$ python mp3.py 
WhisperModel=large-v3

▶️ Transcribing: /home/ubuntu/001-sibutomo.mp3
[0.90 - 3.82] 無添加のシャボン玉石鹸ならもう安心
[3.82 - 10.32] 天然の保湿成分が含まれるため肌にうるおいを与え、すこやかに保ちます
[10.32 - 16.74] お肌のことでお悩みの方はぜひ一度、無添加シャボン玉石鹸をお試しください
[16.74 - 22.42] お求めは0120-0055-95まで

▶️ Transcribing: /home/ubuntu/g_03.mp3
[0.00 - 11.92] イスタンブールは世界で唯一アジア大陸とヨーロッパ大陸にまたがる街でこの2つの大陸を分けているのがボスポラス海峡です
[11.92 - 21.40] アジアとヨーロッパの間を進んでいく壮大な体験ができるボスポラス海峡クルーズを堪能していただく予定です

▶️ Transcribing: /home/ubuntu/g_23.mp3
[0.05 - 3.87] 海外旅行に行きたいけどなかなか休みも取れないし
[3.87 - 5.79] 流行りの店にも飽きちゃった
[5.79 - 10.03] 親子でアウトドア体験もいいけど用意も必要
[10.03 - 12.15] なかなか一歩が踏み出せない
[12.15 - 14.39] アンディは困っていました
[14.39 - 19.07] 息子のケンがテレビで見たアメリカのバーベキューを食べたいというのです
[19.07 - 21.99] アメリカのバーベキューはワイルドだ
[21.99 - 25.77] じいちゃんがよくやってくれたと思い出話をしてたせいでしょうか
```


## おわりに
suppress_tokensを使って漢字を抑制する出力を試してみました。
例えば氏名をカタカナで取得したい場合、suppress_tokensなしだとwhisperが「石川雅人」と出力してしまうと、「イシカワマサト」、「イシカワマサヒト」など一つに絞り込めません。
suppress_tokens使えば例えば、whisperのデコード処理に揺らぎがあるものの、ほぼ確実に氏名をカタカナで取得できそうです。