※AmplifyであればPredictionsとかを使うと良いと思うのですが、今までの環境はほとんど手動でコンポーネントを紐づけているので、今回も手動で紐づけていきます。今後全部自動で環境を作ってみたいと思います。
これの続きです。
用語
Amazon Transcribe
Amazon Transcribe は、音声を自動で文字起こしする AWS の音声認識サービスです。
音声ファイルやストリーミング音声からテキスト化でき、話者分離やタイムスタンプ、専門用語のカスタム語彙にも対応します。
コールセンターの通話記録、会議の議事録作成、字幕生成などに使われます。
Cognito identity pools(Cognito ID プール)
Amazon Cognito の **ID プール(Identity Pools)**は、アプリのユーザーに対して **一時的な AWS 認証情報(IAM ロール)**を払い出し、AWS サービスへ直接アクセスさせる仕組みです。
Cognito ユーザープールのログインや、Google/Apple などの外部 ID プロバイダー、SAML/OIDC と連携した フェデレーションに対応します。
ログインなしの ゲスト(匿名)アクセスも扱え、ユーザー種別ごとに権限(ロール)を分けて制御できます。
環境イメージ
今までの環境にAWS TranscribeとCognito IDプールを追加します。
音声入力をする場合、IDPoolからTranscribeを使うためのIAMRoleを一時的に払い出してもらう事で、Transcribeを利用します。
ハンズオン
IDプールの作成
AWSマネジメントコンソールを開いて、Cognitoの管理画面を開きます。
IDプールを選択して、右上の作成ボタンを押下します。

認証の設定ページが表示されます。
ユーザアクセスは認証されたアクセスを選択。
認証されたIDソースはAmazon Cognitoユーザプールとします。

認証されたユーザにAmazon Transcribeを使っていいよと言う権限を付与するためのRoleを作成します。
SentimentAppAuthenticatedUserRoleForTranscribeという名前で作成しました。Policyを付けるのはここでは行いません。

ユーザプールIDとアプリクライアントIDは紐づけを行いたいものを指定してあげます。
ロールはデフォルト、クレームマッピングもデフォルトを選択して、次に進みます。

IDプールの名前を決めます。今回はSentimentAppAuthenticatedUserIDPoolとしました。
この次に、確認画面が表示されるので確認して作成していきます。

IAMRoleの中身を調整する
先ほどIDプールを作成した時に作成したRoleにPolicyを追加します。
具体的にはTranscribeを使えるように設定を入れていきます。
アクセス許可を追加を押下します。

AmazonTranscribeFullAccessという名前のPolicyを選択してアクセス許可を追加を押下します。

