# Transcription (Shared · `Src\Transcription`)

**Context:** Shared library (not a portal module) · **Routes / UI:** none of its own · **Used by:** Phone Call (phone-call pipeline), Showroom F2F (smart-badge showroom recordings), Zoom Recordings, AI Video (footage transcription) — the single ASR engine every transcription consumer shares.

## What it does
A **provider-agnostic automatic-speech-recognition (ASR) service**: accept raw audio bytes, send them to the configured transcription provider, and return a normalised `TranscriptionResult`. It runs an **ordered driver chain** (`config('ai.transcription.drivers')`, default **Gemini → Deepgram**): the first driver that is **configured** AND returns a **non-empty** transcript wins; the rest are automatic fallbacks. Swapping/re-ordering engines (Gemini, Deepgram, or a future Whisper/AssemblyAI) is a config + container-binding change with zero caller update.

- **Primary — Gemini (`GeminiTranscriber`).** Transcribes the **whole mixed Mandarin+English conversation in a single pass** from a natural-language prompt, with **speaker diarization** (Speaker A / B) in the same call. Generally more accurate than Deepgram for PropertyLab's Manglish/code-switching sales calls, which is why it is the default. Model: `config('ai.transcription.gemini.model')` (default `gemini-3.5-flash`). Small audio is sent inline (base64); audio over the API's 20 MB request cap is uploaded via the **File API** (resumable upload → poll ACTIVE → reference → transcribe → delete) so long Zoom recordings stay on the Gemini path.
- **Fallback — Deepgram (`DeepgramTranscriber`).** The backup, used automatically when Gemini is unconfigured, errors, or returns an empty transcript. Because Deepgram's single-request multilingual code-switching (`language=multi`) does **not** cover Chinese, it runs **two monolingual passes — `language=zh` + `language=en` — merged by `BilingualTranscriptMerger`** (model `nova-3`).
- **Composite — `FallbackTranscriber`.** Wraps the ordered drivers and implements the same `Transcriber` contract, so `TranscriptionService` is unaware there is more than one engine. A single configured driver is bound directly (no wrapper).

It is **not** an `AiClient` consumer. Transcription uses dedicated ASR plumbing (separate endpoints, credentials, billing) and a long recording can take minutes — far exceeding the default queue's 180 s supervisor timeout. It therefore runs on its own **`redis-transcription`** lane, never `redis-ai` or the default `redis` queue.

## How it works

- **Five pieces, clean separation of concerns:**
  - `Src\Transcription\Contracts\Transcriber` — the provider contract: `transcribe(string $bytes, …)` (in-memory bytes) **+ `transcribeFile(string $path, …)`** (streamed from a local file) + `isConfigured(): bool`. Both return a `TranscriptionResult`. Features inject `TranscriptionService` and never reference a concrete driver — so the engine is swappable with zero caller change.
  - `Src\Transcription\TranscriptionService` — the **injectable entry point**. A thin wrapper around the container-bound driver; exposes the same `transcribe()` / `transcribeFile()` / `isConfigured()` surface. **Inject this class, never a concrete driver.**
  - `Src\Transcription\Drivers\FallbackTranscriber` — the **composite** bound to `Transcriber::class` (when ≥2 drivers are configured). Tries the ordered drivers in turn; returns the first configured driver's non-empty result; skips unconfigured / failing / throwing drivers.
  - `Src\Transcription\Drivers\GeminiTranscriber` — the **primary** Gemini implementation. Single-pass mixed-language transcription + diarization; inline or File API by size; fail soft.
  - `Src\Transcription\Drivers\DeepgramTranscriber` — the **fallback** Deepgram implementation. Dual-language pass, merge via `BilingualTranscriptMerger`, fail soft.

