v1.0.0.9
Publisium
- <?php
-
- declare(strict_types=1);
-
- require_once __DIR__ . '/config.php';
- require_once __DIR__ . '/database.php';
-
- final class ReadAloudException extends RuntimeException
- {
- }
-
- final class ReadAloud
- {
- public const PROVIDERS = [
- 'openai' => 'OpenAI',
- 'elevenlabs' => 'ElevenLabs',
- ];
-
- public const OPENAI_MODELS = [
- 'gpt-4o-mini-tts' => 'GPT-4o mini TTS',
- 'tts-1' => 'TTS-1 (fast)',
- 'tts-1-hd' => 'TTS-1 HD (higher quality)',
- ];
-
- public const OPENAI_VOICES = ['alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer'];
-
- public const ELEVENLABS_MODELS = [
- 'eleven_multilingual_v2' => 'Multilingual v2',
- 'eleven_turbo_v2_5' => 'Turbo v2.5',
- 'eleven_flash_v2_5' => 'Flash v2.5 (quickest)',
- ];
-
- public const DEFAULT_ELEVENLABS_VOICE = 'JBFqnCBsd6RMkjVDRZzb';
- public const MAX_TEXT_CHARS = 60000;
- public const MAX_ARTICLE_AUDIO_BYTES = 40 * 1024 * 1024;
- public const MAX_FRAME_AUDIO_BYTES = 10 * 1024 * 1024;
-
- private const CHUNK_CHARS = 3500;
- private const MAX_CHUNK_RESPONSE_BYTES = 30 * 1024 * 1024;
- private const AUDIO_PATH_PATTERN = '#^media/audio/[a-f0-9]{32}\.(mp3|wav)$#';
- private const SETTING_KEYS = [
- 'read_aloud_enabled', 'read_aloud_intro', 'read_aloud_outro', 'read_aloud_provider',
- 'read_aloud_openai_model', 'read_aloud_openai_voice', 'read_aloud_elevenlabs_model', 'read_aloud_elevenlabs_voice',
- ];
-
- private static ?array $settingsCache = null;
-
- public static function settings(): array
- {
- if (self::$settingsCache !== null) {
- return self::$settingsCache;
- }
-
- $stored = [];
- try {
- $placeholders = implode(',', array_fill(0, count(self::SETTING_KEYS), '?'));
- $statement = Database::site()->prepare('SELECT setting_key, setting_value FROM site_settings WHERE setting_key IN (' . $placeholders . ')');
- $statement->execute(self::SETTING_KEYS);
- $stored = $statement->fetchAll(PDO::FETCH_KEY_PAIR);
- } catch (Throwable $exception) {
- $stored = [];
- }
-
- $provider = (string) ($stored['read_aloud_provider'] ?? 'openai');
- $openaiModel = (string) ($stored['read_aloud_openai_model'] ?? '');
- $openaiVoice = (string) ($stored['read_aloud_openai_voice'] ?? '');
- $elevenModel = (string) ($stored['read_aloud_elevenlabs_model'] ?? '');
- $elevenVoice = (string) ($stored['read_aloud_elevenlabs_voice'] ?? '');
-
- self::$settingsCache = [
- 'enabled' => ($stored['read_aloud_enabled'] ?? '0') === '1',
- 'intro' => self::isValidPath((string) ($stored['read_aloud_intro'] ?? '')) ? (string) $stored['read_aloud_intro'] : '',
- 'outro' => self::isValidPath((string) ($stored['read_aloud_outro'] ?? '')) ? (string) $stored['read_aloud_outro'] : '',
- 'provider' => isset(self::PROVIDERS[$provider]) ? $provider : 'openai',
- 'openai_model' => isset(self::OPENAI_MODELS[$openaiModel]) ? $openaiModel : 'gpt-4o-mini-tts',
- 'openai_voice' => in_array($openaiVoice, self::OPENAI_VOICES, true) ? $openaiVoice : 'alloy',
- 'elevenlabs_model' => isset(self::ELEVENLABS_MODELS[$elevenModel]) ? $elevenModel : 'eleven_multilingual_v2',
- 'elevenlabs_voice' => self::isValidVoiceId($elevenVoice) ? $elevenVoice : self::DEFAULT_ELEVENLABS_VOICE,
- ];
-
- return self::$settingsCache;
- }
-
- public static function isEnabled(): bool
- {
- return self::settings()['enabled'];
- }
-
- public static function apiKey(string $provider): ?string
- {
- require_once __DIR__ . '/ai.php';
-
- $key = Ai::apiKey('tts_' . $provider);
- if ($key === null && $provider === 'openai') {
- $key = Ai::apiKey('openai');
- }
-
- return $key;
- }
-
- public static function keyState(string $provider): array
- {
- require_once __DIR__ . '/ai.php';
-
- $state = Ai::keyState('tts_' . $provider);
- $state['shared'] = false;
-
- if ($provider === 'openai' && $state['hint'] === null && !$state['environment']) {
- $shared = Ai::keyState('openai');
- $state['shared'] = $shared['hint'] !== null || $shared['environment'];
- }
-
- return $state;
- }
-
- public static function providerReady(): bool
- {
- return self::apiKey(self::settings()['provider']) !== null;
- }
-
- public static function isValidPath(string $path): bool
- {
- if (preg_match(self::AUDIO_PATH_PATTERN, $path) !== 1) {
- return false;
- }
-
- $absolute = self::absolutePath($path);
- if (!is_file($absolute)) {
- return false;
- }
-
- if ((fileperms($absolute) & 0004) === 0) {
- @chmod($absolute, 0644);
- }
-
- $folder = dirname($absolute);
- if ((fileperms($folder) & 0005) !== 0005) {
- @chmod($folder, 0755);
- }
-
- return true;
- }
-
- public static function publicUrl(string $path): string
- {
- return rtrim((string) Config::get('APP_BASE_PATH', ''), '/') . '/' . $path;
- }
-
- public static function saveGlobalFields(PDO $db, array $post): void
- {
- $current = self::settings();
- $values = ['read_aloud_enabled' => isset($post['read_aloud_enabled']) ? '1' : '0'];
- $replaced = [];
-
- foreach (['intro', 'outro'] as $slot) {
- $submitted = trim((string) ($post['read_aloud_' . $slot] ?? ''));
- $next = isset($post['read_aloud_' . $slot . '_remove']) || !self::isValidPath($submitted) ? '' : $submitted;
- $values['read_aloud_' . $slot] = $next;
-
- if ($current[$slot] !== '' && $current[$slot] !== $next) {
- $replaced[] = $current[$slot];
- }
- }
-
- $statement = $db->prepare(
- 'INSERT INTO site_settings (setting_key, setting_value) VALUES (:key, :value) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)'
- );
- foreach ($values as $key => $value) {
- $statement->execute(['key' => $key, 'value' => $value]);
- }
- self::$settingsCache = null;
-
- foreach ($replaced as $oldPath) {
- self::deleteIfUnused($oldPath);
- }
- }
-
- public static function saveProviderSettings(PDO $db, array $post): ?string
- {
- require_once __DIR__ . '/ai.php';
-
- $provider = (string) ($post['read_aloud_provider'] ?? 'openai');
- if (!isset(self::PROVIDERS[$provider])) {
- $provider = 'openai';
- }
-
- $openaiModel = (string) ($post['read_aloud_openai_model'] ?? '');
- $openaiVoice = (string) ($post['read_aloud_openai_voice'] ?? '');
- $elevenModel = (string) ($post['read_aloud_elevenlabs_model'] ?? '');
- $elevenVoice = trim((string) ($post['read_aloud_elevenlabs_voice'] ?? ''));
-
- $values = [
- 'read_aloud_provider' => $provider,
- 'read_aloud_openai_model' => isset(self::OPENAI_MODELS[$openaiModel]) ? $openaiModel : 'gpt-4o-mini-tts',
- 'read_aloud_openai_voice' => in_array($openaiVoice, self::OPENAI_VOICES, true) ? $openaiVoice : 'alloy',
- 'read_aloud_elevenlabs_model' => isset(self::ELEVENLABS_MODELS[$elevenModel]) ? $elevenModel : 'eleven_multilingual_v2',
- 'read_aloud_elevenlabs_voice' => self::isValidVoiceId($elevenVoice) ? $elevenVoice : self::DEFAULT_ELEVENLABS_VOICE,
- ];
-
- $statement = $db->prepare(
- 'INSERT INTO site_settings (setting_key, setting_value) VALUES (:key, :value) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)'
- );
- foreach ($values as $key => $value) {
- $statement->execute(['key' => $key, 'value' => $value]);
- }
- self::$settingsCache = null;
-
- try {
- foreach (self::PROVIDERS as $key => $label) {
- if (isset($post['read_aloud_key_remove_' . $key])) {
- Ai::forgetSecret('tts_' . $key);
- continue;
- }
-
- $newKey = trim((string) ($post['read_aloud_key_' . $key] ?? ''));
- if ($newKey === '') {
- continue;
- }
- if (preg_match('/^[A-Za-z0-9._\-]{16,400}$/', $newKey) !== 1) {
- return 'The ' . $label . ' Read Aloud API key has an unexpected format and was not saved.';
- }
- Ai::storeSecret('tts_' . $key, $newKey);
- }
- } catch (Throwable $exception) {
- return 'Settings saved, but we couldn’t store the read aloud API key safely: ' . $exception->getMessage();
- }
-
- return null;
- }
-
- public static function storeUpload(array $file, int $maxBytes): array
- {
- $error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
- if ($error === UPLOAD_ERR_INI_SIZE || $error === UPLOAD_ERR_FORM_SIZE) {
- return [false, 'This file is bigger than your server allows.'];
- }
- if ($error !== UPLOAD_ERR_OK || !is_uploaded_file((string) ($file['tmp_name'] ?? ''))) {
- return [false, 'The upload didn’t work. Please try again.'];
- }
-
- $size = (int) ($file['size'] ?? 0);
- if ($size <= 0) {
- return [false, 'This file is empty.'];
- }
- if ($size > $maxBytes) {
- return [false, 'This file is too big (the limit is ' . (int) ($maxBytes / 1048576) . ' MB).'];
- }
-
- $extension = strtolower(pathinfo((string) ($file['name'] ?? ''), PATHINFO_EXTENSION));
- if (!in_array($extension, ['mp3', 'wav'], true)) {
- return [false, 'Please use an MP3 or WAV file.'];
- }
-
- $handle = @fopen((string) $file['tmp_name'], 'rb');
- $head = $handle !== false ? (string) fread($handle, 12) : '';
- if ($handle !== false) {
- fclose($handle);
- }
-
- $detected = null;
- if (strlen($head) >= 12 && str_starts_with($head, 'RIFF') && substr($head, 8, 4) === 'WAVE') {
- $detected = 'wav';
- } elseif (str_starts_with($head, 'ID3') || (strlen($head) >= 2 && ord($head[0]) === 0xFF && (ord($head[1]) & 0xE0) === 0xE0)) {
- $detected = 'mp3';
- }
-
- if ($detected === null || $detected !== $extension) {
- return [false, 'This isn’t a working ' . strtoupper($extension) . ' audio file.'];
- }
-
- $finfo = finfo_open(FILEINFO_MIME_TYPE);
- $mime = $finfo !== false ? (string) finfo_file($finfo, (string) $file['tmp_name']) : '';
- $allowedMimes = ['audio/mpeg', 'audio/mp3', 'audio/x-mpeg', 'audio/wav', 'audio/x-wav', 'audio/wave', 'audio/vnd.wave', 'application/octet-stream'];
- if (!in_array($mime, $allowedMimes, true)) {
- return [false, 'This audio format isn’t supported.'];
- }
-
- $directory = self::directory();
- if ($directory === null) {
- return [false, 'We can’t save files in the audio folder. Check its permissions on the server.'];
- }
-
- $name = bin2hex(random_bytes(16)) . '.' . $detected;
- if (!move_uploaded_file((string) $file['tmp_name'], $directory . '/' . $name)) {
- return [false, 'We couldn’t save the file. Please try again.'];
- }
- @chmod($directory . '/' . $name, 0644);
-
- self::cleanupOrphans();
-
- return [true, 'media/audio/' . $name];
- }
-
- public static function saveArticleFields(int $articleId, array $post, array $user): void
- {
- if (!self::isEnabled() || $articleId <= 0) {
- return;
- }
-
- $db = Database::site();
- $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');
- $statement->execute(['id' => $articleId]);
- $article = $statement->fetch();
-
- if (!$article) {
- return;
- }
- if ((int) $article['author_id'] !== (int) $user['id'] && !Auth::hasRoleAtLeast(Auth::ROLE_EDITOR_IN_CHIEF)) {
- return;
- }
- if (!isset($post['read_aloud_present'])) {
- return;
- }
-
- $wanted = isset($post['read_aloud']) ? 1 : 0;
- $source = ($post['read_aloud_source'] ?? 'auto') === 'upload' ? 'upload' : 'auto';
- $submittedPath = trim((string) ($post['read_aloud_audio_path'] ?? ''));
- $currentAudio = (string) ($article['read_aloud_audio'] ?? '');
- $currentSource = (string) ($article['read_aloud_source'] ?? 'auto');
-
- $fields = ['read_aloud = :wanted', 'read_aloud_source = :source'];
- $params = ['wanted' => $wanted, 'source' => $source, 'id' => $articleId];
- $nextAudio = $currentAudio;
- $resetAudio = false;
-
- if (isset($post['read_aloud_remove_audio'])) {
- $nextAudio = '';
- $resetAudio = true;
- } elseif ($source === 'upload') {
- $nextAudio = self::isValidPath($submittedPath) ? $submittedPath : ($currentSource === 'upload' ? $currentAudio : '');
- $resetAudio = true;
- } elseif ($currentSource === 'upload') {
- $nextAudio = '';
- $resetAudio = true;
- }
-
- if ($resetAudio) {
- $fields[] = 'read_aloud_audio = :audio';
- $fields[] = 'read_aloud_hash = NULL';
- $fields[] = 'read_aloud_error = NULL';
- $fields[] = $source === 'upload' && $nextAudio !== '' ? "read_aloud_status = 'ready'" : 'read_aloud_status = NULL';
- $params['audio'] = $nextAudio !== '' ? $nextAudio : null;
- }
-
- $db->prepare('UPDATE articles SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
-
- if ($currentAudio !== '' && $currentAudio !== $nextAudio) {
- self::deleteIfUnused($currentAudio);
- }
- }
-
- public static function queueGeneration(int $articleId): void
- {
- if ($articleId <= 0 || !self::isEnabled() || !self::providerReady()) {
- return;
- }
-
- try {
- $statement = Database::site()->prepare(
- 'SELECT status, deleted_at, read_aloud, read_aloud_source FROM articles WHERE id = :id LIMIT 1'
- );
- $statement->execute(['id' => $articleId]);
- $article = $statement->fetch();
- } catch (Throwable $exception) {
- return;
- }
-
- if (!$article || $article['status'] !== 'published' || $article['deleted_at'] !== null
- || (int) $article['read_aloud'] !== 1 || (string) $article['read_aloud_source'] !== 'auto') {
- return;
- }
-
- register_shutdown_function(static function () use ($articleId): void {
- ignore_user_abort(true);
- @set_time_limit(240);
-
- if (session_status() === PHP_SESSION_ACTIVE) {
- session_write_close();
- }
-
- if (function_exists('fastcgi_finish_request')) {
- fastcgi_finish_request();
- } else {
- if (!headers_sent() && ob_get_level() === 0) {
- header('Connection: close');
- header('Content-Length: 0');
- }
- while (ob_get_level() > 0) {
- @ob_end_flush();
- }
- @flush();
- }
-
- try {
- self::generate($articleId);
- } catch (Throwable $exception) {
- error_log('Publisium: Read Aloud generation failed: ' . $exception->getMessage());
- }
- });
- }
-
- public static function generate(int $articleId): void
- {
- $db = Database::site();
- $settings = self::settings();
-
- $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');
- $statement->execute(['id' => $articleId]);
- $article = $statement->fetch();
-
- if (!$article || $article['status'] !== 'published' || $article['deleted_at'] !== null
- || (int) $article['read_aloud'] !== 1 || (string) $article['read_aloud_source'] !== 'auto') {
- return;
- }
-
- $provider = $settings['provider'];
- $apiKey = self::apiKey($provider);
- if ($apiKey === null) {
- return;
- }
-
- $model = $provider === 'openai' ? $settings['openai_model'] : $settings['elevenlabs_model'];
- $voice = $provider === 'openai' ? $settings['openai_voice'] : $settings['elevenlabs_voice'];
- $text = self::textFor((string) $article['title'], (string) $article['content_blocks']);
- $hash = sha1($text . '|' . $provider . '|' . $model . '|' . $voice);
-
- $existing = (string) ($article['read_aloud_audio'] ?? '');
- if ($existing !== '' && (string) $article['read_aloud_hash'] === $hash && (string) $article['read_aloud_status'] === 'ready' && self::isValidPath($existing)) {
- return;
- }
-
- $claim = $db->prepare(
- "UPDATE articles SET read_aloud_status = 'generating', read_aloud_started = NOW(), read_aloud_error = NULL
- WHERE id = :id AND (read_aloud_status IS NULL OR read_aloud_status <> 'generating' OR read_aloud_started < (NOW() - INTERVAL 10 MINUTE))"
- );
- $claim->execute(['id' => $articleId]);
- if ($claim->rowCount() !== 1) {
- return;
- }
-
- try {
- if ($text === '') {
- throw new ReadAloudException('There’s no text in this article to read out.');
- }
- if (mb_strlen($text) > self::MAX_TEXT_CHARS) {
- throw new ReadAloudException('This article is too long for automatic audio (the limit is ' . number_format(self::MAX_TEXT_CHARS) . ' characters).');
- }
-
- $audio = '';
- foreach (self::chunks($text) as $chunk) {
- $audio .= self::synthesize($provider, $apiKey, $model, $voice, $chunk);
- if (strlen($audio) > self::MAX_ARTICLE_AUDIO_BYTES) {
- throw new ReadAloudException('The audio came out too big to save.');
- }
- }
-
- if (strlen($audio) < 256) {
- throw new ReadAloudException('The voice service didn’t send any audio back.');
- }
-
- $directory = self::directory();
- if ($directory === null) {
- throw new ReadAloudException('We can’t save files in the audio folder. Check its permissions on the server.');
- }
-
- $name = bin2hex(random_bytes(16)) . '.mp3';
- $temporary = $directory . '/' . $name . '.part';
- if (@file_put_contents($temporary, $audio, LOCK_EX) === false || !@rename($temporary, $directory . '/' . $name)) {
- @unlink($temporary);
- throw new ReadAloudException('We couldn’t save the audio file.');
- }
- @chmod($directory . '/' . $name, 0644);
-
- $path = 'media/audio/' . $name;
- $done = $db->prepare(
- "UPDATE articles SET read_aloud_audio = :audio, read_aloud_hash = :hash, read_aloud_status = 'ready', read_aloud_error = NULL WHERE id = :id"
- );
- $done->execute(['audio' => $path, 'hash' => $hash, 'id' => $articleId]);
-
- if ($existing !== '' && $existing !== $path) {
- self::deleteIfUnused($existing);
- }
- } catch (Throwable $exception) {
- $message = $exception instanceof ReadAloudException ? $exception->getMessage() : 'We couldn’t create the audio.';
- $failed = $db->prepare("UPDATE articles SET read_aloud_status = 'error', read_aloud_error = :error WHERE id = :id");
- $failed->execute(['error' => mb_substr($message, 0, 250), 'id' => $articleId]);
- error_log('Publisium: Read Aloud generation failed for article ' . $articleId . ': ' . $exception->getMessage());
- }
- }
-
- public static function tracksFor(array $article): array
- {
- if (!self::isEnabled() || (int) ($article['read_aloud'] ?? 0) !== 1) {
- return [];
- }
-
- $audio = (string) ($article['read_aloud_audio'] ?? '');
- if ($audio === '' || (string) ($article['read_aloud_status'] ?? '') !== 'ready' || !self::isValidPath($audio)) {
- return [];
- }
-
- $settings = self::settings();
- $tracks = [];
- if ($settings['intro'] !== '') {
- $tracks[] = self::publicUrl($settings['intro']);
- }
- $tracks[] = self::publicUrl($audio);
- if ($settings['outro'] !== '') {
- $tracks[] = self::publicUrl($settings['outro']);
- }
-
- return $tracks;
- }
-
- public static function textFor(string $title, string $blocksJson): string
- {
- $parts = [];
- $title = self::plain($title);
- if ($title !== '') {
- $parts[] = self::terminated($title);
- }
-
- $decoded = json_decode($blocksJson, true);
- foreach (is_array($decoded['blocks'] ?? null) ? $decoded['blocks'] : [] as $block) {
- if (!is_array($block)) {
- continue;
- }
- $data = is_array($block['data'] ?? null) ? $block['data'] : [];
-
- switch ((string) ($block['type'] ?? '')) {
- case 'heading2':
- case 'heading3':
- $text = self::plain((string) ($data['text'] ?? ''));
- if ($text !== '') {
- $parts[] = self::terminated($text);
- }
- break;
- case 'paragraph':
- case 'quote':
- case 'callout':
- $text = self::plain((string) ($data['text'] ?? ''));
- if ($text !== '') {
- $parts[] = $text;
- }
- break;
- case 'list':
- foreach (is_array($data['items'] ?? null) ? $data['items'] : [] as $item) {
- $text = self::plain((string) $item);
- if ($text !== '') {
- $parts[] = self::terminated($text);
- }
- }
- break;
- case 'checklist':
- foreach (is_array($data['items'] ?? null) ? $data['items'] : [] as $item) {
- $text = self::plain((string) (is_array($item) ? ($item['text'] ?? '') : ''));
- if ($text !== '') {
- $parts[] = self::terminated($text);
- }
- }
- break;
- case 'table':
- foreach (is_array($data['rows'] ?? null) ? $data['rows'] : [] as $row) {
- $cells = [];
- foreach (is_array($row) ? $row : [] as $cell) {
- $text = self::plain((string) $cell);
- if ($text !== '') {
- $cells[] = $text;
- }
- }
- if ($cells !== []) {
- $parts[] = self::terminated(implode(', ', $cells));
- }
- }
- break;
- }
- }
-
- return trim(implode("\n\n", $parts));
- }
-
- private static function plain(string $html): string
- {
- $html = (string) preg_replace('#<br\s*/?>#i', ' ', $html);
- $text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
-
- return trim((string) preg_replace('/\s+/u', ' ', $text));
- }
-
- private static function terminated(string $text): string
- {
- return preg_match('/[.!?…:;]$/u', $text) === 1 ? $text : $text . '.';
- }
-
- private static function chunks(string $text): array
- {
- $chunks = [];
- $current = '';
-
- foreach (preg_split('/\n{2,}/', $text) ?: [] as $paragraph) {
- foreach (self::splitLong($paragraph) as $piece) {
- if ($current !== '' && mb_strlen($current) + mb_strlen($piece) + 2 > self::CHUNK_CHARS) {
- $chunks[] = $current;
- $current = '';
- }
- $current = $current === '' ? $piece : $current . "\n\n" . $piece;
- }
- }
-
- if ($current !== '') {
- $chunks[] = $current;
- }
-
- return $chunks;
- }
-
- private static function splitLong(string $paragraph): array
- {
- if (mb_strlen($paragraph) <= self::CHUNK_CHARS) {
- return [$paragraph];
- }
-
- $pieces = [];
- $current = '';
- foreach (preg_split('/(?<=[.!?…])\s+/u', $paragraph) ?: [] as $sentence) {
- while (mb_strlen($sentence) > self::CHUNK_CHARS) {
- $cut = mb_strrpos(mb_substr($sentence, 0, self::CHUNK_CHARS), ' ');
- $cut = $cut === false || $cut < 200 ? self::CHUNK_CHARS : $cut;
- $head = mb_substr($sentence, 0, $cut);
- $sentence = ltrim(mb_substr($sentence, $cut));
- if ($current !== '') {
- $pieces[] = $current;
- $current = '';
- }
- $pieces[] = $head;
- }
-
- if ($current !== '' && mb_strlen($current) + mb_strlen($sentence) + 1 > self::CHUNK_CHARS) {
- $pieces[] = $current;
- $current = '';
- }
- $current = $current === '' ? $sentence : $current . ' ' . $sentence;
- }
-
- if ($current !== '') {
- $pieces[] = $current;
- }
-
- return $pieces;
- }
-
- private static function synthesize(string $provider, string $apiKey, string $model, string $voice, string $text): string
- {
- if ($provider === 'elevenlabs') {
- $url = 'https://api.elevenlabs.io/v1/text-to-speech/' . rawurlencode($voice) . '?output_format=mp3_44100_128';
- $headers = ['xi-api-key: ' . $apiKey, 'Accept: audio/mpeg'];
- $payload = ['text' => $text, 'model_id' => $model];
- } else {
- $url = 'https://api.openai.com/v1/audio/speech';
- $headers = ['Authorization: Bearer ' . $apiKey, 'Accept: audio/mpeg'];
- $payload = ['model' => $model, 'input' => $text, 'voice' => $voice, 'response_format' => 'mp3'];
- }
-
- [$status, $body] = self::post($url, $headers, $payload);
-
- if ($status >= 200 && $status < 300 && $body !== '') {
- return $body;
- }
-
- if ($status === 401 || $status === 403) {
- throw new ReadAloudException('The voice service didn’t accept the API key.');
- }
- if ($status === 429) {
- throw new ReadAloudException('You’ve hit the voice service’s usage limit. Try again later.');
- }
- if ($status >= 500) {
- throw new ReadAloudException('The voice service is down for the moment. Try again in a bit.');
- }
-
- $decoded = json_decode($body, true);
- $message = is_array($decoded) ? ($decoded['error']['message'] ?? ($decoded['detail']['message'] ?? ($decoded['detail'] ?? ''))) : '';
- $message = is_string($message) ? str_replace($apiKey, '***', $message) : '';
- $message = trim((string) preg_replace('/\s+/', ' ', strip_tags($message)));
-
- throw new ReadAloudException('The voice service returned an error' . ($message !== '' ? ': ' . mb_substr($message, 0, 200) : ' (HTTP ' . $status . ').'));
- }
-
- private static function post(string $url, array $headers, array $payload): array
- {
- $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
- if ($body === false) {
- throw new ReadAloudException('Something went wrong while preparing the request. Please try again.');
- }
- $headers[] = 'Content-Type: application/json';
-
- $response = '';
- $status = 0;
-
- if (function_exists('curl_init')) {
- $curl = curl_init($url);
- curl_setopt_array($curl, [
- CURLOPT_POST => true,
- CURLOPT_POSTFIELDS => $body,
- CURLOPT_HTTPHEADER => $headers,
- CURLOPT_CONNECTTIMEOUT => 10,
- CURLOPT_TIMEOUT => 120,
- CURLOPT_FOLLOWLOCATION => false,
- CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$response): int {
- $response .= $chunk;
-
- return strlen($response) > self::MAX_CHUNK_RESPONSE_BYTES ? -1 : strlen($chunk);
- },
- ]);
- $executed = curl_exec($curl);
- $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
-
- if ($executed === false) {
- throw new ReadAloudException('We couldn’t reach the voice service. Check your connection and try again.');
- }
- } else {
- $context = stream_context_create(['http' => [
- 'method' => 'POST',
- 'header' => implode("\r\n", $headers),
- 'content' => $body,
- 'timeout' => 120,
- 'ignore_errors' => true,
- 'follow_location' => 0,
- ]]);
- $result = @file_get_contents($url, false, $context);
- if ($result === false) {
- throw new ReadAloudException('We couldn’t reach the voice service. Check your connection and try again.');
- }
- $response = substr($result, 0, self::MAX_CHUNK_RESPONSE_BYTES);
- foreach ($http_response_header ?? [] as $line) {
- if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $match) === 1) {
- $status = (int) $match[1];
- }
- }
- }
-
- return [$status, $response];
- }
-
- private static function isValidVoiceId(string $voice): bool
- {
- return preg_match('/^[A-Za-z0-9]{10,40}$/', $voice) === 1;
- }
-
- private static function absolutePath(string $path): string
- {
- return dirname(__DIR__) . '/' . $path;
- }
-
- private static function directory(): ?string
- {
- $directory = dirname(__DIR__) . '/media/audio';
- if (!is_dir($directory) && !@mkdir($directory, 0755, true)) {
- return null;
- }
-
- if ((fileperms($directory) & 0005) !== 0005) {
- @chmod($directory, 0755);
- }
-
- return is_writable($directory) ? $directory : null;
- }
-
- private static function deleteIfUnused(string $path): void
- {
- if (preg_match(self::AUDIO_PATH_PATTERN, $path) !== 1) {
- return;
- }
-
- try {
- $settings = self::settings();
- if ($path === $settings['intro'] || $path === $settings['outro']) {
- return;
- }
-
- $usage = Database::site()->prepare('SELECT COUNT(*) FROM articles WHERE read_aloud_audio = :path');
- $usage->execute(['path' => $path]);
- if ((int) $usage->fetchColumn() > 0) {
- return;
- }
- } catch (Throwable $exception) {
- return;
- }
-
- $absolute = self::absolutePath($path);
- if (is_file($absolute)) {
- @unlink($absolute);
- }
- }
-
- private static function cleanupOrphans(): void
- {
- if (random_int(1, 25) !== 1) {
- return;
- }
-
- try {
- $directory = dirname(__DIR__) . '/media/audio';
- $files = is_dir($directory) ? (scandir($directory) ?: []) : [];
- if ($files === []) {
- return;
- }
-
- $referenced = Database::site()->query('SELECT read_aloud_audio FROM articles WHERE read_aloud_audio IS NOT NULL')->fetchAll(PDO::FETCH_COLUMN);
- $settings = self::settings();
- $referenced = array_flip(array_merge($referenced, [$settings['intro'], $settings['outro']]));
-
- foreach ($files as $name) {
- $relative = 'media/audio/' . $name;
- $file = $directory . '/' . $name;
- if (preg_match(self::AUDIO_PATH_PATTERN, $relative) === 1 && !isset($referenced[$relative]) && is_file($file) && filemtime($file) < time() - 86400) {
- @unlink($file);
- }
- }
- } catch (Throwable $exception) {
- return;
- }
- }
- }
-