Symfony-first audio, video, speaker diarization, timestamps, subtitles and live speech transcription. Configure named profiles in YAML, autowire them like Flysystem storages, and keep provider-specific HTTP, polling and WebSocket details out of application code.
Palatine is the first built-in provider. The public contracts are provider-neutral, so another speech provider can be added as a normal Symfony service.
ext-curl and ext-fileinfoffmpeg and ffprobe for video, WebM and traffic-efficient normalizationcomposer require errogaht/transcribe-bundle
If Symfony Flex has not enabled the bundle, add it to config/bundles.php:
<?php
return [
// ...
Errogaht\TranscribeBundle\TranscribeBundle::class => ['all' => true],
];
Keep the API key in an environment secret:
PALATINE_API_KEY=replace-me
Create config/packages/transcribe.yaml:
transcribe:
providers:
palatine:
type: palatine
api_key: '%env(PALATINE_API_KEY)%'
transcribers:
fast:
provider: palatine
mode: plain
language: ru
subtitles:
provider: palatine
mode: timestamped
meeting:
provider: palatine
mode: diarized
min_speakers: 2
max_speakers: 6
default_transcriber: fast
streams:
microphone:
provider: palatine
model: palatine_stream
language: ru
codec: linear16
sample_rate: 48000
channels: 1
default_stream: microphone
The default profile is available through TranscriberInterface:
use Errogaht\TranscribeBundle\Contract\TranscriberInterface;
final class VoiceMessageHandler
{
public function __construct(private TranscriberInterface $transcriber)
{
}
public function __invoke(string $audioPath): string
{
return $this->transcriber->transcribe($audioPath)->text;
}
}
Each named profile is also a non-shared, autowireable service. The argument name selects it:
use Errogaht\TranscribeBundle\Contract\TranscriberInterface;
final class MeetingImporter
{
public function __construct(private TranscriberInterface $meeting)
{
}
public function import(string $videoPath): void
{
$result = $this->meeting->transcribe($videoPath);
foreach ($result->segments as $segment) {
// $segment->speaker, ->start, ->end and ->text are provider-neutral.
}
}
}
For dynamic selection, inject TranscriberRegistry and call get('subtitles'). Inputs may be a path, SplFileInfo, or in-memory bytes:
use Errogaht\TranscribeBundle\Value\MediaInput;
$result = $transcriber->transcribe(
MediaInput::fromBytes($upload, 'voice.ogg', 'audio/ogg'),
);
Per-call options can override a profile without mutating it:
use Errogaht\TranscribeBundle\Value\TranscriptionOptions;
$result = $transcriber->transcribe($path, TranscriptionOptions::diarized('ru', 2));
The default auto strategy passes compact AAC, M4A, MP3 and Ogg/Opus audio through unchanged. Video, browser WebM, lossless and unknown inputs are converted to mono Ogg/Opus at 16 kHz and 24 kbit/s. This extracts only the first audio stream and normally reduces upload traffic significantly.
transcribe:
media:
strategy: auto # auto, always or never
ffmpeg_binary: ffmpeg
ffprobe_binary: ffprobe
codec: libopus
bitrate: 24k
sample_rate: 16000
channels: 1
process_timeout: 900
Caller-owned files are never modified or deleted. Temporary files have mode 0600 and are removed in a finally block, including provider failures.
Palatine live audio uses a full-duplex session. Audio chunks must already match the configured codec, sample rate and channel count.
use Errogaht\TranscribeBundle\Contract\StreamingTranscriberInterface;
use Errogaht\TranscribeBundle\Enum\StreamEventType;
final class LiveCaptionService
{
public function __construct(private StreamingTranscriberInterface $microphone)
{
}
public function run(iterable $pcmChunks): string
{
$session = $this->microphone->open();
try {
foreach ($pcmChunks as $chunk) {
$session->send($chunk);
$event = $session->receive();
if ($event?->type === StreamEventType::Partial) {
// Publish $event->chunkText or $event->fullText to the UI.
}
}
$session->finish();
while (($event = $session->receive(10.0)) !== null) {
if ($event->type === StreamEventType::Final) {
return $event->fullText ?? '';
}
}
return '';
} finally {
$session->close();
}
}
}
The provider token stays server-side. Browsers should connect to an application-owned WebSocket or streaming controller; do not expose Palatine credentials to frontend code.
TranscriptionResult always contains normalized text, language, duration, segments, words, provider, model and safe metadata. Inject FormatterRegistry to render text, timestamped, speaker, json, srt or vtt.
$srt = $formatters->get('srt')->render($result);
The CLI offers the same path:
bin/console transcribe:media interview.mp4 --transcriber=meeting --format=speaker
bin/console transcribe:media movie.mkv --transcriber=subtitles --format=srt --output=movie.srt
bin/console transcribe:providers
bin/console transcribe:debug
In auto transport mode, diarization, media at least 55 seconds long, or prepared files at least 5 MB use Palatine's task API. The bundle schedules, polls, normalizes and releases the task internally; application code receives one completed result. Direct calls are used for smaller files. Retryable transport, rate-limit and server failures are retried with bounded backoff.
All thresholds and timeouts are configurable under a provider. Set transport: direct or transport: polling on a profile only when you need an explicit override.
For durable Messenger workflows, autowire the named task service separately:
use Errogaht\TranscribeBundle\Contract\TaskTranscriberInterface;
public function __construct(
private TaskTranscriberInterface $podcastTaskTranscriber,
) {
}
Call submit() once, persist the returned opaque TranscriptionTask, and use short delayed messages to call check() until the state is terminal. A successful status contains the same normalized TranscriptionResult; call release() only after the result is durably stored. This flow survives worker restarts without uploading or billing the source twice. The primary TranscriberInterface remains the convenient completed-result API.
The bundle intentionally does not own your queue, retries or result persistence. Put the same configured transcriber inside an application message handler:
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class TranscribeRecordingHandler
{
public function __construct(private TranscriberInterface $meeting)
{
}
public function __invoke(TranscribeRecording $message): void
{
$result = $this->meeting->transcribe($message->path);
// Persist the completed result and let Messenger own delivery retries.
}
}
Do not pass audio bytes through the queue. Pass an immutable storage identifier or path that remains valid until the worker finishes.
There is no hard dependency on errogaht/neuron-ai-bundle. Inject a configured transcriber into an agent tool or orchestration service and pass TranscriptionResult::text to the named agent:
final class VoiceAgent
{
public function __construct(
private TranscriberInterface $fast,
private SupportAgent $supportAgent,
) {
}
public function answer(string $voicePath): mixed
{
return $this->supportAgent->chat($this->fast->transcribe($voicePath)->text);
}
}
This keeps speech recognition replaceable and lets Neuron AI agents, workflows, RAG and Messenger remain configured by their own bundle.
Implement TranscriptionProviderInterface and register the service:
transcribe:
providers:
private_speech:
type: service
service: App\Speech\PrivateSpeechProvider
default_provider: private_speech
Implement StreamingTranscriptionProviderInterface on the same service if it supports live sessions. A custom formatter implements TranscriptFormatterInterface and carries the transcribe.formatter tag.
See configuration, runtime API, provider extension guide, security, and the architecture graph.
Completed file calls dispatch TranscriptionStartedEvent, TranscriptionCompletedEvent and TranscriptionFailedEvent. Live sessions dispatch StreamOpenedEvent, StreamEventReceivedEvent and StreamClosedEvent. Logs include profile, provider, media size, duration and normalized counts, but never API keys, raw audio or transcript text.
All bundle failures implement TranscriptionExceptionInterface. Provider exceptions expose isRetryable() and statusCode() so application and Messenger policies can distinguish invalid credentials from temporary failures.
composer validate --strict
composer check
License: MIT.
How can I help you explore Laravel packages today?