Skip to content

Commit d3ac578

Browse files
committed
feat(platform): ElevenLabs stream for TTS
1 parent 14945ab commit d3ac578

File tree

5 files changed

+108
-8
lines changed

5 files changed

+108
-8
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabs;
13+
use Symfony\AI\Platform\Bridge\ElevenLabs\PlatformFactory;
14+
use Symfony\AI\Platform\Message\Content\Text;
15+
use Symfony\Contracts\HttpClient\ChunkInterface;
16+
17+
require_once dirname(__DIR__).'/bootstrap.php';
18+
19+
$platform = PlatformFactory::create(
20+
apiKey: env('ELEVEN_LABS_API_KEY'),
21+
httpClient: http_client(),
22+
);
23+
$model = new ElevenLabs(options: [
24+
'voice' => 'Dslrhjl3ZpzrctukrQSN', // Brad (https://elevenlabs.io/app/voice-library?voiceId=Dslrhjl3ZpzrctukrQSN)
25+
'stream' => true,
26+
]);
27+
28+
$result = $platform->invoke($model, new Text('The first move is what sets everything in motion.'));
29+
30+
$content = implode('', array_map(
31+
static fn (ChunkInterface $chunk): string => $chunk->getContent(),
32+
iterator_to_array($result->asStream()),
33+
));
34+
35+
echo $content.\PHP_EOL;

src/platform/src/Bridge/ElevenLabs/ElevenLabsClient.php

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public function request(Model $model, array|string $payload, array $options = []
4242
}
4343

4444
if (\in_array($model->getName(), [ElevenLabs::SCRIBE_V1, ElevenLabs::SCRIBE_V1_EXPERIMENTAL], true)) {
45-
return $this->doSpeechToTextRequest($model, $payload, $options);
45+
return $this->doSpeechToTextRequest($model, $payload);
4646
}
4747

