From c66f20d4b9413af73526f80afd0e295ff276043f Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 23 Jun 2026 15:33:51 -0600 Subject: [PATCH 1/8] Add the back-end on-demand support for TTS --- includes/Classifai/Features/TextToSpeech.php | 289 ++++++++++++++++++- 1 file changed, 275 insertions(+), 14 deletions(-) diff --git a/includes/Classifai/Features/TextToSpeech.php b/includes/Classifai/Features/TextToSpeech.php index 6e5ca065b..d81912b26 100644 --- a/includes/Classifai/Features/TextToSpeech.php +++ b/includes/Classifai/Features/TextToSpeech.php @@ -105,6 +105,7 @@ public function feature_setup() { add_action( 'add_meta_boxes', array( $this, 'add_meta_box' ) ); add_action( 'admin_notices', array( $this, 'show_error_if' ) ); add_action( 'save_post', array( $this, 'save_post_metadata' ), 5 ); + add_action( 'save_post', array( $this, 'maybe_invalidate_on_demand_audio' ), 20 ); add_action( 'wp_ajax_classifai_get_tts_status', array( $this, 'ajax_get_audio_generation_status' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) ); } @@ -347,6 +348,215 @@ public function register_endpoints() { 'permission_callback' => array( $this, 'speech_synthesis_permissions_check' ), ) ); + + register_rest_route( + 'classifai/v1', + 'synthesize-speech-on-demand/(?P\d+)', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'synthesize_speech_on_demand' ), + 'args' => array( + 'id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'ID of the published post to generate audio for.', 'classifai' ), + ), + ), + 'permission_callback' => array( $this, 'on_demand_synthesis_permissions_check' ), + ) + ); + } + + /** + * Permission check for the public, front-end on-demand synthesis route. + * + * Unlike {@see speech_synthesis_permissions_check()}, this route is reachable + * by anonymous visitors, so it is gated on the feature being enabled and in + * on-demand mode, the target being a published supported post, and a valid + * nonce — rather than an `edit_post` capability check. + * + * Note: nonces for logged-out users are shared and long-lived, so on heavily + * page-cached sites they act as a CSRF / casual-bot deterrent rather than a + * hard gate. Because audio is generated at most once per post, the cost + * ceiling is one generation per published post. Site owners can tighten or + * loosen this via the `classifai_tts_on_demand_permission` filter. + * + * @param WP_REST_Request $request Full data about the request. + * @return bool|WP_Error + */ + public function on_demand_synthesis_permissions_check( WP_REST_Request $request ) { + $post_id = (int) $request->get_param( 'id' ); + $post = $post_id ? get_post( $post_id ) : null; + + $allowed = ( + $post instanceof \WP_Post && + 'publish' === $post->post_status && + in_array( $post->post_type, $this->get_supported_post_types(), true ) && + $this->is_enabled() && + 'on_demand' === $this->get_generation_timing() && + false !== wp_verify_nonce( (string) $request->get_param( 'nonce' ), 'classifai_synthesize_speech_on_demand' ) + ); + + /** + * Filter the permission check for on-demand front-end audio generation. + * + * Return true to allow, or false / a WP_Error to deny. Use this to, for + * example, restrict generation to logged-in users only. + * + * @since x.x.x + * @hook classifai_tts_on_demand_permission + * + * @param bool $allowed Whether the request is allowed. + * @param int $post_id The post ID audio is being generated for. + * @param WP_REST_Request $request The REST request. + * + * @return bool|WP_Error Whether the request is allowed. + */ + return apply_filters( 'classifai_tts_on_demand_permission', $allowed, $post_id, $request ); + } + + /** + * Handle a front-end on-demand audio generation request. + * + * Generates audio synchronously (under the post author's context so the + * Provider capability checks pass), stores it as an attachment for reuse, and + * returns the playable URL. A short-lived per-post lock collapses concurrent + * first-clicks into a single generation. + * + * @param WP_REST_Request $request Full data about the request. + * @return \WP_REST_Response + */ + public function synthesize_speech_on_demand( WP_REST_Request $request ) { + $post_id = (int) $request->get_param( 'id' ); + $post = get_post( $post_id ); + + // Audio may already exist (e.g. a concurrent request just finished, or it + // was generated in the admin) — serve it without regenerating. + $existing = $this->get_on_demand_audio_response( $post_id ); + if ( false !== $existing ) { + return rest_ensure_response( $existing ); + } + + $lock_key = 'classifai_tts_on_demand_lock_' . $post_id; + + // Collapse concurrent first-clicks: only one request generates at a time. + if ( get_transient( $lock_key ) ) { + return rest_ensure_response( + array( + 'success' => false, + 'inProgress' => true, + 'code' => 'generation_in_progress', + 'message' => esc_html__( 'Audio is already being generated. Please try again in a moment.', 'classifai' ), + ) + ); + } + + set_transient( $lock_key, 1, 5 * MINUTE_IN_SECONDS ); + + // Generate as the post author so the Provider's `edit_post` capability + // check passes for anonymous front-end visitors. + $this->generate_text_to_speech_audio( $post_id, $post ? (int) $post->post_author : null ); + + delete_transient( $lock_key ); + + $response = $this->get_on_demand_audio_response( $post_id ); + if ( false !== $response ) { + return rest_ensure_response( $response ); + } + + // Surface any error recorded during generation. + $message = esc_html__( 'Audio generation failed.', 'classifai' ); + $raw = get_post_meta( $post_id, '_classifai_text_to_speech_error', true ); + + if ( ! empty( $raw ) ) { + $decoded = (array) json_decode( (string) $raw ); + if ( ! empty( $decoded['message'] ) ) { + $message = (string) $decoded['message']; + } + } + + return rest_ensure_response( + array( + 'success' => false, + 'code' => 'generation_failed', + 'message' => $message, + ) + ); + } + + /** + * Build the success response for an existing generated audio file. + * + * @param int $post_id The post ID. + * @return array|false The response array, or false if no playable audio exists. + */ + protected function get_on_demand_audio_response( int $post_id ) { + $audio_id = (int) get_post_meta( $post_id, self::AUDIO_ID_KEY, true ); + + if ( ! $audio_id ) { + return false; + } + + $url = wp_get_attachment_url( $audio_id ); + + if ( ! $url ) { + return false; + } + + $timestamp = (int) get_post_meta( $post_id, self::AUDIO_TIMESTAMP_KEY, true ); + + if ( $timestamp ) { + $url = add_query_arg( 'ver', $timestamp, $url ); + } + + return array( + 'success' => true, + 'audio_id' => $audio_id, + 'url' => $url, + ); + } + + /** + * Clear stored audio when an on-demand post's content changes. + * + * In on-demand mode audio isn't regenerated on save, so without this an edit + * would leave stale audio in place forever. Deleting the stored audio makes + * the next front-end "listen" regenerate it. + * + * @param int $post_id The post ID being saved. + */ + public function maybe_invalidate_on_demand_audio( int $post_id ) { + if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || 'revision' === get_post_type( $post_id ) ) { + return; + } + + if ( + 'on_demand' !== $this->get_generation_timing() || + ! in_array( get_post_type( $post_id ), $this->get_supported_post_types(), true ) || + ! $this->is_enabled() + ) { + return; + } + + $audio_id = (int) get_post_meta( $post_id, self::AUDIO_ID_KEY, true ); + + if ( ! $audio_id ) { + return; + } + + $stored_hash = get_post_meta( $post_id, self::AUDIO_HASH_KEY, true ); + $current_hash = md5( $this->normalize_post_content( $post_id ) ); + + // Content unchanged — keep the existing audio. + if ( ! empty( $stored_hash ) && $stored_hash === $current_hash ) { + return; + } + + wp_delete_attachment( $audio_id, true ); + delete_post_meta( $post_id, self::AUDIO_ID_KEY ); + delete_post_meta( $post_id, self::AUDIO_TIMESTAMP_KEY ); + delete_post_meta( $post_id, self::AUDIO_HASH_KEY ); } /** @@ -797,21 +1007,19 @@ public function render_post_audio_controls( string $content ): string { return $content; } - $audio_attachment_id = (int) get_post_meta( $_post->ID, self::AUDIO_ID_KEY, true ); - - if ( ! $audio_attachment_id ) { - return $content; - } - - $audio_attachment_url = wp_get_attachment_url( $audio_attachment_id ); + $on_demand = 'on_demand' === $this->get_generation_timing(); + $audio_attachment_id = (int) get_post_meta( $_post->ID, self::AUDIO_ID_KEY, true ); + $audio_attachment_url = $audio_attachment_id ? wp_get_attachment_url( $audio_attachment_id ) : ''; - if ( ! $audio_attachment_url ) { + // When audio hasn't been generated yet, only render the player if we're + // generating on demand. Otherwise there's nothing to play. + if ( ! $audio_attachment_url && ! ( $on_demand && 'publish' === $_post->post_status ) ) { return $content; } $audio_timestamp = (int) get_post_meta( $_post->ID, self::AUDIO_TIMESTAMP_KEY, true ); - if ( $audio_timestamp ) { + if ( $audio_attachment_url && $audio_timestamp ) { $audio_attachment_url = add_query_arg( 'ver', filter_var( $audio_timestamp, FILTER_SANITIZE_NUMBER_INT ), $audio_attachment_url ); } @@ -859,14 +1067,32 @@ public function render_post_audio_controls( string $content ): string { 'all' ); + // Generate-on-demand only applies when no audio exists yet. + $generate_on_demand = $on_demand && ! $audio_attachment_url; + ob_start(); ?>
-
+
+ data-post-id="ID ); ?>" + data-rest-url="ID ) ); ?>" + data-nonce="" + data-generating-label="" + data-error-label="" + + > +
- +
array( + 'post_types' => array( 'post' => 'post', ), - 'provider' => Speech::ID, + 'generation_timing' => 'automatic', + 'provider' => Speech::ID, ); } + /** + * Returns the supported audio generation timing modes. + * + * - `automatic`: generate audio when a post is published or updated. + * - `manual`: only generate when explicitly triggered from the admin. + * - `on_demand`: generate the first time a visitor listens on the front-end. + * + * @return array + */ + public function get_generation_timing_options(): array { + return array( + 'automatic' => __( 'Automatic (on publish or update)', 'classifai' ), + 'manual' => __( 'Manual (generate from the admin)', 'classifai' ), + 'on_demand' => __( 'On demand (generate on first front-end listen)', 'classifai' ), + ); + } + + /** + * Returns the configured audio generation timing mode. + * + * Falls back to `automatic` for back-compat with installs saved before this + * setting existed. + * + * @return string One of `automatic`, `manual`, `on_demand`. + */ + public function get_generation_timing(): string { + $timing = $this->get_settings( 'generation_timing' ); + + return array_key_exists( $timing, $this->get_generation_timing_options() ) ? $timing : 'automatic'; + } + /** * Sanitizes the default feature settings. * @@ -981,6 +1239,9 @@ public function sanitize_default_feature_settings( array $new_settings ): array } } + $timing = $new_settings['generation_timing'] ?? 'automatic'; + $new_settings['generation_timing'] = array_key_exists( $timing, $this->get_generation_timing_options() ) ? $timing : 'automatic'; + return $new_settings; } @@ -1006,7 +1267,7 @@ public function get_audio_generation_initial_state( $post = null ): bool { * * @return bool Initial state the audio generation toggle should be set to when no audio exists. */ - return apply_filters( 'classifai_audio_generation_initial_state', true, get_post( $post ) ); + return apply_filters( 'classifai_audio_generation_initial_state', 'automatic' === $this->get_generation_timing(), get_post( $post ) ); } /** From c6bc73e31a3826f5a936c4aea0f796ef715e6463 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 23 Jun 2026 15:35:31 -0600 Subject: [PATCH 2/8] Add the client-side code to handle on-demand generation --- .../features/text-to-speech/frontend/index.js | 95 +++++++++++++++++-- .../text-to-speech/frontend/index.scss | 35 ++++++- 2 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/js/features/text-to-speech/frontend/index.js b/src/js/features/text-to-speech/frontend/index.js index cc9ea8e2b..3718df94c 100644 --- a/src/js/features/text-to-speech/frontend/index.js +++ b/src/js/features/text-to-speech/frontend/index.js @@ -4,14 +4,19 @@ import './index.scss'; const audioControlEl = document.querySelector( '.class-post-audio-controls' ); -const playBtn = document.querySelector( '.dashicons-controls-play' ); -const pauseBtn = document.querySelector( '.dashicons-controls-pause' ); -const defaultAria = audioControlEl.ariaLabel; -const pauseAria = audioControlEl.dataset.ariaPauseAudio; if ( audioControlEl ) { + const playBtn = audioControlEl.querySelector( '.dashicons-controls-play' ); + const pauseBtn = audioControlEl.querySelector( + '.dashicons-controls-pause' + ); + const headingEl = document.querySelector( '.classifai-post-audio-heading' ); const audioEl = document.getElementById( 'classifai-post-audio-player' ); + const defaultAria = audioControlEl.ariaLabel; + const pauseAria = audioControlEl.dataset.ariaPauseAudio; + let audioPromise = null; + let isGenerating = false; /** * Switches audio playback state. @@ -22,7 +27,7 @@ if ( audioControlEl ) { pauseBtn.style.display = 'block'; playBtn.style.display = 'none'; audioControlEl.ariaLabel = pauseAria; - } else { + } else if ( audioPromise ) { audioPromise.then( () => { audioEl.pause(); pauseBtn.style.display = 'none'; @@ -32,11 +37,86 @@ if ( audioControlEl ) { } } - audioControlEl.addEventListener( 'click', switchState ); + /** + * Generates the audio on demand (the first time a visitor listens), then + * plays it once ready. + */ + async function generateAndPlay() { + if ( isGenerating ) { + return; + } + + isGenerating = true; + audioControlEl.classList.remove( 'has-error' ); + audioControlEl.classList.add( 'is-generating' ); + + const originalHeading = headingEl ? headingEl.textContent : ''; + + if ( headingEl && audioControlEl.dataset.generatingLabel ) { + headingEl.textContent = audioControlEl.dataset.generatingLabel; + } + + try { + const response = await fetch( audioControlEl.dataset.restUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( { + nonce: audioControlEl.dataset.nonce, + } ), + } ); + const data = await response.json(); + + if ( data && data.success && data.url ) { + audioEl.src = data.url; + audioControlEl.dataset.hasAudio = '1'; + + if ( headingEl ) { + headingEl.textContent = originalHeading; + } + + switchState(); + } else { + throw new Error( + data && data.message ? data.message : 'generation_failed' + ); + } + } catch { + audioControlEl.classList.add( 'has-error' ); + + if ( headingEl ) { + headingEl.textContent = + audioControlEl.dataset.errorLabel || originalHeading; + } + } finally { + isGenerating = false; + audioControlEl.classList.remove( 'is-generating' ); + } + } + + /** + * Handles activation of the control via click or keyboard. + */ + function handleActivate() { + if ( isGenerating ) { + return; + } + + // Generate on demand the first time, otherwise toggle playback. + if ( + '1' !== audioControlEl.dataset.hasAudio && + audioControlEl.dataset.restUrl + ) { + generateAndPlay(); + } else { + switchState(); + } + } + + audioControlEl.addEventListener( 'click', handleActivate ); audioControlEl.addEventListener( 'keypress', ( e ) => { if ( 'Space' === e.code || 'Enter' === e.code ) { e.preventDefault(); - switchState(); + handleActivate(); audioControlEl.focus(); } } ); @@ -45,5 +125,6 @@ if ( audioControlEl ) { audioEl.currentTime = 0; pauseBtn.style.display = 'none'; playBtn.style.display = 'block'; + audioControlEl.ariaLabel = defaultAria; } ); } diff --git a/src/js/features/text-to-speech/frontend/index.scss b/src/js/features/text-to-speech/frontend/index.scss index c73dc2bec..151bcb900 100644 --- a/src/js/features/text-to-speech/frontend/index.scss +++ b/src/js/features/text-to-speech/frontend/index.scss @@ -21,7 +21,7 @@ transition: background-color 0.3s ease; } - span { + span.dashicons { font-size: 4rem; width: auto; height: auto; @@ -32,6 +32,29 @@ transform: translateX(0px); } } + + .classifai-tts-spinner { + display: none; + width: 40px; + height: 40px; + border: 4px solid rgba(0, 0, 0, 0.2); + border-top-color: #000; + border-radius: 50%; + animation: classifai-tts-spin 0.8s linear infinite; + } + + // While generating on demand, swap the play icon for a spinner. + &.is-generating { + cursor: progress; + + span.dashicons { + display: none; + } + + .classifai-tts-spinner { + display: block; + } + } } .classifai-post-audio-heading { @@ -39,4 +62,14 @@ padding-left: 1.5rem; color: #7d7d7d; } + + .class-post-audio-controls.has-error + .classifai-post-audio-heading { + color: #cc1818; + } +} + +@keyframes classifai-tts-spin { + to { + transform: rotate(360deg); + } } From ba8c5eddb52db1cf0e763e72c744a10cb5711fd6 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 23 Jun 2026 15:36:08 -0600 Subject: [PATCH 3/8] Add the settings for the new on-demand mode --- .../text-to-speech.js | 106 +++++++++++++----- 1 file changed, 77 insertions(+), 29 deletions(-) diff --git a/src/js/settings/components/feature-additional-settings/text-to-speech.js b/src/js/settings/components/feature-additional-settings/text-to-speech.js index 701ecef24..57f0c97df 100644 --- a/src/js/settings/components/feature-additional-settings/text-to-speech.js +++ b/src/js/settings/components/feature-additional-settings/text-to-speech.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { useSelect, useDispatch } from '@wordpress/data'; -import { CheckboxControl } from '@wordpress/components'; +import { CheckboxControl, SelectControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; /** @@ -26,33 +26,81 @@ export const TextToSpeechSettings = () => { const { postTypes } = window.classifAISettings; return ( - - { Object.keys( postTypes || {} ).map( ( key ) => { - return ( - { - setFeatureSettings( { - post_types: { - ...featureSettings.post_types, - [ key ]: value ? key : '0', - }, - } ); - } } - __nextHasNoMarginBottom - /> - ); - } ) } - + <> + + { Object.keys( postTypes || {} ).map( ( key ) => { + return ( + { + setFeatureSettings( { + post_types: { + ...featureSettings.post_types, + [ key ]: value ? key : '0', + }, + } ); + } } + __nextHasNoMarginBottom + /> + ); + } ) } + + + { + setFeatureSettings( { + generation_timing: value, + } ); + } } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + + ); }; From d52f6ec91e328b287c53ba6641eba1b51f4dc742 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 23 Jun 2026 15:36:37 -0600 Subject: [PATCH 4/8] Add tests --- .../Integration/Features/TextToSpeechTest.php | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) diff --git a/tests/Integration/Features/TextToSpeechTest.php b/tests/Integration/Features/TextToSpeechTest.php index 8f54ecdec..cd33d7004 100644 --- a/tests/Integration/Features/TextToSpeechTest.php +++ b/tests/Integration/Features/TextToSpeechTest.php @@ -38,6 +38,23 @@ private function enable_feature() { ); } + /** + * Configure and enable the feature in on-demand (front-end) generation mode. + */ + private function enable_on_demand() { + update_option( + self::OPTION, + [ + 'status' => '1', + 'provider' => 'ms_azure_text_to_speech', + 'ms_azure_text_to_speech' => [ 'authenticated' => true ], + 'roles' => [ 'administrator' => 'administrator' ], + 'post_types' => [ 'post' => 'post' ], + 'generation_timing' => 'on_demand', + ] + ); + } + /** * @param mixed $post_id Post ID param. * @return WP_REST_Request @@ -48,6 +65,22 @@ private function request( $post_id ): WP_REST_Request { return $request; } + /** + * Build a request for the public on-demand synthesis route. + * + * @param mixed $post_id Post ID param. + * @param string|null $nonce Nonce to send, or null to omit. + * @return WP_REST_Request + */ + private function on_demand_request( $post_id, $nonce = null ): WP_REST_Request { + $request = new WP_REST_Request( 'POST', '/classifai/v1/synthesize-speech-on-demand/' . $post_id ); + $request->set_param( 'id', $post_id ); + if ( null !== $nonce ) { + $request->set_param( 'nonce', $nonce ); + } + return $request; + } + /** * @covers ::speech_synthesis_permissions_check */ @@ -125,4 +158,197 @@ public function test_save_replaces_existing_audio() { $this->assertSame( $second, (int) get_post_meta( $post_id, TextToSpeech::AUDIO_ID_KEY, true ) ); $this->assertNull( get_post( $first ), 'The old audio attachment is deleted.' ); } + + /** + * On-demand generation is denied when the feature is not in on-demand mode. + * + * @covers ::on_demand_synthesis_permissions_check + */ + public function test_on_demand_denied_when_not_on_demand_mode() { + $post_id = self::factory()->post->create( [ 'post_status' => 'publish' ] ); + $this->as_user_with_role( 'administrator' ); + $this->enable_feature(); // Defaults to automatic mode. + + $feature = new TextToSpeech(); + $nonce = wp_create_nonce( 'classifai_synthesize_speech_on_demand' ); + + $this->assertFalse( $feature->on_demand_synthesis_permissions_check( $this->on_demand_request( $post_id, $nonce ) ) ); + } + + /** + * On-demand generation is denied for posts that aren't published. + * + * @covers ::on_demand_synthesis_permissions_check + */ + public function test_on_demand_denied_for_unpublished_post() { + $post_id = self::factory()->post->create( [ 'post_status' => 'draft' ] ); + $this->enable_on_demand(); + + $feature = new TextToSpeech(); + $nonce = wp_create_nonce( 'classifai_synthesize_speech_on_demand' ); + + $this->assertFalse( $feature->on_demand_synthesis_permissions_check( $this->on_demand_request( $post_id, $nonce ) ) ); + } + + /** + * On-demand generation is denied without a valid nonce. + * + * @covers ::on_demand_synthesis_permissions_check + */ + public function test_on_demand_denied_with_invalid_nonce() { + $post_id = self::factory()->post->create( [ 'post_status' => 'publish' ] ); + $this->enable_on_demand(); + + $feature = new TextToSpeech(); + + $this->assertFalse( $feature->on_demand_synthesis_permissions_check( $this->on_demand_request( $post_id, 'bogus-nonce' ) ) ); + } + + /** + * On-demand generation is allowed for an anonymous visitor with a valid nonce. + * + * @covers ::on_demand_synthesis_permissions_check + */ + public function test_on_demand_allows_anonymous_with_valid_nonce() { + $post_id = self::factory()->post->create( [ 'post_status' => 'publish' ] ); + $this->enable_on_demand(); + wp_set_current_user( 0 ); // Anonymous visitor. + + $feature = new TextToSpeech(); + $nonce = wp_create_nonce( 'classifai_synthesize_speech_on_demand' ); + + $this->assertTrue( $feature->on_demand_synthesis_permissions_check( $this->on_demand_request( $post_id, $nonce ) ) ); + } + + /** + * The permission filter can override the default decision. + * + * @covers ::on_demand_synthesis_permissions_check + */ + public function test_on_demand_permission_filter_override() { + $post_id = self::factory()->post->create( [ 'post_status' => 'publish' ] ); + $this->enable_on_demand(); + + $feature = new TextToSpeech(); + + // Without a nonce the default decision is false; the filter forces true. + add_filter( 'classifai_tts_on_demand_permission', '__return_true' ); + $this->assertTrue( $feature->on_demand_synthesis_permissions_check( $this->on_demand_request( $post_id ) ) ); + } + + /** + * The handler generates, stores and returns a playable URL on first listen. + * + * @covers ::synthesize_speech_on_demand + */ + public function test_on_demand_handler_generates_and_returns_url() { + $post_id = self::factory()->post->create( [ 'post_status' => 'publish' ] ); + $this->enable_on_demand(); + + // Bypass the provider HTTP call with canned audio bytes. + add_filter( 'classifai_pre_fetch_feature_response', fn() => 'fake-audio-bytes' ); + + $response = ( new TextToSpeech() )->synthesize_speech_on_demand( $this->on_demand_request( $post_id ) ); + $data = $response->get_data(); + + $this->assertTrue( $data['success'] ); + $this->assertNotEmpty( $data['url'] ); + $this->assertSame( $data['audio_id'], (int) get_post_meta( $post_id, TextToSpeech::AUDIO_ID_KEY, true ) ); + } + + /** + * The handler returns existing audio without regenerating. + * + * @covers ::synthesize_speech_on_demand + */ + public function test_on_demand_handler_reuses_existing_audio() { + $feature = new TextToSpeech(); + $post_id = self::factory()->post->create( [ 'post_status' => 'publish' ] ); + $this->enable_on_demand(); + + $existing_id = $feature->save( 'existing-audio', $post_id ); + + // If generation runs, this counter increments — it must not. + $called = 0; + add_filter( + 'classifai_pre_fetch_feature_response', + function () use ( &$called ) { + $called++; + return 'should-not-run'; + } + ); + + $response = $feature->synthesize_speech_on_demand( $this->on_demand_request( $post_id ) ); + $data = $response->get_data(); + + $this->assertSame( 0, $called, 'Generation should not run when audio already exists.' ); + $this->assertTrue( $data['success'] ); + $this->assertSame( $existing_id, $data['audio_id'] ); + } + + /** + * A per-post lock prevents a second concurrent generation. + * + * @covers ::synthesize_speech_on_demand + */ + public function test_on_demand_handler_respects_lock() { + $post_id = self::factory()->post->create( [ 'post_status' => 'publish' ] ); + $this->enable_on_demand(); + + set_transient( 'classifai_tts_on_demand_lock_' . $post_id, 1, MINUTE_IN_SECONDS ); + + $called = 0; + add_filter( + 'classifai_pre_fetch_feature_response', + function () use ( &$called ) { + $called++; + return 'should-not-run'; + } + ); + + $response = ( new TextToSpeech() )->synthesize_speech_on_demand( $this->on_demand_request( $post_id ) ); + $data = $response->get_data(); + + $this->assertFalse( $data['success'] ); + $this->assertTrue( $data['inProgress'] ); + $this->assertSame( 0, $called, 'Generation should be skipped while locked.' ); + $this->assertEmpty( get_post_meta( $post_id, TextToSpeech::AUDIO_ID_KEY, true ) ); + } + + /** + * Stored audio is cleared when an on-demand post's content changes. + * + * @covers ::maybe_invalidate_on_demand_audio + */ + public function test_invalidate_clears_audio_on_content_change() { + $feature = new TextToSpeech(); + $post_id = self::factory()->post->create( [ 'post_status' => 'publish' ] ); + $this->enable_on_demand(); + + $feature->save( 'audio-bytes', $post_id ); + update_post_meta( $post_id, TextToSpeech::AUDIO_HASH_KEY, 'stale-hash' ); + + $feature->maybe_invalidate_on_demand_audio( $post_id ); + + $this->assertEmpty( get_post_meta( $post_id, TextToSpeech::AUDIO_ID_KEY, true ) ); + $this->assertEmpty( get_post_meta( $post_id, TextToSpeech::AUDIO_HASH_KEY, true ) ); + } + + /** + * Stored audio is kept when content is unchanged. + * + * @covers ::maybe_invalidate_on_demand_audio + */ + public function test_invalidate_keeps_audio_when_unchanged() { + $feature = new TextToSpeech(); + $post_id = self::factory()->post->create( [ 'post_status' => 'publish' ] ); + $this->enable_on_demand(); + + $audio_id = $feature->save( 'audio-bytes', $post_id ); + update_post_meta( $post_id, TextToSpeech::AUDIO_HASH_KEY, md5( $feature->normalize_post_content( $post_id ) ) ); + + $feature->maybe_invalidate_on_demand_audio( $post_id ); + + $this->assertSame( $audio_id, (int) get_post_meta( $post_id, TextToSpeech::AUDIO_ID_KEY, true ) ); + } } From a706654c2213b9b83916f55675c0f4c93fc94dac Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 23 Jun 2026 16:14:09 -0600 Subject: [PATCH 5/8] Update the description that shows below the audio toggle to make it more clear what mode we're in and what will actually happen. If on-demand is the mode, keep the toggle on by default but allow it to be toggled off to disable for that specific post --- includes/Classifai/Features/TextToSpeech.php | 137 +++++++++++++++--- src/js/features/text-to-speech/index.js | 23 ++- .../text-to-speech.js | 2 +- .../Integration/Features/TextToSpeechTest.php | 82 +++++++++++ 4 files changed, 213 insertions(+), 31 deletions(-) diff --git a/includes/Classifai/Features/TextToSpeech.php b/includes/Classifai/Features/TextToSpeech.php index d81912b26..f635cdb7a 100644 --- a/includes/Classifai/Features/TextToSpeech.php +++ b/includes/Classifai/Features/TextToSpeech.php @@ -59,6 +59,16 @@ class TextToSpeech extends Feature { */ const AUDIO_HASH_KEY = '_classifai_post_audio_hash'; + /** + * Meta key to opt a single post out of on-demand audio generation. + * + * Only meaningful when the feature is in `on_demand` mode. When set, the + * front-end "listen" player is not rendered for the post. + * + * @var string + */ + const DISABLE_ON_DEMAND_KEY = '_classifai_disable_on_demand_audio'; + /** * Constructor. */ @@ -172,6 +182,21 @@ public function enqueue_editor_assets() { get_asset_info( 'classifai-plugin-text-to-speech', 'version' ), true ); + + $post_type_label = esc_html__( 'Post', 'classifai' ); + $post_type_obj = get_post_type_object( $post->post_type ); + if ( $post_type_obj ) { + $post_type_label = $post_type_obj->labels->singular_name; + } + + wp_localize_script( + 'classifai-plugin-text-to-speech', + 'classifaiTextToSpeechData', + array( + 'generationTiming' => $this->get_generation_timing(), + 'enableHelpText' => $this->get_audio_generation_help_text( $post_type_label ), + ) + ); } /** @@ -188,18 +213,23 @@ public function add_meta_to_rest_api() { $supported_post_types, 'classifai_synthesize_speech', array( - 'get_callback' => function ( $data ) { - $audio_id = get_post_meta( $data['id'], self::AUDIO_ID_KEY, true ); - if ( - ( $this->get_audio_generation_initial_state( $data['id'] ) && ! $audio_id ) || - ( $this->get_audio_generation_subsequent_state( $data['id'] ) && $audio_id ) - ) { - return true; + 'get_callback' => function ( $data ) { + return $this->is_synthesize_speech_enabled( $data['id'] ); + }, + 'update_callback' => function ( $value, $data ) { + // In on-demand mode this toggle is a per-post opt-out; in other + // modes it only signals save-time generation (handled elsewhere). + if ( 'on_demand' !== $this->get_generation_timing() ) { + return; + } + + if ( $value ) { + delete_post_meta( $data->ID, self::DISABLE_ON_DEMAND_KEY ); } else { - return false; + update_post_meta( $data->ID, self::DISABLE_ON_DEMAND_KEY, true ); } }, - 'schema' => array( + 'schema' => array( 'type' => 'boolean', 'context' => array( 'view', 'edit' ), ), @@ -260,6 +290,12 @@ public function rest_handle_audio( \WP_Post $post, WP_REST_Request $request ) { return; } + // In on-demand mode audio is generated from the front-end, never on save. + // The synthesize toggle is persisted as an opt-out via its REST field. + if ( 'on_demand' === $this->get_generation_timing() ) { + return; + } + // Ensure we have a logged in user that can edit the item. if ( ! current_user_can( 'edit_post', $post_id ) ) { return; @@ -701,13 +737,7 @@ public function render_audio_generation_ui( \WP_Post $post ) { $source_url = wp_get_attachment_url( $audio_id ); } - $process_content = false; - if ( - ( $this->get_audio_generation_initial_state( $post ) && ! $audio_id ) || - ( $this->get_audio_generation_subsequent_state( $post ) && $audio_id ) - ) { - $process_content = true; - } + $process_content = $this->is_synthesize_speech_enabled( $post ); $display_audio = true; if ( metadata_exists( 'post', $post->ID, self::DISPLAY_GENERATED_AUDIO ) && @@ -720,6 +750,7 @@ public function render_audio_generation_ui( \WP_Post $post ) { if ( $post_type ) { $post_type_label = $post_type->labels->singular_name; } + ?>

- + get_audio_generation_help_text( $post_type_label ) ); ?>

