2026年8月2日

2026年8月2日

WordPressの音声ファイルをOpenAI Whisperで自動書き起こしする実装方法

WordPressのメディアライブラリに音声をアップロードしたら、OpenAIの音声認識API(Whisper)で自動的に文字起こしし、その結果を記事の音声プレーヤーの下に表示する仕組みは、WordPress標準の関数だけで作れます。この記事では、APIキーの安全な置き場所、wp_remote_post でのファイル送信、WP-Cronを使った非同期処理、メディア画面での修正、表示までを1つのmu-pluginとして実装し、よくあるエラーの対処も解説します。

API仕様や対応モデルは更新されることがあります。本文の仕様は2026年10月現在の公式ドキュメントに基づく目安なので、実装前にOpenAIの「Speech to text」のドキュメントで最新の内容を確認してください。

全体の流れと前提

処理は次の順で進みます。

  1. 管理画面から音声ファイルをアップロードする
  2. WordPressが「書き起こし待ち」として予約する(その場ではAPIを呼ばない)
  3. WP-Cronのバックグラウンド処理で音声をAPIに送り、返ってきたテキストを添付ファイルのメタデータに保存する
  4. 編集者がメディア画面でテキストを確認・修正する
  5. 記事内の音声ブロックの下に、折りたたみ式で文字起こしを表示する

アップロードの処理中にAPIを呼ばないのは、長い音声だと応答に数十秒以上かかり、アップロード画面がタイムアウトするためです。

前提として次の点を押さえておきます。

  • ファイルサイズの上限: 書き起こしAPIに送れるファイルは1件25MBまでとされています(2026年10月現在)。長い収録は圧縮するか分割が必要です。
  • 対応形式: mp3、m4a、wav、webm、ogg、flac などの一般的な形式に対応しています。
  • 費用: 音声の長さに応じた従量課金です。料金は変わるため、OpenAIの料金ページで確認し、利用上限(使用量の上限設定)もダッシュボードで決めておくと安心です。
  • 個人データ: 音声は外部のサービスに送信されます。インタビューや会議の録音なら話者の同意を取り、プライバシーポリシーに「音声を外部のAIサービスで処理すること」を記載してください。APIに送ったデータの扱いはOpenAIの利用規約・データ利用ポリシーで確認できます。

作業はステージング環境で行い、本番に入れる前にデータベースとファイルのバックアップを取っておきます。

APIキーはwp-config.phpに置く

APIキーをテーマのファイルやデータベースのオプションに直書きすると、テーマの共有やバックアップの受け渡しで漏れる危険があります。wp-config.php に定数として置き、そのファイルはGitなどのバージョン管理に含めないようにします。

// wp-config.php(「編集が必要なのはここまでです」の行より上に追加)
// サーバーの環境変数に OPENAI_API_KEY を設定できる場合
define( 'WPDR_OPENAI_API_KEY', getenv( 'OPENAI_API_KEY' ) ?: '' );

// 環境変数が使えない場合は直接書く(値はダミー)
// define( 'WPDR_OPENAI_API_KEY', 'your-api-key' );

キーはOpenAIの管理画面で、このサイト専用に発行します。用途ごとにキーを分けておけば、漏れたときにそのキーだけを無効化できます。

書き起こしAPIを呼ぶ関数

ここからのコードは wp-content/mu-plugins/wpdr-transcribe.php という1つのファイルにまとめます(mu-plugins フォルダが無ければ作成します)。mu-pluginは有効化の操作がいらず、管理画面から誤って停止されることもありません。不具合が出たときは、FTPでこのファイルの名前を変えれば停止できます。

WordPressの wp_remote_post は、ファイル送信に使う multipart/form-data の本文を自動では組み立ててくれません。そのため、境界文字列(boundary)を使って本文を自分で組み立てます。

<?php
/**
 * Plugin Name: WPDR Audio Transcription
 * Description: 音声添付ファイルを OpenAI の書き起こしAPIでテキスト化する。
 */

defined( 'ABSPATH' ) || exit;

