Cloudflare Workers AI で Whisper Large V3 Turbo を使った日本語の文字起こしをする

# 目的
Cloudflare Workers AI で Whisper Large V3 Turbo モデルが `Beta` になっています。

https://developers.cloudflare.com/workers-ai/models/whisper-large-v3-turbo/

`whisper-large-v3-turbo` は `whisper-large-v3` の `finetuned` モデルで、多少の品質を犠牲にしながらも高速に動くモデルです。

https://huggingface.co/openai/whisper-large-v3-turbo

>Whisper large-v3-turbo is a finetuned version of a pruned [Whisper large-v3](https://huggingface.co/openai/whisper-large-v3). In other words, it's the exact same model, except that the number of decoding layers have reduced from 32 to 4. As a result, the model is way faster, at the expense of a minor quality degradation. 

例えば、以下のような電話やミーティングの `audio` ファイルから `WebVTT` 形式の字幕ファイルを出力できます。

その後、場合によっては文字起こしデータから、要約や次のアクションの提示を事後処理として、さらに他の AI モデルを使って実行させるようなフローを実現できます。

```mermaid:
%%{init: {'themeCSS': 'img {width: 48px; height:48px;}'}}%%
graph LR

Call["<img src='https://cf-icons.pages.dev/calls.svg' width='48' height='48'/><br>Call Logs"]:::transparent
Showroom["<img src='https://cf-icons.pages.dev/user-multi.svg' width='48' height='48'/><br>Meeting Logs"]:::transparent

subgraph Cloudflare [<div align='center'><img src='https://cf-icons.pages.dev/logo-cloud.svg' width='48' height='48'/><br>Cloudflare]
    WorkersAI[<img src='https://cf-icons.pages.dev/workers-ai.svg' width='48' height='48'/><br>Workers AI<br>whisper-large-v3-turbo]:::transparent
end

Call --> |"<div align='center'><img src='https://cf-icons.pages.dev/file.svg' width='48' height='48'/><br>Audio file<br>(10-20 min)"|WorkersAI
Showroom --> |"<div align='center'><img src='https://cf-icons.pages.dev/file.svg' width='48' height='48'/><br>Audio file<br>(1-2 hour)"|WorkersAI

vtt[<img src='https://cf-icons.pages.dev/file.svg' width='48' height='48'/><br>WebVTT]:::transparent

WorkersAI --> |Transcribe|vtt

Future["<img src='https://cf-icons.pages.dev/ai.svg' width='48' height='48'/><br>Context Summarization<br>or<br>Propose Next Action"]

vtt -..-> |"Post-processing"|Future

classDef hidden display: none;
classDef transparent fill-opacity:0,stroke-opacity:0;
%%style HTTPApp fill-opacity:0,stroke-opacity:0;
%%style TCPApp fill-opacity:0,stroke-opacity:0;
style Future stroke:#ccb, stroke-dasharray: 5 5
```

# 参考： `audio` ファイルの抽出

ミーティング録画から `audio` ファイルを抽出するには以下の `ffmpeg` コマンドを使います。

https://jp.videoproc.com/edit-convert/extract-audio-with-ffmpeg.htm#3.%E9%9F%B3%E5%A3%B0%E6%8A%BD%E5%87%BA%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6%E3%82%82%E3%81%A3%E3%81%A8%E3%82%82%E5%9F%BA%E6%9C%AC%E7%9A%84%E3%81%AA%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89

```shell:
ffmpeg -i ./cf-video.mp4 -vn ./cf-video.mp3
```

# Workers AI デプロイ
以下の Workers コードを使用します。

https://github.com/kyouheicf/hello-whisper

以下のコマンドでデプロイできます。

```shell:
export URL='https://github.com/kyouheicf/hello-whisper.git'
git clone $URL && cd $(basename $_ .git)
npx wrangler deploy 
```

# 使い方
以下のように URL の `path` パラメータとして `audio` ファイルの URL を記述すると、字幕データが返ってきます。

```shell:
curl https://hello-whisper.xxx.workers.dev/https://example.com/cf-video.mp3 | jq
```

```json:result.json
{
  "input": {
    "audio": []
  },
  "response": {
    "transcription_info": {
      "language": "ja",
      "language_probability": 1,
      "duration": 30.046625,
      "duration_after_vad": 30.046625
    },
    "segments": [
      {
        "start": 2.539999999999999,
        "end": 5.66,
        "text": "ミノシロ金3億円?そんな大金あるわけないだろ!",
        "temperature": 0,
        "avg_logprob": -0.2854399464649861,
        "compression_ratio": 1.62,
        "no_speech_prob": 0,
        "words": [
          {
            "start": 2.539999999999999,
            "end": 2.82,
            "word": "ミ"
          },
          {
            "start": 2.82,
            "end": 2.96,
            "word": "ノ"
          },
          ...
          {
            "start": 5.54,
            "end": 5.66,
            "word": "ろ!"
          }
        ],
        "word_count": 1
      },
      ...
      {
        "start": 25.62,
        "end": 29.46,
        "text": "サイバーセキュリティが、あなたの安心を守る。クラウドフレア。",
        "temperature": 0,
        "avg_logprob": -0.2854399464649861,
        "compression_ratio": 1.62,
        "no_speech_prob": 0,
        "words": [
          {
            "start": 25.62,
            "end": 25.9,
            "word": "サ"
          },
          ...
          {
            "start": 29.34,
            "end": 29.46,
            "word": "ア。"
          }
        ],
        "word_count": 1
      }
    ],
    "vtt": "WEBVTT\n\n00:02.540 --> 00:02.820\nミ\n\n00:02.820 --> 00:02.960\nノ\n\n00:02.960 --> 00:03.080\nシ\n\n00:03.080 --> 00:03.160\nロ\n\n00:03.160 --> 00:03.300\n金\n\n00:03.300 --> 00:03.520\n3\n\n00:03.520 --> 00:03.740\n億\n\n00:03.740 --> 00:03.880\n円?\n\n00:04.400 --> 00:04.680\nそんな\n\n00:04.680 --> 00:04.820\n大\n\n00:04.820 --> 00:04.960\n金\n\n00:04.960 --> 00:05.140\nある\n\n00:05.140 --> 00:05.220\nわ\n\n00:05.220 --> 00:05.320\nけ\n\n00:05.320 --> 00:05.400\nない\n\n00:05.400 --> 00:05.540\nだ\n\n00:05.540 --> 00:05.660\nろ!\n\n00:05.840 --> 00:06.000\nえっ?\n\n00:06.640 --> 00:06.880\nも\n\n00:06.880 --> 00:07.060\nしか\n\n00:07.060 --> 00:07.300\nして。\n\n00:07.580 --> 00:07.860\nサ\n\n00:07.860 --> 00:07.940\nイ\n\n00:07.940 --> 00:08.040\nバ\n\n00:08.040 --> 00:08.120\nー\n\n00:08.120 --> 00:08.280\n攻\n\n00:08.280 --> 00:08.480\n撃?\n\n00:08.880 --> 00:09.160\nまず\n\n00:09.160 --> 00:09.300\nい\n\n00:09.300 --> 00:09.420\nな、\n\n00:09.500 --> 00:09.720\nそれ。\n\n00:10.140 --> 00:10.300\nガ\n\n00:10.300 --> 00:10.380\nン\n\n00:10.380 --> 00:10.460\nサ\n\n00:10.460 --> 00:10.560\nム\n\n00:10.560 --> 00:10.680\nウ\n\n00:10.680 --> 00:10.700\nェ\n\n00:10.700 --> 00:10.700\nア\n\n00:10.700 --> 00:10.880\n攻\n\n00:10.880 --> 00:11.020\n撃\n\n00:11.020 --> 00:11.160\nさ\n\n00:11.160 --> 00:11.300\nれた。\n\n00:11.620 --> 00:11.900\n本当\n\n00:11.900 --> 00:12.080\nに?\n\n00:12.180 --> 00:12.340\nや\n\n00:12.340 --> 00:12.420\nば\n\n00:12.420 --> 00:12.520\nく\n\n00:12.520 --> 00:12.640\nない?\n\n00:12.780 --> 00:12.940\nデ\n\n00:12.940 --> 00:13.040\nー\n\n00:13.040 --> 00:13.120\nタ、\n\n00:13.360 --> 00:13.440\nロ\n\n00:13.440 --> 00:13.540\nック\n\n00:13.540 --> 00:13.680\nさ\n\n00:13.680 --> 00:13.740\nれ\n\n00:13.740 --> 00:13.860\nちゃ\n\n00:13.860 --> 00:13.960\nう。\n\n00:14.000 --> 00:14.140\n今、\n\n00:14.280 --> 00:14.300\n来\n\n00:14.300 --> 00:14.420\nて\n\n00:14.420 --> 00:14.480\nる\n\n00:14.480 --> 00:14.580\nんだ\n\n00:14.580 --> 00:14.640\nよ、\n\n00:14.720 --> 00:14.780\nもう\n\n00:14.780 --> 00:14.960\n俺。\n\n00:16.740 --> 00:17.020\n大丈夫?\n\n00:17.680 --> 00:17.880\nん?\n\n00:19.400 --> 00:19.540\nん?\n\n00:20.700 --> 00:20.980\nああ、\n\n00:20.980 --> 00:21.260\n大丈夫。\n\n00:21.640 --> 00:21.840\nク\n\n00:21.840 --> 00:21.900\nラ\n\n00:21.900 --> 00:22.000\nウ\n\n00:22.000 --> 00:22.060\nド\n\n00:22.060 --> 00:22.200\nフ\n\n00:22.200 --> 00:22.240\nレ\n\n00:22.240 --> 00:22.280\nア\n\n00:22.280 --> 00:22.420\nが\n\n00:22.420 --> 00:22.660\n守\n\n00:22.660 --> 00:22.800\nって\n\n00:22.800 --> 00:22.900\nく\n\n00:22.900 --> 00:23.000\nれて\n\n00:23.000 --> 00:23.140\nる\n\n00:23.140 --> 00:23.320\nから。\n\n00:23.500 --> 00:23.700\n正\n\n00:23.700 --> 00:24.000\n解\n\n00:24.000 --> 00:24.140\nだ\n\n00:24.140 --> 00:24.360\nよ!\n\n00:24.680 --> 00:24.880\n一\n\n00:24.880 --> 00:24.980\n緒\n\n00:24.980 --> 00:25.080\nに\n\n00:25.080 --> 00:25.180\n見\n\n00:25.180 --> 00:25.260\nる?\n\n00:25.620 --> 00:25.900\nサ\n\n00:25.900 --> 00:25.960\nイ\n\n00:25.960 --> 00:26.040\nバ\n\n00:26.040 --> 00:26.120\nー\n\n00:26.120 --> 00:26.220\nセ\n\n00:26.220 --> 00:26.280\nキ\n\n00:26.280 --> 00:26.360\nュ\n\n00:26.360 --> 00:26.380\nリ\n\n00:26.380 --> 00:26.480\nテ\n\n00:26.480 --> 00:26.500\nィ\n\n00:26.500 --> 00:26.680\nが、\n\n00:26.800 --> 00:27.180\nあ\n\n00:27.180 --> 00:27.300\nなた\n\n00:27.300 --> 00:27.440\nの\n\n00:27.440 --> 00:27.580\n安\n\n00:27.580 --> 00:27.800\n心\n\n00:27.800 --> 00:27.920\nを\n\n00:27.920 --> 00:28.120\n守\n\n00:28.120 --> 00:28.300\nる。\n\n00:28.660 --> 00:28.940\nク\n\n00:28.940 --> 00:28.980\nラ\n\n00:28.980 --> 00:29.060\nウ\n\n00:29.060 --> 00:29.120\nド\n\n00:29.120 --> 00:29.260\nフ\n\n00:29.260 --> 00:29.340\nレ\n\n00:29.340 --> 00:29.460\nア。\n\n",
    "text": "ミノシロ金3億円?そんな大金あるわけないだろ! えっ?もしかして。 サイバー攻撃? まずいな、それ。 ガンサムウェア攻撃された。 本当に?やばくない? データ、ロックされちゃう。 今、来てるんだよ、もう俺。 大丈夫? ん? ん? ああ、大丈夫。クラウドフレアが守ってくれてるから。 正解だよ! 一緒に見る? サイバーセキュリティが、あなたの安心を守る。クラウドフレア。",
    "word_count": 15,
    "segment_vtt": "WEBVTT\n\n00:00:02.540 --> 00:00:05.660\nミノシロ金3億円?そんな大金あるわけないだろ!\n\n00:00:05.840 --> 00:00:07.300\nえっ?もしかして。\n\n00:00:07.580 --> 00:00:08.480\nサイバー攻撃?\n\n00:00:08.880 --> 00:00:09.720\nまずいな、それ。\n\n00:00:10.140 --> 00:00:11.300\nガンサムウェア攻撃された。\n\n00:00:11.620 --> 00:00:12.640\n本当に?やばくない?\n\n00:00:12.780 --> 00:00:13.960\nデータ、ロックされちゃう。\n\n00:00:14.000 --> 00:00:14.960\n今、来てるんだよ、もう俺。\n\n00:00:16.740 --> 00:00:17.020\n大丈夫?\n\n00:00:17.680 --> 00:00:17.880\nん?\n\n00:00:19.400 --> 00:00:19.540\nん?\n\n00:00:20.700 --> 00:00:23.320\nああ、大丈夫。クラウドフレアが守ってくれてるから。\n\n00:00:23.500 --> 00:00:24.360\n正解だよ!\n\n00:00:24.680 --> 00:00:25.260\n一緒に見る?\n\n00:00:25.620 --> 00:00:29.460\nサイバーセキュリティが、あなたの安心を守る。クラウドフレア。\n\n"
  }
}
```

# `WebVTT` 形式の字幕ファイル保存

上記の `vtt` フィールドを使うと、日本語の場合、1文字ずつ分かれた出力となってしまい、字幕ファイルとしての使い勝手が悪いです。

そこで `segments.text` を変換した `segment_vtt` フィールドとして `WebVTT` 形式の字幕ファイルを出力するようにアレンジしています。

字幕ファイルとして保存する場合は、以下のコマンドを実行します。

```shell:
curl https://hello-whisper.xxx.workers.dev/https://example.com/cf-video.mp3 \
| jq -r .response.segment_vtt > cf-video.vtt
```

```cf-video.vtt
WEBVTT

00:00:02.540 --> 00:00:05.660
ミノシロ金3億円?そんな大金あるわけないだろ!

00:00:05.840 --> 00:00:07.300
えっ?もしかして。

00:00:07.580 --> 00:00:08.480
サイバー攻撃?

00:00:08.880 --> 00:00:09.720
まずいな、それ。

00:00:10.140 --> 00:00:11.300
ガンサムウェア攻撃された。

00:00:11.620 --> 00:00:12.640
本当に?やばくない?

00:00:12.780 --> 00:00:13.960
データ、ロックされちゃう。

00:00:14.000 --> 00:00:14.960
今、来てるんだよ、もう俺。

00:00:16.740 --> 00:00:17.020
大丈夫?

00:00:17.680 --> 00:00:17.880
ん?

00:00:19.400 --> 00:00:19.540
ん?

00:00:20.700 --> 00:00:23.320
ああ、大丈夫。クラウドフレアが守ってくれてるから。

00:00:23.500 --> 00:00:24.360
正解だよ!

00:00:24.680 --> 00:00:25.260
一緒に見る?

00:00:25.620 --> 00:00:29.460
サイバーセキュリティが、あなたの安心を守る。クラウドフレア。
```

字幕付きの動画や音声として再生するには、以下の記事にあるように `VLC media player` を使った方法があります。

https://note.com/t9t/n/n96b7e637aabf

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101361/03b01184-5c12-4afd-833c-c50ada37852d.png)

