Intelligent Technology's Technical Blog

株式会社インテリジェントテクノロジーの技術情報ブログです。

ChatGPT + Amazon Polly + Android で AI 音声アシスタントを作り、一番おすすめのうどん屋を聞く

こんにちは、弊社 Intellijent Technorogy の、香川県のオフィスで活動している中山です。
香川県といえばうどん(さぬきうどん)くらいしか見当たりませんが、この狭い香川県に星の数ほど存在するといううどん店の中で、いったいどのお店を選んだらよいのか、県民は日々悩まされております。

そこでふと思いつきましたのが、昨今話題の ChatGPT
この素晴らしい AI と、それから、Amazon の Alexa や、Apple の Siri のような、音声アシスタント機能を組み合わせれば、うどん屋さんでも何でも、音声で、的確にお薦めしてくれる「AI 音声アシスタント」が作れるのではないか!?
そのように考え、さっそく開発に取りかかってみました。

AI 音声アシスタントに必要な機能

今回開発する「AI 音声アシスタント」としては、最低限、次のような機能があればよいと考えました。

  1. 利用者が発話した内容をテキストに変換する機能
  2. その発話内容のテキストをインプットとして ChatGPT に問い合わせて、回答を得る機能
  3. ChatGPT からの回答のテキストを音声に変換する機能
  4. 変換した音声を再生する機能

実際に利用者が操作する部分は、Android アプリとして作ることにしました。今回、音声を扱う部分がいろいろありますので、Andorid であればそのあたりのライブラリが充実していると考えたためです。

1つめの「利用者が発話した内容をテキストに変換する機能」については、Android 標準の SpeechRecognizer を使うことにしました。入力した音声をテキストに変換してくれる機能なのですが、標準の機能のくせに(?)かなり精度が高い!これは使わない理由が見つかりません。

2つめの「その発話内容のテキストをインプットとして ChatGPT に問い合わせて、回答を得る機能」は、OpenAI が提供する、 Chat API を利用します。なお今回は最新の GPT 4 ではなく GPT 3.5 を利用します。理由は、(今のところ)無料で使えるから、です。

3つめの「ChatGPT からの回答のテキストを音声に変換する機能」は、AWS の Amazon Polly を使って実現します。Amazon Polly は、とても自然な発話の音声を提供してくれるサービスです(Amazon の Alexa と同程度の発話のレベルだと思います)。

4つめの「変換した音声を再生する機能」は、これも Android 標準の MediaPlayer を使います。

モジュール構成

以上を踏まえて、次のようなモジュール構成で進めることにしました。

モジュール構成

またシーケンス図として表すと、以下のようになります。

シーケンス図

ただし、この構成はいくつか課題もあります(詳しくは後述します)。

Amazon Polly のお試し

Amazon Polly は、AWS コンソールからも簡単に試すことができます。コンソール画面に以下のような入力欄が表示されますので、「入力テキスト」欄に、発話させたいテキストを入力すると、それを音声変換して再生してくれます( mp3 ファイルとしてダウンロードも可能です)

Amazon Polly お試し

1点、ポイントとなるのは、「エンジン」として「ニューラル」「スタンダード」が選択できるところです。
スタンダード」を選んだ場合は、こんな感じで(※音が出ます)、

ニューラル」の場合はこんな感じです(※音が出ます)。

スタンダードのほうもだいたい OK なのですが、ニューラルの方の精度を知ってしまうと、これはもうニューラル一択です。
ただし、ニューラルのほうが、料金が高いです。
aws.amazon.com

今回は贅沢に「ニューラル」エンジンのほうを利用することにします。

Amazon Polly のサンプル Android アプリ

実は、 Amazon Polly を Android から利用するための公式のサンプルアプリ(PollyDemo)が、 AWS から提供されていました。
github.com

このサンプルアプリを、今回作成する「AI 音声アシスタント」アプリのベースとして、利用させてもらうことにしました。
これにより、前述の

  • 3. ChatGPT からの回答のテキストを音声に変換する機能
  • 4. 変換した音声を再生する機能

については、ほぼ実装できた状態からスタートすることができます(具体的な実装方法については、PollyDemo のコードをご参照ください)。

なお、 Amazon Polly を Android アプリから利用できるようにするためには、以下の準備が必要となります(詳細は PollyDemo の README.md を参照してください)。

1)AWS Cognito のコンソール画面から「ID」プールを作成

ID プールの作成

2)ID プール作成時に自動的に作成される Role 名を確認

Role 名の確認

3)生成された ID プールの ID と Region を確認

ID プールの ID を確認

4)ID プールにひもつけられている「Unauthenticated」Role のほうに Amazon Polly への Full Access 権限を付与

