# Sofya Transcription

**Sofya Transcription** is a JavaScript library that provides a robust and flexible solution for real-time audio transcription. It is designed to transcribe audio streams and can be easily integrated into web applications. The library also includes a functionality for capturing audio from media elements.

## Features

-   **Real-Time Transcription**: Transcribe audio streams in real time with high accuracy.
-   **Flexible Integration**: Seamlessly integrates with your web applications.
-   **Media Element Audio Capture**: Feature to capture audio from media elements like `<video>` and `<audio>`.
-   **Multiple Provider Support**: Support for Sofya Compliance and Sofya as Service transcription providers.
-   **Type-Safe Configuration**: TypeScript definitions for provider-specific configurations.
-   **Connection Resilience**: Detects stalled websocket upstreams and retries the realtime session on unstable networks.
-   **Telemetry V2**: Exposes a runtime telemetry snapshot, row stream, and OpenTelemetry bridge support for UI and diagnostics.

## Installation

To install **Sofya Transcription**, you can use npm:

`npm install sofya.transcription` 

## Usage

Here's a basic example of how to use **Sofya Transcription** in your project:

1.  **Import the Library**:
    
    `import { MediaElementAudioCapture, createTranscriber, SofyaTranscriber } from 'sofya.transcription';` 
    
2.  **Create a Transcription Service Instance**:
    
    ```typescript
    // Recommended factory with provider-aware typing
    const transcriber = createTranscriber({
      provider: 'sofya_as_service',
      endpoint: 'wss://your-endpoint',
      config: {
        language: 'en-US',
        translation_lang: 'pt-BR',
        batchReprocess: {
          parseResponse: async (response) => response.json() as Promise<{ transcript: string }>
        },
        auditIngestion: {
          endpoint: '/v1/stt/audits',
          headers: {
            'x-api-key': 'YOUR_API_KEY',
            'x-terminal-id': 'TERM-001',
            'x-app-version': '3.2.1'
          }
        }
      }
    });
    
    // Legacy constructor remains supported
    const legacyTranscriber = new SofyaTranscriber({
      provider: 'sofya_compliance',
      endpoint: 'YOUR_ENDPOINT',
      config: {
        language: 'en-US',
        token: 'YOUR_TOKEN',
        compartmentId: 'YOUR_COMPARTMENT_ID',
        region: 'YOUR_REGION'
      }
    });
    ```

    `createTranscriber()` is the recommended entrypoint for new integrations because it narrows the returned type by provider. Whisper providers expose batch reprocessing in IntelliSense; non-Whisper providers do not.
    
3.  **Initialize and Start Transcription**:
 
    ```typescript
    // Wait for the transcriber to be ready
    transcriber.on('ready', () => {
      // Get media stream
      navigator.mediaDevices.getUserMedia({ audio: true })
        .then(mediaStream => {
          // Start transcription
          transcriber.startTranscription(mediaStream);
        })
        .catch(error => {
          console.error('Error accessing microphone:', error);
        });
    });
    ```
    
4.  **Handle Transcription Events**:
    
    ```typescript
    transcriber.on('recognizing', (text) => {
      console.log('Recognizing: ' + text);
    });
    
    transcriber.on('recognized', (text) => {
      console.log('Recognized: ' + text);
    });
    
    transcriber.on('error', (error) => {
      console.error('Transcription error:', error);
    });

    transcriber.on('reconnecting', ({ attempt, delay }) => {
      console.warn(`Realtime connection degraded. Reconnecting (attempt ${attempt}) in ${delay}ms`);
    });

    transcriber.on('reconnected', () => {
      console.log('Realtime connection recovered');
    });

    transcriber.on('disconnected', (details) => {
      console.warn('Realtime connection closed after retries were exhausted', details);
    });

    transcriber.on('resilience_status', (status) => {
      console.log('Realtime resilience status', status);
    });

    transcriber.on('stt_session', ({ sttSessionId, externalId, connectionAttempt }) => {
      console.log(`STT session ${sttSessionId} (connection ${connectionAttempt}) for ${externalId}`);
    });

    transcriber.on('telemetry', (telemetry) => {
      console.log('Telemetry snapshot', telemetry);
    });

    transcriber.on('telemetry_row', (row) => {
      console.log('Telemetry row', row);
    });

    const telemetry = transcriber.getTelemetrySnapshot();
    const rows = transcriber.getTelemetryRows();
    
    transcriber.on('stopped', () => {
      console.log('Transcription stopped');
    });
    ```

    The telemetry snapshot is client-observed. It is designed for UI state, connection banners, buffering indicators, and session diagnostics, not for server-side accuracy or confidence reporting.

    For guidance on live UI feedback and end-of-session dashboards, see [docs/METRICS_AND_REPORT_GUIDE.md](/Users/gabriel/Projects/lib.sofya.transcription/docs/METRICS_AND_REPORT_GUIDE.md).
    
