🏇

2023年時点の音声認識技術を使って、競馬中継の実況音声を文字起こし

2023/06/25に公開

もう既に試している人がいるかどうかは分かりませんが、
「昨今の音声認識技術を使って競馬中継の実況音声をうまく文字起こしできないものか?」と思ったので、実際にいくつか試してみた結果を共有します。

前提

機械学習による昨今の音声認識技術の多くは、大規模なデータセットを用いて音声と単語を結びつける学習をしており、一般的な単語に対する処理能力の精度を飛躍的に向上させました。

逆に言えば、それらは馬名のような固有名詞の認識には向いていません
例えば サクセスブロッケン という馬名は サクセスブロッケン というごく一般的な単語からなりますが、実況音声で読まれる時は「サ↓ク↑セ↑ス↑ブ↑ロ↑ッケ↓ン↓!!!!!![1]」のようにひとつの単語として読まれるため、学習時に得られた サ↑ク↓セ↓ス↓ および ブ↓ロ↑ッケ↓ン↓ というデータとは一致せず、正しく認識されないことが多いです。

とはいえ、馬名を音声認識技術で文字起こしできないと不便ですよね。日常生活とか色々。
というわけで、どこまでできるか実際にやってみました。

今回は、最近の出馬投票の成績が振るわなくて予算が捻出できないので、なるべく無料で使えるライブラリを使います。

  • Google Speech Recognition
  • VOSK
  • Whisper

基本的に Python (3.10) で色々な処理をします。筆者はAnacondaで環境を作っていますが、他のPython環境でも問題ないです。


実践編

今回はこちらを音源として使用します。
(リアルタイム再生でない場合は) wav形式に録音したものを使用します。
https://www.youtube.com/watch?v=-Piy4e507-8

広い府中を一人旅!」の名実況でおなじみ、
タップダンスシチー が大逃げのまま圧勝した2003年のジャパンカップ(GI)です。

地上波版の実況も有名ですが、ラジオNIKKEI[2]版の方が語彙が簡潔なのでこちらを使います。実はこの時の小林雅巳アナは、海外馬の フィールズオブオマー を終始 フィールズオブマター と呼んでしまっていますが、これは誤差の範囲として許容します。英語は難しいので仕方ないですね


1. Google Speech Recognition

Googleの音声認識技術です。Google ChromeのWeb Speech APIの音声認識としても使われています。
Web Speech APIの音声認識については「Google Chromeで使えます!」と短絡的に書いた記事が非常に多いですが、厳密には「 Web Speech APIの音声認識機能は主要なブラウザではサポートしているものの使える状態になっているものは少なくGoogle Chromeの場合はGoogleのサーバを使う形で実装しているため使える 」というのが正解のようです。Google Chromeを普段使いしていないので、ここまで書いてもらわないとどうやって動いているのか分からないです。

https://www.twilio.com/ja/blog/speech-recognition-browser-web-speech-api-jp

今回も同様にGoogleのサーバ側で処理をするので、オフラインでの動作はしません。

まずは、SpeechRecognitionのライブラリをインストール。

pip install speechrecognition

ここからGoogle Speech Recognitionを叩きます。
コードはこちら。

実行用コード:Google Speech Recognition
import speech_recognition as sr

r = sr.Recognizer()

## 音声ファイル
voice_file ="Voice2003.wav"

with sr.AudioFile(voice_file) as source:
    audio = r.record(source)

result = r.recognize_google(audio, language='ja-JP')

print(result)

そして結果がこちら。

