tts = $tts; $this->language = $language; $this->tone = Text_To_Speech::clean_tone( $tone ); $this->max = $max; } /** * Set the feeling the model asked for. * * The model writes the tone tag first, so it is usually set before the * first piece. If not, the first piece uses the tone from the constructor, * and the rest use the tag. * * @param string $tone Tone name from the model. * @return void */ public function set_tone( string $tone ): void { $this->tone = Text_To_Speech::clean_tone( $tone ); } /** * Speak one piece. * * Without $emit: waits for the full clip and returns one event. * With $emit: streams the audio as it is made, so the listener hears the * first word after about 0.5 s. See Text_To_Speech::speak_stream(). * * @param string $piece Raw text from the model. * @param callable|null $emit function( array $event ): void. * @return array|bool|WP_Error|null An event payload when $emit is null. * True when something was emitted, null * when there was nothing to say. */ public function say( string $piece, ?callable $emit = null ) { $text = $this->fit( $this->tts->clean( $piece ) ); if ( '' === $text ) { return null; } $this->used += mb_strlen( $text ); $index = $this->index++; // The browser speaks, or there is no voice for this language. Send // only the words, so the chat still shows them. if ( true === $this->browser || ! $this->supported ) { return $this->give( $this->event( $index, $text, [] ), $emit ); } if ( null !== $emit && $this->tts->can_stream() ) { return $this->stream( $index, $text, $emit ); } $audio = $this->tts->speak( $text, $this->language, $this->tone ); if ( is_wp_error( $audio ) ) { return $audio; } $this->remember( $audio ); return $this->give( $this->event( $index, $text, $audio ), $emit ); } /** * Speak one piece, sending the sound out as it is made. * * The 'say' event is sent only when the first audio chunk arrives * (about 0.5 s). * Why: the reply may not be a stream. A browser engine (Kokoro) sends no * audio, and an older provider sends one full clip. Both need a plain * 'say' event. If the browser was told "audio is coming", it would wait * forever. * * @param int $index Order of this piece, starting at 0. * @param string $text The words. * @param callable $emit function( array $event ): void. * @return bool|WP_Error */ private function stream( int $index, string $text, callable $emit ) { $opened = false; $audio = $this->tts->speak_stream( $text, $this->language, $this->tone, function ( string $chunk, array $shape ) use ( &$opened, $emit, $index, $text ): void { if ( ! $opened ) { $opened = true; $emit( array_merge( $this->event( $index, $text, $shape ), [ 'streaming' => true, 'audio' => '', ] ) ); } $emit( [ 'type' => 'audio', 'index' => $index, 'chunk' => $chunk, ] ); } ); if ( is_wp_error( $audio ) ) { // Nothing was sent yet. Return the error, so the caller can skip // this piece and go on to the next one. if ( ! $opened ) { return $audio; } // Some audio was already sent. Send 'audioEnd' so the browser plays // what it has and does not wait. $emit( [ 'type' => 'audioEnd', 'index' => $index, ] ); return true; } $this->remember( $audio ); // Not a stream: a browser engine, or a provider that only sends full // clips. Send one plain 'say' event. if ( ! $opened ) { return $this->give( $this->event( $index, $text, $audio ), $emit ); } $emit( [ 'type' => 'audioEnd', 'index' => $index, ] ); return true; } /** * Remember what the engine said about itself. * * If the browser speaks, or there is no voice for this language, later * pieces skip the speech request. * * @param array $audio What the speech client returned. * @return void */ private function remember( array $audio ): void { $this->browser = ! empty( $audio['browser'] ); $this->supported = ! isset( $audio['supported'] ) || (bool) $audio['supported']; } /** * Hand one finished event to the caller, whichever way it asked. * * @param array $event The event payload. * @param callable|null $emit function( array $event ): void. * @return array|bool */ private function give( array $event, ?callable $emit ) { if ( null === $emit ) { return $event; } $emit( $event ); return true; } /** * Cut a piece down to what is left of the spoken limit. * * @param string $text Cleaned text. * @return string Empty when the limit is already used up. */ private function fit( string $text ): string { if ( '' === $text || $this->max <= 0 ) { return $text; } $left = $this->max - $this->used; if ( $left <= 0 ) { return ''; } if ( mb_strlen( $text ) <= $left ) { return $text; } $text = mb_substr( $text, 0, $left ); $space = mb_strrpos( $text, ' ' ); // Never stop in the middle of a word. return false !== $space ? rtrim( mb_substr( $text, 0, $space ) ) : ''; } /** * Shape one piece into the payload the browser receives. * * @param int $index Order of this piece, starting at 0. * @param string $text The words. * @param array $audio What the speech client returned, possibly empty. * @return array */ private function event( int $index, string $text, array $audio ): array { $event = [ 'type' => 'say', 'index' => $index, 'text' => $text, 'audio' => (string) ( $audio['audio'] ?? '' ), 'mime' => (string) ( $audio['mime'] ?? '' ), 'speakHere' => true === $this->browser, 'canSpeak' => $this->supported, 'voiceDriver' => (string) ( $audio['driver'] ?? '' ), ]; // Streamed audio is raw PCM with no header. So tell the browser how to // read it: samples per second, channels, bits per sample. if ( isset( $audio['rate'] ) ) { $event['rate'] = (int) $audio['rate']; $event['channels'] = (int) ( $audio['channels'] ?? 1 ); $event['bits'] = (int) ( $audio['bits'] ?? 16 ); } return $event; } }