AWS Transcribe の文字起こし結果を Amazon Comprehend へ連携し、日本語音声の感情分析を行うまでの手順と JSON の読み解きポイントをまとめます。
全体フロー (シーケンス図)
Transcribe JSON ― channel_label と speaker_label の違い
AWS Transcribe には 2 通り の話者区別モードがあります。
| モード | JSON パス | 例 | 特徴 | 
|---|---|---|---|
| マルチチャネル | results.items[].channel_label | 
"ch_1" | 
通常は コールセンター録音 等、左右で別トラックになっているデータ向け。 | 
| 話者ダイアライゼーション | results.speaker_labels.segments[].speaker_label | 
"spk_0" | 
1 トラック録音から話者を自動推定。 | 
1‑1. channel_label サンプル
{
  "results": {
    "items": [
      {
        "start_time": "0.25",
        "end_time": "0.74",
        "alternatives": [{ "content": "こんにちは" }],
        "type": "pronunciation",
        "channel_label": "ch_0"   // ★
      }
    ]
  }
}
話者推定が無い ため、items を start_time でソート → channel_label が切り替わる箇所でセグメント化 します。
1‑2. speaker_label サンプル
{
  "results": {
    "speaker_labels": {
      "segments": [
        {
          "speaker_label": "spk_1",   // ★
          "start_time": "3.12",
          "end_time": "4.58",
          "items": [{ "start_time": "3.12" }, { "start_time": "3.48" }]
        }
      ]
    },
    "items": [
      { "start_time": "3.12", "alternatives": [{ "content": "お世話に" }], "type": "pronunciation" },
      { "start_time": "3.48", "alternatives": [{ "content": "なります" }], "type": "pronunciation" }
    ]
  }
}
この場合は segments が作られているのでマッピングがシンプルです。
MP3 アップロード & Transcribe ジョブ作成
フロントエンドから multipart/form-data で MP3 を /api/upload-audio へ送信し、バックエンドは S3 にアップロード後 StartTranscriptionJob を実行します。
try {
  await this.client.send(
    new StartTranscriptionJobCommand({
      TranscriptionJobName: jobName,
      Media: { MediaFileUri: mediaUri },
      OutputBucketName: outputBucket,
      OutputKey: outputKey,
      LanguageCode: 'ja-JP',
      MediaFormat: 'mp3',
      Settings: forceMono
        ? { ShowSpeakerLabels: true, MaxSpeakerLabels: 2 }
        : { ChannelIdentification: true },
    }),
  );
} catch (e: any) {
  logger.error('Failed to start transcription job: %s', e.message);
  throw e;
}
Webhook 受信 & 感情分析ユースケース (AnalyzeSentimentAsyncUseCase)
Webhook で通知された transcripts/YYYYMMDD/uuid.json を処理し、以下を実行します。
- 文字起こし JSON をロード
 - セグメント抽出 — channel/speaker モードを自動判定
 - 
.txtと.meta.jsonを S3 へアップロード - 
StartBatchSentimentJob を発行 ➜ 
jobId返却 - ポーリング でジョブ完了を監視
 
詳細ロジックは公式を確認してください Amazon Comprehend Insightsの非同期分析
Comprehend 出力 output.tar.gz の解析
sentiments/YYYYMMDD/uuid/
├── output/output.tar.gz  ← 単一・無拡張子ファイル内に NDJSON
└── uuid.txt               ← インプット
行順が保証されない
Line フィールドで必ずソート:
lines.sort((a, b) => a.Line - b.Line);
meta.json とのマージ
meta.json は id / speaker / startTimeSec / text などを保持。行番号ソート後に :
const segments = meta.map((m, i) => ({
  ...m,
  sentiment: lines[i]?.Sentiment ?? 'UNKNOWN',
  score:     lines[i]?.SentimentScore ?? defaultScore,
}));
実装例
import { fetch } from 'undici';
import * as zlib from 'zlib';
import * as tar from 'tar-stream';
import { logger } from '~/infrastructure/logger/logger';
async function fetchAndParseSentimentOutput(uri: string) {
  logger.debug('Fetching sentiment output from: %s', uri);
  // 1. ダウンロード
  const res = await fetch(uri);
  if (!res.ok) throw new Error(`Failed to fetch output.tar.gz: ${res.status}`);
  const arrayBuffer = await res.arrayBuffer();
  const buffer = Buffer.from(arrayBuffer);
  // 2. 解凍+tar 展開
  const extract = tar.extract();
  const sentiments: Array<{ Line: number; Sentiment: string; SentimentScore: any }> = [];
  extract.on('entry', async (header, stream, next) => {
    if (header.name.endsWith('.ndjson')) {
      let chunk = '';
      stream.on('data', (buf) => (chunk += buf.toString()));
      stream.on('end', () => {
        for (const line of chunk.split('\n').filter((l) => l.trim())) {
          try {
            sentiments.push(JSON.parse(line));
          } catch {}
        }
        next();
      });
    } else {
      // テキスト等、不要なファイルはスキップ
      stream.resume();
      stream.on('end', next);
    }
  });
  // zlib で gunzip → tar-stream で展開
  await new Promise<void>((resolve, reject) => {
    extract.on('finish', resolve);
    extract.on('error', reject);
    zlib.gunzip(buffer, (err, result) => {
      if (err) return reject(err);
      extract.end(result);
    });
  });
  // 3. Line フィールドでソート
  sentiments.sort((a, b) => a.Line - b.Line);
  logger.debug('Parsed %d sentiment records', sentiments.length);
  return sentiments;
}
まとめ
- Transcribe の channel_label ( ch_1 ) と speaker_label ( spk_1 ) によって JSON 構造が異なる 点を押さえる
 - Comprehend へ渡す前に 1 行 1 発話テキスト + メタ情報 の 2 本構成に整形
 - output.tar.gz は 単一 NDJSON ファイル。行順はソートで補正
 