Reactのコード修正
「Webブラウザからマイクの音声を拾って、AWSに送信し、文字に変換してもらう」 ために必要な、3つの役割を持った部品(ライブラリ)をインストールします。
C:\Users\ohtsu\Documents\AWS\SentimentRepo>npm install @aws-sdk/client-transcribe-streaming @aws-sdk/credential-providers microphone-stream@6.0.1
C:\Users\ohtsu\Documents\AWS\SentimentRepo>npm install vite-plugin-node-polyfills
プロジェクトフォルダ直下の.env.localファイルに以下を入力します。
IDプールのIDはCognitoの画面で確認することが可能です。
.env.local
# API GatewayのURL設定
VITE_API_BASE_URL=https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com
# ユーザープール設定
VITE_USER_POOL_ID=ap-northeast-1_xxxxxxxxx
VITE_USER_POOL_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxx
# 音声入力の為の環境変数
VITE_COGNITO_IDENTITY_POOL_ID=ap-northeast-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
VITE_AWS_REGION=ap-northeast-1
同じくプロジェクトフォルダ直下のindex.htmlを修正します。
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>感情分析デモ</title>
<!-- ★以下の3行を追加してください -->
<script>
window.global = window;
</script>
<!-- 追加ここまで -->
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
さらにvite.config.jsを以下のように修正します。
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// ★追加: ポリフィルプラグインの読み込み
import { nodePolyfills } from 'vite-plugin-node-polyfills'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
// ★追加: これを入れることで stream や buffer が使えるようになります
nodePolyfills(),
],
define: {
// global がない問題をここでも念のため解決
'global': {},
},
})
src/main.jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import { Amplify } from 'aws-amplify';
// 環境変数の読み込み
const userPoolId = import.meta.env.VITE_USER_POOL_ID;
const userPoolClientId = import.meta.env.VITE_USER_POOL_CLIENT_ID;
// Cognitoの接続設定
Amplify.configure({
Auth: {
Cognito: {
userPoolId: userPoolId,
userPoolClientId: userPoolClientId,
}
}
});
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
src/hooks/useTranscribe.js
import { useState } from "react";
import { fetchAuthSession } from "aws-amplify/auth";
import { TranscribeStreamingClient, StartStreamTranscriptionCommand } from "@aws-sdk/client-transcribe-streaming";
import MicrophoneStream from "microphone-stream";
import { fromCognitoIdentityPool } from "@aws-sdk/credential-providers";
const IDENTITY_POOL_ID = import.meta.env.VITE_COGNITO_IDENTITY_POOL_ID;
const REGION = import.meta.env.VITE_AWS_REGION || "ap-northeast-1";
export function useTranscribe(onTextUpdate) {
const [isRecording, setIsRecording] = useState(false);
const [controller, setController] = useState(null);
async function startRecording() {
if (!IDENTITY_POOL_ID) {
alert("環境変数 VITE_COGNITO_IDENTITY_POOL_ID が設定されていません。");
return;
}
setIsRecording(true);
const abortController = new AbortController();
setController(abortController);
let micStream = null;
try {
const session = await fetchAuthSession();
const idToken = session.tokens?.idToken?.toString();
if (!idToken) throw new Error("認証トークンが取得できません");
// 1. マイク準備
micStream = new MicrophoneStream();
micStream.setStream(await window.navigator.mediaDevices.getUserMedia({ video: false, audio: true }));
// 2. 正しいサンプリングレートを取得
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const sampleRate = audioContext.sampleRate;
audioContext.close();
const client = new TranscribeStreamingClient({
region: REGION,
credentials: fromCognitoIdentityPool({
clientConfig: { region: REGION },
identityPoolId: IDENTITY_POOL_ID,
logins: {
[`cognito-idp.${REGION}.amazonaws.com/${session.tokens.idToken.payload.iss.split('/').pop()}`]: idToken
}
})
});
// 3. 音声ストリーム作成(ここで形式変換を行う)
const audioStream = async function* () {
for await (const chunk of micStream) {
if (abortController.signal.aborted) break;
// ★重要: ブラウザの音声(Float32)をAWS用(Int16)に変換する
// chunk は Buffer だが、中身は Float32Array なので変換が必要
const float32Arr = new Float32Array(chunk.buffer, chunk.byteOffset, chunk.byteLength / 4);
const int16Arr = new Int16Array(float32Arr.length);
for (let i = 0; i < float32Arr.length; i++) {
const s = Math.max(-1, Math.min(1, float32Arr[i]));
int16Arr[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
yield { AudioEvent: { AudioChunk: new Uint8Array(int16Arr.buffer) } };
}
};
// 4. 送信
const command = new StartStreamTranscriptionCommand({
LanguageCode: "ja-JP",
MediaEncoding: "pcm",
MediaSampleRateHertz: sampleRate, // 自動取得したレートを使う
AudioStream: audioStream()
});
const response = await client.send(command, { abortSignal: abortController.signal });
// 5. 受信処理
for await (const event of response.TranscriptResultStream) {
if (abortController.signal.aborted) break;
if (event.TranscriptEvent) {
const results = event.TranscriptEvent.Transcript.Results;
if (results.length > 0 && !results[0].IsPartial) {
const transcript = results[0].Alternatives[0].Transcript;
onTextUpdate((prev) => prev + transcript);
}
}
}
} catch (e) {
// 中断以外のエラーのみアラート
if (e.name !== 'AbortError' && !abortController.signal.aborted) {
console.error(e);
alert("音声入力エラー: " + (e instanceof Error ? e.message : String(e)));
}
} finally {
if (micStream) micStream.stop();
setIsRecording(false);
setController(null);
}
}
function stopRecording() {
if (controller) {
controller.abort();
}
}
return { isRecording, startRecording, stopRecording };
}
src/components/MainApp.jsx
import { useMemo, useState } from "react";
import { fetchAuthSession } from "aws-amplify/auth";
import { useTranscribe } from "../hooks/useTranscribe";
import { ResultCard } from "./ResultCard";
// 環境設定
const API_BASE = (import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/$/, "");
async function safeReadText(res) {
try {
return await res.text();
} catch {
return "";
}
}
export default function MainApp({ signOut, user }) {
const [text, setText] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [result, setResult] = useState(null);
const [apiStatus, setApiStatus] = useState("unchecked");
const { isRecording, startRecording, stopRecording } = useTranscribe(setText);
const canSubmit = useMemo(() => {
const len = text.trim().length;
return !loading && len > 0 && len <= 1000;
}, [text, loading]);
const apiStatusLabel =
!API_BASE
? "未設定"
: apiStatus === "ok"
? "接続OK"
: apiStatus === "ng"
? "接続NG"
: "未確認";
async function onSubmit(e) {
e.preventDefault();
setError("");
setResult(null);
if (!API_BASE) {
setApiStatus("unset");
setError("API の設定がありません(VITE_API_BASE_URL を確認してください)。");
return;
}
setLoading(true);
try {
const session = await fetchAuthSession();
const token = session.tokens?.idToken?.toString();
if (!token) throw new Error("認証トークンの取得に失敗しました");
const res = await fetch(`${API_BASE}/analyze`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token,
},
body: JSON.stringify({ text: text.trim() }),
});
if (!res.ok) {
const bodyText = await safeReadText(res);
throw new Error(bodyText || `HTTP ${res.status}`);
}
const data = await res.json();
setResult(data);
setApiStatus("ok");
} catch (err) {
setApiStatus("ng");
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
return (
<div className="page">
<main className="card">
<div className="header">
<div>
<h1 className="title">感情分析デモ</h1>
<p className="subtitle">
ユーザー: {user?.signInDetails?.loginId || user?.username}
</p>
<p className="subtitle" style={{ marginTop: "4px" }}>
API接続: {apiStatusLabel}
</p>
</div>
<div>
<button
onClick={signOut}
className="btn"
style={{
backgroundColor: "#4b5563",
fontSize: "12px",
padding: "8px 12px",
}}
>
ログアウト
</button>
</div>
</div>
<form onSubmit={onSubmit} className="form">
<textarea
className="textarea"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="ここに文章を入力してください(最大1000文字)"
/>
{/* 音声入力ボタンと送信ボタン */}
<div className="row" style={{ alignItems: 'center' }}>
<button
type="button"
className="btn"
// ★変更: 録音中なら停止、そうでなければ開始
onClick={isRecording ? stopRecording : startRecording}
style={{
backgroundColor: isRecording ? '#dc2626' : '#2563eb',
marginRight: 'auto', // 左寄せ
display: 'flex',
alignItems: 'center',
gap: '6px',
cursor: 'pointer'
}}
>
<span style={{ fontSize: '1.2em' }}>{isRecording ? "⏹" : "🎤"}</span>
{isRecording ? "停止する" : "音声入力"}
</button>
<small className="counter" style={{ marginRight: '10px' }}>{text.length} / 1000</small>
<button type="submit" className="btn" disabled={!canSubmit}>
{loading ? "分析中..." : "分析する"}
</button>
</div>
<p className="hint">
改行しやすいように Enter 送信ではなくボタン送信にしています。
</p>
</form>
{error && <div className="alert alert--error">{error}</div>}
{/* 結果表示コンポーネント */}
<ResultCard result={result} />
</main>
</div>
);
}
動作確認
この状態でnpm run devを実行してアプリを仮想サーバで起動します。
C:\Users\ohtsu\Documents\AWS\SentimentRepo>npm run dev
> sentimentrepo@0.0.0 dev
> vite
21:45:37 [vite] (client) Re-optimizing dependencies because lockfile has changed
VITE v6.4.1 ready in 1376 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h + enter to show help
音声を使って入力をすることが出来ました。
停止するボタンを押下して、マイクからの入力を止めます。

本番環境へデプロイ
Amplifyの環境変数を以下のようにします。
.env.localと内容は全く一緒で構いません。

C:\Users\ohtsu\Documents\AWS\SentimentRepo>git add .
warning: in the working copy of 'src/components/ResultCard.jsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'src/hooks/useTranscribe.js', LF will be replaced by CRLF the next time Git touches it
C:\Users\ohtsu\Documents\AWS\SentimentRepo>git commit -m "ver1.2"
[main 90667ee] ver1.2
8 files changed, 3961 insertions(+), 787 deletions(-)
create mode 100644 src/components/ResultCard.jsx
create mode 100644 src/hooks/useTranscribe.js
C:\Users\ohtsu\Documents\AWS\SentimentRepo>git push origin main
Enumerating objects: 22, done.
Counting objects: 100% (22/22), done.
Delta compression using up to 20 threads
Compressing objects: 100% (12/12), done.
Writing objects: 100% (13/13), 22.34 KiB | 5.58 MiB/s, done.
Total 13 (delta 8), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (8/8), completed with 7 local objects.
To https://github.com/ohtsuka-shota/SentimentRepo.git
bfdd3bd..90667ee main -> main











