Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add text to speech stream support #282

Merged
merged 2 commits into from
Dec 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,22 @@ $client->audio()->speech([
]); // audio file content as string
```

#### `speechStreamed`

Generates streamed audio from the input text.

```php
$stream = $client->audio()->speechStreamed([
'model' => 'tts-1',
'input' => 'The quick brown fox jumped over the lazy dog.',
'voice' => 'alloy',
]);

foreach($stream as $chunk){
$chunk; // chunk of audio file content as string
}
```

#### `transcribe`

Transcribes audio into the input language.
Expand Down
10 changes: 10 additions & 0 deletions src/Contracts/Resources/AudioContract.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace OpenAI\Contracts\Resources;

use OpenAI\Responses\Audio\SpeechStreamResponse;
use OpenAI\Responses\Audio\TranscriptionResponse;
use OpenAI\Responses\Audio\TranslationResponse;

Expand All @@ -16,6 +17,15 @@ interface AudioContract
*/
public function speech(array $parameters): string;

/**
* Generates streamed audio from the input text.
*
* @see https://platform.openai.com/docs/api-reference/audio/createSpeech
*
* @param array<string, mixed> $parameters
*/
public function speechStreamed(array $parameters): SpeechStreamResponse;

/**
* Transcribes audio into the input language.
*
Expand Down
18 changes: 18 additions & 0 deletions src/Contracts/ResponseStreamContract.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace OpenAI\Contracts;

use IteratorAggregate;

/**
* @template T
*
* @extends IteratorAggregate<int, T>
*
* @internal
*/
interface ResponseStreamContract extends IteratorAggregate
{
}
17 changes: 17 additions & 0 deletions src/Resources/Audio.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace OpenAI\Resources;

use OpenAI\Contracts\Resources\AudioContract;
use OpenAI\Responses\Audio\SpeechStreamResponse;
use OpenAI\Responses\Audio\TranscriptionResponse;
use OpenAI\Responses\Audio\TranslationResponse;
use OpenAI\ValueObjects\Transporter\Payload;
Expand All @@ -28,6 +29,22 @@ public function speech(array $parameters): string
return $this->transporter->requestContent($payload);
}

/**
* Generates streamed audio from the input text.
*
* @see https://platform.openai.com/docs/api-reference/audio/createSpeech
*
* @param array<string, mixed> $parameters
*/
public function speechStreamed(array $parameters): SpeechStreamResponse
{
$payload = Payload::create('audio/speech', $parameters);

$response = $this->transporter->requestStream($payload);

return new SpeechStreamResponse($response);
}

/**
* Transcribes audio into the input language.
*
Expand Down
53 changes: 53 additions & 0 deletions src/Responses/Audio/SpeechStreamResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

namespace OpenAI\Responses\Audio;

use Generator;
use Http\Discovery\Psr17Factory;
use OpenAI\Contracts\ResponseHasMetaInformationContract;
use OpenAI\Contracts\ResponseStreamContract;
use OpenAI\Responses\Meta\MetaInformation;
use Psr\Http\Message\ResponseInterface;

/**
* @implements ResponseStreamContract<string>
*/
final class SpeechStreamResponse implements ResponseHasMetaInformationContract, ResponseStreamContract
{
public function __construct(
private readonly ResponseInterface $response,
) {
//
}

/**
* {@inheritDoc}
*/
public function getIterator(): Generator
{
while (! $this->response->getBody()->eof()) {
yield $this->response->getBody()->read(1024);
}
}

public function meta(): MetaInformation
{
// @phpstan-ignore-next-line
return MetaInformation::from($this->response->getHeaders());
}

public static function fake(string $content = null, MetaInformation $meta = null): static
{
$psr17Factory = new Psr17Factory();
$response = $psr17Factory->createResponse()
->withBody($psr17Factory->createStream($content ?? (string) file_get_contents(__DIR__.'/../../Testing/Responses/Fixtures/Audio/speech-streamed.mp3')));

if ($meta instanceof \OpenAI\Responses\Meta\MetaInformation) {
foreach ($meta->toArray() as $key => $value) {
$response = $response->withHeader($key, (string) $value);
}
}

return new self($response);
}
}
6 changes: 3 additions & 3 deletions src/Responses/StreamResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
namespace OpenAI\Responses;

use Generator;
use IteratorAggregate;
use OpenAI\Contracts\ResponseHasMetaInformationContract;
use OpenAI\Contracts\ResponseStreamContract;
use OpenAI\Exceptions\ErrorException;
use OpenAI\Responses\Meta\MetaInformation;
use Psr\Http\Message\ResponseInterface;
Expand All @@ -13,9 +13,9 @@
/**
* @template TResponse
*
* @implements IteratorAggregate<int, TResponse>
* @implements ResponseStreamContract<TResponse>
*/
final class StreamResponse implements IteratorAggregate, ResponseHasMetaInformationContract
final class StreamResponse implements ResponseHasMetaInformationContract, ResponseStreamContract
{
/**
* Creates a new Stream Response instance.
Expand Down
3 changes: 2 additions & 1 deletion src/Testing/ClientFake.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use OpenAI\Contracts\ClientContract;
use OpenAI\Contracts\ResponseContract;
use OpenAI\Contracts\ResponseStreamContract;
use OpenAI\Responses\StreamResponse;
use OpenAI\Testing\Requests\TestRequest;
use OpenAI\Testing\Resources\AssistantsTestResource;
Expand Down Expand Up @@ -113,7 +114,7 @@ private function resourcesOf(string $type): array
return array_filter($this->requests, fn (TestRequest $request): bool => $request->resource() === $type);
}

public function record(TestRequest $request): ResponseContract|StreamResponse|string
public function record(TestRequest $request): ResponseContract|ResponseStreamContract|string
{
$this->requests[] = $request;

Expand Down
6 changes: 6 additions & 0 deletions src/Testing/Resources/AudioTestResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use OpenAI\Contracts\Resources\AudioContract;
use OpenAI\Resources\Audio;
use OpenAI\Responses\Audio\SpeechStreamResponse;
use OpenAI\Responses\Audio\TranscriptionResponse;
use OpenAI\Responses\Audio\TranslationResponse;
use OpenAI\Testing\Resources\Concerns\Testable;
Expand All @@ -22,6 +23,11 @@ public function speech(array $parameters): string
return $this->record(__FUNCTION__, func_get_args());
}

public function speechStreamed(array $parameters): SpeechStreamResponse
{
return $this->record(__FUNCTION__, func_get_args());
}

public function transcribe(array $parameters): TranscriptionResponse
{
return $this->record(__FUNCTION__, func_get_args());
Expand Down
4 changes: 2 additions & 2 deletions src/Testing/Resources/Concerns/Testable.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
namespace OpenAI\Testing\Resources\Concerns;

use OpenAI\Contracts\ResponseContract;
use OpenAI\Responses\StreamResponse;
use OpenAI\Contracts\ResponseStreamContract;
use OpenAI\Testing\ClientFake;
use OpenAI\Testing\Requests\TestRequest;

Expand All @@ -18,7 +18,7 @@ abstract protected function resource(): string;
/**
* @param array<string, mixed> $args
*/
protected function record(string $method, array $args = []): ResponseContract|StreamResponse|string
protected function record(string $method, array $args = []): ResponseContract|ResponseStreamContract|string
{
return $this->fake->record(new TestRequest($this->resource(), $method, $args));
}
Expand Down
Binary file not shown.
1 change: 1 addition & 0 deletions tests/Arch.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
]);

test('responses')->expect('OpenAI\Responses')->toOnlyUse([
'Http\Discovery\Psr17Factory',
'OpenAI\Enums',
'OpenAI\Exceptions\ErrorException',
'OpenAI\Contracts',
Expand Down
8 changes: 8 additions & 0 deletions tests/Fixtures/Audio.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,11 @@ function audioFileContent(): string
{
return file_get_contents(__DIR__.'/audio.mp3');
}

/**
* @return resource
*/
function speechStream()
{
return fopen(__DIR__.'/Streams/Speech.mp3', 'r');
}
Binary file added tests/Fixtures/Streams/Speech.mp3
Binary file not shown.
29 changes: 29 additions & 0 deletions tests/Resources/Audio.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<?php

use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Stream;
use OpenAI\Responses\Audio\SpeechStreamResponse;
use OpenAI\Responses\Audio\TranscriptionResponse;
use OpenAI\Responses\Audio\TranscriptionResponseSegment;
use OpenAI\Responses\Audio\TranslationResponse;
Expand Down Expand Up @@ -209,3 +212,29 @@
->toBeString()
->toBe(audioFileContent());
});

test('text to speech streamed', function () {
$response = new Response(
body: new Stream(speechStream()),
headers: metaHeaders(),
);

$client = mockStreamClient('POST', 'audio/speech', [
'model' => 'tts-1',
'input' => 'Hello, how are you?',
'voice' => 'alloy',
], $response, 'requestContent');

$result = $client->audio()->speechStreamed([
'model' => 'tts-1',
'input' => 'Hello, how are you?',
'voice' => 'alloy',
]);

expect($result)
->toBeInstanceOf(SpeechStreamResponse::class)
->toBeInstanceOf(IteratorAggregate::class);

expect($result->getIterator())
->toBeInstanceOf(Iterator::class);
});
36 changes: 36 additions & 0 deletions tests/Responses/Audio/SpeechStreamResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Stream;
use OpenAI\Responses\Audio\SpeechStreamResponse;
use OpenAI\Responses\Meta\MetaInformation;

test('from response', function () {
$response = new Response(
body: new Stream(speechStream()),
headers: metaHeaders(),
);

$speech = new SpeechStreamResponse($response);

expect($speech)
->toBeInstanceOf(SpeechStreamResponse::class)
->getIterator()->toBeInstanceOf(Iterator::class)
->meta()->toBeInstanceOf(MetaInformation::class);
});

test('fake', function () {
$response = SpeechStreamResponse::fake();

foreach ($response as $chunk) {
expect($chunk)->toBeString();
}
});

test('fake with override', function () {
$response = SpeechStreamResponse::fake('fake audio');

foreach ($response as $chunk) {
expect($chunk)->toBe('fake audio');
}
});
22 changes: 22 additions & 0 deletions tests/Testing/Resources/AudioTestResource.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php

use OpenAI\Resources\Audio;
use OpenAI\Responses\Audio\SpeechStreamResponse;
use OpenAI\Responses\Audio\TranscriptionResponse;
use OpenAI\Responses\Audio\TranslationResponse;
use OpenAI\Testing\ClientFake;
Expand All @@ -26,6 +27,27 @@
});
});

it('records a streamed speech request', function () {
$fake = new ClientFake([
SpeechStreamResponse::fake(),
]);

$fake->audio()->speechStreamed([
'model' => 'tts-1',
'input' => 'Hello, how are you?',
'voice' => 'alloy',
]);

$fake->assertSent(Audio::class, function ($method, $parameters) {
return $method === 'speechStreamed' &&
$parameters === [
'model' => 'tts-1',
'input' => 'Hello, how are you?',
'voice' => 'alloy',
];
});
});

it('records an audio transcription request', function () {
$fake = new ClientFake([
TranscriptionResponse::fake(),
Expand Down
Loading