# Content-Length 7.5 MB 制限を回避する
いろいろな `audio` ファイルを調査する中で、以下のエラーが出ることがありました。

- `Error: 3006: Request is too large`

`Content-Length` レスポンスヘッダが `75000 bytes` を超えると、エラーが出るような挙動を確認しました。

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length

`audio` ファイルがサイズ超過している場合、以下のようなコマンドでビットレートを調整することでサイズを縮小する、もしくはファイルを分割することを検討してください。

https://community.openai.com/t/send-an-hours-worth-of-audio-through-whisper-using-node-js/957808/5

```shell:
# ビットレートを調整
ffmpeg -i cf-pitch.mp3 -b:a 32k cf-pitch-32k.mp3
# ffmpeg -i cf-pitch.mp3 -b:a 48k cf-pitch-48k.mp3
# ffmpeg -i cf-pitch.mp3 -b:a 64k cf-pitch-64k.mp3
# ffmpeg -i cf-pitch.mp3 -b:a 72k cf-pitch-72k.mp3
# ffmpeg -i cf-pitch.mp3 -b:a 96k cf-pitch-96k.mp3
# ffmpeg -i cf-pitch.mp3 -b:a 112k cf-pitch-112k.mp3
# ffmpeg -i cf-pitch.mp3 -b:a 128k cf-pitch-128k.mp3
# ffmpeg -i cf-pitch.mp3 -b:a 160k cf-pitch-160k.mp3
# ffmpeg -i cf-pitch.mp3 -b:a 192k cf-pitch-192k.mp3
# ffmpeg -i cf-pitch.mp3 -b:a 256k cf-pitch-256k.mp3
# ffmpeg -i cf-pitch.mp3 -b:a 320k cf-pitch-320k.mp3

# 圧縮率の高いファイル形式に変更
ffmpeg -i audio.mp3 -vn -map_metadata -1 -ac 1 -c:a libopus -b:a 12k -application voip audio.ogg

# 時間を 50 分で切る
ffmpeg -i audio.mp3 -b:a 16k -t 00:50:00 audio-16k-50m.mp3
```

