From 21168a74705d859bf7126ae14e82204ce303697c Mon Sep 17 00:00:00 2001 From: Guillaume Loulier Date: Fri, 8 Aug 2025 10:53:01 +0200 Subject: [PATCH 1/2] feat(platform): ElevenLabs platform --- examples/.env | 4 + examples/.gitignore | 1 + examples/composer.json | 1 + examples/elevenlabs/speech-to-text.php | 28 +++ examples/elevenlabs/text-to-speech.php | 29 +++ src/ai-bundle/config/options.php | 7 + src/ai-bundle/doc/index.rst | 12 +- src/ai-bundle/src/AiBundle.php | 21 +++ .../DependencyInjection/AiBundleTest.php | 5 + src/platform/composer.json | 3 +- .../ElevenLabs/Contract/AudioNormalizer.php | 58 ++++++ .../Contract/ElevenLabsContract.php | 29 +++ .../src/Bridge/ElevenLabs/ElevenLabs.php | 40 +++++ .../Bridge/ElevenLabs/ElevenLabsClient.php | 122 +++++++++++++ .../ElevenLabs/ElevenLabsResultConverter.php | 65 +++++++ .../src/Bridge/ElevenLabs/PlatformFactory.php | 40 +++++ src/platform/src/Capability.php | 4 + src/platform/src/Message/Content/File.php | 7 +- .../Contract/ElevenLabsContractTest.php | 43 +++++ .../ElevenLabs/ElevenLabsClientTest.php | 165 ++++++++++++++++++ .../ElevenLabs/ElevenLabsConverterTest.php | 78 +++++++++ .../Message/Content/AudioNormalizerTest.php | 2 +- 22 files changed, 760 insertions(+), 4 deletions(-) create mode 100644 examples/elevenlabs/speech-to-text.php create mode 100644 examples/elevenlabs/text-to-speech.php create mode 100644 src/platform/src/Bridge/ElevenLabs/Contract/AudioNormalizer.php create mode 100644 src/platform/src/Bridge/ElevenLabs/Contract/ElevenLabsContract.php create mode 100644 src/platform/src/Bridge/ElevenLabs/ElevenLabs.php create mode 100644 src/platform/src/Bridge/ElevenLabs/ElevenLabsClient.php create mode 100644 src/platform/src/Bridge/ElevenLabs/ElevenLabsResultConverter.php create mode 100644 src/platform/src/Bridge/ElevenLabs/PlatformFactory.php create mode 100644 src/platform/tests/Bridge/ElevenLabs/Contract/ElevenLabsContractTest.php create mode 100644 src/platform/tests/Bridge/ElevenLabs/ElevenLabsClientTest.php create mode 100644 src/platform/tests/Bridge/ElevenLabs/ElevenLabsConverterTest.php diff --git a/examples/.env b/examples/.env index e25d79d0a..132a950fb 100644 --- a/examples/.env +++ b/examples/.env @@ -43,6 +43,10 @@ HUGGINGFACE_KEY= # For using OpenRouter OPENROUTER_KEY= +# For using ElevenLabs +ELEVEN_LABS_URL=https://api.elevenlabs.io/v1 +ELEVEN_LABS_API_KEY= + # For using SerpApi (tool) SERP_API_KEY= diff --git a/examples/.gitignore b/examples/.gitignore index e102f9fd5..b24caa75a 100644 --- a/examples/.gitignore +++ b/examples/.gitignore @@ -2,3 +2,4 @@ .transformers-cache composer.lock vendor +tmp diff --git a/examples/composer.json b/examples/composer.json index 436468351..9b6c11b4e 100644 --- a/examples/composer.json +++ b/examples/composer.json @@ -33,6 +33,7 @@ "config": { "allow-plugins": { "codewithkyrian/platform-package-installer": true, + "codewithkyrian/transformers-libsloader": true, "php-http/discovery": true }, "sort-packages": true diff --git a/examples/elevenlabs/speech-to-text.php b/examples/elevenlabs/speech-to-text.php new file mode 100644 index 000000000..0d27d33d3 --- /dev/null +++ b/examples/elevenlabs/speech-to-text.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabs; +use Symfony\AI\Platform\Bridge\ElevenLabs\PlatformFactory; +use Symfony\AI\Platform\Message\Content\Audio; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create( + apiKey: env('ELEVEN_LABS_API_KEY'), + outputPath: __DIR__.'/tmp', + httpClient: http_client() +); +$model = new ElevenLabs(ElevenLabs::SCRIBE_V1); +$file = Audio::fromFile(dirname(__DIR__, 2).'/fixtures/audio.mp3'); + +$result = $platform->invoke($model, $file); + +echo $result->asText().\PHP_EOL; diff --git a/examples/elevenlabs/text-to-speech.php b/examples/elevenlabs/text-to-speech.php new file mode 100644 index 000000000..775908ee5 --- /dev/null +++ b/examples/elevenlabs/text-to-speech.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabs; +use Symfony\AI\Platform\Bridge\ElevenLabs\PlatformFactory; +use Symfony\AI\Platform\Message\Content\Text; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create( + apiKey: env('ELEVEN_LABS_API_KEY'), + outputPath: dirname(__DIR__).'/tmp', + httpClient: http_client(), +); +$model = new ElevenLabs(options: [ + 'voice' => 'Dslrhjl3ZpzrctukrQSN', // Brad (https://elevenlabs.io/app/voice-library?voiceId=Dslrhjl3ZpzrctukrQSN) +]); + +$result = $platform->invoke($model, new Text('Hello world')); + +echo $result->asBase64().\PHP_EOL; diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index 91ab8bbe6..cb701f320 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -40,6 +40,13 @@ ->end() ->end() ->end() + ->arrayNode('eleven_labs') + ->children() + ->scalarNode('host')->end() + ->scalarNode('api_key')->isRequired()->end() + ->scalarNode('output_path')->isRequired()->end() + ->end() + ->end() ->arrayNode('gemini') ->children() ->scalarNode('api_key')->isRequired()->end() diff --git a/src/ai-bundle/doc/index.rst b/src/ai-bundle/doc/index.rst index fe409dffe..9f6a0975b 100644 --- a/src/ai-bundle/doc/index.rst +++ b/src/ai-bundle/doc/index.rst @@ -34,7 +34,7 @@ Configuration class: 'Symfony\AI\Platform\Bridge\OpenAi\Gpt' name: !php/const Symfony\AI\Platform\Bridge\OpenAi\Gpt::GPT_4O_MINI -**Advanced Example with Anthropic, Azure, Gemini and multiple agents** +**Advanced Example with Anthropic, Azure, ElevenLabs, Gemini, Ollama multiple agents** .. code-block:: yaml @@ -50,6 +50,10 @@ Configuration deployment: '%env(AZURE_OPENAI_GPT)%' api_key: '%env(AZURE_OPENAI_KEY)%' api_version: '%env(AZURE_GPT_VERSION)%' + eleven_labs: + host: '%env(ELEVEN_LABS_HOST)%' + api_key: '%env(ELEVEN_LABS_API_KEY)%' + output_path: '%env(ELEVEN_LABS_OUTPUT_PATH)%' gemini: api_key: '%env(GEMINI_API_KEY)%' ollama: @@ -85,6 +89,12 @@ Configuration tools: # If undefined, all tools are injected into the agent, use "tools: false" to disable tools. - 'Symfony\AI\Agent\Toolbox\Tool\Wikipedia' fault_tolerant_toolbox: false # Disables fault tolerant toolbox, default is true + audio: + platform: 'ai.platform.eleven_labs' + model: + class: 'Symfony\AI\Platform\Bridge\ElevenLabs' + name: !php/const Symfony\AI\Platform\Bridge\ElevenLabs::TEXT_TO_SPEECH + tools: false store: # also azure_search, meilisearch, memory, mongodb, pinecone, qdrant and surrealdb are supported as store type chroma_db: diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index 580a86688..1e6d5b663 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -28,6 +28,7 @@ use Symfony\AI\Platform\Bridge\Anthropic\PlatformFactory as AnthropicPlatformFactory; use Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory as AzureOpenAiPlatformFactory; use Symfony\AI\Platform\Bridge\Cerebras\PlatformFactory as CerebrasPlatformFactory; +use Symfony\AI\Platform\Bridge\ElevenLabs\PlatformFactory as ElevenLabsPlatformFactory; use Symfony\AI\Platform\Bridge\Gemini\PlatformFactory as GeminiPlatformFactory; use Symfony\AI\Platform\Bridge\LmStudio\PlatformFactory as LmStudioPlatformFactory; use Symfony\AI\Platform\Bridge\Mistral\PlatformFactory as MistralPlatformFactory; @@ -206,6 +207,26 @@ private function processPlatformConfig(string $type, array $platform, ContainerB return; } + if ('eleven_labs' === $type) { + $platformId = 'ai.platform.eleven_labs'; + $definition = (new Definition(Platform::class)) + ->setFactory(ElevenLabsPlatformFactory::class.'::create') + ->setLazy(true) + ->addTag('proxy', ['interface' => PlatformInterface::class]) + ->setArguments([ + $platform['api_key'], + $platform['output_path'], + $platform['host'], + new Reference('http_client', ContainerInterface::NULL_ON_INVALID_REFERENCE), + new Reference('ai.platform.contract.default'), + ]) + ->addTag('ai.platform'); + + $container->setDefinition($platformId, $definition); + + return; + } + if ('gemini' === $type) { $platformId = 'ai.platform.gemini'; $definition = (new Definition(Platform::class)) diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index 69305d775..d75923405 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -158,6 +158,11 @@ private function getFullConfig(): array 'api_version' => '2024-02-15-preview', ], ], + 'eleven_labs' => [ + 'host' => 'https://api.elevenlabs.io/v1', + 'api_key' => 'eleven_labs_key_full', + 'output_path' => 'path/to/output', + ], 'gemini' => [ 'api_key' => 'gemini_key_full', ], diff --git a/src/platform/composer.json b/src/platform/composer.json index d8d3b6225..d79d7d803 100644 --- a/src/platform/composer.json +++ b/src/platform/composer.json @@ -40,10 +40,11 @@ "phpstan/phpstan": "^2.1.17", "phpstan/phpstan-symfony": "^2.0.6", "phpunit/phpunit": "^11.5", + "symfony/ai-agent": "@dev", "symfony/console": "^6.4 || ^7.1", "symfony/dotenv": "^6.4 || ^7.1", - "symfony/ai-agent": "@dev", "symfony/event-dispatcher": "^6.4 || ^7.1", + "symfony/filesystem": "^7.3", "symfony/finder": "^6.4 || ^7.1", "symfony/process": "^6.4 || ^7.1", "symfony/var-dumper": "^6.4 || ^7.1" diff --git a/src/platform/src/Bridge/ElevenLabs/Contract/AudioNormalizer.php b/src/platform/src/Bridge/ElevenLabs/Contract/AudioNormalizer.php new file mode 100644 index 000000000..75f68447b --- /dev/null +++ b/src/platform/src/Bridge/ElevenLabs/Contract/AudioNormalizer.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\ElevenLabs\Contract; + +use Symfony\AI\Platform\Message\Content\Audio; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Guillaume Loulier + */ +final readonly class AudioNormalizer implements NormalizerInterface +{ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof Audio; + } + + public function getSupportedTypes(?string $format): array + { + return [ + Audio::class => true, + ]; + } + + /** + * @param Audio $data + * + * @return array{type: 'input_audio', input_audio: array{ + * data: string, + * path: string, + * format: 'mp3'|'wav'|string, + * }} + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'type' => 'input_audio', + 'input_audio' => [ + 'data' => $data->asBase64(), + 'path' => $data->asPath(), + 'format' => match ($data->getFormat()) { + 'audio/mpeg' => 'mp3', + 'audio/wav' => 'wav', + default => $data->getFormat(), + }, + ], + ]; + } +} diff --git a/src/platform/src/Bridge/ElevenLabs/Contract/ElevenLabsContract.php b/src/platform/src/Bridge/ElevenLabs/Contract/ElevenLabsContract.php new file mode 100644 index 000000000..32c06f509 --- /dev/null +++ b/src/platform/src/Bridge/ElevenLabs/Contract/ElevenLabsContract.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\ElevenLabs\Contract; + +use Symfony\AI\Platform\Contract; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Guillaume Loulier + */ +final readonly class ElevenLabsContract extends Contract +{ + public static function create(NormalizerInterface ...$normalizer): Contract + { + return parent::create( + new AudioNormalizer(), + ...$normalizer, + ); + } +} diff --git a/src/platform/src/Bridge/ElevenLabs/ElevenLabs.php b/src/platform/src/Bridge/ElevenLabs/ElevenLabs.php new file mode 100644 index 000000000..63ba580d1 --- /dev/null +++ b/src/platform/src/Bridge/ElevenLabs/ElevenLabs.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\ElevenLabs; + +use Symfony\AI\Platform\Model; + +/** + * @author Guillaume Loulier + */ +final class ElevenLabs extends Model +{ + public const ELEVEN_V3 = 'eleven_v3'; + public const ELEVEN_TTV_V3 = 'eleven_ttv_v3'; + public const ELEVEN_MULTILINGUAL_V2 = 'eleven_multilingual_v2'; + public const ELEVEN_FLASH_V250 = 'eleven_flash_v2_5'; + public const ELEVEN_FLASH_V2 = 'eleven_flashv2'; + public const ELEVEN_TURBO_V2_5 = 'eleven_turbo_v2_5'; + public const ELEVEN_TURBO_v2 = 'eleven_turbo_v2'; + public const ELEVEN_MULTILINGUAL_STS_V2 = 'eleven_multilingual_sts_v2'; + public const ELEVEN_MULTILINGUAL_ttv_V2 = 'eleven_multilingual_ttv_v2'; + public const ELEVEN_ENGLISH_STS_V2 = 'eleven_english_sts_v2'; + public const SCRIBE_V1 = 'scribe_v1'; + public const SCRIBE_V1_EXPERIMENTAL = 'scribe_v1_experimental'; + + public function __construct( + string $name = self::ELEVEN_MULTILINGUAL_V2, + array $options = [], + ) { + parent::__construct($name, [], $options); + } +} diff --git a/src/platform/src/Bridge/ElevenLabs/ElevenLabsClient.php b/src/platform/src/Bridge/ElevenLabs/ElevenLabsClient.php new file mode 100644 index 000000000..aef1f5bf8 --- /dev/null +++ b/src/platform/src/Bridge/ElevenLabs/ElevenLabsClient.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\ElevenLabs; + +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\AI\Platform\Result\RawResultInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Guillaume Loulier + */ +final readonly class ElevenLabsClient implements ModelClientInterface +{ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private string $hostUrl = 'https://api.elevenlabs.io/v1', + ) { + } + + public function supports(Model $model): bool + { + return $model instanceof ElevenLabs; + } + + public function request(Model $model, array|string $payload, array $options = []): RawResultInterface + { + if (!\is_array($payload)) { + throw new InvalidArgumentException(\sprintf('The payload must be an array, received "%s".', get_debug_type($payload))); + } + + if (\in_array($model->getName(), [ElevenLabs::SCRIBE_V1, ElevenLabs::SCRIBE_V1_EXPERIMENTAL], true)) { + return $this->doSpeechToTextRequest($model, $payload, $options); + } + + $capabilities = $this->retrieveCapabilities($model); + + if (!$capabilities['can_do_text_to_speech']) { + throw new InvalidArgumentException(\sprintf('The model "%s" does not support text-to-speech.', $model->getName())); + } + + return $this->doTextToSpeechRequest($model, $payload, $options); + } + + /** + * @param array $payload + * @param array $options + */ + private function doSpeechToTextRequest(Model $model, array|string $payload, array $options): RawHttpResult + { + return new RawHttpResult($this->httpClient->request('POST', \sprintf('%s/speech-to-text', $this->hostUrl), [ + 'headers' => [ + 'xi-api-key' => $this->apiKey, + ], + 'body' => [ + 'file' => fopen($payload['input_audio']['path'], 'r'), + 'model_id' => $model->getName(), + ], + ])); + } + + /** + * @param array $payload + * @param array $options + */ + private function doTextToSpeechRequest(Model $model, array|string $payload, array $options): RawHttpResult + { + if (!\array_key_exists('voice', $model->getOptions())) { + throw new InvalidArgumentException('The voice option is required.'); + } + + if (!\array_key_exists('text', $payload)) { + throw new InvalidArgumentException('The payload must contain a "text" key.'); + } + + $voice = $options['voice'] ??= $model->getOptions()['voice']; + + return new RawHttpResult($this->httpClient->request('POST', \sprintf('%s/text-to-speech/%s', $this->hostUrl, $voice), [ + 'headers' => [ + 'xi-api-key' => $this->apiKey, + ], + 'json' => [ + 'text' => $payload['text'], + 'model_id' => $model->getName(), + ], + ])); + } + + /** + * @return array + */ + private function retrieveCapabilities(Model $model): array + { + $capabilityResponse = $this->httpClient->request('GET', \sprintf('%s/models', $this->hostUrl), [ + 'headers' => [ + 'xi-api-key' => $this->apiKey, + ], + ]); + + $models = $capabilityResponse->toArray(); + + $currentModelConfiguration = array_filter($models, static fn (array $information): bool => $information['model_id'] === $model->getName()); + + if ([] === $currentModelConfiguration) { + throw new InvalidArgumentException('The model information could not be retrieved from the ElevenLabs API. Your model might not be supported. Try to use another one.'); + } + + return reset($currentModelConfiguration); + } +} diff --git a/src/platform/src/Bridge/ElevenLabs/ElevenLabsResultConverter.php b/src/platform/src/Bridge/ElevenLabs/ElevenLabsResultConverter.php new file mode 100644 index 000000000..6fb83224e --- /dev/null +++ b/src/platform/src/Bridge/ElevenLabs/ElevenLabsResultConverter.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\ElevenLabs; + +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Result\BinaryResult; +use Symfony\AI\Platform\Result\RawResultInterface; +use Symfony\AI\Platform\Result\ResultInterface; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\AI\Platform\ResultConverterInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Guillaume Loulier + */ +final readonly class ElevenLabsResultConverter implements ResultConverterInterface +{ + public function __construct( + private string $outputPath, + ) { + if (!class_exists(Filesystem::class)) { + throw new RuntimeException('For using ElevenLabs as platform, the symfony/filesystem package is required. Try running "composer require symfony/filesystem".'); + } + } + + public function supports(Model $model): bool + { + return $model instanceof ElevenLabs; + } + + public function convert(RawResultInterface $result, array $options = []): ResultInterface + { + /** @var ResponseInterface $response */ + $response = $result->getObject(); + + return match (true) { + str_contains($response->getInfo('url'), 'speech-to-text') => new TextResult($result->getData()['text']), + str_contains($response->getInfo('url'), 'text-to-speech') => $this->doConvertTextToSpeech($result), + default => throw new RuntimeException('Unsupported ElevenLabs response.'), + }; + } + + private function doConvertTextToSpeech(RawResultInterface $result): ResultInterface + { + $payload = $result->getObject()->getContent(); + + $path = \sprintf('%s/%s.mp3', $this->outputPath, uniqid()); + + $filesystem = new Filesystem(); + $filesystem->dumpFile($path, $payload); + + return new BinaryResult($filesystem->readFile($path), 'audio/mpeg'); + } +} diff --git a/src/platform/src/Bridge/ElevenLabs/PlatformFactory.php b/src/platform/src/Bridge/ElevenLabs/PlatformFactory.php new file mode 100644 index 000000000..d8be1d7f8 --- /dev/null +++ b/src/platform/src/Bridge/ElevenLabs/PlatformFactory.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\ElevenLabs; + +use Symfony\AI\Platform\Bridge\ElevenLabs\Contract\ElevenLabsContract; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Guillaume Loulier + */ +final readonly class PlatformFactory +{ + public static function create( + string $apiKey, + string $outputPath, + string $hostUrl = 'https://api.elevenlabs.io/v1', + ?HttpClientInterface $httpClient = null, + ?Contract $contract = null, + ): Platform { + $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + + return new Platform( + [new ElevenLabsClient($httpClient, $apiKey, $hostUrl)], + [new ElevenLabsResultConverter($outputPath)], + $contract ?? ElevenLabsContract::create(), + ); + } +} diff --git a/src/platform/src/Capability.php b/src/platform/src/Capability.php index 707845906..455cbf4bf 100644 --- a/src/platform/src/Capability.php +++ b/src/platform/src/Capability.php @@ -33,4 +33,8 @@ enum Capability: string // FUNCTIONALITY case TOOL_CALLING = 'tool-calling'; + + // VOICE + case TEXT_TO_SPEECH = 'text-to-speech'; + case SPEECH_TO_TEXT = 'speech-to-text'; } diff --git a/src/platform/src/Message/Content/File.php b/src/platform/src/Message/Content/File.php index a6d6cbcbf..eecf14d0a 100644 --- a/src/platform/src/Message/Content/File.php +++ b/src/platform/src/Message/Content/File.php @@ -47,7 +47,7 @@ public static function fromFile(string $path): static } return new static( - fn () => file_get_contents($path), + static fn (): string => file_get_contents($path), mime_content_type($path), $path, ); @@ -73,6 +73,11 @@ public function asDataUrl(): string return \sprintf('data:%s;base64,%s', $this->format, $this->asBase64()); } + public function asPath(): ?string + { + return $this->path; + } + /** * @return resource|false */ diff --git a/src/platform/tests/Bridge/ElevenLabs/Contract/ElevenLabsContractTest.php b/src/platform/tests/Bridge/ElevenLabs/Contract/ElevenLabsContractTest.php new file mode 100644 index 000000000..a50d0eeb7 --- /dev/null +++ b/src/platform/tests/Bridge/ElevenLabs/Contract/ElevenLabsContractTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\ElevenLabs\Contract; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\ElevenLabs\Contract\ElevenLabsContract; +use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabs; +use Symfony\AI\Platform\Message\Content\Audio; + +#[CoversClass(ElevenLabsContract::class)] +#[UsesClass(Audio::class)] +#[UsesClass(ElevenLabs::class)] +final class ElevenLabsContractTest extends TestCase +{ + public function testItCanCreatePayloadWithAudio() + { + $audio = Audio::fromFile(\dirname(__DIR__, 6).'/fixtures/audio.mp3'); + + $contract = ElevenLabsContract::create(); + + $payload = $contract->createRequestPayload(new ElevenLabs(), $audio); + + $this->assertSame([ + 'type' => 'input_audio', + 'input_audio' => [ + 'data' => $audio->asBase64(), + 'path' => $audio->asPath(), + 'format' => 'mp3', + ], + ], $payload); + } +} diff --git a/src/platform/tests/Bridge/ElevenLabs/ElevenLabsClientTest.php b/src/platform/tests/Bridge/ElevenLabs/ElevenLabsClientTest.php new file mode 100644 index 000000000..f6c9a0fc2 --- /dev/null +++ b/src/platform/tests/Bridge/ElevenLabs/ElevenLabsClientTest.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\ElevenLabs; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\ElevenLabs\Contract\AudioNormalizer; +use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabs; +use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabsClient; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Message\Content\Audio; +use Symfony\AI\Platform\Model; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\HttpClient\Response\MockResponse; + +#[CoversClass(ElevenLabsClient::class)] +#[UsesClass(ElevenLabs::class)] +#[UsesClass(Model::class)] +#[UsesClass(Audio::class)] +#[UsesClass(AudioNormalizer::class)] +final class ElevenLabsClientTest extends TestCase +{ + public function testSupportsModel() + { + $client = new ElevenLabsClient( + new MockHttpClient(), + 'my-api-key', + 'https://api.elevenlabs.io/v1', + ); + + $this->assertTrue($client->supports(new ElevenLabs())); + $this->assertFalse($client->supports(new Model('any-model'))); + } + + public function testClientCannotPerformWithInvalidModel() + { + $mockHttpClient = new MockHttpClient([ + new JsonMockResponse([ + [ + 'model_id' => 'bar', + 'can_do_text_to_speech' => false, + ], + ]), + new JsonMockResponse([]), + ]); + $normalizer = new AudioNormalizer(); + + $client = new ElevenLabsClient( + $mockHttpClient, + 'my-api-key', + 'https://api.elevenlabs.io/v1', + ); + + $payload = $normalizer->normalize(Audio::fromFile(\dirname(__DIR__, 5).'/fixtures/audio.mp3')); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The model information could not be retrieved from the ElevenLabs API. Your model might not be supported. Try to use another one.'); + $this->expectExceptionCode(0); + $client->request(new ElevenLabs('foo'), $payload); + } + + public function testClientCannotPerformSpeechToTextRequestWithInvalidPayload() + { + $client = new ElevenLabsClient( + new MockHttpClient(), + 'my-api-key', + 'https://api.elevenlabs.io/v1', + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The payload must be an array, received "string".'); + $this->expectExceptionCode(0); + $client->request(new ElevenLabs(ElevenLabs::ELEVEN_MULTILINGUAL_V2), 'foo'); + } + + public function testClientCanPerformSpeechToTextRequest() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'text' => 'foo', + ]), + ]); + $normalizer = new AudioNormalizer(); + + $client = new ElevenLabsClient( + $httpClient, + 'https://api.elevenlabs.io/v1', + 'my-api-key', + ); + + $payload = $normalizer->normalize(Audio::fromFile(\dirname(__DIR__, 5).'/fixtures/audio.mp3')); + + $client->request(new ElevenLabs(ElevenLabs::SCRIBE_V1), $payload); + + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testClientCannotPerformTextToSpeechRequestWithoutValidPayload() + { + $mockHttpClient = new MockHttpClient([ + new JsonMockResponse([ + [ + 'model_id' => ElevenLabs::ELEVEN_MULTILINGUAL_V2, + 'can_do_text_to_speech' => true, + ], + ]), + new JsonMockResponse([]), + ]); + + $client = new ElevenLabsClient( + $mockHttpClient, + 'https://api.elevenlabs.io/v1', + 'my-api-key', + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The payload must contain a "text" key'); + $this->expectExceptionCode(0); + $client->request(new ElevenLabs(options: [ + 'voice' => 'Dslrhjl3ZpzrctukrQSN', + ]), []); + } + + #[Group('foo')] + public function testClientCanPerformTextToSpeechRequest() + { + $payload = Audio::fromFile(\dirname(__DIR__, 5).'/fixtures/audio.mp3'); + + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + [ + 'model_id' => ElevenLabs::ELEVEN_MULTILINGUAL_V2, + 'can_do_text_to_speech' => true, + ], + ]), + new MockResponse($payload->asBinary()), + ]); + + $client = new ElevenLabsClient( + $httpClient, + 'https://api.elevenlabs.io/v1', + 'my-api-key', + ); + + $client->request(new ElevenLabs(options: [ + 'voice' => 'Dslrhjl3ZpzrctukrQSN', + ]), [ + 'text' => 'foo', + ]); + + $this->assertSame(2, $httpClient->getRequestsCount()); + } +} diff --git a/src/platform/tests/Bridge/ElevenLabs/ElevenLabsConverterTest.php b/src/platform/tests/Bridge/ElevenLabs/ElevenLabsConverterTest.php new file mode 100644 index 000000000..7f8650049 --- /dev/null +++ b/src/platform/tests/Bridge/ElevenLabs/ElevenLabsConverterTest.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\ElevenLabs; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabs; +use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabsResultConverter; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Result\BinaryResult; +use Symfony\AI\Platform\Result\InMemoryRawResult; +use Symfony\AI\Platform\Result\TextResult; + +#[CoversClass(ElevenLabsResultConverter::class)] +#[UsesClass(ElevenLabs::class)] +#[UsesClass(Model::class)] +#[UsesClass(TextResult::class)] +#[UsesClass(BinaryResult::class)] +#[UsesClass(InMemoryRawResult::class)] +final class ElevenLabsConverterTest extends TestCase +{ + public function testSupportsModel() + { + $converter = new ElevenLabsResultConverter(sys_get_temp_dir()); + + $this->assertTrue($converter->supports(new ElevenLabs())); + $this->assertFalse($converter->supports(new Model('any-model'))); + } + + public function testConvertSpeechToTextResponse() + { + $converter = new ElevenLabsResultConverter(sys_get_temp_dir()); + $rawResult = new InMemoryRawResult([ + 'text' => 'Hello there', + ], new class { + public function getInfo(): string + { + return 'speech-to-text'; + } + }); + + $result = $converter->convert($rawResult); + + $this->assertInstanceOf(TextResult::class, $result); + $this->assertSame('Hello there', $result->getContent()); + } + + public function testConvertTextToSpeechResponse() + { + $converter = new ElevenLabsResultConverter(sys_get_temp_dir()); + $rawResult = new InMemoryRawResult([], new class { + public function getInfo(): string + { + return 'text-to-speech'; + } + + public function getContent(): string + { + return file_get_contents(\dirname(__DIR__, 5).'/fixtures/audio.mp3'); + } + }); + + $result = $converter->convert($rawResult); + + $this->assertInstanceOf(BinaryResult::class, $result); + $this->assertSame('audio/mpeg', $result->mimeType); + } +} diff --git a/src/platform/tests/Contract/Normalizer/Message/Content/AudioNormalizerTest.php b/src/platform/tests/Contract/Normalizer/Message/Content/AudioNormalizerTest.php index 1b9b441c3..8b5a90ea5 100644 --- a/src/platform/tests/Contract/Normalizer/Message/Content/AudioNormalizerTest.php +++ b/src/platform/tests/Contract/Normalizer/Message/Content/AudioNormalizerTest.php @@ -45,7 +45,7 @@ public function testGetSupportedTypes() #[DataProvider('provideAudioData')] public function testNormalize(string $data, string $format, array $expected) { - $audio = new Audio(base64_decode($data), $format); + $audio = new Audio(base64_decode($data), $format, \dirname(__DIR__, 7).'/fixtures/audio.mp3'); $this->assertSame($expected, $this->normalizer->normalize($audio)); } From 80ce40226f21079b892c153003fae63f3b67fbbc Mon Sep 17 00:00:00 2001 From: Guillaume Loulier Date: Wed, 13 Aug 2025 14:39:30 +0200 Subject: [PATCH 2/2] ref --- examples/elevenlabs/speech-to-text.php | 3 +-- src/ai-bundle/config/options.php | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/examples/elevenlabs/speech-to-text.php b/examples/elevenlabs/speech-to-text.php index 0d27d33d3..711069baf 100644 --- a/examples/elevenlabs/speech-to-text.php +++ b/examples/elevenlabs/speech-to-text.php @@ -21,8 +21,7 @@ httpClient: http_client() ); $model = new ElevenLabs(ElevenLabs::SCRIBE_V1); -$file = Audio::fromFile(dirname(__DIR__, 2).'/fixtures/audio.mp3'); -$result = $platform->invoke($model, $file); +$result = $platform->invoke($model, Audio::fromFile(dirname(__DIR__, 2).'/fixtures/audio.mp3')); echo $result->asText().\PHP_EOL; diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index cb701f320..7b62c92da 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -44,7 +44,7 @@ ->children() ->scalarNode('host')->end() ->scalarNode('api_key')->isRequired()->end() - ->scalarNode('output_path')->isRequired()->end() + ->scalarNode('output_path')->defaultValue('%kernel.project_dir%/var/eleven_labs')->isRequired()->end() ->end() ->end() ->arrayNode('gemini')