RailsアプリでVoiceVoxを使った音声読み上げ機能の実装

## 前提条件

- Ruby on Rails 6.x以上
- Dockerインストール済み
- VoiceVoxエンジンがDockerコンテナとして動作中

## VoiceVoxのセットアップ

DockerコマンドでVoiceVoxエンジンのセットアップを行います。

```bash
docker pull hiroshiba/voicevox_engine:latest
docker run -d -p 127.0.0.1:50021:50021 hiroshiba/voicevox_engine:latest
```

## Railsアプリケーションの設定

Railsプロジェクト側の設定は主に3つの部分に分かれます。サービスクラス設定、コントローラーからのサービスクラスの呼び出し設定、ビューからコントローラアクションの呼び出し設定です。

### クラスの設定

`app/services/voicevox_service.rb`に以下のようにサービスクラスを作成します。

```ruby
class VoicevoxService
  include HTTParty
  # base_uriは、HTTParty gemを使用するクラスメソッドで、このサービスクラスがリクエストを送信する際の基本となるURLを設定
  base_uri 'http://localhost:50021'

  def self.text_to_speech(text, speaker_id)
    # audio_queryのエンドポイントにPOSTリクエストを送信する
    # CGI.escapeメソッドは、URLに含めるテキスト文字列を安全にエスケープするために使用される。
    query_response = post("/audio_query?text=#{CGI.escape(text)}&speaker=#{speaker_id}")
    return nil unless query_response.code == 200

    synthesis_response = post("/synthesis?speaker=#{speaker_id}", body: query_response.body, headers: { 'Content-Type' => 'application/json' })
    return nil unless synthesis_response.code == 200

    synthesis_response.body
  end
end
```

このクラスは、指定されたテキストをVoiceVoxエンジンに送り、合成された音声データを取得します。

### コントローラーの設定

`app/controllers/blogs_controller.rb`に音声合成のためのアクションを追加します。

```ruby
class BlogsController < ApplicationController
  def read_blog
    blog = Blog.find(params[:id])
    voice_data = VoicevoxService.text_to_speech(blog.content, 3) # ずんだもんの声

    if voice_data
      send_data voice_data, type: 'audio/wav', disposition: 'inline'
    else
      render plain: "音声合成に失敗しました。", status: :internal_server_error
    end
  end
end
```

このアクションでは、特定のブログ記事をVoiceVoxで読み上げます。

### ビューの設定

ブログ記事を読み上げるためのリンクをビューに追加します。


```erb
<%= link_to 'この記事を読み上げる', read_blog_blog_path(@blog), remote: true %>
```

このリンクは、JavaScriptを介して`read_blog`アクションを非同期でトリガーします。

## まとめ
以上で完了です。備忘録として残しますが、他の方々に参考になると幸いです。

## 補足
### テストアプリのGitHub
https://github.com/KNGShiranui/VoiceVoxTest

### 遭遇したエラー

### その1: `detail":"Not Found`

ブラウザで`localhost:50021`にアクセスした際、`{"detail":"Not Found"}`というレスポンスが返されました。これは、APIのエンドポイントが正しく指定されていないことを意味しています。

#### トラブルシューティング1: VoiceVoxエンジンの動作確認
まずはcURLコマンドを使用して、VoiceVoxエンジンが正常に動作しているかを確認しました。
```bash
echo -n "こんにちは、音声合成の世界へようこそ" > text.txt

curl -s \
  -X POST \
  "localhost:50021/audio_query?speaker=1" \
  --get --data-urlencode text@text.txt \
  > query.json

curl -s \
  -H "Content-Type: application/json" \
  -X POST \
  -d @query.json \
  "localhost:50021/synthesis?speaker=1" \
  > audio.wav
```

上記のcURLコマンドで出力された`audio.wav`ファイルを再生すると、期待通りに音声が読み上げられました。これにより、VoiceVoxエンジン自体の動作は確認できました。

#### トラブルシューティング2: VoicevoxServiceクラスの作成

Railsアプリケーション内でサービスクラス`VoicevoxService`を`app/services/voicevox_service.rb`に作成しました。

