diff --git a/demo/tests/Blog/Command/StreamCommandTest.php b/demo/tests/Blog/Command/StreamCommandTest.php index 58a647b3a7..5c1346ad3f 100644 --- a/demo/tests/Blog/Command/StreamCommandTest.php +++ b/demo/tests/Blog/Command/StreamCommandTest.php @@ -16,8 +16,13 @@ use Symfony\AI\Agent\AgentInterface; use Symfony\AI\Platform\Message\MessageBag; use Symfony\AI\Platform\Metadata\Metadata; +use Symfony\AI\Platform\Result\DeferredResult; +use Symfony\AI\Platform\Result\InMemoryRawResult; use Symfony\AI\Platform\Result\RawResultInterface; use Symfony\AI\Platform\Result\ResultInterface; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\AI\Platform\Speech\Speech; +use Symfony\AI\Platform\Test\PlainConverter; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Style\SymfonyStyle; @@ -52,6 +57,15 @@ public function getRawResult(): ?RawResultInterface public function setRawResult(RawResultInterface $rawResult): void { } + + public function addSpeech(Speech $speech): void + { + } + + public function getSpeech(string $identifier): Speech + { + return new Speech([], new DeferredResult(new PlainConverter(new TextResult('foo')), new InMemoryRawResult()), 'bar'); + } }); $input = new ArrayInput([]); diff --git a/docs/components/platform.rst b/docs/components/platform.rst index 33998b11eb..76231bcda1 100644 --- a/docs/components/platform.rst +++ b/docs/components/platform.rst @@ -532,6 +532,64 @@ This allows fast and isolated testing of AI-powered features without relying on This requires `cURL` and the `ext-curl` extension to be installed. +Speech support +~~~~~~~~~~~~~~ + +Using speech to send messages / receive answers as audio is a common use case when integrating agents and/or chats. + +Speech support can be enable using ``Symfony\AI\Platform\Speech\SpeechProviderListener``:: + + use Symfony\AI\Agent\Agent; + use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabsSpeechProvider; + use Symfony\AI\Platform\Bridge\ElevenLabs\PlatformFactory; + use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAiPlatformFactory; + use Symfony\AI\Platform\Message\Message; + use Symfony\AI\Platform\Message\MessageBag; + use Symfony\AI\Platform\Speech\SpeechConfiguration; + use Symfony\AI\Platform\Speech\SpeechProviderListener; + use Symfony\Component\EventDispatcher\EventDispatcher; + + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addSubscriber(new SpeechProviderListener([ + new ElevenLabsSpeechProvider(PlatformFactory::create( + apiKey: $elevenLabsApiKey, + httpClient: http_client(), + speechConfiguration: new SpeechConfiguration( + ttsModel: 'eleven_multilingual_v2', + ttsVoice: 'Dslrhjl3ZpzrctukrQSN', // Brad (https://elevenlabs.io/app/voice-library?voiceId=Dslrhjl3ZpzrctukrQSN) + sttModel: 'eleven_multilingual_v2' + )), + ), + ], [])); + + $platform = OpenAiPlatformFactory::create($openAiApiKey, httpClient: HttpClient::create(), eventDispatcher: $eventDispatcher); + + $agent = new Agent($platform, 'gpt-4o'); + $answer = $agent->call(new MessageBag( + Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'), + )); + + echo $answer->getSpeech('elevenlabs')->asBinary(); + +When using the bundle, the configuration allows to configure models and voices:: + + ai: + platform: + elevenlabs: + api_key: '%env(ELEVEN_LABS_API_KEY)%' + + speech: + elevenlabs: + platform: 'ai.platform.elevenlabs' + tts_model: 'eleven_multilingual_v2' + tts_voice: '%env(ELEVEN_LABS_VOICE_IDENTIFIER)%' + tts_extra_options: + foo: bar + +.. note:: + + Please be aware that enabling speech support requires to define corresponding platforms. + Code Examples ~~~~~~~~~~~~~ diff --git a/examples/speech/README.md b/examples/speech/README.md new file mode 100644 index 0000000000..4a54d0da0d --- /dev/null +++ b/examples/speech/README.md @@ -0,0 +1,10 @@ +# Speech Examples + +Speech is mainly used to transform text to audio and vice versa, it can also be used to create an audio to audio pipeline. + +To run the examples, you can use additional tools like (mpg123)[https://www.mpg123.de/]: + +```bash +php speech/agent-eleven-labs-speech-tts.php | mpg123 - +php speech/agent-eleven-labs-speech-sts.php | mpg123 - +``` diff --git a/examples/speech/agent-eleven-labs-speech-sts.php b/examples/speech/agent-eleven-labs-speech-sts.php new file mode 100644 index 0000000000..e9d28960e4 --- /dev/null +++ b/examples/speech/agent-eleven-labs-speech-sts.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabsSpeechPlatform; +use Symfony\AI\Platform\Bridge\ElevenLabs\PlatformFactory; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAiPlatformFactory; +use Symfony\AI\Platform\Message\Content\Audio; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Speech\SpeechListener; +use Symfony\Component\EventDispatcher\EventDispatcher; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$elevenLabsPlatform = new ElevenLabsSpeechPlatform( + PlatformFactory::create( + apiKey: env('ELEVEN_LABS_API_KEY'), + httpClient: http_client(), + ), + [ + 'ttsModel' => 'eleven_multilingual_v2', + 'ttsVoice' => 'Dslrhjl3ZpzrctukrQSN', // Brad (https://elevenlabs.io/app/voice-library?voiceId=Dslrhjl3ZpzrctukrQSN) + 'sttModel' => 'eleven_multilingual_v2', + ], +); + +$eventDispatcher = new EventDispatcher(); +$eventDispatcher->addSubscriber(new SpeechListener([ + $elevenLabsPlatform, +])); + +$platform = OpenAiPlatformFactory::create(env('OPENAI_API_KEY'), httpClient: http_client(), eventDispatcher: $eventDispatcher); + +$agent = new Agent($platform, 'gpt-4o'); +$answer = $agent->call(new MessageBag( + Message::ofUser(Audio::fromFile(dirname(__DIR__, 2).'/fixtures/audio.mp3')) +)); + +echo $answer->getSpeech('elevenlabs')->asBinary(); diff --git a/examples/speech/agent-eleven-labs-speech-stt.php b/examples/speech/agent-eleven-labs-speech-stt.php new file mode 100644 index 0000000000..3d2448716b --- /dev/null +++ b/examples/speech/agent-eleven-labs-speech-stt.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabsSpeechPlatform; +use Symfony\AI\Platform\Bridge\ElevenLabs\PlatformFactory; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAiPlatformFactory; +use Symfony\AI\Platform\Message\Content\Audio; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Speech\SpeechListener; +use Symfony\Component\EventDispatcher\EventDispatcher; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$eventDispatcher = new EventDispatcher(); +$eventDispatcher->addSubscriber(new SpeechListener([ + new ElevenLabsSpeechPlatform( + PlatformFactory::create( + apiKey: env('ELEVEN_LABS_API_KEY'), + httpClient: http_client(), + ), + [ + 'sttModel' => 'eleven_multilingual_v2', + ], + ), +])); + +$platform = OpenAiPlatformFactory::create(env('OPENAI_API_KEY'), httpClient: http_client(), eventDispatcher: $eventDispatcher); + +$agent = new Agent($platform, 'gpt-4o'); +$answer = $agent->call(new MessageBag( + Message::ofUser(Audio::fromFile(dirname(__DIR__, 2).'/fixtures/audio.mp3')) +)); + +echo $answer->getContent(); diff --git a/examples/speech/agent-eleven-labs-speech-tts.php b/examples/speech/agent-eleven-labs-speech-tts.php new file mode 100644 index 0000000000..ad572c69a2 --- /dev/null +++ b/examples/speech/agent-eleven-labs-speech-tts.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabsSpeechPlatform; +use Symfony\AI\Platform\Bridge\ElevenLabs\PlatformFactory; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAiPlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Speech\SpeechListener; +use Symfony\Component\EventDispatcher\EventDispatcher; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$elevenLabsPlatform = new ElevenLabsSpeechPlatform( + PlatformFactory::create( + apiKey: env('ELEVEN_LABS_API_KEY'), + httpClient: http_client(), + ), + [ + 'ttsModel' => 'eleven_multilingual_v2', + 'ttsVoice' => 'Dslrhjl3ZpzrctukrQSN', // Brad (https://elevenlabs.io/app/voice-library?voiceId=Dslrhjl3ZpzrctukrQSN) + ], +); + +$eventDispatcher = new EventDispatcher(); +$eventDispatcher->addSubscriber(new SpeechListener([ + $elevenLabsPlatform, +])); + +$platform = OpenAiPlatformFactory::create(env('OPENAI_API_KEY'), httpClient: http_client(), eventDispatcher: $eventDispatcher); + +$agent = new Agent($platform, 'gpt-4o'); +$answer = $agent->call(new MessageBag( + Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'), +)); + +echo $answer->getSpeech('elevenlabs')->asBinary(); diff --git a/src/agent/src/Output.php b/src/agent/src/Output.php index d069d47a77..a98206aa12 100644 --- a/src/agent/src/Output.php +++ b/src/agent/src/Output.php @@ -13,6 +13,7 @@ use Symfony\AI\Platform\Message\MessageBag; use Symfony\AI\Platform\Result\ResultInterface; +use Symfony\AI\Platform\Speech\Speech; /** * @author Christopher Hertel @@ -27,6 +28,7 @@ public function __construct( private ResultInterface $result, private readonly MessageBag $messageBag, private readonly array $options = [], + private ?Speech $speech = null, ) { } @@ -57,4 +59,14 @@ public function getOptions(): array { return $this->options; } + + public function setSpeech(?Speech $speech): void + { + $this->speech = $speech; + } + + public function getSpeech(): ?Speech + { + return $this->speech; + } } diff --git a/src/ai-bundle/CHANGELOG.md b/src/ai-bundle/CHANGELOG.md index f85ed1f4af..70637cf963 100644 --- a/src/ai-bundle/CHANGELOG.md +++ b/src/ai-bundle/CHANGELOG.md @@ -34,3 +34,4 @@ CHANGELOG - Token usage metadata in agent results including prompt, completion, total, cached, and thinking tokens - Rate limit information tracking for supported platforms * Add support for configuring chats and message stores + * Add support for configuring speeches diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index adc0a656cb..77886cf61b 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -113,6 +113,23 @@ ->booleanNode('api_catalog') ->info('If set, the ElevenLabs API will be used to build the catalog and retrieve models information, using this option leads to additional HTTP calls') ->end() + ->arrayNode('speech') + ->children() + ->stringNode('tts_model')->end() + ->stringNode('tts_voice')->end() + ->arrayNode('tts_options') + ->scalarPrototype() + ->defaultValue([]) + ->end() + ->end() + ->stringNode('stt_model')->end() + ->arrayNode('stt_options') + ->scalarPrototype() + ->defaultValue([]) + ->end() + ->end() + ->end() + ->end() ->end() ->end() ->arrayNode('gemini') diff --git a/src/ai-bundle/config/services.php b/src/ai-bundle/config/services.php index 6980570d1b..3ff609d1be 100644 --- a/src/ai-bundle/config/services.php +++ b/src/ai-bundle/config/services.php @@ -64,6 +64,7 @@ use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; use Symfony\AI\Platform\Serializer\StructuredOutputSerializer; +use Symfony\AI\Platform\Speech\SpeechListener; use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; use Symfony\AI\Platform\StructuredOutput\ResponseFormatFactory; use Symfony\AI\Platform\StructuredOutput\ResponseFormatFactoryInterface; @@ -263,5 +264,12 @@ tagged_locator('ai.message_store', 'name'), ]) ->tag('console.command') + + // listeners + ->set('ai.speech.listener', SpeechListener::class) + ->args([ + tagged_iterator('ai.platform.speech', 'name'), + ]) + ->tag('kernel.event_subscriber') ; }; diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index de37b29eac..0407715f91 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -59,6 +59,7 @@ use Symfony\AI\Platform\Bridge\DeepSeek\PlatformFactory as DeepSeekPlatformFactory; use Symfony\AI\Platform\Bridge\DockerModelRunner\PlatformFactory as DockerModelRunnerPlatformFactory; use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabsApiCatalog; +use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabsSpeechPlatform; use Symfony\AI\Platform\Bridge\ElevenLabs\PlatformFactory as ElevenLabsPlatformFactory; use Symfony\AI\Platform\Bridge\Gemini\PlatformFactory as GeminiPlatformFactory; use Symfony\AI\Platform\Bridge\Generic\PlatformFactory as GenericPlatformFactory; @@ -82,6 +83,7 @@ use Symfony\AI\Platform\Platform; use Symfony\AI\Platform\PlatformInterface; use Symfony\AI\Platform\ResultConverterInterface; +use Symfony\AI\Platform\Speech\SpeechPlatformInterface; use Symfony\AI\Store\Bridge\AzureSearch\SearchStore as AzureSearchStore; use Symfony\AI\Store\Bridge\Cache\Store as CacheStore; use Symfony\AI\Store\Bridge\ChromaDb\Store as ChromaDbStore; @@ -262,6 +264,12 @@ public function loadExtension(array $config, ContainerConfigurator $container, C } } + $speechPlatforms = array_keys($builder->findTaggedServiceIds('ai.platform.speech')); + + if ([] === $speechPlatforms) { + $builder->removeDefinition('ai.speech.listener'); + } + foreach ($config['vectorizer'] ?? [] as $vectorizerName => $vectorizer) { $this->processVectorizerConfig($vectorizerName, $vectorizer, $builder); } @@ -491,6 +499,24 @@ private function processPlatformConfig(string $type, array $platform, ContainerB $container->setDefinition('ai.platform.model_catalog.'.$type, $catalogDefinition); } + if (\array_key_exists('speech', $platform) && [] !== $platform['speech']) { + $decoratedPlatform = new Definition(ElevenLabsSpeechPlatform::class); + $decoratedPlatform + ->setLazy(true) + ->setDecoratedService('ai.platform.'.$type) + ->setArguments([ + new Reference('.inner'), + $platform['speech'], + ]) + ->addTag('proxy', ['interface' => PlatformInterface::class]) + ->addTag('proxy', ['interface' => SpeechPlatformInterface::class]) + ->addTag('ai.platform.speech', ['name' => $type]) + ; + + $container->setDefinition('ai.platform.speech.'.$type, $decoratedPlatform); + $container->registerAliasForArgument('ai.platform.speech.'.$type, SpeechPlatformInterface::class, $type); + } + $definition = (new Definition(Platform::class)) ->setFactory(ElevenLabsPlatformFactory::class.'::create') ->setLazy(true) @@ -551,7 +577,6 @@ private function processPlatformConfig(string $type, array $platform, ContainerB $config['api_key'], new Reference($config['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE), new Reference($config['model_catalog'], ContainerInterface::NULL_ON_INVALID_REFERENCE), - null, new Reference('event_dispatcher'), $config['supports_completions'], $config['supports_embeddings'], diff --git a/src/ai-bundle/src/Profiler/TraceablePlatform.php b/src/ai-bundle/src/Profiler/TraceablePlatform.php index 5bfe6b58b6..928870cb8e 100644 --- a/src/ai-bundle/src/Profiler/TraceablePlatform.php +++ b/src/ai-bundle/src/Profiler/TraceablePlatform.php @@ -12,7 +12,6 @@ namespace Symfony\AI\AiBundle\Profiler; use Symfony\AI\Platform\Message\Content\File; -use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; use Symfony\AI\Platform\PlatformInterface; use Symfony\AI\Platform\Result\DeferredResult; diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index ed2157c22b..e429e5741b 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -29,6 +29,7 @@ use Symfony\AI\Chat\MessageStoreInterface; use Symfony\AI\Platform\Bridge\Decart\PlatformFactory as DecartPlatformFactory; use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabsApiCatalog; +use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabsSpeechPlatform; use Symfony\AI\Platform\Bridge\ElevenLabs\ModelCatalog as ElevenLabsModelCatalog; use Symfony\AI\Platform\Bridge\ElevenLabs\PlatformFactory as ElevenLabsPlatformFactory; use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog; @@ -41,6 +42,7 @@ use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Platform\Speech\SpeechPlatformInterface; use Symfony\AI\Store\Bridge\AzureSearch\SearchStore as AzureStore; use Symfony\AI\Store\Bridge\Cache\Store as CacheStore; use Symfony\AI\Store\Bridge\ChromaDb\Store as ChromaDbStore; @@ -144,6 +146,7 @@ public function testStoreCommandsArentDefinedWithoutStore() 'ai.command.drop_store' => true, 'ai.command.setup_message_store' => true, 'ai.command.drop_message_store' => true, + 'ai.speech.listener' => true, ], $container->getRemovedIds()); } @@ -166,6 +169,7 @@ public function testMessageStoreCommandsArentDefinedWithoutMessageStore() 'ai.command.drop_store' => true, 'ai.command.setup_message_store' => true, 'ai.command.drop_message_store' => true, + 'ai.speech.listener' => true, ], $container->getRemovedIds()); } @@ -7006,6 +7010,54 @@ public function testTemplateRendererServicesAreRegistered() $this->assertTrue($listenerDefinition->hasTag('kernel.event_subscriber')); } + public function testElevenLabsSpeechPlatformCanBeRegistered() + { + $container = $this->buildContainer([ + 'ai' => [ + 'platform' => [ + 'elevenlabs' => [ + 'api_key' => 'foo', + 'speech' => [ + 'tts_model' => 'foo', + 'tts_voice' => 'bar', + 'tts_options' => [ + 'foo' => 'bar', + ], + 'stt_model' => 'foo', + 'stt_options' => [ + 'foo' => 'bar', + ], + ], + ], + ], + ], + ]); + + $this->assertTrue($container->hasDefinition('ai.platform.elevenlabs')); + $this->assertTrue($container->hasDefinition('ai.platform.speech.elevenlabs')); + + $speechPlatformDefinition = $container->getDefinition('ai.platform.speech.elevenlabs'); + $this->assertSame(ElevenLabsSpeechPlatform::class, $speechPlatformDefinition->getClass()); + $this->assertTrue($speechPlatformDefinition->isLazy()); + $this->assertCount(2, $speechPlatformDefinition->getArguments()); + $this->assertInstanceOf(Reference::class, $speechPlatformDefinition->getArgument(0)); + $this->assertSame('.inner', (string) $speechPlatformDefinition->getArgument(0)); + + $this->assertTrue($speechPlatformDefinition->hasTag('proxy')); + $this->assertSame([ + ['interface' => PlatformInterface::class], + ['interface' => SpeechPlatformInterface::class], + ], $speechPlatformDefinition->getTag('proxy')); + $this->assertTrue($speechPlatformDefinition->hasTag('ai.platform.speech')); + $this->assertSame([ + ['name' => 'elevenlabs'], + ], $speechPlatformDefinition->getTag('ai.platform.speech')); + + $this->assertTrue($container->hasAlias('Symfony\AI\Platform\Speech\SpeechPlatformInterface $elevenlabs')); + $this->assertTrue($container->hasAlias('Symfony\AI\Platform\PlatformInterface $elevenlabs')); + $this->assertTrue($container->hasAlias('Symfony\AI\Platform\PlatformInterface')); + } + private function buildContainer(array $configuration): ContainerBuilder { $container = new ContainerBuilder(); @@ -7067,6 +7119,17 @@ private function getFullConfig(): array 'elevenlabs' => [ 'host' => 'https://api.elevenlabs.io/v1', 'api_key' => 'elevenlabs_key_full', + 'speech' => [ + 'tts_model' => 'foo', + 'tts_voice' => 'bar', + 'tts_options' => [ + 'foo' => 'bar', + ], + 'stt_model' => 'foo', + 'stt_options' => [ + 'foo' => 'bar', + ], + ], ], 'gemini' => [ 'api_key' => 'gemini_key_full', diff --git a/src/platform/CHANGELOG.md b/src/platform/CHANGELOG.md index 3bf4557d35..ba7a8f2e77 100644 --- a/src/platform/CHANGELOG.md +++ b/src/platform/CHANGELOG.md @@ -68,3 +68,6 @@ CHANGELOG * Allow beta feature flags to be passed into Anthropic model options * Add Ollama streaming output support * Add multimodal embedding support for Voyage AI + * Introduce support for `Speech` + * Add support `Speech` provider: + - `ElevenLabs` diff --git a/src/platform/composer.json b/src/platform/composer.json index c0a852cd8d..a4659297e7 100644 --- a/src/platform/composer.json +++ b/src/platform/composer.json @@ -29,7 +29,8 @@ "replicate", "transformers", "vertexai", - "voyage" + "voyage", + "speech" ], "authors": [ { diff --git a/src/platform/src/Bridge/ElevenLabs/ElevenLabsApiCatalog.php b/src/platform/src/Bridge/ElevenLabs/ElevenLabsApiCatalog.php index 0e1c5d1bb3..613510d68b 100644 --- a/src/platform/src/Bridge/ElevenLabs/ElevenLabsApiCatalog.php +++ b/src/platform/src/Bridge/ElevenLabs/ElevenLabsApiCatalog.php @@ -33,7 +33,7 @@ public function getModel(string $modelName): ElevenLabs $models = $this->getModels(); if (!\array_key_exists($modelName, $models)) { - throw new InvalidArgumentException(\sprintf('The model "%s" cannot be retrieve from the API.', $modelName)); + throw new InvalidArgumentException(\sprintf('The model "%s" cannot be retrieved from the API.', $modelName)); } return new ElevenLabs($modelName, $models[$modelName]['capabilities']); @@ -49,7 +49,7 @@ public function getModels(): array $models = $response->toArray(); - $capabilities = fn (array $model): array => match (true) { + $capabilities = static fn (array $model): array => match (true) { $model['can_do_text_to_speech'] => [ Capability::TEXT_TO_SPEECH, Capability::INPUT_TEXT, diff --git a/src/platform/src/Bridge/ElevenLabs/ElevenLabsClient.php b/src/platform/src/Bridge/ElevenLabs/ElevenLabsClient.php index b8d8a5c956..44bd8c1721 100644 --- a/src/platform/src/Bridge/ElevenLabs/ElevenLabsClient.php +++ b/src/platform/src/Bridge/ElevenLabs/ElevenLabsClient.php @@ -42,18 +42,14 @@ public function request(Model $model, array|string $payload, array $options = [] throw new InvalidArgumentException(\sprintf('The payload must be an array, received "%s".', get_debug_type($payload))); } - if ($model->supports(Capability::SPEECH_TO_TEXT)) { - return $this->doSpeechToTextRequest($model, $payload); - } - - if ($model->supports(Capability::TEXT_TO_SPEECH)) { - return $this->doTextToSpeechRequest($model, $payload, [ + return match (true) { + $model->supports(Capability::SPEECH_TO_TEXT) => $this->doSpeechToTextRequest($model, $payload), + $model->supports(Capability::TEXT_TO_SPEECH) => $this->doTextToSpeechRequest($model, $payload, [ ...$options, ...$model->getOptions(), - ]); - } - - throw new InvalidArgumentException(\sprintf('The model "%s" does not support text-to-speech or speech-to-text, please check the model information.', $model->getName())); + ]), + default => throw new InvalidArgumentException(\sprintf('The model "%s" does not support text-to-speech or speech-to-text, please check the model information.', $model->getName())), + }; } /** diff --git a/src/platform/src/Bridge/ElevenLabs/ElevenLabsSpeechPlatform.php b/src/platform/src/Bridge/ElevenLabs/ElevenLabsSpeechPlatform.php new file mode 100644 index 0000000000..9b628dd281 --- /dev/null +++ b/src/platform/src/Bridge/ElevenLabs/ElevenLabsSpeechPlatform.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\Bridge\ElevenLabs; + +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Platform\Result\DeferredResult; +use Symfony\AI\Platform\Speech\Speech; +use Symfony\AI\Platform\Speech\SpeechPlatformInterface; + +/** + * @author Guillaume Loulier + */ +final class ElevenLabsSpeechPlatform implements PlatformInterface, SpeechPlatformInterface +{ + /** + * @param array $speechConfiguration + */ + public function __construct( + private readonly PlatformInterface $platform, + private readonly array $speechConfiguration, + ) { + } + + public function invoke(string $model, object|array|string $input, array $options = []): DeferredResult + { + return $this->platform->invoke($model, $input, $options); + } + + public function generate(DeferredResult $result, array $options): ?Speech + { + if (!\array_key_exists('tts_model', $this->speechConfiguration) || !\array_key_exists('tts_voice', $this->speechConfiguration)) { + return null; + } + + $payload = $result->asText(); + + $speechResult = $this->invoke($this->speechConfiguration['tts_model'], ['text' => $payload], [ + 'voice' => $this->speechConfiguration['tts_voice'], + ...$this->speechConfiguration['tts_options'] ?? [], + ...$options, + ]); + + return new Speech($payload, $speechResult, 'elevenlabs'); + } + + public function listen(object|array|string $input, array $options): ?Text + { + if (!\array_key_exists('stt_model', $this->speechConfiguration)) { + return null; + } + + $input = ($input instanceof MessageBag && $input->containsAudio()) ? $input->getUserMessage()->getAudioContent() : $input; + + $result = $this->platform->invoke($this->speechConfiguration['stt_model'], $input, [ + ...$options, + ...$this->speechConfiguration['stt_options'] ?? [], + ]); + + return new Text($result->asText()); + } + + public function getModelCatalog(): ModelCatalogInterface + { + return $this->platform->getModelCatalog(); + } +} diff --git a/src/platform/src/Bridge/ElevenLabs/Tests/ElevenLabsApiCatalogTest.php b/src/platform/src/Bridge/ElevenLabs/Tests/ElevenLabsApiCatalogTest.php index d4d2eaaea6..d7030e22e0 100644 --- a/src/platform/src/Bridge/ElevenLabs/Tests/ElevenLabsApiCatalogTest.php +++ b/src/platform/src/Bridge/ElevenLabs/Tests/ElevenLabsApiCatalogTest.php @@ -30,7 +30,7 @@ public function testModelCatalogCannotReturnModelFromApiWhenUndefined() $modelCatalog = new ElevenLabsApiCatalog($httpClient, 'foo'); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The model "foo" cannot be retrieve from the API.'); + $this->expectExceptionMessage('The model "foo" cannot be retrieved from the API.'); $this->expectExceptionCode(0); $modelCatalog->getModel('foo'); } diff --git a/src/platform/src/Bridge/ElevenLabs/Tests/ElevenLabsSpeechPlatformTest.php b/src/platform/src/Bridge/ElevenLabs/Tests/ElevenLabsSpeechPlatformTest.php new file mode 100644 index 0000000000..ce34ce3262 --- /dev/null +++ b/src/platform/src/Bridge/ElevenLabs/Tests/ElevenLabsSpeechPlatformTest.php @@ -0,0 +1,130 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Bridge\ElevenLabs; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabsSpeechPlatform; +use Symfony\AI\Platform\Message\Content\Audio; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Platform\Result\DeferredResult; +use Symfony\AI\Platform\Result\RawResultInterface; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\AI\Platform\ResultConverterInterface; + +final class ElevenLabsSpeechPlatformTest extends TestCase +{ + public function testPlatformCannotListenWithoutConfiguration() + { + $rawResult = $this->createMock(RawResultInterface::class); + $resultConverter = $this->createMock(ResultConverterInterface::class); + + $deferredResult = new DeferredResult($resultConverter, $rawResult); + + $platform = $this->createMock(PlatformInterface::class); + + $speechAwarePlatform = new ElevenLabsSpeechPlatform($platform, [ + 'tts_model' => 'foo', + ]); + + $this->assertNull($speechAwarePlatform->listen($deferredResult, [])); + } + + public function testPlatformCannotGenerateWithoutConfiguration() + { + $rawResult = $this->createMock(RawResultInterface::class); + $resultConverter = $this->createMock(ResultConverterInterface::class); + + $deferredResult = new DeferredResult($resultConverter, $rawResult); + + $platform = $this->createMock(PlatformInterface::class); + + $speechAwarePlatform = new ElevenLabsSpeechPlatform($platform, [ + 'stt_model' => 'foo', + ]); + + $this->assertNull($speechAwarePlatform->generate($deferredResult, [])); + } + + public function testListenerCanListenOnArrayInput() + { + $rawResult = $this->createMock(RawResultInterface::class); + + $resultConverter = $this->createMock(ResultConverterInterface::class); + $resultConverter->expects($this->once())->method('convert')->willReturn(new TextResult('foo')); + + $platform = $this->createMock(PlatformInterface::class); + $platform->expects($this->once())->method('invoke')->with('foo') + ->willReturn(new DeferredResult($resultConverter, $rawResult)); + + $speechAwarePlatform = new ElevenLabsSpeechPlatform($platform, [ + 'stt_model' => 'foo', + ]); + + $text = $speechAwarePlatform->listen(['text' => 'foo'], []); + + $this->assertInstanceOf(Text::class, $text); + $this->assertSame('foo', $text->getText()); + } + + public function testListenerCanListenOnMessageBag() + { + $rawResult = $this->createMock(RawResultInterface::class); + + $resultConverter = $this->createMock(ResultConverterInterface::class); + $resultConverter->expects($this->once())->method('convert')->willReturn(new TextResult('foo')); + + $deferredResult = new DeferredResult($resultConverter, $rawResult); + + $platform = $this->createMock(PlatformInterface::class); + $platform->expects($this->once())->method('invoke')->with('foo')->willReturn($deferredResult); + + $speechAwarePlatform = new ElevenLabsSpeechPlatform($platform, [ + 'stt_model' => 'foo', + ]); + + $text = $speechAwarePlatform->listen(new MessageBag( + Message::ofUser(Audio::fromFile(\dirname(__DIR__, 5).'/fixtures/audio.mp3')), + ), []); + + $this->assertInstanceOf(Text::class, $text); + $this->assertSame('foo', $text->getText()); + } + + public function testPlatformCanGenerate() + { + $rawResult = $this->createMock(RawResultInterface::class); + + $resultConverter = $this->createMock(ResultConverterInterface::class); + $resultConverter->expects($this->once())->method('convert')->willReturn(new TextResult('foo')); + + $secondResultConverter = $this->createMock(ResultConverterInterface::class); + $secondResultConverter->expects($this->never())->method('convert'); + + $deferredResult = new DeferredResult($resultConverter, $rawResult); + + $platform = $this->createMock(PlatformInterface::class); + $platform->expects($this->once())->method('invoke') + ->willReturn(new DeferredResult($secondResultConverter, $rawResult)); + + $speechPlatform = new ElevenLabsSpeechPlatform($platform, [ + 'tts_model' => 'foo', + 'tts_voice' => 'foo', + ]); + + $speech = $speechPlatform->generate($deferredResult, []); + + $this->assertSame('elevenlabs', $speech->getIdentifier()); + } +} diff --git a/src/platform/src/Message/UserMessage.php b/src/platform/src/Message/UserMessage.php index a4691f6e0d..17377d192a 100644 --- a/src/platform/src/Message/UserMessage.php +++ b/src/platform/src/Message/UserMessage.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Message; +use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Message\Content\Audio; use Symfony\AI\Platform\Message\Content\ContentInterface; use Symfony\AI\Platform\Message\Content\Image; @@ -63,6 +64,19 @@ public function hasAudioContent(): bool return false; } + public function getAudioContent(): Audio + { + foreach ($this->content as $content) { + if (!$content instanceof Audio) { + continue; + } + + return $content; + } + + throw new RuntimeException('No Audio content found.'); + } + public function hasImageContent(): bool { foreach ($this->content as $content) { diff --git a/src/platform/src/Result/BaseResult.php b/src/platform/src/Result/BaseResult.php index fb447594dd..93e3392f3b 100644 --- a/src/platform/src/Result/BaseResult.php +++ b/src/platform/src/Result/BaseResult.php @@ -12,6 +12,7 @@ namespace Symfony\AI\Platform\Result; use Symfony\AI\Platform\Metadata\MetadataAwareTrait; +use Symfony\AI\Platform\Speech\SpeechBagAwareTrait; /** * Base result of converted result classes. @@ -22,4 +23,5 @@ abstract class BaseResult implements ResultInterface { use MetadataAwareTrait; use RawResultAwareTrait; + use SpeechBagAwareTrait; } diff --git a/src/platform/src/Result/DeferredResult.php b/src/platform/src/Result/DeferredResult.php index c929963a27..100b7093e2 100644 --- a/src/platform/src/Result/DeferredResult.php +++ b/src/platform/src/Result/DeferredResult.php @@ -15,6 +15,7 @@ use Symfony\AI\Platform\Exception\UnexpectedResultTypeException; use Symfony\AI\Platform\Metadata\MetadataAwareTrait; use Symfony\AI\Platform\ResultConverterInterface; +use Symfony\AI\Platform\Speech\SpeechBagAwareTrait; use Symfony\AI\Platform\Vector\Vector; /** @@ -23,6 +24,7 @@ final class DeferredResult { use MetadataAwareTrait; + use SpeechBagAwareTrait; private bool $isConverted = false; private ResultInterface $convertedResult; @@ -64,6 +66,10 @@ public function getResult(): ResultInterface $this->isConverted = true; } + foreach ($this->getSpeechBag() as $speech) { + $this->convertedResult->addSpeech($speech); + } + return $this->convertedResult; } diff --git a/src/platform/src/Result/ResultInterface.php b/src/platform/src/Result/ResultInterface.php index 63ac7a4357..c5844e2415 100644 --- a/src/platform/src/Result/ResultInterface.php +++ b/src/platform/src/Result/ResultInterface.php @@ -13,6 +13,7 @@ use Symfony\AI\Platform\Metadata\Metadata; use Symfony\AI\Platform\Result\Exception\RawResultAlreadySetException; +use Symfony\AI\Platform\Speech\Speech; /** * @author Christopher Hertel @@ -33,4 +34,8 @@ public function getRawResult(): ?RawResultInterface; * @throws RawResultAlreadySetException if the result is tried to be set more than once */ public function setRawResult(RawResultInterface $rawResult): void; + + public function addSpeech(Speech $speech): void; + + public function getSpeech(string $identifier): Speech; } diff --git a/src/platform/src/Speech/Speech.php b/src/platform/src/Speech/Speech.php new file mode 100644 index 0000000000..e5e9ab2d9f --- /dev/null +++ b/src/platform/src/Speech/Speech.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Speech; + +use Symfony\AI\Platform\Result\DeferredResult; + +/** + * @author Guillaume Loulier + */ +final class Speech +{ + /** + * @param string|array $payload + */ + public function __construct( + private readonly string|array $payload, + private readonly DeferredResult $result, + private readonly string $identifier, + ) { + } + + /** + * @return string|array + */ + public function getPayload(): string|array + { + return $this->payload; + } + + public function asBinary(): string + { + return $this->result->asBinary(); + } + + public function getIdentifier(): string + { + return $this->identifier; + } +} diff --git a/src/platform/src/Speech/SpeechBag.php b/src/platform/src/Speech/SpeechBag.php new file mode 100644 index 0000000000..79d6ff73aa --- /dev/null +++ b/src/platform/src/Speech/SpeechBag.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Speech; + +use Symfony\AI\Platform\Exception\InvalidArgumentException; + +/** + * @author Guillaume Loulier + * + * @implements \IteratorAggregate + */ +final class SpeechBag implements \IteratorAggregate, \Countable +{ + /** + * @var Speech[] + */ + private array $speeches = []; + + public function add(Speech $speech): void + { + $this->speeches[$speech->getIdentifier()] = $speech; + } + + public function get(string $identifier): Speech + { + return $this->speeches[$identifier] ?? throw new InvalidArgumentException(\sprintf('No speech with identifier "%s" found.', $identifier)); + } + + public function count(): int + { + return \count($this->speeches); + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->speeches); + } +} diff --git a/src/platform/src/Speech/SpeechBagAwareTrait.php b/src/platform/src/Speech/SpeechBagAwareTrait.php new file mode 100644 index 0000000000..8f561605f7 --- /dev/null +++ b/src/platform/src/Speech/SpeechBagAwareTrait.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\Speech; + +/** + * @author Guillaume Loulier + */ +trait SpeechBagAwareTrait +{ + private ?SpeechBag $speechBag = null; + + public function addSpeech(?Speech $speech): void + { + if (null === $this->speechBag) { + $this->speechBag = new SpeechBag(); + } + + $this->speechBag->add($speech); + } + + public function getSpeech(string $identifier): Speech + { + if (null === $this->speechBag) { + $this->speechBag = new SpeechBag(); + } + + return $this->speechBag->get($identifier); + } + + public function getSpeechBag(): SpeechBag + { + return $this->speechBag ??= new SpeechBag(); + } +} diff --git a/src/platform/src/Speech/SpeechListener.php b/src/platform/src/Speech/SpeechListener.php new file mode 100644 index 0000000000..345aadb0fb --- /dev/null +++ b/src/platform/src/Speech/SpeechListener.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Speech; + +use Symfony\AI\Platform\Event\InvocationEvent; +use Symfony\AI\Platform\Event\ResultEvent; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * @author Guillaume Loulier + */ +final class SpeechListener implements EventSubscriberInterface +{ + /** + * @param SpeechPlatformInterface[] $speechPlatforms + */ + public function __construct(private readonly iterable $speechPlatforms) + { + } + + public static function getSubscribedEvents(): array + { + return [ + InvocationEvent::class => ['onInvocation', 255], + ResultEvent::class => 'onResult', + ]; + } + + public function onInvocation(InvocationEvent $event): void + { + $input = $event->getInput(); + $options = $event->getOptions(); + + foreach ($this->speechPlatforms as $speechToTextPlatform) { + $overriddenInput = $speechToTextPlatform->listen($input, $options); + + if (null === $overriddenInput) { + continue; + } + + if (!$input instanceof MessageBag) { + $event->setInput($overriddenInput); + + return; + } + + $event->setInput(new MessageBag( + Message::ofUser($overriddenInput), + )); + } + } + + public function onResult(ResultEvent $event): void + { + $deferredResult = $event->getDeferredResult(); + $options = $event->getOptions(); + + foreach ($this->speechPlatforms as $textToSpeechPlatform) { + $speech = $textToSpeechPlatform->generate($deferredResult, $options); + + if (null === $speech) { + continue; + } + + $deferredResult->addSpeech($speech); + + $event->setDeferredResult($deferredResult); + } + } +} diff --git a/src/platform/src/Speech/SpeechPlatformInterface.php b/src/platform/src/Speech/SpeechPlatformInterface.php new file mode 100644 index 0000000000..3ec7270159 --- /dev/null +++ b/src/platform/src/Speech/SpeechPlatformInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Speech; + +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Result\DeferredResult; + +/** + * @author Guillaume Loulier + */ +interface SpeechPlatformInterface +{ + public function generate(DeferredResult $result, array $options): ?Speech; + + public function listen(object|array|string $input, array $options): ?Text; +} diff --git a/src/platform/tests/Speech/SpeechBagTest.php b/src/platform/tests/Speech/SpeechBagTest.php new file mode 100644 index 0000000000..d26e8847c8 --- /dev/null +++ b/src/platform/tests/Speech/SpeechBagTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Speech; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Result\DeferredResult; +use Symfony\AI\Platform\Result\RawResultInterface; +use Symfony\AI\Platform\ResultConverterInterface; +use Symfony\AI\Platform\Speech\Speech; +use Symfony\AI\Platform\Speech\SpeechBag; + +final class SpeechBagTest extends TestCase +{ + public function testBagCanStoreSpeech() + { + $converter = $this->createMock(ResultConverterInterface::class); + $rawResult = $this->createMock(RawResultInterface::class); + + $result = new DeferredResult($converter, $rawResult); + + $bag = new SpeechBag(); + + $bag->add(new Speech([], $result, 'foo')); + + $this->assertCount(1, $bag); + + $this->assertInstanceOf(Speech::class, $bag->get('foo')); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No speech with identifier "bar" found.'); + $this->expectExceptionCode(0); + $bag->get('bar'); + } +} diff --git a/src/platform/tests/Speech/SpeechListenerTest.php b/src/platform/tests/Speech/SpeechListenerTest.php new file mode 100644 index 0000000000..536ce35adb --- /dev/null +++ b/src/platform/tests/Speech/SpeechListenerTest.php @@ -0,0 +1,125 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Speech; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabs; +use Symfony\AI\Platform\Event\InvocationEvent; +use Symfony\AI\Platform\Event\ResultEvent; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Result\DeferredResult; +use Symfony\AI\Platform\Result\RawResultInterface; +use Symfony\AI\Platform\ResultConverterInterface; +use Symfony\AI\Platform\Speech\Speech; +use Symfony\AI\Platform\Speech\SpeechListener; +use Symfony\AI\Platform\Speech\SpeechPlatformInterface; + +final class SpeechListenerTest extends TestCase +{ + public function testListenerIsConfigured() + { + $this->assertArrayHasKey(InvocationEvent::class, SpeechListener::getSubscribedEvents()); + $this->assertArrayHasKey(ResultEvent::class, SpeechListener::getSubscribedEvents()); + $this->assertSame(['onInvocation', 255], SpeechListener::getSubscribedEvents()[InvocationEvent::class]); + $this->assertSame('onResult', SpeechListener::getSubscribedEvents()[ResultEvent::class]); + } + + public function testListenerCannotBeTriggeredWithoutSupporting() + { + $speechPlatform = $this->createMock(SpeechPlatformInterface::class); + $speechPlatform->expects($this->once())->method('listen')->willReturn(null); + + $listener = new SpeechListener([ + $speechPlatform, + ]); + + $listener->onInvocation(new InvocationEvent(new ElevenLabs('foo'), [])); + } + + public function testListenerCanBeTriggeredWhenSupporting() + { + $speechPlatform = $this->createMock(SpeechPlatformInterface::class); + $speechPlatform->expects($this->once())->method('listen')->willReturn(new Text('foo')); + + $listener = new SpeechListener([ + $speechPlatform, + ]); + + $event = new InvocationEvent(new ElevenLabs('foo'), []); + + $listener->onInvocation($event); + + $this->assertInstanceOf(Text::class, $event->getInput()); + } + + public function testListenerCanBeTriggeredWhenSupportingWithMessageBag() + { + $speechPlatform = $this->createMock(SpeechPlatformInterface::class); + $speechPlatform->expects($this->once())->method('listen')->willReturn(new Text('foo')); + + $listener = new SpeechListener([ + $speechPlatform, + ]); + + $event = new InvocationEvent(new ElevenLabs('foo'), new MessageBag()); + + $listener->onInvocation($event); + + $this->assertInstanceOf(MessageBag::class, $event->getInput()); + $this->assertCount(1, $event->getInput()); + } + + public function testProviderCannotBeTriggeredWithoutSupporting() + { + $rawResult = $this->createMock(RawResultInterface::class); + $resultConverter = $this->createMock(ResultConverterInterface::class); + + $deferredResult = new DeferredResult($resultConverter, $rawResult); + + $speechPlatform = $this->createMock(SpeechPlatformInterface::class); + $speechPlatform->expects($this->once())->method('generate')->willReturn(null); + + $listener = new SpeechListener([ + $speechPlatform, + ]); + + $event = new ResultEvent(new ElevenLabs('foo'), $deferredResult); + + $listener->onResult($event); + } + + public function testProviderCanBeTriggeredWhenSupporting() + { + $rawResult = $this->createMock(RawResultInterface::class); + $resultConverter = $this->createMock(ResultConverterInterface::class); + + $deferredResult = new DeferredResult($resultConverter, $rawResult); + $speechDeferredResult = new DeferredResult($resultConverter, $rawResult); + + $speech = new Speech([], $speechDeferredResult, 'foo'); + + $speechPlatform = $this->createMock(SpeechPlatformInterface::class); + $speechPlatform->expects($this->once())->method('generate')->willReturn($speech); + + $listener = new SpeechListener([ + $speechPlatform, + ]); + + $event = new ResultEvent(new ElevenLabs('foo'), $deferredResult); + + $listener->onResult($event); + + $this->assertSame($deferredResult, $event->getDeferredResult()); + $this->assertSame($speech, $event->getDeferredResult()->getSpeech('foo')); + } +}