1
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?

はじめに

iPhoneの「ボイスメモ」で録音した音声を、あとから文字に起こしたいことがあります。

短いメモであれば聞き直してもよいのですが、講演や研究発表の録音になると、最初から最後まで再生して内容を確認するのは大変です。

そこで今回は、Zoom AI Servicesの Scribe API を使って、iPhoneのボイスメモとして保存されたM4Aファイルを文字起こししてみました。

Zoom Scribe APIは、音声をテキストへ変換するAPIです。1ファイルを同期処理するFast modeと、大量のファイルを処理するBatch modeが用意されています。今回は手元にある1つの音声ファイルを処理するため、Fast modeを使用します。(Zoom)

今回の処理は次の流れです。

iPhoneのボイスメモで録音
        ↓
M4AファイルをMacへ転送
        ↓
PythonからZoom Scribe APIへ送信
        ↓
文字起こし結果をJSON・TXT・CSVで保存

今回試した環境

項目 内容
録音端末 iPhone
録音アプリ ボイスメモ
音声形式 M4A
ファイルサイズ 6.97 MB
実行環境 macOS
言語 日本語
使用言語 Python
API Zoom AI Services Scribe API
処理方式 Fast mode

Zoom Scribe APIでは、WAV、MP3、M4A、MP4などの音声形式が案内されています。iPhoneのボイスメモはM4Aで保存されるため、今回はWAVへの変換を行わずに処理しました。(Zoom)

Zoom Scribe APIの認証情報を取得する

Zoom AI Servicesでは、Build Platformで取得したAPI KeyとAPI Secretを使い、JWTを生成してAPIを呼び出します。

動作確認だけであれば、Build Platformの画面にあるView JWT Tokenから、一時的なJWTをコピーすることもできます。(Zoom)

今回の環境では、Zoomの20 Credits Free Planを追加したあと、CPaaS用アカウントの管理画面を開きました。

画面構成はアカウントによって多少異なりますが、公式ドキュメントでは次の経路が案内されています。(Zoom)

Zoom Web Portal
→ ADMIN
→ Plans and Billing
→ Plan Management
→ Advanced
→ Zoom CPaaS
→ Universal CreditのManage
→ CPaaSアカウント
→ Build App
→ API keys

API keysには次の項目があります。

API Key
API Secret
View JWT Token

Scribe APIで利用するのは、SDK Keyではなく、API keysに表示されるAPI KeyとAPI Secretです。

JWTはAPIリクエストのAuthorizationヘッダーへBearerトークンとして設定します。JWTのペイロードにはissiatexpが必要で、HS256を使ってAPI Secretで署名します。(Zoom)

プロジェクトを作成する

作業用ディレクトリを作成します。

mkdir zoom-scribe-voice-memo
cd zoom-scribe-voice-memo

Pythonの仮想環境を作成します。

python3 -m venv .venv
source .venv/bin/activate

必要なライブラリをインストールします。

pip install requests PyJWT python-dotenv

今回は次の構成にしました。

zoom-scribe-voice-memo/
├── .env
├── .gitignore
├── requirements.txt
└── transcribe.py

requirements.txt

requirements.txtを作成します。

requests
PyJWT
python-dotenv

次のコマンドでもインストールできます。

pip install -r requirements.txt

APIキーを環境変数へ設定する

.envを作成します。

ZOOM_API_KEY=取得したAPI Key
ZOOM_API_SECRET=取得したAPI Secret

View JWT Tokenで取得した一時JWTを使用する場合は、次のように設定します。

ZOOM_JWT_TOKEN=コピーしたJWT

今回のコードでは、ZOOM_JWT_TOKENが設定されている場合は、その値を優先して使用します。

設定されていない場合は、ZOOM_API_KEYZOOM_API_SECRETからJWTを生成します。

#!/usr/bin/env python3
"""iPhoneのボイスメモをZoom Scribe APIで文字起こしする。"""

from __future__ import annotations

import argparse
import csv
import json
import os
import sys
import time
from pathlib import Path
from typing import Any

import jwt
import requests
from dotenv import load_dotenv


API_URL = "https://api.zoom.us/v2/aiservices/scribe/transcribe"