```ruby
class VoicevoxService
  include HTTParty
  base_uri 'http://localhost:50021'

  def self.text_to_speech(text, speaker_id)
    response = post('/audio_query', body: { text: text, speaker: speaker_id }.to_json, headers: { 'Content-Type' => 'application/json' })
    return nil unless response.success?

    audio_response = post('/synthesis', body: { query: response.parsed_response }.to_json, headers: { 'Content-Type' => 'application/json' })
    return nil unless audio_response.success?

    audio_response.body
  end
end
```



### その2: audio_queryエラー
音声読み上げのリンクを踏むと、「音声合成に失敗しました」とブラウザに表示されました。

#### トラブルシューティング1: パラメータの確認・エラー箇所の特定
コンソールで確認したところ、`VoicevoxService.text_to_speech`メソッドから`nil`が返され、エラーメッセージには`"field required"`が含まれていました。原因は、テキストとスピーカーIDが正しくリクエストに含まれていなかったことです。
```
rb(main):004> test_text = "こんにちは、これはテストです。"
irb(main):005> voice_data = VoicevoxService.text_to_speech(test_text)
=> nil
```

#### トラブルシューティング2: エラーハンドリングによるエラー箇所の特定
VoicevoxService.rbに以下のようなエラーハンドリングを追加しました。
```VoicevoxService.rb
class VoicevoxService
  include HTTParty
  base_uri 'http://localhost:50021'

  def self.text_to_speech(text, speaker_id)
    query_response = post('/audio_query', body: { text: text, speaker: speaker_id }.to_json, headers: { 'Content-Type' => 'application/json' })
    # エラーハンドリングを追加
    if query_response.code != 200
      Rails.logger.error "VOICEVOX audio_query error: #{query_response}"
      return nil
    end

    synthesis_response = post('/synthesis', body: query_response.body, headers: { 'Content-Type' => 'application/json' })

    # エラーハンドリングを追加
    if synthesis_response.code != 200
      Rails.logger.error "VOICEVOX synthesis error: #{synthesis_response}"
      return nil
    end

    synthesis_response.body
  end
end
```

そうすると、以下のエラーログが得られました。エラーはaudio_queryエンドポイントへのリクエストが失敗していることが原因だと判明しました。
```
VOICEVOX audio_query error: {"detail":[{"loc":["query","text"],"msg":"field required","type":"value_error.missing"},{"loc":["query","speaker"],"msg":"field required","type":"value_error.missing"}]}
```
#### トラブルシューティング3: VoicevoxServiceの修正

```修正前.rb 
class VoicevoxService
  include HTTParty
  base_uri 'http://localhost:50021'

  def self.text_to_speech(text, speaker_id)
    query_response = post('/audio_query', body: { text: text, speaker: speaker_id }.to_json, headers: { 'Content-Type' => 'application/json' })
    return nil unless query_response.code == 200

    synthesis_response = post('/synthesis', body: query_response.body, headers: { 'Content-Type' => 'application/json' })
    return nil unless synthesis_response.code == 200

    synthesis_response.body
  end
end
```

テキストをURLエンコードし、クエリパラメータとしてリクエストに含めるよう修正しました。

```修正後.rb
class VoicevoxService
  include HTTParty
  base_uri 'http://localhost:50021'

  def self.text_to_speech(text, speaker_id)
    query_response = post("/audio_query?text=#{CGI.escape(text)}&speaker=#{speaker_id}")
    return nil unless query_response.code == 200

    synthesis_response = post('/synthesis', body: query_response.body, headers: { 'Content-Type' => 'application/json' })
    return nil unless synthesis_response.code == 200

    synthesis_response.body
  end
end
```

### その3: synthesisリクエストエラー

`/synthesis`エンドポイントへのリクエストで、必要な`speaker`フィールドが欠けているエラーが生じました。

#### トラブルシューティング: `speaker`情報をクエリパラメータに追加

```ruby
synthesis_response = post("/synthesis?speaker=#{speaker_id}", body: query_response.body, headers: { 'Content-Type' => 'application/json' })
```