AmazonPollyFullAccess を付与

5)ID プールの ID と Region を、Android アプリ側に設定

Amazon Polly サンプルアプリ(PollyDemo)のプロジェクトを Android Studio で開いて、app/res/raw/awsconfiguration.json に、ID プールの ID と Region の値を設定します(本来、こういった情報はできるだけアプリ側には持たせないようにするべきだと思いますが)。

ID プールの ID を設定

SpeechRecognizer で音声をテキストに変換

アプリに向かって発話した音声をテキストに変換する、という処理は、Android が標準で提供している SpeechRecognizer を用いて実装しました。
この部分は特に変わった実装は行っておらず、

  1. アプリの SPEAK ボタンをクリックして音声録音を開始(発話を開始)
  2. 発話が途切れたタイミングで、テキストへの変換を実行
  3. 変換したテキストを返す(これを、Chat API のインプットにする)

という操作を、標準的な実装で実現しています。

SPEAK ボタン

SpeechRecognizer については、以下のようなページが参考になるでしょう。
【2022年最新版】【Android】マイク使用許可を得て音声をテキストに変換する(音声認識)
[Android]音声入力はわりと簡単に実装できる( Speech Recognizer ) - Qiita
SpeechRecognizer  |  Android Developers

Chat API を呼び出す

OpenAI の Chat API は、公式のドキュメントにもありますとおり、こういうリクエストを送ると、

curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-3.5-turbo",
    "messages": [{"role": "user", "content": "Hello!"}]
  }'

こういうレスポンスを返してくれます。

{
  "id": "chatcmpl-123",
  "object": "chat.completion",
  "created": 1677652288,
  "choices": [{
    "index": 0,
    "message": {
      "role": "assistant",
      "content": "\n\nHello there, how may I assist you today?",
    },
    "finish_reason": "stop"
  }],
  "usage": {
    "prompt_tokens": 9,
    "completion_tokens": 12,
    "total_tokens": 21
  }
}

リクエストボディに指定する messages には以下のように、

{
  "model": "gpt-3.5-turbo",
  "messages": [
    {"role": "user", "content": "いちばん好きな果物はなんですか?"},
    {"role": "assistant", "content": "はい、イチゴです。"},
    {"role": "user", "content": "じゃあ、2番目は?"},
  ]
}

