WebOrbiton
v2.0.0.1

Publisium

824 lines · 31.9 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/config.php';
  6. require_once __DIR__ . '/database.php';
  7. ​
  8. final class ReadAloudException extends RuntimeException
  9. {
  10. }
  11. ​
  12. final class ReadAloud
  13. {
  14. public const PROVIDERS = [
  15. 'openai' => 'OpenAI',
  16. 'elevenlabs' => 'ElevenLabs',
  17. ];
  18. ​
  19. public const OPENAI_MODELS = [
  20. 'gpt-4o-mini-tts' => 'GPT-4o mini TTS',
  21. 'tts-1' => 'TTS-1 (fast)',
  22. 'tts-1-hd' => 'TTS-1 HD (higher quality)',
  23. ];
  24. ​
  25. public const OPENAI_VOICES = ['alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer'];
  26. ​
  27. public const ELEVENLABS_MODELS = [
  28. 'eleven_multilingual_v2' => 'Multilingual v2',
  29. 'eleven_turbo_v2_5' => 'Turbo v2.5',
  30. 'eleven_flash_v2_5' => 'Flash v2.5 (quickest)',
  31. ];
  32. ​
  33. public const DEFAULT_ELEVENLABS_VOICE = 'JBFqnCBsd6RMkjVDRZzb';
  34. public const MAX_TEXT_CHARS = 60000;
  35. public const MAX_ARTICLE_AUDIO_BYTES = 40 * 1024 * 1024;
  36. public const MAX_FRAME_AUDIO_BYTES = 10 * 1024 * 1024;
  37. ​
  38. private const CHUNK_CHARS = 3500;
  39. private const MAX_CHUNK_RESPONSE_BYTES = 30 * 1024 * 1024;
  40. private const AUDIO_PATH_PATTERN = '#^media/audio/[a-f0-9]{32}\.(mp3|wav)$#';
  41. private const SETTING_KEYS = [
  42. 'read_aloud_enabled', 'read_aloud_intro', 'read_aloud_outro', 'read_aloud_provider',
  43. 'read_aloud_openai_model', 'read_aloud_openai_voice', 'read_aloud_elevenlabs_model', 'read_aloud_elevenlabs_voice',
  44. ];
  45. ​
  46. private static ?array $settingsCache = null;
  47. ​
  48. public static function settings(): array
  49. {
  50. if (self::$settingsCache !== null) {
  51. return self::$settingsCache;
  52. }
  53. ​
  54. $stored = [];
  55. try {
  56. $placeholders = implode(',', array_fill(0, count(self::SETTING_KEYS), '?'));
  57. $statement = Database::site()->prepare('SELECT setting_key, setting_value FROM site_settings WHERE setting_key IN (' . $placeholders . ')');
  58. $statement->execute(self::SETTING_KEYS);
  59. $stored = $statement->fetchAll(PDO::FETCH_KEY_PAIR);
  60. } catch (Throwable $exception) {
  61. $stored = [];
  62. }
  63. ​
  64. $provider = (string) ($stored['read_aloud_provider'] ?? 'openai');
  65. $openaiModel = (string) ($stored['read_aloud_openai_model'] ?? '');
  66. $openaiVoice = (string) ($stored['read_aloud_openai_voice'] ?? '');
  67. $elevenModel = (string) ($stored['read_aloud_elevenlabs_model'] ?? '');
  68. $elevenVoice = (string) ($stored['read_aloud_elevenlabs_voice'] ?? '');
  69. ​
  70. self::$settingsCache = [
  71. 'enabled' => ($stored['read_aloud_enabled'] ?? '0') === '1',
  72. 'intro' => self::isValidPath((string) ($stored['read_aloud_intro'] ?? '')) ? (string) $stored['read_aloud_intro'] : '',
  73. 'outro' => self::isValidPath((string) ($stored['read_aloud_outro'] ?? '')) ? (string) $stored['read_aloud_outro'] : '',
  74. 'provider' => isset(self::PROVIDERS[$provider]) ? $provider : 'openai',
  75. 'openai_model' => isset(self::OPENAI_MODELS[$openaiModel]) ? $openaiModel : 'gpt-4o-mini-tts',
  76. 'openai_voice' => in_array($openaiVoice, self::OPENAI_VOICES, true) ? $openaiVoice : 'alloy',
  77. 'elevenlabs_model' => isset(self::ELEVENLABS_MODELS[$elevenModel]) ? $elevenModel : 'eleven_multilingual_v2',
  78. 'elevenlabs_voice' => self::isValidVoiceId($elevenVoice) ? $elevenVoice : self::DEFAULT_ELEVENLABS_VOICE,
  79. ];
  80. ​
  81. return self::$settingsCache;
  82. }
  83. ​
  84. public static function isEnabled(): bool
  85. {
  86. return self::settings()['enabled'];
  87. }
  88. ​
  89. public static function apiKey(string $provider): ?string
  90. {
  91. require_once __DIR__ . '/ai.php';
  92. ​
  93. $key = Ai::apiKey('tts_' . $provider);
  94. if ($key === null && $provider === 'openai') {
  95. $key = Ai::apiKey('openai');
  96. }
  97. ​
  98. return $key;
  99. }
  100. ​
  101. public static function keyState(string $provider): array
  102. {
  103. require_once __DIR__ . '/ai.php';
  104. ​
  105. $state = Ai::keyState('tts_' . $provider);
  106. $state['shared'] = false;
  107. ​
  108. if ($provider === 'openai' && $state['hint'] === null && !$state['environment']) {
  109. $shared = Ai::keyState('openai');
  110. $state['shared'] = $shared['hint'] !== null || $shared['environment'];
  111. }
  112. ​
  113. return $state;
  114. }
  115. ​
  116. public static function providerReady(): bool
  117. {
  118. return self::apiKey(self::settings()['provider']) !== null;
  119. }
  120. ​
  121. public static function isValidPath(string $path): bool
  122. {
  123. if (preg_match(self::AUDIO_PATH_PATTERN, $path) !== 1) {
  124. return false;
  125. }
  126. ​
  127. $absolute = self::absolutePath($path);
  128. if (!is_file($absolute)) {
  129. return false;
  130. }
  131. ​
  132. if ((fileperms($absolute) & 0004) === 0) {
  133. @chmod($absolute, 0644);
  134. }
  135. ​
  136. $folder = dirname($absolute);
  137. if ((fileperms($folder) & 0005) !== 0005) {
  138. @chmod($folder, 0755);
  139. }
  140. ​
  141. return true;
  142. }
  143. ​
  144. public static function publicUrl(string $path): string
  145. {
  146. return rtrim((string) Config::get('APP_BASE_PATH', ''), '/') . '/' . $path;
  147. }
  148. ​
  149. public static function saveGlobalFields(PDO $db, array $post): void
  150. {
  151. $current = self::settings();
  152. $values = ['read_aloud_enabled' => isset($post['read_aloud_enabled']) ? '1' : '0'];
  153. $replaced = [];
  154. ​
  155. foreach (['intro', 'outro'] as $slot) {
  156. $submitted = trim((string) ($post['read_aloud_' . $slot] ?? ''));
  157. $next = isset($post['read_aloud_' . $slot . '_remove']) || !self::isValidPath($submitted) ? '' : $submitted;
  158. $values['read_aloud_' . $slot] = $next;
  159. ​
  160. if ($current[$slot] !== '' && $current[$slot] !== $next) {
  161. $replaced[] = $current[$slot];
  162. }
  163. }
  164. ​
  165. $statement = $db->prepare(
  166. 'INSERT INTO site_settings (setting_key, setting_value) VALUES (:key, :value) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)'
  167. );
  168. foreach ($values as $key => $value) {
  169. $statement->execute(['key' => $key, 'value' => $value]);
  170. }
  171. self::$settingsCache = null;
  172. ​
  173. foreach ($replaced as $oldPath) {
  174. self::deleteIfUnused($oldPath);
  175. }
  176. }
  177. ​
  178. public static function saveProviderSettings(PDO $db, array $post): ?string
  179. {
  180. require_once __DIR__ . '/ai.php';
  181. ​
  182. $provider = (string) ($post['read_aloud_provider'] ?? 'openai');
  183. if (!isset(self::PROVIDERS[$provider])) {
  184. $provider = 'openai';
  185. }
  186. ​
  187. $openaiModel = (string) ($post['read_aloud_openai_model'] ?? '');
  188. $openaiVoice = (string) ($post['read_aloud_openai_voice'] ?? '');
  189. $elevenModel = (string) ($post['read_aloud_elevenlabs_model'] ?? '');
  190. $elevenVoice = trim((string) ($post['read_aloud_elevenlabs_voice'] ?? ''));
  191. ​
  192. $values = [
  193. 'read_aloud_provider' => $provider,
  194. 'read_aloud_openai_model' => isset(self::OPENAI_MODELS[$openaiModel]) ? $openaiModel : 'gpt-4o-mini-tts',
  195. 'read_aloud_openai_voice' => in_array($openaiVoice, self::OPENAI_VOICES, true) ? $openaiVoice : 'alloy',
  196. 'read_aloud_elevenlabs_model' => isset(self::ELEVENLABS_MODELS[$elevenModel]) ? $elevenModel : 'eleven_multilingual_v2',
  197. 'read_aloud_elevenlabs_voice' => self::isValidVoiceId($elevenVoice) ? $elevenVoice : self::DEFAULT_ELEVENLABS_VOICE,
  198. ];
  199. ​
  200. $statement = $db->prepare(
  201. 'INSERT INTO site_settings (setting_key, setting_value) VALUES (:key, :value) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)'
  202. );
  203. foreach ($values as $key => $value) {
  204. $statement->execute(['key' => $key, 'value' => $value]);
  205. }
  206. self::$settingsCache = null;
  207. ​
  208. try {
  209. foreach (self::PROVIDERS as $key => $label) {
  210. if (isset($post['read_aloud_key_remove_' . $key])) {
  211. Ai::forgetSecret('tts_' . $key);
  212. continue;
  213. }
  214. ​
  215. $newKey = trim((string) ($post['read_aloud_key_' . $key] ?? ''));
  216. if ($newKey === '') {
  217. continue;
  218. }
  219. if (preg_match('/^[A-Za-z0-9._\-]{16,400}$/', $newKey) !== 1) {
  220. return 'The ' . $label . ' Read Aloud API key has an unexpected format and was not saved.';
  221. }
  222. Ai::storeSecret('tts_' . $key, $newKey);
  223. }
  224. } catch (Throwable $exception) {
  225. return 'Settings saved, but we couldn’t store the read aloud API key safely: ' . $exception->getMessage();
  226. }
  227. ​
  228. return null;
  229. }
  230. ​
  231. public static function storeUpload(array $file, int $maxBytes): array
  232. {
  233. $error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
  234. if ($error === UPLOAD_ERR_INI_SIZE || $error === UPLOAD_ERR_FORM_SIZE) {
  235. return [false, 'This file is bigger than your server allows.'];
  236. }
  237. if ($error !== UPLOAD_ERR_OK || !is_uploaded_file((string) ($file['tmp_name'] ?? ''))) {
  238. return [false, 'The upload didn’t work. Please try again.'];
  239. }
  240. ​
  241. $size = (int) ($file['size'] ?? 0);
  242. if ($size <= 0) {
  243. return [false, 'This file is empty.'];
  244. }
  245. if ($size > $maxBytes) {
  246. return [false, 'This file is too big (the limit is ' . (int) ($maxBytes / 1048576) . ' MB).'];
  247. }
  248. ​
  249. $extension = strtolower(pathinfo((string) ($file['name'] ?? ''), PATHINFO_EXTENSION));
  250. if (!in_array($extension, ['mp3', 'wav'], true)) {
  251. return [false, 'Please use an MP3 or WAV file.'];
  252. }
  253. ​
  254. $handle = @fopen((string) $file['tmp_name'], 'rb');
  255. $head = $handle !== false ? (string) fread($handle, 12) : '';
  256. if ($handle !== false) {
  257. fclose($handle);
  258. }
  259. ​
  260. $detected = null;
  261. if (strlen($head) >= 12 && str_starts_with($head, 'RIFF') && substr($head, 8, 4) === 'WAVE') {
  262. $detected = 'wav';
  263. } elseif (str_starts_with($head, 'ID3') || (strlen($head) >= 2 && ord($head[0]) === 0xFF && (ord($head[1]) & 0xE0) === 0xE0)) {
  264. $detected = 'mp3';
  265. }
  266. ​
  267. if ($detected === null || $detected !== $extension) {
  268. return [false, 'This isn’t a working ' . strtoupper($extension) . ' audio file.'];
  269. }
  270. ​
  271. $finfo = finfo_open(FILEINFO_MIME_TYPE);
  272. $mime = $finfo !== false ? (string) finfo_file($finfo, (string) $file['tmp_name']) : '';
  273. $allowedMimes = ['audio/mpeg', 'audio/mp3', 'audio/x-mpeg', 'audio/wav', 'audio/x-wav', 'audio/wave', 'audio/vnd.wave', 'application/octet-stream'];
  274. if (!in_array($mime, $allowedMimes, true)) {
  275. return [false, 'This audio format isn’t supported.'];
  276. }
  277. ​
  278. $directory = self::directory();
  279. if ($directory === null) {
  280. return [false, 'We can’t save files in the audio folder. Check its permissions on the server.'];
  281. }
  282. ​
  283. $name = bin2hex(random_bytes(16)) . '.' . $detected;
  284. if (!move_uploaded_file((string) $file['tmp_name'], $directory . '/' . $name)) {
  285. return [false, 'We couldn’t save the file. Please try again.'];
  286. }
  287. @chmod($directory . '/' . $name, 0644);
  288. ​
  289. self::cleanupOrphans();
  290. ​
  291. return [true, 'media/audio/' . $name];
  292. }
  293. ​
  294. public static function saveArticleFields(int $articleId, array $post, array $user): void
  295. {
  296. if (!self::isEnabled() || $articleId <= 0) {
  297. return;
  298. }
  299. ​
  300. $db = Database::site();
  301. $statement = $db->prepare('SELECT author_id, read_aloud, read_aloud_source, read_aloud_audio FROM articles WHERE id = :id AND deleted_at IS NULL LIMIT 1');
  302. $statement->execute(['id' => $articleId]);
  303. $article = $statement->fetch();
  304. ​
  305. if (!$article) {
  306. return;
  307. }
  308. if ((int) $article['author_id'] !== (int) $user['id'] && !Auth::hasRoleAtLeast(Auth::ROLE_EDITOR_IN_CHIEF)) {
  309. return;
  310. }
  311. if (!isset($post['read_aloud_present'])) {
  312. return;
  313. }
  314. ​
  315. $wanted = isset($post['read_aloud']) ? 1 : 0;
  316. $source = ($post['read_aloud_source'] ?? 'auto') === 'upload' ? 'upload' : 'auto';
  317. $submittedPath = trim((string) ($post['read_aloud_audio_path'] ?? ''));
  318. $currentAudio = (string) ($article['read_aloud_audio'] ?? '');
  319. $currentSource = (string) ($article['read_aloud_source'] ?? 'auto');
  320. ​
  321. $fields = ['read_aloud = :wanted', 'read_aloud_source = :source'];
  322. $params = ['wanted' => $wanted, 'source' => $source, 'id' => $articleId];
  323. $nextAudio = $currentAudio;
  324. $resetAudio = false;
  325. ​
  326. if (isset($post['read_aloud_remove_audio'])) {
  327. $nextAudio = '';
  328. $resetAudio = true;
  329. } elseif ($source === 'upload') {
  330. $nextAudio = self::isValidPath($submittedPath) ? $submittedPath : ($currentSource === 'upload' ? $currentAudio : '');
  331. $resetAudio = true;
  332. } elseif ($currentSource === 'upload') {
  333. $nextAudio = '';
  334. $resetAudio = true;
  335. }
  336. ​
  337. if ($resetAudio) {
  338. $fields[] = 'read_aloud_audio = :audio';
  339. $fields[] = 'read_aloud_hash = NULL';
  340. $fields[] = 'read_aloud_error = NULL';
  341. $fields[] = $source === 'upload' && $nextAudio !== '' ? "read_aloud_status = 'ready'" : 'read_aloud_status = NULL';
  342. $params['audio'] = $nextAudio !== '' ? $nextAudio : null;
  343. }
  344. ​
  345. $db->prepare('UPDATE articles SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
  346. ​
  347. if ($currentAudio !== '' && $currentAudio !== $nextAudio) {
  348. self::deleteIfUnused($currentAudio);
  349. }
  350. }
  351. ​
  352. public static function queueGeneration(int $articleId): void
  353. {
  354. if ($articleId <= 0 || !self::isEnabled() || !self::providerReady()) {
  355. return;
  356. }
  357. ​
  358. try {
  359. $statement = Database::site()->prepare(
  360. 'SELECT status, deleted_at, read_aloud, read_aloud_source FROM articles WHERE id = :id LIMIT 1'
  361. );
  362. $statement->execute(['id' => $articleId]);
  363. $article = $statement->fetch();
  364. } catch (Throwable $exception) {
  365. return;
  366. }
  367. ​
  368. if (!$article || $article['status'] !== 'published' || $article['deleted_at'] !== null
  369. || (int) $article['read_aloud'] !== 1 || (string) $article['read_aloud_source'] !== 'auto') {
  370. return;
  371. }
  372. ​
  373. register_shutdown_function(static function () use ($articleId): void {
  374. ignore_user_abort(true);
  375. @set_time_limit(240);
  376. ​
  377. if (session_status() === PHP_SESSION_ACTIVE) {
  378. session_write_close();
  379. }
  380. ​
  381. if (function_exists('fastcgi_finish_request')) {
  382. fastcgi_finish_request();
  383. } else {
  384. if (!headers_sent() && ob_get_level() === 0) {
  385. header('Connection: close');
  386. header('Content-Length: 0');
  387. }
  388. while (ob_get_level() > 0) {
  389. @ob_end_flush();
  390. }
  391. @flush();
  392. }
  393. ​
  394. try {
  395. self::generate($articleId);
  396. } catch (Throwable $exception) {
  397. error_log('Publisium: Read Aloud generation failed: ' . $exception->getMessage());
  398. }
  399. });
  400. }
  401. ​
  402. public static function generate(int $articleId): void
  403. {
  404. $db = Database::site();
  405. $settings = self::settings();
  406. ​
  407. $statement = $db->prepare('SELECT id, title, content_blocks, status, deleted_at, read_aloud, read_aloud_source, read_aloud_audio, read_aloud_hash, read_aloud_status FROM articles WHERE id = :id LIMIT 1');
  408. $statement->execute(['id' => $articleId]);
  409. $article = $statement->fetch();
  410. ​
  411. if (!$article || $article['status'] !== 'published' || $article['deleted_at'] !== null
  412. || (int) $article['read_aloud'] !== 1 || (string) $article['read_aloud_source'] !== 'auto') {
  413. return;
  414. }
  415. ​
  416. $provider = $settings['provider'];
  417. $apiKey = self::apiKey($provider);
  418. if ($apiKey === null) {
  419. return;
  420. }
  421. ​
  422. $model = $provider === 'openai' ? $settings['openai_model'] : $settings['elevenlabs_model'];
  423. $voice = $provider === 'openai' ? $settings['openai_voice'] : $settings['elevenlabs_voice'];
  424. $text = self::textFor((string) $article['title'], (string) $article['content_blocks']);
  425. $hash = sha1($text . '|' . $provider . '|' . $model . '|' . $voice);
  426. ​
  427. $existing = (string) ($article['read_aloud_audio'] ?? '');
  428. if ($existing !== '' && (string) $article['read_aloud_hash'] === $hash && (string) $article['read_aloud_status'] === 'ready' && self::isValidPath($existing)) {
  429. return;
  430. }
  431. ​
  432. $claim = $db->prepare(
  433. "UPDATE articles SET read_aloud_status = 'generating', read_aloud_started = NOW(), read_aloud_error = NULL
  434. WHERE id = :id AND (read_aloud_status IS NULL OR read_aloud_status <> 'generating' OR read_aloud_started < (NOW() - INTERVAL 10 MINUTE))"
  435. );
  436. $claim->execute(['id' => $articleId]);
  437. if ($claim->rowCount() !== 1) {
  438. return;
  439. }
  440. ​
  441. try {
  442. if ($text === '') {
  443. throw new ReadAloudException('There’s no text in this article to read out.');
  444. }
  445. if (mb_strlen($text) > self::MAX_TEXT_CHARS) {
  446. throw new ReadAloudException('This article is too long for automatic audio (the limit is ' . number_format(self::MAX_TEXT_CHARS) . ' characters).');
  447. }
  448. ​
  449. $audio = '';
  450. foreach (self::chunks($text) as $chunk) {
  451. $audio .= self::synthesize($provider, $apiKey, $model, $voice, $chunk);
  452. if (strlen($audio) > self::MAX_ARTICLE_AUDIO_BYTES) {
  453. throw new ReadAloudException('The audio came out too big to save.');
  454. }
  455. }
  456. ​
  457. if (strlen($audio) < 256) {
  458. throw new ReadAloudException('The voice service didn’t send any audio back.');
  459. }
  460. ​
  461. $directory = self::directory();
  462. if ($directory === null) {
  463. throw new ReadAloudException('We can’t save files in the audio folder. Check its permissions on the server.');
  464. }
  465. ​
  466. $name = bin2hex(random_bytes(16)) . '.mp3';
  467. $temporary = $directory . '/' . $name . '.part';
  468. if (@file_put_contents($temporary, $audio, LOCK_EX) === false || !@rename($temporary, $directory . '/' . $name)) {
  469. @unlink($temporary);
  470. throw new ReadAloudException('We couldn’t save the audio file.');
  471. }
  472. @chmod($directory . '/' . $name, 0644);
  473. ​
  474. $path = 'media/audio/' . $name;
  475. $done = $db->prepare(
  476. "UPDATE articles SET read_aloud_audio = :audio, read_aloud_hash = :hash, read_aloud_status = 'ready', read_aloud_error = NULL WHERE id = :id"
  477. );
  478. $done->execute(['audio' => $path, 'hash' => $hash, 'id' => $articleId]);
  479. ​
  480. if ($existing !== '' && $existing !== $path) {
  481. self::deleteIfUnused($existing);
  482. }
  483. } catch (Throwable $exception) {
  484. $message = $exception instanceof ReadAloudException ? $exception->getMessage() : 'We couldn’t create the audio.';
  485. $failed = $db->prepare("UPDATE articles SET read_aloud_status = 'error', read_aloud_error = :error WHERE id = :id");
  486. $failed->execute(['error' => mb_substr($message, 0, 250), 'id' => $articleId]);
  487. error_log('Publisium: Read Aloud generation failed for article ' . $articleId . ': ' . $exception->getMessage());
  488. }
  489. }
  490. ​
  491. public static function tracksFor(array $article): array
  492. {
  493. if (!self::isEnabled() || (int) ($article['read_aloud'] ?? 0) !== 1) {
  494. return [];
  495. }
  496. ​
  497. $audio = (string) ($article['read_aloud_audio'] ?? '');
  498. if ($audio === '' || (string) ($article['read_aloud_status'] ?? '') !== 'ready' || !self::isValidPath($audio)) {
  499. return [];
  500. }
  501. ​
  502. $settings = self::settings();
  503. $tracks = [];
  504. if ($settings['intro'] !== '') {
  505. $tracks[] = self::publicUrl($settings['intro']);
  506. }
  507. $tracks[] = self::publicUrl($audio);
  508. if ($settings['outro'] !== '') {
  509. $tracks[] = self::publicUrl($settings['outro']);
  510. }
  511. ​
  512. return $tracks;
  513. }
  514. ​
  515. public static function textFor(string $title, string $blocksJson): string
  516. {
  517. $parts = [];
  518. $title = self::plain($title);
  519. if ($title !== '') {
  520. $parts[] = self::terminated($title);
  521. }
  522. ​
  523. $decoded = json_decode($blocksJson, true);
  524. foreach (is_array($decoded['blocks'] ?? null) ? $decoded['blocks'] : [] as $block) {
  525. if (!is_array($block)) {
  526. continue;
  527. }
  528. $data = is_array($block['data'] ?? null) ? $block['data'] : [];
  529. ​
  530. switch ((string) ($block['type'] ?? '')) {
  531. case 'heading2':
  532. case 'heading3':
  533. $text = self::plain((string) ($data['text'] ?? ''));
  534. if ($text !== '') {
  535. $parts[] = self::terminated($text);
  536. }
  537. break;
  538. case 'paragraph':
  539. case 'quote':
  540. case 'callout':
  541. $text = self::plain((string) ($data['text'] ?? ''));
  542. if ($text !== '') {
  543. $parts[] = $text;
  544. }
  545. break;
  546. case 'list':
  547. foreach (is_array($data['items'] ?? null) ? $data['items'] : [] as $item) {
  548. $text = self::plain((string) $item);
  549. if ($text !== '') {
  550. $parts[] = self::terminated($text);
  551. }
  552. }
  553. break;
  554. case 'checklist':
  555. foreach (is_array($data['items'] ?? null) ? $data['items'] : [] as $item) {
  556. $text = self::plain((string) (is_array($item) ? ($item['text'] ?? '') : ''));
  557. if ($text !== '') {
  558. $parts[] = self::terminated($text);
  559. }
  560. }
  561. break;
  562. case 'table':
  563. foreach (is_array($data['rows'] ?? null) ? $data['rows'] : [] as $row) {
  564. $cells = [];
  565. foreach (is_array($row) ? $row : [] as $cell) {
  566. $text = self::plain((string) $cell);
  567. if ($text !== '') {
  568. $cells[] = $text;
  569. }
  570. }
  571. if ($cells !== []) {
  572. $parts[] = self::terminated(implode(', ', $cells));
  573. }
  574. }
  575. break;
  576. }
  577. }
  578. ​
  579. return trim(implode("\n\n", $parts));
  580. }
  581. ​
  582. private static function plain(string $html): string
  583. {
  584. $html = (string) preg_replace('#<br\s*/?>#i', ' ', $html);
  585. $text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
  586. ​
  587. return trim((string) preg_replace('/\s+/u', ' ', $text));
  588. }
  589. ​
  590. private static function terminated(string $text): string
  591. {
  592. return preg_match('/[.!?…:;]$/u', $text) === 1 ? $text : $text . '.';
  593. }
  594. ​
  595. private static function chunks(string $text): array
  596. {
  597. $chunks = [];
  598. $current = '';
  599. ​
  600. foreach (preg_split('/\n{2,}/', $text) ?: [] as $paragraph) {
  601. foreach (self::splitLong($paragraph) as $piece) {
  602. if ($current !== '' && mb_strlen($current) + mb_strlen($piece) + 2 > self::CHUNK_CHARS) {
  603. $chunks[] = $current;
  604. $current = '';
  605. }
  606. $current = $current === '' ? $piece : $current . "\n\n" . $piece;
  607. }
  608. }
  609. ​
  610. if ($current !== '') {
  611. $chunks[] = $current;
  612. }
  613. ​
  614. return $chunks;
  615. }
  616. ​
  617. private static function splitLong(string $paragraph): array
  618. {
  619. if (mb_strlen($paragraph) <= self::CHUNK_CHARS) {
  620. return [$paragraph];
  621. }
  622. ​
  623. $pieces = [];
  624. $current = '';
  625. foreach (preg_split('/(?<=[.!?…])\s+/u', $paragraph) ?: [] as $sentence) {
  626. while (mb_strlen($sentence) > self::CHUNK_CHARS) {
  627. $cut = mb_strrpos(mb_substr($sentence, 0, self::CHUNK_CHARS), ' ');
  628. $cut = $cut === false || $cut < 200 ? self::CHUNK_CHARS : $cut;
  629. $head = mb_substr($sentence, 0, $cut);
  630. $sentence = ltrim(mb_substr($sentence, $cut));
  631. if ($current !== '') {
  632. $pieces[] = $current;
  633. $current = '';
  634. }
  635. $pieces[] = $head;
  636. }
  637. ​
  638. if ($current !== '' && mb_strlen($current) + mb_strlen($sentence) + 1 > self::CHUNK_CHARS) {
  639. $pieces[] = $current;
  640. $current = '';
  641. }
  642. $current = $current === '' ? $sentence : $current . ' ' . $sentence;
  643. }
  644. ​
  645. if ($current !== '') {
  646. $pieces[] = $current;
  647. }
  648. ​
  649. return $pieces;
  650. }
  651. ​
  652. private static function synthesize(string $provider, string $apiKey, string $model, string $voice, string $text): string
  653. {
  654. if ($provider === 'elevenlabs') {
  655. $url = 'https://api.elevenlabs.io/v1/text-to-speech/' . rawurlencode($voice) . '?output_format=mp3_44100_128';
  656. $headers = ['xi-api-key: ' . $apiKey, 'Accept: audio/mpeg'];
  657. $payload = ['text' => $text, 'model_id' => $model];
  658. } else {
  659. $url = 'https://api.openai.com/v1/audio/speech';
  660. $headers = ['Authorization: Bearer ' . $apiKey, 'Accept: audio/mpeg'];
  661. $payload = ['model' => $model, 'input' => $text, 'voice' => $voice, 'response_format' => 'mp3'];
  662. }
  663. ​
  664. [$status, $body] = self::post($url, $headers, $payload);
  665. ​
  666. if ($status >= 200 && $status < 300 && $body !== '') {
  667. return $body;
  668. }
  669. ​
  670. if ($status === 401 || $status === 403) {
  671. throw new ReadAloudException('The voice service didn’t accept the API key.');
  672. }
  673. if ($status === 429) {
  674. throw new ReadAloudException('You’ve hit the voice service’s usage limit. Try again later.');
  675. }
  676. if ($status >= 500) {
  677. throw new ReadAloudException('The voice service is down for the moment. Try again in a bit.');
  678. }
  679. ​
  680. $decoded = json_decode($body, true);
  681. $message = is_array($decoded) ? ($decoded['error']['message'] ?? ($decoded['detail']['message'] ?? ($decoded['detail'] ?? ''))) : '';
  682. $message = is_string($message) ? str_replace($apiKey, '***', $message) : '';
  683. $message = trim((string) preg_replace('/\s+/', ' ', strip_tags($message)));
  684. ​
  685. throw new ReadAloudException('The voice service returned an error' . ($message !== '' ? ': ' . mb_substr($message, 0, 200) : ' (HTTP ' . $status . ').'));
  686. }
  687. ​
  688. private static function post(string $url, array $headers, array $payload): array
  689. {
  690. $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
  691. if ($body === false) {
  692. throw new ReadAloudException('Something went wrong while preparing the request. Please try again.');
  693. }
  694. $headers[] = 'Content-Type: application/json';
  695. ​
  696. $response = '';
  697. $status = 0;
  698. ​
  699. if (function_exists('curl_init')) {
  700. $curl = curl_init($url);
  701. curl_setopt_array($curl, [
  702. CURLOPT_POST => true,
  703. CURLOPT_POSTFIELDS => $body,
  704. CURLOPT_HTTPHEADER => $headers,
  705. CURLOPT_CONNECTTIMEOUT => 10,
  706. CURLOPT_TIMEOUT => 120,
  707. CURLOPT_FOLLOWLOCATION => false,
  708. CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$response): int {
  709. $response .= $chunk;
  710. ​
  711. return strlen($response) > self::MAX_CHUNK_RESPONSE_BYTES ? -1 : strlen($chunk);
  712. },
  713. ]);
  714. $executed = curl_exec($curl);
  715. $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
  716. ​
  717. if ($executed === false) {
  718. throw new ReadAloudException('We couldn’t reach the voice service. Check your connection and try again.');
  719. }
  720. } else {
  721. $context = stream_context_create(['http' => [
  722. 'method' => 'POST',
  723. 'header' => implode("\r\n", $headers),
  724. 'content' => $body,
  725. 'timeout' => 120,
  726. 'ignore_errors' => true,
  727. 'follow_location' => 0,
  728. ]]);
  729. $result = @file_get_contents($url, false, $context);
  730. if ($result === false) {
  731. throw new ReadAloudException('We couldn’t reach the voice service. Check your connection and try again.');
  732. }
  733. $response = substr($result, 0, self::MAX_CHUNK_RESPONSE_BYTES);
  734. foreach ($http_response_header ?? [] as $line) {
  735. if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $match) === 1) {
  736. $status = (int) $match[1];
  737. }
  738. }
  739. }
  740. ​
  741. return [$status, $response];
  742. }
  743. ​
  744. private static function isValidVoiceId(string $voice): bool
  745. {
  746. return preg_match('/^[A-Za-z0-9]{10,40}$/', $voice) === 1;
  747. }
  748. ​
  749. private static function absolutePath(string $path): string
  750. {
  751. return dirname(__DIR__) . '/' . $path;
  752. }
  753. ​
  754. private static function directory(): ?string
  755. {
  756. $directory = dirname(__DIR__) . '/media/audio';
  757. if (!is_dir($directory) && !@mkdir($directory, 0755, true)) {
  758. return null;
  759. }
  760. ​
  761. if ((fileperms($directory) & 0005) !== 0005) {
  762. @chmod($directory, 0755);
  763. }
  764. ​
  765. return is_writable($directory) ? $directory : null;
  766. }
  767. ​
  768. private static function deleteIfUnused(string $path): void
  769. {
  770. if (preg_match(self::AUDIO_PATH_PATTERN, $path) !== 1) {
  771. return;
  772. }
  773. ​
  774. try {
  775. $settings = self::settings();
  776. if ($path === $settings['intro'] || $path === $settings['outro']) {
  777. return;
  778. }
  779. ​
  780. $usage = Database::site()->prepare('SELECT COUNT(*) FROM articles WHERE read_aloud_audio = :path');
  781. $usage->execute(['path' => $path]);
  782. if ((int) $usage->fetchColumn() > 0) {
  783. return;
  784. }
  785. } catch (Throwable $exception) {
  786. return;
  787. }
  788. ​
  789. $absolute = self::absolutePath($path);
  790. if (is_file($absolute)) {
  791. @unlink($absolute);
  792. }
  793. }
  794. ​
  795. private static function cleanupOrphans(): void
  796. {
  797. if (random_int(1, 25) !== 1) {
  798. return;
  799. }
  800. ​
  801. try {
  802. $directory = dirname(__DIR__) . '/media/audio';
  803. $files = is_dir($directory) ? (scandir($directory) ?: []) : [];
  804. if ($files === []) {
  805. return;
  806. }
  807. ​
  808. $referenced = Database::site()->query('SELECT read_aloud_audio FROM articles WHERE read_aloud_audio IS NOT NULL')->fetchAll(PDO::FETCH_COLUMN);
  809. $settings = self::settings();
  810. $referenced = array_flip(array_merge($referenced, [$settings['intro'], $settings['outro']]));
  811. ​
  812. foreach ($files as $name) {
  813. $relative = 'media/audio/' . $name;
  814. $file = $directory . '/' . $name;
  815. if (preg_match(self::AUDIO_PATH_PATTERN, $relative) === 1 && !isset($referenced[$relative]) && is_file($file) && filemtime($file) < time() - 86400) {
  816. @unlink($file);
  817. }
  818. }
  819. } catch (Throwable $exception) {
  820. return;
  821. }
  822. }
  823. }
  824. ​