Files
2026-06-11 16:22:12 +08:00

221 lines
7.7 KiB
PHP

<?php
namespace app\common\service\chat;
use app\common\model\ai\AiConfig;
class WhisperAsrService
{
private const DEFAULT_BASE_URL = 'http://127.0.0.1:8791';
private const DEFAULT_TIMEOUT = 120;
private const DEFAULT_MAX_SIZE_MB = 10;
private const ALLOWED_EXTENSIONS = ['mp3', 'm4a', 'aac', 'wav', 'amr', 'mp4', '3gp'];
public static function transcribeUploadedFile($file): array
{
if ((int) AiConfig::getVal('whisper_asr_enabled', '1') !== 1) {
return self::fail('语音转文字服务暂未启用');
}
if (empty($file)) {
return self::fail('请先录制语音');
}
$filePath = self::uploadedFilePath($file);
$originalName = self::uploadedFileName($file);
$mime = self::uploadedFileMime($file);
$maxSizeMb = self::maxSizeMb();
$valid = self::validateAudioFile($filePath, $originalName, $maxSizeMb);
if (empty($valid['success'])) {
return $valid;
}
return self::requestAsr($filePath, $originalName, $mime);
}
public static function validateAudioFile(string $filePath, string $originalName = '', int $maxSizeMb = self::DEFAULT_MAX_SIZE_MB): array
{
if ($filePath === '' || !is_file($filePath) || !is_readable($filePath)) {
return self::fail('录音文件不存在');
}
$maxSizeMb = max(1, min(100, $maxSizeMb));
$size = (int) filesize($filePath);
if ($size <= 0) {
return self::fail('录音文件为空');
}
if ($size > $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];
}
}