ateskaya / laravel-voice
Transcribe long recordings and pull structured fields out of them, inside a Laravel application.
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.8
- illuminate/bus: ^11.0|^12.0
- illuminate/database: ^11.0|^12.0
- illuminate/queue: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
- symfony/process: ^7.0
Requires (Dev)
- orchestra/testbench: ^9.0
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-23 13:23:47 UTC
README
Transcribe long recordings and pull structured fields out of them, inside a Laravel application.
Hand it an hour-long call. Get back a merged transcript with word-level timestamps, and a validated JSON object containing only the fields you asked for.
The problem this solves
Transcribing a two-minute voice note is a solved problem: one API call, done. Everything interesting starts when the recording is an hour long.
It won't fit. The Whisper endpoint rejects uploads over 25 MB, and any provider will time out long before an hour of audio finishes. So the audio has to be split.
Splitting loses words. A cut placed mid-word loses that word from both sides. So chunks have to overlap.
Overlap duplicates words. The overlapping seconds get transcribed twice, and naive concatenation produces the artefact that gives a careless pipeline away:
"...so I told him the invoice was already paid the invoice was already paid and he said he would check."
Timestamps can't fix it. The two transcriptions of the same audio don't agree to the millisecond, so cutting at a fixed time lands mid-word about as often as not.
This package's answer is to find the seam by content. Take the words either side of it and look for the longest suffix of the left that matches a prefix of the right. That match is the passage transcribed twice, and where it starts is where the two halves join. Matching runs on normalised words, because case and punctuation are precisely what two runs of the same model disagree about.
When no anchor exists — silence across the seam, music, a provider that genuinely heard two different things — it falls back to cutting at the midpoint of the overlap. That can still clip a word, which is why it is the fallback and not the strategy.
src/Audio/TranscriptMerger.php is the interesting file. Its tests are in tests/Feature/TranscriptMergerTest.php, and they run without touching any API.
What it does
- Splits long audio with ffmpeg, re-encoding to 16 kHz mono Opus — the rate speech models downsample to anyway, which makes chunk size predictable instead of something you have to measure per file.
- Merges overlapping transcripts by content anchor, with a time-based fallback.
- Resumes. Chunk transcripts are cached as they complete, so a failure on chunk 9 of 12 doesn't re-transcribe and re-pay for the first eight.
- Extracts structured data against a JSON Schema you define, and validates what comes back against that same schema.
- Distinguishes transient from permanent failures. Rate limits retry with backoff; a 400 stops immediately.
- Records cost per recording — transcription by the minute, extraction by provider-reported tokens.
Requirements
- PHP 8.2+, Laravel 11 or 12
ffmpegandffprobeon the server- An OpenAI API key, or your own implementation of
Transcriber/Extractor - A queue worker
Install
composer require ateskaya/laravel-voice php artisan vendor:publish --tag=voice-config php artisan migrate php artisan queue:work --queue=voice
OPENAI_API_KEY=sk-... VOICE_CHUNK_SECONDS=600 VOICE_OVERLAP_SECONDS=8
Usage
use IbrahimEnsar\Voice\Models\Recording; use IbrahimEnsar\Voice\Jobs\TranscribeRecording; use IbrahimEnsar\Voice\Jobs\ExtractFromTranscript; $recording = Recording::create([ 'collection' => 'sales-calls', 'external_ref' => $crmCallId, 'disk' => 's3', 'path' => $uploadedPath, 'language' => 'en', ]); TranscribeRecording::dispatch($recording->id);
Poll status: pending → transcribing → transcribed → extracting → ready, or failed with failure_reason set.
ExtractFromTranscript::dispatch($recording->id, 'sales_call'); $extraction = $recording->extractions()->where('schema_key', 'sales_call')->first(); if ($extraction->valid) { $extraction->data['outcome']; // "pending" $extraction->data['next_step']; // "send the revised quote by Friday" $extraction->data['amount_discussed']; // 4200 or null } else { $extraction->schema_violations; // what the model got wrong }
Schemas live in config/voice.php. One recording can carry several — a sales summary and a compliance check want different fields out of the same call.
Design decisions
The overlap is deliberate and it is not free
At the default 600-second chunks with an 8-second overlap, an hour of audio becomes 7 chunks and you pay for roughly 48 extra seconds of transcription. That is under 1.5% overhead, and it buys a seam that can be found by content rather than guessed at by clock.
Below about 5 seconds the overlap stops being reliable: a pause can swallow the whole window and leave no words to match on, forcing the time-based fallback exactly where it is weakest.
Chunk transcripts are cached on disk
Transcription costs money per attempt. A job that fails on chunk 9 of 12 and restarts from zero pays three times for the same audio. Caching each chunk's transcript as it completes makes the retry cost cents instead of the whole job — and the cache is cleared once the merged transcript is on the record, because audio chunks are large and the merged result is what anyone reads.
The extraction output is validated even though the model is in structured-output mode
Strict JSON Schema mode makes a violation unlikely. It does not make it impossible — and an unchecked wrong field doesn't announce itself, it becomes a wrong row in someone's CRM.
So SchemaValidator re-checks what came back, and violations are stored rather than swallowed. A valid = false row with the specific violations recorded is the evidence for whether the prompt or the schema needs changing. Discarding it would leave you guessing.
The validator covers the subset an extraction schema actually uses — object shape, required keys, scalar and array types, enums. A full JSON Schema library would add $ref resolution and format assertions this never needs.
The extraction prompt is told to prefer null over a plausible guess
Transcripts contain speech recognition errors, and they cluster in exactly the fields worth extracting: names, amounts, dates. A model asked for amount_discussed from a garbled number will produce a confident figure. The prompt says, explicitly, to return null where the transcript is unclear — because a null is a question someone can go and answer, and a wrong number is one nobody knows to ask.
Transient and permanent failures are separate types
| Condition | Response |
|---|---|
| 429, 5xx, connection failure | Release with backoff, honouring Retry-After |
| 400, 401, 404, unreadable audio | Fail immediately, record the reason, stop |
retryUntil() gives transcription a two-hour wall-clock deadline rather than a fixed attempt count, so a sustained throttle doesn't exhaust the attempts in a few minutes while the API is merely busy.
Failure modes, and what happens
| What happens | What the package does | What you should do |
|---|---|---|
| Provider rate-limits mid-recording | Released with backoff; completed chunks stay cached | Nothing |
| Audio file is not audio, or zero length | Fails before any API call is made | Check the upload |
| ffmpeg missing | Fails immediately with the reason | Install it |
| Silence across a seam | Falls back to the time-based cut | Nothing, but a word may be clipped |
| Provider returns no word timestamps | Falls back to segment times, spread evenly | Seam precision drops; it is not silent about it |
| Model invents a field | valid = false, violations stored, data kept |
Read the violations, fix the schema or prompt |
| Model guesses a garbled number | Prompt says prefer null, but this still happens sometimes | Treat extracted amounts as needing review |
| Two speakers, no diarisation | Turns are grouped by length instead of speaker | Use a diarising provider if speaker attribution matters |
What this does not do
- No diarisation of its own. Speaker labels are passed through if the provider supplies them. Whisper does not.
- No real-time / streaming. This is a batch pipeline for recordings that already exist.
- No redaction. Transcripts of real calls contain personal data and this package does nothing to mask it. That is your application's decision to make deliberately.
- No retry of a bad extraction. A
valid = falserow is recorded; re-running it is a decision for the caller, not an automatic loop that spends money. - Only one transcription provider implemented.
Transcriberis an interface with one implementation; Deepgram or a self-hosted Whisper would be a new class and no changes anywhere else.
Tests
composer install vendor/bin/phpunit
Both test suites run without an API key or any audio: the merger is tested on synthetic word timings, and the schema validator on hand-built objects. Those are the two pieces where a bug is silent, so they are the two pieces that are tested.
Licence
MIT.