# タイムアウトによる推論時間制限を回避する

Whisper による推論時間が約 2 分を超えると、以下のエラーが出ることがありました。

- `Error: 3007: Request timeout`

`audio` ファイルの長さで言うと、約 50 分になります。

この場合、Cloudflare Workers AI による製品改善を期待する、もしくはファイルを分割することを検討してください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101361/4094cb23-e6b1-4f9b-9d2d-afe3fbdf5565.png)

ファイル分割に関して、`segment` の途中にならないような文脈をとらえた `chunking` についてはいくつか事前処理に使えるツールがあるようです。

https://github.com/openai/whisper/discussions/1977

# `initial_prompt` による日本語認識改善
プロンプト（`initial_prompt` パラメータ）を使用すると、Whisper が生成するトランスクリプトの品質を向上させられる可能性があります。

ただし、改善できる度合いはある程度限られていると考えたほうが良さそうです。

プロンプト内では、専門用語を羅列する単語帳のような使い方が紹介されています。

https://platform.openai.com/docs/guides/speech-to-text/longer-inputs?lang=javascript#prompting

>You can use a prompt to improve the quality of the transcripts generated by the Whisper API. The model tries to match the style of the prompt, so it's more likely to use capitalization and punctuation if the prompt does too. However, the current prompting system is more limited than our other language models and provides limited control over the generated audio.
>
> ...
>
> Because it wasn't trained with instruction-following techniques, Whisper operates more like a base GPT model. Keep in mind that Whisper only considers the first 224 tokens of the prompt.

