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;
}
}