- **`GeminiTranscriber` in detail.** Calls `POST https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent` with two parts: a transcription **prompt** (verbatim, keep each phrase in its original language — no translation; speaker labels when `diarize` is on) and the **audio part**. Audio ≤ `inline_max_bytes` (default 18 MB, under the API's 20 MB request cap) is sent **inline** as base64; larger audio goes through the **File API** — a resumable upload (`/upload/v1beta/files`), poll until the file is `ACTIVE`, reference it by `file_uri`, then delete it afterwards. `transcribeFile()` stream-uploads from disk so long files stay memory-flat. **Fails soft** on any HTTP error, an empty transcript, or a missing key — returns `TranscriptionResult::failed(…)` so the chain falls through to Deepgram; never throws. The key resolves the same way as the analysis path: AI Providers UI first, `GEMINI_API_KEY` env fallback.

- **`DeepgramTranscriber` in detail.** Each `transcribe()` call sends one Deepgram pre-recorded API request per language in `$options['languages']` (default `['zh','en']`). Each pass is a `POST` to `https://api.deepgram.com/v1/listen` with the raw audio bytes as the body, a **120 s HTTP timeout**, and `Content-Type: audio/mpeg`. The two JSON responses are fed to `BilingualTranscriptMerger::merge()`, which: keeps Chinese utterances from the zh pass and English utterances from the en pass; orders them by start time; dedupes overlaps >50% (zh wins); and rebuilds a Deepgram-shaped result. Falls back to the longer single pass when the merge produces nothing. **Fails soft** when the key is absent (`isConfigured() → false`) or when both passes error — returns `TranscriptionResult::failed(…)`, never throws. (Deepgram is UI-only — no `DEEPGRAM_API_KEY` env fallback.)

- **Bytes vs. file — `transcribe()` vs. `transcribeFile()` (memory).** `transcribe(string $bytes)` holds the whole audio in memory and Guzzle copies it into each pass's request body — fine for small clips, but a long recording (e.g. a 90-minute+ Zoom call) buffered as a string **× the dual pass** can exhaust the PHP `memory_limit` (OOM). `transcribeFile(string $path)` is the memory-flat alternative: it re-opens the file as a **stream** per language pass and lets Guzzle upload it directly from disk, so memory stays constant regardless of file size. The merge/result logic is identical to `transcribe()`. **Rule of thumb:** large or unbounded media → `transcribeFile()`; small in-memory bytes → `transcribe()`. (Streaming only removes the *memory* ceiling — the per-pass 120 s HTTP timeout, the job `$timeout`, and Deepgram's own payload limits are separate.)

- **`BilingualTranscriptMerger`.** Relocated from `Src\Call\Support` (the logic is provider-agnostic and shared by the Deepgram fallback for every consumer — Calls, Zoom, F2f). Exposes `isChineseText()` / `isEnglishText()` (character-ratio classifiers), `transcriptOf()` (extract the plain-text field from a Deepgram JSON result), and `dominantLanguage()` (`'zh'` / `'en'` / `'mixed'`).

