【OpenAI x Amazon Polly】漫才ネタと掛け合い音声を自動生成してみた

## はじめに
諸般の事情により、AWSを使ってデモ発表することになりました。その中で「Amazon Pollyを使って何か面白いことができないか？」と考え、「AIが考えた漫才ネタを機械音声で実演させてみよう！」というアイデアにたどり着きました。

本記事では、ChatGPTで漫才台本を自動生成し、それをAWS LambdaからAmazon Pollyで音声化し、S3に保存するまでの一連のフローについて紹介します。

## この記事でわかること
  - **（１）AWS→OpenAI API（ChatGPT）との連携**
  - **（２）Amazon Pollyで2人のキャラクターによる漫才の掛け合い音声を生成**

### 工夫したポイント
Amazon Pollyで音声を生成する際に、**2種類の音声を交互に喋らせ、コンビ漫才をしているかのようにしたこと**です。

というのも、Amazon Pollyは、本来1人分の音声を生成するサービスです。 
日本語だと標準2種類（ニューラルだと4種類）の話者が選べますが、1つのテキストで複数話者を喋らせることはできません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4043663/28cfa344-30dc-4b3e-a565-82ba71cc7c32.png)

そこで、ChatGPTに「芸人A」「芸人B」と話者を明記した台本を作らせ、セリフごとにPollyで声種を切り替えて音声を生成・合成することで、漫才の掛け合いを再現しています。

処理の流れはざっくり以下のとおり：

（１）AWS→OpenAI API（ChatGPT）との連携
　１．ChatGPTで漫才ネタの台本を自動生成する際に、セリフの先頭に「芸人A」/「芸人B」を付与するよう指示

（２）Amazon Pollyで2人のキャラクターによる漫才の掛け合い音声を生成
　２．台本を行ごとに分割し、それぞれのセリフが「芸人A」か「芸人B」かを判定
　３．話者が「芸人A」ならMizukiの音声、「芸人B」ならTakumiの音声を使用
　４．セリフから話者名（例：「芸人A:」）を除き、音声を生成し順番に配列へ追加
　５．最後に、すべての音声データを結合し、1つの音声ファイルとして出力

このようにして、「芸人A → 芸人B → 芸人A → ...」と交互に再生される掛け合い音声が完成します。


  では、具体的な手順を見ていきましょう。

 ## （１） AWS→OpenAI API（ChatGPT）との連携 
 ### 事前準備1 APIキー準備
今回はAWSからOpenAI APIと連携するため、ChatGPT（gpt-3.5-turbo）を利用します。
あらかじめOpenAIの公式サイトでAPIアカウントを作成し、APIキーを取得しておきましょう。
[ChatGPT OpenAI公式サイト](https://platform.openai.com/docs/overview)

※ChatGPTのOpenAI APIは重量課金となりますので、使い過ぎに注意してください。

なお、今回は手早く構築するため、取得したAPIキーはLambdaの環境変数に設定しています。

 ### 事前準備2 ネタ台本保管用S3
ChatGPTで生成した漫才ネタはテキストファイルとしてS3に保存します。
そのため、事前にS3バケットを作成し、バケット名もLambdaの環境変数に設定しておきましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4043663/031764fe-6c12-4cb4-8742-8b7b680128de.png)


事前準備が済んだら、いよいよコーディングです。

### lambda関数でChat GPTを呼び出し、漫才ネタを作ってみる
まずはChatGPTに渡すプロンプト（指示文）を決めておきます。今回、以下のように指定しました。
  - ネタの長さは1分程度で披露できる内容にする
  - 二人組の漫才師（芸人A、芸人B）の台本形式で、各セリフの冒頭に発言者名を明記する

#### プログラム作成
実際のコードはこうなります。
（Javaで作成しました）