5.  **Control Transcription**:
        
    ```typescript
    // Pause transcription
    transcriber.pauseTranscription();
    
    // Resume transcription
    transcriber.resumeTranscription();
    
    // Stop transcription
    await transcriber.stopTranscription();
    ```

6.  **Optional Batch Reprocess After Stop**:

    ```typescript
    await transcriber.stopTranscription();

    const status = transcriber.getBatchReprocessStatus();
    if (status.state === "ready") {
      const batchPayload = await transcriber.reprocessAudio();
      console.log("Batch payload", batchPayload);
    }

    transcriber.on("batch_reprocess_status", (nextStatus) => {
      console.log("Batch availability", nextStatus.state, nextStatus.remainingMs);
    });
    ```

    Batch reprocess is supported only for durable Whisper providers (`sofya_as_service`, `stt_wvad`, `sofya_whisper_flow`). The retained audio is exported from the local durable session as a `.wav` file and is available only after `stopTranscription()` while the current 30 minute retention TTL is still active.

    A successful `reprocessAudio()` call does not delete the retained IndexedDB consultation audio. Storage cleanup remains controlled exclusively by the post-stop TTL.

    By default, the SDK derives the batch POST URL from the realtime endpoint host and uses `/api/transcriber`. It automatically inherits:

    - `Authorization: Bearer ...`
    - extra configured `headers`
    - `x-external-id`
    - `transcription_language`
    - `translation_language`

    The SDK sends the request as `multipart/form-data` with a `file` part. If your browser app calls a different origin, the batch endpoint must allow CORS or you must proxy the request through your backend/dev server.

7.  **Optional STT Audit Ingestion On Stop**:

    ```typescript
    const transcriber = new SofyaTranscriber({
      provider: 'sofya_as_service',
      endpoint: 'wss://your-endpoint',
      config: {
        language: 'en-US',
        auditIngestion: {
          // Optional override. Default: /v1/stt/audits
          endpoint: '/v1/stt/audits',
          // Optional request body fields:
          threadId: 'uuid-thread-id',
          externalIdentifier: 'visit-12345',
          // Header overrides/augmentations:
          headers: {
            'x-api-key': 'YOUR_API_KEY',
            'x-terminal-id': 'TERM-001',
            'x-app-version': '3.2.1'
          }
        }
      }
    });

    transcriber.on('stt_audit_ingestion_warning', (warning) => {
      console.warn('Audit ingestion warning', warning);
    });

    await transcriber.stopTranscription();
    ```

    When `auditIngestion` is configured for Whisper providers, the SDK posts the final audit payload automatically on `stopTranscription()`. It reuses connection context (base host, inherited headers, and bearer token), retries transient failures (`network` / `5xx`) with exponential delays (`300ms`, `600ms`), and never rejects `stopTranscription()` on ingestion failure.

    For full implementation details, see [docs/STT_AUDIT_INGESTION_GUIDE.md](/Users/gabriel/Projects/lib.sofya.transcription/docs/STT_AUDIT_INGESTION_GUIDE.md).

8.  **Optional Debug Audit Mode For Manual DevTools Runs**:

    ```typescript
    const transcriber = new SofyaTranscriber({
      provider: 'sofya_as_service',
      endpoint: 'YOUR_ENDPOINT',
      config: {
        language: 'en-US',
        debug: {
          enabled: true,
          autoDownload: true,
          label: 'devtools-high-jitter',
          metadata: {
            devtoolsProfile: 'High jitter / bursty delivery'
          }
        }
      }
    });

    const audit = await transcriber.getDebugAudit();
    await transcriber.downloadDebugAudit();
    ```

    The SDK always keeps an essential audit view for session-flow diagnostics. When debug mode is enabled, it also captures advanced metrics (including audio-capture diagnostics) and persists richer debug checkpoints. The exported JSON is intentionally slim and contains only the final formatted `report` payload plus session metadata and environment fields, so it is ready for dashboards and interface rendering without exposing raw telemetry or sampled runtime data. The audit is session-scoped, so a new `startTranscription()` after a completed session begins a fresh audit timeline. `autoDownload: true` saves that audit as JSON automatically after `stopTranscription()`.

