Audio AI · Distributed Systems Case study

ScribeFlow

An event-driven audio transcription platform with timestamped transcripts and JSON, SRT and VTT exports.

Audio AI · Distributed Systems

ScribeFlow ScribeFlow

System Design & End-to-End Engineering

System map · Processing pipeline

Reliability model

  • Never silently lost A crash redelivers; a duplicate is recognised and skipped
  • The queue is not the database PostgreSQL holds job state; RabbitMQ holds a transient signal

Job path

  1. Accept

    Validated, stored, 202 with an id

  2. Queue

    RabbitMQ carries the signal only

  3. Transcribe

    Normalise, segment, transcribe

  4. Commit

    Transcript into PostgreSQL

  5. Acknowledge

    Only after the commit

Retry with backoff, then dead-letter

Reliability model

  • Ack-after-commit
  • Idempotent consumers
  • Content-hash deduplication
  • Dead-letter parking

Engineering

NestJS · Next.js · Python · PostgreSQL · RabbitMQ · FFmpeg · faster-whisper

Independent system build · Architecture documented

Role
System Design & End-to-End Engineering
Disclosure
Independent system build · Architecture documented

The problem

What the system had to solve.

Transcribing audio takes tens of seconds to minutes and holds a CPU while it runs. Doing that inside an HTTP request means clients time out, the process cannot be restarted without losing work in flight, and one large upload degrades every other request being served.

The system had to accept uploads, survive worker crashes and broker restarts without losing an accepted job, retry what can be retried, and report progress the whole time.

What I owned

The responsibility behind the work.

Designed and built the whole system: the asynchronous API, the job model, the queue topology, the Python transcription workers, the failure handling and the web client.

Designed and built the asynchronous upload, queue and worker architecture: a NestJS API, PostgreSQL job state, RabbitMQ delivery, Python transcription workers, retry and dead-letter handling, and a Next.js client.

Engineering approach

How it was built.

The API accepts, records and publishes, then returns an identifier; it never reads the queue and never transcribes. Job state lives in PostgreSQL as the source of truth while RabbitMQ carries transient work signals. Workers acknowledge only after the transcript is committed, check job status before starting so redelivery is safe, and fail into a retry queue with growing delays before dead-lettering.

Job path

  1. Accept
  2. Queue
  3. Transcribe
  4. Commit
  5. Acknowledge

The API never does heavy work

One rule the whole design follows from.

Transcribing audio takes tens of seconds to minutes and pins a CPU while it runs. An HTTP request cannot hold that open: the client times out, the server cannot be restarted without losing work in flight, and one slow upload degrades every other request on the process.

So the API accepts the file, records the job, publishes a message and returns immediately with an identifier. Everything expensive happens somewhere else, and the client polls for the result.

Where the work happens

API — fast path only

  • Validate type and size
  • Stream the file to shared storage
  • Insert the job row
  • Publish one message
  • Return 202 with an id

Worker — all the cost

  • Consume one message at a time
  • Normalise audio
  • Run the transcription model
  • Write the transcript
  • Acknowledge the message
The two sides fail, retry and scale independently. Neither can take the other down.

The API never reads the queue. It answers every status request from the database, which means queue depth has no effect on how fast a client can be told what is happening.

Upload to transcript

The whole path, end to end.

Request lifecycle

  1. Upload accepted Validated and streamed to shared storage
  2. Job row inserted Status queued
  3. Message published Persistent, publisher-confirmed
  4. 202 returned with the job id
  5. Worker consumes and transcribes
  6. Transcript committed Status completed
  7. Client polls and receives the result
The client holds an identifier from the second the upload is accepted, and the rest is asynchronous.

The message is published as persistent and with publisher confirms, so an accepted upload is not lost by a broker restart between the API returning 202 and the worker picking the job up.

The queue is not the database

Where job state actually lives.

A queue is a work signal, not a record. Messages are transient, can be redelivered, and disappear once acknowledged. None of that is acceptable for "what is the status of my transcription".

Job state lives in PostgreSQL and is the single source of truth. The message carries only enough to find the work: an identifier and a file path.

Two stores, two jobs

PostgreSQL — source of truth

  • Job status and progress
  • Attempt count
  • The transcript itself, as JSONB
  • Content hash for deduplication
  • Survives everything

RabbitMQ — transient signal

  • Job id and file path
  • Attempt number
  • Delivery and redelivery
  • Retry scheduling and dead-lettering
  • Discarded on acknowledgement
Losing every message in the broker loses no job state. The rows are still there, and the jobs can be republished.

Deduplication by content

The same audio twice is not two jobs.

Before a job is created, the uploaded file is hashed as it is read from disk. If a completed job already exists for that hash, the API returns that job immediately and removes the temporary file. No message is published, and no CPU is spent transcribing something already transcribed.

Upload path

  1. Upload received
  2. Validate type and size
  3. Hash the file contents
  4. Look up a completed job with that hash
    1. Already transcribed Return the existing job
    2. Temporary file removed
    3. No work published
    1. New content Create the job
    2. Row inserted
    3. Message published
The check is on content, not on filename, so the same audio uploaded under a different name is still recognised.

Delivery guarantees

Acknowledge only what is actually finished.

The worker takes one message at a time and acknowledges manually. The acknowledgement is sent after the transcript has been committed to the database — never before. That ordering is the difference between at-least-once delivery being safe and being a source of silent data loss.

