Amazon Polly と API Gateway と Lambda で、ブラウザ入力の日本語を読み上げる Web ページを作成してみた
はじめに
アプリケーションにテキスト読み上げ機能を実装する必要があり、Amazon Polly、AWS Lambda、Amazon API Gateway を組み合わせてどの程度シンプルに実装できるかを確認しました。
今回はその検証として、ブラウザ上のテキスト入力欄に入力した日本語を、Amazon Polly で音声合成して再生するシンプルな Web ページを作成しました。
今回作成したページでは、テキスト入力欄に文章を入力し、ボイスを選択して [再生] をクリックすると、Amazon Polly で合成した音声がブラウザ上で再生されます。
以下の画面で、Amazon CloudFront 経由で配信されたページを確認できます。

構成としては、Amazon CloudFront と Amazon S3 で HTML を配信し、API Gateway HTTP API 経由で Lambda を呼び出し、Lambda から Amazon Polly を呼び出します。
デプロイ作業は AWS CloudShell から実行しています。今回のリージョンは ap-northeast-1 に統一しています。
構成
今回の構成と処理の流れは以下です。
[ブラウザ]
│ ① index.html を取得
▼
[CloudFront]
│
▼
[S3 バケット (index.html)]
[ブラウザ]
│ ② POST /tts (テキスト)
▼
[API Gateway HTTP API]
│ ③ AWS_PROXY 統合
▼
[Lambda]
│ ④ Polly.synthesize_speech
▼
[Amazon Polly]
│ ⑤ MP3 (AudioStream)
▼
[Lambda] base64 化して JSON で返却
│
▼
[ブラウザ] Web Audio API で再生
ブラウザは CloudFront から HTML を取得します。その後、HTML 内の JavaScript から API Gateway HTTP API の /tts にテキスト、ボイス、エンジンを送信します。
Lambda は受け取った内容をもとに Amazon Polly の SynthesizeSpeech を呼び出し、取得した MP3 を base64 化して JSON で返します。ブラウザ側では、返ってきた base64 の音声データを Web Audio API で再生します。
CloudFront と API Gateway は別のホスト名になるため、API Gateway 側で Cross-Origin Resource Sharing(CORS、オリジン間リソース共有)を設定しています。
設計上のポイント
今回は検証用として、できるだけ少ないリソースで動作確認できる構成にしています。
API Gateway は HTTP API を使い、POST /tts の 1 ルートだけを作成します。Lambda のコードは CloudFormation テンプレート内にインラインで記述し、別途 ZIP ファイルを用意しないようにしました。
生成した音声ファイルを S3 に保存する構成も考えられますが、今回は API レスポンスだけで完結する方式にしました。Lambda から base64 化した MP3 を JSON で返し、ブラウザ側では JSON に含まれる base64 の音声データを Web Audio API で再生します。
base64 化した音声データを JSON として返す場合、元の MP3 よりレスポンスサイズが大きくなります。そのため、API Gateway と Lambda のペイロードサイズ上限を確認しておきます。
API Gateway HTTP API のクォータには以下の記載があります。
Payload size 10 MB
ペイロードサイズは 10 MB です。
https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-quotas.html
Lambda のクォータには、同期呼び出しのリクエストとレスポンスのペイロード上限について以下の記載があります。
Invocation payload (request and response) | 6 MB each for request and response (synchronous)
呼び出しペイロード(リクエストおよびレスポンス)は、同期呼び出しの場合、リクエストとレスポンスそれぞれ 6 MB です。
https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html
今回のように Lambda から base64 化した音声を同期レスポンスとして返す構成では、API Gateway HTTP API の 10 MB だけでなく、Lambda の同期レスポンス 6 MB も意識する必要があります。
入力テキストは 3,000 文字までに制限しています。これは、Amazon Polly の同期音声合成 API である SynthesizeSpeech の入力サイズと、base64 化後のレスポンスサイズに余裕を持たせることを意識したためです。
Amazon Polly のクォータには以下の記載があります。
The size of the input text can be up to 3000 billed characters (6000 total characters). SSML tags are not counted as billed characters.
入力テキストのサイズは、最大 3,000 billed characters(課金対象文字)、合計 6,000 文字までです。SSML タグは billed characters にはカウントされません。
ここでいう billed characters は、SSML タグを除いた、音声合成の対象として扱われる文字です。今回は SSML を使わず通常のテキストを Text として渡すため、ブラウザ側と Lambda 側の両方で 3,000 文字までに制限しています。
なお、3,000 文字であれば必ず Lambda の同期レスポンス上限 6 MB 以内に収まる、という意味ではありません。今回の検証では短い日本語テキストの読み上げを想定しているため、この構成で問題ないと判断しました。より長い音声やサイズを厳密に扱う必要がある場合は、生成した音声ファイルを S3 に保存して署名付き URL を返す構成も選択肢になります。
作成したファイル
今回利用したファイルは以下の 4 つです。
template.yaml: CloudFormation テンプレートindex.html: 配信用 HTMLdeploy.sh: CloudFormation デプロイ、Outputs の取得、HTML の S3 アップロードを行うスクリプトteardown.sh: 検証後の片付け用スクリプト
template.yaml
CloudFormation テンプレートの全文です。
S3、CloudFront、Lambda、API Gateway HTTP API を 1 つのスタックに含めています。
AWSTemplateFormatVersion: '2010-09-09'
Description: Polly TTS demo - CloudFront + S3 + API Gateway + Lambda
Resources:
# S3
WebBucket:
Type: AWS::S3::Bucket
Properties:
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
OwnershipControls:
Rules:
- ObjectOwnership: BucketOwnerEnforced
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: AES256
WebBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref WebBucket
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: AllowCloudFrontServicePrincipalReadOnly
Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Action: s3:GetObject
Resource: !Sub '${WebBucket.Arn}/*'
Condition:
StringEquals:
AWS:SourceArn: !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${Distribution}'
# CloudFront
OriginAccessControl:
Type: AWS::CloudFront::OriginAccessControl
Properties:
OriginAccessControlConfig:
Name: !Sub '${AWS::StackName}-oac'
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4
Distribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
DefaultRootObject: index.html
PriceClass: PriceClass_200
Origins:
- Id: S3Origin
DomainName: !GetAtt WebBucket.RegionalDomainName
S3OriginConfig:
OriginAccessIdentity: ''
OriginAccessControlId: !GetAtt OriginAccessControl.Id
DefaultCacheBehavior:
TargetOriginId: S3Origin
ViewerProtocolPolicy: redirect-to-https
AllowedMethods:
- GET
- HEAD
CachedMethods:
- GET
- HEAD
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
# Lambda
LambdaRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Policies:
- PolicyName: PollySynthesizeSpeech
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- polly:SynthesizeSpeech
Resource: '*'
TtsFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: !Sub '${AWS::StackName}-tts'
Runtime: python3.12
Handler: index.handler
Role: !GetAtt LambdaRole.Arn
Timeout: 30
MemorySize: 512
Code:
ZipFile: |
import boto3
import base64
import json
polly = boto3.client('polly')
ALLOWED_VOICE_ENGINE = {
('Kazuha', 'neural'),
('Takumi', 'neural'),
('Mizuki', 'standard'),
}
def response(status_code, body):
return {
'statusCode': status_code,
'headers': {
'Content-Type': 'application/json'
},
'body': json.dumps(body, ensure_ascii=False)
}
def handler(event, context):
try:
try:
body = json.loads(event.get('body') or '{}')
except json.JSONDecodeError:
return response(400, {'error': 'invalid json'})
text = str(body.get('text', ''))
voice_id = str(body.get('voiceId', 'Kazuha'))
engine = str(body.get('engine', 'neural'))
if not text.strip():
return response(400, {'error': 'text is required'})
if len(text) > 3000:
return response(400, {'error': 'text must be 3000 characters or less'})
if (voice_id, engine) not in ALLOWED_VOICE_ENGINE:
return response(400, {'error': 'unsupported voiceId or engine'})
result = polly.synthesize_speech(
Engine=engine,
VoiceId=voice_id,
OutputFormat='mp3',
Text=text
)
audio_b64 = base64.b64encode(result['AudioStream'].read()).decode('utf-8')
return response(200, {'audio': audio_b64})
except Exception as e:
print(f'polly tts error: {e}')
return response(500, {'error': 'internal error'})
LambdaPermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref TtsFunction
Action: lambda:InvokeFunction
Principal: apigateway.amazonaws.com
SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${HttpApi}/*/*'
# API Gateway HTTP API
HttpApi:
Type: AWS::ApiGatewayV2::Api
Properties:
Name: !Sub '${AWS::StackName}-api'
ProtocolType: HTTP
CorsConfiguration:
AllowOrigins:
- !Sub 'https://${Distribution.DomainName}'
AllowMethods:
- POST
- OPTIONS
AllowHeaders:
- Content-Type
MaxAge: 300
Integration:
Type: AWS::ApiGatewayV2::Integration
Properties:
ApiId: !Ref HttpApi
IntegrationType: AWS_PROXY
IntegrationUri: !GetAtt TtsFunction.Arn
PayloadFormatVersion: '2.0'
Route:
Type: AWS::ApiGatewayV2::Route
Properties:
ApiId: !Ref HttpApi
RouteKey: POST /tts
Target: !Sub 'integrations/${Integration}'
Stage:
Type: AWS::ApiGatewayV2::Stage
Properties:
ApiId: !Ref HttpApi
StageName: $default
AutoDeploy: true
Outputs:
BucketName:
Value: !Ref WebBucket
DistributionId:
Value: !Ref Distribution
WebsiteUrl:
Value: !Sub 'https://${Distribution.DomainName}'
ApiEndpoint:
Value: !Sub 'https://${HttpApi}.execute-api.${AWS::Region}.amazonaws.com'
このテンプレートでは、HTML 配信用の S3 バケットと CloudFront、Amazon Polly を呼び出す Lambda、ブラウザから呼び出す API Gateway HTTP API を作成しています。
S3 バケットはパブリック公開せず、CloudFront の Origin Access Control とバケットポリシーで CloudFront からの取得に限定しています。CloudFront + S3 は今回の主題ではないため、静的 HTML の配信基盤として利用しています。
Lambda 関数では、ブラウザから受け取ったテキストを Amazon Polly の SynthesizeSpeech に渡し、MP3 の音声データを取得します。取得した AudioStream は read() で読み切り、base64 文字列に変換して JSON で返しています。
IAM ロールには、polly:SynthesizeSpeech のみを許可するインラインポリシーを付与しています。
Lambda コードでは、リクエストボディから text、voiceId、engine を取り出します。テキストが空の場合、3,000 文字を超える場合、または想定外のボイスとエンジンの組み合わせが指定された場合は、Amazon Polly を呼び出す前にエラーを返します。
今回の画面で選択できる組み合わせだけを、Lambda 側の許可リストに入れています。
ALLOWED_VOICE_ENGINE = {
('Kazuha', 'neural'),
('Takumi', 'neural'),
('Mizuki', 'standard'),
}
API Gateway HTTP API では、POST /tts を Lambda に AWS_PROXY 統合しています。AWS_PROXY 統合では、API Gateway が受け取ったリクエスト情報を Lambda のイベントとして渡し、Lambda の戻り値を HTTP レスポンスとして返します。
PayloadFormatVersion: '2.0' を指定しているため、Lambda 側では HTTP API の v2 イベント形式でリクエストを受け取ります。今回のコードでは、event.get('body') からリクエストボディを取得しています。
StageName: $default と AutoDeploy: true により、明示的なステージ名を URL に含めずに API を呼び出せます。
CORS は API Gateway HTTP API 側で設定しています。AllowOrigins には CloudFront のドメインを指定しています。
CorsConfiguration:
AllowOrigins:
- !Sub 'https://${Distribution.DomainName}'
AllowMethods:
- POST
- OPTIONS
AllowHeaders:
- Content-Type
MaxAge: 300
index.html
配信用 HTML の全文です。
__API_ENDPOINT__ の部分は、後述の deploy.sh が API Gateway のエンドポイントに置換します。
(クリックで展開)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Polly TTS</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+JP:wght@400;500;600&display=swap');
:root {
--bg:#0e0e10;
--surface:#18181c;
--surface2:#222228;
--border:#2e2e38;
--accent:#f59e0b;
--accent-dim:rgba(245,158,11,.12);
--text:#e8e8f0;
--text-dim:#8888a0;
--green:#22c55e;
--red:#f87171;
--blue:#60a5fa;
--orange:#fb923c;
--sans:'IBM Plex Sans JP','Hiragino Kaku Gothic Pro',sans-serif;
--mono:'IBM Plex Mono','Courier New',monospace;
}
* {
box-sizing:border-box;
margin:0;
padding:0;
}
body {
font-family:var(--sans);
background:var(--bg);
color:var(--text);
min-height:100vh;
padding:32px 20px;
}
.wrap {
max-width:580px;
margin:0 auto;
}
header {
margin-bottom:28px;
}
h1 {
font-size:20px;
font-weight:600;
letter-spacing:-.01em;
display:flex;
align-items:center;
gap:10px;
}
.dot {
width:8px;
height:8px;
border-radius:50%;
background:var(--accent);
}
header p {
margin-top:6px;
font-size:13px;
color:var(--text-dim);
}
.card {
background:var(--surface);
border:1px solid var(--border);
border-radius:10px;
padding:20px;
margin-bottom:14px;
}
.card-label {
font-size:11px;
font-weight:500;
letter-spacing:.1em;
text-transform:uppercase;
color:var(--text-dim);
margin-bottom:14px;
}
.row {
display:flex;
gap:12px;
}
.col {
flex:1;
}
label {
display:block;
font-size:12px;
color:var(--text-dim);
margin-bottom:5px;
}
select,
textarea {
width:100%;
background:var(--surface2);
border:1px solid var(--border);
border-radius:7px;
color:var(--text);
font-size:13px;
padding:9px 12px;
outline:none;
transition:border-color .15s;
appearance:none;
-webkit-appearance:none;
}
select {
font-family:var(--sans);
cursor:pointer;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%238888a0' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat:no-repeat;
background-position:right 12px center;
padding-right:32px;
}
textarea {
font-family:var(--sans);
font-size:15px;
line-height:1.7;
resize:vertical;
min-height:130px;
}
select:focus,
textarea:focus {
border-color:var(--accent);
box-shadow:0 0 0 3px var(--accent-dim);
}
textarea::placeholder {
color:#444460;
}
.char-row {
display:flex;
justify-content:flex-end;
margin-top:5px;
}
.char-count {
font-size:11px;
color:var(--text-dim);
font-family:var(--mono);
}
.char-count.warn {
color:var(--orange);
}
.btn-play {
display:flex;
align-items:center;
justify-content:center;
gap:6px;
width:100%;
border:none;
border-radius:7px;
font-family:var(--sans);
font-size:15px;
font-weight:600;
padding:12px 28px;
background:var(--accent);
color:#0e0e10;
cursor:pointer;
transition:opacity .15s,transform .1s;
margin-top:14px;
}
.btn-play:hover {
opacity:.9;
}
.btn-play:active {
transform:scale(.97);
}
.btn-play:disabled {
opacity:.45;
cursor:not-allowed;
transform:none;
}
.status {
display:none;
font-size:13px;
padding:10px 14px;
border-radius:7px;
margin-top:12px;
border-width:1px;
border-style:solid;
line-height:1.6;
}
.status.loading {
display:block;
background:rgba(96,165,250,.08);
border-color:rgba(96,165,250,.25);
color:var(--blue);
}
.status.success {
display:block;
background:rgba(34,197,94,.08);
border-color:rgba(34,197,94,.25);
color:var(--green);
}
.status.error {
display:block;
background:rgba(248,113,113,.08);
border-color:rgba(248,113,113,.25);
color:var(--red);
}
.status code {
font-family:var(--mono);
font-size:11px;
opacity:.8;
display:block;
margin-top:5px;
word-break:break-all;
}
footer {
text-align:center;
font-size:11px;
color:#333348;
margin-top:28px;
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1><span class="dot"></span>Amazon Polly TTS</h1>
<p>ap-northeast-1 · CloudFront + API Gateway + Lambda</p>
</header>
<div class="card">
<div class="card-label">音声設定</div>
<div class="row">
<div class="col">
<label>ボイス</label>
<select id="voice" onchange="syncEngine()">
<option value="Kazuha">Kazuha — 女性 · Neural</option>
<option value="Takumi">Takumi — 男性 · Neural</option>
<option value="Mizuki">Mizuki — 女性 · Standard</option>
</select>
</div>
<div class="col">
<label>エンジン</label>
<select id="engine">
<option value="neural">neural</option>
<option value="standard">standard</option>
</select>
</div>
</div>
</div>
<div class="card">
<div class="card-label">テキスト</div>
<textarea id="textInput" placeholder="読み上げるテキストを入力…" oninput="updateCount()"></textarea>
<div class="char-row"><span class="char-count" id="charCount">0 / 3000 文字</span></div>
<button class="btn-play" id="playBtn" onclick="generate()">▶ 再生</button>
<div class="status" id="status"></div>
</div>
<footer>CloudFront + S3 + API Gateway + Lambda + Polly</footer>
</div>
<script>
const API = '__API_ENDPOINT__/tts';
let audioCtx = null;
function showStatus(msg, type, detail) {
const el = document.getElementById('status');
const esc = s => String(s)
.replace(/&/g,'&')
.replace(/</g,'<')
.replace(/>/g,'>');
el.className = 'status ' + type;
el.innerHTML = esc(msg) + (detail ? `<code>${esc(String(detail))}</code>` : '');
}
function setLoading(on) {
const btn = document.getElementById('playBtn');
btn.disabled = on;
btn.textContent = on ? '生成中…' : '▶ 再生';
}
function updateCount() {
const n = document.getElementById('textInput').value.length;
const el = document.getElementById('charCount');
el.textContent = `${n} / 3000 文字`;
el.className = 'char-count' + (n > 2700 ? ' warn' : '');
}
function syncEngine() {
const voice = document.getElementById('voice').value;
document.getElementById('engine').value =
['Kazuha','Takumi'].includes(voice) ? 'neural' : 'standard';
}
async function generate() {
const text = document.getElementById('textInput').value;
const voiceId = document.getElementById('voice').value;
const engine = document.getElementById('engine').value;
if (!text.trim()) {
showStatus('テキストを入力してください', 'error');
return;
}
if (text.length > 3000) {
showStatus('3000 文字以内にしてください', 'error');
return;
}
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') {
await audioCtx.resume();
}
setLoading(true);
showStatus('Lambda → Polly に送信中…', 'loading');
try {
const res = await fetch(API, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ text, voiceId, engine })
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.error) {
showStatus('エラー: ' + (data.error || `HTTP ${res.status}`), 'error');
return;
}
const raw = atob(data.audio);
const buf = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) {
buf[i] = raw.charCodeAt(i);
}
const audioBuffer = await audioCtx.decodeAudioData(buf.buffer);
const src = audioCtx.createBufferSource();
src.buffer = audioBuffer;
src.connect(audioCtx.destination);
src.onended = () => showStatus('再生完了', 'success');
src.start(0);
showStatus(`再生中 (${audioBuffer.duration.toFixed(1)} 秒)`, 'success');
} catch (e) {
showStatus('エラー', 'error', e.message);
} finally {
setLoading(false);
}
}
syncEngine();
</script>
</body>
</html>
ボイスの初期値は Kazuha、エンジンの初期値は neural です。ボイスを Mizuki に変更すると、syncEngine() によってエンジンが standard に切り替わります。
JavaScript では、API から返ってきた base64 文字列をバイナリに戻し、Web Audio API で再生しています。
deploy.sh
CloudFormation のデプロイ、Outputs の取得、HTML の S3 アップロードを行うスクリプトです。
(クリックで展開)
#!/bin/bash
set -e
STACK_NAME="polly-tts-demo"
REGION="ap-northeast-1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "Polly TTS 検証環境 デプロイ開始"
echo "スタック: $STACK_NAME / リージョン: $REGION"
echo ""
echo "1/3 CloudFormation スタックを作成/更新中..."
aws cloudformation deploy \
--template-file "$SCRIPT_DIR/template.yaml" \
--stack-name "$STACK_NAME" \
--capabilities CAPABILITY_IAM \
--region "$REGION"
echo "完了"
echo ""
echo "2/3 スタック出力を取得中..."
get_output() {
aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--region "$REGION" \
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
--output text
}
BUCKET_NAME=$(get_output BucketName)
API_ENDPOINT=$(get_output ApiEndpoint)
WEBSITE_URL=$(get_output WebsiteUrl)
echo "バケット: $BUCKET_NAME"
echo "API エンドポイント: $API_ENDPOINT"
echo "Web ページ: $WEBSITE_URL"
echo ""
echo "3/3 index.html をアップロード中..."
sed "s|__API_ENDPOINT__|${API_ENDPOINT}|g" "$SCRIPT_DIR/index.html" > /tmp/polly_index.html
aws s3 cp /tmp/polly_index.html "s3://$BUCKET_NAME/index.html" \
--content-type "text/html; charset=utf-8" \
--region "$REGION"
echo "アップロード完了"
echo ""
echo "デプロイ完了"
echo "$WEBSITE_URL"
deploy.sh では、CloudFormation スタックを作成または更新したあと、スタックの Outputs から S3 バケット名、API Gateway のエンドポイント、CloudFront の URL を取得します。
その後、index.html 内の __API_ENDPOINT__ を API Gateway のエンドポイントに置換し、生成した HTML を S3 にアップロードします。
teardown.sh
検証後の片付け用スクリプトです。
S3 バケットにオブジェクトが残っていると CloudFormation スタック削除が失敗するため、先にバケットを空にしてからスタックを削除します。
(クリックで展開)
#!/bin/bash
set -e
STACK_NAME="polly-tts-demo"
REGION="ap-northeast-1"
echo "Polly TTS 検証環境 削除開始"
echo "スタック: $STACK_NAME / リージョン: $REGION"
echo ""
BUCKET_NAME=$(aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--region "$REGION" \
--query "Stacks[0].Outputs[?OutputKey=='BucketName'].OutputValue" \
--output text 2>/dev/null || true)
if [ -n "$BUCKET_NAME" ] && [ "$BUCKET_NAME" != "None" ]; then
echo "1/2 S3 バケット $BUCKET_NAME を空にしています..."
aws s3 rm "s3://$BUCKET_NAME" --recursive --region "$REGION" || true
else
echo "1/2 S3 バケット名を取得できなかったため、空にする処理をスキップします"
fi
echo ""
echo "2/2 CloudFormation スタックを削除しています..."
aws cloudformation delete-stack \
--stack-name "$STACK_NAME" \
--region "$REGION"
aws cloudformation wait stack-delete-complete \
--stack-name "$STACK_NAME" \
--region "$REGION"
echo "削除完了"
CloudFront ディストリビューションの無効化と削除には数分かかります。stack-delete-complete の待機が終わるまで、そのまま待ちます。
技術的なポイント
今回の構成で気になりやすい点を整理します。
CORS は HTTP API 側で設定する
CloudFront と API Gateway は別オリジンになるため、ブラウザから API Gateway を直接呼び出すには CORS の設定が必要です。
今回のテンプレートでは、API Gateway HTTP API の CorsConfiguration に CloudFront のドメインを指定しています。
CorsConfiguration:
AllowOrigins:
- !Sub 'https://${Distribution.DomainName}'
AllowMethods:
- POST
- OPTIONS
AllowHeaders:
- Content-Type
MaxAge: 300
ブラウザは、別オリジンの API に対して Content-Type: application/json で POST する場合、実際の POST の前に OPTIONS メソッドで「このオリジンから POST してよいか」を確認します。API Gateway HTTP API の CORS 設定を入れておくと、この OPTIONS リクエストへの応答を API Gateway が処理します。
また、実際の POST /tts のレスポンスにも、ブラウザが CORS 判定に使うヘッダーが付与されます。
API Gateway のドキュメントには以下の記載があります。
If you configure CORS for an API, API Gateway ignores CORS headers returned from your backend integration.
API に CORS を設定した場合、API Gateway はバックエンド統合から返された CORS ヘッダーを無視します。
https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-cors.html
そのため、今回の Lambda コードでは Access-Control-Allow-Origin を返していません。CORS は API Gateway 側に寄せています。
音声を base64 で返す選択
Amazon Polly から返る音声データをブラウザに返す方法として、今回は MP3 を base64 文字列に変換し、JSON に含めて返す方式にしました。
候補としては、以下の方式が考えられます。
| 方式 | メリット | デメリット |
|---|---|---|
| base64 で JSON に埋め込む | 成功時とエラー時のレスポンス形式を JSON に統一できる。ブラウザ側で扱いやすい | base64 化によりサイズが増える |
| バイナリとして返す | base64 JSON よりレスポンスサイズを抑えやすい | 成功時とエラー時でレスポンス形式が分かれるため、Lambda とブラウザ側の処理を分ける必要がある |
| S3 に保存して署名付き URL を返す | 長い音声や再利用に向く | Lambda から S3 への保存、署名付き URL 発行、ライフサイクル管理が必要になる |
base64 で JSON に埋め込む方式では、成功時は {"audio": "base64化されたMP3文字列"}、エラー時は {"error": "text is required"} のように返せます。そのため、ブラウザ側では res.json() でレスポンスを読み取り、audio があれば再生し、error があればエラー表示する形にできます。
一方、MP3 をバイナリとして返す場合は、成功時は audio/mpeg、エラー時は application/json のようにレスポンス形式が分かれます。その場合、ブラウザ側では HTTP ステータスや Content-Type を見て、res.arrayBuffer() と res.json() を使い分ける必要があります。
今回は検証用としてシンプルに実装したかったため、成功時とエラー時のレスポンス形式を JSON に統一できる base64 方式を採用しました。ただし、base64 化すると元の MP3 よりレスポンスサイズが大きくなるため、長い音声を扱う場合は S3 に保存して署名付き URL を返す方式も選択肢になります。
今回の構成では音声をストリーミング配信していない
今回の構成では、Polly から返った MP3 を Lambda で全て読み取り、base64 化したうえで JSON として返しています。ブラウザ側でも、decodeAudioData で MP3 全体をデコードしてから再生しています。
そのため、音声データを少しずつ受け取って再生するストリーミング構成ではありません。
今回は短いテキストの読み上げを想定しているため、シンプルさを優先しました。再生開始までの時間を短くしたい場合や、より長い音声を扱う場合は、レスポンスストリーミングや生成した音声ファイルを S3 に保存して署名付き URL を返す構成が選択肢になります。
デプロイ手順
今回は AWS CloudShell から実行します。
AWS マネジメントコンソールにログインし、対象アカウント、対象リージョンで CloudShell を開きます。今回のリージョンは ap-northeast-1 です。
CloudShell 上に以下の 4 ファイルを配置します。
template.yamlindex.htmldeploy.shteardown.sh
ローカルから CloudShell にアップロードする場合は、CloudShell の右上の Actions から Upload file を使って 1 ファイルずつアップロードできます。
以下の画面で、CloudShell の Actions からファイルをアップロードする操作を確認できます。

ファイルを配置したら、deploy.sh と teardown.sh を実行できるように権限を付与します。
chmod +x deploy.sh teardown.sh
続けて、デプロイを実行します。
./deploy.sh
初回は CloudFront ディストリビューションの作成があるため、完了まで数分かかります。
実行すると、以下のように進行ログと S3 バケット名、API Gateway のエンドポイント、CloudFront の URL が出力されます。
Polly TTS 検証環境 デプロイ開始
スタック: polly-tts-demo / リージョン: ap-northeast-1
1/3 CloudFormation スタックを作成/更新中...
Waiting for changeset to be created..
Waiting for stack create/update to complete
Successfully created/updated stack - polly-tts-demo
完了
2/3 スタック出力を取得中...
バケット: polly-tts-demo-webbucket-xxxxxxxxxxxx
API エンドポイント: https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com
Web ページ: https://dxxxxxxxxxxxxx.cloudfront.net
3/3 index.html をアップロード中...
upload: ../../tmp/polly_index.html to s3://polly-tts-demo-webbucket-xxxxxxxxxxxx/index.html
アップロード完了
デプロイ完了
https://dxxxxxxxxxxxxx.cloudfront.net
最後に表示される CloudFront の URL が、配信ページの URL です。
動作確認
CloudFront の URL を Chrome で開き、画面上のテキスト入力欄に以下を入力して再生しました。
こんにちは
以下の画面で、CloudFront 経由で配信されたページを確認できます。ボイスは初期値の Kazuha、エンジンは neural のままです。

再生ボタンをクリックすると、ステータス表示が以下のように変わりました。
Lambda → Polly に送信中…
再生中 (約 1 秒)
再生完了
ここで表示している秒数は、API のレスポンス待ち時間ではなく、ブラウザ側でデコードした音声データの再生時間です。
index.html では、decodeAudioData で得られた AudioBuffer の duration を表示しています。
const audioBuffer = await audioCtx.decodeAudioData(buf.buffer);
showStatus(`再生中 (${audioBuffer.duration.toFixed(1)} 秒)`, 'success');
今回入力したテキストは「こんにちは」のみなので、再生時間は約 1 秒でした。スピーカーから日本語女性ボイスで「こんにちは」と読み上げられることを確認できました。
検証用として割り切った点
今回の構成では、/tts に認証を付けていません。
CloudFront のドメインからのみ CORS を許可していますが、CORS はブラウザの制御であり、API 自体の認証ではありません。そのため、API Gateway のエンドポイントを直接呼び出せる利用者であれば、ブラウザ以外からもリクエストできます。
常用や公開を前提にする場合は、以下のような対策を検討します。
- API Gateway の認証を追加する
- Amazon Cognito や JWT オーソライザーを使う
- AWS WAF を組み合わせる
- レート制限を設定する
- CloudWatch Logs やメトリクスで利用状況を監視する
後片付け
検証が終わったら、CloudFront や Lambda などのリソースを削除します。
./teardown.sh
S3 バケット内のオブジェクトを削除したうえで、CloudFormation スタックを削除します。
CloudFront ディストリビューションの削除には数分かかるため、スクリプトが完了するまで待ちます。
まとめ
API Gateway HTTP API と Lambda 経由で Amazon Polly を呼び出し、ブラウザ入力の日本語テキストを音声合成して再生できました。
検証用としては、音声を base64 化して JSON で返す方式にすると、S3 への音声保存なしでシンプルに実装できました。
一方で、/tts には認証を付けていないため、常用する場合は認証やレート制限を追加する必要があります