出力結果:Google Speech Recognition
スタートしました ほぼ 揃いました まず スタンド前
先行 争いは家からタップダンスシーンが行きます タップダンス T
そして ザッツザプレンティ 早めに番て後はアクティブ 培養皿にはアンジュ ガブリエル
家には4番 Fields Of また そして イズリントンが 前から6番手のところ
あの 丸い シンボリクリスエス は中団 グループ
その外に状 はがき 貼り付けています後 サラサラには電圧 位相
市 光が重たいます そして外を回って サンライズペガサス
あるいは NEO UNIVERSE 工法によります 家に入ってするバレー
後方から4番手にはタイガーテール さくらプレゼントがいて
後は後ろから二東名 ダービー レグの最高峰 鶴丸通りとなりまして
春 日市 コーナー カーブしてきました
銭湯は一番 タップダンスシチー 堂々と銭湯でリードを4 馬身から土橋
2番手には ザッツザプレンティで2 コーナー カーブして 向正面が後は3馬身差
アクティブ バイオ 3番 てそして4番手には4番 Fields Of またはなくております
さらには アンジュ ダブリエールを そして イズリントン
外は蒸発して中団 グループ方 待ってするバレー
シンボリクリスエス は中断の前から ファンがマークしています
そのうちには そのうちには 6番のアノマリーが追走して3 コーナーへ向かいます
あと中断に2番の出るんが追走しております
さらには NEO UNIVERSE は中断のやや後ろに走んさんいまして サンライズペガサス
さら には タイガーテール後ろから3番手にはツルマル ボーイ
サクラ プレジデント瓶ています 最高峰はダービー レグノ
価格だ これから 第三項の向かいますが 戦闘はタップダンス チー
リードを広げたり 移動中 バインで残り1000 M 2か
2番手は2番手には 10番のザッツザプレンティがつけて後は3番手にはアクティブ バイオ
そして7つの離島を止まって何とも今4番手に入ります
ルール 描いて後をしている 4番 Fields Of mother
34 コーナー 中間です そして 外 埋まりまして アンジュ ガブリエル
その外には シンボリクリスエス まだ 中断に控えております
さあ 第4コーナーはこれから向かいます
見えているタップダンスシチー より色が亡くなった後走ると600 M 2日
第4コーナーを借りていよいよ 直線コースに向かって参ります
タップダンス中 戦闘ですリードは4 馬身から倉敷
ザッツザプレンティ 忘れて 3番手には追い込んでくる これが 11番 アクティブ バイオ
坂を上って暮らそうか追い込んできた 2番 ので飲んでの寿司
シンボリクリスエス様が中団 グループ 本当は逃げろ逃げろ タップダンスシチー
たっぷりのが3番から4番電話を一本にする あるいは NEO UNIVERSE ザッツザプレンティ
ソラニワホームへの扉 シンボリクリスエス ちょっと無理がない
足りなくなったら追加した 本当は フランス フランス

残り100mで力尽きてしまいました。歓声が大きくなるので、うまく聞き取れなくなっているようです。 フランス は多分 タップダンス のことでしょう。
今回は無加工の状態でしたが、ファイルを分割して渡すと幾分まともになります。

さて、SpeechRecognitionの場合は単語登録ができません。
今回の場合、かろうじて タップダンスシチー シンボリクリスエス ザッツザプレンティ あたりは固有名詞として認識していることが分かります。これらはGI級競走で何度も勝利ないし連対をしているような有名馬なので、Google的に固有名詞として処理されているのだと思われます。 ネオユニヴァース は今回は別の単語として処理されてしまっていますが、この馬も同様に固有名詞として認識されることが多いです。
一方それ以外は、たとえWikipediaに記事があるような馬だったとしても馬名として認識されてないことが分かります。この場合だと アクティブバイオ サクラプレジデント あたりが該当します。


2. VOSK

最近人気の音声認識ツールキットで、日本語をはじめ様々な言語に対応しています。モデルさえダウンロードすればオフラインでも動作するのが特徴です。

モデルは以下を使用します。ディレクトリ指定もできますが、特に用意していなくても実行時に自動でダウンロードしてくれます。

vosk-model-ja-0.22.zip

https://alphacephei.com/vosk/models

パッケージは vosk の他に、WAVファイルがモノラルでないとエラーになるので pydub もインストールします。 ffmpeg が端末に無い場合は怒られますが、無くても変換はできるので無視してOKです。

pip install vosk
pip install pydub

コードは公式のサンプルコードを丸コピ……もとい、参考にしつつ以下のようになりました。

実行コード:VOSK
import wave
import sys
import json
from pydub import AudioSegment
from vosk import Model, KaldiRecognizer, SetLogLevel

## 音声ファイル
voice_file ="Voice2003.wav"

SetLogLevel(-1)

## wavファイルをモノラルPCMに変換
audio = AudioSegment.from_wav(voice_file)

if audio.channels == 2:
    audio = audio.set_channels(1)
if not audio.sample_width == 2:
    audio = audio.set_sample_width(2)

modified_voice_file = "Modified_Voice2003.wav"
audio.export(modified_voice_file, format="wav")
wf = wave.open(modified_voice_file, "rb")

## 使用するモデルの定義 (ダウンロードされる)
model = Model(model_name="vosk-model-ja-0.22")

rec = KaldiRecognizer(model, wf.getframerate())
rec.SetWords(True)
rec.SetPartialWords(True)

with open(modified_voice_file, "rb") as wf:
    wf.read(44)

    while True:
        data = wf.read(4000)
        if len(data) == 0:
            break
        if rec.AcceptWaveform(data):
            res = json.loads(rec.Result())
            print(res["text"])

    res = json.loads(rec.FinalResult())
    print(res["text"])

そして実行結果はこちら。