Acknowledgement ordering

  1. Message delivered
  2. Job marked processing
  3. Audio normalised and transcribed
  4. Transcript committed to PostgreSQL
  5. Message acknowledged
A worker that dies at any point before the acknowledgement leaves the message unacknowledged, and the broker redelivers it.

A prefetch of one means a worker holds exactly one job. Work is distributed by capacity rather than dealt out in advance, so a slow worker never sits on a queue of jobs another worker could have finished.

Retries, backoff and dead letters

What happens when a job fails.

A handled failure returns the job to the queued state and schedules a retry with a growing delay. After three attempts the job is marked failed and the message is parked in a dead-letter queue, where it can be inspected rather than lost or retried forever.

Retry schedule

  1. Attempt 1 fails
  2. Wait 10 seconds
  3. Attempt 2 fails
  4. Wait 30 seconds
  5. Attempt 3 fails
  6. Dead-letter queue
Delays grow so a transient upstream problem is given time to clear, and a permanently broken job stops consuming capacity.

Job state transitions

    • Upload accepted

    queued

    • Worker starts an attempt

    processing, attempt count incremented, progress reset

    • Transcript committed

    completed — terminal

    • Handled failure
    • Retries remaining

    back to queued after a delay

    • Handled failure
    • Retries exhausted

    failed, message parked in the dead-letter queue

    • Worker crashes before acknowledging

    broker redelivers; no state change was committed

Redelivery is normal

Designing for the message arriving twice.

At-least-once delivery means duplicates are not an error case, they are an expected one. A worker crash mid-transcription, a network partition, or a broker restart all produce a redelivered message for work that may or may not have finished.

Every attempt starts with a question

  1. Message delivered
  2. Read the job status from PostgreSQL
  3. Already completed? Acknowledge and skip
  4. Otherwise mark processing and continue
The first thing a worker does is check the database, not the message. Completed is terminal, so a duplicate costs one query rather than a second transcription.

Progress updates are guarded on the job still being in the processing state, so a late write from an abandoned attempt cannot move a job that another worker has already completed.

Inside the worker

Turning arbitrary audio into text.

Uploaded audio is whatever the user had: a phone recording, an export from a meeting tool, a file with a container the model has never seen. Normalisation happens before transcription so the model receives one predictable format regardless of what arrived.

Processing pipeline

  1. Probe the file Reject what is not really audio
  2. Normalise 16 kHz mono, one predictable format
  3. Voice activity detection Skip silence
  4. Chunk long audio Beyond ten minutes
  5. Transcribe with timestamps
  6. Commit transcript as JSONB
Validation before conversion, conversion before inference. Each stage fails loudly rather than passing bad input forward.

The model is baked into the worker image, so the runtime is fully offline. No audio leaves the deployment, and there is no third-party transcription API to be rate-limited by, billed by, or broken by.

What the client sees

Progress without holding a connection.

The client polls a single endpoint that returns status, progress and — once finished — the transcript itself. Progress writes from the worker are throttled, so a long job reports movement without turning a transcription into a write-heavy workload.

Returned with every poll

  • Status
  • Progress
  • Attempt count
  • Detected language
  • Duration
  • Transcript when complete

Export formats

  • JSON with per-segment timestamps
  • SRT subtitles
  • VTT subtitles

Per-segment timestamps are kept in the stored transcript rather than being derived on export, so subtitle formats are a projection of the same data rather than a second transcription path.

Engineering scope

What I worked on.

  1. Asynchronous transcription API
  2. RabbitMQ retries and dead-letter queues
  3. Idempotent PostgreSQL job state
  4. FFmpeg normalization and faster-whisper processing
  5. Timestamped transcript and subtitle exports

Why these decisions mattered

The trade-offs behind the architecture.

  • Asynchronous by default

    Decision
    Accept, queue, return an id; never transcribe in the request.
    Why
    Minutes of CPU work cannot live inside an HTTP request without taking the API down with it.
  • Postgres as source of truth

    Decision
    Job state in the database; the queue carries only a work signal.
    Why
    Losing the broker must not lose the record of what was asked for or already done.
  • Acknowledge after commit

    Decision
    The message is acknowledged only once the transcript is committed.
    Why
    Any earlier and a crash between the two silently drops a job that was reported as accepted.
  • Status check before work

    Decision
    Every attempt reads job status before doing anything.
    Why
    Redelivery is expected under at-least-once delivery; a duplicate should cost a query, not a transcription.
  • Deduplicate on content hash

    Decision
    Hash the upload and reuse a completed job for identical content.
    Why
    The most expensive operation in the system is the one most worth not repeating.
  • Model in the image

    Decision
    Transcription runs locally, fully offline.
    Why
    No audio leaves the deployment, and no third-party API becomes a dependency of the core function.

Impact

What the design guarantees.

An accepted upload is never silently lost: a worker crash redelivers, a duplicate delivery is recognised and skipped, a repeated file is deduplicated by content hash, and a permanently failing job is parked for inspection rather than retried forever.

Technology

The technical surface.

  • NestJS
  • Next.js
  • Python
  • PostgreSQL
  • RabbitMQ
  • FFmpeg
  • faster-whisper

Next: BookRevs

Back to all work Start a conversation