wpmind/includes/API/Services/AudioService.php
LinuxJoy adb6c49301 style: phpcbf 自动格式化 — 全量 WPCS 3.x 规范对齐
135 文件 19,211 处自动修复:空格→Tab 缩进、括号间距、
函数声明空格、前置自增、尾逗号等纯格式化改动。
零逻辑变更,php -l + token 级对比验证通过。

新增 phpcs.xml.dist / phpstan.neon.dist 项目配置。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 00:48:46 +08:00

349 lines
11 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Audio Service
*
* 处理音频转录和语音合成
*
* @package WPMind
* @subpackage API\Services
* @since 3.7.0
*/
declare(strict_types=1);
namespace WPMind\API\Services;
use WP_Error;
/**
* Audio Service
*
* @since 3.7.0
*/
class AudioService extends AbstractService {
/**
* 音频转录(语音转文字)
*
* @since 2.7.0
* @param string $audio_file 音频文件路径或 URL
* @param array $options 选项
* @return array|WP_Error
*/
public function transcribe( string $audio_file, array $options = [] ) {
$defaults = [
'context' => 'transcription',
'language' => 'auto',
'prompt' => '',
'format' => 'text',
'provider' => 'auto',
];
$options = wp_parse_args( $options, $defaults );
$context = $options['context'];
$transcribe_providers = [ 'openai' ];
$provider = $this->resolve_provider( $options['provider'], $context );
// 文件大小上限25MB与 OpenAI Whisper API 限制一致)
$max_file_size = apply_filters( 'wpmind_transcribe_max_file_size', 25 * MB_IN_BYTES );
// 允许的音频文件扩展名
$allowed_extensions = [ 'mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'wav', 'webm', 'ogg', 'flac' ];
// 准备文件内容(在 failover 循环外处理,避免重复下载/IO
$is_temp = false;
if ( filter_var( $audio_file, FILTER_VALIDATE_URL ) ) {
// URL 安全验证:拒绝内网地址,防止 SSRF
if ( ! wp_http_validate_url( $audio_file ) ) {
return new WP_Error( 'wpmind_invalid_url', __( 'URL 验证失败:不允许访问内网地址', 'wpmind' ) );
}
// 协议白名单
$scheme = wp_parse_url( $audio_file, PHP_URL_SCHEME );
if ( ! in_array( $scheme, [ 'http', 'https' ], true ) ) {
return new WP_Error( 'wpmind_invalid_url', __( '仅支持 HTTP/HTTPS 协议', 'wpmind' ) );
}
$temp_file = download_url( $audio_file );
if ( is_wp_error( $temp_file ) ) {
return $temp_file;
}
$file_path = $temp_file;
$is_temp = true;
} else {
// 本地文件路径安全验证:限制在 uploads 目录内
$upload_dir = wp_upload_dir();
$realpath = realpath( $audio_file );
$basedir = realpath( $upload_dir['basedir'] );
if ( $realpath === false || $basedir === false || strpos( $realpath, $basedir ) !== 0 ) {
return new WP_Error( 'wpmind_invalid_path', __( '文件路径必须在 uploads 目录内', 'wpmind' ) );
}
$file_path = $realpath;
}
if ( ! file_exists( $file_path ) ) {
if ( $is_temp && isset( $temp_file ) && file_exists( $temp_file ) ) {
unlink( $temp_file );
}
return new WP_Error( 'wpmind_file_not_found', __( '音频文件不存在', 'wpmind' ) );
}
// 文件扩展名验证
$extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
if ( ! in_array( $extension, $allowed_extensions, true ) ) {
if ( $is_temp && isset( $temp_file ) && file_exists( $temp_file ) ) {
unlink( $temp_file );
}
return new WP_Error(
'wpmind_invalid_filetype',
sprintf( __( '不支持的音频格式: %s', 'wpmind' ), $extension )
);
}
// 文件大小验证
$file_size = filesize( $file_path );
if ( $file_size === false || $file_size > $max_file_size ) {
if ( $is_temp && isset( $temp_file ) && file_exists( $temp_file ) ) {
unlink( $temp_file );
}
return new WP_Error(
'wpmind_file_too_large',
sprintf( __( '文件大小超过限制 (%s)', 'wpmind' ), size_format( $max_file_size ) )
);
}
$file_content = file_get_contents( $file_path );
if ( $is_temp && isset( $temp_file ) && file_exists( $temp_file ) ) {
unlink( $temp_file );
}
do_action( 'wpmind_before_request', 'transcribe', compact( 'audio_file', 'options' ), $context );
return $this->execute_with_failover(
'transcribe',
$provider,
$context,
function ( string $try_provider, array $endpoint ) use ( $file_content, $options, $audio_file ) {
$api_key = $endpoint['api_key'];
$base_url = $endpoint['custom_base_url'] ?? $endpoint['base_url'] ?? '';
$api_url = trailingslashit( $base_url ) . 'audio/transcriptions';
$boundary = wp_generate_password( 24, false );
$body = '';
$body .= "--{$boundary}\r\n";
$body .= "Content-Disposition: form-data; name=\"file\"; filename=\"audio.mp3\"\r\n";
$body .= "Content-Type: audio/mpeg\r\n\r\n";
$body .= $file_content . "\r\n";
$body .= "--{$boundary}\r\n";
$body .= "Content-Disposition: form-data; name=\"model\"\r\n\r\n";
$body .= "whisper-1\r\n";
if ( $options['language'] !== 'auto' ) {
$body .= "--{$boundary}\r\n";
$body .= "Content-Disposition: form-data; name=\"language\"\r\n\r\n";
$body .= "{$options['language']}\r\n";
}
if ( ! empty( $options['prompt'] ) ) {
$body .= "--{$boundary}\r\n";
$body .= "Content-Disposition: form-data; name=\"prompt\"\r\n\r\n";
$body .= "{$options['prompt']}\r\n";
}
$response_format = $options['format'] === 'text' ? 'text' : $options['format'];
$body .= "--{$boundary}\r\n";
$body .= "Content-Disposition: form-data; name=\"response_format\"\r\n\r\n";
$body .= "{$response_format}\r\n";
$body .= "--{$boundary}--\r\n";
$start_time = microtime( true );
$response = wp_remote_post(
$api_url,
[
'headers' => [
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'multipart/form-data; boundary=' . $boundary,
],
'body' => $body,
'timeout' => 120,
]
);
$latency_ms = (int) ( ( microtime( true ) - $start_time ) * 1000 );
if ( is_wp_error( $response ) ) {
$this->record_result( $try_provider, false, $latency_ms );
return new WP_Error(
'wpmind_transcribe_failed',
sprintf( __( '转录请求失败: %s', 'wpmind' ), $response->get_error_message() )
);
}
$status_code = wp_remote_retrieve_response_code( $response );
$resp_body = wp_remote_retrieve_body( $response );
if ( $status_code !== 200 ) {
$this->record_result( $try_provider, false, $latency_ms );
$data = json_decode( $resp_body, true );
$error_message = $data['error']['message'] ?? $resp_body;
return new WP_Error(
'wpmind_transcribe_error',
sprintf( __( '转录 API 错误 (%1$d): %2$s', 'wpmind' ), $status_code, $error_message )
);
}
$this->record_result( $try_provider, true, $latency_ms );
$result = [
'text' => $options['format'] === 'text' ? $resp_body : '',
'data' => $options['format'] !== 'text' ? json_decode( $resp_body, true ) : null,
'provider' => $try_provider,
'format' => $options['format'],
];
if ( $options['format'] !== 'text' && is_array( $result['data'] ) ) {
$result['text'] = $result['data']['text'] ?? '';
}
do_action( 'wpmind_after_request', 'transcribe', $result, compact( 'audio_file', 'options' ), [] );
return $result;
},
$transcribe_providers
);
}
/**
* 文本转语音
*
* @since 2.7.0
* @param string $text 要转换的文本
* @param array $options 选项
* @return array|WP_Error
*/
public function speech( string $text, array $options = [] ) {
$defaults = [
'context' => 'speech',
'voice' => 'alloy',
'model' => 'tts-1',
'speed' => 1.0,
'format' => 'mp3',
'save_to' => '',
'provider' => 'auto',
];
$options = wp_parse_args( $options, $defaults );
$context = $options['context'];
$speech_providers = [ 'openai', 'deepseek' ];
$provider = $this->resolve_provider( $options['provider'], $context );
do_action( 'wpmind_before_request', 'speech', compact( 'text', 'options' ), $context );
return $this->execute_with_failover(
'speech',
$provider,
$context,
function ( string $try_provider, array $endpoint ) use ( $text, $options ) {
$api_key = $endpoint['api_key'];
$base_url = $endpoint['custom_base_url'] ?? $endpoint['base_url'] ?? '';
$api_url = trailingslashit( $base_url ) . 'audio/speech';
$start_time = microtime( true );
$response = wp_remote_post(
$api_url,
[
'headers' => [
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode(
[
'model' => $options['model'],
'input' => $text,
'voice' => $options['voice'],
'speed' => $options['speed'],
'response_format' => $options['format'],
]
),
'timeout' => 60,
]
);
$latency_ms = (int) ( ( microtime( true ) - $start_time ) * 1000 );
if ( is_wp_error( $response ) ) {
$this->record_result( $try_provider, false, $latency_ms );
return new WP_Error(
'wpmind_speech_failed',
sprintf( __( '语音合成请求失败: %s', 'wpmind' ), $response->get_error_message() )
);
}
$status_code = wp_remote_retrieve_response_code( $response );
$audio_data = wp_remote_retrieve_body( $response );
if ( $status_code !== 200 ) {
$this->record_result( $try_provider, false, $latency_ms );
$data = json_decode( $audio_data, true );
$error_message = $data['error']['message'] ?? __( '未知错误', 'wpmind' );
return new WP_Error(
'wpmind_speech_error',
sprintf( __( '语音合成 API 错误 (%1$d): %2$s', 'wpmind' ), $status_code, $error_message )
);
}
$this->record_result( $try_provider, true, $latency_ms );
$result = [
'provider' => $try_provider,
'model' => $options['model'],
'voice' => $options['voice'],
'format' => $options['format'],
'size' => strlen( $audio_data ),
];
if ( ! empty( $options['save_to'] ) ) {
$upload_dir = wp_upload_dir();
$save_dir = realpath( dirname( $options['save_to'] ) );
$base_dir = realpath( $upload_dir['basedir'] );
if ( $save_dir === false || $base_dir === false || strpos( $save_dir, $base_dir ) !== 0 ) {
return new WP_Error( 'wpmind_invalid_path', __( '保存路径必须在 uploads 目录内', 'wpmind' ) );
}
$written = file_put_contents( $options['save_to'], $audio_data );
if ( $written === false ) {
return new WP_Error( 'wpmind_write_failed', __( '文件写入失败', 'wpmind' ) );
}
$result['file'] = $options['save_to'];
} else {
$upload = wp_upload_bits(
'wpmind-speech-' . time() . '.' . $options['format'],
null,
$audio_data
);
if ( ! empty( $upload['error'] ) ) {
return new WP_Error( 'wpmind_upload_failed', $upload['error'] );
}
$result['url'] = $upload['url'];
$result['file'] = $upload['file'];
}
do_action( 'wpmind_after_request', 'speech', $result, compact( 'text', 'options' ), [] );
return $result;
},
$speech_providers
);
}
}