Search by

ateskaya / laravel-voice

ateskaya

Transcribe long recordings and pull structured fields out of them, inside a Laravel application.

Package info

github.com/ateskaya/laravel-voice

pkg:composer/ateskaya/laravel-voice

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-09-22 11:51 UTC

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
  • ffmpeg and ffprobe on 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 = false row is recorded; re-running it is a decision for the caller, not an automatic loop that spends money.
  • Only one transcription provider implemented. Transcriber is 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.