更新
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
<?php
|
||||
|
||||
namespace app\service;
|
||||
|
||||
use think\facade\Cache;
|
||||
|
||||
class CosyVoiceService
|
||||
{
|
||||
private const MODES = ['sft', 'instruct', 'zero_shot', 'cross_lingual', 'instruct2'];
|
||||
private const FALLBACK_VOICES = [
|
||||
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova',
|
||||
'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar',
|
||||
];
|
||||
|
||||
public static function getPersona(): array
|
||||
{
|
||||
$defaults = [
|
||||
'enabled' => (bool) config('cosyvoice.enabled', true),
|
||||
'name' => '小暖',
|
||||
'greeting' => '您好,我是 AI 客服小暖,请问有什么可以帮您?',
|
||||
'role_prompt' => '温暖、专业、耐心,像经验丰富的真人客服一样理解用户的真实诉求。',
|
||||
'base_url' => (string) config('cosyvoice.base_url', 'http://127.0.0.1:50000'),
|
||||
'mode' => (string) config('cosyvoice.mode', 'sft'),
|
||||
'speaker' => (string) config('cosyvoice.speaker', '中文女'),
|
||||
'instruct_text' => (string) config('cosyvoice.instruct_text', ''),
|
||||
'prompt_text' => (string) config('cosyvoice.prompt_text', ''),
|
||||
'prompt_wav' => (string) config('cosyvoice.prompt_wav', ''),
|
||||
'prompt_wav_name' => '',
|
||||
'sample_rate' => (int) config('cosyvoice.sample_rate', 22050),
|
||||
'fallback_voice' => 'marin',
|
||||
'connect_timeout_ms' => (int) config('cosyvoice.connect_timeout_ms', 800),
|
||||
'timeout_seconds' => (int) config('cosyvoice.timeout_seconds', 8),
|
||||
'failure_ttl' => (int) config('cosyvoice.failure_ttl', 20),
|
||||
];
|
||||
$stored = SettingsService::get('voice_persona', []);
|
||||
|
||||
return array_merge($defaults, is_array($stored) ? $stored : []);
|
||||
}
|
||||
|
||||
public static function publicPersona(): array
|
||||
{
|
||||
$persona = self::getPersona();
|
||||
return [
|
||||
'name' => $persona['name'],
|
||||
'greeting' => $persona['greeting'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function normalizePersona(array $input, ?array $current = null): array
|
||||
{
|
||||
$current ??= self::getPersona();
|
||||
$mode = trim((string) ($input['mode'] ?? $current['mode'] ?? 'sft'));
|
||||
if (!in_array($mode, self::MODES, true)) {
|
||||
$mode = 'sft';
|
||||
}
|
||||
|
||||
$baseUrl = rtrim(trim((string) ($input['base_url'] ?? $current['base_url'] ?? '')), '/');
|
||||
if ($baseUrl !== '' && !preg_match('#^https?://#i', $baseUrl)) {
|
||||
$baseUrl = (string) ($current['base_url'] ?? '');
|
||||
}
|
||||
|
||||
$fallbackVoice = trim((string) ($input['fallback_voice'] ?? $current['fallback_voice'] ?? 'marin'));
|
||||
if (!in_array($fallbackVoice, self::FALLBACK_VOICES, true)) {
|
||||
$fallbackVoice = 'marin';
|
||||
}
|
||||
|
||||
$promptWav = self::safePromptPath((string) ($input['prompt_wav'] ?? $current['prompt_wav'] ?? ''));
|
||||
$sampleRate = (int) ($input['sample_rate'] ?? $current['sample_rate'] ?? 22050);
|
||||
if (!in_array($sampleRate, [16000, 22050, 24000, 44100, 48000], true)) {
|
||||
$sampleRate = 22050;
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => filter_var($input['enabled'] ?? $current['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN),
|
||||
'name' => self::limitedText($input['name'] ?? $current['name'] ?? '小暖', 40),
|
||||
'greeting' => self::limitedText($input['greeting'] ?? $current['greeting'] ?? '', 200),
|
||||
'role_prompt' => self::limitedText($input['role_prompt'] ?? $current['role_prompt'] ?? '', 2000),
|
||||
'base_url' => $baseUrl,
|
||||
'mode' => $mode,
|
||||
'speaker' => self::limitedText($input['speaker'] ?? $current['speaker'] ?? '中文女', 80),
|
||||
'instruct_text' => self::limitedText($input['instruct_text'] ?? $current['instruct_text'] ?? '', 1000),
|
||||
'prompt_text' => self::limitedText($input['prompt_text'] ?? $current['prompt_text'] ?? '', 1500),
|
||||
'prompt_wav' => $promptWav,
|
||||
'prompt_wav_name' => self::limitedText($input['prompt_wav_name'] ?? $current['prompt_wav_name'] ?? '', 180),
|
||||
'sample_rate' => $sampleRate,
|
||||
'fallback_voice' => $fallbackVoice,
|
||||
'connect_timeout_ms' => max(200, min(5000, (int) ($input['connect_timeout_ms'] ?? 800))),
|
||||
'timeout_seconds' => max(2, min(60, (int) ($input['timeout_seconds'] ?? 8))),
|
||||
'failure_ttl' => max(5, min(300, (int) ($input['failure_ttl'] ?? 20))),
|
||||
];
|
||||
}
|
||||
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
$persona = self::getPersona();
|
||||
return !empty($persona['enabled']) && trim((string) $persona['base_url']) !== '';
|
||||
}
|
||||
|
||||
public static function canAttempt(): bool
|
||||
{
|
||||
if (!self::isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !Cache::get(self::failureCacheKey(self::getPersona()), false);
|
||||
}
|
||||
|
||||
public static function clearFailure(?array $persona = null): void
|
||||
{
|
||||
Cache::delete(self::failureCacheKey($persona ?? self::getPersona()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用官方 FastAPI 服务。上游返回裸 PCM16 流,这里封装为浏览器可播放的 WAV。
|
||||
*
|
||||
* @return array{audio: string, content_type: string, provider: string}
|
||||
*/
|
||||
public static function speech(string $text): array
|
||||
{
|
||||
$persona = self::getPersona();
|
||||
if (empty($persona['enabled']) || trim((string) $persona['base_url']) === '') {
|
||||
throw new \RuntimeException('CosyVoice 未启用');
|
||||
}
|
||||
|
||||
$mode = trim((string) $persona['mode']);
|
||||
if (!in_array($mode, self::MODES, true)) {
|
||||
throw new \RuntimeException('CosyVoice 模式无效: ' . $mode);
|
||||
}
|
||||
|
||||
$fields = self::requestFields($mode, $text, $persona);
|
||||
$url = rtrim((string) $persona['base_url'], '/') . '/inference_' . $mode;
|
||||
$headers = ['Accept: application/octet-stream'];
|
||||
$apiKey = trim((string) config('cosyvoice.api_key', ''));
|
||||
if ($apiKey !== '') {
|
||||
$headers[] = 'Authorization: Bearer ' . $apiKey;
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $fields,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT_MS => (int) $persona['connect_timeout_ms'],
|
||||
CURLOPT_TIMEOUT => (int) $persona['timeout_seconds'],
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
|
||||
$pcm = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$contentType = strtolower((string) (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: ''));
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($pcm === false) {
|
||||
self::rememberFailure($persona);
|
||||
throw new \RuntimeException('CosyVoice 连接失败: ' . ($curlError ?: '网络不可达'));
|
||||
}
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
self::rememberFailure($persona);
|
||||
$detail = self::errorDetail($pcm);
|
||||
throw new \RuntimeException('CosyVoice 返回 HTTP ' . $httpCode . ($detail ? ': ' . $detail : ''));
|
||||
}
|
||||
if (str_contains($contentType, 'json')) {
|
||||
self::rememberFailure($persona);
|
||||
throw new \RuntimeException('CosyVoice 返回了错误响应: ' . (self::errorDetail($pcm) ?: '未知错误'));
|
||||
}
|
||||
if (strlen($pcm) < 2) {
|
||||
self::rememberFailure($persona);
|
||||
throw new \RuntimeException('CosyVoice 返回了空音频');
|
||||
}
|
||||
|
||||
// PCM16 每个采样占两个字节;丢弃异常的尾部半个采样。
|
||||
if (strlen($pcm) % 2 !== 0) {
|
||||
$pcm = substr($pcm, 0, -1);
|
||||
}
|
||||
|
||||
Cache::delete(self::failureCacheKey($persona));
|
||||
return [
|
||||
'audio' => self::pcm16ToWav($pcm, (int) $persona['sample_rate']),
|
||||
'content_type' => 'audio/wav',
|
||||
'provider' => 'cosyvoice',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 CosyVoice 上游产生的 PCM16 数据块原样向下游推送,避免等待整段音频生成完成。
|
||||
*
|
||||
* @return array{bytes: int, sample_rate: int, provider: string, aborted: bool}
|
||||
*/
|
||||
public static function streamSpeech(string $text, callable $onChunk, string $requestId = ''): array
|
||||
{
|
||||
$persona = self::getPersona();
|
||||
if (empty($persona['enabled']) || trim((string) $persona['base_url']) === '') {
|
||||
throw new \RuntimeException('CosyVoice 未启用');
|
||||
}
|
||||
|
||||
$mode = trim((string) $persona['mode']);
|
||||
if (!in_array($mode, self::MODES, true)) {
|
||||
throw new \RuntimeException('CosyVoice 模式无效: ' . $mode);
|
||||
}
|
||||
|
||||
$fields = self::requestFields($mode, $text, $persona);
|
||||
if ($requestId !== '') {
|
||||
$fields['request_id'] = $requestId;
|
||||
}
|
||||
$url = rtrim((string) $persona['base_url'], '/') . '/inference_' . $mode;
|
||||
$headers = ['Accept: application/octet-stream'];
|
||||
$apiKey = trim((string) config('cosyvoice.api_key', ''));
|
||||
if ($apiKey !== '') {
|
||||
$headers[] = 'Authorization: Bearer ' . $apiKey;
|
||||
}
|
||||
|
||||
$httpCode = 0;
|
||||
$contentType = '';
|
||||
$errorBody = '';
|
||||
$bytes = 0;
|
||||
$aborted = false;
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $fields,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_HEADERFUNCTION => function ($ch, string $header) use (&$httpCode, &$contentType): int {
|
||||
if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+(\d+)/i', trim($header), $matches)) {
|
||||
$httpCode = (int) $matches[1];
|
||||
} elseif (stripos($header, 'Content-Type:') === 0) {
|
||||
$contentType = strtolower(trim(substr($header, strlen('Content-Type:'))));
|
||||
}
|
||||
return strlen($header);
|
||||
},
|
||||
CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use (
|
||||
&$httpCode,
|
||||
&$contentType,
|
||||
&$errorBody,
|
||||
&$bytes,
|
||||
&$aborted,
|
||||
$onChunk
|
||||
): int {
|
||||
if ($httpCode < 200 || $httpCode >= 300 || str_contains($contentType, 'json')) {
|
||||
$errorBody .= $chunk;
|
||||
return strlen($chunk);
|
||||
}
|
||||
if (connection_aborted()) {
|
||||
$aborted = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
$bytes += strlen($chunk);
|
||||
$onChunk($chunk);
|
||||
return strlen($chunk);
|
||||
},
|
||||
CURLOPT_CONNECTTIMEOUT_MS => (int) $persona['connect_timeout_ms'],
|
||||
CURLOPT_TIMEOUT => (int) $persona['timeout_seconds'],
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
$curlError = curl_error($ch);
|
||||
$curlErrno = curl_errno($ch);
|
||||
if ($httpCode === 0) {
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
}
|
||||
curl_close($ch);
|
||||
|
||||
if ($aborted || ($result === false && $curlErrno === CURLE_WRITE_ERROR && connection_aborted())) {
|
||||
return [
|
||||
'bytes' => $bytes,
|
||||
'sample_rate' => (int) $persona['sample_rate'],
|
||||
'provider' => 'cosyvoice',
|
||||
'aborted' => true,
|
||||
];
|
||||
}
|
||||
if ($result === false) {
|
||||
self::rememberFailure($persona);
|
||||
throw new \RuntimeException('CosyVoice 流连接失败: ' . ($curlError ?: '网络不可达'));
|
||||
}
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
self::rememberFailure($persona);
|
||||
$detail = self::errorDetail($errorBody);
|
||||
throw new \RuntimeException('CosyVoice 返回 HTTP ' . $httpCode . ($detail ? ': ' . $detail : ''));
|
||||
}
|
||||
if (str_contains($contentType, 'json')) {
|
||||
self::rememberFailure($persona);
|
||||
throw new \RuntimeException('CosyVoice 返回了错误响应: ' . (self::errorDetail($errorBody) ?: '未知错误'));
|
||||
}
|
||||
if ($bytes < 2) {
|
||||
self::rememberFailure($persona);
|
||||
throw new \RuntimeException('CosyVoice 返回了空音频');
|
||||
}
|
||||
|
||||
Cache::delete(self::failureCacheKey($persona));
|
||||
return [
|
||||
'bytes' => $bytes,
|
||||
'sample_rate' => (int) $persona['sample_rate'],
|
||||
'provider' => 'cosyvoice',
|
||||
'aborted' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public static function cancelSpeech(string $requestId): bool
|
||||
{
|
||||
$persona = self::getPersona();
|
||||
$requestId = trim($requestId);
|
||||
if ($requestId === '' || empty($persona['enabled']) || trim((string) $persona['base_url']) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$url = rtrim((string) $persona['base_url'], '/') . '/cancel/' . rawurlencode($requestId);
|
||||
$headers = ['Accept: application/json'];
|
||||
$apiKey = trim((string) config('cosyvoice.api_key', ''));
|
||||
if ($apiKey !== '') {
|
||||
$headers[] = 'Authorization: Bearer ' . $apiKey;
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => '',
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT_MS => min(800, (int) $persona['connect_timeout_ms']),
|
||||
CURLOPT_TIMEOUT_MS => 1500,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($body === false || $httpCode < 200 || $httpCode >= 300) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = json_decode($body, true);
|
||||
return is_array($payload) && !empty($payload['cancelled']);
|
||||
}
|
||||
|
||||
private static function requestFields(string $mode, string $text, array $persona): array
|
||||
{
|
||||
$fields = ['tts_text' => $text];
|
||||
$speaker = trim((string) $persona['speaker']);
|
||||
$instruct = trim((string) $persona['instruct_text']);
|
||||
|
||||
if (in_array($mode, ['sft', 'instruct'], true)) {
|
||||
$fields['spk_id'] = $speaker;
|
||||
}
|
||||
if (in_array($mode, ['instruct', 'instruct2'], true)) {
|
||||
$fields['instruct_text'] = $instruct;
|
||||
}
|
||||
if (in_array($mode, ['zero_shot', 'instruct2'], true)) {
|
||||
$fields['prompt_text'] = trim((string) $persona['prompt_text']);
|
||||
}
|
||||
if (in_array($mode, ['zero_shot', 'cross_lingual', 'instruct2'], true)) {
|
||||
$promptWav = trim((string) $persona['prompt_wav']);
|
||||
if ($promptWav === '' || !is_file($promptWav)) {
|
||||
throw new \RuntimeException('CosyVoice 音色样本不存在: ' . $promptWav);
|
||||
}
|
||||
$fields['prompt_wav'] = new \CURLFile($promptWav, 'audio/wav', basename($promptWav));
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
private static function pcm16ToWav(string $pcm, int $sampleRate): string
|
||||
{
|
||||
$channels = 1;
|
||||
$bitsPerSample = 16;
|
||||
$dataSize = strlen($pcm);
|
||||
$blockAlign = (int) ($channels * $bitsPerSample / 8);
|
||||
$byteRate = $sampleRate * $blockAlign;
|
||||
|
||||
return 'RIFF'
|
||||
. pack('V', 36 + $dataSize)
|
||||
. 'WAVEfmt '
|
||||
. pack('VvvVVvv', 16, 1, $channels, $sampleRate, $byteRate, $blockAlign, $bitsPerSample)
|
||||
. 'data'
|
||||
. pack('V', $dataSize)
|
||||
. $pcm;
|
||||
}
|
||||
|
||||
private static function errorDetail(string $body): string
|
||||
{
|
||||
$data = json_decode($body, true);
|
||||
if (is_array($data)) {
|
||||
$detail = $data['detail'] ?? $data['message'] ?? $data['error'] ?? '';
|
||||
if (is_array($detail)) {
|
||||
return json_encode($detail, JSON_UNESCAPED_UNICODE) ?: '';
|
||||
}
|
||||
return trim((string) $detail);
|
||||
}
|
||||
|
||||
return mb_substr(trim(strip_tags($body)), 0, 240);
|
||||
}
|
||||
|
||||
private static function rememberFailure(array $persona): void
|
||||
{
|
||||
Cache::set(
|
||||
self::failureCacheKey($persona),
|
||||
true,
|
||||
(int) $persona['failure_ttl']
|
||||
);
|
||||
}
|
||||
|
||||
private static function failureCacheKey(array $persona): string
|
||||
{
|
||||
return 'cosyvoice_unavailable_' . md5((string) $persona['base_url']);
|
||||
}
|
||||
|
||||
private static function limitedText(mixed $value, int $maxLength): string
|
||||
{
|
||||
return mb_substr(trim((string) $value), 0, $maxLength);
|
||||
}
|
||||
|
||||
private static function safePromptPath(string $path): string
|
||||
{
|
||||
$path = trim($path);
|
||||
if ($path === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$realPath = realpath($path);
|
||||
$allowedRoot = realpath(root_path() . 'storage' . DIRECTORY_SEPARATOR . 'cosyvoice');
|
||||
if (!$realPath || !$allowedRoot || !is_file($realPath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$normalizedPath = strtolower(str_replace('\\', '/', $realPath));
|
||||
$normalizedRoot = rtrim(strtolower(str_replace('\\', '/', $allowedRoot)), '/') . '/';
|
||||
return str_starts_with($normalizedPath, $normalizedRoot) ? $realPath : '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user