Skip to content

[Platform][ElevenLabs] Implement speech-to-text functionality with support for additional formats and options#2272

Open
khaperets wants to merge 2 commits into
symfony:mainfrom
khaperets:main
Open

[Platform][ElevenLabs] Implement speech-to-text functionality with support for additional formats and options#2272
khaperets wants to merge 2 commits into
symfony:mainfrom
khaperets:main

Conversation

@khaperets

Copy link
Copy Markdown
Q A
Bug fix? no
New feature? yes
Docs? yes
Issues Fix #2266
License MIT

[ElevenLabs] Support SRT output for speech-to-text via additional_formats

This exposes ElevenLabs Scribe's speech-to-text options and additional
transcript formats (e.g. SRT subtitles) through PlatformInterface::invoke(),
so subtitle generation no longer requires bypassing the bridge and calling
ElevenLabs directly with the HTTP client.

Motivation

The ElevenLabs /v1/speech-to-text endpoint accepts multipart fields such as
language_code, tag_audio_events, num_speakers, diarize,
timestamps_granularity and additional_formats, and returns the requested
export formats under additional_formats[].content. Until now the bridge sent
only file and model_id, and asText() exposed only the plain transcript,
so generating SRT required a hand-rolled HttpClient call (see the workaround in
the issue).

Changes

1. Forwarding speech-to-text options — ElevenLabsClient

ElevenLabsClient::doSpeechToTextRequest() now receives the merged request +
model options (mirroring the text-to-speech branch) and forwards them as
multipart fields. Because Symfony HttpClient builds multipart bodies via
http_build_query, options are normalized to the wire format ElevenLabs expects:

  • arrays (e.g. additional_formats) are JSON-encoded into a single field;
  • booleans are sent as "true"/"false" (not "1"/"");
  • other scalars are cast to strings.

2. First-class result API — Bridge\ElevenLabs\Result\TranscriptionResult

A new bridge-scoped DTO carrying the transcript text alongside the returned
additional_formats. It transparently decodes base64-encoded content and
exposes:

  • getText()
  • getAdditionalFormats()
  • getAdditionalFormat(string $format) — resolved by requested_format
    (e.g. srt, txt, html)
  • asSubRipText() — convenience accessor for SRT

3. Converter — ElevenLabsResultConverter

Speech-to-text responses now return a MultiPartResult containing both a
TextResult (so asText() keeps working) and an ObjectResult wrapping the
new TranscriptionResult (so asObject() returns the structured result).
This mirrors the pattern used by the Mistral OCR bridge and avoids adding
bridge-specific methods to the core DeferredResult.

Usage

use Symfony\AI\Platform\Bridge\ElevenLabs\ElevenLabs;
use Symfony\AI\Platform\Bridge\ElevenLabs\Factory;
use Symfony\AI\Platform\Capability;
use Symfony\AI\Platform\Message\Content\Audio;

$platform = Factory::createPlatform(apiKey: $apiKey);

$result = $platform->invoke(
    model: new ElevenLabs('scribe_v2', [
        Capability::SPEECH_TO_TEXT,
        Capability::INPUT_AUDIO,
        Capability::OUTPUT_TEXT,
    ]),
    input: Audio::fromFile('/path/audio.mp3'),
    options: [
        'language_code' => 'en',
        'tag_audio_events' => false,
        'num_speakers' => 1,
        'diarize' => true,
        'timestamps_granularity' => 'word',
        'additional_formats' => [
            ['format' => 'srt', 'include_timestamps' => true],
        ],
    ],
);

$result->asText();                       // plain transcript
$result->asObject()->asSubRipText();     // SRT subtitles
$result->asObject()->getAdditionalFormat('srt');

// The raw payload is still available:
$result->getRawResult()->getData()['additional_formats'][0]['content'];

Backward compatibility

$result->asText() is preserved unchanged for speech-to-text, so existing
consumers keep working. The converted result type changes from TextResult to
MultiPartResult, but the public entry points (asText(), asObject(),
getRawResult()) remain consistent: asText() behaves identically, and
asObject() now succeeds (returning a TranscriptionResult) where it
previously threw UnexpectedResultTypeException. No UPGRADE.md entry is
therefore required.