実行結果:VOSK
スタート し まし た
ほぼ 揃い まし た まず スタンド 前
先行 争い は うち から タップ ダンス シーン が 行き ます と アップ ダウン シティ
そして ザッツ の プレンティ 早め に 番手 後 は アクティブ 培養
皿 に は アンジュ ガブリエル 中 に は 4 番 フィールズ オ ブ マター
そして 伊豆 リントン が 前 から 6 番 の ところ あまり
シンボリクリスエス は 中団 グループ その 外 に 情報 が ぴったり つけ て い ます
後 そら ファン さらに は デノン 厚い 層 1 川 を 迎え ます
そして 外 を 回っ て サンライズ ペガサス
あるいは ネオ ユニヴァース 工法 に より ます
中 に 入っ て スルー バレエ 後方 から 4 番 手 に は タイガ ー て いる
サクラ プレジデント が い て あと は 後ろ から 2 頭 目 ダービー レッグ の
最高 峰 鶴丸 ボーイ と なり まし て 各 ば 1 コーナー を カーブ し て いき まし た
本当 は 一番 タップダンスシチー 堂々 と 先頭 で リード を 4 馬身 から 5 馬身
2 番 手 に は ザッツ の プレンティ で
2 コーナー を カーブ し て 向こう 上面 で
後 は サンバ 審査 アクティブ バイオ 3 番 手
そして 4 番 手 に は 4 番 フ ィールド オブ マター が つけ て おり ます
更に は アンジュ ガブリエル を そして いずれ 林 と 外 は 徐波
そして 中団 グ ループ 固まっ て スルー バレエ
シンボリクリスエス は 中段 の 前 から ファン が マーク し て い ます
その 中 に は その 中 に は 6 番 の 誤り が 追想 し て 3 コーナー へ 向かい ます
後 中断 に 2 番 の デノン が 追想 し て おり ます
更に は ネオ ユニヴァース は 中団 の やや 後ろ に
橋 に 触り まし て サンライズ ペガサス さらに は タイガー テール
後ろ から 三 番 手 に は 鶴丸 ボーイ サクラ プレジデント た め て い ます
最後 は ダービー レグ の 各 これ から 第 3 コーナー を 迎え ます が
戦闘 は タップ ダンス 木 リード を 広げ た リード 十 馬身 で 残り 千 メートル の 2 か
2 番 手 は 2 番 手 に は 十 番 の 雑 さ プリント が 透け て
あと は 3 番 手 に は アクティブ バイヤー そし て 長く 乗り と
外 を 回っ て いずれ に とも 今 4 番 手 に 上がり ます
スルー で 描い て 後 押し て いる 4 番 フィー ルド オブ また
3 4 コーナー 中間 です そして 外側 まいり まし て アンジュ が ビール
その 外 に は シンボリクリスエス まだ 中団 に 控え て おり ます
さあ 第 4 コーナー を これ から 向かい ます
見え て いる タップダンスシチー リード が なくなっ て き た 5 馬身 ほど
六百 メートル 2 か  第 4 コーナー を カーブ し て
いよいよ 直線 コース に 向かっ て まいり ます
タップ ダンス し 戦闘 です リード が 4 馬身 6 馬身
雑木 林 4 番 手 3 番 手 に は 追い込ん で くる
これ が 十 倍 アップ バイ お 坂 を 上がっ て くる
アポロ か 追い込ん で と 二 番 の で 飲ん で
そして シンボリクリスエス は まだ 中団 グループ は
逃げろ 逃げろタップ ダンス タップ ダンス リード が 桟橋 から 四 柱
寺 が 追い込ん で くる あるいは ウイル バース 雑 は プレンティ
さらに は 懸命 の 国 の シンボリクリスエス ちょっと の ランス は
二百 迫力 の 姿 本当 は タップ ダンス タップ し
そして さあ さあ プリント 紙 バンク て いる バス 中 から 三 番 手
そして ようやく 追い込ん で た シンボリクリスエス
しかし 日 が 来 た タップ ダンス し た プロ 志望
タップダンスシチー 広い 駆虫 を 独り旅

見やすいように改行しています。 タップダンスシチーシンボリクリスエス は語彙として登録されていたので、区切りなしで固有名詞としてしっかり認識されていますね。一応 ネオユニヴァース も登録されているようでしたが……

さて、VOSKの場合は語彙の追加ができるようです。
ただし、大容量モデルではこの機能に対応していないようです。(2023年6月時点)
なので次は軽量モデルを使うことにします。

vosk-model-small-ja-0.22

語彙はJSON形式で渡せるので、適当に文字列を書いてから適当に加工します。
タップダンスシチー という形式のままだとうまく認識してくれなかったので、タップ ダンス シ チー のように聞き取りやすいように分割してあげます。馬名には シチー のように一般的でない読み方が多いですからね。 (※友駿ホースクラブさんの冠名の悪口ではありません)

実行コード:VOSK (語彙追加版)
import wave
import sys
import json
from pydub import AudioSegment
from vosk import Model, KaldiRecognizer, SetLogLevel

## 音声ファイル
voice_file ="Voice2003.wav"

## 単語辞書
horse_dictionary = 'いち,に,さん,よん,ご,ろく,なな,はち,きゅう,馬身,差,先頭,番手,追走,中団,最後方,スタート,コーナー,ゴール イン,府中,大欅,'
horse_dictionary = horse_dictionary + 'タップ ダンス シ チー,ザッツ ザ プレンティ,シンボリ クリスエス,ネオ ユニヴァース,アクティブ バイオ,タイガー テイル,アンジュ ガブリエル,デノン,イズリントン,ダービー レグノ,サンライズ ペガサス,アナマリー,スルー ヴァレイ,サクラ プレジデント,ツルマル ボーイ,ジョハー,サラファン,フィールズ オブ オマー'
horse_dictionary = json.dumps(horse_dictionary.split(','), ensure_ascii=False)