https://medium.com/axinc/whisper%E3%81%AB%E3%81%8A%E3%81%91%E3%82%8Bprompt-engineering-7d3bbdd22a46

>Whisperのコンテキストのサイズは448です。これは、入力用と出力用のトークンの合計数であり、Whisperの実装では入力用には半分の224トークンまでに制約されています。そのため、未知語の数が多くなってきた場合は、prompt以外の方法を検討する必要があります。

例えば、日本語の文章をうまく理解させるために、「やさしい日本語」にあるような文章を `initial_prompt` パラメータに入れることで多少の日本語認識改善が見られました。

日本語の文字起こしの場合、`initial_prompt` パラメータには**英単語等が含まれていない**方が認識率が高かったです。

https://tabunka.tokyo-tsunagari.or.jp/yasanichi/about.html

```diff_javascript
+ // やさしい日本語のサンプル文章
+ initial_prompt: '1 日の PM 2.5 の量が、 70 μg / m^2 を超えたときは、外に出ることをできるだけ少なくしましょう。そして、外での長い時間の激しい運動はできるだけ少なくしましょう。病気（呼吸器や循環器）の人、小さな子ども、お年寄りの方は、体調に気をつけましょう。',
```

# まとめ

Cloudflare Workers AI では、すぐに AI を使い始めることができます。

これも Cloudflare の拠点に GPU が配備され、最寄りの拠点で推論をおこなえるインフラが整っているからです。

Workers の使いやすさに加えて、さまざまなコンポーネントを `Bindings` で追加するだけで連携できる点も魅力です。

`@cf/openai/whisper-large-v3-turbo` で日本語の文字起こしもそれなりに使えることがわかったので、事後処理に他の AI モデルを組み合わせる等、アイデアが膨らむところです。

これからも Cloudflare Workers AI の成長を見守っていきましょう。