```javascript:MANZAI.js
//初期設定
const AWS = require('aws-sdk');  // AWSサービス操作用
const axios = require('axios');  // HTTP通信（OpenAI API呼び出し用）

const s3 = new AWS.S3();   　    // S3クライントインスタンス


// 環境変数から取得
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;　　　// ChatGPTのAPIキー
const NETA_BUCKET_NAME = process.env.NETA_BUCKET_NAME; // ネタ台本保管用バケット

exports.handler = async (event) => {
    try {
        // リクエストのボディからお題を取得
        const body = JSON.parse(event.body);
        const { topic } = body;

        // OpenAIにリクエストして漫才ネタを生成
        // - Systemロール、Userロールで指示
        const openAiPayload = {
            model: 'gpt-3.5-turbo',
            messages: [
                { role: 'system', content: 'あなたはお笑いのプロです。' },
                { role: 'user', content: 次のお題で1分の漫才ネタを考えてください: ${topic} },
            ],
            max_tokens: 500,
        };
        
        // OpenAI APIへHTTP POSTでリクエストを送信
        const response = await axios.post(
            'https://api.openai.com/v1/chat/completions',
            openAiPayload,
            {
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': Bearer ${OPENAI_API_KEY},   // 認証トークン（Bearer接頭辞必須）
                },
            }
        );

        // ChatGPTが生成した台本テキストを取得
        const generatedText = response.data.choices[0].message.content;
        console.log('生成されたネタ:', generatedText);

        // 生成したネタ台本をテキストファイルとしてS3に保存
        const textFileName = comedy-${Date.now()}.txt;
        await s3.putObject({
            Bucket: NETA_BUCKET_NAME,
            Key: text/${textFileName},
            Body: generatedText,
            ContentType: 'text/plain',
        }).promise();
        console.log(ネタをS3に保存しました: text/${textFileName});

        // 台本の各行をセリフ単位で分割（改行区切り・空白除外）
        const lines = generatedText.split('\n').filter(line => line.trim() !== '');

        // それぞれの発話者ごとにセリフを整理（後続のPolly音声合成等に利用）
        const dialogue = { comedianA: [], comedianB: [] };
        for (const line of lines) {
            if (line.startsWith('芸人A:')) {
                dialogue.comedianA.push(line.replace('芸人A: ', '').trim());
            } else if (line.startsWith('芸人B:')) {
                dialogue.comedianB.push(line.replace('芸人B: ', '').trim());
            }
        }
```
#### lambda実行
作成したLambda関数をデプロイしたら、テスト実行で動作を確認します。
漫才ネタのお題は、LambdaコンソールのイベントJSONに設定します。

```jsonc:test
{
  "body": "{\"topic\": \"ここにお題を設定\"}"
}
```

　
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4043663/92d8c7f6-661f-4811-a17e-0787fb2bbcef.png)



これで実行すると、設定したお題をもとに漫才ネタが自動生成され、S3バケットに台本テキストファイルが保存されるはずです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4043663/2a40089f-ddc5-4c64-aa51-bb011275a48e.png)


AIが考えた漫才ネタが面白いかどうかは、ぜひ実際に試してみてください。


## （２）Amazon Pollyで2人のキャラクターによる漫才の掛け合い音声を生成
次に、ChatGPTで作成した漫才ネタをAmazon Pollyを使って実際に喋らせてみます。

 ### 事前準備1 音声ファイル保管用S3
Amazon Pollyで作成した音声は、mp3ファイルとしてS3に保存します。
あらかじめS3バケットを作成し、バケット名をLambdaの環境変数に設定しておきましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4043663/169fd854-385c-4eeb-bc00-568cdf5a1eb0.png)



### 機械音声で漫才を実演させてみる
せっかくなので、2人の異なる声で掛け合いを再現し、より本物の漫才に近づけてみます。
Amazon Pollyの日本語音声から「Takumi（男性音声）」と「Mizuki（女性音声）」を使用します。
それぞれの音声でセリフを生成し、最後に1つの音声ファイルとして合成します。
（★ここがポイント！：2つの声を交互に使うことで雰囲気がかなりリアルになります）

