5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Bedrock × Polly で作る掛け合い音声チャットボット

5
Last updated at Posted at 2025-09-15

東京支部 CommunityBuilders Night #2 / Jr.Champions コラボイベントにて、Bedrock × Pollyを組み合わせた掛け合い音声チャットボットの実装方法を発表しました。

本記事では、発表内容をもとに アーキテクチャ・実装コード・工夫点 を解説します。

TL;DR

  • Bedrockで会話台本を生成し、Pollyで男女掛け合い音声に合成
  • SSML対応で話速・間を制御し、飽きない自然な掛け合いを実装
  • Pollyの仕組み・料金・実運用のポイントをまとめ、実装コードを公開

本記事ではまず背景を押さえ、次にアーキテクチャ→実装例の順で全体像を短距離で把握できる構成にしています。

背景

“ながらでインプットしたい”というニーズが増えた今、単調な読み上げ機能だけでは不足

なぜ「掛け合い音声チャットボット」が必要なのか

こんな経験はないでしょうか。
「長い記事を読んでいたが、途中で疲れてブラウザバックしてしまった。」
「通勤や移動の隙間時間に情報を取り込みたいが、PC画面を見るのは難しい。」

コロナ禍が落ち着き、外出や移動が増えたことで、“ながらでインプットしたい” というニーズは確実に強まっています。

最近の生成AIツールにも音声再生機能を搭載しているものは数多くあります。
しかし、ただの機械的な読み上げは退屈で、しばらく聞いていると眠くなったり、肝心な内容を聞き逃してしまったりします。

そこで注目したのが「会話調の掛け合い」です。

Personalization effect:語りかけ調の音声は理解や記憶を助ける
Voice numerosity effect:声が切り替わると注意がリセットされ、説得力も増す

たとえば「要点を説明する人」と「補足や質問をする人」が交互に話すと、リズムが生まれて自然と情報が頭に入ってきます。まるでラジオ番組を聞いているように、最後まで飽きずに聞き切れるのです。

Amazon Polly概要

Amazon Polly はテキストを自然な音声に変換するクラウド型 TTS(Text-to-Speech)サービスです。
深層学習を活用した Neural音声を使うと、ニュースアナウンサーのように滑らかで聞き取りやすい合成音声を生成できます。また、SSML(音声合成マークアップ言語)で話速や間の調整ができ、より自然な会話体に近づけられるのが強みです。
(公式サイトより抜粋)

主な特徴

  • 29 言語、60 以上の音声に対応(日本語にも対応)
  • 東京(ap-northeast-1)リージョンでは Standard と Neural音声の2種類が利用可能
  • 低遅延での音声合成

日本語の代表的な音声は以下の通りです。

言語と言語バリアント 言語コード 名前/ID 性別 Standard Neural
日本語 ja-JP Mizuki 女性
日本語 ja-JP Takumi 男性
日本語 ja-JP Kazuha 女性
日本語 ja-JP Tomoko 女性

※ 対応ボイス/Neural化の有無は更新されることがあります。

掛け合いは Takumi(男性)+Kazuha(女性)のNeural音声が自然でおすすめ

料金イメージ

  • 課金は「合成した文字数」に応じて発生

東京(ap-northeast-1)リージョンの場合

音声 100万文字あたり 円換算
Standard $4.00 約 600 円
Neural $16.00 約 2,400 円

男女掛け合いで 1 回 500 文字 × 1 日 10 回 × 30 日 = 月間 15 万文字の場合でも

  • Standard音声のみ:約 90 円
  • Neural音声のみ:約 360 円
  • Standard + Neural音声の混合:約 225 円

と、かなり現実的なコスト感で運用できます。

運用時のポイント

  • SSMLによって句読点や感嘆符でポーズを入れると掛け合いが自然に
  • 難解ワードを指定すると、そのワードを含む文章のときだけ音声速度を下げることが可能
  • Neural音声非対応エラー時は Standard 自動フォールバックを実装することで堅牢化

全体像

