0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

感情分析Webアプリに音声入力機能を追加する

Last updated at Posted at 2026-01-31

※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を利用します。

aws04-ページ17.drawio.png

ハンズオン

IDプールの作成

AWSマネジメントコンソールを開いて、Cognitoの管理画面を開きます。
IDプールを選択して、右上の作成ボタンを押下します。
image.png

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

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

ユーザプールIDとアプリクライアントIDは紐づけを行いたいものを指定してあげます。
ロールはデフォルト、クレームマッピングもデフォルトを選択して、次に進みます。
screencapture-ap-northeast-1-console-aws-amazon-cognito-v2-identity-identity-pools-create-2026-01-31-21_06_54.png

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

作成出来たことを確認します。
image.png

IAMRoleの中身を調整する

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

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

RoleにPolicyが追加されたことを確認します。
image.png

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

ユーザでアクセスします。
image.png

音声入力を押下します。
image.png

アクセス許可を求められます。
今回のみ許可を押下します。
image.png

音声を使って入力をすることが出来ました。
停止するボタンを押下して、マイクからの入力を止めます。
image.png

分析することも問題なさそうです。
image.png

本番環境へデプロイ

Amplifyの環境変数を以下のようにします。
.env.localと内容は全く一緒で構いません。
2026013102.png

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

Amplifyでデプロイが走ります。問題なくデプロイされることを確認しましょう。
image.png
image.png

ログイン・音声入力も本番環境で問題なさそうです。
image.png

Comprehendとの連携も問題なし。
image.png

DynamoDBにも入力と結果が出力されています。全体の動きもバグって無さそうです。
image.png

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?