更新
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user