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