Skip to content

Navigation Menu

Sign in
Sign up

Repository files navigation

VoiceFlow

Pull a Retell account's entire call history, and transcribe every recording on your own machine.

Python FastAPI Next.js Whisper License

Call recordings are the most sensitive thing a voice agent produces. VoiceFlow pages through the Retell API, downloads every recording, and runs Whisper large-v3 on the machine doing the export, so the audio never reaches a third-party transcription service. Built against a production account of 2,100+ outbound calls.

Full case study: hammadahmad.co.uk/projects/voiceflow

The export configuration screen

Pick an output directory, optionally bound it by date, choose your columns, and start. Progress and a live log stream while it runs, and the job can be cancelled part-way without losing the work already on disk.


Why local transcription

Sending call audio to a hosted speech API means a recording of your customer crosses a trust boundary you do not control, and usually one you have to declare. Whisper large-v3 is good enough to remove that requirement entirely. The cost is wall-clock time: roughly 5 to 10 minutes per hour of audio on CPU, considerably less on a GPU.

Nothing leaves the machine except the Retell API calls themselves.


What a run produces

One dated directory per run, under the export root you chose:

2026年08月13日_retell_export/
├── csv/retell_calls_2026年08月13日.csv one row per call
├── recordings/<to_number>_<call_id>.wav
├── transcripts/<to_number>_<call_id>.txt
└── run.log

Plus, at the export root itself, processed_ids.json, which is what makes a second run skip everything already fetched.

The CSV and the transcripts are separate. The CSV carries Retell's own server-side transcript in its Transcript column. Whisper's output goes to the transcripts/ directory as plain text, keyed by the recording filename. Nothing joins the two, so if you want Whisper's text in the spreadsheet, that is a step you still have to write.

The 24 columns

Selectable individually in the UI. Defaults to all of them.

Time, Duration_ms, Channel Type, Cost_USD, Session ID, End Reason, Session Status, User Sentiment, From, To, Session Outcome, E2E Latency_p50, detailed_call_summary, callback_time, user_intent_summary, recording_path, Agent ID, Agent Version, Direction, Call Type, End Timestamp, Transcript, Recording URL, Public Log URL

The three custom_analysis_data fields come from whatever post-call analysis your Retell agent is configured to emit, so they may be empty on your account.


How it works

flowchart LR
 UI[Next.js UI<br/>key held in sessionStorage] -->|POST /api/run| API[FastAPI]
 API --> JOB[job thread<br/>uuid, cancel flag]
 JOB --> P[page through Retell<br/>pagination_key, 250 per request]
 P --> F[dedup vs processed_ids<br/>then filter by date]
 F --> CSV[write CSV<br/>downloading each WAV inline]
 CSV --> DL[download pass<br/>emits progress]
 DL --> TR[Whisper large-v3<br/>one file at a time]
 TR --> OUT[(csv + wav + txt + run.log)]
 API -.->|GET /api/logs SSE| UI
 API -.->|GET /api/progress, polled| UI
Loading

A job is an OS thread, not an asyncio task. That keeps the blocking work, HTTP downloads and torch inference, off the event loop, at the cost of there being no queue: every POST /api/run starts another thread immediately, and each one loads its own copy of the model.

Job lifecycle and progress
POST /api/run -> {"job_id": "<uuid>"}
GET /api/progress/:id -> {"current","total","percent","stage"}
GET /api/logs/:id -> text/event-stream, one log line per frame
GET /api/summary/:id -> 400 until the job is done, then the result
POST /api/cancel/:id -> sets a cooperative cancel flag

Stage is one of queue, download_recordings, transcribe. Worth knowing: the fetch and CSV phase both report as queue, so on a large account progress sits at 0% for a long time before it starts moving. That is the Retell paging and the inline audio downloads, not a hang. The log stream is the thing to watch during that window.

Cancellation is checked between calls, between rows, and on every download chunk, but not inside a single Whisper call, so a cancel lands when the current file finishes rather than immediately.