architecture.png

  1. User → CloudFront → API Gateway + Lambda
    • リクエストを送信
  2. Lambda → Bedrock
    • 掛け合い台本を生成
  3. Lambda(SSML 整形)
    • 台本を男女に分離し、話速・間を制御する SSML を付与
  4. Lambda → Polly(Takumi / Kazuha, Neural音声)
    • 音声に変換
  5. API Gateway + Lambda → CloudFront → User
    • 生成音声を返却

CloudFront + S3 はOACで保護し、配信をCloudFront経由のみに制限
CloudFront → API Gateway はカスタムヘッダ検証で保護し、API直アクセスを遮断

実装例

個人環境が出やすいファイルは記事内では触れません(package-lock.json 等)

bedrock-polly-chatbot/
├── bin/bedrock-polly-chatbot.ts       # CDKエントリポイント
├── cdk-stack.ts                       # CDKインフラ定義
├── lambda/app.py                      # Lambda本体
├── lambda/requirements.txt            # Python依存
└── index.html                         # 単一ファイルUI

CDKエントリポイント

bedrock-polly-chatbot.ts
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { BedrockPollyChatbotStack } from '../cdk-stack';

const app = new cdk.App();

// CDK_DEFAULT_ACCOUNT/REGION があればそれを使い、なければ ap-northeast-1
new BedrockPollyChatbotStack(app, 'BedrockPollyChatbotStack', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION ?? 'ap-northeast-1',
  },
});

CDKインフラ定義

  • CloudFront → API Gatewayはカスタムヘッダ検証(Lambda側)で保護
  • S3はOAIで保護(本稿は簡潔さ優先で OAI、実運用は OAC 推奨)
  • WAF はレート制御やBot対策に活用
cdk-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigw from 'aws-cdk-lib/aws-apigateway';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';

