From 178b184e8fa838ad91f15224adbc3fb0d1d4079e Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 16 Jul 2026 15:04:45 -0600 Subject: [PATCH 01/20] Add a few new helper methods for TTS --- includes/helpers.php | 96 ++++++++++++++++++++++ tests/Integration/Includes/HelpersTest.php | 52 ++++++++++++ 2 files changed, 148 insertions(+) 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/tests/Integration/Includes/HelpersTest.php b/tests/Integration/Includes/HelpersTest.php index 153d233fd..ecf13ace9 100644 --- a/tests/Integration/Includes/HelpersTest.php +++ b/tests/Integration/Includes/HelpersTest.php @@ -22,6 +22,8 @@ use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AI\Experiments\Summarization\Summarization; +use function WordPress\AI\get_preferred_speech_models; +use function WordPress\AI\has_text_to_speech_support; use function WordPress\AI\post_type_supports_bulk_action; /** @@ -1885,4 +1887,54 @@ public function test_post_type_supports_bulk_ai_summarization_returns_false_for_ public function test_post_type_supports_bulk_ai_summarization_returns_false_for_unknown_post_type(): void { $this->assertFalse( post_type_supports_bulk_action( 'does_not_exist', Summarization::get_id() ) ); } + + /** + * Test that get_preferred_speech_models() returns provider/model tuples. + * + * @since x.x.x + */ + public function test_get_preferred_speech_models_returns_tuples(): void { + $models = get_preferred_speech_models(); + + $this->assertNotEmpty( $models ); + + foreach ( $models as $model ) { + $this->assertIsArray( $model ); + $this->assertCount( 2, $model ); + $this->assertIsString( $model[0] ); + $this->assertIsString( $model[1] ); + } + } + + /** + * Test that the wpai_preferred_speech_models filter overrides the list. + * + * @since x.x.x + */ + public function test_get_preferred_speech_models_is_filterable(): void { + $override = array( array( 'acme', 'acme-tts-1' ) ); + + add_filter( + 'wpai_preferred_speech_models', + static function () use ( $override ) { + return $override; + } + ); + + $this->assertSame( $override, get_preferred_speech_models() ); + } + + /** + * Test that the wpai_has_text_to_speech_support filter can force support on. + * + * @since x.x.x + */ + public function test_has_text_to_speech_support_is_filterable(): void { + add_filter( 'wpai_has_text_to_speech_support', '__return_true' ); + $this->assertTrue( has_text_to_speech_support( true ) ); + + remove_filter( 'wpai_has_text_to_speech_support', '__return_true' ); + add_filter( 'wpai_has_text_to_speech_support', '__return_false' ); + $this->assertFalse( has_text_to_speech_support( true ) ); + } } From 292149eaa5cc3ddb59566a4b4b598404ab649518 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 16 Jul 2026 15:10:59 -0600 Subject: [PATCH 02/20] Add a content chunker class used to chunk content down before we do audio processing so we stay under API limits --- .../Text_To_Speech/Content_Chunker.php | 102 ++++++++++++++++++ .../Text_To_Speech/Content_ChunkerTest.php | 98 +++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 includes/Experiments/Text_To_Speech/Content_Chunker.php create mode 100644 tests/Integration/Includes/Experiments/Text_To_Speech/Content_ChunkerTest.php 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/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 ) ); + } + } +} From df7a1f2fa76360a74720325c88e6e870153c6ac5 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 16 Jul 2026 15:14:40 -0600 Subject: [PATCH 03/20] Add an audio combiner class that takes in multiple audio files and combines those into a single file --- .../Text_To_Speech/Audio_Combiner.php | 136 +++++++++++++++++ .../Text_To_Speech/Audio_CombinerTest.php | 142 ++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 includes/Experiments/Text_To_Speech/Audio_Combiner.php create mode 100644 tests/Integration/Includes/Experiments/Text_To_Speech/Audio_CombinerTest.php 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/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 ); + } +} From 73e466db0ffbb13eb5f5e21312a64c22c3d6f0e6 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 16 Jul 2026 15:21:41 -0600 Subject: [PATCH 04/20] Add a speech generator class that takes in some content and sends that to the AI Client to generate speech. Set up to be used for both Ability calls and background jobs --- .../Text_To_Speech/Speech_Generator.php | 151 ++++++++++++++++++ .../Text_To_Speech/Speech_GeneratorTest.php | 62 +++++++ 2 files changed, 213 insertions(+) create mode 100644 includes/Experiments/Text_To_Speech/Speech_Generator.php create mode 100644 tests/Integration/Includes/Experiments/Text_To_Speech/Speech_GeneratorTest.php 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..61f1fa2f2 --- /dev/null +++ b/includes/Experiments/Text_To_Speech/Speech_Generator.php @@ -0,0 +1,151 @@ +, 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() ) + ->as_output_mime_type( 'audio/mpeg' ); + + 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/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.' ) ); + } +} From 3523125b8d5dd466f8f66d80f22b1f9b11a639b2 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 16 Jul 2026 15:27:14 -0600 Subject: [PATCH 05/20] Add a job manager class that is responsible for the bulk of the work. Fires individual jobs, via cron, that will chunk content down and turn those into audio files, combining all files at the end --- .../Text_To_Speech/Job_Manager.php | 467 ++++++++++++++++++ .../Text_To_Speech/Job_ManagerTest.php | 252 ++++++++++ 2 files changed, 719 insertions(+) create mode 100644 includes/Experiments/Text_To_Speech/Job_Manager.php create mode 100644 tests/Integration/Includes/Experiments/Text_To_Speech/Job_ManagerTest.php 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..caf431140 --- /dev/null +++ b/includes/Experiments/Text_To_Speech/Job_Manager.php @@ -0,0 +1,467 @@ +|\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 + $content = normalize_content( (string) apply_filters( 'the_content', $post->post_content ) ); + + if ( '' === $content ) { + return new WP_Error( + 'no_content', + esc_html__( 'This post has no content to generate audio from.', 'ai' ) + ); + } + + /** + * 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 ), + ); + } + + /** + * 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( + 'ai_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/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..c939ac8e7 --- /dev/null +++ b/tests/Integration/Includes/Experiments/Text_To_Speech/Job_ManagerTest.php @@ -0,0 +1,252 @@ +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'], 'ai_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 ) ); + } +} From b34c528f0f2559d46560f4dbb5d0d77f9ec0fe35 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 16 Jul 2026 15:36:08 -0600 Subject: [PATCH 06/20] Register the TTS experiment and all needed hooks --- includes/Experiments/Experiments.php | 1 + .../Text_To_Speech/Text_To_Speech.php | 290 ++++++++++++++++++ .../Text_To_Speech/Text_To_SpeechTest.php | 119 +++++++ 3 files changed, 410 insertions(+) create mode 100644 includes/Experiments/Text_To_Speech/Text_To_Speech.php create mode 100644 tests/Integration/Includes/Experiments/Text_To_Speech/Text_To_SpeechTest.php 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/Text_To_Speech.php b/includes/Experiments/Text_To_Speech/Text_To_Speech.php new file mode 100644 index 000000000..b7f6ea335 --- /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' => 'speech_generation', + ); + } + + /** + * {@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/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..f7c342e16 --- /dev/null +++ b/tests/Integration/Includes/Experiments/Text_To_Speech/Text_To_SpeechTest.php @@ -0,0 +1,119 @@ +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'] ); + } +} From b38eddfb3a4c0d18e7a0d806443965ca549d6b39 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 16 Jul 2026 15:37:19 -0600 Subject: [PATCH 07/20] Add a TTS REST controller to handle starting jobs and checking on the status of a job --- .../Text_To_Speech/REST_Controller.php | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 includes/Experiments/Text_To_Speech/REST_Controller.php 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..42cce301e --- /dev/null +++ b/includes/Experiments/Text_To_Speech/REST_Controller.php @@ -0,0 +1,217 @@ +\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(), + ), + ) + ); + } + + /** + * 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 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'] ) ) ); + } +} From ad66bc0dcd41436ef1e4cfa7eeb0e568199f56f6 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 16 Jul 2026 15:42:05 -0600 Subject: [PATCH 08/20] Add a generate-speech ability that can be used to generate speech for a string of text or post content from a specific post ID --- includes/Abilities/Speech/Generate_Speech.php | 256 ++++++++++++++++++ includes/Contracts/Feature.php | 2 +- 2 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 includes/Abilities/Speech/Generate_Speech.php 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/Contracts/Feature.php b/includes/Contracts/Feature.php index 5fe362067..041f56bae 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', 'speech_generation'). */ public function get_capability(): string; } From 73977b776c1bee38f6a82b73ca2e7241544c3aaa Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 16 Jul 2026 15:42:35 -0600 Subject: [PATCH 09/20] Add a import-base64-audio ability that takes in base64 encoded data and imports it into the media library as an MP3 file --- .../Abilities/Speech/Import_Base64_Audio.php | 321 ++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 includes/Abilities/Speech/Import_Base64_Audio.php 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, + ); + } +} From a3587728cc851d69a0bba853d39d9403fdac3adc Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 16 Jul 2026 15:51:33 -0600 Subject: [PATCH 10/20] Add additional test coverage --- .../Text_To_Speech/Job_Manager.php | 2 +- .../Includes/Abilities/SpeechTest.php | 263 ++++++++++++++++++ .../Text_To_Speech/Job_ManagerTest.php | 2 +- .../Text_To_Speech/Text_To_SpeechTest.php | 2 +- 4 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 tests/Integration/Includes/Abilities/SpeechTest.php diff --git a/includes/Experiments/Text_To_Speech/Job_Manager.php b/includes/Experiments/Text_To_Speech/Job_Manager.php index caf431140..3d8663096 100644 --- a/includes/Experiments/Text_To_Speech/Job_Manager.php +++ b/includes/Experiments/Text_To_Speech/Job_Manager.php @@ -371,7 +371,7 @@ protected function finalize_job( int $post_id, array $job ): void { 'post_mime_type' => $mime_type, 'post_author' => (int) $job['user_id'], 'meta_input' => array( - 'ai_generated' => 1, + 'wpai_generated' => 1, ), ); 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/Job_ManagerTest.php b/tests/Integration/Includes/Experiments/Text_To_Speech/Job_ManagerTest.php index c939ac8e7..41c073953 100644 --- a/tests/Integration/Includes/Experiments/Text_To_Speech/Job_ManagerTest.php +++ b/tests/Integration/Includes/Experiments/Text_To_Speech/Job_ManagerTest.php @@ -171,7 +171,7 @@ static function () { $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'], 'ai_generated', true ) ); + $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'] ); 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 index f7c342e16..27be863b8 100644 --- a/tests/Integration/Includes/Experiments/Text_To_Speech/Text_To_SpeechTest.php +++ b/tests/Integration/Includes/Experiments/Text_To_Speech/Text_To_SpeechTest.php @@ -44,7 +44,7 @@ public function setUp(): void { 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->assertSame( 'speech_generation', $this->experiment->get_capability() ); $this->assertNotEmpty( $this->experiment->get_label() ); $this->assertNotEmpty( $this->experiment->get_description() ); } From 0c63fd4bbd3b72c798b8b385cfdfdf40cbec68f8 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 16 Jul 2026 15:57:11 -0600 Subject: [PATCH 11/20] Add additional test coverage --- .../Text_To_Speech/REST_ControllerTest.php | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 tests/Integration/Includes/Experiments/Text_To_Speech/REST_ControllerTest.php 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..b0123358b --- /dev/null +++ b/tests/Integration/Includes/Experiments/Text_To_Speech/REST_ControllerTest.php @@ -0,0 +1,148 @@ +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 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() ); + } +} From cf878ebc2c0d7fce3b9ca4afce93b91b3db2036a Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 16 Jul 2026 16:28:41 -0600 Subject: [PATCH 12/20] Add the necessary client-side code to render both in the editor and the front-end --- .../components/TextToSpeechPanel.tsx | 126 ++++++++++++++++ .../components/useSpeechGeneration.ts | 140 ++++++++++++++++++ src/experiments/text-to-speech/index.scss | 29 ++++ src/experiments/text-to-speech/index.tsx | 45 ++++++ src/experiments/text-to-speech/types.ts | 18 +++ .../Text_To_Speech/Text_To_SpeechTest.php | 121 +++++++++++++++ webpack.config.js | 5 + 7 files changed, 484 insertions(+) create mode 100644 src/experiments/text-to-speech/components/TextToSpeechPanel.tsx create mode 100644 src/experiments/text-to-speech/components/useSpeechGeneration.ts create mode 100644 src/experiments/text-to-speech/index.scss create mode 100644 src/experiments/text-to-speech/index.tsx create mode 100644 src/experiments/text-to-speech/types.ts 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..1ff50097c --- /dev/null +++ b/src/experiments/text-to-speech/components/TextToSpeechPanel.tsx @@ -0,0 +1,126 @@ +/** + * Text to Speech sidebar panel contents. + */ + +/** + * WordPress dependencies + */ +import { Button, Notice, ToggleControl } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; + +/** + * 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, + setDisplayAudio, + handleGenerate, + } = useSpeechGeneration(); + + return ( + <> + { ! hasTtsSupport && ( + + { __( + 'No connected AI provider supports text to speech.', + 'ai' + ) } + + ) } + + { isGenerating && status && ( +

+ { sprintf( + /* translators: 1: number of chunks processed, 2: total number of chunks */ + __( 'Generating audio… (%1$d of %2$d)', 'ai' ), + status.done, + status.total + ) } +

+ ) } + + { hasAudio && ! isGenerating && audioUrl && ( +