Chat API から受け取った回答のテキストもどんどん加えていくことで、「会話」を続けていくことが可能になっているようです。
( role が assistant となっているのが、Chat API からの回答です。詳細はこちら

今回作成する Android アプリから、この Chat API へのリクエストを行うために、Retrofit ライブラリを使いました。

まず、Chat API を表す interface を作成して、

/**
 * OpenAI の Chat API を表すインタフェース(Retrofit)
 * ChatGptRequest は、Chat API のリクエストボディのデータを表すクラス
 * ChatGptResponse は、Chat API からのレスポンスボディのデータを表すクラス
 */
public interface ChatGptApi {
    @POST("/v1/chat/completions")
    Call<ChatGptResponse> chat(
            @Header("Authorization") String authorization, 
            @Body ChatGptRequest request);
}

この interface を利用して、実際に Chat API にリクエストする機能を、以下のように ChatGptService クラスとしてまとめました。

/**
 * OpenAI の Chat API へのリクエストを担当するクラス
 */
public class ChatGptService {
    // OpenAI API のベースの URL
    private final static String BASE_URL = "https://api.openai.com/";
    // OpenAI API の認証キー(本来はこのような場所で定義すべきではない)
    private final static String AUTHORIZATION = "Bearer sk-xxxxxxxx";
    // 利用する GPT モデル(3.5 を利用)
    private final static String MODEL = "gpt-3.5-turbo";

    private final static String ROLE_USER = "user";
    private final static String ROLE_ASSISTANT = "assistant";

    private final ChatGptApi chatGptApi;
    // Chat API へのインプット(=利用者からの質問と ChatGPT からの回答の履歴を保持するリスト)
    // (このクラスに会話の履歴を持たせるのが妥当かどうかは議論の余地があるかも)
    private final List<ChatGptRequestMessage> requestMessages;

    /**
     * コンストラクタ
     */
    public ChatGptService() {
        // Chat API のターンアラウンドタイムが比較的長いため、タイムアウトを長めに設定
        OkHttpClient okHttpClient = new OkHttpClient.Builder()
                .connectTimeout(60, TimeUnit.SECONDS)
                .readTimeout(60, TimeUnit.SECONDS)
                .writeTimeout(60, TimeUnit.SECONDS)
                .build();

        // Retrofit オブジェクトのインスタンスを生成
        Retrofit retrofit = new Retrofit.Builder()
                .baseUrl(BASE_URL)
                .addConverterFactory(GsonConverterFactory.create())
                .client(okHttpClient)
                .build();

        // Retrofit で Chat API リクエスト用のオブジェクトのインスタンスを生成
        chatGptApi = retrofit.create(ChatGptApi.class);
        // Chat API へのインプットを初期化
        requestMessages = new ArrayList<>();
    }

    /**
     * OpenAI の Chat API を呼び出して、ChatGPT からの回答を返す
     * @param content アプリ利用者が音声入力したテキスト
     * @return インプットに対する、ChatGPT からの回答テキスト
     * @throws IOException
     */
    public String chat(String content) throws IOException {
        // アプリ利用者の音声入力のテキストをリストに追加( role は user とする)
        addRequestMessages(ROLE_USER, content);
        // Chat API のインプットのフォーマットに合わせたオブジェクトを生成
        ChatGptRequest chatGptRequest = new ChatGptRequest(MODEL, requestMessages);
        // Chat API を呼び出す( ChatGptResponse は、Chat API のレスポンスボディのデータを表すクラス)
        Call<ChatGptResponse> chatGptResponseCall = chatGptApi.chat(AUTHORIZATION, chatGptRequest);
        Response<ChatGptResponse> chatGptResponseResponse = chatGptResponseCall.execute();

        if (chatGptResponseResponse.isSuccessful()) {
            ChatGptResponse chatGptResponse = chatGptResponseResponse.body();
            // Chat API から正常にレスポンスを取得できた場合
            if (chatGptResponse.choices.size() > 0) {
                // レスポンスから role と content を取得(role は通常、assistant となる)
                // content には回答のテキストが格納されている
                String responseRole = chatGptResponse.choices.get(0).message.role;
                String responseContent = chatGptResponse.choices.get(0).message.content;
                // Chat API からの回答も requestMessages リストに追加する
                // これにより、次回の Chat API へのリクエスト時に、「さっきの続きから」という形で
                // 対話を行うことが可能になる
                addRequestMessages(responseRole, responseContent);
                // Chat API からの回答テキストを返す(Amazon Polly に渡され、音声変換される)
                return responseContent;
            }
        }

        // Chat API へのリクエストが失敗した場合、requestMessages リストに追加した、
        // 利用者の音声入力テキストはいったん削除した上で、null を返す
        requestMessages.remove(requestMessages.size() - 1);
        return null;
    }

    // requestMessages リストに要素を追加する
    private void addRequestMessages(String role, String content) {
        requestMessages.add(new ChatGptRequestMessage(role, content));
    }
}

OpenAI の認証キーを直接コードの中で指定していたり、と、本来は推奨されないであろう書き方をしていたりもしますが、とりあえず、動かせる状態にはなりました。
アプリ利用者が音声入力したテキストをパラメータにして、この chat メソッドを呼び出せば、ChatGPT からの回答が得られる、という想定です。
なお今回は、ベースにした PollyDemo サンプルアプリが Java で書かれていましたので、同じように Java でコードを書いています。

アプリを仕上げる

SpeeechRecognizer で音声入力のテキスト変換が成功した時に呼ばれる、onResults イベントハンドラの中で、先ほどの chat メソッドを呼び出します。

@Override
public void onResults(Bundle bundle) {
    // 音声認識結果をリストで受け取る
    List<String> results = bundle.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION);

    if (results.isEmpty()) {
        // 音声認識結果が空の場合、エラーメッセージを Toast で表示
        // ( showErrorMessage は、指定されたメッセージを Toast で表示する private メソッド)
        showErrorMessage("Speech to text is failed. response is empty");
        return;
    }
    String result = results.get(0);
    if (result == null) {
        // 音声認識結果のリストの先頭の要素が取得できなかった場合、エラーメッセージを Toast で表示
        showErrorMessage("Speech to text is failed. response is null");
        return;
    }

    runOnUiThread(new Runnable() {
        @Override
        public void run() {
            // アプリ利用者の音声入力を変換したテキストを画面に表示
            textViewMeContent.setText(result);
        }
    });

    // 選択した Amazon Polly の話者
    Voice selectedVoice = (Voice) voicesSpinner.getSelectedItem();

    ExecutorService executor = Executors.newSingleThreadExecutor();
    executor.execute(new Runnable() {
        @Override
        public void run() {
            try {
                // 別スレッドで Chat API へのリクエストを実行
                String chatGptResponseContent = chatGptService.chat(result);
                // Chat API からの回答テキストを Amazon Polly に渡して音声に変換し、再生する
                playVoice(chatGptResponseContent, selectedVoice);

            } catch (IOException e) {
                showErrorMessage("ChatGPT request is failed. message: " + e.getMessage());
            }
        }
    });
}

