timer = $timer; } /** * Speak one answer. * * @param string $text The answer to speak. * @param string $language Two-letter language code, or an empty string. * @param string $tone How it should sound. One of self::TONES. * @return array|WP_Error {audio: base64 string, mime: string} on success. */ public function speak( string $text, string $language = '', string $tone = self::DEFAULT_TONE ) { if ( ! $this->is_configured() ) { return $this->not_configured(); } $text = $this->prepare( $text ); if ( '' === $text ) { return new WP_Error( 'aisentic_voice_nothing_to_say', __( 'There was nothing to say.', 'aisentic' ), [ 'status' => 422 ] ); } $at = $this->timer ? $this->timer->mark() : 0.0; $response = wp_remote_post( $this->endpoint() . '/api/v1/audio/speak', [ 'timeout' => 30, 'headers' => [ 'Authorization' => 'Bearer ' . $this->api_key(), 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], 'body' => wp_json_encode( [ 'text' => $text, 'language' => $language, 'tone' => self::clean_tone( $tone ), 'format' => 'mp3', ] ), ] ); if ( $this->timer ) { // Time to make and download the full clip, before the first word // is heard. speak_stream() removes this wait. $this->timer->took( 'tts.blocking', $at ); $this->timer->count( 'ttsBlockingCalls' ); } $raw = $this->unwrap( $response, __( 'text to speech', 'aisentic' ) ); if ( is_wp_error( $raw ) ) { return $raw; } return $this->shape_reply( json_decode( (string) $raw, true ) ); } /** * Turn the provider's JSON into the array the rest of the plugin reads. * * Used by speak() and by the fallback in speak_stream(), so both return * the same shape. * * @param mixed $data Decoded JSON body. * @return array|WP_Error */ private function shape_reply( $data ) { if ( ! is_array( $data ) ) { return new WP_Error( 'aisentic_voice_bad_response', __( 'The voice service sent back nothing usable.', 'aisentic' ), [ 'status' => 502 ] ); } // The engine runs in the browser (Kokoro). No audio is returned, and // this is not an error. The page makes the sound. if ( ! empty( $data['browser'] ) ) { return [ 'browser' => true, 'driver' => (string) ( $data['driver'] ?? 'kokoro' ), 'supported' => ! isset( $data['supported'] ) || (bool) $data['supported'], 'voice' => (string) ( $data['voice'] ?? '' ), 'model' => (string) ( $data['model'] ?? '' ), 'audio' => '', 'mime' => '', ]; } // The engine has no voice for this language. Also not an error. if ( isset( $data['spoken'] ) && false === $data['spoken'] ) { return [ 'browser' => false, 'driver' => (string) ( $data['driver'] ?? '' ), 'supported' => false, 'audio' => '', 'mime' => '', ]; } if ( empty( $data['audio'] ) ) { return new WP_Error( 'aisentic_voice_no_audio', __( 'The voice service sent back no audio.', 'aisentic' ), [ 'status' => 502 ] ); } return [ 'browser' => false, 'driver' => (string) ( $data['driver'] ?? 'google' ), 'supported' => true, 'audio' => (string) $data['audio'], 'mime' => (string) ( $data['mime'] ?? 'audio/mpeg' ), // What the engine says it read out. This can differ from the text // sent: the voice is a chat model, and on a short line it sometimes // answers the line instead of reading it. // See Voice_Controller::faithful(). 'transcript' => (string) ( $data['transcript'] ?? '' ), ]; } /** * Check if this site can stream audio piece by piece. * * Needs cURL and the `voice.streamAudio` setting (on by default). * Why cURL: wp_remote_post() waits for the whole response. * If false, speak() is used and waits for the full clip. * * @return bool */ public function can_stream(): bool { if ( ! function_exists( 'curl_init' ) ) { return false; } return (bool) $this->settings->get( 'voice.streamAudio', true ); } /** * Speak one line, and pass each audio piece to $on_chunk as it arrives. * * Why: without streaming, the listener waits for the full clip. * In a test of 7 turns, time to first sound fell from 2723 ms to 1996 ms * (about 730 ms saved). Most of the rest is gpt-audio-mini starting up. * * $on_chunk gets the audio still in base64, as received. Decoding and * encoding again for the browser would only waste time. * * @param string $text The line to speak. * @param string $language Two-letter language code, or an empty string. * @param string $tone How it should sound. One of self::TONES. * @param callable $on_chunk function( string $base64, array $shape ): void. * @return array|WP_Error {mime, rate, channels, bits, streamed} on success. */ public function speak_stream( string $text, string $language, string $tone, callable $on_chunk ) { if ( ! $this->is_configured() ) { return $this->not_configured(); } $text = $this->prepare( $text ); if ( '' === $text ) { return new WP_Error( 'aisentic_voice_nothing_to_say', __( 'There was nothing to say.', 'aisentic' ), [ 'status' => 422 ] ); } // Data that read() collects. One array, so the cURL callback can // change it by reference. $state = [ 'kind' => null, // True if the answer is a stream, false if not. 'line' => '', // Half a line left over from the last read. 'json' => '', // The body, when the answer was NOT a stream. 'shape' => [], 'chunks' => 0, 'error' => '', ]; $started = microtime( true ); $first = 0.0; $curl = curl_init(); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_init -- wp_remote_post() buffers the whole body; see can_stream(). curl_setopt_array( // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt_array $curl, [ CURLOPT_URL => $this->endpoint() . '/api/v1/audio/speak', CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => false, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 30, // WordPress core filter, the same one wp_remote_post() uses. // Lets a local site with a self-signed certificate work. CURLOPT_SSL_VERIFYPEER => (bool) apply_filters( 'https_ssl_verify', true ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core filter, not ours to rename. CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $this->api_key(), 'Content-Type: application/json', 'Accept: text/event-stream', ], CURLOPT_POSTFIELDS => wp_json_encode( [ 'text' => $text, 'language' => $language, 'tone' => self::clean_tone( $tone ), 'format' => 'mp3', 'stream' => true, ] ), CURLOPT_WRITEFUNCTION => function ( $handle, $data ) use ( &$state, $on_chunk, &$first ) { if ( 0.0 === $first ) { $first = microtime( true ); } return $this->read( $data, $state, $on_chunk ); }, ] ); curl_exec( $curl ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_exec $failed = curl_error( $curl ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_error $code = (int) curl_getinfo( $curl, CURLINFO_RESPONSE_CODE ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_getinfo if ( $this->timer ) { // Four times: // connect time to connect to the provider. Network only. // ttfb time to the provider's first byte. Provider + model. // sound time to our first audio data, which is when sound starts. // whole time to the last byte: the full sentence. $this->timer->add( 'tts.connect', (int) round( ( (float) curl_getinfo( $curl, CURLINFO_CONNECT_TIME ) ) * 1000 ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_getinfo $this->timer->add( 'tts.ttfb', (int) round( ( (float) curl_getinfo( $curl, CURLINFO_STARTTRANSFER_TIME ) ) * 1000 ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_getinfo $this->timer->add( 'tts.sound', $first > 0.0 ? (int) round( ( $first - $started ) * 1000 ) : 0 ); $this->timer->add( 'tts.whole', (int) round( ( microtime( true ) - $started ) * 1000 ) ); $this->timer->note( 'ttsChars', strlen( $text ) ); $this->timer->count( 'ttsStreamCalls' ); } curl_close( $curl ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_close // Not a stream: an older provider sent one full clip (or an error). // Read it as a normal JSON answer. if ( true !== $state['kind'] ) { return $this->whole_clip( $state['json'], $code, $failed ); } if ( '' !== $failed && 0 === $state['chunks'] ) { return new WP_Error( 'aisentic_voice_transport', sprintf( /* translators: 1: step name, 2: underlying error. */ __( 'Could not reach the voice service (%1$s): %2$s', 'aisentic' ), __( 'text to speech', 'aisentic' ), $failed ), [ 'status' => 502 ] ); } if ( '' !== $state['error'] ) { return new WP_Error( 'aisentic_voice_upstream', $state['error'], [ 'status' => 502 ] ); } if ( 0 === $state['chunks'] ) { return new WP_Error( 'aisentic_voice_no_audio', __( 'The voice service sent back no audio.', 'aisentic' ), [ 'status' => 502 ] ); } return array_merge( $state['shape'], [ 'browser' => false, 'supported' => true, 'streamed' => true, 'chunks' => $state['chunks'], 'audio' => '', ] ); } /** * Read one part of the response, as cURL gives it. * * The first part decides the type. A stream starts with "data:". Anything * else is kept as plain JSON. * Why check the body, not a header: an old provider, a proxy that changes * headers, or an error page are all handled correctly. * * @param string $data Bytes from cURL. * @param array $state Reader state, changed in place. * @param callable $on_chunk Called with each piece of audio. * @return int Bytes accepted. Anything else tells cURL to stop. */ private function read( string $data, array &$state, callable $on_chunk ): int { $len = strlen( $data ); if ( null === $state['kind'] ) { $state['kind'] = ( 0 === strpos( ltrim( $data ), 'data:' ) ); } if ( true !== $state['kind'] ) { $state['json'] .= $data; return $len; } $state['line'] .= $data; $cut = strpos( $state['line'], "\n" ); while ( false !== $cut ) { $line = trim( substr( $state['line'], 0, $cut ) ); $state['line'] = substr( $state['line'], $cut + 1 ); // Find the next line break now, before any `continue` below. // Otherwise the loop would read the same line forever. $cut = strpos( $state['line'], "\n" ); if ( 0 !== strpos( $line, 'data:' ) ) { continue; } $event = json_decode( trim( substr( $line, 5 ) ), true ); if ( ! is_array( $event ) ) { continue; } switch ( $event['type'] ?? '' ) { case 'meta': unset( $event['type'] ); $state['shape'] = $event; break; case 'chunk': if ( ! empty( $event['audio'] ) ) { ++$state['chunks']; $on_chunk( (string) $event['audio'], $state['shape'] ); } break; case 'error': $state['error'] = (string) ( $event['message'] ?? __( 'Text to speech failed.', 'aisentic' ) ); break; } } return $len; } /** * Read a plain JSON answer, for a provider that did not stream. * * @param string $body Raw body. * @param int $code HTTP status. * @param string $failed cURL error, empty when there was none. * @return array|WP_Error */ private function whole_clip( string $body, int $code, string $failed ) { if ( 0 === $code ) { return new WP_Error( 'aisentic_voice_transport', sprintf( /* translators: 1: step name, 2: underlying error. */ __( 'Could not reach the voice service (%1$s): %2$s', 'aisentic' ), __( 'text to speech', 'aisentic' ), '' !== $failed ? $failed : __( 'no answer', 'aisentic' ) ), [ 'status' => 502 ] ); } // Build the array shape unwrap() expects, so the status checks // (including "out of credits") are shared, not written twice. $raw = $this->unwrap( [ 'response' => [ 'code' => $code ], 'body' => $body, ], __( 'text to speech', 'aisentic' ) ); if ( is_wp_error( $raw ) ) { return $raw; } return $this->shape_reply( json_decode( (string) $raw, true ) ); } /** * Clean the answer and cut it to the spoken limit. * * Why the cap is in code: speech is about 90% of the cost of a turn, and * the model does not always follow "be short" in the prompt. * * @param string $text Raw answer from the agent. * @return string Text that is safe to read aloud. */ public function prepare( string $text ): string { return $this->cap( $this->clean( $text ) ); } /** * Clean the answer, without cutting it short. * * A streamed answer comes here one piece at a time. The cap (280 * characters by default) is for the whole answer, not each piece, so * cleaning and capping are separate. * * @param string $text Raw answer, or one piece of it. * @return string Text that is safe to read aloud. */ public function clean( string $text ): string { // The tone tag is an instruction to the voice, not words to read out. $text = self::strip_tone( $text ); // Remove links with their label first. wp_strip_all_tags() would keep // the label, so "New Event" from New Event would be read // out. A link is for the browser, not for speech. $text = (string) preg_replace( '/]*>.*?<\/a>/is', ' ', $text ); // Strip anything that would be read out as punctuation noise. $text = wp_strip_all_tags( $text ); $text = preg_replace( '/```.*?```/s', ' ', $text ); // Code fences. $text = preg_replace( '/`([^`]*)`/', '$1', (string) $text ); // Inline code. $text = preg_replace( '/!?\[([^\]]*)\]\([^)]*\)/', '$1', (string) $text ); // Markdown links. $text = preg_replace( '#https?://\S+#', '', (string) $text ); // Bare URLs. $text = preg_replace( '/[*_#>|]+/', ' ', (string) $text ); // Markdown marks. // Remove list markers at the start of a line. // Why: the model writes lists anyway, and "- Winter Vacation" is read // as "dash Winter Vacation". Only at line start, so "November 1 - 2" // keeps its hyphen. $text = preg_replace( '/^[ \t]*(?:[-–—•·]|\d+[.)])[ \t]+/mu', '', (string) $text ); $text = preg_replace( '/\s+/u', ' ', (string) $text ); return trim( (string) $text ); } /** * Cut the text to the spoken limit. * * @param string $text Cleaned text. * @return string */ public function cap( string $text ): string { $max = (int) $this->settings->get( 'voice.maxAnswerChars', 280 ); if ( $max > 0 && mb_strlen( $text ) > $max ) { $text = mb_substr( $text, 0, $max ); // Cut back to the last sentence end. If there is none, cut back to // the last space, so we never stop in the middle of a word. if ( preg_match( '/^(.*[.!?\x{0964}])[^.!?\x{0964}]*$/u', $text, $m ) ) { $text = $m[1]; } elseif ( preg_match( '/^(.*\S)\s+\S*$/su', $text, $m ) ) { $text = (string) preg_replace( '/[\s,;:\-–—]+$/u', '', $m[1] ); } $text = rtrim( $text ); } return $text; } /** * How this answer should be spoken. * * Uses the [[tone: X]] tag if there is one. If not, guesses from the words * (see guess_tone()). * Why: models often skip the tag. In 40 test runs, deepseek-v4-flash wrote * it 30-50% of the time, and gpt-4.1-nano never did. * The guess uses English words, so other languages get 'friendly'. * * @param string $text Raw answer from the agent. * @return string One of self::TONES, never anything else. */ public static function read_tone( string $text ): string { if ( preg_match( self::TONE_TAG, $text, $m ) ) { // Only one of the two groups matched. PHP may leave the other // group out of $m, so use ?? ''. return self::clean_tone( '' !== ( $m[1] ?? '' ) ? $m[1] : ( $m[2] ?? '' ) ); } return self::guess_tone( $text ); } /** * Guess the tone from the words, when the model did not give a tag. * * Order matters: sorry, then excited, then happy, then friendly. * Why: "I can't see your earnings, but I'm opening the dashboard" must * sound sorry, not excited. * * @param string $text Answer text, tag already absent. * @return string One of self::TONES. */ private static function guess_tone( string $text ): string { // Remove links. A label like "Events" must not change the tone. $plain = (string) preg_replace( '/]*>.*?<\/a>/is', ' ', $text ); $plain = mb_strtolower( wp_strip_all_tags( $plain ) ); if ( '' === trim( $plain ) ) { return self::DEFAULT_TONE; } // A refusal is bad news, so it wins. REFUSAL also matches "can only", // because rule 11 of the voice prompt says no with "I can only read // things during a call". if ( preg_match( self::REFUSAL, $plain ) ) { return 'sorry'; } $sorry = [ "i'm sorry", 'i am sorry', 'sorry', "don't have", 'do not have', "didn't find", 'did not find', "couldn't find", 'no results', 'nothing found', 'not found', "there aren't", 'there are no', 'afraid', "i'd love to", ]; foreach ( $sorry as $word ) { if ( false !== mb_strpos( $plain, $word ) ) { return 'sorry'; } } // Opening a page. Rule 2 of the voice prompt asks for "I'm opening…" // or "Taking you to…", so these are easy to find. $excited = [ "i'm opening", 'i am opening', 'opening the', 'opening your', 'taking you', 'heading to', "i'm on it", 'right away', 'here we go', 'let me open', 'bringing up', 'pulling up', ]; foreach ( $excited as $word ) { if ( false !== mb_strpos( $plain, $word ) ) { return 'excited'; } } // A count, a price, a date: something was looked up and found. if ( preg_match( '/\d/u', $plain ) ) { return 'happy'; } $happy = [ 'you have', "you've got", 'you got', 'all set', 'good news', 'already' ]; foreach ( $happy as $word ) { if ( false !== mb_strpos( $plain, $word ) ) { return 'happy'; } } return self::DEFAULT_TONE; } /** * Remove every tone tag from the text. * * Also used on the saved and shown text, so the chat never shows * "[[tone: happy]]" to the admin. * * @param string $text Raw answer from the agent. * @return string */ public static function strip_tone( string $text ): string { $text = (string) preg_replace( self::TONE_TAG, ' ', $text ); return trim( (string) preg_replace( '/[ \t]+/', ' ', $text ) ); } /** * Keep a tone only if we know it. * * @param string $tone Tone name from the model or the caller. * @return string */ public static function clean_tone( string $tone ): string { $tone = strtolower( trim( $tone ) ); return in_array( $tone, self::TONES, true ) ? $tone : self::DEFAULT_TONE; } }