From acb3ce701e7cfeaea2faee84b21d9e98aef5c09d Mon Sep 17 00:00:00 2001 From: RANGROO Date: Wed, 15 Jul 2026 18:50:02 -0700 Subject: [PATCH 1/2] Add TTS Studio voice removal controls --- ui/xtts_clone.php | 177 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 170 insertions(+), 7 deletions(-) diff --git a/ui/xtts_clone.php b/ui/xtts_clone.php index 4661c34..1573f1b 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 { @@ -2304,6 +2374,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 +2677,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,6 +2919,40 @@ 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, @@ -3251,7 +3391,8 @@ function cancelBatchUpload() { .sync-btn, .unsync-btn, - .resync-btn { + .resync-btn, + .delete-provider-btn { opacity: 0.4; background: none; border: none; @@ -3264,7 +3405,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 +3417,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 +3770,11 @@ function cancelBatchUpload() {

Successfully synced voice '' to server.

+ +
+

Removed provider copy of ''. The local WAV is still available to re-upload.

+
+

Successfully generated voice '' for Cartesia.

@@ -3699,6 +3847,8 @@ function cancelBatchUpload() { + @@ -3849,6 +3999,8 @@ class="sync-btn" title="Sync this voice to XTTS server">↻ + @@ -3963,6 +4115,8 @@ class="sync-btn" title="Sync this voice to Chatterbox server">↻ $displayName = basename($speaker, '.wav'); $xttsVoices[$displayName] = true; } + $pocketTtsEndpoint = dialecticTtsStudioResolveEndpointForDriver('pockettts'); + $pocketTtsCanDeleteRemote = !dialecticTtsStudioIsAudioCppPocketTts('pockettts', $pocketTtsEndpoint); ?>
@@ -3984,6 +4138,10 @@ class="sync-btn" title="Sync this voice to Chatterbox server">↻ + + + @@ -4183,6 +4341,7 @@ class="sync-btn" title="Sync this voice to PocketTTS server">↻ $item = $omnivoiceItemMap[$voice] ?? []; $isReady = isset($omnivoiceReadyVoices[$voice]); $hasLocalSample = isset($localVoiceMap[$voice]); + $canDeleteProviderVoice = !empty($item['can_delete']) || !empty($item['custom_voice']); $serverStatus = strtolower(trim(strval($item['status'] ?? ($isReady ? 'ready' : 'local')))); $statusTitle = $serverStatus; if (!$isReady && !empty($item['transcription_error'])) { @@ -4209,6 +4368,10 @@ class="play-btn" title="Test voice">▶ + + +
From d908b161e6e124b919bc95e31a47fa10d721aed7 Mon Sep 17 00:00:00 2001 From: RANGROO Date: Thu, 16 Jul 2026 07:41:18 -0700 Subject: [PATCH 2/2] Add safe Cartesia and Inworld voice rebuilds --- tts/tts-cartesia.php | 99 ++++++++++++++++++++++++++++++++++++++++++++ tts/tts-inworld.php | 94 +++++++++++++++++++++++++++++++++++++++++ ui/xtts_clone.php | 68 ++++++++++++++++++++++++------ 3 files changed, 249 insertions(+), 12 deletions(-) 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 0b065bb..c99afb6 100644 --- a/tts/tts-inworld.php +++ b/tts/tts-inworld.php @@ -427,6 +427,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) { @@ -666,6 +695,7 @@ function getOrCreateInworldVoice($voiceName) { if ($existingVoiceId !== '') { Logger::info("Found existing Inworld voice for {$cloneDisplayName}: {$existingVoiceId}"); storeCachedInworldVoiceId($voiceName, $existingVoiceId, $config, $sampleFingerprint); + storeInworldVoiceMetadata($voiceName, $existingVoiceId, $voiceSamplePath, false, $config); clearInworldAttemptFailure($voiceName, $config); return $existingVoiceId; } @@ -696,6 +726,7 @@ function getOrCreateInworldVoice($voiceName) { } storeCachedInworldVoiceId($voiceName, $inworldVoiceId, $config, $sampleFingerprint); + storeInworldVoiceMetadata($voiceName, $inworldVoiceId, $voiceSamplePath, true, $config); clearInworldAttemptFailure($voiceName, $config); clearInworldLastError(); @@ -862,6 +893,69 @@ 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; + } + $fingerprint = getInworldVoiceSampleFingerprint($voiceName); + $oldVoiceId = getCachedInworldVoiceId($voiceName, $config); + $oldMetadata = getInworldVoiceMetadata($voiceName, $config); + $displayName = getInworldCloneDisplayName($voiceName, $fingerprint) . '_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, $fingerprint); + storeInworldVoiceMetadata($voiceName, $newVoiceId, $samplePath, true, $config); + if ($oldVoiceId !== '' && $oldVoiceId !== $newVoiceId && !empty($oldMetadata['managed']) && trim(strval($oldMetadata['voice_id'] ?? '')) === $oldVoiceId) deleteInworldRemoteVoice($oldVoiceId, $config); + clearInworldAttemptFailure($voiceName, $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 1573f1b..a49746a 100644 --- a/ui/xtts_clone.php +++ b/ui/xtts_clone.php @@ -2071,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.

"; @@ -2088,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; @@ -2104,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.

"; } @@ -2268,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.

"; @@ -2285,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; @@ -2301,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.

"; } @@ -2956,8 +2980,12 @@ function deleteProviderVoice(provider, voiceName) { 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'); @@ -4470,7 +4498,12 @@ class="delete-provider-btn" title="Remove custom voice from OmniVoice">× - +
@@ -4488,7 +4521,11 @@ class="play-btn" title="Test voice">▶ + class="resync-btn" title="Clone again from the local sample, validate it, and switch IDs" >↻ + + + @@ -4602,6 +4639,9 @@ class="sync-btn" title="Sync this voice"
@@ -4628,7 +4668,11 @@ class="play-btn" title="Test voice">▶ + class="resync-btn" title="Clone again from the local sample, validate it, and switch IDs" >↻ + + +