export class BedrockPollyChatbotStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // ====== 秘匿値は環境変数から注入(公開用コードはマスク)======
    const ORIGIN_TOKEN = process.env.ORIGIN_TOKEN ?? '***REDACTED***'; // CloudFront→APIGW用
    const BEDROCK_MODEL_ID = process.env.BEDROCK_MODEL_ID ?? '***REDACTED***';

    // ====== Lambda IAM ロール ======
    const lambdaRole = new iam.Role(this, 'ChatbotLambdaRole', {
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'),
      ],
      inlinePolicies: {
        InvokeBedrockAndPolly: new iam.PolicyDocument({
          statements: [
            new iam.PolicyStatement({
              actions: ['bedrock:InvokeModel', 'bedrock:Retrieve'],
              resources: ['*'], // ※ 実運用はモデル/KB ARN に絞ってください
            }),
            new iam.PolicyStatement({
              actions: ['polly:SynthesizeSpeech'],
              resources: ['*'],
            }),
          ],
        }),
      },
    });

    // ====== Lambda 本体 ======
    const fn = new lambda.Function(this, 'ChatbotFunction', {
      runtime: lambda.Runtime.PYTHON_3_11,
      handler: 'app.lambda_handler',
      code: lambda.Code.fromAsset('lambda'),
      timeout: cdk.Duration.seconds(120),
      memorySize: 1024,
      role: lambdaRole,
      environment: {
        KNOWLEDGE_BASE_ID: 'MANUAL_SETUP',         // まずはRAGオフ。使うならIDを注入
        BEDROCK_REGION: this.region,
        POLLY_REGION: this.region,
        BEDROCK_MODEL_ID: BEDROCK_MODEL_ID,        // ***REDACTED***のままなら起動時にエラー返却
        ORIGIN_TOKEN: ORIGIN_TOKEN,                // Lambda 側でヘッダ検証
      },
    });

    // ====== API Gateway(REST)======
    const api = new apigw.RestApi(this, 'ChatbotApi', {
      restApiName: 'BedrockPolly Chatbot API',
      description: 'Bedrock (Claude) + Polly (ja-JP) chatbot backend',
      defaultCorsPreflightOptions: {
        allowOrigins: apigw.Cors.ALL_ORIGINS,
        allowMethods: ['POST', 'OPTIONS'],
        allowHeaders: ['Content-Type', 'Authorization', 'X-Origin-Token'],
      },
      deployOptions: { stageName: 'prod' },
    });
    const apiRoot = api.root.addResource('api');
    apiRoot.addResource('chat').addMethod('POST', new apigw.LambdaIntegration(fn));

    // ====== S3(静的サイト)+ CloudFront(OAIで保護)======
    const siteBucket = new s3.Bucket(this, 'WebsiteBucket', {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    const oai = new cloudfront.OriginAccessIdentity(this, 'SiteOAI');
    siteBucket.grantRead(oai.grantPrincipal);

    const siteOrigin = new origins.S3Origin(siteBucket, {
      originAccessIdentity: oai,
    });

    // API Gateway を CloudFront 経由で叩く(固定ヘッダ付与)
    const apiOrigin = new origins.HttpOrigin(
      `${api.restApiId}.execute-api.${this.region}.amazonaws.com`,
      {
        originPath: `/${api.deploymentStage.stageName}`,
        // CloudFront→APIGW に固定ヘッダを注入(Lambdaで検証)
        customHeaders: { 'X-Origin-Token': ORIGIN_TOKEN },
      }
    );

    const dist = new cloudfront.Distribution(this, 'SiteDistribution', {
      defaultRootObject: 'index.html',
      defaultBehavior: {
        origin: siteOrigin,
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD,
      },
      additionalBehaviors: {
        // /api/* は API Gateway にプロキシ(キャッシュは無効化)
        'api/*': {
          origin: apiOrigin,
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
          allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
          cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
          originRequestPolicy: cloudfront.OriginRequestPolicy.ALL_VIEWER, // ヘッダ/クエリをそのまま
        },
      },
      priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
    });

    // ※ OAC 利用に切り替える場合(推奨):
    //  - CDK L1 の cloudfront.CfnOriginAccessControl を作成し、Distribution のオリジンに紐付け。
    //  - S3 バケットポリシーで cloudfront.amazonaws.com サービスプリンシパル + SourceArn=Distribution ARN を許可。
    //  - ここでは簡潔さを優先して OAI 実装にしています。

    // ====== Web 資材デプロイ(index.html をルートに)======
    new s3deploy.BucketDeployment(this, 'DeployWebsite', {
      sources: [s3deploy.Source.asset('.', {
        exclude: [
          'node_modules/**', 'cdk.out/**', 'lambda/**', 'bin/**',
          '*.ts', '*.json', '*.md',
        ],
      })],
      destinationBucket: siteBucket,
      distribution: dist,
      distributionPaths: ['/*'],
    });

    // ====== 出力 ======
    new cdk.CfnOutput(this, 'CloudFrontURL', { value: `https://${dist.distributionDomainName}` });
    new cdk.CfnOutput(this, 'ApiInvokeUrl',   { value: `${api.url}chat` });
  }
}

Lambda本体

app.py
import json, boto3, base64, re, os, time
from typing import Dict, Any, List, Tuple
from xml.sax.saxutils import escape as xml_escape

# ===== Regions / Model =====
BEDROCK_REGION = os.getenv("BEDROCK_REGION", "ap-northeast-1")
POLLY_REGION   = os.getenv("POLLY_REGION",   "ap-northeast-1")
MODEL_ID       = os.getenv("BEDROCK_MODEL_ID", "***REDACTED***")  # 実値は注入。未設定なら起動時エラー
ORIGIN_TOKEN   = os.getenv("ORIGIN_TOKEN",   "***REDACTED***")    # CloudFront→APIGW固定ヘッダ

bedrock = boto3.client("bedrock-runtime", region_name=BEDROCK_REGION)
kb_client = boto3.client("bedrock-agent-runtime", region_name=BEDROCK_REGION)
polly   = boto3.client("polly",           region_name=POLLY_REGION)

# ===== SSML helpers =====
_P_BREAK = {
    "": '、<break time="300ms"/>',
    "": '。<break time="500ms"/>',
    "": '!<break time="500ms"/>',
    "": '?<break time="500ms"/>',
}