SetLogLevel(-1)

## wavファイルをモノラルPCMに変換
audio = AudioSegment.from_wav(voice_file)

if audio.channels == 2:
    audio = audio.set_channels(1)
if not audio.sample_width == 2:
    audio = audio.set_sample_width(2)

modified_voice_file = "Modified_Voice2003.wav"
audio.export(modified_voice_file, format="wav")
wf = wave.open(modified_voice_file, "rb")

## 使用するモデルの定義 (ダウンロードされる)
model = Model(model_name="vosk-model-small-ja-0.22")

rec = KaldiRecognizer(model, wf.getframerate(), horse_dictionary)
rec.SetWords(True)
rec.SetPartialWords(True)


with open(modified_voice_file, "rb") as wf:
    wf.read(44)

    while True:
        data = wf.read(4000)
        if len(data) == 0:
            break
        if rec.AcceptWaveform(data):
            res = json.loads(rec.Result())
            print(res["text"])

    res = json.loads(rec.FinalResult())
    print(res["text"])

そして実行結果はこちら。

実行結果:VOSK (語彙追加版)
スタート シ
オブ イン
スタート
先頭
タップ ダンス シ チー タップ ダンス シ チー ザッツ ザ プレンティ に 番手
アクティブ バイオ に
アンジュ ガブリエル よん フィールズ オブ オマー
イン
ろく 番手
差 中団 スルー
に
スタート スルー
に デノン はち いち コーナー
オマー サンライズ ペガサス
ネオ に に よん に スルー コーナー
番手 に タイガー テイル サクラ プレジデント 番手 シ ろく に
ダービー レグノ スルー ボーイ
サクラ いち コーナー ダービー シ チー
先頭 いち 番手 タップ ダンス シ チー 先頭 ダービー よん 馬身 ご 馬身
番手 に ザッツ ザ プレンティ に コーナー オブ シ
コーナー
さん 馬身 差 アクティブ バイオ さん 番手
よん 番手 に よん バイオ フィールズ オブ オマー
タイガー テイル に アンジュ ガブリエル
スタート
中団 ろく 番手 スルー バイオ シ 中団
さん オマー シ イン
に
に ろく 番手
さん コーナー
中団 に に 番手 デノン はち 差 シ テイル に ネオ に 差 中団
に 馬身 差 サンライズ ペガサス に タイガー テイル シ ろく
番手 に スルー ボーイ サクラ プレジデント イン
タイガー ダービー レグノ
タップ ダンス コーナー
先頭 タップ ダンス フィールズ ダービー
馬身 デノン 先頭
番手 番手
番手 ザッツ ザ プレンティ スタート さん 番手 に アクティブ バイオ
なな レグノ に オマー プレンティ よん 番手 スルー レグノ シ テイル
よん バイオ フィールズ オブ さん よん コーナー 中団
アンジュ ガブリエル に
中団 に
差 バイオ コーナー タイガー テイル タップ ダンス シ なな
馬身 ろく ろく バイオ コーナー ダービー よん
先頭 タップ タップ ダンス シ 先頭 スルー
よん 馬身 馬身 ザッツ ザ プレンティ 番手 さん 番手 に
ボーイ
アクティブ バイオ 差
ろく
バイオ デノン デノン
サクラ 中団 ろく 先頭
タップ ダンス シ タップ ダンス シ ろく
馬身 よん 馬身 デノン
ザッツ ザ プレンティ
馬身 ペガサス 差
先頭 タップ ダンス シ タップ ダンス シ ザッツ ザ プレンティ 番手 ネオ
さん 番手 よん
馬身 プレンティ

タップ ダンス シ タップ ダンス シ ゴール イン
タップ ダンス シ チー
府中 ダービー

期待よりも遥かに悲惨な結果になりました。軽量モデル+文字情報の語彙だけだとこのくらいになるのでしょう。

もっと精度を高めるための工夫もいくつか考えられますが、沼が深そうなので今回は割愛します。

https://alphacephei.com/vosk/adaptation


3. Whisper (OpenAI)

Whisper」と「Whisper API」の2種類があって紛らわしいですが、今回はGitHubに公開されている前者のライブラリを使います。
https://github.com/openai/whisper

こちらもVOSK同様に自分のマシンで処理をするので、モデルさえダウンロードしておけばオフラインで使うことができます。

ffmpeg がないとうまく動かないので、追加でインストールします。

pip install ffmpeg
# conda install -c conda-forge ffmpeg

pip install git
# conda install git

pip install git+https://github.com/openai/whisper.git

コードはこちら。

実行用コード:Whisper
import whisper
 
