is_configured() ) { return $this->not_configured(); } if ( ! is_readable( $path ) ) { return new WP_Error( 'aisentic_voice_unreadable', __( 'The audio could not be read.', 'aisentic' ), [ 'status' => 400 ] ); } $read_at = $timer ? $timer->mark() : 0.0; $contents = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local temp file, not a remote fetch. if ( false === $contents ) { return new WP_Error( 'aisentic_voice_unreadable', __( 'The audio could not be read.', 'aisentic' ), [ 'status' => 400 ] ); } if ( $timer ) { $timer->took( 'stt.read', $read_at ); $timer->note( 'clipBytes', strlen( $contents ) ); } // The word list. If it is not cached, this reads the header of every // active plugin from disk. This is the only slow step that is on this // site, not on the network. $hints_at = $timer ? $timer->mark() : 0.0; $hints = $this->hints(); if ( $timer ) { $timer->took( 'stt.hints', $hints_at ); } $boundary = wp_generate_password( 24, false ); $body = $this->multipart_body( $boundary, $filename, $contents, $hints ); $http_at = $timer ? $timer->mark() : 0.0; $response = wp_remote_post( $this->endpoint() . '/api/v1/audio/transcribe', [ 'timeout' => 30, 'headers' => [ 'Authorization' => 'Bearer ' . $this->api_key(), 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, 'Accept' => 'application/json', ], 'body' => $body, ] ); if ( $timer ) { $timer->took( 'stt.http', $http_at ); $timer->at( 'sttDone' ); } $raw = $this->unwrap( $response, __( 'speech to text', 'aisentic' ) ); if ( is_wp_error( $raw ) ) { return $raw; } $data = json_decode( (string) $raw, true ); if ( ! is_array( $data ) || ! isset( $data['text'] ) ) { return new WP_Error( 'aisentic_voice_bad_response', __( 'The voice service sent back nothing usable.', 'aisentic' ), [ 'status' => 502 ] ); } $text = trim( (string) $data['text'] ); if ( '' === $text ) { return new WP_Error( 'aisentic_voice_no_speech', __( 'I did not catch that.', 'aisentic' ), [ 'status' => 422 ] ); } if ( $this->only_a_name_we_supplied( $text, $hints ) ) { return new WP_Error( 'aisentic_voice_no_speech', __( 'I did not catch that.', 'aisentic' ), [ 'status' => 422 ] ); } return [ 'text' => $text, 'language' => $this->normalize_language( (string) ( $data['language'] ?? '' ) ), // A short "please wait" line the provider wrote for THIS question. // Played while the answer is written. Empty when there is none or // it is not usable. Then the caller uses its own lines. 'waiting' => $this->waiting_line( $data ), ]; } /** * The waiting line the provider sent, if it sent one worth speaking. * * Checked here too, not only on the provider. Why: this text is read out * loud, and the model's main job was transcription, not this line. * * @param array $data Decoded provider response. * @return string Empty when there is nothing usable. */ private function waiting_line( array $data ): string { $line = trim( (string) ( $data['waiting'] ?? '' ) ); if ( '' === $line ) { return ''; } // Must be one short line: no line break, at most 60 characters. if ( false !== strpos( $line, "\n" ) || mb_strlen( $line ) > 60 ) { return ''; } return $line; } /** * Check if the text is only one of the hint names we sent. * * If yes, we treat it as "I did not catch that". * Why: when the model cannot hear the speech, it sometimes returns a hint * name. In one call, three turns came back as "FluentCRM", which nobody said. * Downside: saying only "FluentCRM" no longer works. "Open FluentCRM" does. * * @param string $text What came back. * @param string $hints Comma separated list we sent. * @return bool */ private function only_a_name_we_supplied( string $text, string $hints ): bool { if ( '' === $hints ) { return false; } $plain = $this->letters_only( $text ); if ( '' === $plain ) { return false; } foreach ( explode( ',', $hints ) as $name ) { if ( $plain === $this->letters_only( $name ) ) { return true; } } return false; } /** * Reduce text to lowercase letters and numbers. * * So "Fluent CRM.", "fluentcrm" and "FluentCRM" are the same. * * @param string $value Text to squash. * @return string */ private function letters_only( string $value ): string { $value = (string) preg_replace( '/[^\p{L}\p{N}]+/u', '', $value ); return mb_strtolower( $value ); } /** * Build a multipart body by hand. * * Why: WordPress has no multipart helper, and the HTTP API cannot use * CURLFile. * * @param string $boundary Boundary string. * @param string $filename File name to declare. * @param string $contents Raw file bytes. * @param string $hints Comma separated words the model should expect. * @return string */ private function multipart_body( string $boundary, string $filename, string $contents, string $hints = '' ): string { $eol = "\r\n"; $body = ''; // The name comes from the browser. Remove quotes and line breaks so // it cannot break the header line. $filename = str_replace( [ '"', "\r", "\n" ], '', sanitize_file_name( $filename ) ); if ( '' === $filename ) { $filename = 'clip.webm'; } if ( '' !== $hints ) { $body .= '--' . $boundary . $eol; $body .= 'Content-Disposition: form-data; name="hints"' . $eol . $eol; $body .= $hints . $eol; } $body .= '--' . $boundary . $eol; $body .= 'Content-Disposition: form-data; name="file"; filename="' . $filename . '"' . $eol; $body .= 'Content-Type: application/octet-stream' . $eol . $eol; $body .= $contents . $eol; $body .= '--' . $boundary . '--' . $eol; return $body; } /** * Words from this site that the speech model should expect. * * Active plugin names plus the site name, at most 25. * Why: the audio model hears sound, not meaning. Without hints, "Eventin" * can come back as "return". * * Add-ons are left out. Nobody says "Eventin Addon For LearnDash", and * each extra name is one more name the model may return by mistake. * See only_a_name_we_supplied(). * * @return string Comma separated list, empty when nothing is worth sending. */ private function hints(): string { // The "_2" in the key: change it when the rules below change, so the // old cached list is not used for up to an hour. $cached = get_transient( 'aisentic_voice_stt_hints_2' ); if ( is_string( $cached ) ) { return $cached; } if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $words = []; foreach ( (array) get_option( 'active_plugins', [] ) as $file ) { $data = get_plugin_data( WP_PLUGIN_DIR . '/' . $file, false, false ); $name = trim( (string) ( $data['Name'] ?? '' ) ); // Drop the marketing tail: "Eventin - Event Manager" -> "Eventin". $name = trim( (string) preg_split( '/\s[-–|:]\s/u', $name )[0] ); // Skip add-ons (names with "addon", "for", "extension", "integration"). if ( preg_match( '/\b(add-?ons?|for|extension|integration)\b/i', $name ) ) { continue; } if ( '' !== $name && strlen( $name ) < 40 ) { $words[] = $name; } } $words[] = get_bloginfo( 'name' ); $words = array_slice( array_unique( array_filter( $words ) ), 0, 25 ); $hints = implode( ', ', $words ); set_transient( 'aisentic_voice_stt_hints_2', $hints, HOUR_IN_SECONDS ); return $hints; } /** * Reduce whatever the model returned to a short language code. * * Models return 'en', 'en-US', 'English' or nothing. Anything else becomes * an empty string, which means "let the model decide". * * @param string $value Raw language value. * @return string Two-letter code, or an empty string. */ private function normalize_language( string $value ): string { $value = strtolower( trim( $value ) ); if ( '' === $value ) { return ''; } $names = [ 'english' => 'en', 'bengali' => 'bn', 'bangla' => 'bn', 'hindi' => 'hi', 'arabic' => 'ar', 'spanish' => 'es', 'french' => 'fr', 'german' => 'de', ]; if ( isset( $names[ $value ] ) ) { return $names[ $value ]; } if ( preg_match( '/^([a-z]{2})(?:[-_][a-z]{2,4})?$/i', $value, $m ) ) { return strtolower( $m[1] ); } return ''; } }