The SSE contract

Deliberately minimal. GET /api/logs/{job_id} returns text/event-stream and emits unnamed frames, one log line each:

data: [TR] 41/2100 447xxxxxxxxx_call_abc123.wav

No event names, no JSON, no ids, no heartbeat. The server drains its log buffer once a second. Progress is not on this stream; the UI polls /api/progress every 1.5 seconds for that.

Log prefixes you will see: [CSV] while rows are being built, [DL] during the download pass, [MODEL] during the one-off model download, [GPU] for device selection, [TR] per transcription.

Whisper configuration

openai/whisper-large-v3 through Transformers, cached in backend/models/whisper-large-v3. About 2.9 GB on first run, resumable, and re-downloaded automatically if the weight files are missing or truncated.

Device selection is a single attempt: CUDA device 0 with fp16 if available, otherwise CPU with fp32, logged either way. There is no multi-GPU path.

The pipeline runs with chunk_length_s=None and return_timestamps=True, which uses the sequential long-form path rather than chunked batching. batch_size is 8 but never fills, because files are transcribed one at a time. no_repeat_ngram_size is 3, which is the only guard against the repetition loops Whisper falls into on silence. Language is not pinned, so it is detected per file.


Configuration

Only two environment variables are actually read by the backend:

Variable Default Purpose
RETELL_API_KEY none Fallback if the request does not carry a key
EXPORT_ROOT ./exports Where run directories are created

Plus one on the frontend:

Variable Default Purpose
NEXT_PUBLIC_BACKEND_URL http://localhost:8000 Where the UI sends requests

.env.example lists several more, including WHISPER_MODEL, LOG_LEVEL and WORKERS. Those are not wired up. The model id is hardcoded, and the app does not call load_dotenv, so a .env file only takes effect if something else loads it into the environment first.

In normal use the key is not an environment variable at all. It is typed into the UI, held in sessionStorage, and cleared on tab close.

The key gate


Run it

Requires Python 3.11, Node 18+, and ffmpeg on PATH.

git clone https://github.com/1oNN/VoiceFlow.git
cd VoiceFlow
# backend
cd backend
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
python -m uvicorn app.main:app --port 8000
# frontend, in a second terminal
cd frontend
npm install
echo "NEXT_PUBLIC_BACKEND_URL=http://localhost:8000" > .env.local
npm run dev # http://localhost:3000

The first export downloads the model before it transcribes anything. Expect that to dominate the first run.


Known issues

Worth reading before you point this at a real account.

  • The Docker setup does not build as committed. The Dockerfile uses a shell redirect inside a COPY, the frontend stage expects a static export that next build is not configured to produce, and docker-compose.yml references a frontend/Dockerfile that does not exist. Run the two processes directly. There is also no GPU wiring in any container, so a containerised run would be CPU-only regardless.
  • GET /api/file serves any path it is given, with no authentication and no allowlist. It exists so the UI can offer download links. Do not expose this backend beyond localhost as it stands.
  • The whole call list is held in memory before filtering. The date range is applied client-side after fetching, not pushed into the API query, so a narrow date range on a large account still pages through everything.
  • /api/columns never receives the browser's key. The frontend sends it as a header, the backend reads a query parameter, so column discovery falls back to the server-side key or to the default list.
  • A run with nothing left to transcribe never shows its summary. The UI waits for the transcribe stage to reach 100%, and that stage emits no progress if every recording already has a transcript.
  • Job state is in memory and unbounded. Restarting the backend loses every job, and completed jobs are never evicted from the dict.
  • No tests. CONTRIBUTING.md refers to a suite that does not exist.

License

ISC. See LICENSE.

About

Retell call exporter with local Whisper transcription (large-v3) - async jobs, live SSE progress, docker-compose deploy. Audio never leaves the machine

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

AltStyle によって変換されたページ (->オリジナル) /