def _insert_breaks(text: str) -> str:
    for mark, rep in _P_BREAK.items():
        text = re.sub(rf'{re.escape(mark)}(?!\s*<break\b)', rep, text)
    return text

def create_ssml_text(text: str, is_complex: bool) -> str:
    safe = xml_escape(text)
    safe = _insert_breaks(safe)
    rate = "slow" if is_complex else "medium"
    return f'<speak><prosody rate="{rate}">{safe}</prosody><break time="300ms"/></speak>'

def is_complex_content(text: str) -> bool:
    kw = ['技術','システム','アーキテクチャ','セキュリティ','データベース','API','AWS','クラウド']
    return any(k in text for k in kw) or len(text) > 50

# ===== Dialogue parsing =====
_SPEAKER_RE = re.compile(r'^(男性|女性)(?:([ABAB]))?\s*[::]\s*(.+)$')

def parse_dialogue(text: str) -> List[Tuple[str, str]]:
    parts: List[Tuple[str,str]] = []
    for raw in text.strip().splitlines():
        m = _SPEAKER_RE.match(raw.strip())
        if m:
            parts.append((m.group(1), m.group(2).strip()))
    return parts

# ===== Polly synth strategies =====
def synthesize_with_strategies(ssml: str, strategies: List[Tuple[str, str]]) -> Tuple[bytes, str, str]:
    """
    strategies: [(engine, voiceId), ...] 優先順
    戻り値: (audio_bytes, engine_used, voice_used)
    """
    last_err = None
    for engine, voice in strategies:
        try:
            r = polly.synthesize_speech(
                Text=ssml, TextType="ssml",
                OutputFormat="mp3",
                VoiceId=voice, Engine=engine,
                LanguageCode="ja-JP", SampleRate="24000",
            )
            return r["AudioStream"].read(), engine, voice
        except Exception as e:
            last_err = e
            time.sleep(0.2)
    raise last_err if last_err else RuntimeError("Polly synthesis failed")

def voice_strategies_for(speaker: str) -> List[Tuple[str, str]]:
    # 男性: Takumi (Neural→Standard)
    # 女性: Kazuha (Neural) → 失敗時は Mizuki(Standard) にフォールバック
    if speaker == "男性":
        return [("neural","Takumi"), ("standard","Takumi")]
    else:
        return [("neural","Kazuha"), ("standard","Mizuki")]

# ===== HTTP helpers =====
def _cors_headers() -> Dict[str, str]:
    return {
        "Content-Type": "application/json",
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Methods": "POST, OPTIONS",
        "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Origin-Token",
    }

def _resp(status: int, body):
    return {
        "statusCode": status,
        "headers": _cors_headers(),
        "body": json.dumps(body, ensure_ascii=False) if body != '' else ''
    }