model = whisper.load_model("medium") # tiny/base/small/medium/large
 
## 音声ファイル
voice_file ="Voice2003.wav"

## 単語辞書
horse_dictionary = "スタート、1コーナー、2コーナー、向正面、第3コーナー、第4コーナー、先頭、1馬身、2馬身差、中団の各馬、後方を追走、府中、"
horse_dictionary = horse_dictionary + "タップダンスシチーがリード、ザッツザプレンティが追走、中団にシンボリクリスエス、内からネオユニヴァース、外からアクティブバイオ、追ってタイガーテイル、アンジュガブリエルはここ、デノンが追走、最内にイズリントン、"
horse_dictionary = horse_dictionary + "ダービーレグノ、サンライズペガサスは後方、アナマリー、スルーヴァレイが追走、サクラプレジデント、ツルマルボーイが追走、後方にジョハー、サラファン、最後方にフィールズオブオマー"

result = model.transcribe(voice_file, verbose=True, language='ja', initial_prompt=horse_dictionary, condition_on_previous_text=False)["text"]

print(result)

initial_prompt でヒントを渡すことができるので、
horse_dictionary に基本的な競馬用語と該当レース出走馬を指定しておきます。
単に固有名詞の指定としても使えますが、「こういう風に文章を出力してください」という指示の指定でもあるので、実際の実況っぽくなるように文章を加工しています。

最後の直線で歓声が大きくなると音声認識がしづらくなるので、condition_on_previous_text=False を指定して都度そのシーンに合わせた文章出力をさせます。(出力した文章をそのまま次のプロンプトとして提供する機能)
これを指定しないことによるデメリットは、結果の後に書きます。

使用するモデルについては tiny から large までの5種類あります。
詳細はここでは省きますが、とりあえず大きければ大きいほど正確になり、かわりにリソースを消費するようになることだけ認識していれば大丈夫です。

そして結果がこちら。

出力結果:Whisper
スタートしました。ほぼ揃いました。
まずスタンド前、先行争いは、内からタップダンスシチーが行きます。タップダンスシチー、
そしてザッツザプレンティ、早め2番手、あとはアクティブバイオ、
さらにはアンジュガブリエル、内には4番フィールズオブマター、
そしてイズリントンが前から6番手のところ、アナマリー、
シンボリクリスエスは中団グループ、その外にジョハーがぴったり付けています。
あとサラファン、さらにはデノンが追走、1コーガーを向かいます。
そして外を回ってサンライズペガサス、あるいはネオユニバース後方によります
うちに入ってスルーバレー後方から4番手にはタイガーテイル 桜プレジェントがいて
後は後ろから2頭目ダービーレグの最高法つるまるボーイとなりまして
各場1コーナーをカーブしていきました
先頭は一番タップダンス7堂々と先頭でリードを4馬進から5馬進
2番手にはザッツザプレンティーで2コーナーをカーブして向こう上面へ
あとは3馬進差アクティブバイオ3番手
そして4番手には4番フィールズオブマターがつけております
さらにはアンジュ・ガブリエル、そしてイズリントン、外はジョハー、
そして中段グループ固まって、スルーヴァレー、
シンボリ・クリステスは中段の前、サラファンがマークしています。
そのうちには、そのうちには6番のアナマリーが追走して3コーナーへ向かいます。
あと中段に2番のデノンが追走しております。
さらにはネオ・ユニバースは中段のやや後ろ、ニバシンサありましてサンダイズ・ペガサス、
さらにはタイガーテイル。後ろから3番手にはつるまるボーイ桜プレジェント貯めています
最高法はダービーレグの各場これから第三コーナーを向かいますが
先頭はタップダンスチリードを広げたリードを10バシンで残り1000メートルを通過
2番手は2番手には10番の雑さプレンティーがつけてあとは3番手にはアクティブバイオ
そして7楽の2等外を回ってイズリントが今4番手に上がりますスルーバレーがいて
あと押している4番フィールドまた
34コーナー中間ですそして外を回りましてアンジュがブリエル
その外にはシンボリックリステスまだ中段に控えております
さあ第4コーナーをこれから向かいます見えているタップダンスし
リードがなくなってきた5バシンの600メートルを通過
第4コーナーをカーブしていよいよ直線コースに向かって参りますタップダンスし先頭です
リードが4バシンから5バシン ザッツザプレンティング2番手
そして3番手には追い込んでくるこれが11番アクティブバイオ坂を上がってくる
あとはどうか追い込んできた2番のデノンでのそしてシンボリックに接送の中段グループ
先頭は逃げる逃げるタップランスし
タップランスしリードが3場しから4場しでのが追い込んでくるあるいはねーバース
雑雑プレンティーさらには懸命に込んでるシンボリックリステスショットの
リアライゾ200メートルを通過した先頭はタップランスチートアップランシー
そしてザプリン的なリバンティがネオインビバースの力3番手
そしてようやく追い込んできたシンポリックステス
しかし逃げ切ったタップランスシータップランスシーゴールイン
タップランスシーヒロイン苦中を一人旅