## API

### `createTranscriber(connection)`

-   Preferred factory for new integrations.
-   Returns a provider-aware type:
    - Whisper providers expose `reprocessAudio()` and `getBatchReprocessStatus()`
    - non-Whisper providers return the common transcription surface without batch capability in IntelliSense

### `SofyaTranscriber`

-   **constructor(connection: Connection)**: Creates a new instance of the transcription service with a connection object.
    
-   **startTranscription(mediaStream: MediaStream): void**: Starts the transcription process with a given `MediaStream`.
    
-   **stopTranscription(): Promise<Blob | null>**: Stops the transcription process and resolves when the realtime session is closed. Returns `null` by default.

-   **reprocessAudio<TPayload = unknown>(): Promise<TPayload>**: Reprocesses retained consultation audio through the batch endpoint. Available only for durable Whisper sessions after `stopTranscription()` and while the 30 minute durable-audio TTL has not expired.

-   **getBatchReprocessStatus(): BatchReprocessStatus**: Returns the latest batch reprocess capability snapshot, including `state`, `expiresAt`, `remainingMs`, and the last typed error.

-   **getBatchReprocessRemainingTime(): number | null**: Returns the remaining retained-audio TTL in milliseconds, or `null` when batch reprocessing is unavailable.

-   **pauseTranscription(): void**: Pauses the transcription process.
    
-   **resumeTranscription(): void**: Resumes the transcription process.

-   **getDebugAudit(): Promise<DebugAuditFile | null>**: Builds the current exported audit snapshot asynchronously. Essential audit data is always available, while advanced metrics are controlled by the debug config. The snapshot contains session metadata, environment metadata, and a derived `report` section with summary, health, timeline, findings, and dashboard cards/hero stats. Raw telemetry, sampled runtime snapshots, and internal audit arrays are intentionally omitted from the exported JSON.

-   **getTelemetrySnapshot(): TelemetrySnapshot | null**: Returns the current telemetry snapshot for the active realtime session.

-   **getSttSessionId(): string | null**: Returns the server `session_id` of the current realtime connection, or `null` when the server has not sent one yet (older servers, non-Whisper providers, or before the first transcript message of the run). The last value is kept after `stopTranscription()` until the next start.

-   **getSttSessionIds(): string[]**: Returns every server `session_id` received since `startTranscription()`, in connection order (one per connection, reconnections included). Cleared on the next `startTranscription()`.

-   **getTelemetryRows(): TelemetryRow[]**: Returns the in-memory ring buffer of export-friendly telemetry rows.

-   **clearTelemetryRows(): void**: Clears retained telemetry rows.

-   **resetTelemetry(): void**: Resets telemetry to a fresh session baseline.

-   **downloadDebugAudit(fileName?: string): Promise<string | null>**: Downloads the current audit snapshot as JSON and clears the persisted audit session after a successful SDK-managed export.
    
