diff --git a/docs/experiments/text-to-speech.md b/docs/experiments/text-to-speech.md new file mode 100644 index 000000000..7d4602035 --- /dev/null +++ b/docs/experiments/text-to-speech.md @@ -0,0 +1,106 @@ +# Text to Speech + +## Summary + +The Text to Speech experiment generates an audio version of a post's content so visitors can listen to the post instead of reading it. Generation is triggered from a "Text to Speech" panel in the block editor sidebar, runs in the background via WP-Cron (so it continues even if the editor is closed), and stores the result as an audio attachment on the post. When enabled per post, an audio player is rendered above the content on the singular front-end view. The experiment also registers two reusable WordPress Abilities — `ai/speech-generation` and `ai/speech-import` — that mirror the image generation ability pair. + +## Overview + +### For End Users + +When enabled, a "Text to Speech" panel appears in the document sidebar of the block editor: + +- **Generate Audio** starts background generation from the post's saved content. The button becomes **Regenerate Audio** once audio exists; regenerating deletes the current audio and creates a new version. +- Progress is shown while generation runs ("Generating audio… (2 of 5)"). You can close the editor — generation continues on the server. +- Once generated, an inline preview player appears, along with a **Display audio player on the front end** toggle (on by default; persisted when the post is saved). +- **Delete Audio** removes the generated audio and clears all of the post's text to speech state, returning the post to its pre-generation state. It asks for confirmation first, since the action cannot be undone. +- On the front end, a native audio player is rendered above the post content on the singular view. + +**Key Features:** + +- One-click audio generation from a post's title and content +- Background processing — generation survives page refreshes and closed tabs +- Long content is split into chunks (to respect provider request limits) and combined into a single MP3 +- Per-post front-end display toggle +- Explicit regeneration control — audio is only replaced when you ask for it +- Explicit deletion — remove the audio and its state without regenerating + +## Architecture & Implementation + +- `register()` wires: `wp_abilities_api_init` (abilities), `rest_api_init` (job trigger/status/delete routes), `enqueue_block_editor_assets` / `enqueue_block_assets` (assets), the `wpai_tts_process_chunk` cron hook (one content chunk per event), and a `the_content` filter (front-end player, guarded by `is_singular()` / `in_the_loop()` / `is_main_query()` so player markup never leaks into REST responses or AI context building). +- The post title is prepended to the body so the audio announces it first, then the combined text is normalized (`normalize_content()` after `the_content`), split into ≤ 4,000-character sentence-boundary chunks, generated chunk-by-chunk as `audio/mpeg` (`Speech_Generator`, the single place the AI client is called), appended to a temp file in the uploads directory (ID3 tags stripped at joins), and finally imported via `media_handle_sideload()` as an attachment of the post (`wpai_generated` meta = 1). The previous attachment is deleted only after the new one exists. +- Job state lives in post meta. Only the display toggle is exposed to REST; the editor reads everything else through the status endpoint, so a stale editor save can never clobber job state. + +### Post meta + +| Key | Purpose | +| --- | --- | +| `wpai_tts_audio_id` | Generated audio attachment ID | +| `wpai_tts_display_audio` | Front-end display toggle (REST-exposed, default true) | +| `wpai_tts_status` | `pending` / `processing` / `complete` / `error` | +| `wpai_tts_error` | Last error message | +| `wpai_tts_updated` | Last activity timestamp (stuck-job detection) | +| `wpai_tts_job` | Transient job blob (chunks, progress, temp file); removed on completion | + +Generated audio attachments are flagged with `wpai_generated` = 1. + +### Settings + +- **Voice** (`wpai_feature_text-to-speech_field_voice`): optional voice identifier passed to the provider (`as_output_speech_voice()`); empty uses the provider default. +- The standard per-feature developer provider/model override is honored. + +### REST Endpoints + +Start (or restart) background generation for a post: + + curl -X POST --user admin:password \ + https://example.com/wp-json/ai/v1/text-to-speech/123 + +Poll status: + + curl --user admin:password \ + https://example.com/wp-json/ai/v1/text-to-speech/123 + +Delete the generated audio and clear all text to speech state for a post: + + curl -X DELETE --user admin:password \ + https://example.com/wp-json/ai/v1/text-to-speech/123 + +All three return `{ "status", "done", "total", "error", "audio_id", "audio_url", "display_audio" }`; after a delete the payload reports the idle, no-audio state. + +### Abilities (REST examples) + +Generate speech synchronously (from text, or pass `post_id` instead): + + curl -X POST --user admin:password \ + https://example.com/wp-json/wp-abilities/v1/abilities/ai/speech-generation/run \ + -H 'Content-Type: application/json' \ + -d '{"input": {"text": "Hello world."}}' + +Import base64 audio into the media library: + + curl -X POST --user admin:password \ + https://example.com/wp-json/wp-abilities/v1/abilities/ai/speech-import/run \ + -H 'Content-Type: application/json' \ + -d '{"input": {"data": "", "mime_type": "audio/mpeg", "title": "My audio", "post_id": 123}}' + +Note: `ai/speech-generation` runs synchronously in a single request — long content can be slow. The editor's background flow (REST endpoints above) is the recommended path for whole posts. + +### Filters + +| Filter | Purpose | +| --- | --- | +| `wpai_tts_max_chunk_length` | Maximum characters per chunk (default 4000) | +| `wpai_tts_pre_generate_chunk` | Short-circuit chunk generation (return `{data: base64, mime_type?: string}` or `WP_Error`) | +| `wpai_tts_audio_filename` | Base filename of audio imported by the background job (default `post-audio-{ID}`) | +| `wpai_generated_audio_filename` | Base filename of audio imported via `ai/speech-import` | +| `wpai_tts_player_markup` | Front-end player markup | +| `wpai_has_text_to_speech_support` | Override TTS capability detection | +| `wpai_preferred_speech_models` | Provider/model preference list for TTS | + +## Limitations + +- Requires a connector with a working text to speech model. +- Chunk joins are plain MP3 concatenation: not guaranteed gapless, and multi-chunk jobs require MP3 output. +- Generation reads the post's **saved** content; the editor blocks the button while there are unsaved changes. +- WP-Cron scheduling depends on site traffic; the editor's status polling keeps it moving while the editor is open. On very low-traffic sites with the editor closed, generation may pause until the next request arrives (or a real cron runner is configured). diff --git a/includes/Abilities/Speech/Generate_Speech.php b/includes/Abilities/Speech/Generate_Speech.php new file mode 100644 index 000000000..d40e8fa34 --- /dev/null +++ b/includes/Abilities/Speech/Generate_Speech.php @@ -0,0 +1,256 @@ + 'object', + 'properties' => array( + 'text' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_textarea_field', + 'description' => esc_html__( 'The text to generate speech from. Takes precedence over post_id when both are provided.', 'ai' ), + ), + 'post_id' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'A post ID whose content will be used when no text is provided.', 'ai' ), + ), + 'voice' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'Optional voice identifier. Defaults to the Text to Speech feature setting, then the provider default.', 'ai' ), + ), + ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'audio' => array( + 'type' => 'object', + 'description' => esc_html__( 'Generated audio data.', 'ai' ), + 'properties' => array( + 'data' => array( + 'type' => 'string', + 'description' => esc_html__( 'The base64 encoded audio data.', 'ai' ), + ), + 'mime_type' => array( + 'type' => 'string', + 'description' => esc_html__( 'The MIME type of the audio.', 'ai' ), + ), + 'provider_metadata' => array( + 'type' => 'object', + 'description' => esc_html__( 'Information about the provider that generated the audio.', 'ai' ), + ), + 'model_metadata' => array( + 'type' => 'object', + 'description' => esc_html__( 'Information about the model that generated the audio.', 'ai' ), + ), + ), + ), + ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function execute_callback( $input ) { + $args = wp_parse_args( + $input, + array( + 'text' => '', + 'post_id' => 0, + 'voice' => null, + ), + ); + + $post_id = absint( $args['post_id'] ); + $text = (string) $args['text']; + + if ( '' === trim( $text ) && $post_id ) { + $post = get_post( $post_id ); + + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + /* translators: %d: Post ID. */ + sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), $post_id ) + ); + } + + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + $text = (string) apply_filters( 'the_content', $post->post_content ); + } + + $text = normalize_content( $text ); + + if ( '' === $text ) { + return new WP_Error( + 'no_content', + esc_html__( 'Text or a post with content is required to generate speech.', 'ai' ) + ); + } + + $voice = null !== $args['voice'] && '' !== $args['voice'] + ? (string) $args['voice'] + : (string) get_option( 'wpai_feature_' . Job_Manager::FEATURE_ID . '_field_voice', '' ); + + /** This filter is documented in includes/Experiments/Text_To_Speech/Job_Manager.php */ + $max_length = (int) apply_filters( 'wpai_tts_max_chunk_length', 4000, $post_id ); + + $chunks = Content_Chunker::chunk( $text, $max_length ); + $total = count( $chunks ); + + $generator = new Speech_Generator(); + $combined = ''; + $audio = array( + 'data' => '', + 'mime_type' => '', + 'provider_metadata' => array(), + 'model_metadata' => array(), + ); + + foreach ( $chunks as $index => $chunk ) { + $result = $generator->generate_chunk( $chunk, $voice ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $bytes = base64_decode( $result['data'], true ); + + if ( false === $bytes || '' === $bytes ) { + return new WP_Error( + 'no_audio_data', + esc_html__( 'The provider returned invalid audio data.', 'ai' ) + ); + } + + $is_first = 0 === $index; + $is_last = $index + 1 >= $total; + + if ( $is_first ) { + $audio['mime_type'] = $result['mime_type']; + $audio['provider_metadata'] = $result['provider_metadata']; + $audio['model_metadata'] = $result['model_metadata']; + } elseif ( $audio['mime_type'] !== $result['mime_type'] ) { + return new WP_Error( + 'inconsistent_audio', + esc_html__( 'The provider returned inconsistent audio formats across chunks.', 'ai' ) + ); + } + + if ( $total > 1 && 'audio/mpeg' !== $audio['mime_type'] ) { + return new WP_Error( + 'unsupported_format', + esc_html__( 'Combining audio chunks requires MP3 output, which the provider did not return.', 'ai' ) + ); + } + + $combined .= Audio_Combiner::prepare_chunk( $bytes, $is_first, $is_last ); + } + + $audio['data'] = base64_encode( $combined ); + + return array( + 'audio' => $audio, + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function permission_callback( $args ) { + if ( ! current_user_can( 'upload_files' ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to generate speech.', 'ai' ) + ); + } + + $post_id = isset( $args['post_id'] ) ? absint( $args['post_id'] ) : 0; + + if ( $post_id ) { + $post = get_post( $post_id ); + + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + /* translators: %d: Post ID. */ + sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), $post_id ) + ); + } + + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to generate speech for this post.', 'ai' ) + ); + } + + $post_type_obj = get_post_type_object( (string) get_post_type( $post_id ) ); + + if ( ! $post_type_obj || empty( $post_type_obj->show_in_rest ) ) { + return false; + } + } + + return true; + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + ); + } +} diff --git a/includes/Abilities/Speech/Import_Base64_Audio.php b/includes/Abilities/Speech/Import_Base64_Audio.php new file mode 100644 index 000000000..028bd068a --- /dev/null +++ b/includes/Abilities/Speech/Import_Base64_Audio.php @@ -0,0 +1,321 @@ + 'object', + 'properties' => array( + 'data' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'The base64 encoded audio data to import into the media library.', 'ai' ), + ), + 'filename' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'The filename of the audio, without extension.', 'ai' ), + ), + 'title' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'The title of the audio attachment.', 'ai' ), + ), + 'description' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'The description of the audio attachment.', 'ai' ), + ), + 'mime_type' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'The MIME type of the audio.', 'ai' ), + ), + 'post_id' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'Optional post ID to attach the audio to.', 'ai' ), + ), + 'ai_generated' => array( + 'type' => 'boolean', + 'sanitize_callback' => 'rest_sanitize_boolean', + 'description' => esc_html__( 'Whether the audio was generated by AI.', 'ai' ), + ), + ), + 'required' => array( 'data' ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'audio' => array( + 'type' => 'object', + 'description' => esc_html__( 'Imported audio data.', 'ai' ), + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => esc_html__( 'Attachment ID.', 'ai' ), + ), + 'url' => array( + 'type' => 'string', + 'description' => esc_html__( 'Attachment URL.', 'ai' ), + ), + 'filename' => array( + 'type' => 'string', + 'description' => esc_html__( 'Attachment filename.', 'ai' ), + ), + 'title' => array( + 'type' => 'string', + 'description' => esc_html__( 'Attachment title.', 'ai' ), + ), + ), + ), + ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function execute_callback( $input ) { + $args = wp_parse_args( + $input, + array( + 'filename' => 'ai-generated-audio-' . time(), + 'title' => '', + 'description' => '', + 'mime_type' => null, + 'post_id' => 0, + 'ai_generated' => false, + ), + ); + + // Verify the data is a base64 encoded string. + try { + $file = new File( $input['data'], $args['mime_type'] ); + } catch ( Throwable $t ) { + return new WP_Error( + 'invalid_data', + esc_html__( 'The data is not a valid base64 encoded string.', 'ai' ) + ); + } + + // Verify the data is valid audio. + if ( ! $file->isAudio() ) { + return new WP_Error( + 'invalid_data', + esc_html__( 'The data is not valid audio.', 'ai' ) + ); + } + + $base64_data = $file->getBase64Data(); + + if ( empty( $base64_data ) ) { + return new WP_Error( + 'no_base64_data', + esc_html__( 'No base64 data found in the provided input.', 'ai' ) + ); + } + + $result = $this->import_audio( + $base64_data, + array( + 'mime_type' => $file->getMimeType(), + 'title' => $args['title'], + 'description' => $args['description'], + 'filename' => $args['filename'], + 'post_id' => absint( $args['post_id'] ), + 'ai_generated' => rest_sanitize_boolean( (bool) $args['ai_generated'] ), + ) + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return array( + 'audio' => $result, + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function permission_callback( $args ) { + if ( ! current_user_can( 'upload_files' ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to import audio.', 'ai' ) + ); + } + + $post_id = isset( $args['post_id'] ) ? absint( $args['post_id'] ) : 0; + + if ( $post_id && ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to attach audio to this post.', 'ai' ) + ); + } + + return true; + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + ); + } + + /** + * Imports audio from a base64 encoded string into the media library. + * + * @since x.x.x + * + * @param string $data The base64 encoded audio data. + * @param array $args The arguments for the audio import. + * - mime_type: The MIME type of the audio (e.g., 'audio/mpeg'). + * - title: The title of the attachment. + * - description: The description of the attachment. + * - filename: The filename, without extension. + * - post_id: Optional post ID to attach the audio to. + * - ai_generated: Whether the audio was generated by AI. + * @return array|\WP_Error The attachment data, or a WP_Error if there was an error. + */ + protected function import_audio( string $data, array $args = array() ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + + $decoded_data = base64_decode( $data, true ); + + if ( false === $decoded_data ) { + return new WP_Error( + 'invalid_base64', + esc_html__( 'Failed to decode base64 audio data.', 'ai' ) + ); + } + + $temp_file = wp_tempnam( 'ai-audio' ); + + $bytes_written = file_put_contents( $temp_file, $decoded_data ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_file_put_contents + + if ( false === $bytes_written ) { + wp_delete_file( $temp_file ); + return new WP_Error( + 'write_failed', + esc_html__( 'Failed to write audio data to temporary file.', 'ai' ) + ); + } + + $extension = wp_get_default_extension_for_mime_type( $args['mime_type'] ); + + if ( ! $extension ) { + $extension = 'mp3'; + } + + /** + * Filters the base filename (without extension) used when importing + * AI-generated audio. + * + * The returned value is sanitized via `sanitize_file_name()` and the + * extension is appended afterwards. + * + * @since x.x.x + * + * @param string $filename The base filename, without extension. + * @param array $args The import arguments. + * @return string The filtered base filename, without extension. + */ + $filename = (string) apply_filters( 'wpai_generated_audio_filename', $args['filename'], $args ); + + $file_array = array( + 'name' => sanitize_file_name( $filename ) . '.' . $extension, + 'type' => $args['mime_type'], + 'tmp_name' => $temp_file, + ); + + $post_data = array( + 'post_title' => sanitize_text_field( $args['title'] ), + 'post_content' => sanitize_text_field( $args['description'] ), + 'post_mime_type' => $args['mime_type'], + ); + + if ( $args['ai_generated'] ) { + $post_data['meta_input'] = array( + 'wpai_generated' => 1, + ); + } + + $attachment_id = media_handle_sideload( $file_array, (int) $args['post_id'], $args['description'], $post_data ); + + if ( file_exists( $temp_file ) ) { + wp_delete_file( $temp_file ); + } + + if ( is_wp_error( $attachment_id ) ) { + return $attachment_id; + } + + $attachment = get_post( $attachment_id ); + + if ( ! $attachment ) { + return new WP_Error( + 'attachment_not_found', + esc_html__( 'Failed to retrieve attachment data.', 'ai' ) + ); + } + + $attached_file = get_attached_file( $attachment_id ); + + return array( + 'id' => $attachment_id, + 'url' => wp_get_attachment_url( $attachment_id ), + 'filename' => $attached_file ? basename( $attached_file ) : '', + 'title' => $attachment->post_title, + ); + } +} diff --git a/includes/Contracts/Feature.php b/includes/Contracts/Feature.php index 5fe362067..924c85779 100644 --- a/includes/Contracts/Feature.php +++ b/includes/Contracts/Feature.php @@ -139,7 +139,7 @@ public function get_image(): string; * * @since 0.9.0 * - * @return string The capability type (e.g. 'text_generation', 'image_generation', 'vision'). + * @return string The capability type (e.g. 'text_generation', 'image_generation', 'vision', 'text_to_speech_conversion'). */ public function get_capability(): string; } diff --git a/includes/Experiments/Experiments.php b/includes/Experiments/Experiments.php index 4799075ec..d857d4da5 100644 --- a/includes/Experiments/Experiments.php +++ b/includes/Experiments/Experiments.php @@ -41,6 +41,7 @@ final class Experiments { \WordPress\AI\Experiments\Editorial_Updates\Editorial_Updates::class, \WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class, \WordPress\AI\Experiments\Meta_Description\Meta_Description::class, + \WordPress\AI\Experiments\Text_To_Speech\Text_To_Speech::class, \WordPress\AI\Experiments\Title_Generation\Title_Generation::class, \WordPress\AI\Experiments\Type_Ahead\Type_Ahead::class, ); diff --git a/includes/Experiments/Text_To_Speech/Audio_Combiner.php b/includes/Experiments/Text_To_Speech/Audio_Combiner.php new file mode 100644 index 000000000..dc27c7917 --- /dev/null +++ b/includes/Experiments/Text_To_Speech/Audio_Combiner.php @@ -0,0 +1,136 @@ += 128 && 'TAG' === substr( $bytes, -128, 3 ) ) { + return substr( $bytes, 0, -128 ); + } + + return $bytes; + } +} diff --git a/includes/Experiments/Text_To_Speech/Content_Chunker.php b/includes/Experiments/Text_To_Speech/Content_Chunker.php new file mode 100644 index 000000000..46c38a1f3 --- /dev/null +++ b/includes/Experiments/Text_To_Speech/Content_Chunker.php @@ -0,0 +1,102 @@ + The list of chunks. Empty if there is no content. + */ + public static function chunk( string $content, int $max_length ): array { + $content = trim( $content ); + + if ( '' === $content || $max_length < 1 ) { + return array(); + } + + if ( mb_strlen( $content ) <= $max_length ) { + return array( $content ); + } + + $sentences = preg_split( '/(?<=[.!?])\s+/u', $content ); + + if ( false === $sentences ) { + $sentences = array( $content ); + } + + $chunks = array(); + $current = ''; + + foreach ( $sentences as $sentence ) { + // A single sentence longer than the limit gets hard-split. + if ( mb_strlen( $sentence ) > $max_length ) { + if ( '' !== $current ) { + $chunks[] = $current; + $current = ''; + } + + $offset = 0; + $length = mb_strlen( $sentence ); + + while ( $offset < $length ) { + $chunks[] = mb_substr( $sentence, $offset, $max_length ); + $offset += $max_length; + } + + continue; + } + + $candidate = '' === $current ? $sentence : $current . ' ' . $sentence; + + if ( mb_strlen( $candidate ) > $max_length ) { + $chunks[] = $current; + $current = $sentence; + } else { + $current = $candidate; + } + } + + if ( '' !== $current ) { + $chunks[] = $current; + } + + return array_values( + array_filter( + array_map( 'trim', $chunks ), + static function ( string $chunk ): bool { + return '' !== $chunk; + } + ) + ); + } +} diff --git a/includes/Experiments/Text_To_Speech/Job_Manager.php b/includes/Experiments/Text_To_Speech/Job_Manager.php new file mode 100644 index 000000000..f3994e547 --- /dev/null +++ b/includes/Experiments/Text_To_Speech/Job_Manager.php @@ -0,0 +1,514 @@ +|\WP_Error The job status payload (see + * get_status()), or a WP_Error. + */ + public function start_job( int $post_id, int $user_id ) { + $post = get_post( $post_id ); + + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + /* translators: %d: Post ID. */ + sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), $post_id ) + ); + } + + $status = (string) get_post_meta( $post_id, self::META_STATUS, true ); + $updated = (int) get_post_meta( $post_id, self::META_UPDATED, true ); + + if ( + in_array( $status, array( 'pending', 'processing' ), true ) && + ( time() - $updated ) < self::STALE_JOB_SECONDS + ) { + return new WP_Error( + 'job_in_progress', + esc_html__( 'Audio generation is already in progress for this post.', 'ai' ) + ); + } + + // Match how other abilities read post content for AI context. + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + $body = normalize_content( (string) apply_filters( 'the_content', $post->post_content ) ); + + // Require actual body content: narrating a bare title is not a useful + // audio version of the post, and the title alone is never enough. + if ( '' === $body ) { + return new WP_Error( + 'no_content', + esc_html__( 'This post has no content to generate audio from.', 'ai' ) + ); + } + + // Prepend the post title so the audio announces it before the body. + // The period gives text to speech a sentence break between the two. + $title = normalize_content( get_the_title( $post ) ); + $content = '' !== $title ? $title . '. ' . $body : $body; + + /** + * Filters the maximum chunk length, in characters, for text to + * speech generation. + * + * The default of 4000 leaves headroom under most provider limits. + * + * @since x.x.x + * + * @param int $max_length The maximum chunk length in characters. + * @param int $post_id The post being converted. + */ + $max_length = (int) apply_filters( 'wpai_tts_max_chunk_length', 4000, $post_id ); + + $chunks = Content_Chunker::chunk( $content, $max_length ); + + if ( empty( $chunks ) ) { + return new WP_Error( + 'no_content', + esc_html__( 'This post has no content to generate audio from.', 'ai' ) + ); + } + + // Remove any temp file left behind by a previous job. + $this->delete_temp_file( $post_id ); + + $upload_dir = wp_upload_dir(); + $temp_file = trailingslashit( $upload_dir['basedir'] ) . sprintf( + 'wpai-tts-%d-%s.part', + $post_id, + wp_generate_password( 8, false ) + ); + + $job = array( + 'chunks' => $chunks, + 'next' => 0, + 'total' => count( $chunks ), + 'temp_file' => $temp_file, + 'mime_type' => '', + 'voice' => (string) get_option( 'wpai_feature_' . self::FEATURE_ID . '_field_voice', '' ), + 'user_id' => $user_id, + 'hash' => md5( $content ), + 'started' => time(), + ); + + update_post_meta( $post_id, self::META_JOB, $job ); + update_post_meta( $post_id, self::META_STATUS, 'pending' ); + update_post_meta( $post_id, self::META_ERROR, '' ); + update_post_meta( $post_id, self::META_UPDATED, time() ); + + $this->schedule_next( $post_id ); + + return $this->get_status( $post_id ); + } + + /** + * Processes the next pending chunk for a post. Cron callback. + * + * Generates audio for one chunk, appends it to the temp file, and either + * schedules the next chunk event or finalizes the job. + * + * @since x.x.x + * + * @param int $post_id The post being converted. + */ + public function process_chunk( int $post_id ): void { + $job = get_post_meta( $post_id, self::META_JOB, true ); + + if ( ! is_array( $job ) || ! isset( $job['chunks'], $job['next'], $job['total'], $job['temp_file'] ) ) { + return; + } + + update_post_meta( $post_id, self::META_STATUS, 'processing' ); + update_post_meta( $post_id, self::META_UPDATED, time() ); + + $index = (int) $job['next']; + $total = (int) $job['total']; + + if ( ! isset( $job['chunks'][ $index ] ) ) { + $this->fail_job( $post_id, $job, esc_html__( 'Audio generation state was corrupted. Please try again.', 'ai' ) ); + return; + } + + $result = ( new Speech_Generator() )->generate_chunk( (string) $job['chunks'][ $index ], (string) $job['voice'] ); + + if ( is_wp_error( $result ) ) { + $this->fail_job( $post_id, $job, $result->get_error_message() ); + return; + } + + $bytes = base64_decode( $result['data'], true ); + + if ( false === $bytes || '' === $bytes ) { + $this->fail_job( $post_id, $job, esc_html__( 'The provider returned invalid audio data.', 'ai' ) ); + return; + } + + $is_first = 0 === $index; + $is_last = $index + 1 >= $total; + + if ( $is_first ) { + $job['mime_type'] = $result['mime_type']; + } elseif ( $job['mime_type'] !== $result['mime_type'] ) { + $this->fail_job( $post_id, $job, esc_html__( 'The provider returned inconsistent audio formats across chunks.', 'ai' ) ); + return; + } + + if ( $total > 1 && 'audio/mpeg' !== $job['mime_type'] ) { + $this->fail_job( $post_id, $job, esc_html__( 'Combining audio chunks requires MP3 output, which the provider did not return.', 'ai' ) ); + return; + } + + $appended = Audio_Combiner::append_chunk( (string) $job['temp_file'], $bytes, $is_first, $is_last ); + + if ( is_wp_error( $appended ) ) { + $this->fail_job( $post_id, $job, $appended->get_error_message() ); + return; + } + + $job['next'] = $index + 1; + update_post_meta( $post_id, self::META_JOB, $job ); + update_post_meta( $post_id, self::META_UPDATED, time() ); + + if ( ! $is_last ) { + $this->schedule_next( $post_id ); + return; + } + + $this->finalize_job( $post_id, $job ); + } + + /** + * Returns the current job/audio status payload for a post. + * + * @since x.x.x + * + * @param int $post_id The post ID. + * @return array{status: string, done: int, total: int, error: string, audio_id: int, audio_url: string, display_audio: bool} The status payload. + */ + public function get_status( int $post_id ): array { + $status = (string) get_post_meta( $post_id, self::META_STATUS, true ); + $job = get_post_meta( $post_id, self::META_JOB, true ); + + $audio_id = absint( get_post_meta( $post_id, self::META_AUDIO_ID, true ) ); + $audio_url = $audio_id ? (string) wp_get_attachment_url( $audio_id ) : ''; + + return array( + 'status' => '' === $status ? 'idle' : $status, + 'done' => is_array( $job ) ? (int) $job['next'] : 0, + 'total' => is_array( $job ) ? (int) $job['total'] : 0, + 'error' => (string) get_post_meta( $post_id, self::META_ERROR, true ), + 'audio_id' => $audio_id, + 'audio_url' => $audio_url, + 'display_audio' => (bool) get_post_meta( $post_id, self::META_DISPLAY, true ), + ); + } + + /** + * Deletes a post's generated audio and all of its text to speech state. + * + * Cancels any in-flight job (scheduled cron event and temp file), deletes + * the audio attachment, and removes every piece of text to speech post + * meta, returning the post to the state it was in before any audio was + * generated. + * + * @since x.x.x + * + * @param int $post_id The post whose audio should be deleted. + * @return array{status: string, done: int, total: int, error: string, audio_id: int, audio_url: string, display_audio: bool} The status payload after deletion. + */ + public function delete_audio( int $post_id ): array { + // Stop any in-flight job before removing the state it depends on. + wp_clear_scheduled_hook( self::CRON_HOOK, array( $post_id ) ); + $this->delete_temp_file( $post_id ); + + $audio_id = absint( get_post_meta( $post_id, self::META_AUDIO_ID, true ) ); + + if ( $audio_id ) { + wp_delete_attachment( $audio_id, true ); + } + + foreach ( + array( + self::META_AUDIO_ID, + self::META_STATUS, + self::META_ERROR, + self::META_UPDATED, + self::META_JOB, + self::META_DISPLAY, + ) as $meta_key + ) { + delete_post_meta( $post_id, $meta_key ); + } + + return $this->get_status( $post_id ); + } + + /** + * Imports the combined audio file as an attachment and completes the job. + * + * The previously generated attachment (if any) is deleted only after the + * new one has been created successfully, so regeneration can never leave + * the post without audio. + * + * @since x.x.x + * + * @param int $post_id The post ID. + * @param array $job The job state. + */ + protected function finalize_job( int $post_id, array $job ): void { + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + + $mime_type = (string) $job['mime_type']; + $extension = wp_get_default_extension_for_mime_type( $mime_type ); + + if ( ! $extension ) { + $extension = 'mp3'; + } + + /** + * Filters the base filename (without extension) used when importing + * generated post audio. + * + * The returned value is sanitized via `sanitize_file_name()` and the + * extension is appended afterwards. + * + * @since x.x.x + * + * @param string $filename The base filename, without extension. + * @param int $post_id The post the audio belongs to. + */ + $filename = (string) apply_filters( 'wpai_tts_audio_filename', 'post-audio-' . $post_id, $post_id ); + + $file_array = array( + 'name' => sanitize_file_name( $filename ) . '.' . $extension, + 'type' => $mime_type, + 'tmp_name' => (string) $job['temp_file'], + ); + + $post_data = array( + 'post_title' => sprintf( + /* translators: %s: Post title. */ + __( 'Audio for “%s”', 'ai' ), + get_the_title( $post_id ) + ), + 'post_mime_type' => $mime_type, + 'post_author' => (int) $job['user_id'], + 'meta_input' => array( + 'wpai_generated' => 1, + ), + ); + + $attachment_id = media_handle_sideload( $file_array, $post_id, null, $post_data ); + + // media_handle_sideload() moves the temp file on success; remove it + // explicitly if it is still around (failure path). + if ( file_exists( (string) $job['temp_file'] ) ) { + wp_delete_file( (string) $job['temp_file'] ); + } + + if ( is_wp_error( $attachment_id ) ) { + $this->fail_job( $post_id, $job, $attachment_id->get_error_message() ); + return; + } + + $old_audio_id = absint( get_post_meta( $post_id, self::META_AUDIO_ID, true ) ); + + update_post_meta( $post_id, self::META_AUDIO_ID, $attachment_id ); + update_post_meta( $post_id, self::META_STATUS, 'complete' ); + update_post_meta( $post_id, self::META_ERROR, '' ); + update_post_meta( $post_id, self::META_UPDATED, time() ); + delete_post_meta( $post_id, self::META_JOB ); + + if ( ! $old_audio_id || $old_audio_id === $attachment_id ) { + return; + } + + wp_delete_attachment( $old_audio_id, true ); + } + + /** + * Marks a job as failed and cleans up its transient state. + * + * Previously generated audio (and its meta) is intentionally preserved. + * + * @since x.x.x + * + * @param int $post_id The post ID. + * @param array $job The job state. + * @param string $message The error message to store. + */ + protected function fail_job( int $post_id, array $job, string $message ): void { + if ( ! empty( $job['temp_file'] ) && file_exists( (string) $job['temp_file'] ) ) { + wp_delete_file( (string) $job['temp_file'] ); + } + + update_post_meta( $post_id, self::META_STATUS, 'error' ); + update_post_meta( $post_id, self::META_ERROR, $message ); + update_post_meta( $post_id, self::META_UPDATED, time() ); + delete_post_meta( $post_id, self::META_JOB ); + } + + /** + * Schedules the next chunk event and asks WP-Cron to spawn immediately. + * + * The editor's status polling issues REST requests every few seconds + * while a job runs, which also triggers WP-Cron spawning on low-traffic + * sites. + * + * @since x.x.x + * + * @param int $post_id The post ID. + */ + protected function schedule_next( int $post_id ): void { + if ( ! wp_next_scheduled( self::CRON_HOOK, array( $post_id ) ) ) { + wp_schedule_single_event( time(), self::CRON_HOOK, array( $post_id ) ); + } + + if ( ! function_exists( 'spawn_cron' ) ) { + return; + } + + spawn_cron(); + } + + /** + * Deletes the temp file referenced by a post's current job blob, if any. + * + * @since x.x.x + * + * @param int $post_id The post ID. + */ + protected function delete_temp_file( int $post_id ): void { + $job = get_post_meta( $post_id, self::META_JOB, true ); + + if ( ! is_array( $job ) || empty( $job['temp_file'] ) || ! file_exists( (string) $job['temp_file'] ) ) { + return; + } + + wp_delete_file( (string) $job['temp_file'] ); + } +} diff --git a/includes/Experiments/Text_To_Speech/REST_Controller.php b/includes/Experiments/Text_To_Speech/REST_Controller.php new file mode 100644 index 000000000..ff58cd91c --- /dev/null +++ b/includes/Experiments/Text_To_Speech/REST_Controller.php @@ -0,0 +1,273 @@ +\d+)', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'start_job' ), + 'permission_callback' => array( $this, 'can_generate' ), + 'args' => $this->get_route_args(), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_status' ), + 'permission_callback' => array( $this, 'can_view_status' ), + 'args' => $this->get_route_args(), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_audio' ), + 'permission_callback' => array( $this, 'can_delete' ), + 'args' => $this->get_route_args(), + ), + ) + ); + } + + /** + * Returns the shared route argument definitions. + * + * @since x.x.x + * + * @return array> The route args. + */ + protected function get_route_args(): array { + return array( + 'id' => array( + 'description' => esc_html__( 'The post ID.', 'ai' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + ); + } + + /** + * Checks whether the current user can trigger audio generation. + * + * @since x.x.x + * + * @param \WP_REST_Request $request The request. + * @return true|\WP_Error True if permitted, WP_Error otherwise. + */ + public function can_generate( WP_REST_Request $request ) { + $post_check = $this->check_post( $request ); + + if ( is_wp_error( $post_check ) ) { + return $post_check; + } + + $post_id = absint( $request['id'] ); + + if ( ! current_user_can( 'edit_post', $post_id ) || ! current_user_can( 'upload_files' ) ) { + return new WP_Error( + 'rest_forbidden', + esc_html__( 'You do not have permission to generate audio for this post.', 'ai' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Checks whether the current user can view generation status. + * + * @since x.x.x + * + * @param \WP_REST_Request $request The request. + * @return true|\WP_Error True if permitted, WP_Error otherwise. + */ + public function can_view_status( WP_REST_Request $request ) { + $post_check = $this->check_post( $request ); + + if ( is_wp_error( $post_check ) ) { + return $post_check; + } + + if ( ! current_user_can( 'edit_post', absint( $request['id'] ) ) ) { + return new WP_Error( + 'rest_forbidden', + esc_html__( 'You do not have permission to view audio generation status for this post.', 'ai' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Checks whether the current user can delete a post's generated audio. + * + * @since x.x.x + * + * @param \WP_REST_Request $request The request. + * @return true|\WP_Error True if permitted, WP_Error otherwise. + */ + public function can_delete( WP_REST_Request $request ) { + $post_check = $this->check_post( $request ); + + if ( is_wp_error( $post_check ) ) { + return $post_check; + } + + $post_id = absint( $request['id'] ); + + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'rest_forbidden', + esc_html__( 'You do not have permission to delete audio for this post.', 'ai' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + $audio_id = absint( get_post_meta( $post_id, Job_Manager::META_AUDIO_ID, true ) ); + + if ( $audio_id && get_post( $audio_id ) && ! current_user_can( 'delete_post', $audio_id ) ) { + return new WP_Error( + 'rest_forbidden', + esc_html__( 'You do not have permission to delete the audio file for this post.', 'ai' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Checks that the requested post exists and is REST-visible. + * + * @since x.x.x + * + * @param \WP_REST_Request $request The request. + * @return true|\WP_Error True if valid, WP_Error otherwise. + */ + protected function check_post( WP_REST_Request $request ) { + $post_id = absint( $request['id'] ); + $post = get_post( $post_id ); + + if ( ! $post ) { + return new WP_Error( + 'rest_post_not_found', + esc_html__( 'Post not found.', 'ai' ), + array( 'status' => 404 ) + ); + } + + $post_type_obj = get_post_type_object( (string) get_post_type( $post_id ) ); + + if ( ! $post_type_obj || empty( $post_type_obj->show_in_rest ) ) { + return new WP_Error( + 'rest_invalid_post_type', + esc_html__( 'Audio generation is not available for this post type.', 'ai' ), + array( 'status' => 400 ) + ); + } + + return true; + } + + /** + * Starts (or restarts) a background audio generation job. + * + * @since x.x.x + * + * @param \WP_REST_Request $request The request. + * @return \WP_REST_Response|\WP_Error The status payload or an error. + */ + public function start_job( WP_REST_Request $request ) { + if ( ! has_text_to_speech_support( true ) ) { + return new WP_Error( + 'unsupported', + esc_html__( 'No connected AI provider supports text to speech.', 'ai' ), + array( 'status' => 400 ) + ); + } + + $result = ( new Job_Manager() )->start_job( absint( $request['id'] ), get_current_user_id() ); + + if ( is_wp_error( $result ) ) { + return new WP_Error( + $result->get_error_code(), + $result->get_error_message(), + array( 'status' => 400 ) + ); + } + + return rest_ensure_response( $result ); + } + + /** + * Returns the current job/audio status for a post. + * + * @since x.x.x + * + * @param \WP_REST_Request $request The request. + * @return \WP_REST_Response The status payload. + */ + public function get_status( WP_REST_Request $request ) { + return rest_ensure_response( ( new Job_Manager() )->get_status( absint( $request['id'] ) ) ); + } + + /** + * Deletes a post's generated audio and its text to speech state. + * + * @since x.x.x + * + * @param \WP_REST_Request $request The request. + * @return \WP_REST_Response The status payload after deletion. + */ + public function delete_audio( WP_REST_Request $request ) { + return rest_ensure_response( ( new Job_Manager() )->delete_audio( absint( $request['id'] ) ) ); + } +} diff --git a/includes/Experiments/Text_To_Speech/Speech_Generator.php b/includes/Experiments/Text_To_Speech/Speech_Generator.php new file mode 100644 index 000000000..a5d40a521 --- /dev/null +++ b/includes/Experiments/Text_To_Speech/Speech_Generator.php @@ -0,0 +1,150 @@ +, model_metadata: array}|\WP_Error + * Base64 audio data plus metadata, or a WP_Error. + */ + public function generate_chunk( string $text, string $voice = '' ) { + /** + * Short-circuits text to speech generation for a single chunk. + * + * Return an array with a base64 `data` key (and optional `mime_type`, + * default 'audio/mpeg') to skip calling the AI client entirely, or a + * WP_Error to fail generation. This is the integration point for + * third-party TTS services and for tests, and works even when no + * connected AI provider supports text to speech (pair it with the + * `wpai_has_text_to_speech_support` filter). + * + * @since x.x.x + * + * @param array{data: string, mime_type?: string}|\WP_Error|null $pre The short-circuit value. Default null. + * @param string $text The chunk text. + * @param string $voice The configured voice, or empty string. + */ + $pre = apply_filters( 'wpai_tts_pre_generate_chunk', null, $text, $voice ); + + if ( is_wp_error( $pre ) ) { + return $pre; + } + + if ( is_array( $pre ) && isset( $pre['data'] ) ) { + return array( + 'data' => (string) $pre['data'], + 'mime_type' => isset( $pre['mime_type'] ) ? (string) $pre['mime_type'] : 'audio/mpeg', + 'provider_metadata' => array(), + 'model_metadata' => array(), + ); + } + + try { + $request_options = new RequestOptions(); + $request_options->setTimeout( + get_default_request_timeout( Job_Manager::FEATURE_ID, 120 ) + ); + + $prompt_builder = wp_ai_client_prompt( $text ) + ->using_request_options( $request_options ) + ->as_output_file_type( FileTypeEnum::inline() ); + + if ( '' !== $voice ) { + $prompt_builder = $prompt_builder->as_output_speech_voice( $voice ); + } + + // Same provider/model resolution as + // Abstract_Ability::set_provider_model_preference(), replicated + // here because the cron path runs outside an ability. + $config = get_feature_developer_model_config( Job_Manager::FEATURE_ID ); + + if ( ! empty( $config['provider'] ) && ! empty( $config['model'] ) ) { + $prompt_builder->using_model( + AiClient::defaultRegistry()->getProviderModel( $config['provider'], $config['model'] ) + ); + } else { + if ( ! empty( $config['provider'] ) ) { + $prompt_builder->using_provider( $config['provider'] ); + } + + $prompt_builder->using_model_preference( ...get_preferred_speech_models() ); + } + + if ( ! $prompt_builder->is_supported_for_text_to_speech_conversion() ) { + return new WP_Error( + 'unsupported_model', + esc_html__( 'Audio generation failed. Please ensure you have a connected provider that supports text to speech.', 'ai' ) + ); + } + + $result = $prompt_builder->convert_text_to_speech_result(); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $file = $result->toAudioFile(); + $data = (string) ( $file->getBase64Data() ?? '' ); + + if ( '' === $data ) { + return new WP_Error( + 'no_audio_data', + esc_html__( 'The provider did not return inline audio data.', 'ai' ) + ); + } + + $provider_metadata = $result->getProviderMetadata()->toArray(); + $model_metadata = $result->getModelMetadata()->toArray(); + + // Remove data we don't care about. + unset( $provider_metadata[ ProviderMetadata::KEY_CREDENTIALS_URL ] ); + unset( $model_metadata[ ModelMetadata::KEY_SUPPORTED_OPTIONS ] ); + unset( $model_metadata[ ModelMetadata::KEY_SUPPORTED_CAPABILITIES ] ); + + return array( + 'data' => $data, + 'mime_type' => (string) $file->getMimeType(), + 'provider_metadata' => $provider_metadata, + 'model_metadata' => $model_metadata, + ); + } catch ( Throwable $t ) { + return new WP_Error( 'tts_failed', $t->getMessage() ); + } + } +} diff --git a/includes/Experiments/Text_To_Speech/Text_To_Speech.php b/includes/Experiments/Text_To_Speech/Text_To_Speech.php new file mode 100644 index 000000000..81f9fcd77 --- /dev/null +++ b/includes/Experiments/Text_To_Speech/Text_To_Speech.php @@ -0,0 +1,290 @@ + __( 'Text to Speech', 'ai' ), + 'description' => __( 'Generates an audio version of post content so visitors can listen instead of read. Requires an AI connector that includes support for text to speech models.', 'ai' ), + 'category' => Experiment_Category::EDITOR, + 'capability' => 'text_to_speech_conversion', + ); + } + + /** + * {@inheritDoc} + */ + public function register(): void { + $this->register_post_meta(); + + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) ); + add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_assets' ), 5 ); + add_action( 'enqueue_block_assets', array( $this, 'enqueue_block_assets' ) ); + add_action( Job_Manager::CRON_HOOK, array( $this, 'process_chunk' ) ); + add_filter( 'the_content', array( $this, 'render_audio_player' ) ); + } + + /** + * Registers the post meta used to track generated audio. + * + * @since x.x.x + */ + public function register_post_meta(): void { + register_meta( + 'post', + Job_Manager::META_DISPLAY, + array( + 'type' => 'boolean', + 'single' => true, + 'default' => true, + 'show_in_rest' => true, + ) + ); + + register_meta( + 'post', + Job_Manager::META_AUDIO_ID, + array( + 'type' => 'integer', + 'single' => true, + 'default' => 0, + ) + ); + + register_meta( + 'post', + Job_Manager::META_STATUS, + array( + 'type' => 'string', + 'single' => true, + 'default' => '', + ) + ); + + register_meta( + 'post', + Job_Manager::META_ERROR, + array( + 'type' => 'string', + 'single' => true, + 'default' => '', + ) + ); + + register_meta( + 'post', + Job_Manager::META_UPDATED, + array( + 'type' => 'integer', + 'single' => true, + 'default' => 0, + ) + ); + } + + /** + * Registers any needed abilities. + * + * @since x.x.x + */ + public function register_abilities(): void { + wp_register_ability( + 'ai/speech-generation', + array( + 'label' => __( 'Speech Generation', 'ai' ), + 'description' => __( 'Generates speech audio from text or from a post’s content.', 'ai' ), + 'ability_class' => Generate_Speech::class, + ), + ); + + wp_register_ability( + 'ai/speech-import', + array( + 'label' => __( 'Speech Import', 'ai' ), + 'description' => __( 'Imports base64-encoded audio into the media library.', 'ai' ), + 'ability_class' => Import_Base64_Audio::class, + ), + ); + } + + /** + * Registers the REST routes used by the editor's background flow. + * + * @since x.x.x + */ + public function register_rest_routes(): void { + ( new REST_Controller() )->register_routes(); + } + + /** + * Processes the next audio chunk for a post. Cron callback. + * + * @since x.x.x + * + * @param int $post_id The post being converted. + */ + public function process_chunk( $post_id ): void { + ( new Job_Manager() )->process_chunk( (int) $post_id ); + } + + /** + * Enqueues and localizes the block editor script. + * + * @since x.x.x + */ + public function enqueue_assets(): void { + $screen = get_current_screen(); + + if ( ! $screen || 'post' !== $screen->base || 'attachment' === $screen->post_type ) { + return; + } + + Asset_Loader::enqueue_script( 'text_to_speech', 'experiments/text-to-speech' ); + Asset_Loader::localize_script( + 'text_to_speech', + 'TextToSpeechData', + array( + 'enabled' => $this->is_enabled(), + 'hasTtsSupport' => has_text_to_speech_support(), + ) + ); + } + + /** + * Enqueues the stylesheet for the editor iframe and the front end. + * + * @since x.x.x + */ + public function enqueue_block_assets(): void { + Asset_Loader::enqueue_style( 'text_to_speech', 'experiments/text-to-speech' ); + } + + /** + * Prepends the audio player to post content on singular front-end views. + * + * @since x.x.x + * + * @param string $content The post content. + * @return string The content, with the player prepended when applicable. + */ + public function render_audio_player( string $content ): string { + if ( is_admin() || ! is_singular() || ! in_the_loop() || ! is_main_query() ) { + return $content; + } + + $post_id = get_the_ID(); + + if ( ! $post_id ) { + return $content; + } + + if ( ! get_post_meta( $post_id, Job_Manager::META_DISPLAY, true ) ) { + return $content; + } + + $audio_id = absint( get_post_meta( $post_id, Job_Manager::META_AUDIO_ID, true ) ); + + if ( ! $audio_id ) { + return $content; + } + + $audio_url = wp_get_attachment_url( $audio_id ); + + if ( ! $audio_url ) { + return $content; + } + + $player = sprintf( + '
%s
', + esc_html__( 'Listen to this post', 'ai' ), + esc_url( $audio_url ) + ); + + /** + * Filters the audio player markup rendered above post content. + * + * @since x.x.x + * + * @param string $player The player markup. + * @param int $post_id The post ID. + * @param int $audio_id The audio attachment ID. + */ + $player = (string) apply_filters( 'wpai_tts_player_markup', $player, $post_id, $audio_id ); + + return $player . $content; + } + + /** + * Registers experiment-specific settings. + * + * @since x.x.x + */ + public function register_settings(): void { + register_setting( + Settings_Registration::OPTION_GROUP, + static::get_field_option_name( 'voice' ), + array( + 'type' => 'string', + 'default' => '', + 'sanitize_callback' => 'sanitize_text_field', + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'string', + ), + ), + ) + ); + } + + /** + * {@inheritDoc} + */ + public function get_settings_fields(): array { + return array( + array( + 'id' => 'voice', + 'label' => __( 'Voice', 'ai' ), + 'type' => 'text', + 'default' => '', + ), + ); + } +} diff --git a/includes/REST/Models_Controller.php b/includes/REST/Models_Controller.php index fe2ee8988..be1bb0bc5 100644 --- a/includes/REST/Models_Controller.php +++ b/includes/REST/Models_Controller.php @@ -167,6 +167,12 @@ private function build_requirements( string $capability ): ModelRequirements { array() ); + case 'text_to_speech_conversion': + return new ModelRequirements( + array( CapabilityEnum::textToSpeechConversion() ), + array() + ); + case 'vision': return new ModelRequirements( array( CapabilityEnum::textGeneration() ), diff --git a/includes/helpers.php b/includes/helpers.php index e50baffcc..8c8ab7823 100644 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -345,6 +345,40 @@ function get_preferred_vision_models(): array { return (array) apply_filters( 'wpai_preferred_vision_models', $preferred_models ); } +/** + * Returns the preferred models for text to speech conversion. + * + * @since x.x.x + * + * @return array The preferred models for text to speech conversion. + */ +function get_preferred_speech_models(): array { + $preferred_models = array( + array( + 'openai', + 'gpt-4o-mini-tts', + ), + array( + 'openai', + 'tts-1-hd', + ), + array( + 'openai', + 'tts-1', + ), + ); + + /** + * Filters the preferred models for text to speech conversion. + * + * @since x.x.x + * + * @param array $preferred_models The preferred models for text to speech conversion. + * @return array The filtered preferred models. + */ + return (array) apply_filters( 'wpai_preferred_speech_models', $preferred_models ); +} + /** * Returns the developer-mode provider/model config saved for a feature. * @@ -575,6 +609,68 @@ function has_image_generation_support( bool $reset_cache = false ): bool { return $result; } +/** + * Checks whether any configured connector exposes a text-to-speech-capable model. + * + * @since x.x.x + * + * @param bool $reset_cache Whether to bypass the static cache and recompute. Default false. + * @return bool True if at least one connector supports text to speech conversion. + */ +function has_text_to_speech_support( bool $reset_cache = false ): bool { + static $result = null; + + if ( ! $reset_cache && null !== $result ) { + return $result; + } + + $connectors = array(); + $has_support = false; + $registry = AiClient::defaultRegistry(); + $connectors = get_ai_connectors(); + + foreach ( array_keys( $connectors ) as $connector_id ) { + if ( ! has_connector_authentication( $connector_id ) ) { + continue; + } + + try { + $provider_class = $registry->getProviderClassName( $connector_id ); + + /** @var \WordPress\AiClient\Providers\Contracts\ProviderInterface $provider_class */ + $models = $provider_class::modelMetadataDirectory()->listModelMetadata(); + + foreach ( $models as $model ) { + foreach ( $model->getSupportedCapabilities() as $capability ) { + if ( CapabilityEnum::TEXT_TO_SPEECH_CONVERSION === $capability->value ) { + $has_support = true; + break 3; + } + } + } + } catch ( Throwable $e ) { + continue; + } + } + + /** + * Filters whether text to speech conversion is supported. + * + * Allows third-party plugins to declare text to speech support for + * connectors that do not rely on API key settings, or to force support + * on when providing audio through the `wpai_tts_pre_generate_chunk` + * filter instead of an AI provider. + * + * @since x.x.x + * + * @param bool $has_support Whether text to speech conversion is supported. + * @param array $connectors The registered connectors. + */ + $result = (bool) apply_filters( 'wpai_has_text_to_speech_support', $has_support, $connectors ); + + return $result; +} + /** * Returns provider availability data for script localization. * diff --git a/src/experiments/text-to-speech/components/TextToSpeechPanel.tsx b/src/experiments/text-to-speech/components/TextToSpeechPanel.tsx new file mode 100644 index 000000000..8ed6ddd72 --- /dev/null +++ b/src/experiments/text-to-speech/components/TextToSpeechPanel.tsx @@ -0,0 +1,187 @@ +/** + * Text to Speech sidebar panel contents. + */ + +/** + * WordPress dependencies + */ +import { Button, Notice, Spinner, ToggleControl } from '@wordpress/components'; +import { useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { audio } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { useSpeechGeneration } from './useSpeechGeneration'; +import type { TextToSpeechData } from '../types'; + +/** + * Get the settings for the Text to Speech panel. + * + * @return {TextToSpeechData} The settings for the Text to Speech panel. + */ +const getSettings = (): TextToSpeechData => { + const settings = ( window as any ).aiTextToSpeechData ?? {}; + + return { + enabled: settings.enabled ?? false, + hasTtsSupport: settings.hasTtsSupport ?? false, + }; +}; + +/** + * Panel component with the generate button, progress, preview, and the + * front-end display toggle. + * + * @return {React.JSX.Element} The Text to Speech panel component. + */ +export default function TextToSpeechPanel(): React.JSX.Element { + const { hasTtsSupport } = getSettings(); + const { + status, + isGenerating, + isBlockedByUnsavedChanges, + hasAudio, + audioUrl, + displayAudio, + isDeleting, + setDisplayAudio, + handleGenerate, + handleDelete, + } = useSpeechGeneration(); + const [ confirmingDelete, setConfirmingDelete ] = useState( false ); + + if ( isGenerating ) { + return ( +
+
+ + + { status && status.total > 0 + ? sprintf( + /* translators: 1: number of chunks processed, 2: total number of chunks */ + __( + 'Generating audio… (%1$d of %2$d)', + 'ai' + ), + status.done, + status.total + ) + : __( 'Generating audio…', 'ai' ) } + +
+
+ ); + } + + return ( +
+ { ! hasTtsSupport && ( + + { __( + 'No connected AI provider supports text to speech.', + 'ai' + ) } + + ) } + + { hasAudio && audioUrl && ( +
+ ); +} diff --git a/src/experiments/text-to-speech/components/useSpeechGeneration.ts b/src/experiments/text-to-speech/components/useSpeechGeneration.ts new file mode 100644 index 000000000..f1ea73880 --- /dev/null +++ b/src/experiments/text-to-speech/components/useSpeechGeneration.ts @@ -0,0 +1,171 @@ +/** + * Shared hook for text to speech generation logic. + */ + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { dispatch, useDispatch, useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { useCallback, useEffect, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import { ensureProvider } from '../../../utils/provider-status'; +import type { TtsStatus } from '../types'; + +const NOTICE_ID = 'ai_text_to_speech_error'; +const POLL_INTERVAL = 5000; + +/** + * Hook for text to speech generation functionality. + * + * Starts background generation via POST /ai/v1/text-to-speech/{id} and polls + * the same route with GET while a job is running. The job itself runs + * server-side in WP-Cron, so it survives the editor being closed; re-opening + * the editor resumes polling from the persisted status. + * + * @return {Object} Object with generation state and handlers. + */ +export function useSpeechGeneration(): { + status: TtsStatus | null; + isGenerating: boolean; + isBlockedByUnsavedChanges: boolean; + hasAudio: boolean; + audioUrl: string; + displayAudio: boolean; + isDeleting: boolean; + setDisplayAudio: ( value: boolean ) => void; + handleGenerate: () => Promise< void >; + handleDelete: () => Promise< void >; +} { + const { postId, isDirty, isSaving, meta } = useSelect( ( select ) => { + return { + postId: select( editorStore ).getCurrentPostId(), + isDirty: select( editorStore ).isEditedPostDirty(), + isSaving: select( editorStore ).isSavingPost(), + meta: select( editorStore ).getEditedPostAttribute( 'meta' ) as + | { wpai_tts_display_audio?: boolean } + | undefined, + }; + }, [] ); + const { editPost } = useDispatch( editorStore ); + const [ status, setStatus ] = useState< TtsStatus | null >( null ); + const [ isStarting, setIsStarting ] = useState< boolean >( false ); + const [ isDeleting, setIsDeleting ] = useState< boolean >( false ); + + const fetchStatus = useCallback( async (): Promise< TtsStatus | null > => { + if ( ! postId ) { + return null; + } + + try { + const result = await apiFetch< TtsStatus >( { + path: `/ai/v1/text-to-speech/${ postId }`, + } ); + setStatus( result ); + return result; + } catch { + return null; + } + }, [ postId ] ); + + // Load persisted status when the editor opens. + useEffect( () => { + fetchStatus(); + }, [ fetchStatus ] ); + + const isGenerating = + isStarting || + status?.status === 'pending' || + status?.status === 'processing'; + + // Poll while a background job is running. Polling also keeps WP-Cron + // spawning on low-traffic sites. + useEffect( () => { + if ( ! isGenerating ) { + return undefined; + } + + const intervalId = window.setInterval( async () => { + const result = await fetchStatus(); + + if ( result?.status === 'error' ) { + dispatch( noticesStore ).createErrorNotice( + result.error || __( 'Audio generation failed.', 'ai' ), + { id: NOTICE_ID, isDismissible: true } + ); + } + }, POLL_INTERVAL ); + + return () => window.clearInterval( intervalId ); + }, [ isGenerating, fetchStatus ] ); + + const handleGenerate = async () => { + if ( ! ensureProvider( NOTICE_ID ) ) { + return; + } + + setIsStarting( true ); + dispatch( noticesStore ).removeNotice( NOTICE_ID ); + + try { + const result = await apiFetch< TtsStatus >( { + path: `/ai/v1/text-to-speech/${ postId }`, + method: 'POST', + } ); + setStatus( result ); + } catch ( error: any ) { + dispatch( noticesStore ).createErrorNotice( + error?.message ?? + __( 'Failed to start audio generation.', 'ai' ), + { id: NOTICE_ID, isDismissible: true } + ); + } finally { + setIsStarting( false ); + } + }; + + const handleDelete = async () => { + setIsDeleting( true ); + dispatch( noticesStore ).removeNotice( NOTICE_ID ); + + try { + const result = await apiFetch< TtsStatus >( { + path: `/ai/v1/text-to-speech/${ postId }`, + method: 'DELETE', + } ); + setStatus( result ); + } catch ( error: any ) { + dispatch( noticesStore ).createErrorNotice( + error?.message ?? __( 'Failed to delete audio.', 'ai' ), + { id: NOTICE_ID, isDismissible: true } + ); + } finally { + setIsDeleting( false ); + } + }; + + const displayAudio = meta?.wpai_tts_display_audio ?? true; + + const setDisplayAudio = ( value: boolean ) => { + editPost( { meta: { wpai_tts_display_audio: value } } ); + }; + + return { + status, + isGenerating, + isBlockedByUnsavedChanges: Boolean( isDirty || isSaving ), + hasAudio: Boolean( status?.audio_id ), + audioUrl: status?.audio_url ?? '', + displayAudio, + isDeleting, + setDisplayAudio, + handleGenerate, + handleDelete, + }; +} diff --git a/src/experiments/text-to-speech/index.scss b/src/experiments/text-to-speech/index.scss new file mode 100644 index 000000000..0af9aa344 --- /dev/null +++ b/src/experiments/text-to-speech/index.scss @@ -0,0 +1,63 @@ +// Front-end player (rendered via the_content filter in PHP). +.wpai-tts-player { + display: flex; + flex-direction: column; + gap: 0.5em; + margin-bottom: 1.5em; + + audio { + width: 100%; + } + + &__label { + font-weight: 600; + } +} + +// Editor sidebar panel. +.ai-text-to-speech-panel { + &__content { + display: flex; + flex-direction: column; + gap: 16px; + + .components-button { + justify-content: center !important; + } + } + + &__loading { + display: flex; + align-items: center; + gap: 8px; + + .components-spinner { + margin: 0; + } + } + + &__preview { + width: 100%; + } + + &__confirm { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__confirm-actions { + display: flex; + gap: 8px; + + .components-button { + flex: 1; + } + } + + &__help { + margin: 0; + color: #757575; + font-size: 12px; + } +} diff --git a/src/experiments/text-to-speech/index.tsx b/src/experiments/text-to-speech/index.tsx new file mode 100644 index 000000000..0c6ea5ee0 --- /dev/null +++ b/src/experiments/text-to-speech/index.tsx @@ -0,0 +1,45 @@ +/** + * Text to Speech experiment plugin registration. + */ + +/** + * WordPress dependencies + */ +import { PluginDocumentSettingPanel } from '@wordpress/editor'; +import { registerPlugin } from '@wordpress/plugins'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import TextToSpeechPanel from './components/TextToSpeechPanel'; +import './index.scss'; + +import type { TextToSpeechData } from './types'; + +const localized = ( window as any ).aiTextToSpeechData as + | TextToSpeechData + | undefined; + +/** + * Plugin component that renders the Text to Speech panel in the editor sidebar. + */ +const TextToSpeechPlugin = (): React.JSX.Element | null => { + if ( ! localized?.enabled ) { + return null; + } + + return ( + + + + ); +}; + +registerPlugin( 'ai-text-to-speech', { + render: TextToSpeechPlugin, +} ); diff --git a/src/experiments/text-to-speech/types.ts b/src/experiments/text-to-speech/types.ts new file mode 100644 index 000000000..b909e6c0b --- /dev/null +++ b/src/experiments/text-to-speech/types.ts @@ -0,0 +1,18 @@ +/** + * Types for the Text to Speech experiment. + */ + +export interface TextToSpeechData { + enabled: boolean; + hasTtsSupport: boolean; +} + +export interface TtsStatus { + status: 'idle' | 'pending' | 'processing' | 'complete' | 'error'; + done: number; + total: number; + error: string; + audio_id: number; + audio_url: string; + display_audio: boolean; +} diff --git a/tests/Integration/Includes/Abilities/SpeechTest.php b/tests/Integration/Includes/Abilities/SpeechTest.php new file mode 100644 index 000000000..7fff9ace0 --- /dev/null +++ b/tests/Integration/Includes/Abilities/SpeechTest.php @@ -0,0 +1,263 @@ +generate_ability = new Generate_Speech( + 'ai/speech-generation', + array( + 'label' => 'Speech Generation', + 'description' => 'Generates speech audio from text or from a post.', + ) + ); + + $this->import_ability = new Import_Base64_Audio( + 'ai/speech-import', + array( + 'label' => 'Speech Import', + 'description' => 'Imports base64-encoded audio into the media library.', + ) + ); + + // Fake audio so no AI provider is required. + add_filter( + 'wpai_tts_pre_generate_chunk', + static function ( $pre, $text ) { + return array( 'data' => base64_encode( 'X:' . $text ) ); + }, + 10, + 2 + ); + + // Fake bytes are not real MP3 data; bypass content sniffing. + add_filter( + 'wp_check_filetype_and_ext', + static function () { + return array( + 'ext' => 'mp3', + 'type' => 'audio/mpeg', + 'proper_filename' => false, + ); + } + ); + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Invokes a protected callback on an ability instance. + * + * @since x.x.x + * + * @param object $ability The ability instance. + * @param string $method The method name. + * @param mixed $input The input argument. + * @return mixed The callback result. + */ + private function invoke( $ability, string $method, $input ) { + $reflection = new ReflectionClass( $ability ); + $callback = $reflection->getMethod( $method ); + $callback->setAccessible( true ); + + return $callback->invoke( $ability, $input ); + } + + /** + * Test that speech is generated from direct text input. + * + * @since x.x.x + */ + public function test_generate_from_text(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + $result = $this->invoke( $this->generate_ability, 'execute_callback', array( 'text' => 'Hello world.' ) ); + + $this->assertIsArray( $result ); + $this->assertSame( base64_encode( 'X:Hello world.' ), $result['audio']['data'] ); + $this->assertSame( 'audio/mpeg', $result['audio']['mime_type'] ); + } + + /** + * Test that speech is generated from a post's content. + * + * @since x.x.x + */ + public function test_generate_from_post_id(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $post_id = self::factory()->post->create( array( 'post_content' => 'Post content sentence.' ) ); + + $result = $this->invoke( $this->generate_ability, 'execute_callback', array( 'post_id' => $post_id ) ); + + $this->assertIsArray( $result ); + $this->assertSame( base64_encode( 'X:Post content sentence.' ), $result['audio']['data'] ); + } + + /** + * Test that multiple chunks are combined into one audio payload. + * + * @since x.x.x + */ + public function test_generate_combines_chunks(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + // 25-char limit: each sentence fits alone (20 and 16 chars) but not + // joined (37 chars), so the chunker produces exactly two chunks. + add_filter( + 'wpai_tts_max_chunk_length', + static function () { + return 25; + } + ); + + $result = $this->invoke( + $this->generate_ability, + 'execute_callback', + array( 'text' => 'First sentence here. Second one here.' ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( + base64_encode( 'X:First sentence here.' . 'X:Second one here.' ), + $result['audio']['data'] + ); + } + + /** + * Test that generation without text or a post errors. + * + * @since x.x.x + */ + public function test_generate_requires_text_or_post(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + $result = $this->invoke( $this->generate_ability, 'execute_callback', array() ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'no_content', $result->get_error_code() ); + } + + /** + * Test that generation permission is denied without upload_files. + * + * @since x.x.x + */ + public function test_generate_permission_denied_for_subscriber(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + $result = $this->invoke( $this->generate_ability, 'permission_callback', array( 'text' => 'Hi.' ) ); + + $this->assertInstanceOf( WP_Error::class, $result ); + } + + /** + * Test that audio import creates an attachment. + * + * @since x.x.x + */ + public function test_import_creates_attachment(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $post_id = self::factory()->post->create(); + + $result = $this->invoke( + $this->import_ability, + 'execute_callback', + array( + 'data' => base64_encode( 'FAKEAUDIOBYTES' ), + 'mime_type' => 'audio/mpeg', + 'title' => 'Test audio', + 'post_id' => $post_id, + 'ai_generated' => true, + ) + ); + + $this->assertIsArray( $result ); + $this->assertGreaterThan( 0, $result['audio']['id'] ); + + $attachment = get_post( $result['audio']['id'] ); + $this->assertSame( 'attachment', $attachment->post_type ); + $this->assertSame( $post_id, $attachment->post_parent ); + $this->assertSame( 'Test audio', $attachment->post_title ); + $this->assertSame( 1, (int) get_post_meta( $result['audio']['id'], 'wpai_generated', true ) ); + } + + /** + * Test that import rejects non-audio data. + * + * @since x.x.x + */ + public function test_import_rejects_non_audio(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + $result = $this->invoke( + $this->import_ability, + 'execute_callback', + array( + 'data' => base64_encode( 'NOTAUDIO' ), + 'mime_type' => 'image/png', + ) + ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'invalid_data', $result->get_error_code() ); + } + + /** + * Test that import permission is denied without upload_files. + * + * @since x.x.x + */ + public function test_import_permission_denied_for_subscriber(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + $result = $this->invoke( $this->import_ability, 'permission_callback', array( 'data' => 'x' ) ); + + $this->assertInstanceOf( WP_Error::class, $result ); + } +} diff --git a/tests/Integration/Includes/Experiments/Text_To_Speech/Audio_CombinerTest.php b/tests/Integration/Includes/Experiments/Text_To_Speech/Audio_CombinerTest.php new file mode 100644 index 000000000..57b550df5 --- /dev/null +++ b/tests/Integration/Includes/Experiments/Text_To_Speech/Audio_CombinerTest.php @@ -0,0 +1,142 @@ +> 21 ) & 0x7F ) . chr( ( $value >> 14 ) & 0x7F ) . chr( ( $value >> 7 ) & 0x7F ) . chr( $value & 0x7F ); + } + + /** + * Test that strip_id3v2 removes a leading ID3v2 tag. + * + * @since x.x.x + */ + public function test_strip_id3v2_removes_leading_tag(): void { + $payload = "\xFF\xFBAUDIOFRAMES"; + + $this->assertSame( $payload, Audio_Combiner::strip_id3v2( $this->with_id3v2( $payload ) ) ); + } + + /** + * Test that strip_id3v2 leaves untagged bytes alone. + * + * @since x.x.x + */ + public function test_strip_id3v2_ignores_untagged_bytes(): void { + $payload = "\xFF\xFBAUDIOFRAMES"; + + $this->assertSame( $payload, Audio_Combiner::strip_id3v2( $payload ) ); + } + + /** + * Test that strip_id3v1 removes a trailing 128-byte TAG block. + * + * @since x.x.x + */ + public function test_strip_id3v1_removes_trailing_tag(): void { + $payload = str_repeat( "\xFF\xFB", 100 ); + $tagged = $payload . 'TAG' . str_repeat( "\x00", 125 ); + + $this->assertSame( $payload, Audio_Combiner::strip_id3v1( $tagged ) ); + $this->assertSame( $payload, Audio_Combiner::strip_id3v1( $payload ) ); + } + + /** + * Test that prepare_chunk applies the position-dependent strip rules. + * + * @since x.x.x + */ + public function test_prepare_chunk_strips_by_position(): void { + $tagged = $this->with_id3v2( 'BODY' ) . 'TAG' . str_repeat( "\x00", 125 ); + + // First chunk: keeps its ID3v2 header, loses its ID3v1 trailer. + $this->assertSame( $this->with_id3v2( 'BODY' ), Audio_Combiner::prepare_chunk( $tagged, true, false ) ); + + // Middle chunk: loses both. + $this->assertSame( 'BODY', Audio_Combiner::prepare_chunk( $tagged, false, false ) ); + + // Last chunk: loses only the leading ID3v2 header. + $this->assertSame( 'BODY' . 'TAG' . str_repeat( "\x00", 125 ), Audio_Combiner::prepare_chunk( $tagged, false, true ) ); + } + + /** + * Test that append_chunk builds a combined file, stripping inner tags. + * + * @since x.x.x + */ + public function test_append_chunk_combines_stripped_chunks(): void { + $file = wp_tempnam( 'wpai-tts-test' ); + + $first = $this->with_id3v2( 'FIRST' ); + $middle = $this->with_id3v2( 'MIDDLE' ) . 'TAG' . str_repeat( "\x00", 125 ); + $last = $this->with_id3v2( 'LAST' ); + + $this->assertTrue( Audio_Combiner::append_chunk( $file, $first, true, false ) ); + $this->assertTrue( Audio_Combiner::append_chunk( $file, $middle, false, false ) ); + $this->assertTrue( Audio_Combiner::append_chunk( $file, $last, false, true ) ); + + // First chunk keeps its ID3v2 header (players read it); middle loses + // both blocks; last keeps only its tail. + $expected = $this->with_id3v2( 'FIRST' ) . 'MIDDLE' . 'LAST'; + + $this->assertSame( $expected, file_get_contents( $file ) ); + + wp_delete_file( $file ); + } + + /** + * Test that the first append truncates leftover file contents. + * + * @since x.x.x + */ + public function test_first_append_truncates_existing_file(): void { + $file = wp_tempnam( 'wpai-tts-test' ); + file_put_contents( $file, 'LEFTOVER' ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_file_put_contents + + $this->assertTrue( Audio_Combiner::append_chunk( $file, 'FRESH', true, true ) ); + $this->assertSame( 'FRESH', file_get_contents( $file ) ); + + wp_delete_file( $file ); + } +} diff --git a/tests/Integration/Includes/Experiments/Text_To_Speech/Content_ChunkerTest.php b/tests/Integration/Includes/Experiments/Text_To_Speech/Content_ChunkerTest.php new file mode 100644 index 000000000..da299ed0b --- /dev/null +++ b/tests/Integration/Includes/Experiments/Text_To_Speech/Content_ChunkerTest.php @@ -0,0 +1,98 @@ +assertSame( array( $content ), Content_Chunker::chunk( $content, 100 ) ); + } + + /** + * Test that empty content returns no chunks. + * + * @since x.x.x + */ + public function test_empty_content_returns_no_chunks(): void { + $this->assertSame( array(), Content_Chunker::chunk( '', 100 ) ); + $this->assertSame( array(), Content_Chunker::chunk( ' ', 100 ) ); + } + + /** + * Test that long content splits on sentence boundaries under the limit. + * + * @since x.x.x + */ + public function test_long_content_splits_on_sentence_boundaries(): void { + $sentence = 'The quick brown fox jumps over the lazy dog.'; + $content = trim( str_repeat( $sentence . ' ', 10 ) ); + + $chunks = Content_Chunker::chunk( $content, 100 ); + + $this->assertGreaterThan( 1, count( $chunks ) ); + + foreach ( $chunks as $chunk ) { + $this->assertLessThanOrEqual( 100, mb_strlen( $chunk ) ); + // Each chunk should end at a sentence boundary. + $this->assertMatchesRegularExpression( '/[.!?]$/', $chunk ); + } + + // No content lost: rejoining chunks reproduces the original words. + $this->assertSame( + preg_replace( '/\s+/', ' ', $content ), + preg_replace( '/\s+/', ' ', implode( ' ', $chunks ) ) + ); + } + + /** + * Test that a single over-long sentence is hard-split at the limit. + * + * @since x.x.x + */ + public function test_overlong_sentence_is_hard_split(): void { + $content = str_repeat( 'a', 250 ); + + $chunks = Content_Chunker::chunk( $content, 100 ); + + $this->assertSame( array( str_repeat( 'a', 100 ), str_repeat( 'a', 100 ), str_repeat( 'a', 50 ) ), $chunks ); + } + + /** + * Test that multibyte content is split without corrupting characters. + * + * @since x.x.x + */ + public function test_multibyte_content_is_not_corrupted(): void { + $content = str_repeat( 'こんにちは世界', 30 ); // 210 chars, no sentence punctuation. + + $chunks = Content_Chunker::chunk( $content, 100 ); + + $this->assertSame( $content, implode( '', $chunks ) ); + + foreach ( $chunks as $chunk ) { + $this->assertLessThanOrEqual( 100, mb_strlen( $chunk ) ); + // Valid UTF-8 (no split mid-character). + $this->assertTrue( (bool) preg_match( '//u', $chunk ) ); + } + } +} diff --git a/tests/Integration/Includes/Experiments/Text_To_Speech/Job_ManagerTest.php b/tests/Integration/Includes/Experiments/Text_To_Speech/Job_ManagerTest.php new file mode 100644 index 000000000..a78897919 --- /dev/null +++ b/tests/Integration/Includes/Experiments/Text_To_Speech/Job_ManagerTest.php @@ -0,0 +1,297 @@ +job_manager = new Job_Manager(); + + // Fake audio bytes so no AI provider is required. Each chunk becomes + // recognizable bytes so the combined file can be asserted against. + add_filter( + 'wpai_tts_pre_generate_chunk', + static function ( $pre, $text ) { + return array( + 'data' => base64_encode( '[' . $text . ']' ), + 'mime_type' => 'audio/mpeg', + ); + }, + 10, + 2 + ); + + // The fake bytes are not real MP3 data, so bypass WordPress's + // content-based file type sniffing during sideload. + add_filter( + 'wp_check_filetype_and_ext', + static function () { + return array( + 'ext' => 'mp3', + 'type' => 'audio/mpeg', + 'proper_filename' => false, + ); + } + ); + } + + /** + * Creates a test post with enough content for a TTS job. + * + * @since x.x.x + * + * @param string $content The post content. + * @return int The post ID. + */ + private function create_post( string $content = 'First sentence here. Second sentence here. Third sentence here.' ): int { + return self::factory()->post->create( array( 'post_content' => $content ) ); + } + + /** + * Runs all scheduled chunk events for a post, simulating WP-Cron. + * + * @since x.x.x + * + * @param int $post_id The post ID. + */ + private function run_all_chunk_events( int $post_id ): void { + $guard = 0; + + while ( wp_next_scheduled( Job_Manager::CRON_HOOK, array( $post_id ) ) && $guard < 50 ) { + wp_unschedule_event( (int) wp_next_scheduled( Job_Manager::CRON_HOOK, array( $post_id ) ), Job_Manager::CRON_HOOK, array( $post_id ) ); + $this->job_manager->process_chunk( $post_id ); + $guard++; + } + } + + /** + * Test that start_job() records a pending job and schedules a cron event. + * + * @since x.x.x + */ + public function test_start_job_schedules_cron_event(): void { + $post_id = $this->create_post(); + + $result = $this->job_manager->start_job( $post_id, get_current_user_id() ); + + $this->assertIsArray( $result ); + $this->assertSame( 'pending', $result['status'] ); + $this->assertNotFalse( wp_next_scheduled( Job_Manager::CRON_HOOK, array( $post_id ) ) ); + $this->assertIsArray( get_post_meta( $post_id, Job_Manager::META_JOB, true ) ); + } + + /** + * Test that start_job() rejects posts with no content. + * + * @since x.x.x + */ + public function test_start_job_requires_content(): void { + $post_id = $this->create_post( '' ); + + $result = $this->job_manager->start_job( $post_id, get_current_user_id() ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'no_content', $result->get_error_code() ); + } + + /** + * Test that start_job() rejects a second start while a job is running. + * + * @since x.x.x + */ + public function test_start_job_blocks_duplicate_jobs(): void { + $post_id = $this->create_post(); + + $this->job_manager->start_job( $post_id, get_current_user_id() ); + $result = $this->job_manager->start_job( $post_id, get_current_user_id() ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'job_in_progress', $result->get_error_code() ); + } + + /** + * Test the full multi-chunk lifecycle: chunks generated, combined, and + * imported as a single attachment. + * + * @since x.x.x + */ + public function test_full_job_lifecycle_creates_attachment(): void { + // Force multiple chunks with a small limit. + add_filter( + 'wpai_tts_max_chunk_length', + static function () { + return 30; + } + ); + + $post_id = $this->create_post(); + + $this->job_manager->start_job( $post_id, get_current_user_id() ); + + $job = get_post_meta( $post_id, Job_Manager::META_JOB, true ); + $this->assertGreaterThan( 1, (int) $job['total'] ); + + $this->run_all_chunk_events( $post_id ); + + $status = $this->job_manager->get_status( $post_id ); + + $this->assertSame( 'complete', $status['status'] ); + $this->assertGreaterThan( 0, $status['audio_id'] ); + $this->assertNotEmpty( $status['audio_url'] ); + + $attachment = get_post( $status['audio_id'] ); + $this->assertNotNull( $attachment ); + $this->assertSame( 'attachment', $attachment->post_type ); + $this->assertSame( $post_id, $attachment->post_parent ); + $this->assertSame( 1, (int) get_post_meta( $status['audio_id'], 'wpai_generated', true ) ); + + // Combined file holds every chunk's fake bytes, in order. + $file = get_attached_file( $status['audio_id'] ); + $contents = file_get_contents( $file ); + $expected = implode( + '', + array_map( + static function ( string $chunk ): string { + return '[' . $chunk . ']'; + }, + $job['chunks'] + ) + ); + $this->assertSame( $expected, $contents ); + + // Job state is cleaned up. + $this->assertSame( '', get_post_meta( $post_id, Job_Manager::META_JOB, true ) ); + } + + /** + * Test that regeneration deletes the previous audio attachment only after + * the new one exists. + * + * @since x.x.x + */ + public function test_regeneration_deletes_old_attachment(): void { + $post_id = $this->create_post(); + + $this->job_manager->start_job( $post_id, get_current_user_id() ); + $this->run_all_chunk_events( $post_id ); + $first_id = $this->job_manager->get_status( $post_id )['audio_id']; + + $this->job_manager->start_job( $post_id, get_current_user_id() ); + $this->run_all_chunk_events( $post_id ); + $second_id = $this->job_manager->get_status( $post_id )['audio_id']; + + $this->assertNotSame( $first_id, $second_id ); + $this->assertNull( get_post( $first_id ) ); + $this->assertNotNull( get_post( $second_id ) ); + } + + /** + * Test that a generation failure marks the job errored and preserves any + * previously generated audio. + * + * @since x.x.x + */ + public function test_failed_generation_marks_job_errored(): void { + $post_id = $this->create_post(); + + // Successful first generation. + $this->job_manager->start_job( $post_id, get_current_user_id() ); + $this->run_all_chunk_events( $post_id ); + $first_id = $this->job_manager->get_status( $post_id )['audio_id']; + + // Second generation fails. + add_filter( + 'wpai_tts_pre_generate_chunk', + static function () { + return new WP_Error( 'tts_failed', 'Provider exploded.' ); + }, + 20 + ); + + $this->job_manager->start_job( $post_id, get_current_user_id() ); + $this->run_all_chunk_events( $post_id ); + + $status = $this->job_manager->get_status( $post_id ); + + $this->assertSame( 'error', $status['status'] ); + $this->assertSame( 'Provider exploded.', $status['error'] ); + // Old audio untouched. + $this->assertSame( $first_id, $status['audio_id'] ); + $this->assertNotNull( get_post( $first_id ) ); + // Job blob cleaned up. + $this->assertSame( '', get_post_meta( $post_id, Job_Manager::META_JOB, true ) ); + } + + /** + * Test that delete_audio() removes the attachment and all TTS meta. + * + * @since x.x.x + */ + public function test_delete_audio_removes_attachment_and_meta(): void { + $post_id = $this->create_post(); + + $this->job_manager->start_job( $post_id, get_current_user_id() ); + $this->run_all_chunk_events( $post_id ); + + $audio_id = $this->job_manager->get_status( $post_id )['audio_id']; + $this->assertGreaterThan( 0, $audio_id ); + $this->assertNotNull( get_post( $audio_id ) ); + + update_post_meta( $post_id, Job_Manager::META_DISPLAY, true ); + + $status = $this->job_manager->delete_audio( $post_id ); + + // The attachment is gone. + $this->assertNull( get_post( $audio_id ) ); + + // The returned payload reflects a clean slate. + $this->assertSame( 'idle', $status['status'] ); + $this->assertSame( 0, $status['audio_id'] ); + $this->assertSame( '', $status['audio_url'] ); + + // Every piece of TTS meta is removed. + foreach ( + array( + Job_Manager::META_AUDIO_ID, + Job_Manager::META_STATUS, + Job_Manager::META_ERROR, + Job_Manager::META_UPDATED, + Job_Manager::META_JOB, + Job_Manager::META_DISPLAY, + ) as $meta_key + ) { + $this->assertSame( '', get_post_meta( $post_id, $meta_key, true ) ); + } + + // No chunk event is left scheduled. + $this->assertFalse( wp_next_scheduled( Job_Manager::CRON_HOOK, array( $post_id ) ) ); + } +} diff --git a/tests/Integration/Includes/Experiments/Text_To_Speech/REST_ControllerTest.php b/tests/Integration/Includes/Experiments/Text_To_Speech/REST_ControllerTest.php new file mode 100644 index 000000000..1c84340df --- /dev/null +++ b/tests/Integration/Includes/Experiments/Text_To_Speech/REST_ControllerTest.php @@ -0,0 +1,224 @@ +register_routes(); + } + ); + + do_action( 'rest_api_init', $wp_rest_server ); + + add_filter( 'wpai_has_text_to_speech_support', '__return_true' ); + add_filter( + 'wpai_tts_pre_generate_chunk', + static function ( $pre, $text ) { + return array( 'data' => base64_encode( '[' . $text . ']' ) ); + }, + 10, + 2 + ); + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + global $wp_rest_server; + $wp_rest_server = null; + + wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Test that POST starts a background job. + * + * @since x.x.x + */ + public function test_post_starts_job(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $post_id = self::factory()->post->create( array( 'post_content' => 'Some content to read.' ) ); + + $request = new WP_REST_Request( 'POST', '/ai/v1/text-to-speech/' . $post_id ); + $response = rest_do_request( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'pending', $response->get_data()['status'] ); + $this->assertNotFalse( wp_next_scheduled( Job_Manager::CRON_HOOK, array( $post_id ) ) ); + } + + /** + * Test that GET returns idle status for a fresh post. + * + * @since x.x.x + */ + public function test_get_returns_idle_status(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $post_id = self::factory()->post->create(); + + $request = new WP_REST_Request( 'GET', '/ai/v1/text-to-speech/' . $post_id ); + $response = rest_do_request( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'idle', $response->get_data()['status'] ); + $this->assertSame( 0, $response->get_data()['audio_id'] ); + } + + /** + * Test that a subscriber cannot trigger generation. + * + * @since x.x.x + */ + public function test_post_denied_for_subscriber(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + $post_id = self::factory()->post->create(); + + $request = new WP_REST_Request( 'POST', '/ai/v1/text-to-speech/' . $post_id ); + $response = rest_do_request( $request ); + + $this->assertSame( 403, $response->get_status() ); + } + + /** + * Test that POST errors when no provider supports text to speech. + * + * @since x.x.x + */ + public function test_post_errors_without_tts_support(): void { + remove_all_filters( 'wpai_has_text_to_speech_support' ); + add_filter( 'wpai_has_text_to_speech_support', '__return_false' ); + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $post_id = self::factory()->post->create( array( 'post_content' => 'Some content.' ) ); + + $request = new WP_REST_Request( 'POST', '/ai/v1/text-to-speech/' . $post_id ); + $response = rest_do_request( $request ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'unsupported', $response->get_data()['code'] ); + } + + /** + * Test that DELETE removes generated audio and its meta. + * + * @since x.x.x + */ + public function test_delete_removes_audio(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $post_id = self::factory()->post->create( array( 'post_content' => 'Some content to read.' ) ); + $attachment_id = self::factory()->attachment->create_object( + array( + 'file' => 'post-audio-' . $post_id . '.mp3', + 'post_parent' => $post_id, + 'post_mime_type' => 'audio/mpeg', + ) + ); + update_post_meta( $post_id, Job_Manager::META_AUDIO_ID, $attachment_id ); + update_post_meta( $post_id, Job_Manager::META_STATUS, 'complete' ); + + $request = new WP_REST_Request( 'DELETE', '/ai/v1/text-to-speech/' . $post_id ); + $response = rest_do_request( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'idle', $response->get_data()['status'] ); + $this->assertSame( 0, $response->get_data()['audio_id'] ); + $this->assertNull( get_post( $attachment_id ) ); + $this->assertSame( '', get_post_meta( $post_id, Job_Manager::META_AUDIO_ID, true ) ); + } + + /** + * Test that a subscriber cannot delete audio. + * + * @since x.x.x + */ + public function test_delete_denied_for_subscriber(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + $post_id = self::factory()->post->create(); + + $request = new WP_REST_Request( 'DELETE', '/ai/v1/text-to-speech/' . $post_id ); + $response = rest_do_request( $request ); + + $this->assertSame( 403, $response->get_status() ); + } + + /** + * Test that a user who can edit the post but cannot delete the audio + * attachment (owned by another user) is denied. + * + * @since x.x.x + */ + public function test_delete_denied_without_attachment_delete_cap(): void { + $author_id = self::factory()->user->create( array( 'role' => 'author' ) ); + $other_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + + $post_id = self::factory()->post->create( array( 'post_author' => $author_id ) ); + $attachment_id = self::factory()->attachment->create_object( + array( + 'file' => 'post-audio-' . $post_id . '.mp3', + 'post_parent' => $post_id, + 'post_author' => $other_id, + 'post_mime_type' => 'audio/mpeg', + ) + ); + update_post_meta( $post_id, Job_Manager::META_AUDIO_ID, $attachment_id ); + + // The author can edit their own post but not delete another user's + // attachment (authors lack delete_others_posts). + wp_set_current_user( $author_id ); + + $request = new WP_REST_Request( 'DELETE', '/ai/v1/text-to-speech/' . $post_id ); + $response = rest_do_request( $request ); + + $this->assertSame( 403, $response->get_status() ); + // The attachment must survive a denied request. + $this->assertNotNull( get_post( $attachment_id ) ); + } + + /** + * Test that a missing post returns 404. + * + * @since x.x.x + */ + public function test_missing_post_returns_404(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + $request = new WP_REST_Request( 'GET', '/ai/v1/text-to-speech/999999' ); + $response = rest_do_request( $request ); + + $this->assertSame( 404, $response->get_status() ); + } +} diff --git a/tests/Integration/Includes/Experiments/Text_To_Speech/Speech_GeneratorTest.php b/tests/Integration/Includes/Experiments/Text_To_Speech/Speech_GeneratorTest.php new file mode 100644 index 000000000..4eb02819f --- /dev/null +++ b/tests/Integration/Includes/Experiments/Text_To_Speech/Speech_GeneratorTest.php @@ -0,0 +1,62 @@ + base64_encode( $text . '|' . $voice ) ); + }, + 10, + 3 + ); + + $result = ( new Speech_Generator() )->generate_chunk( 'Hello.', 'nova' ); + + $this->assertIsArray( $result ); + $this->assertSame( base64_encode( 'Hello.|nova' ), $result['data'] ); + $this->assertSame( 'audio/mpeg', $result['mime_type'] ); + $this->assertSame( array(), $result['provider_metadata'] ); + $this->assertSame( array(), $result['model_metadata'] ); + } + + /** + * Test that a WP_Error from the filter is returned as-is. + * + * @since x.x.x + */ + public function test_pre_generate_filter_error_passthrough(): void { + $error = new WP_Error( 'tts_failed', 'Nope.' ); + + add_filter( + 'wpai_tts_pre_generate_chunk', + static function () use ( $error ) { + return $error; + } + ); + + $this->assertSame( $error, ( new Speech_Generator() )->generate_chunk( 'Hello.' ) ); + } +} diff --git a/tests/Integration/Includes/Experiments/Text_To_Speech/Text_To_SpeechTest.php b/tests/Integration/Includes/Experiments/Text_To_Speech/Text_To_SpeechTest.php new file mode 100644 index 000000000..5b9dcd7e6 --- /dev/null +++ b/tests/Integration/Includes/Experiments/Text_To_Speech/Text_To_SpeechTest.php @@ -0,0 +1,240 @@ +experiment = new Text_To_Speech(); + } + + /** + * Test the experiment ID and metadata. + * + * @since x.x.x + */ + public function test_experiment_metadata(): void { + $this->assertSame( 'text-to-speech', Text_To_Speech::get_id() ); + $this->assertSame( 'experimental', $this->experiment->get_stability() ); + $this->assertSame( 'text_to_speech_conversion', $this->experiment->get_capability() ); + $this->assertNotEmpty( $this->experiment->get_label() ); + $this->assertNotEmpty( $this->experiment->get_description() ); + } + + /** + * Test that the experiment is registered in the default experiment list. + * + * @since x.x.x + */ + public function test_experiment_is_registered_as_default(): void { + $classes = apply_filters( 'wpai_default_feature_classes', array() ); + + $this->assertArrayHasKey( 'text-to-speech', $classes ); + $this->assertSame( Text_To_Speech::class, $classes['text-to-speech'] ); + } + + /** + * Test that register() registers the post meta keys. + * + * @since x.x.x + */ + public function test_register_registers_post_meta(): void { + $this->experiment->register(); + + $this->assertTrue( registered_meta_key_exists( 'post', Job_Manager::META_DISPLAY ) ); + $this->assertTrue( registered_meta_key_exists( 'post', Job_Manager::META_AUDIO_ID ) ); + $this->assertTrue( registered_meta_key_exists( 'post', Job_Manager::META_STATUS ) ); + $this->assertTrue( registered_meta_key_exists( 'post', Job_Manager::META_ERROR ) ); + $this->assertTrue( registered_meta_key_exists( 'post', Job_Manager::META_UPDATED ) ); + } + + /** + * Test that the display toggle defaults to true. + * + * @since x.x.x + */ + public function test_display_meta_defaults_to_true(): void { + $this->experiment->register(); + + $post_id = self::factory()->post->create(); + + $this->assertTrue( (bool) get_post_meta( $post_id, Job_Manager::META_DISPLAY, true ) ); + } + + /** + * Test that register() wires the cron hook, REST routes, abilities, and + * the content filter. + * + * @since x.x.x + */ + public function test_register_wires_hooks(): void { + $this->experiment->register(); + + $this->assertNotFalse( has_action( Job_Manager::CRON_HOOK, array( $this->experiment, 'process_chunk' ) ) ); + $this->assertNotFalse( has_filter( 'the_content', array( $this->experiment, 'render_audio_player' ) ) ); + $this->assertNotFalse( has_action( 'wp_abilities_api_init', array( $this->experiment, 'register_abilities' ) ) ); + $this->assertNotFalse( has_action( 'rest_api_init', array( $this->experiment, 'register_rest_routes' ) ) ); + } + + /** + * Test that the voice settings field is exposed. + * + * @since x.x.x + */ + public function test_settings_fields_include_voice(): void { + $fields = $this->experiment->get_settings_fields(); + + $this->assertCount( 1, $fields ); + $this->assertSame( 'voice', $fields[0]['id'] ); + $this->assertSame( 'text', $fields[0]['type'] ); + } + + /** + * Creates a post with a fake audio attachment and TTS meta. + * + * @since x.x.x + * + * @return array{0: int, 1: int} The post ID and attachment ID. + */ + private function create_post_with_audio(): array { + $post_id = self::factory()->post->create( array( 'post_content' => 'Hello world content.' ) ); + + $attachment_id = self::factory()->attachment->create_object( + array( + 'file' => 'post-audio-' . $post_id . '.mp3', + 'post_parent' => $post_id, + 'post_mime_type' => 'audio/mpeg', + ) + ); + + update_post_meta( $post_id, Job_Manager::META_AUDIO_ID, $attachment_id ); + + return array( $post_id, $attachment_id ); + } + + /** + * Simulates the main loop on the singular view of the given post. + * + * @since x.x.x + * + * @param int $post_id The post ID. + */ + private function enter_singular_loop( int $post_id ): void { + $this->go_to( get_permalink( $post_id ) ); + + global $wp_query; + $wp_query->the_post(); + } + + /** + * Test that the player is prepended on the singular view when enabled. + * + * @since x.x.x + */ + public function test_player_renders_on_singular_view(): void { + list( $post_id ) = $this->create_post_with_audio(); + + $this->experiment->register(); + $this->enter_singular_loop( $post_id ); + + $output = $this->experiment->render_audio_player( 'CONTENT' ); + + $this->assertStringContainsString( 'wpai-tts-player', $output ); + $this->assertStringContainsString( '