Files
chat/backend/app/service/CosyVoiceService.php
2026-08-03 10:00:05 +08:00

434 lines
17 KiB
PHP

<?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 : '';
}
}