-   **on(event: string, callback: Function): this**: Registers an event handler for transcription events. Possible events include:
    
    -   `recognizing`: Fired when transcription is in progress.
    -   `recognized`: Fired when transcription is complete.
    -   `error`: Fired when an error occurs.
    -   `ready`: Fired when the transcription service is ready to start.
    -   `stopped`: Fired when the transcription process is stopped.
    -   `connected`: Fired when the transcription service is connected to the provider.
    -   `reconnecting`: Fired while the realtime transport is retrying and the session is still recoverable.
    -   `reconnected`: Fired when the realtime transport recovers after a retry.
    -   `disconnected`: Fired only when the realtime transport reaches a terminal disconnected state.
    -   `resilience_status`: Fired whenever the transport resilience snapshot changes.
    -   `telemetry`: Fired whenever the telemetry snapshot changes.
    -   `telemetry_row`: Fired whenever a telemetry row is recorded.
    -   `batch_reprocess_status`: Fired whenever batch reprocess availability changes.
    -   `telemetry_integration_warning`: Fired on non-fatal telemetry vendor integration warnings.
    -   `stt_audit_ingestion_warning`: Fired when automatic STT audit ingestion fails or is skipped.
    -   `stt_session`: Fired when the server session id that names the recording first appears in a transcript message or changes (reconnection). See [Server-Side Recording](#server-side-recording-record-external_id-stt_session).

### Connection Types

The SDK supports different connection modes based on the provider:

#### API Key Connection

```typescript
{
  apiKey: string;
  config?: ApiKeyConfig;
}
```

#### Sofya Compliance Provider Connection

```typescript
{
  provider: "sofya_compliance";
  endpoint: string;
  config: SofyaComplianceConfig;
}
```

#### Sofya As Service Provider Connection

```typescript
{
  provider: "sofya_as_service";
  endpoint: string;
  config: SofyaSpeechConfig;
}
```

#### STT WVAD Provider Connection

```typescript
{
  provider: "stt_wvad";
  endpoint: string;
  config: SofyaSpeechConfig;
}
```

#### Sofya Whisper Flow Provider Connection

```typescript
{
  provider: "sofya_whisper_flow";
  endpoint: string;
  config: SofyaSpeechConfig;
}
```

### Configuration Types

#### BaseConfig

```typescript
interface BaseConfig {
  language: string;
  external_id?: string;
  record?: boolean;
  protocols?: string | string[];
  resilience?: {
    connectTimeoutMs?: number;
    healthCheckIntervalMs?: number;
    maxBufferedAmountBytes?: number;
    reconnectBaseDelayMs?: number;
    reconnectMaxDelayMs?: number;
    reconnectDelayGrowFactor?: number;
    minConnectionUptimeMs?: number;
    maxReconnectAttempts?: number;
  };
  debug?: boolean | {
    enabled?: boolean;
    advancedMetrics?: boolean;
    autoDownload?: boolean;
    fileName?: string;
    label?: string;
    metadata?: Record<string, unknown>;
  };
  telemetry?: {
    enabled?: boolean;
    provider?: "dynatrace";
    dynatrace?: {
      globalKey?: string; // default: "dtrum"
      actionName?: string; // default: "sofya transcription audit"
    };
  };
}
```

`protocols` is optional. When provided, the SDK forwards the value as the websocket subprotocol argument, which results in the `Sec-WebSocket-Protocol` header being negotiated by the browser. When direct-mode `auth` uses the `subprotocol` transport, the SDK appends the `x-api-key.<key>` token after the configured protocols, so your first protocol is still the one the server echoes back.

> **With `auth: { type: "jwt" }`, do not put the JWT in `protocols`.** The SDK already sends it as the first subprotocol; repeating it here sends it twice.

`external_id` is optional. When provided, the SDK appends it to the websocket URL as the `x-external-id` query parameter on every connection, reconnections included. See [Server-Side Recording](#server-side-recording-record-external_id-stt_session) for the value rules.

`record` is optional. `true` requests server-side recording of the session and `false` refuses it; both are sent as the `record` query parameter on every connection. When omitted, the parameter is not sent and the server environment default applies.

`resilience` is optional. The Whisper realtime transport is backed by `reconnecting-websocket`, with SDK defaults of a 10 second connection timeout, 1 second minimum reconnect delay, 10 second maximum reconnect delay, 1.3x delay growth, 5 second minimum stable uptime, 96 maximum reconnect attempts (about 15 minutes with the default backoff curve), a 2 second upstream health check interval, and a 256 KB stalled-upstream threshold.

`debug` is optional and off by default. Essential audit metrics are always available. Set `debug` to `true` (or `debug.enabled: true`) to enable advanced metric capture. You can also control this explicitly with `debug.advancedMetrics`.

`telemetry` is optional and off by default. When enabled, the SDK dispatches audit-derived payloads to the configured provider on terminal flows (`stop`, `error`, terminal `disconnected`). The initial provider is `dynatrace`, resolved through a global runtime object (default `window.dtrum`). Missing runtime is non-fatal and reported through `telemetry_integration_warning`.

##### Telemetry Vendor Integration (Audit-First)

The vendor integration path is audit-first:

- the source payload is `await transcriber.getDebugAudit()`
- dispatch happens automatically on terminal flow boundaries
- outbound vendor payload excludes raw transcript text
- only one provider is active at a time (`provider`)

Dynatrace example:

```typescript
const transcriber = new SofyaTranscriber({
  provider: "sofya_as_service",
  endpoint: "wss://your-endpoint",
  config: {
    language: "en-US",
    telemetry: {
      enabled: true,
      provider: "dynatrace",
      dynatrace: {
        globalKey: "dtrum", // defaults to "dtrum"
        actionName: "sofya transcription audit", // optional
      },
    },
  },
});

transcriber.on("telemetry_integration_warning", (warning) => {
  console.warn("Telemetry integration warning:", warning.code, warning.message);
});
```

The warning event is non-fatal and does not interrupt transcription lifecycle operations.

You can inspect the current runtime state of the resilience subsystem at any time with `transcriber.getResilienceStatus()`. The returned snapshot includes:

-   `connectionState` and `websocketState`
-   retry progress through `reconnectAttempt`, `nextReconnectDelayMs`, `maxReconnectAttempts`, and `remainingReconnectAttempts`
-   buffering visibility through `pendingBufferedAudioBytes`, `persistedBufferedAudioBytes`, `persistedBufferedAudioSegments`, and `totalBufferedAudioBytes`
-   the latest disconnect context through `lastDisconnect`

For the recommended browser-level resilience validation strategy and scenario matrix, see [docs/RESILIENCE_E2E_TEST_SUITE.md](./docs/RESILIENCE_E2E_TEST_SUITE.md).

##### Server-Side Recording (`record`, `external_id`, `stt_session`)

Whisper providers can record the audio and transcript of a realtime session on the server, best-effort and without changing transcription behavior (latency, messages and shutdown are identical with or without recording). Three independent pieces are involved:

```typescript
const transcriber = new SofyaTranscriber({
  provider: "sofya_as_service",
  endpoint: "wss://your-endpoint",
  config: {
    language: "pt-BR",
    record: true,
    external_id: "ef99a3f6-9ce0-4f84-8d85-ef8b2872d0cc",
  },
});

transcriber.on("stt_session", (info) => {
  // info: { sttSessionId, externalId, connectionAttempt }
});

transcriber.getSttSessionId();  // string | null - current connection
transcriber.getSttSessionIds(); // string[] - every connection since startTranscription()
```

`record` decides whether this session is recorded:

| value | effect |
|---|---|
| `true` | requests recording. The server only records when its environment allows it; otherwise the session runs normally without recording |
| `false` | does not record this session, even if the environment records by default |
| `undefined` | the parameter is not sent and the server environment default applies |

The same value is repeated on every reconnection. If the endpoint URL already carries a `record=` query parameter, an explicit `record` in the config takes precedence (the same rule applied to `x-external-id`). Whether a session is actually recorded is decided by the server environment and this flag; nothing in the realtime messages confirms it.

`external_id` identifies the consultation on the client side and groups every connection of it in the bucket: recordings are stored under `<env>/<client>/ext_<external_id>/<ts>_<session_id>/`, so reconnections of the same consultation end up in the same folder, in chronological order. It is copied verbatim into the recording metadata, so follow these rules:

- use an opaque value (UUID, hash, internal id). Never CPF, medical record number, name or e-mail: the value is stored in clear text with long retention;
- use only `[A-Za-z0-9._-]`, start with a letter or digit, and keep it up to 64 characters (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`): such values are used as-is in the bucket path (case preserved). Anything else (for example a `:` or a `/`) is accepted up to 512 characters and URL-encoded on the wire, but the server then uses a hash in the path (`ext_h_<sha256[:16]>`) and the original value is only kept in `meta.json`, which makes the recording harder to find;
- the wire field name is always `x-external-id` and cannot be changed;
- **send the same value on every connection of the consultation**: the SDK repeats it automatically on reconnections. Without an `external_id` the recording falls under `sid_<session_id>/` instead, and reconnections are not grouped together. The SDK never generates one for you and does not warn when it is missing: only the client knows the consultation id.

The server includes `session_id` in every `partial`/`final` message (including the terminal `final` of `stopTranscription()`). The SDK reads it from any message without changing how the message is processed, and emits `stt_session` when the id first appears or changes (reconnection):

| field | type | meaning |
|---|---|---|
| `sttSessionId` | `string` | server session id (UUID v4) of the STT connection. One websocket connection = one id; a reconnection produces a new one |
| `externalId` | `string \| null` | the configured `external_id` sent on this connection |
| `connectionAttempt` | `number` | 1-based connection counter since `startTranscription()`; reconnections increment it |

`sttSessionId` only identifies the STT connection. It does **not** mean the session is being recorded: recording is an environment/`record` decision, and the message makes no statement about it. When a recording exists, the client locates it in the bucket either by consultation, listing the folder `<env>/<client>/ext_<external_id>/` (every connection of the consultation, in chronological order), or by connection, finding the leaf `<ts>_<session_id>/` inside it (glob `*_<session_id>`). Uploads finish up to about one minute after the connection closes.

The id arrives with the first transcript message, so it is only known after the first audio is processed. Servers that do not send `session_id` never emit the event, `getSttSessionId()` returns `null` and `getSttSessionIds()` returns `[]`; nothing in the SDK waits for it. The collected ids are also exported as `sttSessionIds` in `getDebugAudit()` and as `stt_session_ids` in the STT audit ingestion payload (see [docs/STT_AUDIT_INGESTION_GUIDE.md](./docs/STT_AUDIT_INGESTION_GUIDE.md)). Reading or downloading the recording is outside the SDK; bucket access is granted separately by service account.

#### SofyaComplianceConfig

```typescript
interface SofyaComplianceConfig extends BaseConfig {
  token: string;
  compartmentId: string;
  region: string;
}
```

#### ApiKeyConfig / SofyaSpeechConfig

```typescript
interface SofyaSpeechConfig<TBatchPayload = unknown> extends BaseConfig {
  auth?:
    | { type: "none" }
    | {
        type: "api_key";
        key: string;
        transport?: "subprotocol" | "query"; // default: "subprotocol"
      }
    | { type: "jwt"; token: string }; // raw JWT, without "Bearer "
  translation_lang?: SupportedLanguage;
  token?: string; // deprecated: use auth: { type: "jwt", token }
  headers?: Record<string, string>;
  finalUpload?: FinalUploadConfig;
  batchReprocess?: {
    endpoint?: string;
    parseResponse?: (response: Response) => Promise<TBatchPayload>;
  };
  auditIngestion?: {
    endpoint?: string;
    threadId?: string;
    externalIdentifier?: string;
    headers?: Record<string, string>;
  };
}
```

`auth` is optional and only applies to direct-endpoint mode (`{ provider, endpoint, config }`); in `apiKey` mode the reasoner `/providers` flow already authenticates the session. It defaults to `{ type: "none" }`, which keeps the URL and the protocols exactly as configured.

| field | type | meaning |
|---|---|---|
| `type` | `"none" \| "api_key" \| "jwt"` | `"none"` (default) sends no credentials; `"api_key"` sends the STT API key; `"jwt"` sends a JWT instead |
| `key` | `string` | the STT API key. Required when `type` is `"api_key"` |
| `transport` | `"subprotocol" \| "query"` | how the key reaches the server. Defaults to `"subprotocol"` |
| `token` | `string` | the raw JWT. Required when `type` is `"jwt"` |

A session uses exactly one strategy: `api_key` and `jwt` cannot be combined (see [ADR 0002](./docs/adr/0002-exclusive-auth-strategy.md)). Every request the SDK makes follows it:

| strategy | WebSocket | batch, batch reprocess, audit ingestion |
|---|---|---|
| `api_key` | `x-api-key.<key>` subprotocol or `x-api-key` query (below) | `x-api-key: <key>` header |
| `jwt` | the JWT as the **first** subprotocol, before your `protocols`, so the server echoes it | `Authorization: Bearer <token>` |

`finalUpload` goes to your own endpoint and never carries credentials.

| transport | what the SDK sends | notes |
|---|---|---|
| `"subprotocol"` (default) | the extra subprotocol token `x-api-key.<key>`, always appended after your own protocols (`["sofya-stt.v1", "x-api-key.<key>"]` when `protocols` is omitted) | the server needs a companion token, so the SDK never sends the key alone; the key must be an RFC 6455 token (letters, digits and ``!#$%&'*+-.^_`|~``) |
| `"query"` | `x-api-key=<key>` appended to the realtime URL, with `protocols` untouched | less safe: URLs end up in proxy, gateway and browser logs |

```typescript
const transcriber = new SofyaTranscriber({
  provider: "sofya_as_service",
  endpoint: "wss://your-endpoint",
  config: {
    language: "pt-BR",
    auth: { type: "api_key", key: "YOUR_STT_API_KEY" },
  },
});
```

##### JWT

```typescript
const transcriber = new SofyaTranscriber({
  provider: "sofya_as_service",
  endpoint: "wss://your-endpoint",
  config: {
    language: "pt-BR",
    auth: { type: "jwt", token: userJwt }, // "eyJ...", NOT "Bearer eyJ..."
  },
});
```

> **Pass the raw JWT. Do not add `Bearer `: the SDK adds it to the HTTP requests.** A token starting with `Bearer ` makes the constructor throw `SofyaAuthError("INVALID_JWT")`; the SDK never strips it silently.

- **Realtime:** the JWT must be a valid subprotocol token, or the browser refuses the handshake. A standard JWT (base64url, no padding) always is. Anything else (`=`, `/`, spaces) throws `SofyaAuthError("INVALID_JWT_CHARACTERS")` before connecting. Batch mode sends the JWT only as a header and accepts any character.
- **Do not repeat the JWT in `protocols`**: it would be sent twice.
- An `Authorization` header set in `headers` still wins over the JWT.
- **Who validates it:** the Sofya STT does not validate JWTs. Use `jwt` with STTs that have authentication off or sit behind a gateway that validates the JWT; an STT that requires an API key rejects the connection (`4401`). Audit ingestion requires `x-api-key`, so with `jwt` alone it is skipped with the `missing_api_key` warning.
- The JWT is never logged (debug output shows `[redacted]`).

`config.token` is **deprecated** in direct mode and will be removed in 1.0.0; use `auth: { type: "jwt", token }`. Until then it keeps working (sent as `Authorization: Bearer <token>` with any `auth`), the SDK warns once per instance, and `auth.token` wins when both are set.

##### API key

The key is reused on every reconnection, is also sent as the `x-api-key` header on the HTTP calls the SDK makes (batch reprocess and audit ingestion) when that header is not already configured, and is never logged (debug output shows `[redacted]`). A key that cannot be sent as a subprotocol makes the constructor throw `SofyaAuthError` before connecting; when the server rejects the key with close code `4401`, the SDK emits `error` with a `SofyaAuthError` and stops reconnecting. See [docs/CONNECTION_MODE.md](./docs/CONNECTION_MODE.md#authentication-in-direct-mode).

`batchReprocess` is Whisper-only. When `endpoint` is omitted, the SDK derives the batch URL from the realtime base host and uses `/api/transcriber`.

`auditIngestion` is Whisper-only and optional. When configured, the SDK posts the final audit JSON on `stopTranscription()` using `/v1/stt/audits` by default (or the configured `endpoint` override).

#### Batch Reprocess Status

`getBatchReprocessStatus()` returns a snapshot with these states:

- `unsupported`: provider does not support retained-audio batch reprocessing
- `waiting_for_stop`: realtime session is still active
- `unconfigured`: a Whisper session exists but the batch endpoint cannot be resolved
- `ready`: retained audio is still available and can be batch reprocessed
- `running`: batch POST is in flight
- `expired`: retained audio TTL elapsed or the durable audio is no longer available
- `error`: the last batch attempt failed but the TTL window may still be open

## React Example

```jsx
import React from 'react'
import { SofyaTranscriber } from 'sofya.transcription'

const App = () => {
  const transcriberRef = React.useRef<SofyaTranscriber | null>(null)
  const [transcription, setTranscription] = React.useState('')
  const transcriptionRef = React.useRef('')

  const getMediaStream = async () => {
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
    return stream
  }

  const startTranscription = async () => {
    try {
      const stream = await getMediaStream()
      
      // Create transcriber with API key connection
      const transcriber = new SofyaTranscriber({
        apiKey: 'your_api_key',
        config: {
          language: 'en-US'
        }
      })
      
      transcriberRef.current = transcriber

      transcriber.on("ready", () => {
        transcriber.startTranscription(stream)
      })
      
      transcriber.on('recognizing', (result: string) => {
        transcriptionRef.current = result
        setTranscription(result)
      })
      
      transcriber.on('recognized', (result: string) => {
        transcriptionRef.current = result
        setTranscription(result)
      })
      
      transcriber.on('error', (error: Error) => {
        console.error('Transcription error:', error)
      })
    } catch (error) {
      console.error('Error starting transcription:', error)
    }
  }

  const stopTranscription = async () => {
    if (transcriberRef.current) {
      await transcriberRef.current.stopTranscription()
    }
  }

  return (
    <div>
      <button onClick={startTranscription}>Start Transcription</button>
      <button onClick={stopTranscription}>Stop Transcription</button>
      <div>
        <h3>Transcription:</h3>
        <p>{transcription}</p>
      </div>
    </div>
  )
}

export default App
```

## License

This project is licensed under the MIT License - see the LICENSE file for details.