initial_prompt で単語を渡しているおかげで、序盤のみかなり高精度に認識しています。しかし第1コーナーを回ったあたりで怪しくなり、終盤は見る影も無くなってしまっています。
これは condition_on_previous_text=False にしていることの弊害で、前の出力文章が次の出力用のプロンプトとして全く参照されていないことによります。

では condition_on_previous_text=True にすれば解決するのかと言われると、それでも同様に 「固有名詞として指定したい馬名」については最初の文章以外では正しく反映してくれませんでした。
2023年6月時点のバージョンでは、 initial_prompt の指定が効くのはいずれにせよ最初の文章のみ、ということです。

さて、
将来的には initial_prompt を継続的に引用できるようになるかもしれませんが、
今回はこちらを参考にして transcribe.py に細工をします。

whisper/transcribe.py
- decode_options["prompt"] = all_tokens[prompt_reset_since:]
+ decode_options["prompt"] = initial_prompt_tokens

これで、すべての出力で initial_prompt の文章を参照するようになります。

また、せっかくなのでモデルも large を指定します。マシンが悲鳴を上げる場合は medium なりそれ以下のモデルに戻してください。

実行用コード:Whisper (精度改善後)
import whisper
 
model = whisper.load_model("large") # tiny/base/small/medium/large
 
## 音声ファイル
voice_file ="Voice2003.wav"

## 単語辞書
horse_dictionary = "スタート、1コーナー、2コーナー、向正面、第3コーナー、第4コーナー、先頭、1馬身、2馬身差、中団の各馬、後方を追走、府中、"
horse_dictionary = horse_dictionary + "タップダンスシチーがリード、ザッツザプレンティが追走、中団にシンボリクリスエス、内からネオユニヴァース、外からアクティブバイオ、追ってタイガーテイル、アンジュガブリエルはここ、デノンが追走、最内にイズリントン、"
horse_dictionary = horse_dictionary + "ダービーレグノ、サンライズペガサスは後方、アナマリー、スルーヴァレイが追走、サクラプレジデント、ツルマルボーイが追走、後方にジョハー、サラファン、最後方にフィールズオブオマー"

result = model.transcribe(voice_file, verbose=True, language='ja', initial_prompt=horse_dictionary, condition_on_previous_text=False)["text"]

print(result)

このバージョンでの実行結果がこちら。

出力結果:Whisper (精度改善後)
スタートしました!ほぼ揃いました。
まずスタンド前。先行争いは、内からタップダンスシチーが行きます。タップダンスシチー、
そしてザッツザプレンティ早め2番手、あとはアクティブバイオ、さらにはアンジュガブリエル、
内には4番、フィールズオブオマター 、そしてイズリントンが前から6番手のところ、
アナマリー、シンボリクリスエスは中団グループ、その外にジョハーがぴったり付けています。
さらにはデノンが追走、1コーナーを向かいます。
そして外を回ってサンライズペガサス、あるいはネオユニバース後方におります、
内に入ってスルーヴァレイ、後方から4番手にはタイガーテイル、サクラプレジデントがいて、
後は後ろから2頭目、ダービーレグノ、最後方、ツルマルボーイとなりまして、
各馬1コーナーをカーブしていきました
先頭は1番、タップダンスシチー、堂々と先頭でリードを4馬身から5馬身、
2番手にはザッツザプレンティで2コーナーをカーブして向こう上面へ、
あとは3馬身差、アクティブバイオ3番手、
そして4番手には4番フィールズオブオマターが付けております
さらにはアンジュガブリエル、そしてイズリントン、外はジョハー、
そして中団グループ固まってスルーヴァレイ、
シンボリクリスエスは中団の前、サラファンがマークしていますがマークしています。
そのうちには、そのうちには6番のアナマリーが追走して3コーナーへ向かいます。
あと中団に2番のデノンが追走しております。
さらにはネオユニバースは中団の矢や後ろ、2馬身差ありまして、サンライズペガサス、
さらにはタイガーテイル、後ろから3番手にはツルマルボーイ、
サクラプレジデント貯めています。
最後方はダービーレグノ、各馬これから第3コーナーを向かいますが、
先頭はタップダンスシチーリードを広げたリード10馬身で残り1000mを通過。
2番手には10番のザッツザプレンティがつけて、
あとは3番手にはアクティブバイオ、そして7番の2頭、外を回って、
イズリントンが今4番手に上がります、
スルーヴァレイがいて、あと押している4番、フィールズオブオマター、
3、4コーナー中間ですそして外を回りまして、アンジュガブリエル、
その外にはシンボリクリスエス、まだ中団に控えております
さあ第4コーナーをこれから向かいます、
逃げているタップダンスシチー、リードがなくなってきた5馬身ほど600mの通過
第4コーナーをカーブしていよいよ直線コースに向かって参ります
タップダンスシチー先頭ですリードが4馬身から5馬身、ザッツザプレンティが2番手、
そして3番手には追い込んでくるこれが11番アクティブバイオ、坂を上がってくる、
あとはどうか、追い込んできた2番のデノン、デノン、
そしてシンボリクリスエスとまだ中団グループ
先頭は逃げる逃げるタップダンスシチー、タップダンスシチー、リードが3馬身から4馬身、
デノンが追い込んでくる、あるいはネオユニバース、ザッツザプレンティが、
ネオユニバースの力、3番手、そしてようやく追い込んできた、シンボリクリスエス、
しかし逃げ切った、タップダンスシチー、タップダンスシチー、ゴールイン!
タップダンスシチー、ヒロイン、府中の一人旅!

