$maxSizeMb * 1024 * 1024) { return self::fail('录音文件不能超过' . $maxSizeMb . 'MB'); } $extension = self::resolveAudioExtension($originalName, $filePath); if ($extension === '') { return self::fail('无法识别录音文件格式'); } if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) { return self::fail('仅支持 mp3、m4a、aac、wav、amr、mp4、3gp 格式语音'); } return self::success([ 'extension' => $extension, 'size' => $size, 'max_size_mb' => $maxSizeMb, ]); } public static function normalizeAsrResponse(array $payload): array { $nested = is_array($payload['data'] ?? null) ? $payload['data'] : []; $text = trim((string) ($payload['text'] ?? $payload['result'] ?? $nested['text'] ?? '')); if ($text === '') { return self::fail('未识别到语音内容'); } $durationMs = (int) ($payload['duration_ms'] ?? $nested['duration_ms'] ?? 0); if ($durationMs <= 0) { $duration = (float) ($payload['duration'] ?? $nested['duration'] ?? 0); $durationMs = $duration > 0 ? (int) round($duration * 1000) : 0; } return self::success([ 'text' => $text, 'duration_ms' => max(0, $durationMs), ]); } private static function requestAsr(string $filePath, string $originalName, string $mime): array { if (!function_exists('curl_init')) { return self::fail('服务器未启用 cURL,无法连接语音识别服务'); } $baseUrl = rtrim((string) AiConfig::getVal('whisper_asr_base_url', self::DEFAULT_BASE_URL), '/'); if ($baseUrl === '') { return self::fail('语音转文字服务地址未配置'); } $headers = ['Accept: application/json']; $apiToken = trim((string) AiConfig::getVal('whisper_asr_api_token', '')); if ($apiToken !== '') { $headers[] = 'Authorization: Bearer ' . $apiToken; } $timeout = self::timeoutSeconds(); $startTime = microtime(true); $postFields = [ 'file' => new \CURLFile( $filePath, $mime !== '' ? $mime : 'application/octet-stream', self::asrUploadFileName($originalName, $filePath) ), ]; $ch = curl_init($baseUrl . '/v1/transcribe'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_POSTFIELDS => $postFields, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_SSL_VERIFYPEER => false, ]); $response = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); $costMs = (int) ((microtime(true) - $startTime) * 1000); if ($error !== '') { return self::fail('语音识别服务连接失败:' . $error, ['duration_ms' => $costMs]); } $payload = json_decode((string) $response, true); if (!is_array($payload)) { return self::fail('语音识别服务返回异常:响应不是有效JSON', ['duration_ms' => $costMs]); } if ($httpCode !== 200) { $message = $payload['detail'] ?? $payload['message'] ?? $payload['error'] ?? ('HTTP ' . $httpCode); return self::fail('语音识别服务返回异常:' . (is_string($message) ? $message : json_encode($message, JSON_UNESCAPED_UNICODE)), ['duration_ms' => $costMs]); } $normalized = self::normalizeAsrResponse($payload); if (empty($normalized['success'])) { return $normalized; } if (empty($normalized['data']['duration_ms'])) { $normalized['data']['duration_ms'] = $costMs; } return $normalized; } private static function uploadedFilePath($file): string { foreach (['getRealPath', 'getPathname'] as $method) { if (is_object($file) && method_exists($file, $method)) { $path = (string) $file->{$method}(); if ($path !== '') { return $path; } } } return ''; } private static function uploadedFileName($file): string { if (is_object($file) && method_exists($file, 'getOriginalName')) { return (string) $file->getOriginalName(); } return ''; } private static function uploadedFileMime($file): string { if (is_object($file) && method_exists($file, 'getMime')) { return (string) $file->getMime(); } return ''; } private static function resolveAudioExtension(string $originalName, string $filePath): string { $extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); if ($extension === '') { $extension = strtolower((string) pathinfo($filePath, PATHINFO_EXTENSION)); } return $extension; } private static function asrUploadFileName(string $originalName, string $filePath): string { if ($originalName !== '' && pathinfo($originalName, PATHINFO_EXTENSION) !== '') { return $originalName; } $extension = self::resolveAudioExtension($originalName, $filePath); return 'voice.' . ($extension !== '' ? $extension : 'mp3'); } private static function timeoutSeconds(): int { return max(5, min(300, (int) AiConfig::getVal('whisper_asr_timeout', (string) self::DEFAULT_TIMEOUT))); } private static function maxSizeMb(): int { return max(1, min(100, (int) AiConfig::getVal('whisper_asr_max_size_mb', (string) self::DEFAULT_MAX_SIZE_MB))); } private static function success(array $data): array { return ['success' => true, 'data' => $data]; } private static function fail(string $error, array $data = []): array { return ['success' => false, 'error' => $error, 'data' => $data]; } }