- **`TranscriptionResult` value object.** Mirrors `AiResponse` for a consistent "ok / text / error + optional raw" pattern:

  | Property | Type | Notes |
  |----------|------|-------|
  | `ok` | `bool` | `true` on success, `false` on any failure |
  | `text` | `?string` | Plain-text transcript (populated on success) |
  | `language` | `?string` | Detected / dominant language hint — optional; `DeepgramTranscriber` stores dominant-language data in `raw.summary.dominant_language`, not this property |
  | `raw` | `?array` | Raw merged Deepgram response (for debugging or a `deepgram_json`-style column) |
  | `error` | `?string` | Human-readable failure reason (populated on failure) |
  | `provider` | `?string` | Slug of the driver that produced this result (`'gemini'` / `'deepgram'`). Each real driver stamps its own slug on success; it **propagates unchanged** through `FallbackTranscriber` (returns the winning driver's result) and `LoggingTranscriber` — so a consumer that stores transcript provenance records the driver that actually won the chain, not a guess. Null when the driver reports none / on a `failed()` result. (Zoom uses this for its `transcript_source` badge.) |

  Use the static factories: `TranscriptionResult::ok($text, $raw, $language, $provider)` and `TranscriptionResult::failed($error)`.

- **`$options` tunables (all overridable per call):**

  | Key | Driver default | Notes |
  |-----|----------------|-------|
  | `languages` | `['zh','en']` | Deepgram: one pass per entry. Gemini: a prompt hint only (it auto-detects mixed languages in one pass). |
  | `model` | Gemini `gemini-3.5-flash` / Deepgram `nova-3` | Gemini resolves `config('ai.transcription.gemini.model')`; Deepgram resolves `config('ai.providers.deepgram.default_model')`. Per-call `model` overrides either. |
  | `diarize` | **`true`** | Speaker labels. Gemini adds `Speaker A/B:` lines; Deepgram sets its diarize flag. Both honour a per-call `false`. |
  | `mime` | `'audio/mpeg'` | Audio content type (Gemini uses it for the inline/File API part). |

  > **Diarize nuance.** Both drivers default to `true`. The Zoom consumer (`TranscribeZoomMeeting`) **explicitly overrides to `false`** — a plan-locked v1 decision (`zoomtranscriptionfallback.md §1a`); with Gemini that means a plain running transcript (no speaker lines). The Calls / F2f consumers omit `diarize` and inherit `true`. **New consumers must decide and pass `diarize` explicitly** — do not rely on the default silently.

- **Container binding & key resolution.** `AppServiceProvider` binds `Transcriber::class` to the **driver chain from `config('ai.transcription.drivers')`** (default `['gemini','deepgram']`). It builds each named driver from a small registry, passing each a **lazy key-resolver `Closure`** (Gemini: `resolveGlobal('gemini') ?: config('services.gemini.api_key')`; Deepgram: `resolveGlobal('deepgram')`). One driver is bound directly; two or more are wrapped in a `FallbackTranscriber`. The drivers import **nothing from `Src\Ai`**: they only know "call this closure for the key", keeping the dependency one-way. The closure is invoked **per resolution** (`bind`, not `singleton`), so a key saved *after* a worker booted is still picked up — preserving the fail-soft "configure the key later, re-run the job" contract. To re-order or drop an engine: edit `config('ai.transcription.drivers')` (or `TRANSCRIPTION_DRIVERS`). To add one: register a new driver implementing `Transcriber` in the registry and name it in the config.

- **Request logging (AI Requests page).** Transcription bypasses `AiClient`, so it would not normally appear in the `ai_requests` log. A **`LoggingTranscriber` decorator** (`app/Support/Transcription` — the wiring layer, where bridging `Src\Transcription` + `Src\Ai` is allowed) wraps **each** driver in `AppServiceProvider` and writes one `ai_requests` row per attempt (`prompt_key = transcription`): provider, model, status, Gemini token usage + estimated cost, duration, and the recording as `subject` (jobs pass `'subject' => $recording` in the options — the real drivers ignore it). A Gemini→Deepgram fallback logs **two** rows. The **audio bytes are never stored** (only their byte size); logging is **best-effort** — a logging failure is swallowed and never breaks transcription. The drivers themselves stay free of any `Src\Ai` dependency.
  - **Token usage is the provider's own.** Tokens come straight from Gemini's `usageMetadata` (`promptTokenCount` for input; `candidatesTokenCount` + `thoughtsTokenCount` for output — thinking tokens bill at the output rate). Deepgram is not token-billed → tokens/cost null.
  - **Cost is audio-aware.** Gemini bills **audio input** at a higher rate than text on several models (`config('ai.pricing.gemini.{model}').audio_input`; e.g. `gemini-2.5-flash` audio `$1.00`/M vs text `$0.30`/M). Since a transcription's input is almost all audio, `LoggingTranscriber::estimateCost()` bills audio tokens at the `audio_input` rate (falling back to the text `input` rate when the model has no separate audio price — e.g. `gemini-3.5-flash`, where audio = text = `$1.50`/M). When Gemini returns a per-modality breakdown (`promptTokensDetails`) each modality is billed at its own rate. The cost is still an **estimate** (rates are admin/dev-editable in `config/ai.php`), but it mirrors Google's published per-modality rates.

- **Dedicated queue lane (`redis-transcription` / `transcription`).** The default queue (`supervisor-1`, `timeout = 180 s`, `retry_after = 190 s`) would kill a multi-minute transcription (a Deepgram dual-pass, or a Gemini File-API upload + generateContent on long audio) mid-run and redeliver it — causing duplicate billing and a stuck row. The dedicated lane enforces the invariant: **job `$timeout` (600 s) < `supervisor-transcription` `timeout` (650 s) < `redis-transcription` `retry_after` (700 s)**. Transcription jobs pin themselves to this lane in their constructors via:
  ```php
  $this->onConnection(config('queue.transcription_connection', 'redis-transcription'));
  $this->onQueue('transcription');
  ```
  (`transcription_connection` is env-overridable — `phpunit.xml` sets it to `'sync'` so tests never need Redis.) The Horizon wait alert is 600 s (transcription is inherently slow; alert only on genuine backlog). **Never dispatch a long transcription step on the default queue.**

### Reference usage — the canonical job pattern

The standard pattern is a **two-job split**: a lightweight dispatcher on the default lane dispatches a dedicated transcription job (on `redis-transcription`), which on success dispatches the downstream analysis job (on its own lane). This keeps each concern on the right lane and prevents a slow ASR pass from dragging analysis onto an oversized timeout lane.

```php
use Src\Transcription\TranscriptionService;

class TranscribeMyThing implements ShouldQueue
{
    // Must be < supervisor timeout (650 s) < retry_after (700 s).
    public int $timeout = 600;
    public int $tries   = 3;
    public int $backoff = 60;

    public function __construct(public int $thingId)
    {
        // Pin to the transcription lane — never the default queue.
        $this->onConnection(config('queue.transcription_connection', 'redis-transcription'));
        $this->onQueue('transcription');
    }

    public function handle(TranscriptionService $transcription): void
    {
        // 1. Fail soft when no transcriber is configured (no Gemini & no Deepgram key).
        if (! $transcription->isConfigured()) {
            return; // Leave the row as-is — a config gap, not a bug.
        }

        // 2. Transcribe. Pass diarize explicitly — do not omit it.
        $result = $transcription->transcribe($audioBytes, [
            'languages' => ['zh', 'en'],
            'diarize'   => false, // or true — your decision; pass it consciously
        ]);

        // 3. Read the result.
        if (! $result->ok || empty($result->text)) {
            // Persist the failure state, then throw so $tries/$backoff retry transient errors.
            throw new \RuntimeException($result->error ?? 'Transcription failed.');
        }

        // 4. Persist $result->text. Optionally store $result->raw for debugging.
        // 5. Dispatch the downstream analysis job on its own lane.
        AnalyzeMyThing::dispatch($this->thingId);
    }
}
```

> **Large media:** if your audio can be long/unbounded, download it to a local file and call `$transcription->transcribeFile($path, […])` instead of buffering bytes — then clean the file up in a `finally`. The Zoom consumer does exactly this (streamed CDN download → temp file → `transcribeFile`).

**Four real consumers to read as worked examples:**

- **`App\Jobs\Calls\TranscribeCallRecording`** — Calls (phone) pipeline. Dispatched by the thin `ProcessCallRecording` (default lane). Reads audio from disk; omits `diarize` (inherits driver default `true`). On success dispatches `AnalyzeCallRecording`. See [Phone Call](/docs/modules_handbook/manage/call-history/readMe.md).

- **`App\Jobs\F2f\TranscribeF2fRecording`** — F2f smart-badge showroom pipeline. Dispatched by the thin `ProcessF2fRecording` (redis-f2f lane), which `DownloadYhyRecording` fires after filing the merged MP3. Reads audio from disk; omits `diarize` (driver default `true`). On success dispatches `AnalyzeF2fRecording` (default lane, Gemini). **Same three-job split as Calls** — the dual-pass runs on this dedicated lane, never on redis-f2f. See [F2f](/docs/modules_handbook/manage/f2f/readMe.md).

- **`App\Jobs\Zoom\TranscribeZoomMeeting`** — Zoom transcription fallback (used only when Zoom returned no native VTT transcript). Dispatched by `ZoomRecordingSyncService::maybeFetchTranscript()` (when no Zoom VTT is available) or by the on-demand "Transcribe" / "Retry transcription" button on the Show page. **Streams** the audio: `ZoomServerService::downloadRecordingToFile()` sinks the Zoom CDN download to a temp file, then `transcribeFile()` streams it to the transcription service (Gemini → Deepgram) (the file is `@unlink`ed in a `finally`) — so a long call never OOMs. Explicitly passes `diarize = false`. Adds an atomic `markTranscriptProcessingIfEligible()` claim and `WithoutOverlapping` middleware to prevent concurrent billing. On success dispatches `AnalyzeZoomMeeting`. See [Zoom](/docs/modules_handbook/manage/zoom/readMe.md).

- **`App\Jobs\Video\TranscribeFootageJob`** — AI Video footage editor. Dispatched by `ProjectsController::storeFootage` when a user uploads real footage. Calls `transcribe()` (bytes), then splits + scores the segments inline (no follow-on job). **Runs on its own `redis-video` lane, not redis-transcription** — that lane is already long enough (retry_after 1900 > supervisor timeout 1800 > job timeout 600) to host the dual-pass without a mid-run kill, so it does not split. See [AI Video](/docs/modules_handbook/shared/video/readMe.md).

> **Not an `AiClient` call.** Transcription has its own plumbing (its own keys, endpoints, and the `redis-transcription` lane) — even Gemini transcription calls `generateContent` directly via `GeminiTranscriber`, not `AiClient`. Do not route transcription through `AiClient`: that is for LLM text-generation (Anthropic, OpenAI, Gemini, DeepSeek). The Gemini *analysis* that runs after transcription (`AnalyzeZoomMeeting`, `AnalyzeCallRecording`, `AnalyzeF2fRecording`) is a separate step.

## Configuration

The keys live in the AI Providers UI (encrypted `ai_credentials`); only the non-secret knobs live in `config/ai.php`:

| What | Where | Notes |
|------|-------|-------|
| **driver order** | `config('ai.transcription.drivers')` (default `['gemini','deepgram']`) | Ordered chain; first configured + non-empty result wins. Override with `TRANSCRIPTION_DRIVERS` (comma-separated), e.g. `deepgram,gemini` to flip primary/backup, or `deepgram` for Deepgram-only. |
| **Gemini model** | `config('ai.transcription.gemini.model')` (default `gemini-3.5-flash`) | Separate from the *analysis* model (`config('services.gemini.model')`). Swap to `gemini-2.5-pro` for max accuracy. Env: `GEMINI_TRANSCRIPTION_MODEL`. |
| Gemini **diarize / inline cap / timeouts** | `config('ai.transcription.gemini.*')` | `diarize` (default true), `inline_max_bytes` (18 MB), `timeout` (480 s), `upload_timeout` (120 s). |
| Gemini API **key** | AI Providers UI → `ai_credentials` (`gemini`), `GEMINI_API_KEY` env fallback | Same resolution as the analysis path. |
| Deepgram API **key** | AI Providers UI → `ai_credentials` (`deepgram`) | **No** `DEEPGRAM_API_KEY` env fallback (UI-only). |
| Deepgram **model** | `config('ai.providers.deepgram.default_model')` (default `'nova-3'`) | The non-secret provider catalog; `DeepgramTranscriber::DEFAULT_MODEL` is the ultimate fallback. |

When **no driver is configured** (no Gemini and no Deepgram key), `isConfigured()` returns `false` and every transcription job fails soft — leaving the row in its current state, never marking it failed; it resumes automatically once a key is saved. When **at least one** driver is configured but all configured drivers error, the job marks the recording **failed** (the Retry button re-runs it).

**Queue lane invariant:**

| Item | Value | Why |
|------|-------|-----|
| Job `$timeout` | 600 s | Deepgram dual-pass ≈ 240 s; 600 s gives comfortable headroom |
| `supervisor-transcription` `timeout` | 650 s | Must exceed job `$timeout`; Horizon kills the worker process after this |
| `redis-transcription` `retry_after` | 700 s | Must exceed supervisor `timeout`; prevents re-delivery of a still-running job |
| Horizon wait alert | 600 s | Transcription jobs are inherently long; alert only on genuine backlog |

`config/queue.php` also exposes a `transcription_connection` string (default `'redis-transcription'`) so jobs can be overridden via `TRANSCRIPTION_QUEUE_CONNECTION=sync` for test runs without Redis.

## Related files

**Backend — Contract, service, drivers**
- [src/Transcription/Contracts/Transcriber.php](/src/Transcription/Contracts/Transcriber.php) — provider-agnostic interface (`transcribe` bytes + `transcribeFile` streamed + `isConfigured`).
- [src/Transcription/Contracts/HasProviderName.php](/src/Transcription/Contracts/HasProviderName.php) — optional companion interface (`providerName()`) so `FallbackTranscriber` labels per-driver failures by the real provider slug even when each driver is wrapped in the logging decorator.
- [src/Transcription/TranscriptionService.php](/src/Transcription/TranscriptionService.php) — the injectable entry point; thin wrapper around the bound driver (`transcribe` / `transcribeFile`).
- [src/Transcription/Drivers/FallbackTranscriber.php](/src/Transcription/Drivers/FallbackTranscriber.php) — **composite** driver: tries an ordered chain (Gemini → Deepgram), returns the first configured driver's non-empty result; skips unconfigured / failing / throwing drivers; `isConfigured()` is true when any driver is.
- [src/Transcription/Drivers/GeminiTranscriber.php](/src/Transcription/Drivers/GeminiTranscriber.php) — **primary** Gemini driver: single-pass mixed-language transcription + diarization; inline base64 ≤ 18 MB, File API (resumable upload → poll ACTIVE → reference → delete) for larger; `transcribeFile()` stream-uploads; fail soft.
- [src/Transcription/Drivers/DeepgramTranscriber.php](/src/Transcription/Drivers/DeepgramTranscriber.php) — **fallback** Deepgram driver (dual zh+en pass, 120 s per-pass timeout, fail soft when unconfigured). `transcribe()` sends in-memory bytes; `transcribeFile()` streams from disk (memory-flat) via a re-opened stream per pass.
- [src/Transcription/TranscriptionResult.php](/src/Transcription/TranscriptionResult.php) — normalised value object (`ok`, `text`, `language`, `raw`, `error`, `provider`; static `ok()`/`failed()` factories).
- [src/Transcription/BilingualTranscriptMerger.php](/src/Transcription/BilingualTranscriptMerger.php) — merges zh + en Deepgram passes into one mixed transcript; `isChineseText()`, `isEnglishText()`, `transcriptOf()`, `dominantLanguage()`.

**Backend — Container binding & logging**
- [app/Providers/AppServiceProvider.php](/app/Providers/AppServiceProvider.php) — binds `Transcriber::class` to the config-driven driver chain (`config('ai.transcription.drivers')`): builds each driver from a registry with a lazy key-resolver `Closure`, wraps each in a `LoggingTranscriber`, then binds one directly or wraps ≥2 in a `FallbackTranscriber`. The only place `Src\Transcription` is wired to the AI Providers keys.
- [app/Support/Transcription/LoggingTranscriber.php](/app/Support/Transcription/LoggingTranscriber.php) — decorator that records each transcription attempt in `ai_requests` (`prompt_key = transcription`) via `AiRequestRepository::log()`; never stores audio bytes; best-effort. The bridge between `Src\Transcription` and `Src\Ai`.

**Backend — Consumer jobs (worked examples)**
- [app/Jobs/Calls/ProcessCallRecording.php](/app/Jobs/Calls/ProcessCallRecording.php) — thin dispatcher (default lane) that triggers `TranscribeCallRecording`.
- [app/Jobs/Calls/TranscribeCallRecording.php](/app/Jobs/Calls/TranscribeCallRecording.php) — Calls pipeline; driver-default `diarize = true`; dispatches `AnalyzeCallRecording` on success.
- [app/Jobs/F2f/ProcessF2fRecording.php](/app/Jobs/F2f/ProcessF2fRecording.php) — thin dispatcher (redis-f2f lane) that triggers `TranscribeF2fRecording`.
- [app/Jobs/F2f/TranscribeF2fRecording.php](/app/Jobs/F2f/TranscribeF2fRecording.php) — F2f pipeline; driver-default `diarize = true`; dispatches `AnalyzeF2fRecording` on success.
- [app/Jobs/Zoom/TranscribeZoomMeeting.php](/app/Jobs/Zoom/TranscribeZoomMeeting.php) — Zoom fallback; explicit `diarize = false`; atomic claim + `WithoutOverlapping`; dispatches `AnalyzeZoomMeeting` on success.
- [app/Jobs/Video/TranscribeFootageJob.php](/app/Jobs/Video/TranscribeFootageJob.php) — AI Video footage; `transcribe()` bytes; runs on the `redis-video` lane (no split — that lane is already long enough).

**Config**
- [config/ai.php](/config/ai.php) — `transcription` block: `drivers` order (default `gemini,deepgram`) + `gemini` knobs (`model` `gemini-3.5-flash`, `diarize`, `inline_max_bytes`, `timeout`, `upload_timeout`). Also `providers.deepgram` catalog: `default_model` (`'nova-3'`) + `base_url` / `verify_path` for the Save/Test probe. **Keys** are stored encrypted in `ai_credentials` (Gemini also has a `GEMINI_API_KEY` env fallback; Deepgram is UI-only).
- [config/queue.php](/config/queue.php) — `redis-transcription` connection (`retry_after = 700 s`); `transcription_connection` env key for test overrides.
- [config/horizon.php](/config/horizon.php) — `supervisor-transcription` (`timeout = 650 s`; `maxProcesses` 1 base / 2 production / 1 local); Horizon wait alert at 600 s.

**See also:** [Phone Call](/docs/modules_handbook/manage/call-history/readMe.md) · [Zoom](/docs/modules_handbook/manage/zoom/readMe.md) — the two current consumers of this service.