先ほど作成した漫才台本生成用Lambdaに、音声生成・合成機能をアドオンして実装します。

まず、初期設定や環境変数まわりに下記内容を追加します。

```javascript:MANZAI_ver2.js
//初期設定
const AWS = require('aws-sdk');
const axios = require('axios');

const s3 = new AWS.S3();
// ★追加★
const polly = new AWS.Polly();　　// Amazon Polly クライアント設定

// 環境変数から取得
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;　　　// ChatGPTのAPIキー
const NETA_BUCKET_NAME = process.env.NETA_BUCKET_NAME; // ネタ台本保管用バケット
// ★追加★
const POLLY_BUCKET_NAME = process.env.POLLY_BUCKET_NAME; // 音声保管用バケット

```

次に、漫才ネタ作成の処理の続きにPollyでの音声合成処理を追記します。
（エラーハンドリングも追加しています）

```javascript:MANZAI_ver2.js

        // Pollyで音声を生成
        // 2人のキャラクターごとに VoiceId（Mizuki/Takumi）を割り当て
        const voices = { comedianA: 'Mizuki', comedianB: 'Takumi' }; // 日本語の女性と男性の声
        const audioStreams = [];

        for (const line of lines) {
        // セリフの先頭で話者を判定し、VoiceIdを動的に切り替え
        // こうすることで「AとBが交互に喋る漫才」を再現
            const speaker = line.startsWith('芸人A:') ? 'comedianA' : 'comedianB';
            const params = {
                // '芸人A:'/'芸人B:' という話者表記をセリフから除いて音声生成
                Text: line.replace(/芸人[AB]: /, ''),
                VoiceId: voices[speaker],
                OutputFormat: 'mp3',
            };
            // Pollyで音声データ(mp3)を生成し、ストリームとして格納
            const response = await polly.synthesizeSpeech(params).promise();
            audioStreams.push(response.AudioStream);
        }

        // すべてのセリフの音声ストリームを順番に結合
        const combinedAudio = Buffer.concat(audioStreams);

        // 合成した音声データをmp3ファイルとしてS3に保存
        const audioFileName = textFileName.replace('.txt', '.mp3');
        await s3.putObject({
            Bucket: POLLY_BUCKET_NAME,
            Key: audio/${audioFileName},
            Body: combinedAudio,
            ContentType: 'audio/mpeg',
        }).promise();
        console.log(音声ファイルをS3に保存しました: audio/${audioFileName});

        // 正常終了レスポンスを返却
        return {
            statusCode: 200,
            body: JSON.stringify({
                message: 'ネタ生成と音声作成成功',
                topic: topic,
                textFile: text/${textFileName},
                audioFile: audio/${audioFileName},
            }),
        };
    } catch (error) {
        // エラーレスポンス＆エラーログ
        console.error('エラーが発生しました:', error);

        return {
            statusCode: 500,
            body: JSON.stringify({
                message: '処理中にエラーが発生しました。',
                error: error.message,
            }),
        };
    }
};

```


これで、指定したS3バケットに漫才の掛け合い音声(mp3)が作成されるはずです。

![Qiita1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4043663/6cabb19e-025e-44e4-a87b-3d0bff524f60.jpeg)


## おわりに
発表の場でお題をもらいデモ実演することで、**ライブ感**を演出でき、本番はとても盛り上がりました。
OpenAIでコンテンツを生み出し、Pollyで“声”にするという流れは、まさに生成AIの実用例として面白い体験でした。

今回は、生成AIと音声合成、そしてAWSのサーバーレス技術を組み合わせることで、「ネタを作って喋らせる」という一連の流れを自動化できました。
真面目な用途にも転用できそうですが、こうした遊び心のあるユースケースこそ、技術に対する理解や応用力を育てるチャンスだと感じています。
もしこの記事が面白いと思ったら、ぜひ試してみてください！