function wpdr_whisper_transcribe( $file_path, $language = 'ja' ) {
	$api_key = defined( 'WPDR_OPENAI_API_KEY' ) ? WPDR_OPENAI_API_KEY : '';
	if ( '' === $api_key ) {
		return new WP_Error( 'wpdr_no_key', 'APIキーが設定されていません。' );
	}
	if ( ! $file_path || ! is_readable( $file_path ) ) {
		return new WP_Error( 'wpdr_no_file', '音声ファイルを読み込めません。' );
	}
	if ( filesize( $file_path ) > 25 * MB_IN_BYTES ) {
		return new WP_Error( 'wpdr_too_large', 'ファイルが25MBを超えています。' );
	}

	$fields = array(
		'model'           => 'whisper-1',
		'language'        => $language,
		'response_format' => 'json',
	);

	$boundary = wp_generate_password( 24, false );
	$body     = '';
	foreach ( $fields as $name => $value ) {
		$body .= "--{$boundary}\r\n";
		$body .= "Content-Disposition: form-data; name=\"{$name}\"\r\n\r\n";
		$body .= $value . "\r\n";
	}

	$filename = wp_basename( $file_path );
	$filetype = wp_check_filetype( $filename );
	$mime     = $filetype['type'] ? $filetype['type'] : 'application/octet-stream';

	$body .= "--{$boundary}\r\n";
	$body .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n";
	$body .= "Content-Type: {$mime}\r\n\r\n";
	$body .= file_get_contents( $file_path ) . "\r\n";
	$body .= "--{$boundary}--\r\n";

	$response = wp_remote_post(
		'https://api.openai.com/v1/audio/transcriptions',
		array(
			'timeout' => 300,
			'headers' => array(
				'Authorization' => 'Bearer ' . $api_key,
				'Content-Type'  => 'multipart/form-data; boundary=' . $boundary,
			),
			'body'    => $body,
		)
	);

	if ( is_wp_error( $response ) ) {
		return $response;
	}

	$code = (int) wp_remote_retrieve_response_code( $response );
	$data = json_decode( wp_remote_retrieve_body( $response ), true );

	if ( 200 !== $code ) {
		$message = isset( $data['error']['message'] ) ? $data['error']['message'] : 'HTTP ' . $code;
		return new WP_Error( 'wpdr_api_error', $message, array( 'status' => $code ) );
	}
	if ( ! isset( $data['text'] ) ) {
		return new WP_Error( 'wpdr_bad_response', '応答にテキストが含まれていません。' );
	}

	return $data['text'];
}

model には whisper-1 を指定しています。OpenAIは gpt-4o-transcribe や gpt-4o-mini-transcribe といった新しい書き起こしモデルも提供していますが、指定できる出力形式などがモデルによって異なります。切り替える場合はドキュメントで対応パラメーターを確認してください。また、固有名詞や専門用語の誤変換が多いときは、prompt パラメーターに用語を含む短い文を渡すと改善することがあります。

アップロード時に予約し、バックグラウンドで処理する

続けて、同じファイルに予約と実行の処理を追加します。

// 音声がアップロードされたら、書き起こしを予約する
add_action( 'add_attachment', function ( $attachment_id ) {
	if ( 0 !== strpos( (string) get_post_mime_type( $attachment_id ), 'audio/' ) ) {
		return;
	}
	update_post_meta( $attachment_id, '_wpdr_transcript_status', 'queued' );
	wp_schedule_single_event( time() + 10, 'wpdr_transcribe_attachment', array( (int) $attachment_id ) );
} );

// 予約された処理の本体
add_action( 'wpdr_transcribe_attachment', function ( $attachment_id ) {
	$attachment_id = (int) $attachment_id;
	if ( 'done' === get_post_meta( $attachment_id, '_wpdr_transcript_status', true ) ) {
		return; // 二重実行を防ぐ
	}

	$result = wpdr_whisper_transcribe( get_attached_file( $attachment_id ) );

	if ( is_wp_error( $result ) ) {
		update_post_meta( $attachment_id, '_wpdr_transcript_status', 'error' );
		update_post_meta( $attachment_id, '_wpdr_transcript_error', wp_slash( $result->get_error_message() ) );
		return;
	}

	// update_post_meta は値のバックスラッシュを取り除くため、wp_slash で保護する
	update_post_meta( $attachment_id, '_wpdr_transcript', wp_slash( $result ) );
	update_post_meta( $attachment_id, '_wpdr_transcript_status', 'done' );
	delete_post_meta( $attachment_id, '_wpdr_transcript_error' );
} );

失敗したときに自動で何度も再実行する作りにはしていません。APIキーの誤りや残高不足が原因の場合、再実行しても同じエラーを繰り返して無駄なリクエストが増えるだけだからです。原因を直してから、WP-CLIで手動で再実行します(123は添付ファイルのID)。

wp eval 'delete_post_meta( 123, "_wpdr_transcript_status" ); do_action( "wpdr_transcribe_attachment", 123 );'

WP-CLIから実行すると、ブラウザのリクエストの時間制限を受けないので、長い音声の確認にも向いています。

WP-Cronが動かないときの注意

WP-Cronは「サイトにアクセスがあったとき」に予約処理を起動する仕組みです。アクセスが少ないサイトでは処理が遅れ、サーバーによってはループバック通信の制限やPHPの実行時間制限で途中で止まることもあります。書き起こしのように時間のかかる処理を扱うなら、wp-config.php に define( 'DISABLE_WP_CRON', true ); を書き、サーバーのcronから wp cron event run --due-now を数分おきに実行する構成が安定します。

なお、メディアをS3などの外部ストレージに移すプラグインを使っている場合、get_attached_file() が指すローカルのファイルが存在しないことがあります。その場合は、一時ファイルにダウンロードしてから送る処理が必要です。