@@ -842,6 +870,17 @@ public function save_post_metadata( int $post_id ) { delete_post_meta( $post_id, self::DISPLAY_GENERATED_AUDIO ); } + // In on-demand mode the synthesize toggle is a per-post opt-out and never + // triggers save-time generation. + if ( 'on_demand' === $this->get_generation_timing() ) { + if ( isset( $_POST['classifai_synthesize_speech'] ) ) { + delete_post_meta( $post_id, self::DISABLE_ON_DEMAND_KEY ); + } else { + update_post_meta( $post_id, self::DISABLE_ON_DEMAND_KEY, true ); + } + return; + } + $job_args = array( 'post_id' => (int) $post_id, 'calling_user_id' => get_current_user_id(), @@ -1011,6 +1050,11 @@ public function render_post_audio_controls( string $content ): string { $audio_attachment_id = (int) get_post_meta( $_post->ID, self::AUDIO_ID_KEY, true ); $audio_attachment_url = $audio_attachment_id ? wp_get_attachment_url( $audio_attachment_id ) : ''; + // Respect a per-post opt-out of on-demand generation. + if ( $on_demand && (bool) get_post_meta( $_post->ID, self::DISABLE_ON_DEMAND_KEY, true ) ) { + return $content; + } + // When audio hasn't been generated yet, only render the player if we're // generating on demand. Otherwise there's nothing to play. if ( ! $audio_attachment_url && ! ( $on_demand && 'publish' === $_post->post_status ) ) { @@ -1195,7 +1239,7 @@ public function get_feature_default_settings(): array { * Returns the supported audio generation timing modes. * * - `automatic`: generate audio when a post is published or updated. - * - `manual`: only generate when explicitly triggered from the admin. + * - `manual`: only generate when explicitly turned on for each post. * - `on_demand`: generate the first time a visitor listens on the front-end. * * @return array @@ -1203,7 +1247,7 @@ public function get_feature_default_settings(): array { public function get_generation_timing_options(): array { return array( 'automatic' => __( 'Automatic (on publish or update)', 'classifai' ), - 'manual' => __( 'Manual (generate from the admin)', 'classifai' ), + 'manual' => __( 'Manual (generation needs to be manually turned on for each post)', 'classifai' ), 'on_demand' => __( 'On demand (generate on first front-end listen)', 'classifai' ), ); } @@ -1222,6 +1266,55 @@ public function get_generation_timing(): string { return array_key_exists( $timing, $this->get_generation_timing_options() ) ? $timing : 'automatic'; } + /** + * Help text for the "Enable" toggle, worded for the generation timing mode. + * + * @param string $post_type_label Singular label of the post type (e.g. "Post"). + * @return string + */ + public function get_audio_generation_help_text( string $post_type_label ): string { + switch ( $this->get_generation_timing() ) { + case 'manual': + /* translators: %s Post type label */ + return sprintf( __( 'Audio won\'t be generated until you enable this and save the %s.', 'classifai' ), $post_type_label ); + + case 'on_demand': + /* translators: %s Post type label */ + return sprintf( __( 'Audio is generated the first time a visitor chooses to listen on the front-end. Turn this off to disable audio for this %s.', 'classifai' ), $post_type_label ); + + case 'automatic': + default: + /* translators: %s Post type label */ + return sprintf( __( 'ClassifAI will generate audio for this %s when it is published or updated.', 'classifai' ), $post_type_label ); + } + } + + /** + * Whether the per-post "Enable audio generation" toggle should be on. + * + * In on-demand mode the toggle controls whether the post participates in + * on-demand generation at all — it defaults on and is only off when the post + * has been explicitly opted out. In other modes it reflects the existing + * initial/subsequent generation state. + * + * @param int|\WP_Post $post Post ID or object. + * @return bool + */ + public function is_synthesize_speech_enabled( $post ): bool { + $post_id = $post instanceof \WP_Post ? $post->ID : (int) $post; + + if ( 'on_demand' === $this->get_generation_timing() ) { + return ! (bool) get_post_meta( $post_id, self::DISABLE_ON_DEMAND_KEY, true ); + } + + $audio_id = get_post_meta( $post_id, self::AUDIO_ID_KEY, true ); + + return ( + ( $this->get_audio_generation_initial_state( $post ) && ! $audio_id ) || + ( $this->get_audio_generation_subsequent_state( $post ) && $audio_id ) + ); + } + /** * Sanitizes the default feature settings. * diff --git a/src/js/features/text-to-speech/index.js b/src/js/features/text-to-speech/index.js index 4b1e84d72..7a5ee6b78 100644 --- a/src/js/features/text-to-speech/index.js +++ b/src/js/features/text-to-speech/index.js @@ -23,6 +23,10 @@ import { store as postAudioStore } from './store'; const { ClassifaiEditorSettingPanel } = window; +// Localized data describing the configured audio generation timing mode. +const { enableHelpText: ttsEnableHelpText } = + window.classifaiTextToSpeechData || {}; + /** * ClassifAI Text to Audio component. */ @@ -212,14 +216,17 @@ const TextToSpeechPlugin = () => { { wp.data.dispatch( editorStore ).editPost( { diff --git a/src/js/settings/components/feature-additional-settings/text-to-speech.js b/src/js/settings/components/feature-additional-settings/text-to-speech.js index 57f0c97df..b21494d30 100644 --- a/src/js/settings/components/feature-additional-settings/text-to-speech.js +++ b/src/js/settings/components/feature-additional-settings/text-to-speech.js @@ -79,7 +79,7 @@ export const TextToSpeechSettings = () => { }, { label: __( - 'Manual (generate from the admin)', + 'Manual (generation needs to be manually turned on for each post)', 'classifai' ), value: 'manual', diff --git a/tests/Integration/Features/TextToSpeechTest.php b/tests/Integration/Features/TextToSpeechTest.php index cd33d7004..679c2b8bf 100644 --- a/tests/Integration/Features/TextToSpeechTest.php +++ b/tests/Integration/Features/TextToSpeechTest.php @@ -334,6 +334,88 @@ public function test_invalidate_clears_audio_on_content_change() { $this->assertEmpty( get_post_meta( $post_id, TextToSpeech::AUDIO_HASH_KEY, true ) ); } + /** + * The enable-toggle help text is worded for the configured timing mode. + * + * @covers ::get_audio_generation_help_text + */ + public function test_help_text_varies_by_mode() { + $feature = new TextToSpeech(); + + update_option( self::OPTION, [ 'generation_timing' => 'automatic' ] ); + $this->assertStringContainsString( 'published or updated', $feature->get_audio_generation_help_text( 'Post' ) ); + + update_option( self::OPTION, [ 'generation_timing' => 'manual' ] ); + $this->assertStringContainsString( "won't be generated", $feature->get_audio_generation_help_text( 'Post' ) ); + + update_option( self::OPTION, [ 'generation_timing' => 'on_demand' ] ); + $this->assertStringContainsString( 'first time a visitor', $feature->get_audio_generation_help_text( 'Post' ) ); + } + + /** + * The per-post toggle defaults on in on-demand mode and reflects the opt-out. + * + * @covers ::is_synthesize_speech_enabled + */ + public function test_on_demand_toggle_defaults_on_and_reflects_opt_out() { + $feature = new TextToSpeech(); + $post_id = self::factory()->post->create( [ 'post_status' => 'publish' ] ); + $this->enable_on_demand(); + + // No opt-out meta yet → toggle is on. + $this->assertTrue( $feature->is_synthesize_speech_enabled( $post_id ) ); + + update_post_meta( $post_id, TextToSpeech::DISABLE_ON_DEMAND_KEY, true ); + $this->assertFalse( $feature->is_synthesize_speech_enabled( $post_id ) ); + } + + /** + * Saving the classic meta box persists the opt-out without generating audio. + * + * @covers ::save_post_metadata + */ + public function test_on_demand_save_persists_opt_out() { + $feature = new TextToSpeech(); + $post_id = self::factory()->post->create( [ 'post_status' => 'publish' ] ); + $this->as_user_with_role( 'administrator' ); + $this->enable_on_demand(); + + $_POST['classifai_text_to_speech_meta'] = wp_create_nonce( 'classifai_text_to_speech_meta_action' ); + + // Toggle off (checkbox not submitted) → opt-out persisted. + unset( $_POST['classifai_synthesize_speech'] ); + $feature->save_post_metadata( $post_id ); + $this->assertTrue( (bool) get_post_meta( $post_id, TextToSpeech::DISABLE_ON_DEMAND_KEY, true ) ); + $this->assertEmpty( get_post_meta( $post_id, TextToSpeech::AUDIO_ID_KEY, true ), 'No audio is generated on save in on-demand mode.' ); + + // Toggle back on → opt-out cleared. + $_POST['classifai_synthesize_speech'] = '1'; + $feature->save_post_metadata( $post_id ); + $this->assertEmpty( get_post_meta( $post_id, TextToSpeech::DISABLE_ON_DEMAND_KEY, true ) ); + + unset( $_POST['classifai_text_to_speech_meta'], $_POST['classifai_synthesize_speech'] ); + } + + /** + * The front-end player is hidden for a post opted out of on-demand audio. + * + * @covers ::render_post_audio_controls + */ + public function test_on_demand_player_hidden_when_opted_out() { + $feature = new TextToSpeech(); + $post_id = self::factory()->post->create( [ 'post_status' => 'publish' ] ); + $this->enable_on_demand(); + + $this->go_to( get_permalink( $post_id ) ); + + // Opted in (default) → the player renders. + $this->assertStringContainsString( 'class-post-audio-controls', $feature->render_post_audio_controls( 'CONTENT' ) ); + + // Opted out → the content is returned untouched. + update_post_meta( $post_id, TextToSpeech::DISABLE_ON_DEMAND_KEY, true ); + $this->assertSame( 'CONTENT', $feature->render_post_audio_controls( 'CONTENT' ) ); + } + /** * Stored audio is kept when content is unchanged. * From 333544afaaab821529d20a7b9b6a82598541061e Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 23 Jun 2026 16:40:38 -0600 Subject: [PATCH 6/8] Fix a bug where logged-in users couldn't generate on-demand audio due to a failing nonce check --- includes/Classifai/Features/TextToSpeech.php | 12 ++++++------ src/js/features/text-to-speech/frontend/index.js | 8 ++++---- tests/Integration/Features/TextToSpeechTest.php | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/includes/Classifai/Features/TextToSpeech.php b/includes/Classifai/Features/TextToSpeech.php index f635cdb7a..2ada48b28 100644 --- a/includes/Classifai/Features/TextToSpeech.php +++ b/includes/Classifai/Features/TextToSpeech.php @@ -410,11 +410,11 @@ public function register_endpoints() { * Unlike {@see speech_synthesis_permissions_check()}, this route is reachable * by anonymous visitors, so it is gated on the feature being enabled and in * on-demand mode, the target being a published supported post, and a valid - * nonce — rather than an `edit_post` capability check. + * REST nonce — rather than an `edit_post` capability check. * - * Note: nonces for logged-out users are shared and long-lived, so on heavily - * page-cached sites they act as a CSRF / casual-bot deterrent rather than a - * hard gate. Because audio is generated at most once per post, the cost + * Note: the nonce for logged-out users is shared and long-lived, so + * on heavily page-cached sites it acts as a CSRF / casual-bot deterrent rather + * than a hard gate. Because audio is generated at most once per post, the cost * ceiling is one generation per published post. Site owners can tighten or * loosen this via the `classifai_tts_on_demand_permission` filter. * @@ -431,7 +431,7 @@ public function on_demand_synthesis_permissions_check( WP_REST_Request $request in_array( $post->post_type, $this->get_supported_post_types(), true ) && $this->is_enabled() && 'on_demand' === $this->get_generation_timing() && - false !== wp_verify_nonce( (string) $request->get_param( 'nonce' ), 'classifai_synthesize_speech_on_demand' ) + false !== wp_verify_nonce( (string) $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ) ); /** @@ -1129,7 +1129,7 @@ class="class-post-audio-controls" data-post-id="ID ); ?>" data-rest-url="ID ) ); ?>" - data-nonce="" + data-nonce="" data-generating-label="" data-error-label="" diff --git a/src/js/features/text-to-speech/frontend/index.js b/src/js/features/text-to-speech/frontend/index.js index 3718df94c..0d0fb1746 100644 --- a/src/js/features/text-to-speech/frontend/index.js +++ b/src/js/features/text-to-speech/frontend/index.js @@ -57,12 +57,12 @@ if ( audioControlEl ) { } try { + // Send the REST (`wp_rest`) nonce so logged-in users' requests + // authenticate; without it WordPress rejects the cookie with a 403 + // before our permission callback runs. const response = await fetch( audioControlEl.dataset.restUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify( { - nonce: audioControlEl.dataset.nonce, - } ), + headers: { 'X-WP-Nonce': audioControlEl.dataset.nonce }, } ); const data = await response.json(); diff --git a/tests/Integration/Features/TextToSpeechTest.php b/tests/Integration/Features/TextToSpeechTest.php index 679c2b8bf..f27fbaa6e 100644 --- a/tests/Integration/Features/TextToSpeechTest.php +++ b/tests/Integration/Features/TextToSpeechTest.php @@ -69,14 +69,14 @@ private function request( $post_id ): WP_REST_Request { * Build a request for the public on-demand synthesis route. * * @param mixed $post_id Post ID param. - * @param string|null $nonce Nonce to send, or null to omit. + * @param string|null $nonce `wp_rest` nonce to send as X-WP-Nonce, or null to omit. * @return WP_REST_Request */ private function on_demand_request( $post_id, $nonce = null ): WP_REST_Request { $request = new WP_REST_Request( 'POST', '/classifai/v1/synthesize-speech-on-demand/' . $post_id ); $request->set_param( 'id', $post_id ); if ( null !== $nonce ) { - $request->set_param( 'nonce', $nonce ); + $request->set_header( 'X-WP-Nonce', $nonce ); } return $request; } @@ -170,7 +170,7 @@ public function test_on_demand_denied_when_not_on_demand_mode() { $this->enable_feature(); // Defaults to automatic mode. $feature = new TextToSpeech(); - $nonce = wp_create_nonce( 'classifai_synthesize_speech_on_demand' ); + $nonce = wp_create_nonce( 'wp_rest' ); $this->assertFalse( $feature->on_demand_synthesis_permissions_check( $this->on_demand_request( $post_id, $nonce ) ) ); } @@ -185,7 +185,7 @@ public function test_on_demand_denied_for_unpublished_post() { $this->enable_on_demand(); $feature = new TextToSpeech(); - $nonce = wp_create_nonce( 'classifai_synthesize_speech_on_demand' ); + $nonce = wp_create_nonce( 'wp_rest' ); $this->assertFalse( $feature->on_demand_synthesis_permissions_check( $this->on_demand_request( $post_id, $nonce ) ) ); } @@ -215,7 +215,7 @@ public function test_on_demand_allows_anonymous_with_valid_nonce() { wp_set_current_user( 0 ); // Anonymous visitor. $feature = new TextToSpeech(); - $nonce = wp_create_nonce( 'classifai_synthesize_speech_on_demand' ); + $nonce = wp_create_nonce( 'wp_rest' ); $this->assertTrue( $feature->on_demand_synthesis_permissions_check( $this->on_demand_request( $post_id, $nonce ) ) ); } From bb87fc7c6dd9d8da7dbb33f74ced99c2997f4803 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 23 Jun 2026 16:51:20 -0600 Subject: [PATCH 7/8] Add E2E tests --- .../text-to-speech-generation-modes.spec.ts | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 tests/e2e/specs/language-processing/text-to-speech-generation-modes.spec.ts diff --git a/tests/e2e/specs/language-processing/text-to-speech-generation-modes.spec.ts b/tests/e2e/specs/language-processing/text-to-speech-generation-modes.spec.ts new file mode 100644 index 000000000..df8ed5463 --- /dev/null +++ b/tests/e2e/specs/language-processing/text-to-speech-generation-modes.spec.ts @@ -0,0 +1,263 @@ +/** + * Internal dependencies + */ +import { test, expect } from '../../fixtures/test'; + +/** + * Covers the `generation_timing` setting (Automatic / Manual / On demand) and + * the on-demand front-end "generate on first listen" flow. Uses the Microsoft + * Azure provider since the test plugin mocks its synthesis endpoint, so + * on-demand generation completes synchronously without a real API call. + */ +test.describe( '[Language Processing] Text to Speech generation modes', () => { + test.beforeAll( async ( { browser, requestUtils } ) => { + try { + await requestUtils.deactivatePlugin( 'classic-editor' ); + } catch { + // noop + } + + const page = await browser.newPage(); + await page.goto( + '/wp-admin/tools.php?page=classifai#/language_processing/feature_text_to_speech_generation' + ); + await expect( page.locator( '#classifai-logo' ) ).toBeVisible(); + await expect( + page.locator( '.classifai-loading-settings' ) + ).toHaveCount( 0 ); + + // Select provider (initial). + const editBtn = page.locator( '.classifai-settings-edit-provider' ); + if ( await editBtn.count() ) { + await editBtn.first().click(); + } + await page + .locator( '.classifai-provider-select select' ) + .selectOption( 'ms_azure_text_to_speech' ); + + await page.locator( '.settings-allowed-post-types input#post' ).check(); + + // Select provider again (re-render after enabling the post type). + const editBtn2 = page.locator( '.classifai-settings-edit-provider' ); + if ( await editBtn2.count() ) { + await editBtn2.first().click(); + } + await page + .locator( '.classifai-provider-select select' ) + .selectOption( 'ms_azure_text_to_speech' ); + + await page + .locator( '#ms_azure_text_to_speech_endpoint_url' ) + .fill( '' ); + await page + .locator( '#ms_azure_text_to_speech_endpoint_url' ) + .fill( 'https://service.com' ); + await page + .locator( '#ms_azure_text_to_speech_api_key' ) + .fill( 'password' ); + + // Enable feature. + await page.evaluate( () => { + window.localStorage.setItem( + 'classifai_dont_ask_credential_reuse', + 'true' + ); + } ); + const toggle = page.locator( + '.classifai-enable-feature-toggle input[type="checkbox"]' + ); + if ( ! ( await toggle.isChecked() ) ) { + await toggle.check(); + } + + const responsePromise1 = page.waitForResponse( + ( res ) => + res.url().includes( '/wp-json/classifai/v1/settings/' ) && + res.request().method() === 'POST' + ); + await page + .locator( '.classifai-settings-footer button.save-settings-button' ) + .click(); + await responsePromise1; + + // Voices populate after the first save; pick one and save again. + await page + .locator( '#ms_azure_text_to_speech_voice' ) + .selectOption( 'en-AU-AnnetteNeural|Female' ); + + const responsePromise2 = page.waitForResponse( + ( res ) => + res.url().includes( '/wp-json/classifai/v1/settings/' ) && + res.request().method() === 'POST' + ); + await page + .locator( '.classifai-settings-footer button.save-settings-button' ) + .click(); + await responsePromise2; + + // Opt in to all features for the admin user. + await page.goto( '/wp-admin/profile.php' ); + const optOuts = page.locator( + 'input[name="classifai_opted_out_features[]"]' + ); + const count = await optOuts.count(); + let anyChecked = false; + for ( let i = 0; i < count; i++ ) { + const cb = optOuts.nth( i ); + if ( await cb.isChecked() ) { + await cb.uncheck(); + anyChecked = true; + } + } + if ( anyChecked ) { + await page.locator( '#submit' ).click(); + } + await page.close(); + } ); + + test( 'Audio generation timing setting offers all three modes', async ( { + classifaiUtils, + page, + } ) => { + await classifaiUtils.visitFeatureSettings( + 'language_processing/feature_text_to_speech_generation' + ); + + const select = page.locator( '#generation_timing' ); + await expect( select ).toBeVisible(); + await expect( select.locator( 'option' ) ).toHaveCount( 3 ); + await expect( + select.locator( 'option[value="automatic"]' ) + ).toHaveCount( 1 ); + await expect( select.locator( 'option[value="manual"]' ) ).toHaveCount( + 1 + ); + await expect( + select.locator( 'option[value="on_demand"]' ) + ).toHaveCount( 1 ); + + // Switch to on-demand for the following tests. + await select.selectOption( 'on_demand' ); + await classifaiUtils.saveFeatureSettings(); + } ); + + test( 'On-demand: per-post toggle is enabled and on by default', async ( { + classifaiUtils, + editor, + page, + } ) => { + await classifaiUtils.createPost( { + title: 'On demand toggle default', + content: 'On-demand audio generation toggle should default on.', + publish: false, + } ); + await editor.openDocumentSettingsSidebar(); + await classifaiUtils.openClassifAIPostPanel(); + + const enableToggle = page.getByRole( 'checkbox', { + name: 'Enable audio generation', + } ); + await expect( enableToggle ).toBeChecked(); + await expect( enableToggle ).toBeEnabled(); + } ); + + test( 'On-demand: audio is generated on the first front-end listen', async ( { + classifaiUtils, + page, + } ) => { + await classifaiUtils.createPost( { + title: 'On demand listen', + content: 'This audio is generated the first time someone listens.', + } ); + + await page.goto( '/on-demand-listen/' ); + + const controls = page.locator( '.class-post-audio-controls' ); + await expect( controls ).toBeVisible(); + // No audio exists yet, so the player advertises the on-demand state. + await expect( controls ).toHaveAttribute( 'data-has-audio', '0' ); + + // Clicking triggers synchronous generation against the mocked provider. + const generation = page.waitForResponse( + ( res ) => + res + .url() + .includes( '/classifai/v1/synthesize-speech-on-demand/' ) && + res.request().method() === 'POST' + ); + await controls.click(); + const response = await generation; + expect( response.ok() ).toBeTruthy(); + + // Once generated, the player flips to the "has audio" + playing state. + await expect( controls ).toHaveAttribute( 'data-has-audio', '1' ); + await expect( + page.locator( '.dashicons-controls-pause' ) + ).toBeVisible(); + } ); + + test( 'On-demand: a post can be opted out, hiding the player', async ( { + classifaiUtils, + editor, + page, + } ) => { + await classifaiUtils.createPost( { + title: 'On demand opted out', + content: 'This post opts out of on-demand audio generation.', + publish: false, + } ); + await editor.openDocumentSettingsSidebar(); + await classifaiUtils.openClassifAIPostPanel(); + + // Turn the per-post toggle off, then publish. + const enableToggle = page.getByRole( 'checkbox', { + name: 'Enable audio generation', + } ); + await expect( enableToggle ).toBeChecked(); + await enableToggle.uncheck(); + await editor.publishPost(); + await classifaiUtils.closePublishPanel(); + + await page.goto( '/on-demand-opted-out/' ); + await expect( + page.locator( '.class-post-audio-controls' ) + ).toHaveCount( 0 ); + } ); + + test( 'Manual: no audio is generated until the toggle is turned on', async ( { + classifaiUtils, + editor, + page, + } ) => { + await classifaiUtils.visitFeatureSettings( + 'language_processing/feature_text_to_speech_generation' + ); + await page.locator( '#generation_timing' ).selectOption( 'manual' ); + await classifaiUtils.saveFeatureSettings(); + + await classifaiUtils.createPost( { + title: 'Manual mode post', + content: 'Manual mode should not generate audio automatically.', + } ); + await editor.openDocumentSettingsSidebar(); + await classifaiUtils.openClassifAIPostPanel(); + + // The toggle defaults off in manual mode. + await expect( + page.getByRole( 'checkbox', { name: 'Enable audio generation' } ) + ).not.toBeChecked(); + + // And with no audio generated, the front-end shows no player. + await page.goto( '/manual-mode-post/' ); + await expect( + page.locator( '.class-post-audio-controls' ) + ).toHaveCount( 0 ); + + // Reset to automatic so the suite leaves settings in the default mode. + await classifaiUtils.visitFeatureSettings( + 'language_processing/feature_text_to_speech_generation' + ); + await page.locator( '#generation_timing' ).selectOption( 'automatic' ); + await classifaiUtils.saveFeatureSettings(); + } ); +} ); From 2a3d9f4161c68f4ebf0d78494ba43b00710e66d6 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 23 Jun 2026 17:10:48 -0600 Subject: [PATCH 8/8] Ensure audio can't be generated for a post that has controls turned off or generation turned off --- includes/Classifai/Features/TextToSpeech.php | 11 +++++++ .../Integration/Features/TextToSpeechTest.php | 33 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/includes/Classifai/Features/TextToSpeech.php b/includes/Classifai/Features/TextToSpeech.php index 2ada48b28..16cce68f0 100644 --- a/includes/Classifai/Features/TextToSpeech.php +++ b/includes/Classifai/Features/TextToSpeech.php @@ -425,12 +425,23 @@ public function on_demand_synthesis_permissions_check( WP_REST_Request $request $post_id = (int) $request->get_param( 'id' ); $post = $post_id ? get_post( $post_id ) : null; + // Audio controls hidden via the "Display audio controls" setting. + $display_hidden = ( + metadata_exists( 'post', $post_id, self::DISPLAY_GENERATED_AUDIO ) && + ! (bool) get_post_meta( $post_id, self::DISPLAY_GENERATED_AUDIO, true ) + ); + + // Opted out via the per-post "Enable audio generation" toggle. + $opted_out = (bool) get_post_meta( $post_id, self::DISABLE_ON_DEMAND_KEY, true ); + $allowed = ( $post instanceof \WP_Post && 'publish' === $post->post_status && in_array( $post->post_type, $this->get_supported_post_types(), true ) && $this->is_enabled() && 'on_demand' === $this->get_generation_timing() && + ! $display_hidden && + ! $opted_out && false !== wp_verify_nonce( (string) $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ) ); diff --git a/tests/Integration/Features/TextToSpeechTest.php b/tests/Integration/Features/TextToSpeechTest.php index f27fbaa6e..22f83e943 100644 --- a/tests/Integration/Features/TextToSpeechTest.php +++ b/tests/Integration/Features/TextToSpeechTest.php @@ -220,6 +220,39 @@ public function test_on_demand_allows_anonymous_with_valid_nonce() { $this->assertTrue( $feature->on_demand_synthesis_permissions_check( $this->on_demand_request( $post_id, $nonce ) ) ); } + /** + * Generation is denied for a post opted out at the post level, even with a + * valid (global) nonce borrowed from another page. + * + * @covers ::on_demand_synthesis_permissions_check + */ + public function test_on_demand_denied_when_post_opted_out() { + $post_id = self::factory()->post->create( [ 'post_status' => 'publish' ] ); + $this->enable_on_demand(); + update_post_meta( $post_id, TextToSpeech::DISABLE_ON_DEMAND_KEY, true ); + + $feature = new TextToSpeech(); + $nonce = wp_create_nonce( 'wp_rest' ); + + $this->assertFalse( $feature->on_demand_synthesis_permissions_check( $this->on_demand_request( $post_id, $nonce ) ) ); + } + + /** + * Generation is denied when audio controls are hidden for the post. + * + * @covers ::on_demand_synthesis_permissions_check + */ + public function test_on_demand_denied_when_audio_display_hidden() { + $post_id = self::factory()->post->create( [ 'post_status' => 'publish' ] ); + $this->enable_on_demand(); + update_post_meta( $post_id, TextToSpeech::DISPLAY_GENERATED_AUDIO, false ); + + $feature = new TextToSpeech(); + $nonce = wp_create_nonce( 'wp_rest' ); + + $this->assertFalse( $feature->on_demand_synthesis_permissions_check( $this->on_demand_request( $post_id, $nonce ) ) ); + } + /** * The permission filter can override the default decision. *