ネオユニヴァース が一度として正しく呼ばれていないことが気になりますが、100点満点で言うと90点くらいの出力結果になりました。いくつか省略されてしまっている文章もありますが、精度としては十分でしょう。


リアルタイム編

ここからはおまけです。
どちらかというとリアルタイムで解析できた方がいいですよね、と思っていましたが実際にやってみるとなかなか制約があり難しかったので断念しました。

今回、実況音声はPC内で流すものを使う想定なので、PC内の音声をプログラムに渡すための設定が必要になります。
今回、OSはWindows 11を使います。Windows 10でも基本的に同様です。
マクドの人はWindows設定の部分をなんかいい感じに代替してください。

  • Windows設定 → システム → サウンド → サウンドの詳細設定」
  • サウンド → 「録音」タブ
  • 「ステレオミキサー」を右クリック → 有効
    (出ない場合は、何もない場所で右クリックすると「無効なデバイスの表示」という項目が出るのでそこにチェックすると出るらしい)

1. Google Speech Recognition リアルタイム

pip install speechrecognition
pip install pyaudio
## python3.10では、pyaudio 2.13以上推奨とのこと

コードはこちらの記事を参考にして、大部分を流用させていただきました。
https://qiita.com/KENTAROSZK/items/3f393c000c2492034c1b

実行用コード:Google Speech Recognition リアルタイム
import speech_recognition as sr
import wave
import time
from datetime import datetime
import pyaudio  ## python3.10ではv2.13以上推奨 # pip install pyaudio 

FORMAT        = pyaudio.paInt16
SAMPLE_RATE   = 44100    
CHANNELS      = 1        
INPUT_DEVICE_INDEX  = 1 ## ステレオミキサーのindexの値を指定 (端末毎に異なる場合あり)
CALL_BACK_FREQUENCY = 3 ## コールバック呼び出しの周期 (秒)

## ステレオミキサーのindexの値を探すためのスクリプト
def look_for_audio_input():
    pa = pyaudio.PyAudio()
    for i in range(pa.get_device_count()):
        device_info = pa.get_device_info_by_index(i)
        if 'ステレオ ミキサー' in device_info['name']:
            print("Index:", device_info['index'])
            print("Name:", device_info['name'])
            print("-----------")
    pa.terminate()

## コールバック関数の定義
def callback(in_data, frame_count, time_info, status):
    
    global sprec

    try:
        audiodata  = sr.AudioData(in_data, SAMPLE_RATE, 2)
        sprec_text = sprec.recognize_google(audiodata, language='ja-JP')
        print(sprec_text)
    
    except sr.UnknownValueError:
        pass
    
    except sr.RequestError as e:
        pass
    
    finally:
        return (None, pyaudio.paContinue)

def realtime_text_gen():

    global sprec
    sprec = sr.Recognizer() 
    pa = pyaudio.PyAudio() 

    ## ストリームオブジェクトの作成
    stream = pa.open(format             = FORMAT,
                     rate               = SAMPLE_RATE,
                     channels           = CHANNELS,
                     input_device_index = INPUT_DEVICE_INDEX,
                     input              = True, 
                     frames_per_buffer  = SAMPLE_RATE*CALL_BACK_FREQUENCY,
                     stream_callback    = callback)
    
    stream.start_stream()

    while stream.is_active():
        time.sleep(0.1)
    
    stream.stop_stream()
    stream.close()
    pa.terminate()

def main():
    look_for_audio_input()
    realtime_text_gen()

if __name__ == '__main__':
    main()

そして結果はこちら。

