Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions tts/tts-cartesia.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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}");
Expand Down Expand Up @@ -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
*
Expand Down
102 changes: 98 additions & 4 deletions tts/tts-inworld.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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}");
Expand All @@ -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'] ?? ''));

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
*
Expand Down
Loading