This commit is contained in:
Your Name
2026-08-03 10:00:05 +08:00
parent 0fb03d0bca
commit 01729b1e0b
109 changed files with 7577 additions and 1153 deletions
+129
View File
@@ -55,6 +55,35 @@ class OpenAIService
return $model;
}
/**
* 语音合成必须使用 OpenAI 兼容协议模型。Dify/ComfyUI 的 API 地址不提供
* /audio/speech,因此所选对话模型不兼容时自动回落到已启用的 OpenAI 模型。
*/
public static function getSpeechModel(?int $preferredModelId = null): AiModel
{
$model = null;
if ($preferredModelId) {
$model = AiModel::where('id', $preferredModelId)
->where('enabled', 1)
->where('provider', 'openai')
->find();
}
if (!$model) {
$model = AiModel::where('enabled', 1)
->where('provider', 'openai')
->order('is_default', 'desc')
->order('sort_order')
->find();
}
if (!$model) {
self::throwUnavailableModel('未配置支持语音合成的 OpenAI 兼容模型');
}
return $model;
}
public static function getImageModel(?int $preferredModelId = null): AiModel
{
$model = null;
@@ -252,6 +281,106 @@ class OpenAIService
return $data;
}
/**
* 使用神经语音模型生成短句 WAV。短句由前端在文本流式输出期间提前提交,
* WAV 则避免浏览器额外的解码启动开销。
*
* @return array{audio: string, content_type: string}
*/
public static function speech(AiModel $model, string $input, string $voice = 'marin'): array
{
$extra = is_array($model->extra_config ?? null) ? $model->extra_config : [];
$ttsModel = trim((string) ($extra['tts_model'] ?? 'gpt-4o-mini-tts'));
$ttsVoice = trim((string) ($extra['tts_voice'] ?? $voice));
$instructions = trim((string) ($extra['tts_instructions'] ?? (
'Speak in natural, warm, conversational Mandarin Chinese. '
. 'Use relaxed pacing, subtle emotion, human-like phrasing and short natural pauses. '
. 'Avoid an announcer, customer-service, robotic, or overly enthusiastic tone.'
)));
$allowedVoices = [
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova',
'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar',
];
if (!in_array($ttsVoice, $allowedVoices, true)) {
$ttsVoice = 'marin';
}
if ($ttsModel === '') {
$ttsModel = 'gpt-4o-mini-tts';
}
$legacyTts = in_array($ttsModel, ['tts-1', 'tts-1-hd'], true);
if ($legacyTts && !in_array($ttsVoice, ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'], true)) {
$ttsVoice = 'nova';
}
$url = rtrim((string) $model->api_base_url, '/') . '/audio/speech';
$payload = [
'model' => $ttsModel,
'input' => $input,
'voice' => $ttsVoice,
'response_format' => 'wav',
];
if (!$legacyTts && $instructions !== '') {
$payload['instructions'] = $instructions;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: audio/wav',
'Authorization: Bearer ' . $model->api_key,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = (string) (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: 'audio/wav');
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false) {
throw new HttpResponseException(json([
'code' => 1,
'message' => '语音合成连接失败: ' . ($curlError ?: '网络不可达'),
'data' => null,
], 502));
}
if ($httpCode < 200 || $httpCode >= 300) {
$detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode);
throw new HttpResponseException(json([
'code' => 1,
'message' => '自然语音生成失败: ' . $detail,
'data' => null,
], 502));
}
if ($response === '') {
throw new HttpResponseException(json([
'code' => 1,
'message' => '语音服务返回了空音频',
'data' => null,
], 502));
}
if (!str_starts_with(strtolower($contentType), 'audio/')) {
$contentType = 'audio/wav';
}
return [
'audio' => $response,
'content_type' => $contentType,
];
}
/**
* 测试模型连接是否正常
* @return array{success: bool, latency_ms: int, reply: string, model: string}