SUPPORTED_SUFFIXES = {
    ".m4a",
    ".mp3",
    ".mp4",
    ".wav",
    ".webm",
}

MIME_TYPES = {
    ".m4a": "audio/mp4",
    ".mp3": "audio/mpeg",
    ".mp4": "video/mp4",
    ".wav": "audio/wav",
    ".webm": "audio/webm",
}


class ZoomScribeError(RuntimeError):
    """Zoom Scribe APIの処理に失敗した場合の例外。"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "iPhoneのボイスメモなどの音声ファイルを"
            "Zoom Scribe APIで文字起こしします。"
        )
    )

    parser.add_argument(
        "audio",
        type=Path,
        help="入力する音声ファイル",
    )

    parser.add_argument(
        "--language",
        default="ja-JP",
        help="音声の言語。既定値: ja-JP",
    )

    parser.add_argument(
        "--output-dir",
        type=Path,
        default=Path("result"),
        help="出力先。既定値: result",
    )

    parser.add_argument(
        "--word-timestamps",
        action="store_true",
        help="単語単位のタイムスタンプを取得します。",
    )

    parser.add_argument(
        "--channel-separation",
        action="store_true",
        help="ステレオの左右チャンネルを分けて処理します。",
    )

    parser.add_argument(
        "--timeout",
        type=int,
        default=7200,
        help="APIの応答を待つ最大秒数。既定値: 7200",
    )

    return parser.parse_args()


def validate_audio_file(path: Path) -> Path:
    """入力された音声ファイルを確認する。"""

    path = path.expanduser().resolve()

    if not path.is_file():
        raise ZoomScribeError(
            f"音声ファイルが見つかりません: {path}"
        )

    suffix = path.suffix.lower()

    if suffix not in SUPPORTED_SUFFIXES:
        supported = ", ".join(sorted(SUPPORTED_SUFFIXES))

        raise ZoomScribeError(
            f"未対応のファイル形式です: {suffix or '拡張子なし'}\n"
            f"対応形式: {supported}"
        )

    if path.stat().st_size == 0:
        raise ZoomScribeError(
            "音声ファイルのサイズが0バイトです。"
        )

    return path


def generate_zoom_jwt() -> str:
    """環境変数からJWTを取得または生成する。"""

    copied_token = os.getenv(
        "ZOOM_JWT_TOKEN",
        "",
    ).strip()

    if copied_token:
        return copied_token

    api_key = os.getenv(
        "ZOOM_API_KEY",
        "",
    ).strip()

    api_secret = os.getenv(
        "ZOOM_API_SECRET",
        "",
    ).strip()

    if not api_key or not api_secret:
        raise ZoomScribeError(
            ".envにZOOM_JWT_TOKENを設定するか、"
            "ZOOM_API_KEYとZOOM_API_SECRETを設定してください。"
        )

    now = int(time.time())

    payload = {
        "iss": api_key,
        "iat": now - 30,
        "exp": now + 3600,
    }

    token = jwt.encode(
        payload,
        api_secret,
        algorithm="HS256",
    )

    if isinstance(token, bytes):
        return token.decode("utf-8")

    return token


def transcribe_audio(
    audio_path: Path,
    *,
    language: str,
    word_timestamps: bool,
    channel_separation: bool,
    timeout: int,
) -> dict[str, Any]:
    """音声ファイルをZoom Scribe APIへ送信する。"""

    token = generate_zoom_jwt()

    config = {
        "language": language,
        "word_time_offsets": word_timestamps,
        "channel_separation": channel_separation,
    }

    mime_type = MIME_TYPES.get(
        audio_path.suffix.lower(),
        "application/octet-stream",
    )

    headers = {
        "Authorization": f"Bearer {token}",
    }

    try:
        with audio_path.open("rb") as audio_file:
            response = requests.post(
                API_URL,
                headers=headers,
                files={
                    "file": (
                        audio_path.name,
                        audio_file,
                        mime_type,
                    ),
                },
                data={
                    "config": json.dumps(
                        config,
                        ensure_ascii=False,
                    ),
                },
                timeout=(30, timeout),
            )

    except requests.Timeout as exc:
        raise ZoomScribeError(
            f"Zoom Scribe APIが{timeout}秒以内に"
            "応答しませんでした。"
        ) from exc

    except requests.RequestException as exc:
        raise ZoomScribeError(
            f"Zoom Scribe APIへの接続に失敗しました: {exc}"
        ) from exc

    if not response.ok:
        try:
            response_body = json.dumps(
                response.json(),
                ensure_ascii=False,
                indent=2,
            )
        except ValueError:
            response_body = response.text

        hints = {
            400: "音声ファイル、拡張子、configを確認してください。",
            401: "JWTまたはAPIキーの有効期限を確認してください。",
            403: "AI Servicesのプランや権限を確認してください。",
            413: "音声ファイルのサイズを確認してください。",
            415: "音声形式またはMIME Typeを確認してください。",
            429: "APIのレート制限に達した可能性があります。",
        }

        hint = hints.get(
            response.status_code,
            "Zoom側のレスポンスを確認してください。",
        )

        raise ZoomScribeError(
            "Zoom Scribe APIがエラーを返しました。\n"
            f"HTTP {response.status_code}\n"
            f"{hint}\n"
            "--- response ---\n"
            f"{response_body}"
        )

    try:
        data: dict[str, Any] = response.json()
    except ValueError as exc:
        raise ZoomScribeError(
            "APIレスポンスをJSONとして読み取れませんでした。"
        ) from exc

    if not isinstance(data.get("result"), dict):
        raise ZoomScribeError(
            "APIレスポンスにresultがありません。\n"
            + json.dumps(
                data,
                ensure_ascii=False,
                indent=2,
            )
        )

    return data


def format_timestamp(
    seconds: float | int | None,
) -> str:
    """秒をHH:MM:SS.mmm形式へ変換する。"""

    if seconds is None:
        return "--:--:--.---"

    milliseconds_total = max(
        0,
        round(float(seconds) * 1000),
    )

    hours, remainder = divmod(
        milliseconds_total,
        3_600_000,
    )

    minutes, remainder = divmod(
        remainder,
        60_000,
    )

    seconds_value, milliseconds = divmod(
        remainder,
        1000,
    )

    return (
        f"{hours:02d}:"
        f"{minutes:02d}:"
        f"{seconds_value:02d}."
        f"{milliseconds:03d}"
    )


def save_results(
    data: dict[str, Any],
    output_dir: Path,
) -> dict[str, Path]:
    """APIレスポンスをファイルへ保存する。"""

    output_dir = output_dir.expanduser().resolve()
    output_dir.mkdir(
        parents=True,
        exist_ok=True,
    )

    result = data.get("result", {})
    transcript = str(
        result.get("text_display", "")
    ).strip()

    segments = result.get("segments") or []

    json_path = output_dir / "transcript.json"
    text_path = output_dir / "transcript.txt"
    timestamp_path = (
        output_dir / "transcript_with_timestamps.txt"
    )
    words_path = output_dir / "words.csv"

    json_path.write_text(
        json.dumps(
            data,
            ensure_ascii=False,
            indent=2,
        )
        + "\n",
        encoding="utf-8",
    )

    text_path.write_text(
        transcript + "\n",
        encoding="utf-8",
    )

    timestamp_lines: list[str] = []
    word_rows: list[dict[str, Any]] = []

    for segment_index, segment in enumerate(
        segments,
        start=1,
    ):
        start = format_timestamp(
            segment.get("start")
        )
        end = format_timestamp(
            segment.get("end")
        )

        speaker = segment.get("speaker")
        channel = segment.get("channel")
        text = str(
            segment.get("text", "")
        ).strip()

        labels: list[str] = []

        if speaker is not None:
            labels.append(str(speaker))

        if channel is not None:
            labels.append(f"channel_{channel}")

        label_text = (
            f" [{' / '.join(labels)}]"
            if labels
            else ""
        )

        timestamp_lines.append(
            f"[{start} - {end}]"
            f"{label_text} {text}"
        )

        for word_index, word in enumerate(
            segment.get("words") or [],
            start=1,
        ):
            word_rows.append(
                {
                    "segment_index": segment_index,
                    "word_index": word_index,
                    "word": word.get("word", ""),
                    "start_sec": word.get("start", ""),
                    "end_sec": word.get("end", ""),
                    "speaker": speaker or "",
                    "channel": (
                        ""
                        if channel is None
                        else channel
                    ),
                }
            )

    timestamp_path.write_text(
        (
            "\n".join(timestamp_lines) + "\n"
            if timestamp_lines
            else ""
        ),
        encoding="utf-8",
    )

    if word_rows:
        with words_path.open(
            "w",
            encoding="utf-8-sig",
            newline="",
        ) as file:
            writer = csv.DictWriter(
                file,
                fieldnames=list(
                    word_rows[0].keys()
                ),
            )
            writer.writeheader()
            writer.writerows(word_rows)

    paths = {
        "json": json_path,
        "text": text_path,
        "timestamps": timestamp_path,
    }

    if word_rows:
        paths["words"] = words_path

    return paths


def main() -> int:
    load_dotenv()
    args = parse_args()

    try:
        audio_path = validate_audio_file(
            args.audio
        )

        file_size_mb = (
            audio_path.stat().st_size
            / 1024
            / 1024
        )

        print(f"入力ファイル : {audio_path}")
        print(
            f"ファイル形式 : "
            f"{audio_path.suffix.lower()}"
        )
        print(
            f"ファイル容量 : "
            f"{file_size_mb:.2f} MB"
        )
        print(f"言語         : {args.language}")
        print(
            "Zoom Scribe APIへ送信しています..."
        )

        started_at = time.perf_counter()

        response_data = transcribe_audio(
            audio_path,
            language=args.language,
            word_timestamps=(
                args.word_timestamps
            ),
            channel_separation=(
                args.channel_separation
            ),
            timeout=args.timeout,
        )

        elapsed = (
            time.perf_counter() - started_at
        )

        paths = save_results(
            response_data,
            args.output_dir,
        )

        transcript = str(
            response_data
            .get("result", {})
            .get("text_display", "")
        ).strip()

        print("\n--- 文字起こし結果 ---")
        print(
            transcript
            or "文字起こし結果が空でした。"
        )

        print("\n--- 実行情報 ---")
        print(
            "リクエストID : "
            f"{response_data.get('request_id', '不明')}"
        )
        print(
            "音声時間     : "
            f"{response_data.get('duration_sec', '不明')}"
        )
        print(
            f"API処理時間  : {elapsed:.2f}"
        )

        print("\n--- 保存先 ---")

        for name, path in paths.items():
            print(f"{name:10s}: {path}")

        return 0

    except ZoomScribeError as exc:
        print(
            f"エラー: {exc}",
            file=sys.stderr,
        )
        return 1

    except KeyboardInterrupt:
        print(
            "\n処理を中断しました。",
            file=sys.stderr,
        )
        return 130


if __name__ == "__main__":
    raise SystemExit(main())

実行する

iPhoneのボイスメモをAirDropなどでMacへ送ります。

実行コマンドは次のとおりです。

python transcribe.py \
  "/file_path"

単語単位のタイムスタンプも取得する場合は、--word-timestampsを付けます。

python transcribe.py \
  "/filepath" \
  --word-timestamps

ファイル名に空白や日本語が入っている場合は、パス全体をダブルクォートで囲みます。

実行時には次のように表示されます。

入力ファイル : /file name
ファイル形式 : .m4a
ファイル容量 : 6.97 MB
言語         : ja-JP
Zoom Scribe APIへ送信しています...

最初に発生したエラー

最初の実装では、次のエラーが発生しました。

{
  "code": 400,
  "reason": "UNSUPPORTED_MEDIA",
  "message": "File extension \"\" is not supported. Supported extensions: .m4a, .mp3, .mp4, .wav, .webm",
  "metadata": {}
}

M4Aファイルを指定しているにもかかわらず、Zoom側では拡張子が空文字として認識されていました。

原因は、multipart/form-dataで送信する際に、ファイル本体だけを渡し、ファイル名を明示していなかったことでした。

問題のある例は次のようなコードです。

files = {
    "file": audio_file,
}

この場合、Zoom側へ元のファイル名が正しく伝わらず、拡張子を判定できないことがありました。

次のように、ファイル名とMIME Typeを明示すると処理できました。

files = {
    "file": (
        audio_path.name,
        audio_file,
        "audio/mp4",
    ),
}

特に重要なのは、タプルの先頭にある次の値です。

audio_path.name

これによって、Zoom側へ次のファイル名が送信されます。

file name.m4a

Zoom側が.m4aを判定できるようになり、文字起こしが正常に実行されました。

また、multipart/form-dataを送る場合は、Content-Typeヘッダーを自分で設定してはいけません。

次のような指定は行いません。

headers = {
    "Authorization": f"Bearer {token}",
    "Content-Type": "multipart/form-data",
}

requestsfilesを渡すと、boundaryを含む正しいContent-Typeが自動で設定されます。

公式ドキュメントとの違いについて

2026年7月時点のZoom公式Fast modeページでは、公開されている音声URLをJSONのfileへ指定する例が掲載されています。(Zoom)

{
  "file": "https://example.com/path/clip.mp3",
  "config": {
    "language": "en-US",
    "word_time_offsets": true,
    "channel_separation": false
  }
}

一方、今回使用した環境では、multipart/form-dataでローカルのM4Aファイルを直接送信する方法でも正常に処理できました。

この記事では、実際に手元のiPhoneボイスメモで動作を確認できた、直接アップロード方式を使用しています。

APIの仕様やドキュメントは更新される可能性があるため、将来同じコードでエラーが発生した場合は、最新のFast modeドキュメントも確認してください。

出力されるファイル

処理が成功すると、resultディレクトリが作成されます。

result/
├── transcript.json
├── transcript.txt
├── transcript_with_timestamps.txt
└── words.csv

各ファイルの内容は次のとおりです。

ファイル 内容
transcript.json APIレスポンス全体
transcript.txt 文字起こし本文
transcript_with_timestamps.txt セグメント単位の時刻付き本文
words.csv 単語単位の開始・終了時刻

words.csvは、--word-timestampsを付けた場合に生成されます。

Zoom Scribe APIのレスポンスには、文章全体のtext_displayに加えて、セグメントごとの開始時刻、終了時刻、文章、単語情報などが含まれます。(Zoom)

講演音声でも試してみた

短い音声だけでなく、大学で行われた研究発表の録音でも試しました。

使用した音声の条件は次のとおりです。

項目 内容
ファイル形式 M4A
ファイルサイズ 6.97 MB
録音端末 iPhone
録音環境 大学の講演・研究発表
音声の特徴 専門用語、英語の固有名詞、会場の反響あり

全文は長いため、特徴が分かりやすい部分のみ抜粋します。

一般的な内容はある程度認識できた

文字起こし結果の一部は次のとおりです。

銀河団とは、宇宙で最大規模の自己重力計。
非常に広がった戦隊と言われております。
左下に写っておりますのが、銀河団の可視光イメージとなっておりまして。

「銀河団」「宇宙」「可視光イメージ」など、発表の主要な単語や文章全体の流れは認識できています。

一方、文脈から考えると、次のような誤認識が起きたと考えられます。

文字起こし結果 おそらく元の発言
自己重力計 自己重力系
広がった戦隊 広がった天体

音が似ている一般的な単語へ置き換わっています。

天体名では大きな誤認識があった

別の箇所では、次のように文字起こしされました。

今回、私は平米2029という天体とペルセウスクラスターという2つの天体を用いて解析を行いました。

「平米2029」は、文脈上は天体名の Abell 2029 を指していると考えられます。

さらに、別の箇所では次のような結果になりました。

次に示しているのが、メルセデス・クラスターの。

ここは、おそらく「ペルセウスクラスター」と発言していた部分です。

一般的な日本語はある程度認識できる一方で、次のような単語では誤認識が増えました。

  • Abell 2029などの天体名
  • Xtendなどの装置名
  • オフアクシスなどの技術用語
  • 熱制動放射などの専門用語
  • 英語と日本語が混ざった固有名詞

発表全体のテーマは把握できた

細かい誤認識は多かったものの、文字起こし全体から次の話題を扱っていることは読み取れました。

  • 銀河団のX線観測
  • 高温ガスのスペクトル
  • 検出器の有効面積
  • モデルフィット
  • Abell 2029
  • ペルセウス銀河団
  • 観測位置や観測時期による比較
  • 温度と明るさの評価

そのため、完全な議事録を自動生成する用途では、人による修正が必要です。

一方で、次のような用途には利用できそうです。

  • 発表全体の話題を大まかに把握する
  • 特定の単語が出てきた場所を探す
  • 音声を聞き直す位置をタイムスタンプから特定する
  • 議事録を作るための下書きとして使用する
  • 長時間録音の概要を確認する

講演音声で精度が下がった理由

今回の録音では、次の条件が認識精度へ影響したと考えられます。

  • 発表者とiPhoneの距離が離れていた
  • 会場の反響が含まれていた
  • 周囲の人の声が入り込んでいた
  • 専門用語が多かった
  • 英語の装置名や天体名が多かった
  • スライドを示しながら話すため、省略された文章が多かった
  • 言い直しやフィラーが含まれていた

特に「Abell 2029」のように、一般的な日本語の音としては珍しい固有名詞は、似た音の日本語へ置き換わりやすいようです。

短いボイスメモでも試す

講演音声だけでは条件が厳しいため、精度を比較する場合は、静かな部屋で短い文章を録音すると分かりやすくなります。

例えば、次の文章を読み上げます。

今回はZoom Scribe APIを使って、
iPhoneのボイスメモを文字起こしします。

音声ファイルはM4A形式のまま送信し、
日本語の認識結果とタイムスタンプを確認します。

短い録音を実行します。

python transcribe.py \
  "/Users/ユーザー名/Downloads/zoom-scribe-test.m4a" \
  --word-timestamps

講演音声と短いボイスメモを比較することで、録音環境や専門用語が認識精度へ与える影響を確認できます。

エラーが発生した場合

HTTP 400 UNSUPPORTED_MEDIA

次のエラーが出る場合があります。

File extension "" is not supported.

ファイル送信時に、ファイル名がZoom側へ渡っているか確認します。

files = {
    "file": (
        audio_path.name,
        audio_file,
        mime_type,
    ),
}

audio_path.nameを省略すると、拡張子を判定できないことがあります。

HTTP 401

JWTが無効または期限切れになっている可能性があります。

次の項目を確認します。

  • API Keyが正しいか
  • API Secretが正しいか
  • JWTのissにAPI Keyを設定しているか
  • iatexpがUNIX時刻になっているか
  • View JWT Tokenで取得したJWTが期限切れになっていないか

AI ServicesではJWTをBearerトークンとして送信します。Zoomはトークンの有効期間を1時間以内にすることを推奨しています。(Zoom)

HTTP 403

AI Servicesを利用できるプランや権限が不足している可能性があります。

次の項目を確認します。

Zoom CPaaS
Universal Credit
CPaaSアカウント
Build PlatformのAPI Key

HTTP 429

短時間に多数のリクエストを送信し、レート制限に達した可能性があります。

少し時間を空けてから再実行します。AI ServicesにはBuildアカウントに基づくレート制限があります。(Zoom)

まとめ

今回は、iPhoneのボイスメモとして保存されたM4Aファイルを、Zoom Scribe APIで文字起こししました。

実装上のポイントは次のとおりです。

  • Zoom Build PlatformのAPI KeyとAPI SecretからJWTを生成する
  • iPhoneのボイスメモはM4Aのまま処理できた
  • 日本語はja-JPを指定する
  • multipart/form-dataでファイルを直接送信した
  • ファイル名を明示しないと、拡張子が空として判定されることがあった
  • 文字起こし本文だけでなく、タイムスタンプや単語情報も保存できた
  • 一般的な日本語はある程度認識できた
  • 専門用語や天体名、装置名では誤認識が目立った

静かな環境で録音した短いボイスメモであれば、文章の下書きやメモの整理に利用できそうです。

一方、研究発表のように専門用語や固有名詞が多い音声では、そのまま完成原稿として使うのは難しく、人による修正が必要でした。

それでも、長時間の音声を最初から聞き直す代わりに、発表の大まかな内容を確認したり、確認したい場所を探したりする補助ツールとしては便利だと感じました。

1
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
1
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?