__( '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', 'usage' => array( 'trigger' => 'on_demand', 'runs' => __( 'When you click Generate Audio or Regenerate Audio in the editor, then in the background until the audio is done', 'ai' ), 'sends' => __( 'The post title and saved post content as plain text, plus the chosen voice', 'ai' ), 'requests' => __( 'About one request per 4,000 characters of title and content', 'ai' ), 'details' => __( 'Adds the audio to the Media Library and shows a player above the post for visitors', 'ai' ), ), ); } /** * {@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( 'wp_enqueue_scripts', array( $this, 'enqueue_frontend_assets' ) ); add_action( Job_Manager::CRON_HOOK, array( $this, 'process_chunk' ) ); add_filter( 'the_content', array( $this, 'render_audio_player' ) ); add_action( 'post_updated', array( $this, 'sync_audio_visibility' ), 10, 3 ); } /** * Registers the post meta used to track generated audio. * * @since 1.5.0 */ 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 1.5.0 */ 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, ), ); } /** * Registers the REST routes used by the editor's background flow. * * @since 1.5.0 */ public function register_rest_routes(): void { ( new REST_Controller() )->register_routes(); } /** * Processes the next audio chunk for a post. Cron callback. * * @since 1.5.0 * * @param int $post_id The post being converted. */ public function process_chunk( $post_id ): void { ( new Job_Manager() )->process_chunk( (int) $post_id ); } /** * Keeps a post's audio hidden from visitors while the post has a password. * * @since 1.5.0 * * @param int $post_id The post ID. * @param \WP_Post $post_after The post after the update. * @param \WP_Post $post_before The post before the update. */ public function sync_audio_visibility( $post_id, $post_after, $post_before ): void { if ( ! $post_after instanceof \WP_Post || ! $post_before instanceof \WP_Post ) { return; } // Only a password being added or removed changes who may see the audio. if ( ( '' === $post_after->post_password ) === ( '' === $post_before->post_password ) ) { return; } ( new Job_Manager() )->sync_audio_visibility( (int) $post_id ); } /** * Enqueues and localizes needed assets. * * @since 1.5.0 */ 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::enqueue_style( '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 player stylesheet on singular front-end views. * * @since 1.5.0 */ public function enqueue_frontend_assets(): void { if ( ! is_singular() || ! $this->get_player_audio_id( get_queried_object_id() ) ) { return; } Asset_Loader::enqueue_style( 'text_to_speech', 'experiments/text-to-speech' ); } /** * Returns the audio attachment the front-end player should play for a post. * * @since 1.5.0 * * @param int $post_id The post ID. * @return int The audio attachment ID, or 0 when no player should render. */ private function get_player_audio_id( int $post_id ): int { if ( ! $post_id ) { return 0; } if ( post_password_required( $post_id ) ) { return 0; } // If a post isn't publicly viewable, only show it to users who can edit the post. if ( ! is_post_publicly_viewable( $post_id ) && ! current_user_can( 'edit_post', $post_id ) ) { return 0; } if ( ! get_post_meta( $post_id, Job_Manager::META_DISPLAY, true ) ) { return 0; } $audio_id = absint( get_post_meta( $post_id, Job_Manager::META_AUDIO_ID, true ) ); if ( ! $audio_id || ! wp_get_attachment_url( $audio_id ) ) { return 0; } return $audio_id; } /** * Prepends the audio player to post content on singular front-end views. * * @since 1.5.0 * * @param mixed $content The post content. * @return mixed The content, with the player prepended when applicable. */ public function render_audio_player( $content ) { if ( ! is_string( $content ) ) { return $content; } if ( is_admin() || ! is_singular() || ! in_the_loop() || ! is_main_query() ) { return $content; } $post_id = (int) get_the_ID(); // Ensure we're not in a Query Loop or other nested content. if ( get_queried_object_id() !== $post_id ) { return $content; } $audio_id = $this->get_player_audio_id( $post_id ); if ( ! $audio_id ) { return $content; } $audio_url = (string) wp_get_attachment_url( $audio_id ); $player = sprintf( '