221 lines
7.7 KiB
PHP
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];
|
|
}
|
|
}
|