diff --git a/tts/tts-cartesia.php b/tts/tts-cartesia.php index 3b329c8..4ac1c5d 100644 --- a/tts/tts-cartesia.php +++ b/tts/tts-cartesia.php @@ -202,6 +202,43 @@ function deleteCachedCartesiaVoiceId($voiceName, ?array $config = null): void { $db->execQuery("DELETE FROM conf_opts WHERE id = '{$optKeyEscaped}'"); } +function getCartesiaVoiceMetadataKey($voiceName, ?array $config = null): string { + return 'cartesia_voice_meta_' . substr(md5(getCartesiaVoiceCachePrefix($config)), 0, 12) . '__' . $voiceName; +} + +function getCartesiaVoiceMetadata($voiceName, ?array $config = null): array { + $db = ensureCartesiaDb(); + if (!$db) return []; + $key = $db->escape(getCartesiaVoiceMetadataKey($voiceName, $config)); + $row = $db->fetchOne("SELECT value FROM conf_opts WHERE id = '{$key}' LIMIT 1"); + $decoded = json_decode(strval($row['value'] ?? ''), true); + return is_array($decoded) ? $decoded : []; +} + +function storeCartesiaVoiceMetadata($voiceName, $voiceId, $samplePath, bool $managed, ?array $config = null): void { + $db = ensureCartesiaDb(); + if (!$db) return; + $metadata = ['voice_id' => trim(strval($voiceId)), 'managed' => $managed, 'sample_sha256' => is_file($samplePath) ? hash_file('sha256', $samplePath) : '', 'created_at' => gmdate('c')]; + $key = $db->escape(getCartesiaVoiceMetadataKey($voiceName, $config)); + $value = $db->escape(json_encode($metadata, JSON_UNESCAPED_SLASHES)); + $db->execQuery("INSERT INTO conf_opts (id, value) VALUES ('{$key}', '{$value}') ON CONFLICT(id) DO UPDATE SET value = '{$value}'"); +} + +function deleteCartesiaVoiceMetadata($voiceName, ?array $config = null): void { + $db = ensureCartesiaDb(); + if (!$db) return; + $key = $db->escape(getCartesiaVoiceMetadataKey($voiceName, $config)); + $db->execQuery("DELETE FROM conf_opts WHERE id = '{$key}'"); +} + +function findCartesiaVoiceSamplePath($voiceName): string { + foreach ([__DIR__ . "/../data/voices/{$voiceName}.wav", "/var/www/html/DialecticServer/data/voices/{$voiceName}.wav"] as $path) { + $resolved = realpath($path); + if ($resolved !== false && is_file($resolved)) return $resolved; + } + return ''; +} + function getCartesiaCachedVoicesMap(?array $config = null): array { $db = ensureCartesiaDb(); if (!$db) { @@ -363,6 +400,7 @@ function getOrCreateCartesiaVoice($voiceName) { if ($existingVoiceId !== '') { Logger::info("Found existing Cartesia voice for {$voiceName}: {$existingVoiceId}"); storeCachedCartesiaVoiceId($voiceName, $existingVoiceId, $config); + storeCartesiaVoiceMetadata($voiceName, $existingVoiceId, '', false, $config); return $existingVoiceId; } @@ -410,6 +448,7 @@ function getOrCreateCartesiaVoice($voiceName) { } storeCachedCartesiaVoiceId($voiceName, $cartesiaVoiceId, $config); + storeCartesiaVoiceMetadata($voiceName, $cartesiaVoiceId, $voiceSamplePath, true, $config); clearCartesiaLastError(); Logger::info("Successfully cloned voice {$voiceName} to Cartesia with ID: {$cartesiaVoiceId}"); @@ -560,6 +599,66 @@ function cloneVoiceToCartesia($voiceName, $voiceSamplePath, ?array $config = nul return $result['id']; } +function deleteCartesiaRemoteVoice($voiceId, ?array $config = null): bool { + $config = is_array($config) ? $config : getCartesiaActiveConfig(); + $apiKey = trim(strval($config['api_key'] ?? '')); + $voiceId = trim(strval($voiceId)); + if ($apiKey === '' || $voiceId === '') return false; + $context = stream_context_create(['http' => ['method' => 'DELETE', 'header' => "X-API-Key: {$apiKey}\r\nCartesia-Version: 2026-03-01\r\nAccept: application/json\r\n", 'ignore_errors' => true, 'timeout' => 30]]); + $response = @file_get_contents('https://api.cartesia.ai/voices/' . rawurlencode($voiceId), false, $context); + $statusLine = $http_response_header[0] ?? ''; + $httpCode = preg_match('/\s(\d{3})\s/', $statusLine, $matches) ? intval($matches[1]) : 0; + if ($httpCode >= 200 && $httpCode < 300) { + unset($GLOBALS['CARTESIA_REMOTE_VOICES_CACHE']); + return true; + } + setCartesiaLastError("Cartesia delete API returned HTTP {$httpCode}."); + Logger::warn("Failed to delete Cartesia voice {$voiceId}: HTTP {$httpCode}; " . substr(strval($response), 0, 500)); + return false; +} + +function rebuildCartesiaVoice($voiceName) { + clearCartesiaLastError(); + $config = getCartesiaActiveConfig(); + $samplePath = findCartesiaVoiceSamplePath($voiceName); + if ($samplePath === '') { + setCartesiaLastError("Voice sample not found for {$voiceName}."); + return false; + } + $oldVoiceId = getCachedCartesiaVoiceId($voiceName, $config); + $oldMetadata = getCartesiaVoiceMetadata($voiceName, $config); + $newVoiceId = cloneVoiceToCartesia($voiceName, $samplePath, $config); + if ($newVoiceId === false || $newVoiceId === '') return false; + $audio = generateCartesiaTTS('Voice synchronization test.', $newVoiceId); + if (!is_string($audio) || $audio === '') { + $error = getCartesiaLastError(); + deleteCartesiaRemoteVoice($newVoiceId, $config); + setCartesiaLastError($error !== '' ? $error : 'The new Cartesia clone failed validation.'); + return false; + } + storeCachedCartesiaVoiceId($voiceName, $newVoiceId, $config); + storeCartesiaVoiceMetadata($voiceName, $newVoiceId, $samplePath, true, $config); + if ($oldVoiceId !== '' && $oldVoiceId !== $newVoiceId && !empty($oldMetadata['managed']) && trim(strval($oldMetadata['voice_id'] ?? '')) === $oldVoiceId) deleteCartesiaRemoteVoice($oldVoiceId, $config); + clearCartesiaLastError(); + return $newVoiceId; +} + +function deleteManagedCartesiaVoice($voiceName): bool { + clearCartesiaLastError(); + $config = getCartesiaActiveConfig(); + $voiceId = getCachedCartesiaVoiceId($voiceName, $config); + $metadata = getCartesiaVoiceMetadata($voiceName, $config); + if ($voiceId === '' || empty($metadata['managed']) || trim(strval($metadata['voice_id'] ?? '')) !== $voiceId) { + setCartesiaLastError('This Cartesia voice is not marked as managed by this installation; only its cached ID can be forgotten.'); + return false; + } + if (!deleteCartesiaRemoteVoice($voiceId, $config)) return false; + deleteCachedCartesiaVoiceId($voiceName, $config); + deleteCartesiaVoiceMetadata($voiceName, $config); + clearCartesiaLastError(); + return true; +} + /** * Generate TTS audio from Cartesia * diff --git a/tts/tts-inworld.php b/tts/tts-inworld.php index 0cb449a..4f22f5a 100644 --- a/tts/tts-inworld.php +++ b/tts/tts-inworld.php @@ -368,6 +368,35 @@ function deleteCachedInworldVoiceId($voiceName, ?array $config = null): void { $db->execQuery("DELETE FROM conf_opts WHERE id IN ('{$optKeyEscaped}', '{$fingerprintKey}')"); } +function getInworldVoiceMetadataKey($voiceName, ?array $config = null): string { + return 'inworld_voice_meta_' . substr(md5(getInworldVoiceCachePrefix($config)), 0, 12) . '__' . $voiceName; +} + +function getInworldVoiceMetadata($voiceName, ?array $config = null): array { + $db = ensureInworldDb(); + if (!$db) return []; + $key = $db->escape(getInworldVoiceMetadataKey($voiceName, $config)); + $row = $db->fetchOne("SELECT value FROM conf_opts WHERE id = '{$key}' LIMIT 1"); + $decoded = json_decode(strval($row['value'] ?? ''), true); + return is_array($decoded) ? $decoded : []; +} + +function storeInworldVoiceMetadata($voiceName, $voiceId, $samplePath, bool $managed, ?array $config = null): void { + $db = ensureInworldDb(); + if (!$db) return; + $metadata = ['voice_id' => trim(strval($voiceId)), 'managed' => $managed, 'sample_sha256' => is_file($samplePath) ? hash_file('sha256', $samplePath) : '', 'created_at' => gmdate('c')]; + $key = $db->escape(getInworldVoiceMetadataKey($voiceName, $config)); + $value = $db->escape(json_encode($metadata, JSON_UNESCAPED_SLASHES)); + $db->execQuery("INSERT INTO conf_opts (id, value) VALUES ('{$key}', '{$value}') ON CONFLICT(id) DO UPDATE SET value = '{$value}'"); +} + +function deleteInworldVoiceMetadata($voiceName, ?array $config = null): void { + $db = ensureInworldDb(); + if (!$db) return; + $key = $db->escape(getInworldVoiceMetadataKey($voiceName, $config)); + $db->execQuery("DELETE FROM conf_opts WHERE id = '{$key}'"); +} + function getInworldCachedVoicesMap(?array $config = null): array { $db = ensureInworldDb(); if (!$db) { @@ -582,18 +611,18 @@ function getOrCreateInworldVoice($voiceName) { return $cachedVoiceId; } + $voiceSamplePath = findInworldVoiceSamplePath($voiceName); $existingVoiceId = getExistingInworldVoiceIdByName($voiceName, $config); if ($existingVoiceId !== '') { Logger::info("Found existing Inworld voice for {$voiceName}: {$existingVoiceId}"); storeCachedInworldVoiceId($voiceName, $existingVoiceId, $config); + storeInworldVoiceMetadata($voiceName, $existingVoiceId, $voiceSamplePath, false, $config); return $existingVoiceId; } // No cached voice ID, need to clone the voice Logger::info("No cached Inworld voice ID found for {$voiceName}, cloning voice..."); - $voiceSamplePath = findInworldVoiceSamplePath($voiceName); - if ($voiceSamplePath === '') { $message = "Voice sample not found for {$voiceName} in data/voices."; setInworldLastError($message); @@ -615,6 +644,7 @@ function getOrCreateInworldVoice($voiceName) { } storeCachedInworldVoiceId($voiceName, $inworldVoiceId, $config); + storeInworldVoiceMetadata($voiceName, $inworldVoiceId, $voiceSamplePath, true, $config); clearInworldLastError(); Logger::info("Successfully cloned voice {$voiceName} to Inworld with ID: {$inworldVoiceId}"); @@ -630,7 +660,7 @@ function getOrCreateInworldVoice($voiceName) { * @param array|null $config Resolved connector-scoped Inworld configuration * @return string|false The Inworld voice ID or false on error */ -function cloneVoiceToInworld($voiceName, $voiceSamplePath, ?array $config = null) { +function cloneVoiceToInworld($voiceName, $voiceSamplePath, ?array $config = null, string $displayName = '') { $config = is_array($config) ? $config : getInworldActiveConfig(); $apiCredential = trim(strval($config['api_key'] ?? '')); @@ -675,10 +705,13 @@ function cloneVoiceToInworld($voiceName, $voiceSamplePath, ?array $config = null Logger::info("Read " . number_format(strlen($audioContent)) . " bytes from voice file"); $audioBase64 = base64_encode($audioContent); + if ($displayName === '') { + $displayName = $voiceName; + } // Prepare request data $data = array( - 'displayName' => $voiceName, + 'displayName' => $displayName, 'langCode' => $language, 'voiceSamples' => array( array( @@ -770,6 +803,67 @@ function cloneVoiceToInworld($voiceName, $voiceSamplePath, ?array $config = null return $result['voice']['voiceId']; } +function deleteInworldRemoteVoice($voiceId, ?array $config = null): bool { + $config = is_array($config) ? $config : getInworldActiveConfig(); + $apiCredential = trim(strval($config['api_key'] ?? '')); + $voiceId = trim(strval($voiceId)); + if ($apiCredential === '' || $voiceId === '') return false; + $context = stream_context_create(['http' => ['method' => 'DELETE', 'header' => "Authorization: Basic {$apiCredential}\r\nAccept: application/json\r\n", 'ignore_errors' => true, 'timeout' => 30]]); + $response = @file_get_contents('https://api.inworld.ai/voices/v1/voices/' . rawurlencode($voiceId), false, $context); + $statusLine = $http_response_header[0] ?? ''; + $httpCode = preg_match('/\s(\d{3})\s/', $statusLine, $matches) ? intval($matches[1]) : 0; + if ($httpCode >= 200 && $httpCode < 300) { + unset($GLOBALS['INWORLD_REMOTE_VOICES_CACHE']); + return true; + } + setInworldLastError("Inworld delete API returned HTTP {$httpCode}."); + Logger::warn("Failed to delete Inworld voice {$voiceId}: HTTP {$httpCode}; " . substr(strval($response), 0, 500)); + return false; +} + +function rebuildInworldVoice($voiceName) { + clearInworldLastError(); + $config = getInworldActiveConfig(); + $samplePath = findInworldVoiceSamplePath($voiceName); + if ($samplePath === '') { + setInworldLastError("Voice sample not found for {$voiceName}."); + return false; + } + $oldVoiceId = getCachedInworldVoiceId($voiceName, $config); + $oldMetadata = getInworldVoiceMetadata($voiceName, $config); + $displayName = $voiceName . '_r' . gmdate('YmdHis'); + $newVoiceId = cloneVoiceToInworld($voiceName, $samplePath, $config, $displayName); + if ($newVoiceId === false || $newVoiceId === '') return false; + $audio = generateInworldTTS('Voice synchronization test.', $newVoiceId); + if (!is_string($audio) || $audio === '') { + $error = getInworldLastError(); + deleteInworldRemoteVoice($newVoiceId, $config); + setInworldLastError($error !== '' ? $error : 'The new Inworld clone failed validation.'); + return false; + } + storeCachedInworldVoiceId($voiceName, $newVoiceId, $config); + storeInworldVoiceMetadata($voiceName, $newVoiceId, $samplePath, true, $config); + if ($oldVoiceId !== '' && $oldVoiceId !== $newVoiceId && !empty($oldMetadata['managed']) && trim(strval($oldMetadata['voice_id'] ?? '')) === $oldVoiceId) deleteInworldRemoteVoice($oldVoiceId, $config); + clearInworldLastError(); + return $newVoiceId; +} + +function deleteManagedInworldVoice($voiceName): bool { + clearInworldLastError(); + $config = getInworldActiveConfig(); + $voiceId = getCachedInworldVoiceId($voiceName, $config); + $metadata = getInworldVoiceMetadata($voiceName, $config); + if ($voiceId === '' || empty($metadata['managed']) || trim(strval($metadata['voice_id'] ?? '')) !== $voiceId) { + setInworldLastError('This Inworld voice is not marked as managed by this installation; only its cached ID can be forgotten.'); + return false; + } + if (!deleteInworldRemoteVoice($voiceId, $config)) return false; + deleteCachedInworldVoiceId($voiceName, $config); + deleteInworldVoiceMetadata($voiceName, $config); + clearInworldLastError(); + return true; +} + /** * Generate TTS audio from Inworld * diff --git a/ui/xtts_clone.php b/ui/xtts_clone.php index 4661c34..a49746a 100644 --- a/ui/xtts_clone.php +++ b/ui/xtts_clone.php @@ -377,18 +377,88 @@ function dialecticTtsStudioFetchOmniVoiceVoiceItems(string $language): array if (!function_exists('dialecticTtsStudioVoiceUploadPostFields')) { function dialecticTtsStudioVoiceUploadPostFields(string $driver, string $voicePath, string $fileType, string $fileName, string $language = ''): array { - $postFields = ['wavFile' => new CURLFile($voicePath, $fileType, $fileName)]; + $postFields = [ + 'wavFile' => new CURLFile($voicePath, $fileType, $fileName), + 'force' => 'true', + ]; if ($driver === 'omnivoice') { $voiceName = pathinfo($fileName, PATHINFO_FILENAME); $postFields['language'] = dialecticTtsStudioResolveOmniVoiceLanguage($language); $postFields['speaker_name'] = $voiceName; $postFields['display_name'] = $voiceName; - $postFields['force'] = 'true'; } return $postFields; } } +if (!function_exists('dialecticTtsStudioDeleteVoice')) { + function dialecticTtsStudioDeleteVoice(string $driver, string $voice, string $language = ''): array + { + $endpoint = dialecticTtsStudioResolveEndpointForDriver($driver); + if ($endpoint === '') { + return ['success' => false, 'message' => 'No endpoint is configured for this provider.']; + } + if (dialecticTtsStudioIsAudioCppPocketTts($driver, $endpoint)) { + return [ + 'success' => false, + 'message' => 'audio.cpp uses the local data/voices sample directly; upload a replacement local WAV instead.', + ]; + } + + $voice = preg_replace('/\.wav$/i', '', trim($voice)); + if ($voice === '' || !preg_match('/^[A-Za-z0-9][A-Za-z0-9_.-]*$/', $voice)) { + return ['success' => false, 'message' => 'Invalid voice ID.']; + } + + $url = rtrim($endpoint, '/') . '/voices/' . rawurlencode($voice); + if ($driver === 'omnivoice') { + $url .= '?language=' . rawurlencode(dialecticTtsStudioResolveOmniVoiceLanguage($language)); + } + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['accept: application/json']); + $response = curl_exec($ch); + $httpCode = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE)); + $curlError = curl_error($ch); + curl_close($ch); + + if ($response === false) { + return ['success' => false, 'message' => 'Delete request failed: ' . $curlError]; + } + + $decoded = json_decode($response, true); + $detailValue = is_array($decoded) + ? ($decoded['detail'] ?? $decoded['message'] ?? $decoded['error'] ?? '') + : ''; + if (is_array($detailValue)) { + $detailParts = array_filter([ + trim(strval($detailValue['error'] ?? '')), + trim(strval($detailValue['reason'] ?? '')), + trim(strval($detailValue['hint'] ?? '')), + ]); + $detail = implode(': ', $detailParts); + } else { + $detail = trim(strval($detailValue)); + } + if ($detail === '' && !is_array($decoded)) { + $detail = trim($response); + } + if ($httpCode >= 200 && $httpCode < 300) { + return ['success' => true, 'message' => $detail !== '' ? $detail : 'Voice removed from provider.']; + } + + if ($httpCode === 404 && $detail === '') { + $detail = 'Voice was not found, or this connector version does not support deletion.'; + } + return [ + 'success' => false, + 'message' => $detail !== '' ? $detail : 'Provider returned HTTP ' . $httpCode . '.', + ]; + } +} + if (!function_exists('dialecticTtsStudioVoiceUploadResult')) { function dialecticTtsStudioVoiceUploadResult(string $driver, int $httpCode, string $response): array { @@ -2001,7 +2071,8 @@ function extractWavFromZip($zipPath, $destDir) { dialecticTtsStudioApplyConnectorGlobals('cartesia'); $voice = resolveLocalVoiceName($_POST['voice']); if ($voice !== '') { - deleteCachedCartesiaVoiceId($voice, null, true); + deleteCachedCartesiaVoiceId($voice); + deleteCartesiaVoiceMetadata($voice); $cartesiaMessage .= "
Unsynced Cartesia voice cache for " . htmlspecialchars($voice) . ".
"; } else { $cartesiaMessage .= "Voice file not found.
"; @@ -2018,8 +2089,7 @@ function extractWavFromZip($zipPath, $destDir) { $voice = resolveLocalVoiceName($_POST['voice']); $voiceSamplePath = __DIR__ . '/../data/voices/' . $voice . '.wav'; if ($voice !== '' && file_exists($voiceSamplePath)) { - deleteCachedCartesiaVoiceId($voice, null, true); - $result = getOrCreateCartesiaVoice($voice); + $result = rebuildCartesiaVoice($voice); if ($result !== false && !empty($result)) { header('Location: ' . $webRoot . '/ui/xtts_clone.php?tab=cartesia&synced=' . urlencode($voice)); exit; @@ -2034,12 +2104,24 @@ function extractWavFromZip($zipPath, $destDir) { } } + if (isset($_POST['action']) && $_POST['action'] === 'delete_cartesia_single' && isset($_POST['voice'])) { + dialecticTtsStudioApplyConnectorGlobals('cartesia'); + $voice = resolveLocalVoiceName($_POST['voice']); + if ($voice !== '' && deleteManagedCartesiaVoice($voice)) { + $cartesiaMessage .= "Deleted managed Cartesia clone for " . htmlspecialchars($voice) . ".
"; + } else { + $error = getCartesiaLastError(); + $cartesiaMessage .= "" . htmlspecialchars($error !== '' ? $error : 'Could not delete Cartesia voice.') . "
"; + } + } + // Cartesia clear cache handler if (isset($_POST['action']) && $_POST['action'] === 'clear_cartesia_cache') { $db = $GLOBALS["db"]; $legacyPrefixEscaped = $db->escape('cartesia_voice_id_'); $scopedPrefixEscaped = $db->escape('cartesia_voice_scope_'); - $db->execQuery("DELETE FROM conf_opts WHERE id LIKE '{$legacyPrefixEscaped}%' OR id LIKE '{$scopedPrefixEscaped}%'"); + $metadataPrefixEscaped = $db->escape('cartesia_voice_meta_'); + $db->execQuery("DELETE FROM conf_opts WHERE id LIKE '{$legacyPrefixEscaped}%' OR id LIKE '{$scopedPrefixEscaped}%' OR id LIKE '{$metadataPrefixEscaped}%'"); $cartesiaMessage .= "Cartesia voice cache cleared.
"; } @@ -2198,7 +2280,8 @@ function extractWavFromZip($zipPath, $destDir) { dialecticTtsStudioApplyConnectorGlobals('inworld'); $voice = resolveLocalVoiceName($_POST['voice']); if ($voice !== '') { - deleteCachedInworldVoiceId($voice, null, true); + deleteCachedInworldVoiceId($voice); + deleteInworldVoiceMetadata($voice); $inworldMessage .= "Unsynced Inworld voice cache for " . htmlspecialchars($voice) . ".
"; } else { $inworldMessage .= "Voice file not found.
"; @@ -2215,8 +2298,7 @@ function extractWavFromZip($zipPath, $destDir) { $voice = resolveLocalVoiceName($_POST['voice']); $voiceSamplePath = __DIR__ . '/../data/voices/' . $voice . '.wav'; if ($voice !== '' && file_exists($voiceSamplePath)) { - deleteCachedInworldVoiceId($voice, null, true); - $result = getOrCreateInworldVoice($voice); + $result = rebuildInworldVoice($voice); if ($result !== false && !empty($result)) { header('Location: ' . $webRoot . '/ui/xtts_clone.php?tab=inworld&synced=' . urlencode($voice)); exit; @@ -2231,12 +2313,24 @@ function extractWavFromZip($zipPath, $destDir) { } } + if (isset($_POST['action']) && $_POST['action'] === 'delete_inworld_single' && isset($_POST['voice'])) { + dialecticTtsStudioApplyConnectorGlobals('inworld'); + $voice = resolveLocalVoiceName($_POST['voice']); + if ($voice !== '' && deleteManagedInworldVoice($voice)) { + $inworldMessage .= "Deleted managed Inworld clone for " . htmlspecialchars($voice) . ".
"; + } else { + $error = getInworldLastError(); + $inworldMessage .= "" . htmlspecialchars($error !== '' ? $error : 'Could not delete Inworld voice.') . "
"; + } + } + // Inworld clear cache handler if (isset($_POST['action']) && $_POST['action'] === 'clear_inworld_cache') { $db = $GLOBALS["db"]; $legacyPrefixEscaped = $db->escape('inworld_voice_id_'); $scopedPrefixEscaped = $db->escape('inworld_voice_scope_'); - $db->execQuery("DELETE FROM conf_opts WHERE id LIKE '{$legacyPrefixEscaped}%' OR id LIKE '{$scopedPrefixEscaped}%'"); + $metadataPrefixEscaped = $db->escape('inworld_voice_meta_'); + $db->execQuery("DELETE FROM conf_opts WHERE id LIKE '{$legacyPrefixEscaped}%' OR id LIKE '{$scopedPrefixEscaped}%' OR id LIKE '{$metadataPrefixEscaped}%'"); $inworldMessage .= "Inworld voice cache cleared.
"; } @@ -2304,6 +2398,41 @@ function extractWavFromZip($zipPath, $destDir) { } + // Remove only the provider-side copy. The local data/voices WAV remains available for re-upload. + if (isset($_POST['action'], $_POST['voice']) && + in_array($_POST['action'], ['delete_xtts_single', 'delete_chatterbox_single', 'delete_pockettts_single', 'delete_omnivoice_single'], true)) { + $deleteActionTabs = [ + 'delete_xtts_single' => 'xtts', + 'delete_chatterbox_single' => 'chatterbox', + 'delete_pockettts_single' => 'pockettts', + 'delete_omnivoice_single' => 'omnivoice', + ]; + $redirectTab = $deleteActionTabs[$_POST['action']]; + $driver = dialecticTtsStudioTabToDriver($redirectTab); + $voice = trim(strval($_POST['voice'])); + $language = $driver === 'omnivoice' + ? dialecticTtsStudioResolveOmniVoiceLanguage($_POST['language'] ?? ($_GET['language'] ?? '')) + : ''; + $deleteResult = dialecticTtsStudioDeleteVoice($driver, $voice, $language); + + if ($deleteResult['success']) { + dialecticTtsStudioStoreSpeakersList( + $driver, + dialecticTtsStudioFetchSpeakersList($driver, $language), + $language + ); + $redirect = $webRoot . '/ui/xtts_clone.php?tab=' . rawurlencode($redirectTab) . '&deleted=' . rawurlencode($voice); + if ($language !== '') { + $redirect .= '&language=' . rawurlencode($language); + } + header('Location: ' . $redirect); + exit; + } + + $message .= 'Could not remove voice from provider: ' + . htmlspecialchars($deleteResult['message']) . '
'; + } + // XTTS/Chatterbox/PocketTTS/OmniVoice single voice sync handler if (isset($_POST['action']) && isset($_POST['voice']) && in_array($_POST['action'], ['sync_xtts_single', 'sync_chatterbox_single', 'sync_pockettts_single', 'sync_omnivoice_single'])) { @@ -2572,9 +2701,10 @@ function extractWavFromZip($zipPath, $destDir) { // Clean up URL after showing success message window.addEventListener('DOMContentLoaded', function() { const url = new URL(window.location); - if (url.searchParams.has('synced')) { - // Remove the 'synced' parameter from URL without refreshing + if (url.searchParams.has('synced') || url.searchParams.has('deleted')) { + // Remove one-time result parameters from the URL without refreshing. url.searchParams.delete('synced'); + url.searchParams.delete('deleted'); window.history.replaceState({}, '', url); } }); @@ -2813,11 +2943,49 @@ function syncSingleVoice(provider, voiceName) { form.submit(); } + function deleteProviderVoice(provider, voiceName) { + const providerNames = { + xtts: 'XTTS', + chatterbox: 'Chatterbox', + pockettts: 'PocketTTS', + omnivoice: 'OmniVoice' + }; + const providerName = providerNames[provider] || provider; + if (!window.confirm('Remove "' + voiceName + '" from ' + providerName + '? The local WAV will be kept for re-upload.')) { + return; + } + + showLoadingMessage('Removing voice from ' + providerName + ', please wait...'); + const form = document.createElement('form'); + form.method = 'POST'; + form.action = WEB_ROOT + '/ui/xtts_clone.php?tab=' + provider; + + const actionInput = document.createElement('input'); + actionInput.type = 'hidden'; + actionInput.name = 'action'; + actionInput.value = 'delete_' + provider + '_single'; + + const voiceInput = document.createElement('input'); + voiceInput.type = 'hidden'; + voiceInput.name = 'voice'; + voiceInput.value = voiceName; + + form.appendChild(actionInput); + form.appendChild(voiceInput); + appendProviderLanguageInput(form, provider); + document.body.appendChild(form); + form.submit(); + } + function manageCloudVoice(provider, voiceName, mode) { const actionTextMap = { unsync: 'Forgetting cached voice ID for ' + provider, - resync: 'Regenerating voice for ' + provider + resync: 'Rebuilding voice from the local sample for ' + provider, + delete: 'Deleting managed cloud voice from ' + provider }; + if (mode === 'delete' && !confirm('Delete the managed ' + provider + ' cloud clone for ' + voiceName + '? The local sample will be kept.')) { + return; + } showLoadingMessage((actionTextMap[mode] || 'Processing voice for ' + provider) + ', please wait...'); const form = document.createElement('form'); @@ -3251,7 +3419,8 @@ function cancelBatchUpload() { .sync-btn, .unsync-btn, - .resync-btn { + .resync-btn, + .delete-provider-btn { opacity: 0.4; background: none; border: none; @@ -3264,7 +3433,8 @@ function cancelBatchUpload() { .voice-status-item:hover .sync-btn, .voice-status-item:hover .unsync-btn, - .voice-status-item:hover .resync-btn { + .voice-status-item:hover .resync-btn, + .voice-status-item:hover .delete-provider-btn { opacity: 0.8; } @@ -3275,7 +3445,8 @@ function cancelBatchUpload() { color: rgb(242, 124, 17); } - .unsync-btn:hover:not(:disabled) { + .unsync-btn:hover:not(:disabled), + .delete-provider-btn:hover:not(:disabled) { opacity: 1 !important; transform: scale(1.1); color: #f44336; @@ -3627,6 +3798,11 @@ function cancelBatchUpload() {Successfully synced voice '' to server.
+ + + @@ -4307,7 +4498,12 @@ class="sync-btn" title="Import this voice into OmniVoice">↻