def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
    # CORS preflight
    if event.get("httpMethod") == "OPTIONS":
        return _resp(200, '')

    try:
        # ===== 固定ヘッダ検証(直叩き防止)=====
        # CloudFront→APIGW で付与した X-Origin-Token と一致するか確認
        if ORIGIN_TOKEN and ORIGIN_TOKEN != "***REDACTED***":
            headers = event.get("headers") or {}
            incoming = headers.get("X-Origin-Token") or headers.get("x-origin-token")
            if incoming != ORIGIN_TOKEN:
                return _resp(403, {"error": "forbidden"})

        # ===== 事前チェック =====
        if not MODEL_ID or MODEL_ID == "***REDACTED***":
            return _resp(500, {"error": "Server not configured: BEDROCK_MODEL_ID is not set."})

        body = json.loads(event.get("body") or "{}")
        user_query = (body.get("query") or "").strip()
        if not user_query:
            return _resp(400, {"error": "query is required"})

        # ===== KB(オプション)=====
        kb_id = body.get("knowledge_base_id", "MANUAL_SETUP")
        context_text = "General knowledge response without specific document context."
        if kb_id != "MANUAL_SETUP":
            try:
                kb = kb_client.retrieve(
                knowledgeBaseId=kb_id,
                retrievalQuery={"text": user_query},
                retrievalConfiguration={"vectorSearchConfiguration": {"numberOfResults": 3}},
                )
                context_text = "\n".join([r["content"]["text"] for r in kb.get("retrievalResults", [])]) or context_text
            except Exception:
                context_text = "Knowledge Base not available. Providing general response."

        # ===== Bedrock (Claude系想定) =====
        prompt = f"""以下のコンテキストを参考に、ユーザーの質問に対して男性と女性の2人が掛け合いで詳しく説明する形式で回答してください。

コンテキスト:
{context_text}

質問: {user_query}

【音声化に最適化された制約】
- 男性(A):メインの詳細説明を担当。10-20秒程度(60-120文字)
- 女性(B):要約・補足・相づち。5-10秒程度(30-60文字)
- 合計6-8ターン、具体例/メリット・デメリット/使用例を含める
- 専門用語は丁寧に解説し、初心者にも分かりやすく

回答形式(例):
男性: [概要と基本の説明]
女性: [要約や次のポイントへの誘導]
男性: [具体例やメリット]
女性: [理解を深める質問]
男性: [注意点/デメリット]
女性: [実用的なアドバイスやまとめ]
"""
        br = bedrock.invoke_model(
            modelId=MODEL_ID,
            body=json.dumps({
                "anthropic_version": "bedrock-2023-05-31",
                "max_tokens": 2000,
                "messages": [{"role": "user", "content": prompt}]
            })
        )
        br_body = json.loads(br["body"].read())
        conversation_text = br_body["content"][0]["text"]

        # ===== 台本を行単位で分離 =====
        dialogue_parts = parse_dialogue(conversation_text)

        # ===== Polly で音声化 =====
        audio_files = []
        for speaker, text in dialogue_parts:
            ssml = create_ssml_text(text, is_complex_content(text))
            audio_bytes, engine_used, voice_used = synthesize_with_strategies(ssml, voice_strategies_for(speaker))
            audio_files.append({
                "speaker": speaker,
                "text": text,
                "ssml": ssml,
                "voice_id": voice_used,
                "engine": engine_used,
                "audio": base64.b64encode(audio_bytes).decode("utf-8")
            })

        return _resp(200, {
            "conversation": conversation_text,
            "audio_files": audio_files,
            "meta": {
                "bedrock_region": BEDROCK_REGION,
                "polly_region": POLLY_REGION
                # model_id は露出させない
            }
        })

    except Exception as e:
        return _resp(500, {"error": str(e)})

Python依存

requirements.txt
boto3==1.34.0
botocore==1.34.0

単一ファイルUI

  • API は CloudFront の相対パス /api/chat
  • UIは「最小構成+プロンプト例とSSML Tips」のみ掲載