メディア画面で確認・修正できるようにする

機械の書き起こしには誤りが含まれるため、公開前に人が確認できるようにします。メディアの詳細画面に入力欄を追加します。

add_filter( 'attachment_fields_to_edit', function ( $fields, $post ) {
	if ( 0 !== strpos( (string) $post->post_mime_type, 'audio/' ) ) {
		return $fields;
	}
	$status = get_post_meta( $post->ID, '_wpdr_transcript_status', true );
	$error  = get_post_meta( $post->ID, '_wpdr_transcript_error', true );
	$help   = '状態: ' . ( $status ? $status : '未処理' ) . ( $error ? '(' . $error . ')' : '' );

	$fields['wpdr_transcript'] = array(
		'label' => '文字起こし',
		'input' => 'textarea',
		'value' => esc_textarea( get_post_meta( $post->ID, '_wpdr_transcript', true ) ),
		'helps' => esc_html( $help ),
	);
	return $fields;
}, 10, 2 );

add_filter( 'attachment_fields_to_save', function ( $post, $attachment ) {
	if ( isset( $attachment['wpdr_transcript'] ) && current_user_can( 'edit_post', $post['ID'] ) ) {
		$text = sanitize_textarea_field( wp_unslash( $attachment['wpdr_transcript'] ) );
		update_post_meta( $post['ID'], '_wpdr_transcript', wp_slash( $text ) );
	}
	return $post;
}, 10, 2 );

この入力欄の値は、WordPressが自動ではエスケープしないため、esc_textarea と esc_html を通してから渡しています。保存時のnonceの検証はWordPressのメディア画面側で行われますが、念のため権限(edit_post)も確認しています。

記事の音声ブロックの下に表示する

最後に、音声ブロックの出力の後ろに文字起こしを付け足します。render_block_core/audio フィルターを使うと、音声ブロックだけを対象にできます。

add_filter( 'render_block_core/audio', function ( $content, $block ) {
	$id = isset( $block['attrs']['id'] ) ? (int) $block['attrs']['id'] : 0;
	if ( ! $id ) {
		return $content;
	}
	$text = get_post_meta( $id, '_wpdr_transcript', true );
	if ( '' === $text ) {
		return $content;
	}
	return $content
		. '<details class="wpdr-transcript"><summary>文字起こしを表示</summary>'
		. wpautop( esc_html( $text ) )
		. '</details>';
}, 10, 2 );

文字起こしを添えることは、音声を聞けない・聞き取りにくい人への配慮にもなります。WCAG 2.2の達成基準1.2.1(音声のみ及び映像のみ、収録済み)は、収録済みの音声に同等の内容のテキストを用意することを求めています。ただし、誤りの残った機械書き起こしでは代替として不十分なので、確認を済ませたものだけを表示する運用にしてください。

よくあるエラーと対処

メディア画面の「状態」に表示されるエラーメッセージや、PHPのエラーログを手がかりに、次の順で確認します。

  1. APIキーが設定されていません / HTTP 401: 定数名の綴り、wp-config.php での定義位置、環境変数が実際にPHPから読めるかを確認します。キーを無効化・再発行した場合は値も差し替えます。
  2. HTTP 429: リクエストの頻度制限か、利用枠・支払い設定の問題です。OpenAIのダッシュボードで使用量と請求の設定を確認し、一度に大量のファイルを予約しないようにします。
  3. ファイルが25MBを超えています / HTTP 400: サイズか形式の問題です。音声認識には高音質は不要なので、モノラル・16kHz程度のmp3に変換すると大幅に小さくなります(例: ffmpeg -i input.wav -ac 1 -ar 16000 -b:a 32k output.mp3)。それでも大きい場合は分割します。
  4. cURL error 28(タイムアウト): 応答を待つ時間が足りません。コード内の timeout を延ばし、上で説明した実際のcronやWP-CLIでの実行に切り替えます。
  5. 予約したまま進まない: WP-Cronが起動していません。wp cron event list で予約が残っているかを確認し、cronの構成を見直します。
  6. 誤変換・同じ文の繰り返し: 長い無音部分で同じ語句が繰り返し出力されることがあります。収録の前後の無音を削る、prompt で用語を渡す、といった方法で改善を試します。

まとめ

  • APIキーは wp-config.php に定数で置き、バージョン管理やデータベースに入れないようにします。
  • wp_remote_post はmultipartを自動で組み立てないため、境界文字列を使って本文を作ります。上限サイズ(2026年10月現在は25MB)の確認も事前に行います。
  • アップロード時は予約だけにし、WP-Cron(できればサーバーのcronやWP-CLI)で非同期に処理します。
  • 結果は添付ファイルのメタデータに保存し、メディア画面で人が修正してから、音声ブロックの下に表示します。
  • 音声を外部に送ることを話者とプライバシーポリシーで明示し、料金や仕様は公式ドキュメントで最新を確認してください。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