最後に呼び出している playVoice メソッドでは、Amazon Polly で、テキスト → 音声変換を行ったのち、その音声を再生します。
これは、もとの PollyDemo のコードをほぼそのまま流用していますが、1点だけ、SynthesizeSpeechPresignRequest のインスタンスを生成する際に、明示的に「ニューラル」エンジンを指定するようにしています。

    private void playVoice(String textToRead, Voice selectedVoice) throws IOException {
        // Create speech synthesis request.
        SynthesizeSpeechPresignRequest synthesizeSpeechPresignRequest =
                new SynthesizeSpeechPresignRequest()
                        // Set text to synthesize.
                        .withText(textToRead)
                        // Set voice selected by the user.
                        .withVoiceId(selectedVoice.getId())
                        .withEngine(Engine.Neural) // ニューラルエンジンを指定
                        // Set format to MP3.
                        .withOutputFormat(OutputFormat.Mp3);

        // Get the presigned URL for synthesized speech audio stream.
        URL presignedSynthesizeSpeechUrl =
                client.getPresignedSynthesizeSpeechUrl(synthesizeSpeechPresignRequest);

        Log.i(TAG, "Playing speech from presigned URL: " + presignedSynthesizeSpeechUrl);

        // Create a media player to play the synthesized audio stream.
        if (mediaPlayer.isPlaying()) {
            setupNewMediaPlayer();
        }
        mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);

        try {
            // Set media player's data source to previously obtained URL.
            mediaPlayer.setDataSource(presignedSynthesizeSpeechUrl.toString());
        } catch (IOException e) {
            Log.e(TAG, "Unable to set data source for the media player! " + e.getMessage());
        }

        // Start the playback asynchronously (since the data source is a network stream).
        mediaPlayer.prepareAsync();

        runOnUiThread(new Runnable() {
            @Override
            public void run() {
                // Chat API からのレスポンス(回答のテキスト)を画面に表示
                textViewAssistantContent.setText(textToRead);
            }
        });
    }

アプリの実行

ひととおり機能を実装できましたので、このアプリを動かしてみます。
さっそくではありますが、「香川県でいちばんのうどん屋はどこか?」という禁断の質問を ChatGPT 先生に問い合わせてみました。
その衝撃の結果を以下の動画でご覧ください(※音が出ます)。

なんと、ChatGPT 先生が回答してくれたいくつかのうどん屋さんのうち、香川県内に実在するのは「丸亀製麺」1軒だけだったのです!
これがいわゆる「ハルシネーション」というものなのでしょうか。
ただ、今回利用しているのが GPT 3.5 ですので、最新の GPT 4 だとまた異なる回答になる可能性は考えられます。

一方で 2回目の質問は「では、2番目のおすすめは?」というふうに、「うどん」という単語はひとつも言ってないのですが、ChatGPT はそれでも、2番目におすすめの「うどん屋さん」を回答してくれました。これは、想定どおりに「会話」を行えている、ということだと思いました。

課題

今回のモジュール構成では、OpenAI の認証キー、Amazon Polly で使う ID プールの ID など、すべて Android アプリ側で持たせてしまっていますので、セキュリティ的にもあまりよくない状態です。
たとえば以下のようなモジュール構成にして、Android アプリから OpenAI の API や Amazon Polly を直接呼び出すのではなく、そのあいだに Lambda などを経由させるようにすると、少なくともこのセキュリティ面の課題については解消できるのではないか、と考えます。

モジュール構成(改善案)

それから、 Chat API のリクエストボディの以下の部分ですが

{
  "model": "gpt-3.5-turbo",
  "messages": [
    {"role": "user", "content": "いちばん好きな果物はなんですか?"},
    {"role": "assistant", "content": "はい、イチゴです。"},
    {"role": "user", "content": "じゃあ、2番目は?"},
  ]
}

現状だと、会話を続けていくと、どんどんリクエストボディのサイズが膨らんでいく一方ですので、これについては、一定の時間が空いたとき、あるいはユーザが明示的にクリアの操作を行ったときに会話の履歴をクリアする、などの対応が必要かも、と思いました。

感想

ChatGPT 先生は丸亀製麺を推している、ということが判明した今回の結果ですが、しかし、入力・出力ともに音声で対話できる、という仕組み自体は、アイデアによってはいろいろと可能性を持っているのではと感じました。

実際の香川県のうどん屋さんに関しては、麺も、おだしも、天ぷらも、お店ごとにぜんぜん違いますので、機会がございましたらぜひ足を運んでみてください。