出力結果:Google Speech Recognition リアルタイム
スタートしました おばさん
まず スタンドまで 先行 争いは
家からタップダンスシチー が行きます タップランシティ
そして ザッツザプレンティ 早めに半纏 あとは アクティ
ソラニワ アンジュ ガブリエル 家には4番 フィールド
また そして イズリントンが前から6
ところ あまり シンボリクリスエス は中断 グループ
その外に情報がぴったりつけています あと サラファン
宍粟市小川の腕 そして 外
勝手 サンライズペガサス あるいは NEO UNIVERSE
東方に寄ります 家に入ってするバレー 後方から4番
タイガービール さくらプレゼントがいて後は後ろから
ダービー レグの最高峰 鶴丸通りとなり
春日市 コーナー カーブしてきました
今日は1番 タップダンスシチー 堂々と銭湯で
4馬身から5馬身 2番手には ザッツザプレンティ
2コーナーはカーブして 向正面が後は桟橋
アクティブ バイオ 3番 てそして4番手には
フィールドバター つけております ソラニワ アンジュラヴィ
イズリントン 外は情報 そして中団 グループ
固まってするバレー シンボリクリスエス は中断の前
さんがマークしています そのうちには その内にある
坂野有里 が追走して3 コーナーへ向かいます
集団に2番の電話が追走しております さらには
ユニバースは中段のやや後ろに走んさんいませさん
それには海があっている 後ろから3番目
ツルマル ボーイ サクラ プレジデント 兼ねています 西高校
これから 第3コーナーへ向かいます
セントはタップダンス C リードを広げたり 移動中
残り1000メートルは2か2番手は
10番のザットプリンティングつけて後は3番
アクティブ バイオ そして 700の糸を止まっている
4番手に擦る バレーがいて後をしているよ
フィールド また34 コーナー 中間です
お泊まりの日でアンジュ ガブリエル その外には死ぬ
まだ 中断に控えております さあ 第4コーナー
これから向かいます 入れているタップダンスシチー リードがなくなった
小柱600 M 中華 第4コーナー カーブ
いよいよ 直線コースに向かってまいります タップダンス
セントリードが4% loveマシーン ザッツザプレンティ
バイオ かかってくるやろうか 追い込んだと日本の
シンボリクリスエス が中断 グループ X とは
逃げろ逃げろ たっ + シータ +2 大桟橋
電話 追い込んでくれたんだね 良い子は育つ アプリ
となりの件名で扉 シンボリクリスエス ちょっと無理がないと ありがとう
ゴールイン タップダンス
広い宇宙を一人旅

3秒ずつ解析しているので言葉の途中で切れている部分も多いですが、分割したことで最後の「広い府中を一人旅」までしっかりと認識してくれています。 よく見ると宇宙旅行してますが。


まとめ

さて、ここまで何度も文字起こししたことで、「2400!逃げ切るとはこういうことだ!見せてくれた仮柵沿い!」[3]とも評された タップダンスシチー の強さはよく分かったところでしょう。

それと同じくらい、「馬名」という特殊な単語ばかりを何度も連呼する競馬中継の実況音声、それを音声認識することの難しさがよく分かるかと思います。ここの課題がうまくクリアできれば、おそらくリアルタイムの方の音声認識も飛躍的に精度が向上すると思われます。

とはいえ、馬名のアクセントは一定のパターンがあり[4]実況音声で使われる語彙のバリエーションもそこまで多くはない[5]ので、競馬中継の実況音声に特化したモデルなりプログラムなりを作ってしまう方がより早くゴールに辿り着けるかもしれません。

「で、本当は何をしたかったの?」

はい。出走馬の情報と実況音声のリアルタイム文字起こしを使って上位入着馬の判定ができれば、色々なコンテンツに応用できるなーとか考えていました。

さて、2023年現在、中央競馬だったら netkeiba.com で「ゴール後 30秒速報」というサービスがあり、文字どおりゴール直後から30秒くらいで1着馬と想定オッズを表示してくれるサービスがあります。[6] (有料会員の場合は2着馬と3着馬も表示してくれます、また際どい決着の場合はその旨も知ることができます)

なので、ちょっと触った限りだと、実況音声を使ってあれこれ取得を試すよりかは上記の「ゴール後 30秒速報」の情報をそのまま持ってくる方が色々とラクかなと思いました。

ちなみに、JRAが2023年の春から開始したレース動画のライブ配信の場合は、リアルタイムと比べて30秒くらい遅延があるようなので、ゴールした瞬間に上記の netkeiba.com を見ると既に1着馬と想定オッズが表示されていたりします。素晴らしい顧客体験だと思いませんか? そんなことないです?

動画から判定するパターンや、動画と音声を混ぜ合わせて解析する方法もあるかもしれませんが、結局このくらいの用途なら担当者1人を置いておいた方がコスト面でも正確さでも優秀なような気がしました。 netkeiba.com の「ゴール後 30秒速報」に中の人がいるのかどうかは知りませんが。(本当はもっとすごいアルゴリズムが動いているのかもしれない……)


脚注
  1. "!"は全角で6個と正式に決まっている ↩︎

  2. 当時の愛称は"ラジオたんぱ"(直後の2004年から"ラジオNIKKEI"に変更) ↩︎

  3. 地上波の実況(塩原恒夫アナ) ↩︎

  4. ウイニングポスト8以降のユーザーなら体感で納得しやすいかもしれない ↩︎

  5. こちらは流石にウイニングポストほど少なくはない ↩︎

  6. 地方競馬のダートグレード競走にも対応 ↩︎

Discussion