Tests

  • Updated ElevenLabsConverterTest for the new MultiPartResult shape, with
    dedicated cases for plain transcripts, additional formats (both raw and
    base64-encoded content) and the absence of additional formats.
  • Added ElevenLabsClientTest::testClientCanPerformSpeechToTextRequestWithOptions
    asserting that the multipart body forwards language_code, diarize
    (true), tag_audio_events (false), num_speakers (1),
    timestamps_granularity and the JSON-encoded additional_formats.
  • Full platform suite passes (733 tests).

Documentation

  • New "Speech-to-Text" section in docs/components/platform.rst covering the
    basic transcript and the SRT export flow (validated with doctor-rst).
  • New runnable example examples/elevenlabs/speech-to-text-srt.php.
  • CHANGELOG.md entry under the unreleased 0.11 section.

@chr-hertel chr-hertel changed the title [ElevenLabs] Implement speech-to-text functionality with support for additional formats and options [Platform][ElevenLabs] Implement speech-to-text functionality with support for additional formats and options Jul 10, 2026
@chr-hertel chr-hertel added the Platform Issues & PRs about the AI Platform component label Jul 10, 2026

@chr-hertel chr-hertel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @khaperets - thanks for working on this! 🙏

Added a first round of comments and tested the provided example - worked right away 😎👍

Thanks already for the follow-up patch!

Comment on lines +22 to +26
model: new ElevenLabs('scribe_v2', [
Capability::SPEECH_TO_TEXT,
Capability::INPUT_AUDIO,
Capability::OUTPUT_TEXT,
]),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could just use $platform->invoke('scribe_v2', Audio::fromFile(...), [...])

no need for instantiating the ElevenLabs model class i guess

$converter->convert($rawResult);
}

private function extractTranscription(ResultInterface $result): TranscriptionResult

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's go with "Transcript" instead of "Transcription" - in my understanding "Transcription" is what ElevenLabs does and "Transcript" is the result of it

Suggested change
private function extractTranscription(ResultInterface $result): TranscriptionResult
private function extractTranscript(ResultInterface $result): Transcript

*
* @author Dmytro Khaperets <khaperets@gmail.com>
*/
final class TranscriptionResult

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rename to Transcript and drop the Result suffix - it is used quite heavily in the Platform component already in a different meaning

Comment on lines +824 to +828
model: new ElevenLabs('scribe_v2', [
Capability::SPEECH_TO_TEXT,
Capability::INPUT_AUDIO,
Capability::OUTPUT_TEXT,
]),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't think we need to instantiate the class here, just use the model identifier scribe_v2

private function stringifySpeechToTextOption(int|string $key, mixed $value): string
{
if (\is_array($value)) {
return json_encode($value, \JSON_THROW_ON_ERROR);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please catch that JsonException and do an upcast to Symfony\AI\Platform\Exception\InvalidArgumentException while forwarding the original exception

Comment on lines +83 to +89
/** @var list<array<string, mixed>> $additionalFormats */
$additionalFormats = \is_array($data['additional_formats'] ?? null) ? $data['additional_formats'] : [];

return new MultiPartResult([
new TextResult($text),
new ObjectResult(new TranscriptionResult($text, $additionalFormats)),
]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't think we should be that ambiguous here, but

  1. if the user doesn't provide any additional_formats option => return TextResult
  2. if the user requests additions_formats => return ObjectResult with Transcript object

see the Whisper implementation in OpenAI bridge for reference

}

/**
* @return list<array<string, mixed>>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can type this as class as well, no matter which format is chosen, we always have:

  • requested_format: string
  • file_extension: string
  • content_type: string
  • is_base64_encoded: boolean
  • content: string

@chr-hertel

Copy link
Copy Markdown
Member

btw, PHPStan issue in the demo should be gone after rebase on latest main

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Feature New feature Platform Issues & PRs about the AI Platform component Status: Needs Work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ElevenLabs] Support SRT output for speech-to-text via additional_formats

3 participants