diff --git a/lib/tts_voice_management.php b/lib/tts_voice_management.php new file mode 100644 index 0000000..cb9b304 --- /dev/null +++ b/lib/tts_voice_management.php @@ -0,0 +1,420 @@ + 'pocket_tts', + 'xtts', 'xtts-fastapi' => 'xtts', + 'chatterbox' => 'chatterbox', + 'omnivoice' => 'omnivoice', + 'cartesia' => 'cartesia', + 'inworld' => 'inworld', + default => '', + }; +} + +function stobeVoiceProviderNormalizeId(string $voiceId): string +{ + $voiceId = preg_replace('/\.wav$/i', '', trim($voiceId)) ?? ''; + return preg_match('/^[A-Za-z0-9][A-Za-z0-9_.-]*$/', $voiceId) === 1 ? $voiceId : ''; +} + +function stobeVoiceProviderIsAudioCpp(string $provider, string $endpoint): bool +{ + return stobeVoiceProviderNormalize($provider) === 'pocket_tts' + && (preg_match('/\:8086(?:\/|$)/', $endpoint) === 1 || str_contains($endpoint, '/v1/audio/speech')); +} + +function stobeVoiceProviderTarget(array $connector): array +{ + $config = $connector['config'] ?? []; + if (!is_array($config)) { + $decoded = json_decode(strval($config), true); + $config = is_array($decoded) ? $decoded : []; + } + $provider = stobeVoiceProviderNormalize(strval($config['provider'] ?? $connector['connector_type'] ?? '')); + $endpoint = trim(strval($connector['base_url'] ?? '')); + if ($endpoint === '') { + $endpoint = trim(strval($config['endpoint'] ?? '')); + } + if ($endpoint === '' && !in_array($provider, ['cartesia', 'inworld'], true) && $provider !== '') { + $endpoint = $provider === 'omnivoice' ? 'http://127.0.0.1:8021' : 'http://127.0.0.1:8020'; + } + $apiKey = trim(strval($connector['api_badge_key'] ?? ($connector['api_key'] ?? ($config['api_key'] ?? '')))); + $workspace = trim(strval($config['workspace'] ?? '')); + $isCloud = in_array($provider, ['cartesia', 'inworld'], true); + $modelId = trim(strval($config['model_id'] ?? '')); + if ($modelId === '' && $provider === 'cartesia') { + $modelId = 'sonic-3'; + } elseif ($modelId === '' && $provider === 'inworld') { + $modelId = 'inworld-tts-1'; + } + return [ + 'id' => intval($connector['id'] ?? 0), + 'name' => trim(strval($connector['name'] ?? '')), + 'provider' => $provider, + 'endpoint' => rtrim($endpoint, '/'), + 'language' => strtolower(trim(strval($config['language'] ?? ''))), + 'api_key' => $apiKey, + 'workspace' => $workspace, + 'model_id' => $modelId, + 'config' => $config, + 'cloud' => $isCloud, + 'can_manage' => $provider !== '' + && ($isCloud ? ($apiKey !== '' && ($provider !== 'inworld' || $workspace !== '')) : $endpoint !== '') + && !stobeVoiceProviderIsAudioCpp($provider, $endpoint), + ]; +} + +function stobeVoiceCloudMetadataKey(array $target, string $voiceId): string +{ + return 'tts_cloud_voice_meta_' . strval($target['provider'] ?? '') . '_' + . intval($target['id'] ?? 0) . '_' . md5(strtolower($voiceId)); +} + +function stobeVoiceCloudCacheKey(array $target, string $voiceId): string +{ + return stobeCloudVoiceCacheKey( + strval($target['provider'] ?? ''), + stobeSanitizeVoiceToken($voiceId), + intval($target['id'] ?? 0) + ); +} + +function stobeVoiceCloudGetMetadata(array $target, string $voiceId): array +{ + $decoded = json_decode(stobeConfOptGet(stobeVoiceCloudMetadataKey($target, $voiceId)), true); + return is_array($decoded) ? $decoded : []; +} + +function stobeVoiceCloudDeleteConfOpt(string $key): void +{ + $GLOBALS['db']->exec('DELETE FROM conf_opts WHERE id = $1', [$key]); +} + +function stobeVoiceCloudDeleteRemote(array $target, string $remoteId): array +{ + $provider = strval($target['provider'] ?? ''); + $apiKey = trim(strval($target['api_key'] ?? '')); + $remoteId = trim($remoteId); + if ($apiKey === '' || $remoteId === '') { + return ['success' => false, 'message' => 'Cloud API credential or remote voice ID is missing.']; + } + + $url = $provider === 'cartesia' + ? 'https://api.cartesia.ai/voices/' . rawurlencode($remoteId) + : 'https://api.inworld.ai/voices/v1/voices/' . rawurlencode($remoteId); + $headers = $provider === 'cartesia' + ? ['X-API-Key: ' . $apiKey, 'Cartesia-Version: 2026-03-01', 'Accept: application/json'] + : ['Authorization: Basic ' . $apiKey, 'Accept: application/json']; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 30, + ]); + $response = curl_exec($ch); + $httpCode = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE)); + $error = curl_error($ch); + curl_close($ch); + if ($response === false) { + return ['success' => false, 'message' => 'Delete request failed: ' . $error]; + } + return [ + 'success' => $httpCode >= 200 && $httpCode < 300, + 'message' => stobeVoiceProviderResponseMessage(strval($response), $httpCode), + ]; +} + +function stobeVoiceCloudClone(array $target, string $voiceId, string $samplePath): array +{ + $provider = strval($target['provider'] ?? ''); + $apiKey = trim(strval($target['api_key'] ?? '')); + $voiceToken = stobeSanitizeVoiceToken($voiceId); + if ($apiKey === '' || $voiceToken === '' || !is_file($samplePath)) { + return ['success' => false, 'message' => 'A cloud API credential, voice ID, and WAV sample are required.']; + } + + if ($provider === 'cartesia') { + $payload = [ + 'clip' => new CURLFile($samplePath, 'audio/wav', basename($samplePath)), + 'name' => $voiceToken, + 'description' => 'Stobe managed voice for ' . $voiceToken, + 'language' => strtolower(trim(strval($target['language'] ?? 'en'))) ?: 'en', + 'mode' => 'similarity', + ]; + $ch = curl_init('https://api.cartesia.ai/voices/clone'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => ['X-API-Key: ' . $apiKey, 'Cartesia-Version: 2026-03-01'], + CURLOPT_TIMEOUT => 90, + ]); + } else { + $audio = @file_get_contents($samplePath); + if (!is_string($audio) || $audio === '') { + return ['success' => false, 'message' => 'Could not read the normalized WAV sample.']; + } + $payload = json_encode([ + 'displayName' => $voiceToken . '_r' . gmdate('YmdHis'), + 'langCode' => stobeMapLanguageToInworld(strval($target['language'] ?? 'en')), + 'voiceSamples' => [['audioData' => base64_encode($audio)]], + 'description' => 'Stobe managed voice for ' . $voiceToken, + 'tags' => ['stobe-managed'], + ], JSON_UNESCAPED_UNICODE); + $ch = curl_init('https://api.inworld.ai/voices/v1/voices:clone'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => ['Authorization: Basic ' . $apiKey, 'Content-Type: application/json'], + CURLOPT_TIMEOUT => 90, + ]); + } + + $response = curl_exec($ch); + $httpCode = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE)); + $error = curl_error($ch); + curl_close($ch); + if (!is_string($response) || $response === '' || $httpCode < 200 || $httpCode >= 300) { + return ['success' => false, 'message' => $error !== '' ? $error : stobeVoiceProviderResponseMessage(strval($response), $httpCode)]; + } + $decoded = json_decode($response, true); + $remoteId = $provider === 'cartesia' + ? trim(strval($decoded['id'] ?? '')) + : trim(strval($decoded['voice']['voiceId'] ?? '')); + return $remoteId !== '' + ? ['success' => true, 'voice_id' => $remoteId, 'message' => 'Cloud clone created.'] + : ['success' => false, 'message' => 'The cloud provider did not return a voice ID.']; +} + +function stobeVoiceCloudValidate(array $target, string $remoteId): bool +{ + $runtime = [ + 'provider' => strval($target['provider'] ?? ''), + 'connector_id' => intval($target['id'] ?? 0), + 'voiceid' => $remoteId, + 'api_key' => strval($target['api_key'] ?? ''), + 'workspace' => strval($target['workspace'] ?? ''), + 'language' => strval($target['language'] ?? 'en'), + 'model_id' => strval($target['model_id'] ?? ''), + 'connector_config' => is_array($target['config'] ?? null) ? $target['config'] : [], + ]; + $audio = ($target['provider'] ?? '') === 'cartesia' + ? stobeSynthesizeViaCartesia('Voice synchronization test.', $runtime) + : stobeSynthesizeViaInworld('Voice synchronization test.', $runtime); + return is_string($audio) && $audio !== ''; +} + +function stobeVoiceProviderDeleteUrl(array $target, string $voiceId): string +{ + $voiceId = stobeVoiceProviderNormalizeId($voiceId); + if ($voiceId === '' || empty($target['endpoint'])) { + return ''; + } + $url = rtrim(strval($target['endpoint']), '/') . '/voices/' . rawurlencode($voiceId); + if (($target['provider'] ?? '') === 'omnivoice') { + $language = strtolower(trim(strval($target['language'] ?? ''))); + if ($language !== '') { + $url .= '?language=' . rawurlencode($language); + } + } + return $url; +} + +function stobeVoiceProviderResponseMessage(string $response, int $httpCode): string +{ + $decoded = json_decode($response, true); + if (is_array($decoded)) { + $messageValue = $decoded['detail'] ?? $decoded['message'] ?? $decoded['error'] ?? ''; + if (is_array($messageValue)) { + $message = implode(': ', array_filter([ + trim(strval($messageValue['error'] ?? '')), + trim(strval($messageValue['reason'] ?? '')), + trim(strval($messageValue['hint'] ?? '')), + ])); + } else { + $message = trim(strval($messageValue)); + } + if ($message !== '') { + return $message; + } + } + $response = trim($response); + return $response !== '' ? $response : 'Provider returned HTTP ' . $httpCode . '.'; +} + +function stobeVoiceProviderDelete(array $target, string $voiceId): array +{ + if (empty($target['can_manage'])) { + return ['success' => false, 'message' => !empty($target['cloud']) + ? 'This cloud connector is missing its API credential or workspace configuration.' + : 'This connector uses the local WAV directly and has no uploaded provider copy to remove.']; + } + if (!empty($target['cloud'])) { + $metadata = stobeVoiceCloudGetMetadata($target, $voiceId); + $remoteId = trim(strval($metadata['voice_id'] ?? '')); + if (empty($metadata['managed']) || $remoteId === '') { + return ['success' => false, 'message' => 'This cloud voice is not marked as managed by this Stobe installation.']; + } + $result = stobeVoiceCloudDeleteRemote($target, $remoteId); + if ($result['success']) { + stobeVoiceCloudDeleteConfOpt(stobeVoiceCloudCacheKey($target, $voiceId)); + stobeVoiceCloudDeleteConfOpt(stobeVoiceCloudMetadataKey($target, $voiceId)); + } + return $result; + } + $url = stobeVoiceProviderDeleteUrl($target, $voiceId); + if ($url === '') { + return ['success' => false, 'message' => 'Invalid voice ID or connector endpoint.']; + } + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ['accept: application/json'], + CURLOPT_TIMEOUT => 30, + ]); + $response = curl_exec($ch); + $httpCode = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE)); + $error = curl_error($ch); + curl_close($ch); + if ($response === false) { + return ['success' => false, 'message' => 'Delete request failed: ' . $error]; + } + return [ + 'success' => $httpCode >= 200 && $httpCode < 300, + 'message' => stobeVoiceProviderResponseMessage($response, $httpCode), + ]; +} + +function stobeVoiceProviderUpload(array $target, string $voiceId, string $samplePath): array +{ + $voiceId = stobeVoiceProviderNormalizeId($voiceId); + if (empty($target['can_manage'])) { + return ['success' => false, 'message' => !empty($target['cloud']) + ? 'This cloud connector is missing its API credential or workspace configuration.' + : 'This connector uses the local WAV directly; replacing the local sample is sufficient.']; + } + if ($voiceId === '' || !is_file($samplePath) || !is_readable($samplePath)) { + return ['success' => false, 'message' => 'A valid voice ID and readable local audio sample are required.']; + } + + $uploadPath = $samplePath; + $tempConvertedPath = ''; + $header = @file_get_contents($samplePath, false, null, 0, 12); + $isRiffWav = is_string($header) + && strlen($header) >= 12 + && substr($header, 0, 4) === 'RIFF' + && substr($header, 8, 4) === 'WAVE'; + if (!$isRiffWav) { + $ffmpegPath = trim(strval(@shell_exec('command -v ffmpeg 2>/dev/null'))); + if ($ffmpegPath === '') { + return ['success' => false, 'message' => 'ffmpeg is required to convert this sample to WAV.']; + } + $tempBase = tempnam(sys_get_temp_dir(), 'stobe_voice_provider_'); + if (!is_string($tempBase) || $tempBase === '') { + return ['success' => false, 'message' => 'Could not allocate a temporary WAV file.']; + } + @unlink($tempBase); + $tempConvertedPath = $tempBase . '.wav'; + $command = escapeshellarg($ffmpegPath) + . ' -y -i ' . escapeshellarg($samplePath) + . ' -ac 1 -ar 22050 -f wav ' . escapeshellarg($tempConvertedPath) + . ' >/dev/null 2>&1'; + $exitCode = 1; + @exec($command, $unused, $exitCode); + if ($exitCode !== 0 || !is_file($tempConvertedPath) || intval(@filesize($tempConvertedPath)) <= 44) { + @unlink($tempConvertedPath); + return ['success' => false, 'message' => 'Failed to convert the local sample to WAV.']; + } + $uploadPath = $tempConvertedPath; + } + + if (!empty($target['cloud'])) { + $cacheKey = stobeVoiceCloudCacheKey($target, $voiceId); + $metadataKey = stobeVoiceCloudMetadataKey($target, $voiceId); + $oldRemoteId = stobeConfOptGet($cacheKey); + $oldMetadata = stobeVoiceCloudGetMetadata($target, $voiceId); + $clone = stobeVoiceCloudClone($target, $voiceId, $uploadPath); + if (empty($clone['success'])) { + if ($tempConvertedPath !== '' && is_file($tempConvertedPath)) @unlink($tempConvertedPath); + return $clone; + } + $newRemoteId = trim(strval($clone['voice_id'] ?? '')); + if (!stobeVoiceCloudValidate($target, $newRemoteId)) { + stobeVoiceCloudDeleteRemote($target, $newRemoteId); + if ($tempConvertedPath !== '' && is_file($tempConvertedPath)) @unlink($tempConvertedPath); + return ['success' => false, 'message' => 'The new cloud clone failed synthesis validation; the existing voice remains active.']; + } + stobeConfOptSet($cacheKey, $newRemoteId); + stobeConfOptSet($metadataKey, json_encode([ + 'voice_id' => $newRemoteId, + 'managed' => true, + 'sample_sha256' => hash_file('sha256', $uploadPath), + 'created_at' => gmdate('c'), + ], JSON_UNESCAPED_SLASHES)); + if ( + $oldRemoteId !== '' && + $oldRemoteId !== $newRemoteId && + !empty($oldMetadata['managed']) && + trim(strval($oldMetadata['voice_id'] ?? '')) === $oldRemoteId + ) { + stobeVoiceCloudDeleteRemote($target, $oldRemoteId); + } + if ($tempConvertedPath !== '' && is_file($tempConvertedPath)) @unlink($tempConvertedPath); + return ['success' => true, 'message' => 'Cloud voice rebuilt and validated.', 'voice_id' => $newRemoteId]; + } + + $postFields = [ + 'wavFile' => new CURLFile($uploadPath, 'audio/wav', $voiceId . '.wav'), + 'force' => 'true', + 'speaker_name' => $voiceId, + 'speaker_id' => $voiceId, + ]; + if (($target['provider'] ?? '') === 'omnivoice') { + $postFields['language'] = strval($target['language'] ?? ''); + $postFields['display_name'] = $voiceId; + } + + $ch = curl_init(rtrim(strval($target['endpoint']), '/') . '/upload_sample'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $postFields, + CURLOPT_HTTPHEADER => ['accept: application/json', 'Content-Type: multipart/form-data'], + CURLOPT_TIMEOUT => 60, + ]); + $response = curl_exec($ch); + $httpCode = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE)); + $error = curl_error($ch); + curl_close($ch); + if ($tempConvertedPath !== '' && is_file($tempConvertedPath)) { + @unlink($tempConvertedPath); + } + if ($response === false) { + return ['success' => false, 'message' => 'Upload request failed: ' . $error]; + } + $success = $httpCode >= 200 && $httpCode < 300; + if ($success && ($target['provider'] ?? '') === 'omnivoice') { + $decoded = json_decode($response, true); + $status = is_array($decoded) + ? strtolower(trim(strval($decoded['import_status'] ?? $decoded['status'] ?? ''))) + : ''; + $success = in_array($status, ['runtime_ready', 'ready', 'ok'], true); + if (!$success && is_array($decoded)) { + $transcriptionError = trim(strval($decoded['transcription_error'] ?? '')); + if ($transcriptionError !== '') { + return ['success' => false, 'message' => $transcriptionError]; + } + } + } + return [ + 'success' => $success, + 'message' => stobeVoiceProviderResponseMessage($response, $httpCode), + ]; +} diff --git a/tests/tts_voice_management_test.php b/tests/tts_voice_management_test.php new file mode 100644 index 0000000..0d14cfd --- /dev/null +++ b/tests/tts_voice_management_test.php @@ -0,0 +1,68 @@ + 4, + 'name' => 'Omni', + 'connector_type' => 'omnivoice', + 'base_url' => 'http://localhost:8021/', + 'config' => json_encode(['language' => 'EN']), +]); +expectSame('http://localhost:8021/voices/npc.voice-1?language=en', stobeVoiceProviderDeleteUrl($omni, 'npc.voice-1'), 'builds OmniVoice delete URL'); + +$audioCpp = stobeVoiceProviderTarget([ + 'connector_type' => 'pocket_tts', + 'base_url' => 'http://127.0.0.1:8086', +]); +expectSame(false, $audioCpp['can_manage'], 'protects audio.cpp local samples'); + +$cartesia = stobeVoiceProviderTarget([ + 'id' => 8, + 'name' => 'Cartesia', + 'connector_type' => 'cartesia', + 'base_url' => '', + 'api_badge_key' => 'test-key', + 'config' => json_encode(['language' => 'EN']), +]); +expectSame('cartesia', $cartesia['provider'], 'normalizes Cartesia'); +expectSame(true, $cartesia['cloud'], 'marks Cartesia as cloud'); +expectSame(true, $cartesia['can_manage'], 'enables configured Cartesia management'); + +$inworldMissingWorkspace = stobeVoiceProviderTarget([ + 'id' => 9, + 'name' => 'Inworld', + 'connector_type' => 'inworld', + 'api_badge_key' => 'test-key', + 'config' => json_encode(['language' => 'EN_US']), +]); +expectSame(false, $inworldMissingWorkspace['can_manage'], 'requires Inworld workspace'); + +$inworld = stobeVoiceProviderTarget([ + 'id' => 9, + 'name' => 'Inworld', + 'connector_type' => 'inworld', + 'api_badge_key' => 'test-key', + 'config' => json_encode(['language' => 'EN_US', 'workspace' => 'workspace-1']), +]); +expectSame(true, $inworld['can_manage'], 'enables configured Inworld management'); +expectSame('not found', stobeVoiceProviderResponseMessage('{"detail":"not found"}', 404), 'extracts API detail'); +expectSame( + 'protected_voice: Only custom uploaded voices can be deleted.', + stobeVoiceProviderResponseMessage('{"detail":{"error":"protected_voice","hint":"Only custom uploaded voices can be deleted."}}', 403), + 'extracts nested FastAPI detail' +); + +fwrite(STDOUT, "tts_voice_management_test: OK\n"); diff --git a/tts/tts-pockettts.php b/tts/tts-pockettts.php index fdc5fb0..9711d85 100644 --- a/tts/tts-pockettts.php +++ b/tts/tts-pockettts.php @@ -890,7 +890,7 @@ function stobeUploadVoiceSampleToLocalEndpoint(string $endpoint, string $sampleP } $cfile = new CURLFile($uploadSourcePath, 'audio/wav', $uploadName); - $postFields = ['wavFile' => $cfile]; + $postFields = ['wavFile' => $cfile, 'force' => 'true']; if ($voiceToken !== '') { $postFields['speaker_name'] = $voiceToken; $postFields['speaker_id'] = $voiceToken; @@ -916,8 +916,7 @@ function stobeUploadVoiceSampleToLocalEndpoint(string $endpoint, string $sampleP @unlink($tempConvertedPath); } - $alreadyExists = is_string($response) && stripos($response, 'already exists') !== false; - if (($status >= 200 && $status < 300) || ($status === 400 && $alreadyExists)) { + if ($status >= 200 && $status < 300) { return true; } @@ -1056,13 +1055,35 @@ function stobeLooksLikeInworldVoiceId(string $voiceId): bool { if ($v === '') { return false; } - return str_contains($v, '__design-voice-') || str_starts_with($v, 'voices/'); + return str_contains($v, '__') || str_starts_with($v, 'voices/'); } function stobeLooksLikeCartesiaVoiceId(string $voiceId): bool { return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', trim($voiceId)) === 1; } +function stobeCloudVoiceCacheKey(string $provider, string $voiceToken, int $connectorId = 0): string { + $provider = strtolower(trim($provider)); + $voiceToken = strtolower(trim($voiceToken)); + return $connectorId > 0 + ? $provider . '_voice_scope_' . $connectorId . '__' . $voiceToken + : $provider . '_voice_id_' . $voiceToken; +} + +function stobeGetScopedCloudVoiceId(string $provider, string $voiceToken, array $runtime): string { + $connectorId = intval($runtime['connector_id'] ?? 0); + $scopedKey = stobeCloudVoiceCacheKey($provider, $voiceToken, $connectorId); + $cached = stobeConfOptGet($scopedKey); + if ($cached !== '' || $connectorId <= 0) { + return $cached; + } + $legacy = stobeConfOptGet(stobeCloudVoiceCacheKey($provider, $voiceToken, 0)); + if ($legacy !== '') { + stobeConfOptSet($scopedKey, $legacy); + } + return $legacy; +} + function stobeGetOrCreateInworldVoiceId(string $voiceId, array $runtime): string { $voiceId = trim($voiceId); if ($voiceId === '') { @@ -1076,8 +1097,8 @@ function stobeGetOrCreateInworldVoiceId(string $voiceId, array $runtime): string if ($voiceToken === '') { return ''; } - $cacheKey = 'inworld_voice_id_' . strtolower($voiceToken); - $cached = stobeConfOptGet($cacheKey); + $cacheKey = stobeCloudVoiceCacheKey('inworld', $voiceToken, intval($runtime['connector_id'] ?? 0)); + $cached = stobeGetScopedCloudVoiceId('inworld', $voiceToken, $runtime); if ($cached !== '') { return $cached; } @@ -1159,8 +1180,8 @@ function stobeGetOrCreateCartesiaVoiceId(string $voiceId, array $runtime): strin if ($voiceToken === '') { return $voiceId; } - $cacheKey = 'cartesia_voice_id_' . strtolower($voiceToken); - $cached = stobeConfOptGet($cacheKey); + $cacheKey = stobeCloudVoiceCacheKey('cartesia', $voiceToken, intval($runtime['connector_id'] ?? 0)); + $cached = stobeGetScopedCloudVoiceId('cartesia', $voiceToken, $runtime); if ($cached !== '') { return $cached; } diff --git a/ui/config_hub.php b/ui/config_hub.php index d15deab..eed8076 100644 --- a/ui/config_hub.php +++ b/ui/config_hub.php @@ -58,7 +58,7 @@ function buildTabTargetSrc(array $tab, string $webRoot): string ['id' => 'bio', 'group' => 'characters', 'icon' => '🪪', 'label' => 'NPC Biographies', 'page' => 'npc_bios.php', 'status' => 'wired', 'embed' => true], ['id' => 'llm', 'group' => 'ai-voice', 'icon' => '🧠', 'label' => 'LLM', 'page' => 'llm_connectors.php', 'status' => 'wired', 'embed' => true], ['id' => 'tts', 'group' => 'ai-voice', 'icon' => '📢', 'label' => 'TTS', 'page' => 'tts_connectors.php', 'status' => 'wired', 'embed' => true], - ['id' => 'voice', 'group' => 'ai-voice', 'icon' => '🎙️', 'label' => 'Voice Manager', 'page' => 'voice_manager.php', 'status' => 'wired', 'embed' => true], + ['id' => 'voice', 'group' => 'ai-voice', 'icon' => '🎙️', 'label' => 'TTS Studio / Voices', 'page' => 'voice_manager.php', 'status' => 'wired', 'embed' => true], ['id' => 'keys', 'group' => 'ai-voice', 'icon' => '🔑', 'label' => 'API Keys', 'page' => 'api_badges.php', 'status' => 'wired', 'embed' => true], ['id' => 'settings', 'group' => 'world-behavior', 'icon' => '🌐', 'label' => 'Global Settings', 'page' => 'settings.php', 'status' => 'wired', 'embed' => true], ['id' => 'world_knowledge', 'group' => 'world-behavior', 'icon' => '📖', 'label' => 'World Knowledge', 'page' => 'world_knowledge.php', 'status' => 'wired', 'embed' => true], diff --git a/ui/voice_manager.php b/ui/voice_manager.php index 7ed1b49..884c75c 100644 --- a/ui/voice_manager.php +++ b/ui/voice_manager.php @@ -1,6 +1,7 @@ fetchAll( + "SELECT c.id, c.name, c.connector_type, c.base_url, c.config, + COALESCE(b.api_key, '') AS api_badge_key + FROM core_tts_connector c + LEFT JOIN core_api_badge b ON b.id = c.api_badge_id + ORDER BY c.is_default DESC, c.id ASC" +) as $connectorRow) { + $target = stobeVoiceProviderTarget($connectorRow); + if ($target["provider"] === "") { + continue; + } + $providerConnectors[] = $target; + $providerConnectorMap[intval($target["id"])] = $target; +} if ($_SERVER["REQUEST_METHOD"] === "POST") { - if (isset($_POST["save_voice"])) { + if (isset($_POST["provider_voice_action"])) { + $connectorId = intval($_POST["connector_id"] ?? 0); + $voiceid = stobeVoiceProviderNormalizeId(strval($_POST["voiceid"] ?? "")); + $providerAction = strtolower(stobe_voice_trim($_POST["provider_voice_action"] ?? "")); + $target = $providerConnectorMap[$connectorId] ?? null; + + if (!is_array($target)) { + $message = "Select a configured TTS connector."; + $messageType = "err"; + } elseif ($voiceid === "") { + $message = "Enter a valid voice ID."; + $messageType = "err"; + } elseif ($providerAction === "delete") { + $result = stobeVoiceProviderDelete($target, $voiceid); + if ($result["success"]) { + $syncKey = "tts_sync_v2_" . $target["provider"] . "_" . md5(strtolower($voiceid)); + $db->exec("DELETE FROM conf_opts WHERE id = $1", [$syncKey]); + header("Location: " . stobe_voice_build_url(["ok" => "provider_deleted", "provider_voice" => $voiceid], $isEmbed, "provider-voices")); + exit; + } + $message = "Could not remove provider voice: " . $result["message"]; + $messageType = "err"; + } elseif ($providerAction === "sync") { + $sampleRow = $db->fetchOne( + "SELECT sample_file FROM combined_core_voiceid WHERE LOWER(voiceid) = LOWER($1) LIMIT 1", + [$voiceid] + ); + $sampleFile = stobe_voice_normalize_sample_file($sampleRow["sample_file"] ?? ""); + $samplePath = $sampleFile !== "" ? $voicesDir . DIRECTORY_SEPARATOR . $sampleFile : ""; + $result = stobeVoiceProviderUpload($target, $voiceid, $samplePath); + if ($result["success"]) { + $fileHash = is_file($samplePath) ? strval(md5_file($samplePath)) : ""; + if ($fileHash !== "") { + $syncKey = "tts_sync_v2_" . $target["provider"] . "_" . md5(strtolower($voiceid)); + $db->exec( + "INSERT INTO conf_opts (id, value, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (id) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()", + [$syncKey, $fileHash] + ); + } + header("Location: " . stobe_voice_build_url(["ok" => "provider_synced", "provider_voice" => $voiceid], $isEmbed, "provider-voices")); + exit; + } + $message = "Could not upload provider voice: " . $result["message"]; + $messageType = "err"; + } else { + $message = "Unknown provider voice action."; + $messageType = "err"; + } + } elseif (isset($_POST["save_voice"])) { $originalVoiceId = stobe_voice_normalize_voiceid($_POST["original_voiceid"] ?? ""); $voiceid = stobe_voice_normalize_voiceid($_POST["voiceid"] ?? ""); if ($originalVoiceId !== "") { @@ -267,6 +332,10 @@ function stobe_voice_store_upload(array $file, string $voiceid, string $voicesDi $message = "Custom voice override deleted."; } elseif ($ok === "reset") { $message = "All custom voice overrides cleared."; + } elseif ($ok === "provider_deleted") { + $message = "Provider copy removed. The local voice sample remains available for re-upload."; + } elseif ($ok === "provider_synced") { + $message = "Voice sample uploaded to the provider. Existing provider artifacts were replaced."; } } @@ -316,6 +385,7 @@ function stobe_voice_store_upload(array $file, string $voiceid, string $voicesDi ); $editVoiceId = stobe_voice_normalize_voiceid($_GET["edit"] ?? ""); +$providerVoiceId = stobeVoiceProviderNormalizeId(strval($_GET["provider_voice"] ?? $editVoiceId)); $editRow = false; if ($editVoiceId !== "") { $editRow = $db->fetchOne( @@ -343,7 +413,7 @@ function stobe_voice_store_upload(array $file, string $voiceid, string $voicesDi - Voice Manager + TTS Studio & Voice Manager @@ -561,6 +631,14 @@ function stobe_voice_store_upload(array $file, string $voiceid, string $voicesDi max-width: 100%; height: 34px; } + .provider-form { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto; + gap: 14px; + align-items: end; + } + .provider-form .action-row { margin-bottom: 10px; } + .provider-form label { margin-top: 0; } .small-muted { color: #9fb1c9; font-size: 12px; @@ -596,6 +674,7 @@ function stobe_voice_store_upload(array $file, string $voiceid, string $voicesDi grid-template-columns: 1fr; gap: 20px; } + .provider-form { grid-template-columns: 1fr; } } @@ -608,8 +687,8 @@ function stobe_voice_store_upload(array $file, string $voiceid, string $voicesDi
@@ -687,6 +766,41 @@ class="btn-danger"

Tip: use sample_file values that exist under data/voices for local sample preview and consistency.

+
+

Connector Voice Copies

+

Upload or rebuild the selected local sample on a connector, or remove only its provider-side copy. Cartesia and Inworld use clone-first replacement: the new clone is validated before its ID becomes active. Removing a provider copy never deletes the WAV under data/voices.

+ +

No manageable TTS connector is configured.

+ +
+
+ + +
+
+ + + + + + + +
+
+ + +
+
+

OmniVoice uses the connector language. Cartesia and Inworld require a configured API badge; Inworld also requires a workspace. Cloud deletion is allowed only for clones marked as created by this Stobe installation.

+ +
+
@@ -757,6 +871,7 @@ class="btn-danger" $voiceid], $isEmbed, "edit-form")) ?>">Edit + $voiceid], $isEmbed, "provider-voices")) ?>">Manage Provider