min = $min;
$this->max = $max;
}
/**
* Add streamed text and take out whatever is ready to speak.
*
* @param string $text Newly streamed text.
* @return string[] Zero or more pieces, in order.
*/
public function add( string $text ): array {
$this->buffer .= $text;
$pieces = [];
while ( true ) {
$piece = $this->take();
if ( null === $piece ) {
break;
}
$pieces[] = $piece;
}
return $pieces;
}
/**
* Look at what is waiting, without taking it.
*
* Used when the model stops to call a tool. The caller decides if the
* text so far is long enough to speak.
*
* @return string
*/
public function peek(): string {
return trim( $this->buffer );
}
/**
* Take everything that is left, finished or not.
*
* Called once the model has stopped writing.
*
* @return string May be empty.
*/
public function rest(): string {
$rest = trim( $this->buffer );
$this->buffer = '';
return $rest;
}
/**
* Throw away everything waiting, without speaking it.
*
* Used when an answer was a guess and is thrown away.
* Why: the last sentence stays in the buffer until rest(). Without this,
* the end of the deleted guess ("Want me to narrow that down?") was
* spoken later in the turn.
* rest() also empties the buffer, but this name says what we mean.
*
* @return void
*/
public function forget(): void {
$this->buffer = '';
}
/**
* Pull one finished piece off the front of the buffer.
*
* @return string|null Null while there is nothing ready.
*/
private function take(): ?string {
$limit = $this->settled();
if ( $limit < $this->min ) {
return null;
}
$region = mb_substr( $this->buffer, 0, $limit );
// A full stop, ?, ! or Bangla danda (ред), followed by a space.
// The space keeps "9.30" and "wp-admin.php" in one piece. It also
// shows the sentence really ended, and the stream did not just pause
// inside a number. A sentence end at the very end of the buffer waits.
$pattern = '/^.{' . $this->min . ',}?[.!?\x{0964}](?=\s)/us';
if ( preg_match( $pattern, $region, $m ) ) {
return $this->cut( mb_strlen( $m[0] ) );
}
// No sentence end in sight. Cut at the last space instead.
if ( $limit >= $this->max ) {
$head = mb_substr( $this->buffer, 0, $this->max );
$space = mb_strrpos( $head, ' ' );
if ( false !== $space && $space >= $this->min ) {
return $this->cut( $space );
}
}
return null;
}
/**
* Remove the first $length characters from the buffer and return them.
*
* @param int $length Characters to take.
* @return string
*/
private function cut( int $length ): string {
$piece = mb_substr( $this->buffer, 0, $length );
$this->buffer = ltrim( mb_substr( $this->buffer, $length ) );
return trim( $piece );
}
/**
* How much of the buffer is safe to cut, in characters.
*
* Stops before any HTML tag or link that is not finished yet.
* Why: a link like `Open page` comes in several chunks.
* A cut inside it would make "Open page" be read out as a sentence.
*
* @return int
*/
private function settled(): int {
$limit = mb_strlen( $this->buffer );
// An opening tag with no ">" yet.
$angle = mb_strrpos( $this->buffer, '<' );
if ( false !== $angle && false === mb_strpos( mb_substr( $this->buffer, $angle ), '>' ) ) {
$limit = min( $limit, $angle );
}
// A link that has not been closed yet.
$link = mb_strrpos( $this->buffer, 'buffer, $link ), '' ) ) {
$limit = min( $limit, $link );
}
return $limit;
}
}