4848
$capabilities = $this->retrieveCapabilities($model);
@@ -56,9 +56,8 @@ public function request(Model $model, array|string $payload, array $options = []
5656

5757
/**
5858
* @param array<string|int, mixed> $payload
59-
* @param array<string, mixed> $options
6059
*/
61-
private function doSpeechToTextRequest(Model $model, array|string $payload, array $options): RawHttpResult
60+
private function doSpeechToTextRequest(Model $model, array|string $payload): RawHttpResult
6261
{
6362
return new RawHttpResult($this->httpClient->request('POST', \sprintf('%s/speech-to-text', $this->hostUrl), [
6463
'headers' => [
@@ -86,6 +85,21 @@ private function doTextToSpeechRequest(Model $model, array|string $payload, arra
8685
}
8786

8887
$voice = $options['voice'] ??= $model->getOptions()['voice'];
88+
$stream = $options['stream'] ??= $model->getOptions()['stream'] ?? false;
89+
90+
if ($stream) {
91+
$streamSource = $this->httpClient->request('POST', \sprintf('%s/text-to-speech/%s/stream', $this->hostUrl, $voice), [
92+
'headers' => [
93+
'xi-api-key' => $this->apiKey,
94+
],
95+
'json' => [
96+
'text' => $payload['text'],
97+
'model_id' => $model->getName(),
98+
],
99+
]);
100+
101+
return new RawHttpResult($this->httpClient->stream($streamSource));
102+
}
89103

90104
return new RawHttpResult($this->httpClient->request('POST', \sprintf('%s/text-to-speech/%s', $this->hostUrl, $voice), [
91105
'headers' => [

src/platform/src/Bridge/ElevenLabs/ElevenLabsResultConverter.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616
use Symfony\AI\Platform\Result\BinaryResult;
1717
use Symfony\AI\Platform\Result\RawResultInterface;
1818
use Symfony\AI\Platform\Result\ResultInterface;
19+
use Symfony\AI\Platform\Result\StreamResult;
1920
use Symfony\AI\Platform\Result\TextResult;
2021
use Symfony\AI\Platform\ResultConverterInterface;
2122
use Symfony\Contracts\HttpClient\ResponseInterface;
23+
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
2224

2325
/**
2426
* @author Guillaume Loulier <[email protected]>
@@ -32,10 +34,23 @@ public function supports(Model $model): bool
3234

3335
public function convert(RawResultInterface $result, array $options = []): ResultInterface
3436
{
35-
/** @var ResponseInterface $response */
37+
/** @var ResponseInterface|ResponseStreamInterface $response */
3638
$response = $result->getObject();
3739

3840
return match (true) {
41+
$response instanceof ResponseStreamInterface => new StreamResult((static function () use ($response): \Generator {
42+
foreach ($response as $chunk) {
43+
if ($chunk->isFirst()) {
44+
continue;
45+
}
46+
47+
if ('' === $chunk->getContent()) {
48+
continue;
49+
}
50+
51+
yield $chunk;
52+
}
53+
})()),
3954
str_contains($response->getInfo('url'), 'speech-to-text') => new TextResult($result->getData()['text']),
4055
str_contains($response->getInfo('url'), 'text-to-speech') => new BinaryResult($result->getObject()->getContent(), 'audio/mpeg'),
4156
default => throw new RuntimeException('Unsupported ElevenLabs response.'),

src/platform/src/Result/RawHttpResult.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,28 @@
1212
namespace Symfony\AI\Platform\Result;
1313

1414
use Symfony\Contracts\HttpClient\ResponseInterface;
15+
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
1516

1617
/**
1718
* @author Christopher Hertel <[email protected]
1819
*/
1920
final readonly class RawHttpResult implements RawResultInterface
2021
{
2122
public function __construct(
22-
private ResponseInterface $response,
23+
private ResponseInterface|ResponseStreamInterface $response,
2324
) {
2425
}
2526

2627
public function getData(): array
2728
{
29+
if ($this->response instanceof ResponseStreamInterface) {
30+
return [];
31+
}
32+
2833
return $this->response->toArray(false);
2934
}
3035

31-
public function getObject(): ResponseInterface
36+
public function getObject(): ResponseInterface|ResponseStreamInterface
3237
{
3338
return $this->response;
3439
}

src/platform/tests/Bridge/ElevenLabs/ElevenLabsClientTest.php

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
namespace Symfony\AI\Platform\Tests\Bridge\ElevenLabs;
1313

1414
use PHPUnit\Framework\Attributes\CoversClass;
15-
use PHPUnit\Framework\Attributes\Group;
1615
use PHPUnit\Framework\Attributes\UsesClass;
1716
use PHPUnit\Framework\TestCase;
1817
use Symfony\AI\Platform\Bridge\ElevenLabs\Contract\AudioNormalizer;
@@ -21,6 +20,7 @@
2120
use Symfony\AI\Platform\Exception\InvalidArgumentException;
2221
use Symfony\AI\Platform\Message\Content\Audio;
2322
use Symfony\AI\Platform\Model;
23+
use Symfony\AI\Platform\Result\RawHttpResult;
2424
use Symfony\Component\HttpClient\MockHttpClient;
2525
use Symfony\Component\HttpClient\Response\JsonMockResponse;
2626
use Symfony\Component\HttpClient\Response\MockResponse;
@@ -30,6 +30,7 @@
3030
#[UsesClass(Model::class)]
3131
#[UsesClass(Audio::class)]
3232
#[UsesClass(AudioNormalizer::class)]
33+
#[UsesClass(RawHttpStreamResult::class)]
3334
final class ElevenLabsClientTest extends TestCase
3435
{
3536
public function testSupportsModel()
@@ -133,7 +134,6 @@ public function testClientCannotPerformTextToSpeechRequestWithoutValidPayload()
133134
]), []);
134135
}
135136

136-
#[Group('foo')]
137137
public function testClientCanPerformTextToSpeechRequest()
138138
{
139139
$payload = Audio::fromFile(\dirname(__DIR__, 5).'/fixtures/audio.mp3');
@@ -162,4 +162,35 @@ public function testClientCanPerformTextToSpeechRequest()
162162

163163
$this->assertSame(2, $httpClient->getRequestsCount());
164164
}
165+
166+
public function testClientCanPerformTextToSpeechRequestAsStream()
167+
{
168+
$payload = Audio::fromFile(\dirname(__DIR__, 5).'/fixtures/audio.mp3');
169+
170+
$httpClient = new MockHttpClient([
171+
new JsonMockResponse([
172+
[
173+
'model_id' => ElevenLabs::ELEVEN_MULTILINGUAL_V2,
174+
'can_do_text_to_speech' => true,
175+
],
176+
]),
177+
new MockResponse($payload->asBinary()),
178+
]);
179+
180+
$client = new ElevenLabsClient(
181+
$httpClient,
182+
'https://api.elevenlabs.io/v1',
183+
'my-api-key',
184+
);
185+
186+
$result = $client->request(new ElevenLabs(options: [
187+
'voice' => 'Dslrhjl3ZpzrctukrQSN',
188+
'stream' => true,
189+
]), [
190+
'text' => 'foo',
191+
]);
192+
193+
$this->assertInstanceOf(RawHttpResult::class, $result);
194+
$this->assertSame(2, $httpClient->getRequestsCount());
195+
}
165196
}

0 commit comments

Comments
 (0)