index.html
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Bedrock × Polly 掛け合いデモ</title>
  <style>
    body { font-family: system-ui, -apple-system, Segoe UI, Roboto, 'Noto Sans JP', sans-serif; margin:0; color:#1b1f23; background:#fafbfc; }
    header { padding:20px; border-bottom:1px solid #e1e4e8; background:#fff; position:sticky; top:0; }
    main { max-width: 1080px; margin: 24px auto; padding: 0 16px; display:grid; grid-template-columns: 1.2fr .8fr; gap:24px; }
    h1 { font-size:18px; margin:0; }
    .card { background:#fff; border:1px solid #e1e4e8; border-radius:12px; padding:16px; }
    .row { display:flex; gap:8px; margin-top:8px; }
    textarea { width:100%; min-height:80px; padding:12px; border:1px solid #d1d5da; border-radius:8px; resize:vertical; }
    button { padding:10px 14px; border:1px solid #d1d5da; border-radius:8px; background:#2ea44f; color:white; cursor:pointer; }
    button:disabled { opacity:.6; cursor:not-allowed; }
    pre, code { background:#f6f8fa; padding:12px; border-radius:8px; overflow:auto; }
    .dialogue { margin-top:16px; }
    .talk { border-left:4px solid #0366d6; background:#f1f8ff; padding:10px 12px; border-radius:6px; margin:8px 0; }
    .talk.female { border-left-color:#e36209; background:#fff5e6; }
    audio { width:100%; margin-top:6px; }
    .muted { color:#586069; font-size:12px; }
    @media (max-width: 960px){ main{ grid-template-columns: 1fr; } }
  </style>
</head>
<body>
  <header>
    <h1>Bedrock × Polly 掛け合い音声(CloudFront 経由 /api/chat)</h1>
  </header>

  <main>
    <!-- 左:送信 & 結果 -->
    <section class="card">
      <h3>質問を送る</h3>
      <p class="muted">例:「Amazon Polly の東京リージョン料金を教えて」「SSML のコツを教えて」</p>
      <textarea id="query" placeholder="ここに質問を入力…"></textarea>
      <div class="row">
        <button id="send">送信</button>
        <span id="status" class="muted" aria-live="polite"></span>
      </div>

      <div id="result" class="dialogue"></div>
    </section>

    <!-- 右:プロンプト例&SSML Tips -->
    <aside class="card">
      <h3>プロンプト例(掛け合い最適化)</h3>
      <pre><code>以下の制約で、男性(A)と女性(B)の掛け合いで説明してください。
- A: 詳細説明を10-20秒(60-120文字)
- B: 要約・補足を5-10秒(30-60文字)
- 6-8ターンで、具体例/メリデメ/注意点を含める
- 専門用語は簡単に解説する

トピック: [ここにテーマ]</code></pre>

      <h3>SSML Tips(日本語)</h3>
      <pre><code>&lt;speak&gt;
  &lt;prosody rate="medium"&gt;
    こんにちは、&lt;break time="300ms"/&gt; はじめまして。
    重要語はゆっくり説明します。&lt;prosody rate="slow"&gt;Amazon Polly&lt;/prosody&gt;です。
  &lt;/prosody&gt;
&lt;/speak&gt;</code></pre>

      <p class="muted">
        ※ このデモでは句読点後に自動で &lt;break&gt; を付与。<br/>
        難解語が多い文は自動で <code>rate="slow"</code> に切替。
      </p>
    </aside>
  </main>

  <script>
    const $ = (s) => document.querySelector(s);
    const $result = $('#result');
    const $send = $('#send');
    const $status = $('#status');

    async function postChat(query) {
      // CloudFront 経由の相対パス(/api/chat)
      const res = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        // CloudFront 側で APIGW に X-Origin-Token を付与
        body: JSON.stringify({ query, knowledge_base_id: 'MANUAL_SETUP' })
      });
      return await res.json();
    }

    function renderConversation(data) {
      $result.innerHTML = '';
      if (data.error) {
        $result.innerHTML = `<div class="talk">エラー: ${data.error}</div>`;
        return;
      }
      (data.audio_files || []).forEach(item => {
        const div = document.createElement('div');
        div.className = `talk ${item.speaker === '女性' ? 'female' : 'male'}`;
        div.innerHTML = `
          <div><strong>${item.speaker}</strong>:${escapeHtml(item.text)}</div>
          <audio controls src="${makeAudioUrl(item.audio)}"></audio>
          <div class="muted">voice=${item.voice_id}, engine=${item.engine}</div>
        `;
        $result.appendChild(div);
      });
    }

    function makeAudioUrl(b64) {
      const blob = base64ToBlob(b64, 'audio/mp3');
      return URL.createObjectURL(blob);
    }

    function base64ToBlob(b64, mime){
      const bin = atob(b64), len = bin.length;
      const arr = new Uint8Array(len);
      for (let i=0;i<len;i++) arr[i] = bin.charCodeAt(i);
      return new Blob([arr], { type: mime });
    }

    function escapeHtml(s){
      return s.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
    }

    $send.addEventListener('click', async () => {
      const q = ($('#query').value || '').trim();
      if (!q) return;
      $send.disabled = true;
      $status.textContent = '生成中…';
      $result.innerHTML = '';
      try {
        const data = await postChat(q);
        renderConversation(data);
      } catch (e) {
        $result.innerHTML = `<div class="talk">通信エラー: ${e?.message || e}</div>`;
      } finally {
        $send.disabled = false;
        $status.textContent = '';
      }
    });
  </script>
</body>
</html>

運用チェックリスト

  • 環境変数:BEDROCK_MODEL_ID(実値はデプロイ時に注入)、ORIGIN_TOKEN
  • リージョン整合:Bedrock / Polly は同一リージョン
  • ネットワーク保護:S3 は OAI、API は X-Origin-Token 必須
  • CORS:POST, OPTIONSContent-Type, X-Origin-Token を許可
  • コスト:Pollyは文字数課金、Bedrockはトークン課金、CloudFrontの転送料も確認

使用例

初期画面

  • 簡易的なUIが表示
    →「Amazon Pollyについて教えて下さい」と入力してみましょう

index.png

実行画面

  • 男性と女性それぞれの音声で台本が生成
    →個別再生や途中で停止も可能

play.png

  • 全体再生やダウンロードも可能
    →ダウンロードすることで“ながらでインプット可能に”
    play2.png

まとめ

要点のふり返り

  • Bedrockで会話台本を生成し、Pollyで男女掛け合い音声に合成
  • SSML対応で話速・間を制御し、飽きない自然な掛け合いを実装
  • Pollyの仕組み・料金・実運用のポイントをまとめ、実装コードを公開
    • 句読点の後に自動で <break> を差し込み、難しめの文は rate="slow"
    • Kazuha は Neural音声専用なので、万一失敗したら Mizuki(Standard)に退避。Takumi は Neural → Standard音声の順でフォールバック
    • フロントは CloudFront、S3はOAI/OACで私設化
    • CloudFront → API Gateway 間は固定ヘッダ(X-Origin-Token)で検証
    • 月 15 万文字クラスでも Neural音声 ≈ 360円、Standard音声なら ≈ 90円、混合でも ≈ 225円(概算)

運用チェックリスト

  • 環境変数:BEDROCK_MODEL_ID(実値はデプロイ時に注入)、ORIGIN_TOKEN
  • リージョン整合:Bedrock / Polly は同一リージョン
  • ネットワーク保護:S3 は OAI、API は X-Origin-Token 必須
  • CORS:POST, OPTIONSContent-Type, X-Origin-Token を許可
  • コスト:Pollyは文字数課金、Bedrockはトークン課金、CloudFrontの転送料も確認

つまずきポイントと対処

  • 403 Forbidden(API 直叩き):CloudFront 経由の /api/chat を使用
    • X-Origin-Token のミスマッチ/未付与を確認
  • “Server not configured”:BEDROCK_MODEL_ID がマスクのまま
    • デプロイ時に実値を環境変数で置換
  • Polly の組み合わせエラー:Kazuha+Standard など未対応
    • 実装済みのフォールバック(→ Mizuki/Standard)で吸収
  • SSML の崩れ:特殊文字は XML エスケープが基本
    • カスタムで <prosody> を入れ子にする際は <speak> 配下の構造を維持

これからの伸ばし方

  • RAGを活用:KNOWLEDGE_BASE_ID を設定して、社内ドキュメントから台本生成
  • 音声キャッシュ:テキストのハッシュをキーにS3再利用し、合成コスト&待ち時間を節約
  • アクセント調整:固有名詞や専門語だけ <prosody rate="slow"> を当てる軽量辞書を用意
  • 配信最適化:OAI → OACへ移行、WAF マネージドルールの追加、CloudFront Functions で軽量 A/B

登壇後にいただいたFB

  • 文章生成の最適化:文章のみを先に生成(Streaming配信)し、バックグラウンドで音声生成を行うことでUX改善
  • 速度調整:句点、感嘆符が文章の最後に来る場合、ポーズを入れると音声がやや冗長に
    • 文章の最後に句点、感嘆符が来ないようプロンプトで調整が必要

みなさんのチャットボットにも、掛け合い音声機能をぜひ取り入れてみてください

参考文献

Personalization effect

Mayer et al. (2004): Personalization Effect in Multimedia Learning

Voice numerosity effect

Brock (2005): To Think or Not to Think: Multiple Source Effects

Amazon Polly

Amazon Polly - AI 音声ジェネレーター
Amazon Polly Documentation

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?