diff --git a/README.md b/README.md
index 7047212..6f9b89a 100644
--- a/README.md
+++ b/README.md
@@ -3,59 +3,61 @@
[](https://badge.fury.io/js/react-native-deepgram)
[](https://opensource.org/licenses/MIT)
-**react-native-deepgram** brings Deepgram's AI platform to React Native & Expo.
+The complete client for Deepgram's voice AI platform on **React Native** and **Expo** β real-time transcription, conversational voice agents, text-to-speech, and text intelligence in a single dependency, with hardware echo cancellation and background-audio support wired up for you.
-> β
Supports **Speech-to-Text v1** and the new **Speech-to-Text v2 (Flux)** streaming API alongside Text-to-Speech, Text Intelligence, and the Management API.
+```bash
+yarn add react-native-deepgram
+```
+
+## At a glance
+
+| | |
+| --- | --- |
+| π£οΈ **Voice Agent** | Full-duplex agents over Deepgram's WebSocket. Hardware AEC on iOS (VPIO) and Android (`VOICE_COMMUNICATION` + `MODE_IN_COMMUNICATION`). Function calling, prompt updates, latency telemetry. |
+| ποΈ **Speech-to-Text** | Live PCM streaming over WebSocket on STT v1 *or* Flux v2 (`flux-general-en`). File transcription with summarisation, topics, intents, entities. |
+| π **Text-to-Speech** | One-shot HTTP synthesis or low-latency WebSocket streaming with `Speak` / `Flush` / `Clear` / `Close` controls. |
+| π§ **Text Intelligence** | Summaries, topics, intents, sentiment over text or URLs. |
+| π οΈ **Management API** | Typed wrapper for projects, models, keys, usage, balances, members, invitations, scopes, purchases, and temporary tokens. |
+| βοΈ **Expo plugin** | One-line install β handles permissions, background-audio modes, and Android foreground service automatically. |
+| π§° **Resilient by default** | Audio interruptions, route changes, headphone unplugging, mediaserverd resets, audio-focus loss, and queue overflow are all handled. |
+| π **Modern iOS APIs** | `AVAudioApplication.requestRecordPermission` and `AllowBluetoothHFP` on iOS 17+, with safe fallbacks. |
+
+---
## Table of contents
-1. [Features](#features)
-2. [Installation](#installation)
-3. [Expo config plugin](#expo-config-plugin)
-4. [Configuration](#configuration)
-5. [Usage overview](#usage-overview)
+1. [Quick start (5 minutes)](#quick-start-5-minutes)
+2. [Requirements](#requirements)
+3. [Installation](#installation)
+4. [Core concepts](#core-concepts)
+5. [Audio session, AEC & background](#audio-session-aec--background)
6. [Voice Agent](#voice-agent-usedeepgramvoiceagent)
7. [Speech-to-Text](#speech-to-text-usedeepgramspeechtotext)
8. [Text-to-Speech](#text-to-speech-usedeepgramtexttospeech)
9. [Text Intelligence](#text-intelligence-usedeepgramtextintelligence)
10. [Management API](#management-api-usedeepgrammanagement)
-11. [Example app](#example-app)
-12. [Roadmap](#roadmap)
-13. [Contributing](#contributing)
-14. [License](#license)
+11. [Expo config plugin reference](#expo-config-plugin-reference)
+12. [Common recipes](#common-recipes)
+13. [Troubleshooting](#troubleshooting)
+14. [Example app](#example-app)
+15. [Roadmap](#roadmap)
+16. [Contributing](#contributing)
+17. [License](#license)
---
-## Features
+## Quick start (5 minutes)
-- π **Live Speech-to-Text** β capture PCM audio and stream it over WebSocket (STT v1 or v2/Flux).
-- π **File Transcription** β send audio files/URIs to Deepgram and receive transcripts.
-- π€ **Text-to-Speech** β synthesize speech with HTTP requests or WebSocket streaming controls.
-- π£οΈ **Voice Agent** β orchestrate realtime conversational agents with microphone capture + audio playback.
-- π§ **Text Intelligence** β summarisation, topic detection, intents, sentiment and more.
-- π οΈ **Management API** β list models, keys, usage, projects, balances, etc.
-- βοΈ **Expo config plugin** β automatic native configuration for managed and bare workflows.
-
----
-
-## Installation
+### 1. Install
```bash
yarn add react-native-deepgram
-# or
-npm install react-native-deepgram
```
-### iOS (CocoaPods)
-
-```bash
-cd ios && pod install
-```
-
-### Expo
+### 2. Configure the Expo plugin
```js
-// app.config.js
+// app.config.js (or app.json under "plugins")
module.exports = {
expo: {
plugins: [
@@ -76,19 +78,115 @@ npx expo prebuild
npx expo run:ios # or expo run:android
```
+> Bare React Native? Skip the plugin, run `cd ios && pod install`. The package autolinks via `react-native.config.js`.
+
+### 3. Configure the SDK once at startup
+
+```ts
+// App.tsx
+import { configure } from 'react-native-deepgram';
+
+configure({ apiKey: process.env.EXPO_PUBLIC_DEEPGRAM_API_KEY! });
+```
+
+> **Never ship raw API keys.** Use `EXPO_PUBLIC_*` env vars, secrets storage, or a backend proxy that mints scoped keys.
+
+### 4. Stream microphone audio to Deepgram (live STT)
+
+```tsx
+import { useDeepgramSpeechToText } from 'react-native-deepgram';
+import { Button, Text, View } from 'react-native';
+
+export function LiveCaption() {
+ const {
+ startListening,
+ stopListening,
+ state, // { status: 'idle' | 'listening' | 'error' | ... }
+ transcript, // accumulated final transcript
+ interimTranscript, // current partial
+ } = useDeepgramSpeechToText({
+ trackState: true,
+ trackTranscript: true,
+ live: { punctuate: true, interimResults: true },
+ });
+
+ return (
+
+ {transcript} {interimTranscript}
+
+ );
+}
+```
+
+### 5. Talk to a Voice Agent (full-duplex with AEC)
+
+```tsx
+import { useDeepgramVoiceAgent } from 'react-native-deepgram';
+
+const agent = useDeepgramVoiceAgent({
+ trackState: true,
+ trackConversation: true,
+ defaultSettings: {
+ audio: {
+ input: { encoding: 'linear16', sample_rate: 24_000 },
+ output: { encoding: 'linear16', sample_rate: 24_000, container: 'none' },
+ },
+ agent: {
+ language: 'en',
+ greeting: 'Hi! What can I help you with?',
+ listen: { provider: { type: 'deepgram', model: 'nova-3' } },
+ think: { provider: { type: 'open_ai', model: 'gpt-4o' }, prompt: 'You are a helpful assistant.' },
+ speak: { provider: { type: 'deepgram', model: 'aura-2-asteria-en' } },
+ },
+ },
+});
+
+await agent.connect(); // requests mic, opens WS, starts streaming
+agent.injectUserMessage('Hi'); // optional: prompt the agent with text
+agent.disconnect(); // tears everything down
+```
+
+That's a working, low-latency conversational agent with hardware echo cancellation. Open [the example app](#example-app) to try the full surface area.
+
---
-## Expo config plugin
+## Requirements
-The package ships with an Expo config plugin (exported from `app.plugin.js`) that keeps microphone permissions in sync for both
-platforms:
+| | Minimum |
+| --- | --- |
+| Node | 18 |
+| React | 18.0 |
+| React Native | 0.72 |
+| iOS | 13 (15+ recommended for VPIO AEC) |
+| Android | API 24 (Android 7.0) |
+| Xcode | 15+ if building against the iOS 17 SDK |
-- **Android** β automatically adds `android.permission.RECORD_AUDIO` to your manifest if it is missing.
-- **iOS** β sets `NSMicrophoneUsageDescription` with the message you provide (or a sensible fallback).
+> The library is published with the New Architecture in mind but works on the legacy renderer as well.
-### Options
+---
+
+## Installation
+
+```bash
+yarn add react-native-deepgram
+# or
+npm install react-native-deepgram
+```
+
+### Bare React Native
+
+```bash
+cd ios && pod install
+```
+
+The package is autolinked through `react-native.config.js` β no manual `MainApplication` edits required.
+
+### Expo (managed or bare)
-You can customise the iOS prompt via the `microphonePermission` option:
+Add the plugin to your config:
```js
// app.config.js
@@ -99,7 +197,8 @@ module.exports = {
'react-native-deepgram',
{
microphonePermission:
- 'Allow $(PRODUCT_NAME) to capture audio for real-time transcription.',
+ 'Allow $(PRODUCT_NAME) to access your microphone.',
+ // backgroundAudio: true, // default β see plugin docs below
},
],
],
@@ -107,35 +206,109 @@ module.exports = {
};
```
-> π§ Need the plugin in a bare React Native project? Import it via
-> `require('react-native-deepgram/app.plugin.js')` in your config plugin pipeline.
+Then prebuild and run:
+
+```bash
+npx expo prebuild
+npx expo run:ios # or expo run:android
+```
+
+> Already running an existing development build? Re-run `expo prebuild` (and `pod install` on iOS) so the new permissions, background modes, and Android foreground service are picked up.
---
-## Configuration
+## Core concepts
+
+A few ideas you only have to learn once.
+
+### 1. Configure once, use anywhere
```ts
import { configure } from 'react-native-deepgram';
+configure({ apiKey: process.env.EXPO_PUBLIC_DEEPGRAM_API_KEY! });
+```
+
+`configure` is global β call it at the entry point (e.g. `App.tsx`). Every hook reads from this config. The Management API additionally needs a key with management scopes.
+
+> **Never ship raw API keys.** Use `EXPO_PUBLIC_*` env vars, secrets storage, or a backend proxy that mints scoped keys.
-configure({ apiKey: 'YOUR_DEEPGRAM_API_KEY' });
+### 2. Hooks return reactive state β opt in
+
+Every hook supports `track*` flags that turn on a reactive return value. Without them, the hook stays event-driven (callbacks only) so it never causes unnecessary re-renders.
+
+```ts
+const { transcript, state } = useDeepgramSpeechToText({
+ trackTranscript: true, // exposes `transcript` + `interimTranscript`
+ trackState: true, // exposes `state.status`, `state.error`
+});
```
-> **Headsβup π** The Management API needs a key with management scopes.
-> Do not ship production keys in source controlβprefer environment variables, Expo secrets, or a backend proxy.
+| Hook | Tracking flags |
+| --- | --- |
+| `useDeepgramVoiceAgent` | `trackState`, `trackConversation`, `trackAgentStatus` |
+| `useDeepgramSpeechToText` | `trackState`, `trackTranscript` |
+| `useDeepgramTextToSpeech` | `trackState` |
+| `useDeepgramTextIntelligence` | `trackState` |
+
+### 3. Audio session is automatic
+
+You don't manage `AVAudioSession`, `AudioManager`, or focus. The native module activates the right session category, switches to `VoiceChat` / `MODE_IN_COMMUNICATION` when echo cancellation is enabled, requests/abandons audio focus, starts/stops a foreground service on Android, and rebuilds after `mediaserverd` resets on iOS.
+
+For tuning details, see [Audio session, AEC & background](#audio-session-aec--background).
+
+### 4. Hooks at a glance
+
+| Hook | Purpose | Endpoint |
+| --- | --- | --- |
+| `useDeepgramVoiceAgent` | Conversational agents (full-duplex) | `wss://agent.deepgram.com/v1/agent/converse` |
+| `useDeepgramSpeechToText` | Live STT + file transcription | `wss://api.deepgram.com/v1/listen` (v1) / `/v2/listen` (Flux) |
+| `useDeepgramTextToSpeech` | TTS via REST or streaming | `https://api.deepgram.com/v1/speak` & `wss://api.deepgram.com/v1/speak` |
+| `useDeepgramTextIntelligence` | Summaries, topics, intents, sentiment | `https://api.deepgram.com/v1/read` |
+| `useDeepgramManagement` | Projects / keys / usage REST API | `https://api.deepgram.com/v1` |
---
-## Usage overview
+## Audio session, AEC & background
+
+This section covers what the native module does for you so you can debug or tune behaviour with confidence.
+
+### Hardware echo cancellation (full-duplex)
+
+When you build a Voice Agent (or any duplex flow where Deepgram is speaking *and* the mic is open), set `enableVoiceProcessing: true`:
+
+```ts
+import { Deepgram } from 'react-native-deepgram';
+
+await Deepgram.startRecording({ enableVoiceProcessing: true });
+```
+
+`useDeepgramVoiceAgent` opts into this automatically. What this gives you:
+
+| Platform | Behaviour |
+| --- | --- |
+| iOS | Capture is routed through `AVAudioEngine.inputNode` with `setVoiceProcessingEnabled:YES` on both input and output, and the audio session uses `AVAudioSessionModeVoiceChat`. This engages Apple's Voice-Processing I/O Audio Unit β the only way to get hardware AEC on iOS. |
+| Android | Capture uses `MediaRecorder.AudioSource.VOICE_COMMUNICATION` and the system is set to `MODE_IN_COMMUNICATION` so the platform's hardware AEC engages with the active playback signal as its reference. The previous mode is restored on `stopRecording`. |
+
+For pure speech-to-text usage you generally **don't** want this β leave the option off (the default). STT-only paths use `VOICE_RECOGNITION` on Android and a pure AudioQueue capture on iOS so Deepgram receives the rawest possible signal.
+
+### Background audio
-| Hook | Purpose |
-| ----------------------------- | ---------------------------------------------------- |
-| `useDeepgramVoiceAgent` | Build conversational agents with streaming audio I/O |
-| `useDeepgramSpeechToText` | Live microphone streaming and file transcription |
-| `useDeepgramTextToSpeech` | Text-to-Speech synthesis (HTTP + WebSocket streaming) |
-| `useDeepgramTextIntelligence` | Text analysis (summaries, topics, intents, sentiment) |
-| `useDeepgramManagement` | Typed wrapper around the Management REST API |
+Leave `backgroundAudio: true` on the Expo plugin (it's the default) to keep playback and capture alive when the user leaves the app:
-> π‘ **Pro tip**: All hooks now export a `state` object (and other reactive values) so you can easily track connection status, errors, and transcripts without maintaining your own state.
+- **iOS** β `UIBackgroundModes: ["audio"]` is added to your `Info.plist`.
+- **Android** β a foreground service (`DeepgramAudioService`) is bundled with `foregroundServiceType=microphone|mediaPlayback` and the matching `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_MICROPHONE`, `FOREGROUND_SERVICE_MEDIA_PLAYBACK` permissions are merged into your manifest. The service is started/stopped automatically whenever recording or playback is active.
+
+### Built-in resilience
+
+The native modules handle the awkward edge cases for you:
+
+- **Audio interruptions** β phone calls, Siri, system alerts pause + resume cleanly on iOS.
+- **Route changes** β unplugging headphones or disconnecting Bluetooth pauses playback (instead of suddenly blasting through the speaker), matching Apple's `AVAudioSessionRouteChangeReasonOldDeviceUnavailable` guidance and Android's `ACTION_AUDIO_BECOMING_NOISY` broadcast.
+- **Audio focus loss (Android)** β transient losses pause playback; permanent losses tear down recording + playback so you don't fight other apps for the mic.
+- **mediaserverd resets (iOS)** β if iOS restarts its audio service mid-session, the audio engine is rebuilt from scratch on the next call.
+- **Bluetooth on iOS 17+** β uses `AllowBluetoothHFP` when available, with a deprecation-safe fallback for older toolchains.
+- **iOS 17+ permission API** β `requestMicPermission` calls `AVAudioApplication.requestRecordPermissionWithCompletionHandler` when available, falling back to `AVAudioSession` on older OS versions.
+- **Bounded playback queue (Android)** β if JS feeds audio faster than the device can play it, the oldest chunks are dropped at ~1.5βMB so you never OOM mid-call.
---
@@ -143,7 +316,7 @@ configure({ apiKey: 'YOUR_DEEPGRAM_API_KEY' });
`useDeepgramVoiceAgent` connects to `wss://agent.deepgram.com/v1/agent/converse`, captures microphone audio, and optionally auto-plays the agent's streamed responses. It wraps the full Voice Agent messaging surface so you can react to conversation updates, function calls, warnings, and raw PCM audio.
-> π **Audio Handling**: This hook uses `AVAudioEngine` on iOS for hardware-accelerated echo cancellation, ensuring the agent doesn't hear itself speak. It also manages the audio session automatically.
+> π **Hardware AEC enabled by default.** The hook calls `startRecording({ enableVoiceProcessing: true })`, so iOS routes capture through Apple's VPIO Audio Unit and Android switches to `VOICE_COMMUNICATION` + `MODE_IN_COMMUNICATION`. The result: the agent never hears itself speak, even on speakerphone. (Note: VPIO is not available in the iOS Simulator β test on a physical device.)
### Quick start
@@ -232,62 +405,132 @@ return (
### API reference (Voice Agent)
+**Signature**
+
+```ts
+const {
+ // Connection
+ connect, disconnect, isConnected,
+ // Messaging
+ sendMessage, sendSettings, sendMedia, sendKeepAlive,
+ injectUserMessage, injectAgentMessage, updatePrompt,
+ // Function calls
+ sendFunctionCallResponse,
+ // Reactive state (opt-in via track* flags)
+ state, conversation, agentStatus, clearConversation,
+} = useDeepgramVoiceAgent(props);
+```
+
#### Hook props
-| Prop | Type | Description |
-| ---- | ---- | ----------- |
-| `endpoint` | `string` | WebSocket endpoint used for the agent conversation (defaults to `wss://agent.deepgram.com/v1/agent/converse`). |
-| `defaultSettings` | `DeepgramVoiceAgentSettings` | Base `Settings` payload sent on connect; merge per-call overrides via `connect(override)`. |
-| `autoStartMicrophone` | `boolean` | Automatically requests mic access and starts streaming PCM when `true` (default). |
-| `autoPlayAudio` | `boolean` | Automatically plays received audio using the native player (default: `true`). |
-| `trackState` | `boolean` | Enable reactive state tracking (connection, errors, warnings) via the `state` return value (default: `false`). |
-| `trackConversation` | `boolean` | Enable conversation history tracking via the `conversation` return value (default: `false`). |
-| `trackAgentStatus` | `boolean` | Enable agent status tracking (thinking, latency) via the `agentStatus` return value (default: `false`). |
-| `downsampleFactor` | `number` | Manually override the downsample ratio applied to captured audio (defaults to a heuristic based on the requested sample rate). |
+**Configuration**
+
+| Prop | Type | Default | Description |
+| ---- | ---- | ------- | ----------- |
+| `endpoint` | `string` | `wss://agent.deepgram.com/v1/agent/converse` | WebSocket endpoint used for the agent conversation. |
+| `defaultSettings` | `DeepgramVoiceAgentSettings` | β | Base `Settings` payload sent on connect; merge per-call overrides via `connect(override)`. |
+| `autoStartMicrophone` | `boolean` | `true` | Automatically requests mic access and starts streaming PCM. |
+| `autoPlayAudio` | `boolean` | `true` | Plays received audio using the native player. |
+| `downsampleFactor` | `number` | heuristic | Manually override the downsample ratio applied to captured audio. |
+
+**Reactive tracking flags** (opt-in β extra renders only when set)
+
+| Prop | Default | Enables |
+| ---- | ------- | ------- |
+| `trackState` | `false` | `state` return β connection status, error, warning. |
+| `trackConversation` | `false` | `conversation` return β running history of `{ role, content }`. |
+| `trackAgentStatus` | `false` | `agentStatus` return β thinking flag and latency metrics. |
#### Callbacks
+
+Lifecycle β connect/disconnect/error
+
| Callback | Signature | Fired when |
| -------- | --------- | ---------- |
-| `onBeforeConnect` | `() => void` | `connect` is calledβbefore requesting mic permissions or opening the socket. |
-| `onConnect` | `() => void` | The socket opens and the initial settings payload is delivered. |
-| `onClose` | `(event?: any) => void` | The socket closes (manual disconnect or remote). |
-| `onError` | `(error: unknown) => void` | Any unexpected error occurs (mic, playback, socket send, etc.). |
-| `onMessage` | `(message: DeepgramVoiceAgentServerMessage) => void` | Every JSON message from the Voice Agent API. |
-| `onWelcome` | `(message: DeepgramVoiceAgentWelcomeMessage) => void` | The agent returns the initial `Welcome` envelope. |
+| `onBeforeConnect` | `() => void` | `connect` is called, before mic prompt or socket open. |
+| `onConnect` | `() => void` | Socket opens and the initial settings payload is delivered. |
+| `onClose` | `(event?: any) => void` | Socket closes (manual disconnect or remote). |
+| `onError` | `(error: unknown) => void` | Unexpected client-side error (mic, playback, socket send). |
+| `onServerError` | `(message: DeepgramVoiceAgentErrorMessage) => void` | API reports a structured error (`description` + `code`). |
+| `onWarning` | `(message: DeepgramVoiceAgentWarningMessage) => void` | Non-fatal warning (e.g. degraded audio quality). |
+
+
+
+
+Conversation β text, audio, agent state
+
+| Callback | Signature | Fired when |
+| -------- | --------- | ---------- |
+| `onWelcome` | `(message: DeepgramVoiceAgentWelcomeMessage) => void` | Agent returns the initial `Welcome` envelope. |
| `onSettingsApplied` | `(message: DeepgramVoiceAgentSettingsAppliedMessage) => void` | Settings are acknowledged by the agent. |
-| `onConversationText` | `(message: DeepgramVoiceAgentConversationTextMessage) => void` | Transcript updates (`role` + `content`) arrive. |
-| `onAgentThinking` | `(message: DeepgramVoiceAgentAgentThinkingMessage) => void` | The agent reports internal reasoning state. |
-| `onAgentStartedSpeaking` | `(message: DeepgramVoiceAgentAgentStartedSpeakingMessage) => void` | A response playback session begins (latency metrics included). |
-| `onAgentAudioDone` | `(message: DeepgramVoiceAgentAgentAudioDoneMessage) => void` | The agent finishes emitting audio for a turn. |
+| `onConversationText` | `(message: DeepgramVoiceAgentConversationTextMessage) => void` | Transcript update (`role` + `content`) arrives. |
| `onUserStartedSpeaking` | `(message: DeepgramVoiceAgentUserStartedSpeakingMessage) => void` | Server-side VAD detects the user speaking. |
-| `onFunctionCallRequest` | `(message: DeepgramVoiceAgentFunctionCallRequestMessage) => void` | The agent asks the client to execute a tool marked `client_side: true`. |
-| `onFunctionCallResponse` | `(message: DeepgramVoiceAgentReceiveFunctionCallResponseMessage) => void` | The server shares the outcome of a non-client-side function call. |
-| `onPromptUpdated` | `(message: DeepgramVoiceAgentPromptUpdatedMessage) => void` | The active prompt is updated (e.g., after `updatePrompt`). |
-| `onSpeakUpdated` | `(message: DeepgramVoiceAgentSpeakUpdatedMessage) => void` | The active speak configuration changes (sent by the server). |
-| `onInjectionRefused` | `(message: DeepgramVoiceAgentInjectionRefusedMessage) => void` | An inject request is rejected (typically while the agent is speaking). |
-| `onWarning` | `(message: DeepgramVoiceAgentWarningMessage) => void` | The API surfaces a non-fatal warning (e.g., degraded audio quality). |
-| `onServerError` | `(message: DeepgramVoiceAgentErrorMessage) => void` | The API reports a structured error payload (`description` + `code`). |
+| `onAgentThinking` | `(message: DeepgramVoiceAgentAgentThinkingMessage) => void` | Agent reports internal reasoning state. |
+| `onAgentStartedSpeaking` | `(message: DeepgramVoiceAgentAgentStartedSpeakingMessage) => void` | Response playback session begins (latency metrics included). |
+| `onAgentAudioDone` | `(message: DeepgramVoiceAgentAgentAudioDoneMessage) => void` | Agent finishes emitting audio for a turn. |
+| `onAudio` | `(audioData: ArrayBuffer) => void` | Raw binary audio chunk received from the socket. |
+| `onPromptUpdated` | `(message: DeepgramVoiceAgentPromptUpdatedMessage) => void` | Active prompt is updated (e.g. after `updatePrompt`). |
+| `onSpeakUpdated` | `(message: DeepgramVoiceAgentSpeakUpdatedMessage) => void` | Active speak configuration changes (server-driven). |
+| `onListenUpdated` | `(message: DeepgramVoiceAgentListenUpdatedMessage) => void` | Listen configuration is updated (e.g. after `updateListen`). |
+| `onThinkUpdated` | `(message: DeepgramVoiceAgentThinkUpdatedMessage) => void` | Think configuration is updated (e.g. after `updateThink`). |
+| `onHistory` | `(message: DeepgramVoiceAgentHistoryMessage) => void` | Conversation history (text turn or function calls) is replayed by the server. |
+| `onInjectionRefused` | `(message: DeepgramVoiceAgentInjectionRefusedMessage) => void` | Inject request rejected (typically while the agent is speaking). |
+| `onMessage` | `(message: DeepgramVoiceAgentServerMessage) => void` | Catch-all for every JSON message from the API. |
+
+
+
+
+Function calls β client-side tools
+
+| Callback | Signature | Fired when |
+| -------- | --------- | ---------- |
+| `onFunctionCallRequest` | `(message: DeepgramVoiceAgentFunctionCallRequestMessage) => void` | Agent asks the client to execute a tool marked `client_side: true`. Respond with `sendFunctionCallResponse`. |
+| `onFunctionCallResponse` | `(message: DeepgramVoiceAgentReceiveFunctionCallResponseMessage) => void` | Server shares the outcome of a non-client-side function call. |
+
+
#### Returned methods
+**Connection**
+
| Method | Signature | Description |
| ------ | --------- | ----------- |
-| `connect` | `(settings?: DeepgramVoiceAgentSettings) => Promise` | Opens the socket, optionally merges additional settings, and begins microphone streaming. |
-| `disconnect` | `() => void` | Tears down the socket, stops recording, and removes listeners. |
-| `sendMessage` | `(message: DeepgramVoiceAgentClientMessage) => boolean` | Sends a pre-built client envelope (handy for custom message types). |
-| `sendSettings` | `(settings: DeepgramVoiceAgentSettings) => boolean` | Sends a `Settings` message mid-session (merged with the `type` field). |
-| `injectUserMessage` | `(content: string) => boolean` | Injects a user-side text message. |
-| `injectAgentMessage` | `(message: string) => boolean` | Injects an assistant-side text message. |
-| `sendFunctionCallResponse` | `(response: Omit) => boolean` | Returns tool results for client-side function calls. |
-| `sendKeepAlive` | `() => boolean` | Emits a `KeepAlive` ping to keep the session warm. |
-| `updatePrompt` | `(prompt: string) => boolean` | Replaces the active system prompt. |
-| `sendMedia` | `(chunk: ArrayBuffer \| Uint8Array \| number[]) => boolean` | Streams additional PCM audio to the agent (e.g., pre-recorded buffers). |
+| `connect` | `(settings?: DeepgramVoiceAgentSettings) => Promise` | Opens the socket, optionally merges settings, starts mic streaming. |
+| `disconnect` | `() => void` | Tears down the socket, stops recording, removes listeners. |
| `isConnected` | `() => boolean` | Returns `true` when the socket is open. |
-| `clearConversation` | `() => void` | Clears the internal conversation history. |
-| `state` | `DeepgramVoiceAgentState` | Reactive state object (requires `trackState: true`). |
-| `conversation` | `DeepgramVoiceAgentConversationMessage[]` | Reactive conversation history (requires `trackConversation: true`). |
-| `agentStatus` | `DeepgramVoiceAgentStatus` | Reactive agent status (requires `trackAgentStatus: true`). |
+
+**Messaging**
+
+| Method | Signature | Description |
+| ------ | --------- | ----------- |
+| `sendMessage` | `(message: DeepgramVoiceAgentClientMessage) => boolean` | Send a pre-built client envelope (custom message types). |
+| `sendSettings` | `(settings: DeepgramVoiceAgentSettings) => boolean` | Update settings mid-session. |
+| `sendMedia` | `(chunk: ArrayBuffer \| Uint8Array \| number[]) => boolean` | Stream additional PCM (e.g. pre-recorded buffer). |
+| `sendKeepAlive` | `() => boolean` | Emit a `KeepAlive` ping. |
+| `injectUserMessage` | `(content: string) => boolean` | Inject a user-side text turn. |
+| `injectAgentMessage` | `(message: string, behavior?: string) => boolean` | Inject an assistant-side text turn (optional `behavior`, e.g. `'default'`). |
+| `updatePrompt` | `(prompt: string) => boolean` | Replace the active system prompt. |
+| `updateListen` | `(listen: DeepgramVoiceAgentListenConfig) => boolean` | Swap the speech-recognition (listen) provider mid-session. |
+| `updateThink` | `(think: DeepgramVoiceAgentThinkConfig) => boolean` | Swap the LLM (think) provider mid-session. |
+| `updateSpeak` | `(speak: DeepgramVoiceAgentSpeakConfig) => boolean` | Swap the text-to-speech (speak) provider mid-session. |
+
+**Function calls**
+
+| Method | Signature | Description |
+| ------ | --------- | ----------- |
+| `sendFunctionCallResponse` | `(response: Omit) => boolean` | Return tool results for a client-side function call. |
+
+#### Reactive state
+
+Each value is `undefined` unless its corresponding `track*` flag is `true`.
+
+| Return | Type | Requires |
+| ------ | ---- | -------- |
+| `state` | `{ connectionState: 'idle' \| 'connecting' \| 'connected' \| 'disconnected'; error: string \| null; warning: string \| null }` | `trackState: true` |
+| `conversation` | `Array<{ role: string; content: string }>` | `trackConversation: true` |
+| `agentStatus` | `{ thinking: string \| null; latency: { total?: number; tts?: number; ttt?: number } \| null }` | `trackAgentStatus: true` |
+| `clearConversation` | `() => void` | `trackConversation: true` |
#### Settings payload (`DeepgramVoiceAgentSettings`)
@@ -302,7 +545,7 @@ return (
| `audio.output` | `DeepgramVoiceAgentAudioConfig` | Choose output encoding/sample rate/bitrate for agent speech. |
| `agent.language` | `string` | Primary language for the conversation. |
| `agent.context.messages` | `DeepgramVoiceAgentContextMessage[]` | Seed the conversation with prior turns or system notes. |
-| `agent.listen.provider` | `DeepgramVoiceAgentListenProvider` | Speech recognition provider/model configuration. |
+| `agent.listen.provider` | `DeepgramVoiceAgentListenProvider` | Speech recognition provider/model configuration (Deepgram listen also accepts Flux fields: `version`, `eot_threshold`, `eager_eot_threshold`, `eot_timeout_ms`, `language_hints`). |
| `agent.think.provider` | `DeepgramVoiceAgentThinkProvider` | LLM selection (`type`, `model`, `temperature`, etc.). |
| `agent.think.functions` | `DeepgramVoiceAgentFunctionConfig[]` | Tooling exposed to the agent (name, parameters, optional endpoint metadata). |
| `agent.think.prompt` | `string` | System prompt presented to the thinking provider. |
@@ -316,7 +559,9 @@ return (
## Speech-to-Text (`useDeepgramSpeechToText`)
-The speech hook streams microphone audio using WebSockets and can also transcribe prerecorded audio sources. It defaults to STT v1 but automatically boots into Flux when `apiVersion: 'v2'` is supplied (defaulting the model to `flux-general-en`).
+The speech hook streams microphone audio over WebSocket and also handles prerecorded transcription. It defaults to STT v1 and auto-boots into Flux v2 when `apiVersion: 'v2'` is supplied (model defaults to `flux-general-en`).
+
+> π **Flux v2 envelope handled correctly.** The hook decodes Deepgram's `TurnInfo` events and treats `event === 'EndOfTurn'` as the final flag, so you don't have to reason about the new envelope yourself. `Connected`, `ConfigureSuccess` / `ConfigureFailure`, and `Error` messages are surfaced via callbacks.
### Live streaming quick start
@@ -341,7 +586,7 @@ const {
Transcript: {transcript}
startListening({ keywords: ['Deepgram'] })}
+ onPress={() => startListening({ keyterm: ['Deepgram'] })}
/>
```
@@ -369,118 +614,238 @@ const pickFile = async () => {
### API reference (Speech-to-Text)
+**Signature**
+
+```ts
+const {
+ // Live streaming
+ startListening, stopListening,
+ // File transcription
+ transcribeFile,
+ // Reactive state (opt-in)
+ state, transcript, interimTranscript,
+} = useDeepgramSpeechToText(props);
+```
+
#### Hook props
-| Prop | Type | Description |
-| ---------------------- | ------------------------------ | --------------------------------------------------------------- |
-| `onBeforeStart` | `() => void` | Invoked before requesting mic permissions or starting a stream. |
-| `onStart` | `() => void` | Fired once the WebSocket opens. |
-| `onTranscript` | `(transcript: string) => void` | Called for every transcript update (partial and final). |
-| `onError` | `(error: unknown) => void` | Receives streaming errors. |
-| `onEnd` | `() => void` | Fired when the socket closes. |
-| `onBeforeTranscribe` | `() => void` | Called before posting a prerecorded transcription request. |
-| `onTranscribeSuccess` | `(transcript: string) => void` | Receives the final transcript for prerecorded audio. |
-| `onTranscribeError` | `(error: unknown) => void` | Fired if prerecorded transcription fails. |
-| `live` | `DeepgramLiveListenOptions` | Default options merged into every live stream. |
-| `prerecorded` | `DeepgramPrerecordedOptions` | Default options merged into every file transcription. |
-| `trackState` | `boolean` | Enable reactive state tracking via the `state` return value (default: `false`). |
-| `trackTranscript` | `boolean` | Enable reactive transcript tracking via the `transcript` return value (default: `false`). |
+**Configuration**
+
+| Prop | Type | Description |
+| ---- | ---- | ----------- |
+| `live` | `DeepgramLiveListenOptions` | Default options merged into every live stream. |
+| `prerecorded` | `DeepgramPrerecordedOptions` | Default options merged into every file transcription. |
+
+**Live streaming callbacks**
+
+| Callback | Signature | Fired when |
+| -------- | --------- | ---------- |
+| `onBeforeStart` | `() => void` | Before requesting mic permissions or starting a stream. |
+| `onStart` | `() => void` | The WebSocket opens. |
+| `onTranscript` | `(transcript: string, event?: DeepgramTranscriptEvent) => void` | Every transcript update (partial and final). |
+| `onError` | `(error: unknown) => void` | A streaming error occurs. |
+| `onEnd` | `() => void` | The socket closes. |
+
+**File transcription callbacks**
+
+| Callback | Signature | Fired when |
+| -------- | --------- | ---------- |
+| `onBeforeTranscribe` | `() => void` | Before posting a prerecorded request. |
+| `onTranscribeSuccess` | `(transcript: string) => void` | The final transcript arrives. |
+| `onTranscribeError` | `(error: unknown) => void` | The prerecorded request fails. |
+
+**Reactive tracking flags** (opt-in)
+
+| Prop | Default | Enables |
+| ---- | ------- | ------- |
+| `trackState` | `false` | `state` return β status + last error. |
+| `trackTranscript` | `false` | `transcript` and `interimTranscript` returns. |
#### Returned methods
-| Method | Signature | Description |
-| ------------------ | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
-| `startListening` | `(options?: DeepgramLiveListenOptions) => Promise` | Requests mic access, starts recording, and streams audio to Deepgram. |
-| `stopListening` | `() => void` | Stops recording and closes the active WebSocket. |
-| `transcribeFile` | `(file: DeepgramPrerecordedSource, options?: DeepgramPrerecordedOptions) => Promise` | Uploads a file/URI/URL and resolves via the success/error callbacks. |
-| `state` | `DeepgramSpeechToTextState` | Reactive state object (requires `trackState: true`). |
-| `transcript` | `string` | Reactive final transcript (requires `trackTranscript: true`). |
-| `interimTranscript`| `string` | Reactive interim transcript (requires `trackTranscript: true`). |
+| Method | Signature | Description |
+| ------ | --------- | ----------- |
+| `startListening` | `(options?: DeepgramLiveListenOptions) => Promise` | Requests mic access, starts recording, streams audio to Deepgram. |
+| `stopListening` | `() => void` | Stops recording and closes the active WebSocket. |
+| `transcribeFile` | `(file: DeepgramPrerecordedSource, options?: DeepgramPrerecordedOptions) => Promise` | Uploads a file/URI/URL and resolves via the success/error callbacks. |
+
+#### Reactive state
+
+| Return | Type | Requires |
+| ------ | ---- | -------- |
+| `state` | `{ status: 'idle' \| 'loading' \| 'listening' \| 'transcribing' \| 'error'; error: Error \| null }` | `trackState: true` |
+| `transcript` | `string` | `trackTranscript: true` |
+| `interimTranscript` | `string` | `trackTranscript: true` |
#### Live transcription options (`DeepgramLiveListenOptions`)
-Expand all live streaming parameters
-
-| Option | Type | Purpose | Default |
-| -------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------- |
-| `apiVersion` | `'v1' \| 'v2'` | Selects the realtime API generation (`'v2'` unlocks Flux streaming). | `'v1'` |
-| `callback` | `string` | Webhook URL invoked when the stream finishes. | β |
-| `callbackMethod` | `'POST' \| 'GET' \| 'PUT' \| 'DELETE'` | HTTP verb Deepgram should use for `callback`. | `'POST'` |
-| `channels` | `number` | Number of audio channels in the input. | β |
-| `diarize` | `boolean` | Separate speakers into individual tracks. | Disabled |
-| `dictation` | `boolean` | Enable dictation features (punctuation, formatting). | Disabled |
-| `encoding` | `DeepgramLiveListenEncoding` | Audio codec supplied to Deepgram. | `'linear16'` |
-| `endpointing` | `number \| boolean` | Control endpoint detection (`false` disables). | β |
-| `extra` | `Record` | Attach custom metadata returned with the response. | β |
-| `fillerWords` | `boolean` | Include filler words such as "um"/"uh". | Disabled |
-| `interimResults` | `boolean` | Emit interim (non-final) transcripts. | Disabled |
-| `keyterm` | `string \| string[]` | Provide key terms to bias Nova-3 transcription. | β |
-| `keywords` | `string \| string[]` | Boost or suppress keywords. | β |
-| `language` | `string` | BCP-47 language hint (e.g. `en-US`). | Auto |
-| `mipOptOut` | `boolean` | Opt out of the Model Improvement Program. | Disabled |
-| `model` | `DeepgramLiveListenModel` | Streaming model to request. | `'nova-2'` (v1) / `'flux-general-en'` (v2) |
-| `multichannel` | `boolean` | Transcribe each channel independently. | Disabled |
-| `numerals` | `boolean` | Convert spoken numbers into digits. | Disabled |
-| `profanityFilter` | `boolean` | Remove profanity from transcripts. | Disabled |
-| `punctuate` | `boolean` | Auto-insert punctuation and capitalization. | Disabled |
-| `redact` | `DeepgramLiveListenRedaction \| DeepgramLiveListenRedaction[]` | Remove sensitive content such as PCI data. | β |
-| `replace` | `string \| string[]` | Replace specific terms in the output. | β |
-| `sampleRate` | `number` | Sample rate of the PCM audio being sent. | `16000` |
-| `search` | `string \| string[]` | Return timestamps for search terms. | β |
-| `smartFormat` | `boolean` | Apply Deepgram smart formatting. | Disabled |
-| `tag` | `string` | Label the request for reporting. | β |
-| `eagerEotThreshold` | `number` | Confidence required to emit an eager turn (Flux only). | β |
-| `eotThreshold` | `number` | Confidence required to finalise a turn (Flux only). | β |
-| `eotTimeoutMs` | `number` | Silence timeout before closing a turn (Flux only). | β |
-| `utteranceEndMs` | `number` | Delay before emitting an utterance end event. | β |
-| `vadEvents` | `boolean` | Emit voice activity detection events. | Disabled |
-| `version` | `string` | Request a specific model version. | β |
+Model & API version
+
+| Option | Type | Default | Purpose |
+| ------ | ---- | ------- | ------- |
+| `apiVersion` | `'v1' \| 'v2'` | `'v1'` | Selects the realtime API generation (`'v2'` unlocks Flux streaming). |
+| `model` | `DeepgramLiveListenModel` | `'nova-3'` (v1) / `'flux-general-en'` (v2) | Streaming model. Flux also supports `'flux-general-multi'` for multilingual turns. |
+| `version` | `string` | β | Specific model version. |
+| `language` | `string` | Auto | BCP-47 hint (e.g. `en-US`). |
+
+
+
+
+Audio input
+
+| Option | Type | Default | Purpose |
+| ------ | ---- | ------- | ------- |
+| `encoding` | `DeepgramLiveListenEncoding` | `'linear16'` | Codec supplied to Deepgram. |
+| `sampleRate` | `number` | `16000` | PCM sample rate. |
+| `channels` | `number` | β | Channel count. |
+| `multichannel` | `boolean` | `false` | Transcribe each channel independently. |
+
+
+
+
+Formatting & content
+
+| Option | Type | Default | Purpose |
+| ------ | ---- | ------- | ------- |
+| `punctuate` | `boolean` | `false` | Auto-insert punctuation/capitalisation. |
+| `smartFormat` | `boolean` | `false` | Apply Deepgram smart formatting. |
+| `numerals` | `boolean` | `false` | Convert spoken numbers to digits. |
+| `dictation` | `boolean` | `false` | Dictation features. |
+| `fillerWords` | `boolean` | `false` | Include "um"/"uh". |
+| `profanityFilter` | `boolean` | `false` | Remove profanity. |
+| `detectEntities` | `boolean` | `false` | Extract entities (names, places, etc.) into the results. |
+| `redact` | `DeepgramLiveListenRedaction \| DeepgramLiveListenRedaction[]` | β | Remove PCI/PII. |
+| `replace` | `string \| string[]` | β | Replace specific terms. |
+| `search` | `string \| string[]` | β | Return timestamps for search terms. |
+| `keywords` | `string \| string[]` | β | Boost or suppress keywords on legacy/non-Nova-3 models. |
+| `keyterm` | `string \| string[]` | β | Bias Nova-3 and Flux with key terms. |
+
+
+
+
+Endpointing & VAD
+
+| Option | Type | Default | Purpose |
+| ------ | ---- | ------- | ------- |
+| `endpointing` | `number \| boolean` | β | Endpoint detection (`false` to disable). |
+| `interimResults` | `boolean` | `false` | Emit interim (non-final) transcripts. |
+| `utteranceEndMs` | `number` | β | Delay before emitting utterance-end event. |
+| `vadEvents` | `boolean` | `false` | Emit voice activity detection events. |
+| `diarizeModel` | `DeepgramLiveListenDiarizeModel` | β | Diarisation model (`'latest'` / `'v1'`). Separates speakers. |
+| `diarize` | `boolean` | `false` | **Deprecated** β use `diarizeModel`. Separate speakers. |
+
+
+
+
+Flux v2 (only when apiVersion: 'v2')
+
+| Option | Type | Purpose |
+| ------ | ---- | ------- |
+| `eotThreshold` | `number` | Confidence required to finalise a turn. |
+| `eagerEotThreshold` | `number` | Confidence required to emit an eager turn. |
+| `eotTimeoutMs` | `number` | Silence timeout before closing a turn. |
+| `languageHint` | `string \| string[]` | Language hint(s) β only valid with the `flux-general-multi` model. |
+
+
+
+
+Webhooks & metadata
+
+| Option | Type | Default | Purpose |
+| ------ | ---- | ------- | ------- |
+| `callback` | `string` | β | Webhook URL invoked when the stream finishes. |
+| `callbackMethod` | `'POST' \| 'GET' \| 'PUT' \| 'DELETE'` | `'POST'` | HTTP verb for `callback`. |
+| `extra` | `Record` | β | Custom metadata returned with the response. |
+| `tag` | `string` | β | Label the request for reporting. |
+| `mipOptOut` | `boolean` | `false` | Opt out of the Model Improvement Program. |
#### Prerecorded transcription options (`DeepgramPrerecordedOptions`)
-Expand all prerecorded transcription parameters
-
-| Option | Type | Purpose | Default |
-| ------------------ | ------------------------------------------------- | ----------------------------------------------------------------------- | ----------------------- |
-| `callback` | `string` | Webhook URL invoked once transcription finishes. | β |
-| `callbackMethod` | `DeepgramPrerecordedCallbackMethod` | HTTP verb used for `callback`. | `'POST'` |
-| `extra` | `DeepgramPrerecordedExtra` | Metadata returned with the response. | β |
-| `sentiment` | `boolean` | Run sentiment analysis. | Disabled |
-| `summarize` | `DeepgramPrerecordedSummarize` | Request AI summaries (`true`, `'v1'`, or `'v2'`). | Disabled |
-| `tag` | `string \| string[]` | Label the request. | β |
-| `topics` | `boolean` | Detect topics. | Disabled |
-| `customTopic` | `string \| string[]` | Provide additional topics to monitor. | β |
-| `customTopicMode` | `DeepgramPrerecordedCustomMode` | Interpret `customTopic` as `'extended'` or `'strict'`. | `'extended'` |
-| `intents` | `boolean` | Detect intents. | Disabled |
-| `customIntent` | `string \| string[]` | Provide custom intents to bias detection. | β |
-| `customIntentMode` | `DeepgramPrerecordedCustomMode` | Interpret `customIntent` as `'extended'` or `'strict'`. | `'extended'` |
-| `detectEntities` | `boolean` | Extract entities (names, places, etc.). | Disabled |
-| `detectLanguage` | `boolean \| string \| string[]` | Auto-detect language or limit detection. | Disabled |
-| `diarize` | `boolean` | Enable speaker diarisation. | Disabled |
-| `dictation` | `boolean` | Enable dictation formatting. | Disabled |
-| `encoding` | `DeepgramPrerecordedEncoding` | Encoding/codec of the uploaded audio. | β |
-| `fillerWords` | `boolean` | Include filler words. | Disabled |
-| `keyterm` | `string \| string[]` | Provide key terms to bias Nova-3. | β |
-| `keywords` | `string \| string[]` | Boost or suppress keywords. | β |
-| `language` | `string` | Primary spoken language hint (BCP-47). | Auto |
-| `measurements` | `boolean` | Convert measurements into abbreviations. | Disabled |
-| `model` | `DeepgramPrerecordedModel` | Model to use for transcription. | API default |
-| `multichannel` | `boolean` | Transcribe each channel independently. | Disabled |
-| `numerals` | `boolean` | Convert spoken numbers into digits. | Disabled |
-| `paragraphs` | `boolean` | Split transcript into paragraphs. | Disabled |
-| `profanityFilter` | `boolean` | Remove profanity from the transcript. | Disabled |
-| `punctuate` | `boolean` | Auto-insert punctuation and capitalisation. | Disabled |
-| `redact` | `DeepgramPrerecordedRedaction \| DeepgramPrerecordedRedaction[]` | Remove sensitive content (PCI/PII). | β |
-| `replace` | `string \| string[]` | Replace specific terms in the output. | β |
-| `search` | `string \| string[]` | Return timestamps for search terms. | β |
-| `smartFormat` | `boolean` | Apply Deepgram smart formatting. | Disabled |
-| `utterances` | `boolean` | Return utterance-level timestamps. | Disabled |
-| `uttSplit` | `number` | Pause duration (seconds) used to split utterances. | β |
-| `version` | `DeepgramPrerecordedVersion` | Request a specific model version (e.g. `'latest'`). | API default (`'latest'`) |
+Model & language
+
+| Option | Type | Default | Purpose |
+| ------ | ---- | ------- | ------- |
+| `model` | `DeepgramPrerecordedModel` | API default | Transcription model. |
+| `version` | `DeepgramPrerecordedVersion` | `'latest'` | Specific model version. |
+| `language` | `string` | Auto | BCP-47 language hint. |
+| `detectLanguage` | `boolean \| string \| string[]` | `false` | Auto-detect language or restrict detection. |
+
+
+
+
+Audio input
+
+| Option | Type | Default | Purpose |
+| ------ | ---- | ------- | ------- |
+| `encoding` | `DeepgramPrerecordedEncoding` | β | Codec of the uploaded audio. |
+| `multichannel` | `boolean` | `false` | Transcribe each channel independently. |
+
+
+
+
+Formatting & content
+
+| Option | Type | Default | Purpose |
+| ------ | ---- | ------- | ------- |
+| `punctuate` | `boolean` | `false` | Auto-insert punctuation. |
+| `smartFormat` | `boolean` | `false` | Smart formatting. |
+| `numerals` | `boolean` | `false` | Convert spoken numbers to digits. |
+| `measurements` | `boolean` | `false` | Convert measurements to abbreviations. |
+| `paragraphs` | `boolean` | `false` | Split transcript into paragraphs. |
+| `dictation` | `boolean` | `false` | Dictation formatting. |
+| `fillerWords` | `boolean` | `false` | Include filler words. |
+| `profanityFilter` | `boolean` | `false` | Remove profanity. |
+| `redact` | `DeepgramPrerecordedRedaction \| DeepgramPrerecordedRedaction[]` | β | Remove sensitive content. |
+| `replace` | `string \| string[]` | β | Replace specific terms. |
+| `search` | `string \| string[]` | β | Timestamps for search terms. |
+| `keywords` | `string \| string[]` | β | Boost or suppress keywords on legacy/non-Nova-3 models. |
+| `keyterm` | `string \| string[]` | β | Bias Nova-3 and Flux with key terms. |
+
+
+
+
+Diarisation & utterances
+
+| Option | Type | Default | Purpose |
+| ------ | ---- | ------- | ------- |
+| `diarizeModel` | `DeepgramPrerecordedDiarizeModel` | β | Diarisation model (`'latest'` / `'v1'` / `'v2'`). Separates speakers. |
+| `diarize` | `boolean` | `false` | **Deprecated** β use `diarizeModel`. Speaker diarisation. |
+| `utterances` | `boolean` | `false` | Utterance-level timestamps. |
+| `uttSplit` | `number` | β | Pause duration (s) used to split utterances. |
+| `detectEntities` | `boolean` | `false` | Extract entities (names, places, etc.). |
+
+
+
+
+Intelligence (summarise / topics / intents / sentiment)
+
+| Option | Type | Default | Purpose |
+| ------ | ---- | ------- | ------- |
+| `summarize` | `DeepgramPrerecordedSummarize` | `false` | AI summary (`true`, `'v1'`, `'v2'`). |
+| `topics` | `boolean` | `false` | Detect topics. |
+| `customTopic` | `string \| string[]` | β | Additional topics to monitor. |
+| `customTopicMode` | `DeepgramPrerecordedCustomMode` | `'extended'` | `'extended'` (additive) or `'strict'` (exact). |
+| `intents` | `boolean` | `false` | Detect intents. |
+| `customIntent` | `string \| string[]` | β | Custom intents. |
+| `customIntentMode` | `DeepgramPrerecordedCustomMode` | `'extended'` | `'extended'` or `'strict'`. |
+| `sentiment` | `boolean` | `false` | Sentiment analysis. |
+
+
+
+
+Webhooks & metadata
+
+| Option | Type | Default | Purpose |
+| ------ | ---- | ------- | ------- |
+| `callback` | `string` | β | Webhook URL. |
+| `callbackMethod` | `DeepgramPrerecordedCallbackMethod` | `'POST'` | HTTP verb for `callback`. |
+| `extra` | `DeepgramPrerecordedExtra` | β | Metadata returned with the response. |
+| `tag` | `string \| string[]` | β | Label the request. |
+| `mipOptOut` | `boolean` | `false` | Opt out of the Model Improvement Program. |
@@ -490,6 +855,17 @@ const pickFile = async () => {
Generate audio via a single HTTP call or stream interactive responses over WebSocket. The hook exposes granular configuration for both request paths.
+### Streaming protocol parity notes (Deepgram review)
+
+- WebSocket endpoint: `wss://api.deepgram.com/v1/speak`
+- Canonical streaming input message for text is `{ "type": "Speak", "text": "..." }`
+- Supported control messages: `Flush`, `Clear`, `Close`
+- Authorization supports both `Token ` and `Bearer ` formats
+- Streaming media output settings supported by Deepgram: `encoding` in `linear16 | mulaw | alaw`, with streaming `sample_rate` options based on encoding
+- Flush limit: up to 20 `Flush` messages per 60 seconds (Deepgram sends warnings when exceeded)
+
+For backward compatibility, `sendMessage` still accepts legacy `{ type: 'Text' }` payloads and maps them to `{ type: 'Speak' }` before sending.
+
### HTTP synthesis quick start
```tsx
@@ -544,39 +920,78 @@ closeStreamGracefully();
### API reference
+**Signature**
+
+```ts
+const {
+ // HTTP synthesis
+ synthesize,
+ // Streaming
+ startStreaming, sendText, sendMessage,
+ flushStream, clearStream,
+ closeStreamGracefully, stopStreaming,
+ // Reactive state (opt-in)
+ state,
+} = useDeepgramTextToSpeech(props);
+```
+
#### Hook props
-| Prop | Type | Description |
-| -------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------- |
-| `onBeforeSynthesize` | `() => void` | Called before dispatching an HTTP synthesis request. |
-| `onSynthesizeSuccess`| `(audio: ArrayBuffer) => void` | Receives the raw audio bytes when the HTTP request succeeds. |
-| `onSynthesizeError` | `(error: unknown) => void` | Fired if the HTTP request fails. |
-| `onBeforeStream` | `() => void` | Called prior to opening the WebSocket stream. |
-| `onStreamStart` | `() => void` | Fired once the socket is open and ready. |
-| `onAudioChunk` | `(chunk: ArrayBuffer) => void` | Called for each PCM chunk received from the stream. |
-| `onStreamMetadata` | `(metadata: DeepgramTextToSpeechStreamMetadataMessage) => void` | Emits metadata describing the current stream. |
-| `onStreamFlushed` | `(event: DeepgramTextToSpeechStreamFlushedMessage) => void` | Raised when Deepgram confirms a flush. |
-| `onStreamCleared` | `(event: DeepgramTextToSpeechStreamClearedMessage) => void` | Raised when Deepgram confirms a clear. |
-| `onStreamWarning` | `(warning: DeepgramTextToSpeechStreamWarningMessage) => void` | Raised when Deepgram warns about the stream. |
-| `onStreamError` | `(error: unknown) => void` | Fired when the WebSocket errors. |
-| `onStreamEnd` | `() => void` | Fired when the stream closes (gracefully or otherwise). |
-| `options` | `UseDeepgramTextToSpeechOptions` | Default configuration merged into HTTP and streaming requests. |
-| `autoPlayAudio` | `boolean` | Automatically plays received audio using the native player (default: `true`). |
-| `trackState` | `boolean` | Enable reactive state tracking via the `state` return value (default: `false`). |
+**Configuration**
+
+| Prop | Type | Default | Description |
+| ---- | ---- | ------- | ----------- |
+| `options` | `UseDeepgramTextToSpeechOptions` | β | Defaults merged into HTTP and streaming requests. |
+| `autoPlayAudio` | `boolean` | `true` | Auto-play received audio with the native player. |
+| `trackState` | `boolean` | `false` | Enable `state` return. |
+
+**HTTP synthesis callbacks**
+
+| Callback | Signature | Fired when |
+| -------- | --------- | ---------- |
+| `onBeforeSynthesize` | `() => void` | Before dispatching the HTTP request. |
+| `onSynthesizeSuccess` | `(audio: ArrayBuffer) => void` | The HTTP request succeeds. |
+| `onSynthesizeError` | `(error: unknown) => void` | The HTTP request fails. |
+
+**Streaming callbacks**
+
+| Callback | Signature | Fired when |
+| -------- | --------- | ---------- |
+| `onBeforeStream` | `() => void` | Before opening the WebSocket. |
+| `onStreamStart` | `() => void` | Socket is open and ready. |
+| `onAudioChunk` | `(chunk: ArrayBuffer) => void` | Each PCM chunk arrives. |
+| `onStreamMetadata` | `(metadata: DeepgramTextToSpeechStreamMetadataMessage) => void` | Stream metadata arrives. |
+| `onStreamFlushed` | `(event: DeepgramTextToSpeechStreamFlushedMessage) => void` | Deepgram confirms a flush. |
+| `onStreamCleared` | `(event: DeepgramTextToSpeechStreamClearedMessage) => void` | Deepgram confirms a clear. |
+| `onStreamWarning` | `(warning: DeepgramTextToSpeechStreamWarningMessage) => void` | Deepgram warns about the stream. |
+| `onStreamError` | `(error: unknown) => void` | The WebSocket errors. |
+| `onStreamEnd` | `() => void` | The stream closes (gracefully or otherwise). |
#### Returned methods
-| Method | Signature | Description |
-| --------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
-| `synthesize` | `(text: string) => Promise` | Sends a single piece of text via REST and resolves with the full audio buffer. |
-| `startStreaming` | `(text: string) => Promise` | Opens the streaming WebSocket and queues the first message. |
-| `sendMessage` | `(message: DeepgramTextToSpeechStreamInputMessage) => boolean` | Sends a raw control message (`Text`, `Flush`, `Clear`, `Close`) to the active stream. |
-| `sendText` | `(text: string, options?: { flush?: boolean; sequenceId?: number }) => boolean` | Queues additional text frames, optionally suppressing auto-flush or setting a sequence id. |
-| `flushStream` | `() => boolean` | Requests Deepgram to emit all buffered audio immediately. |
-| `clearStream` | `() => boolean` | Clears buffered text/audio without closing the socket. |
-| `closeStreamGracefully` | `() => boolean` | Asks Deepgram to finish outstanding audio then close the stream. |
-| `stopStreaming` | `() => void` | Force-closes the socket and releases resources. |
-| `state` | `DeepgramTextToSpeechState` | Reactive state object (requires `trackState: true`). |
+**HTTP**
+
+| Method | Signature | Description |
+| ------ | --------- | ----------- |
+| `synthesize` | `(text: string) => Promise` | Single-shot REST call; resolves with the full audio buffer. |
+
+**Streaming**
+
+| Method | Signature | Description |
+| ------ | --------- | ----------- |
+| `startStreaming` | `(text: string) => Promise` | Open the WebSocket and queue the first message. |
+| `sendText` | `(text: string, options?: { flush?: boolean; sequenceId?: number }) => boolean` | Queue additional text; can suppress auto-flush or set sequence id. |
+| `sendMessage` | `(message: DeepgramTextToSpeechStreamInputMessage) => boolean` | Send a raw control message (`Speak`, `Flush`, `Clear`, `Close`). |
+| `flushStream` | `() => boolean` | Tell Deepgram to emit all buffered audio now. |
+| `clearStream` | `() => boolean` | Clear buffered text/audio without closing the socket. |
+| `closeStreamGracefully` | `() => boolean` | Drain outstanding audio, then close. |
+| `stopStreaming` | `() => void` | Force-close and release resources. |
+
+#### Reactive state
+
+| Return | Type | Requires |
+| ------ | ---- | -------- |
+| `state` | `DeepgramTextToSpeechState` | `trackState: true` |
#### Configuration (`UseDeepgramTextToSpeechOptions`)
@@ -595,6 +1010,8 @@ closeStreamGracefully();
| `format`* | `'mp3' \| 'wav' \| 'opus' \| 'pcm' \| (string & {})` | HTTP | Legacy shortcut for container/format. |
| `callback`* | `string` | HTTP | Legacy shortcut for callback URL. |
| `callbackMethod`* | `DeepgramTextToSpeechCallbackMethod` | HTTP | Legacy shortcut for callback method. |
+| `speed`* | `number` | Both | Legacy shortcut for playback speed (prefer `http.speed` / `stream.speed`). |
+| `tag`* | `string \| string[]` | HTTP | Legacy shortcut for the usage-reporting tag (prefer `http.tag`). |
| `mipOptOut`* | `boolean` | Both | Legacy shortcut for Model Improvement Program opt-out. |
| `queryParams` | `Record` | Both | Shared query string parameters appended to all requests. |
| `http` | `DeepgramTextToSpeechHttpOptions` | HTTP | Fine-grained HTTP synthesis configuration. |
@@ -615,6 +1032,8 @@ closeStreamGracefully();
| `container` | `DeepgramTextToSpeechContainer` | Wrap audio in a container (`'none'`, `'wav'`, `'ogg'`). |
| `format` | `'mp3' \| 'wav' \| 'opus' \| 'pcm' \| (string & {})` | Deprecated alias for `container`. |
| `bitRate` | `DeepgramTextToSpeechBitRate` | Bit rate for compressed formats (e.g. MP3). |
+| `speed` | `number` | Playback speed multiplier (`0.7`β`1.5`, default `1`). |
+| `tag` | `string \| string[]` | Label the request for usage reporting. |
| `callback` | `string` | Webhook URL invoked after synthesis completes. |
| `callbackMethod` | `DeepgramTextToSpeechCallbackMethod` | HTTP verb used for the callback. |
| `mipOptOut` | `boolean` | Opt out of the Model Improvement Program. |
@@ -630,6 +1049,7 @@ closeStreamGracefully();
| `model` | `DeepgramTextToSpeechModel \| (string & {})` | Select the streaming voice/model. |
| `encoding` | `DeepgramTextToSpeechStreamEncoding` | Output PCM encoding for streamed chunks. |
| `sampleRate` | `DeepgramTextToSpeechSampleRate` | Output sample rate in Hz. |
+| `speed` | `number` | Playback speed multiplier (`0.7`β`1.5`, default `1`). |
| `mipOptOut` | `boolean` | Opt out of the Model Improvement Program. |
| `queryParams`| `Record` | Extra query parameters appended to the streaming URL. |
| `autoFlush` | `boolean` | Automatically flush after each `sendText` call (defaults to `true`). |
@@ -645,7 +1065,7 @@ Run summarisation, topic detection, intent detection, sentiment analysis, and mo
```tsx
const { analyze, state } = useDeepgramTextIntelligence({
trackState: true,
- onAnalyzeSuccess: (result) => console.log(result.summary),
+ onAnalyzeSuccess: (response) => console.log(response.results.summary?.text),
options: {
summarize: true,
topics: true,
@@ -657,22 +1077,56 @@ const { analyze, state } = useDeepgramTextIntelligence({
await analyze({ text: 'Deepgram makes voice data useful.' });
```
-### Options (`UseDeepgramTextIntelligenceOptions`)
+### API reference
+
+**Signature**
+
+```ts
+const {
+ analyze, // (input: { text?: string; url?: string }) => Promise
+ state, // requires trackState: true
+} = useDeepgramTextIntelligence(props);
+```
+
+#### Hook props
+
+| Prop | Type | Description |
+| ---- | ---- | ----------- |
+| `options` | `UseDeepgramTextIntelligenceOptions` | Defaults merged into every `analyze` call. |
+| `onBeforeAnalyze` | `() => void` | Before the request is dispatched. |
+| `onAnalyzeSuccess` | `(response: DeepgramTextIntelligenceResponse) => void` | Result arrives. |
+| `onAnalyzeError` | `(error: unknown) => void` | Request fails. |
+| `trackState` | `boolean` | Enable `state` return (default `false`). |
+
+#### Options (`UseDeepgramTextIntelligenceOptions`)
+
+
+Analysis
+
+| Option | Type | Purpose |
+| ------ | ---- | ------- |
+| `summarize` | `boolean` | Run summarisation on the input. |
+| `topics` | `boolean` | Detect topics. |
+| `customTopic` | `string \| string[]` | Additional topics to monitor. |
+| `customTopicMode` | `'extended' \| 'strict'` | Additive (`extended`) or exact (`strict`). |
+| `intents` | `boolean` | Detect intents. |
+| `customIntent` | `string \| string[]` | Custom intents. |
+| `customIntentMode` | `'extended' \| 'strict'` | Additive or exact match. |
+| `sentiment` | `boolean` | Sentiment analysis. |
+| `language` | `DeepgramTextIntelligenceLanguage` | BCP-47 hint (default `'en'`). |
+| `tag` | `string \| string[]` | Label the request for usage reporting. |
+
+
+
+
+Webhooks
-| Option | Type | Purpose |
-| ------------------ | -------------------------------------------- | ---------------------------------------------------------------------------- |
-| `summarize` | `boolean` | Run summarisation on the input. |
-| `topics` | `boolean` | Detect topics. |
-| `customTopic` | `string \| string[]` | Supply additional topics to monitor. |
-| `customTopicMode` | `'extended' \| 'strict'` | Interpret custom topics as additive (`extended`) or exact (`strict`). |
-| `intents` | `boolean` | Detect intents. |
-| `customIntent` | `string \| string[]` | Provide custom intents to bias detection. |
-| `customIntentMode` | `'extended' \| 'strict'` | Interpret custom intents as additive (`extended`) or exact (`strict`). |
-| `sentiment` | `boolean` | Run sentiment analysis. |
-| `language` | `DeepgramTextIntelligenceLanguage` | BCP-47 language hint (defaults to `'en'`). |
-| `callback` | `string` | Webhook URL invoked after processing completes. |
-| `callbackMethod` | `'POST' \| 'PUT' \| (string & {})` | HTTP method used for the callback. |
-| `trackState` | `boolean` | Enable reactive state tracking via the `state` return value (default: `false`). |
+| Option | Type | Purpose |
+| ------ | ---- | ------- |
+| `callback` | `string` | Webhook URL invoked after processing. |
+| `callbackMethod` | `'POST' \| 'PUT' \| (string & {})` | HTTP method for the callback. |
+
+
---
@@ -689,6 +1143,8 @@ console.log('Projects:', projects.map((p) => p.name));
### Snapshot of available groups
+Most methods also accept an optional trailing `query?: Record` argument for additional server-side filters/pagination.
+
| Group | Representative methods |
| ---------- | -------------------------------------------------------------------------------------- |
| `models` | `list(includeOutdated?)`, `get(modelId)` |
@@ -696,14 +1152,310 @@ console.log('Projects:', projects.map((p) => p.name));
| `keys` | `list(projectId)`, `create(projectId, body)`, `get(projectId, keyId)`, `delete(...)` |
| `usage` | `listRequests(projectId)`, `getRequest(projectId, requestId)`, `getBreakdown(projectId)` |
| `balances` | `list(projectId)`, `get(projectId, balanceId)` |
+| `auth` | `grant(body?)` β mint a short-lived access token (`{ ttl_seconds? }`) |
_(Plus helpers for `members`, `scopes`, `invitations`, and `purchases`.)_
+### Query examples
+
+```tsx
+const dg = useDeepgramManagement();
+
+// Include outdated models + extra query filters
+const models = await dg.models.list(true, { limit: 50 });
+
+// Project list pagination/filtering
+const page1 = await dg.projects.list({ page: 1, per_page: 25 });
+
+// Usage request windows
+const requests = await dg.usage.listRequests('project_id', {
+ page: 1,
+ per_page: 100,
+ from: '2026-03-01T00:00:00Z',
+ to: '2026-03-17T23:59:59Z',
+});
+
+// Keys/members scoping
+const keys = await dg.keys.list('project_id', { page: 1, per_page: 20 });
+const members = await dg.members.list('project_id', { page: 1 });
+```
+
+`query` is passed through as URL query parameters, so you can supply endpoint-specific filters/pagination supported by Deepgram.
+
+---
+
+## Expo config plugin reference
+
+The package ships with an Expo config plugin (exported from `app.plugin.js`). It mutates `AndroidManifest.xml` and `Info.plist` at prebuild time so you don't have to.
+
+### What it does
+
+| Platform | Effect |
+| --- | --- |
+| Android | Adds `android.permission.RECORD_AUDIO` if missing. |
+| Android | When `backgroundAudio !== false`, also adds `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_MICROPHONE`, and `FOREGROUND_SERVICE_MEDIA_PLAYBACK` permissions. |
+| Android | Registers the `DeepgramAudioService` (microphone + media-playback foreground service). |
+| iOS | Sets `NSMicrophoneUsageDescription` from the `microphonePermission` option (with a sensible fallback). |
+| iOS | When `backgroundAudio !== false`, adds `audio` to `UIBackgroundModes`. |
+
+### Options
+
+```js
+// app.config.js
+module.exports = {
+ expo: {
+ plugins: [
+ [
+ 'react-native-deepgram',
+ {
+ microphonePermission:
+ 'Allow $(PRODUCT_NAME) to capture audio for real-time transcription.',
+ backgroundAudio: true, // default β set false to disable background audio + foreground service
+ },
+ ],
+ ],
+ },
+};
+```
+
+| Option | Type | Default | Effect |
+| --- | --- | --- | --- |
+| `microphonePermission` | `string` | Generic English fallback | Sets `NSMicrophoneUsageDescription`. Always provide your own copy in production. |
+| `backgroundAudio` | `boolean` | `true` | When `false`, **omits** iOS background-audio mode and Android foreground-service permissions. Choose this if your app must never run audio in the background. |
+
+> π§ **Bare React Native** without Expo? You can still load the plugin in your own prebuild pipeline: `require('react-native-deepgram/app.plugin.js')`.
+
+---
+
+## Common recipes
+
+Copy-paste patterns for tasks that come up often.
+
+### Push-to-talk live transcription
+
+```tsx
+import { useDeepgramSpeechToText } from 'react-native-deepgram';
+import { Pressable, Text } from 'react-native';
+
+export function PushToTalk() {
+ const { startListening, stopListening, transcript } =
+ useDeepgramSpeechToText({
+ trackTranscript: true,
+ live: { punctuate: true, interimResults: true },
+ });
+
+ return (
+ startListening()}
+ onPressOut={() => stopListening()}
+ >
+ {transcript || 'Hold to talk'}
+
+ );
+}
+```
+
+### Flux v2 streaming with end-of-turn handling
+
+```tsx
+const stt = useDeepgramSpeechToText({
+ live: {
+ apiVersion: 'v2',
+ model: 'flux-general-en',
+ eotThreshold: 0.6,
+ eotTimeoutMs: 800,
+ },
+ onTranscript: (text, event) => {
+ if (event?.isFinal) {
+ // a `TurnInfo` with `event === 'EndOfTurn'` arrived
+ submitToBackend(text);
+ } else {
+ renderInterim(text);
+ }
+ },
+});
+```
+
+### Transcribe a picked file with summaries + topics
+
+```tsx
+import * as DocumentPicker from 'expo-document-picker';
+
+const { transcribeFile } = useDeepgramSpeechToText({
+ prerecorded: { punctuate: true, smartFormat: true },
+ onTranscribeSuccess: (transcript) => console.log(transcript),
+});
+
+const pick = async () => {
+ const f = await DocumentPicker.getDocumentAsync({ type: 'audio/*' });
+ if (f.type === 'success') {
+ await transcribeFile(f, { summarize: 'v2', topics: true, intents: true });
+ }
+};
+```
+
+### Voice Agent with a client-side function
+
+```tsx
+const agent = useDeepgramVoiceAgent({
+ trackState: true,
+ defaultSettings: {
+ agent: {
+ think: {
+ provider: { type: 'open_ai', model: 'gpt-4o' },
+ prompt: 'You are a helpful weather assistant.',
+ functions: [
+ {
+ name: 'get_weather',
+ description: 'Look up live weather for a city.',
+ parameters: {
+ type: 'object',
+ properties: { city: { type: 'string' } },
+ required: ['city'],
+ },
+ client_side: true, // executed by the app, not the server
+ },
+ ],
+ },
+ // ...listen + speak providers
+ },
+ },
+ onFunctionCallRequest: async (call) => {
+ const args = JSON.parse(call.arguments);
+ const data = await fetchWeather(args.city);
+ agent.sendFunctionCallResponse({
+ id: call.function_call_id,
+ name: call.name,
+ content: JSON.stringify(data),
+ client_side: true,
+ });
+ },
+});
+```
+
+### Streaming TTS with manual flushing
+
+```tsx
+const { startStreaming, sendText, flushStream, closeStreamGracefully } =
+ useDeepgramTextToSpeech({
+ autoPlayAudio: true,
+ options: {
+ stream: {
+ model: 'aura-2-asteria-en',
+ encoding: 'linear16',
+ sampleRate: 24000,
+ autoFlush: false, // we'll flush on punctuation boundaries instead
+ },
+ },
+ });
+
+await startStreaming('Streaming up.');
+sendText(' Sentence one.');
+sendText(' Sentence two.');
+flushStream(); // emit audio for the buffered text immediately
+closeStreamGracefully(); // finish remaining audio then close
+```
+
+### One-shot HTTP TTS as MP3
+
+```tsx
+const { synthesize } = useDeepgramTextToSpeech({
+ options: {
+ http: {
+ model: 'aura-2-asteria-en',
+ encoding: 'mp3',
+ bitRate: 48000,
+ container: 'none',
+ },
+ },
+});
+
+const buffer = await synthesize('Hello from Deepgram.');
+// `buffer` is an ArrayBuffer of MP3 bytes β write to disk, upload, etc.
+```
+
+### Run text intelligence on a transcript
+
+```tsx
+const { analyze } = useDeepgramTextIntelligence({
+ options: { summarize: true, topics: true, sentiment: true },
+});
+
+const result = await analyze({ text: longTranscript });
+console.log(result.summary, result.topics, result.sentiment);
+```
+
+### Use the Management API to rotate keys
+
+```tsx
+const dg = useDeepgramManagement();
+
+const newKey = await dg.keys.create('project_id', {
+ comment: 'mobile app',
+ scopes: ['usage:write'],
+ time_to_live_in_seconds: 60 * 60, // short-lived token
+});
+console.log('temp token =', newKey.key);
+```
+
+---
+
+## Troubleshooting
+
+### "I hear myself echo back through the agent's mic"
+
+You're not using hardware echo cancellation. If you're going through `useDeepgramVoiceAgent`, this should already be on β make sure you're testing on a **physical device**: VPIO (iOS hardware AEC) is not available in the iOS Simulator. If you're calling `Deepgram.startRecording` yourself, pass `{ enableVoiceProcessing: true }`.
+
+### "Recording stops when the app goes to the background"
+
+Make sure `backgroundAudio` is left at its default (`true`) on the Expo plugin and that you've re-prebuilt + re-installed after enabling it. On Android, check that the foreground-service notification is showing while audio is active β if it isn't, the manifest entries from the plugin haven't been picked up.
+
+### "Permission denied" on `startListening` / `connect`
+
+The microphone permission was never granted. Either:
+
+- Trigger the permission prompt manually (`requestMicPermission` is exported from the package), or
+- Make sure you're not on a secondary entitlement profile that strips `NSMicrophoneUsageDescription`.
+
+If you previously denied the prompt, the OS will silently refuse subsequent requests until the user re-enables the permission in Settings.
+
+### Flux v2 returns no transcripts
+
+Flux only accepts a small set of query parameters. The hook builds the right query map for you when you set `apiVersion: 'v2'`, but if you bypass it, ensure you're sending only Flux-supported keys (`model`, `encoding`, `sample_rate`, `eager_eot_threshold`, `eot_threshold`, `eot_timeout_ms`, `keyterm`, `mip_opt_out`, `tag`).
+
+### "Sound disappears when I unplug headphones"
+
+That's intentional. The native module pauses playback on `OldDeviceUnavailable` (iOS) and `ACTION_AUDIO_BECOMING_NOISY` (Android) so the user isn't surprised by audio blasting through the speaker. Resume playback explicitly if your UX requires it.
+
+### Native code changes aren't taking effect
+
+Native edits require a rebuild. With the example app:
+
+```bash
+yarn example:prebuild
+yarn example:pods # iOS only
+yarn example:ios # or example:android
+```
+
+If things look stale, `yarn example:clean` wipes the generated native folders and caches.
+
+### "Cannot read property 'startRecording' of undefined"
+
+The native module didn't link. In a bare RN project, run `cd ios && pod install` and rebuild. In Expo, you must use a development build (the package can't run in Expo Go because it has native code).
+
+### Android builds fail with "minSdk too low"
+
+Bump `android/build.gradle` `minSdkVersion` to **24** (Android 7.0). Some required `AudioRecord.Builder` APIs aren't available below that.
+
+### Example app: `Failed to resolve plugin for module "react-native-deepgram"`
+
+You ran `yarn prebuild` from `example/` but the workspace symlink wasn't created. This repo's root `postinstall` script creates it for you β re-run `yarn install` from the **repo root**, then retry `yarn example:prebuild`. (Yarn 3 with `nmHoistingLimits: workspaces` skips the symlink because the root workspace and the dependency share a name; the postinstall script restores it.)
+
---
## Example app
-The repository includes an Expo-managed playground under `example/` that wires up every hook in this package.
+The repository includes an Expo-managed playground under `example/` that wires up every hook in this package. It is configured for a tight inner-dev loop: edits to the library's TypeScript sources are hot-reloaded into the running app β no rebuild required.
### 1. Install workspace dependencies
@@ -715,21 +1467,63 @@ yarn install
### 2. Configure your Deepgram key
-Create `example/.env` with an Expo public key so the app can authenticate:
-
```bash
-echo "EXPO_PUBLIC_DEEPGRAM_API_KEY=your_deepgram_key" > example/.env
+cp example/.env.example example/.env
+# then edit example/.env and paste your key
```
You can generate API keys from the [Deepgram Console](https://console.deepgram.com/). For management endpoints, ensure the key carries the right scopes.
-### 3. Run or build the example
+### 3. First run (compiles native code)
+
+```bash
+yarn example:ios # iOS simulator (or `yarn example:ios --device` for a phone)
+yarn example:android # Android emulator / connected device
+```
+
+These run `expo run:` under the hood, which generates the native projects (if needed), installs CocoaPods, and launches Metro plus the app.
+
+### 4. Subsequent runs (JS-only changes)
+
+Once the app is installed on the device/simulator you can iterate on the library's TypeScript without rebuilding native code:
+
+```bash
+yarn example # just starts Metro; the app on the device picks up changes
+```
+
+Edit anything in `src/**/*.ts` (or `example/src/**`) and the app reloads via Fast Refresh. Metro is configured to resolve `react-native-deepgram` directly to `src/` for this purpose.
+
+> Want to test the published JS build instead? Run `yarn build` once, then start with `yarn example:start:built` (sets `DEEPGRAM_USE_BUILT=1` so Metro resolves to `lib/module`).
+
+### 5. When to rebuild natively
+
+You only need to recompile when you change one of:
+
+- `ios/**/*.{m,mm,h,swift}` or any podspec
+- `android/**/*.{kt,java,xml,gradle}`
+- `app.plugin.js` / `plugin/src/**` (Expo config plugin)
+- `example/app.json` or any other prebuild input
+
+Rebuild flow:
+
+```bash
+yarn example:prebuild # regenerates ios/ + android/ from app.json
+yarn example:pods # (iOS) reinstall pods after native deps change
+yarn example:ios # or yarn example:android
+```
+
+If the app gets into a weird state, `yarn example:clean` wipes the generated native folders and caches so the next `example:ios|android` rebuilds from scratch.
-- `yarn example` β start Expo bundler in development mode (web preview + QR code)
-- `yarn example:ios` β compile and launch the iOS app with `expo run:ios`
-- `yarn example:android` β compile and launch the Android app with `expo run:android`
+### Available example scripts
-If you prefer using bare Expo commands, `cd example` and run `yarn start`, `yarn ios`, or `yarn android`.
+| Script | Purpose |
+| ------ | ------- |
+| `yarn example` | Start Metro bundler (Fast Refresh) |
+| `yarn example:ios` / `yarn example:android` | Build + run on simulator/device |
+| `yarn example:prebuild` | Regenerate native projects from `app.json` |
+| `yarn example:pods` | Reinstall iOS pods after native dep changes |
+| `yarn example:start:built` | Run Metro pointed at `lib/module` instead of `src/` |
+| `yarn example:clean` | Wipe generated native folders + caches |
---
@@ -740,6 +1534,9 @@ If you prefer using bare Expo commands, `cd example` and run `yarn start`, `yarn
- β
Text-to-Speech (HTTP synthesis + WebSocket streaming)
- β
Text Intelligence (summaries, topics, sentiment, intents)
- β
Management API wrapper
+- β
Hardware echo cancellation (iOS VPIO + Android `VOICE_COMMUNICATION`)
+- β
Background-audio support (iOS `UIBackgroundModes` + Android foreground service)
+- β
iOS 17+ permission API (`AVAudioApplication`) and Bluetooth option (`AllowBluetoothHFP`)
- π§ Detox E2E tests for the example app
---
diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
index a2f47b6..92b8e10 100644
--- a/android/src/main/AndroidManifest.xml
+++ b/android/src/main/AndroidManifest.xml
@@ -1,2 +1,15 @@
+
+
+
+
+
+
+
+
+
diff --git a/android/src/main/java/com/deepgram/DeepgramAudioService.kt b/android/src/main/java/com/deepgram/DeepgramAudioService.kt
new file mode 100644
index 0000000..c2d0170
--- /dev/null
+++ b/android/src/main/java/com/deepgram/DeepgramAudioService.kt
@@ -0,0 +1,85 @@
+package com.deepgram
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+
+class DeepgramAudioService : Service() {
+ companion object {
+ private const val CHANNEL_ID = "deepgram_audio_channel"
+ private const val CHANNEL_NAME = "Deepgram Audio"
+ private const val NOTIFICATION_ID = 43123
+
+ fun start(context: Context) {
+ val intent = Intent(context, DeepgramAudioService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ val intent = Intent(context, DeepgramAudioService::class.java)
+ context.stopService(intent)
+ }
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ createNotificationChannelIfNeeded()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val notification = buildNotification()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ startForeground(
+ NOTIFICATION_ID,
+ notification,
+ android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE or
+ android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
+ )
+ } else {
+ startForeground(NOTIFICATION_ID, notification)
+ }
+
+ return START_STICKY
+ }
+
+ private fun createNotificationChannelIfNeeded() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
+ ?: return
+
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ CHANNEL_NAME,
+ NotificationManager.IMPORTANCE_LOW
+ )
+ channel.description = "Keeps Deepgram audio capture and playback active"
+ manager.createNotificationChannel(channel)
+ }
+
+ private fun buildNotification(): Notification {
+ val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Notification.Builder(this, CHANNEL_ID)
+ } else {
+ Notification.Builder(this)
+ }
+
+ return builder
+ .setContentTitle("Deepgram Audio Active")
+ .setContentText("Recording or playback is running")
+ .setSmallIcon(android.R.drawable.stat_sys_speakerphone)
+ .setOngoing(true)
+ .build()
+ }
+}
diff --git a/android/src/main/java/com/deepgram/DeepgramModule.kt b/android/src/main/java/com/deepgram/DeepgramModule.kt
index 9821ab7..6f8ab25 100644
--- a/android/src/main/java/com/deepgram/DeepgramModule.kt
+++ b/android/src/main/java/com/deepgram/DeepgramModule.kt
@@ -1,27 +1,41 @@
package com.deepgram
+import android.Manifest
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageManager
import android.media.AudioAttributes
import android.media.AudioFormat
+import android.media.AudioManager
+import android.media.AudioFocusRequest
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
import android.util.Base64
import android.util.Log
+import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
-import com.facebook.react.bridge.WritableNativeArray
+import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.modules.core.DeviceEventManagerModule
+import java.util.concurrent.LinkedBlockingQueue
+import java.util.concurrent.atomic.AtomicInteger
/**
* Native module that records raw PCM audio and plays back base64 encoded
* PCM chunks. Audio data recorded from the microphone is emitted to JS as the
- * `AudioChunk` event.
+ * `AudioChunk` event with base64-encoded PCM.
*/
class DeepgramModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
@@ -29,60 +43,203 @@ class DeepgramModule(private val reactContext: ReactApplicationContext) :
companion object {
const val NAME = "Deepgram"
private const val TAG = "DeepgramModule"
- private const val DEFAULT_SAMPLE_RATE = 16000
+ // Recording is locked to 16 kHz PCM16 mono β the JS side downsamples from
+ // here to whatever Deepgram is configured for. Do NOT mix this with the
+ // playback rate (which can vary, e.g. 24 kHz for Aura TTS).
+ private const val RECORD_SAMPLE_RATE = 16000
+ private const val DEFAULT_PLAYBACK_SAMPLE_RATE = 16000
private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO
private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
+ // Cap the streaming playback queue to prevent unbounded memory growth
+ // when JS feeds audio faster than the device can play it. ~5 s of 24 kHz
+ // PCM16 mono is roughly 240 KB β we allow a little more headroom.
+ private const val MAX_QUEUED_PLAYBACK_BYTES = 1_500_000
}
- private var currentSampleRate: Int = DEFAULT_SAMPLE_RATE
- private val minBufferSize: Int
- get() = AudioRecord.getMinBufferSize(currentSampleRate, CHANNEL_CONFIG, AUDIO_FORMAT)
+ private var playbackSampleRate: Int = DEFAULT_PLAYBACK_SAMPLE_RATE
+ private var currentOutputChannels: Int = 1
+
+ private val mainHandler by lazy { Handler(Looper.getMainLooper()) }
+
+ private val minRecordBufferSize: Int
+ get() {
+ val min = AudioRecord.getMinBufferSize(RECORD_SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)
+ if (min > 0) return min
+
+ val fallback = maxOf((RECORD_SAMPLE_RATE / 5) * 2, 4096)
+ Log.w(TAG, "Invalid AudioRecord min buffer ($min), using fallback=$fallback")
+ return fallback
+ }
+
private val bufferSize: Int
- get() = minBufferSize * 4
+ get() = minRecordBufferSize * 4
+ @Volatile
private var audioRecord: AudioRecord? = null
private var recordingThread: Thread? = null
+ @Volatile
private var isRecording = false
private var acousticEchoCanceler: AcousticEchoCanceler? = null
private var noiseSuppressor: NoiseSuppressor? = null
private var automaticGainControl: AutomaticGainControl? = null
- // Enhanced TTS Playback Properties
+ // When `enableVoiceProcessing=true`, we set the system into
+ // MODE_IN_COMMUNICATION so the platform engages telephony-grade AEC.
+ // We remember the prior mode so we can restore it on stop and not
+ // strand the device in voice-call routing.
+ private var voiceProcessingActive = false
+ private var savedAudioMode: Int = AudioManager.MODE_NORMAL
+
+ // TTS Streaming Playback
private var audioTrack: AudioTrack? = null
- private var audioBuffer: ByteArray = ByteArray(0)
+ private val audioQueue = LinkedBlockingQueue()
+ @Volatile
private var isPlaying = false
private var playbackThread: Thread? = null
- private val bufferLock = Any()
- private val playbackThreshold = 1000 // Wait for at least 1KB before starting playback
+ private val playbackThreshold = 1024
+ private val queuedBytes = AtomicInteger(0)
+
+ // Audio focus
+ private var audioManager: AudioManager? = null
+ private var audioFocusRequest: AudioFocusRequest? = null
+ private var hasAudioFocus = false
+
+ // ACTION_AUDIO_BECOMING_NOISY β fires when wired headset is unplugged or A2DP
+ // disconnects. Without handling this, our AudioTrack would silently re-route
+ // to the loudspeaker and surprise the user. Standard Android best practice.
+ private var noisyReceiverRegistered = false
+ private val becomingNoisyReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) {
+ Log.i(TAG, "AUDIO_BECOMING_NOISY β pausing playback")
+ try { audioTrack?.pause() } catch (_: Exception) {}
+ }
+ }
+ }
+
+ private val audioFocusListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
+ when (focusChange) {
+ AudioManager.AUDIOFOCUS_LOSS -> {
+ // Permanent loss (e.g. another app took focus). Tear down so we don't
+ // fight other audio packages and so we release the microphone.
+ Log.i(TAG, "AUDIOFOCUS_LOSS β stopping Deepgram audio")
+ hasAudioFocus = false
+ try { stopStreamingPlayback(throwOnError = false) } catch (_: Exception) {}
+ try { stopRecordingInternal(throwOnError = false) } catch (_: Exception) {}
+ }
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
+ // Transient loss (e.g. incoming call) β pause playback but keep state.
+ Log.i(TAG, "AUDIOFOCUS_LOSS_TRANSIENT β pausing playback")
+ try { audioTrack?.pause() } catch (_: Exception) {}
+ }
+ AudioManager.AUDIOFOCUS_GAIN -> {
+ Log.i(TAG, "AUDIOFOCUS_GAIN β resuming playback if active")
+ hasAudioFocus = true
+ if (isPlaying) {
+ try { audioTrack?.play() } catch (_: Exception) {}
+ }
+ }
+ }
+ }
override fun getName() = NAME
+ private fun outputChannelMask(): Int {
+ return if (currentOutputChannels >= 2) {
+ AudioFormat.CHANNEL_OUT_STEREO
+ } else {
+ AudioFormat.CHANNEL_OUT_MONO
+ }
+ }
+
+ private fun minTrackBufferSize(channelMask: Int): Int {
+ val min = AudioTrack.getMinBufferSize(playbackSampleRate, channelMask, AUDIO_FORMAT)
+ if (min > 0) return min
+
+ val bytesPerFrame = if (currentOutputChannels >= 2) 4 else 2
+ val fallback = maxOf((playbackSampleRate / 5) * bytesPerFrame, 4096)
+ Log.w(TAG, "Invalid AudioTrack min buffer ($min), using fallback=$fallback")
+ return fallback
+ }
+
+ private fun startForegroundAudioService() {
+ try {
+ DeepgramAudioService.start(reactContext)
+ } catch (e: Exception) {
+ Log.w(TAG, "Unable to start foreground audio service", e)
+ }
+ }
+
+ private fun stopForegroundAudioServiceIfInactive() {
+ if (isRecording || isPlaying) return
+ try {
+ DeepgramAudioService.stop(reactContext)
+ } catch (e: Exception) {
+ Log.w(TAG, "Unable to stop foreground audio service", e)
+ }
+ }
+
// -------------------------------------------------------------------
// Recording
// -------------------------------------------------------------------
@ReactMethod
- fun startRecording(promise: Promise) {
+ fun startRecording(options: ReadableMap?, promise: Promise) {
if (isRecording) {
promise.resolve(null)
return
}
- try {
- audioRecord = AudioRecord(
- MediaRecorder.AudioSource.VOICE_RECOGNITION,
- currentSampleRate,
- CHANNEL_CONFIG,
- AUDIO_FORMAT,
- bufferSize
+ if (ContextCompat.checkSelfPermission(reactContext, Manifest.permission.RECORD_AUDIO)
+ != PackageManager.PERMISSION_GRANTED
+ ) {
+ promise.reject(
+ "permission_denied",
+ "RECORD_AUDIO permission has not been granted. Request it from JS before calling startRecording."
)
+ return
+ }
- val recorder = audioRecord
+ val enableVoiceProcessing = options?.let {
+ it.hasKey("enableVoiceProcessing") && !it.isNull("enableVoiceProcessing") &&
+ it.getBoolean("enableVoiceProcessing")
+ } ?: false
+
+ // Voice Agent / duplex usage: route capture through the telephony stack so
+ // the platform's hardware AEC engages with the active playback signal as
+ // its reference. VOICE_RECOGNITION explicitly disables AEC/NS/AGC and is
+ // therefore the *wrong* source for full-duplex β it's only correct for
+ // one-way ASR where you want the rawest possible signal.
+ val audioSource = if (enableVoiceProcessing) {
+ MediaRecorder.AudioSource.VOICE_COMMUNICATION
+ } else {
+ MediaRecorder.AudioSource.VOICE_RECOGNITION
+ }
- if (recorder?.state != AudioRecord.STATE_INITIALIZED) {
- recorder?.release()
+ if (enableVoiceProcessing) {
+ applyVoiceCommunicationMode()
+ }
+
+ try {
+ val recorder = AudioRecord.Builder()
+ .setAudioSource(audioSource)
+ .setAudioFormat(
+ AudioFormat.Builder()
+ .setEncoding(AUDIO_FORMAT)
+ .setSampleRate(RECORD_SAMPLE_RATE)
+ .setChannelMask(CHANNEL_CONFIG)
+ .build()
+ )
+ .setBufferSizeInBytes(bufferSize)
+ .build()
+ audioRecord = recorder
+
+ if (recorder.state != AudioRecord.STATE_INITIALIZED) {
+ recorder.release()
audioRecord = null
releaseAudioEffects()
+ restoreAudioModeIfNeeded()
promise.reject("init_failed", "AudioRecord initialization failed")
return
}
@@ -90,21 +247,70 @@ class DeepgramModule(private val reactContext: ReactApplicationContext) :
enableAudioEffects(recorder)
recorder.startRecording()
isRecording = true
+ startForegroundAudioService()
startRecordingThread()
promise.resolve(null)
+ } catch (e: SecurityException) {
+ Log.e(TAG, "startRecording security error", e)
+ audioRecord?.release()
+ audioRecord = null
+ releaseAudioEffects()
+ restoreAudioModeIfNeeded()
+ promise.reject("permission_denied", e)
} catch (e: Exception) {
Log.e(TAG, "startRecording error", e)
audioRecord?.release()
audioRecord = null
releaseAudioEffects()
+ restoreAudioModeIfNeeded()
promise.reject("start_error", e)
}
}
+ private fun applyVoiceCommunicationMode() {
+ if (voiceProcessingActive) return
+ try {
+ val manager = audioManager
+ ?: (reactContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)
+ if (manager == null) {
+ Log.w(TAG, "applyVoiceCommunicationMode: AudioManager unavailable")
+ return
+ }
+ audioManager = manager
+ savedAudioMode = manager.mode
+ manager.mode = AudioManager.MODE_IN_COMMUNICATION
+ voiceProcessingActive = true
+ Log.i(TAG, "Switched audio mode to IN_COMMUNICATION (was $savedAudioMode) for AEC")
+ } catch (e: Exception) {
+ Log.w(TAG, "applyVoiceCommunicationMode failed", e)
+ }
+ }
+
+ private fun restoreAudioModeIfNeeded() {
+ if (!voiceProcessingActive) return
+ try {
+ val manager = audioManager
+ ?: (reactContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)
+ manager?.mode = savedAudioMode
+ Log.i(TAG, "Restored audio mode to $savedAudioMode")
+ } catch (e: Exception) {
+ Log.w(TAG, "restoreAudioModeIfNeeded failed", e)
+ } finally {
+ voiceProcessingActive = false
+ }
+ }
+
private fun stopRecordingInternal(throwOnError: Boolean = true) {
isRecording = false
- recordingThread?.interrupt()
+ recordingThread?.let { thread ->
+ thread.interrupt()
+ try {
+ thread.join(500)
+ } catch (_: InterruptedException) {
+ Thread.currentThread().interrupt()
+ }
+ }
recordingThread = null
try {
@@ -123,6 +329,8 @@ class DeepgramModule(private val reactContext: ReactApplicationContext) :
audioRecord = null
releaseAudioEffects()
+ restoreAudioModeIfNeeded()
+ stopForegroundAudioServiceIfInactive()
}
@ReactMethod
@@ -202,32 +410,47 @@ class DeepgramModule(private val reactContext: ReactApplicationContext) :
}
private fun startRecordingThread() {
- recordingThread = Thread {
+ recordingThread = Thread({
try {
+ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO)
val buffer = ByteArray(bufferSize)
- while (isRecording && audioRecord != null) {
- val read = audioRecord!!.read(buffer, 0, buffer.size)
+ while (isRecording) {
+ val recorder = audioRecord ?: break
+ val read = recorder.read(buffer, 0, buffer.size)
if (read > 0) {
- sendAudioChunk(buffer.copyOf(read))
+ sendAudioChunk(buffer, read)
+ } else if (read < 0) {
+ // ERROR_INVALID_OPERATION / ERROR_BAD_VALUE / ERROR_DEAD_OBJECT β
+ // bail rather than spin in a tight loop.
+ Log.w(TAG, "AudioRecord.read returned $read β stopping thread")
+ break
}
}
} catch (e: Exception) {
Log.e(TAG, "recording thread error", e)
}
- }
+ }, "Deepgram-Recording")
recordingThread?.start()
}
- private fun sendAudioChunk(byteArray: ByteArray) {
+ /**
+ * Emit audio data to JS as base64-encoded PCM (matches iOS format).
+ * This is far more efficient than sending individual byte values.
+ */
+ private fun sendAudioChunk(data: ByteArray, length: Int) {
+ if (!reactContext.hasActiveReactInstance()) return
+ val b64 = Base64.encodeToString(data, 0, length, Base64.NO_WRAP)
val map = WritableNativeMap()
- val array = WritableNativeArray()
- for (b in byteArray) {
- array.pushInt(b.toInt())
+ map.putString("b64", b64)
+ map.putInt("sampleRate", RECORD_SAMPLE_RATE)
+ try {
+ reactContext
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
+ .emit("AudioChunk", map)
+ } catch (e: Exception) {
+ // Catalyst tearing down or JS bundle not ready β drop silently.
+ Log.w(TAG, "sendAudioChunk emit failed", e)
}
- map.putArray("data", array)
- reactContext
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
- .emit("AudioChunk", map)
}
// -------------------------------------------------------------------
@@ -237,33 +460,98 @@ class DeepgramModule(private val reactContext: ReactApplicationContext) :
private fun ensureAudioTrack() {
if (audioTrack != null) return
- val channelMask = AudioFormat.CHANNEL_OUT_MONO
- val minBuf = AudioTrack.getMinBufferSize(currentSampleRate, channelMask, AUDIO_FORMAT)
+ val channelMask = outputChannelMask()
+ val minBuf = minTrackBufferSize(channelMask)
audioTrack = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
- .setUsage(AudioAttributes.USAGE_MEDIA)
+ .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AUDIO_FORMAT)
- .setSampleRate(currentSampleRate)
+ .setSampleRate(playbackSampleRate)
.setChannelMask(channelMask)
.build()
)
- .setBufferSizeInBytes(minBuf)
+ .setBufferSizeInBytes(maxOf(minBuf, bufferSize))
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
}
+ private fun requestAudioFocus(): Boolean {
+ if (hasAudioFocus) return true
+ try {
+ val manager = reactContext.getSystemService(android.content.Context.AUDIO_SERVICE) as? AudioManager
+ ?: return false
+ audioManager = manager
+
+ // Use AUDIOFOCUS_GAIN_TRANSIENT so other audio apps (Spotify, podcasts,
+ // navigation, etc.) pause/duck and then automatically resume when we
+ // abandon focus. AUDIOFOCUS_GAIN would permanently stop them.
+ val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
+ .setAudioAttributes(
+ AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
+ .build()
+ )
+ .setOnAudioFocusChangeListener(audioFocusListener, mainHandler)
+ .setWillPauseWhenDucked(true)
+ .build()
+ audioFocusRequest = request
+ manager.requestAudioFocus(request)
+ } else {
+ @Suppress("DEPRECATION")
+ manager.requestAudioFocus(
+ audioFocusListener,
+ AudioManager.STREAM_VOICE_CALL,
+ AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
+ )
+ }
+
+ hasAudioFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
+ if (!hasAudioFocus) {
+ Log.w(TAG, "Audio focus request not granted (result=$result)")
+ }
+ return hasAudioFocus
+ } catch (e: Exception) {
+ Log.w(TAG, "requestAudioFocus error", e)
+ hasAudioFocus = false
+ return false
+ }
+ }
+
+ private fun abandonAudioFocus() {
+ if (!hasAudioFocus) return
+ try {
+ val manager = audioManager ?: return
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ audioFocusRequest?.let { manager.abandonAudioFocusRequest(it) }
+ } else {
+ @Suppress("DEPRECATION")
+ manager.abandonAudioFocus(audioFocusListener)
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "abandonAudioFocus error", e)
+ } finally {
+ hasAudioFocus = false
+ audioFocusRequest = null
+ }
+ }
+
@ReactMethod
fun startAudio(promise: Promise) {
try {
+ requestAudioFocus()
ensureAudioTrack()
audioTrack?.play()
+ isPlaying = true
+ startForegroundAudioService()
promise.resolve(null)
} catch (e: Exception) {
Log.e(TAG, "startAudio error", e)
@@ -287,6 +575,9 @@ class DeepgramModule(private val reactContext: ReactApplicationContext) :
}
audioTrack = null
+ isPlaying = false
+ abandonAudioFocus()
+ stopForegroundAudioServiceIfInactive()
}
@ReactMethod
@@ -300,20 +591,18 @@ class DeepgramModule(private val reactContext: ReactApplicationContext) :
}
}
-
-
// -------------------------------------------------------------------
- // Enhanced TTS Streaming Audio Player (matches iOS functionality)
+ // TTS Streaming Audio Player
+ // Uses a proper queue instead of O(nΒ²) array concatenation.
// -------------------------------------------------------------------
@ReactMethod
fun startPlayer(sampleRate: Int, channels: Int) {
stopStreamingPlayback()
-
- currentSampleRate = sampleRate
- synchronized(bufferLock) {
- audioBuffer = ByteArray(0)
- }
+ playbackSampleRate = if (sampleRate > 0) sampleRate else DEFAULT_PLAYBACK_SAMPLE_RATE
+ currentOutputChannels = if (channels >= 2) 2 else 1
+ audioQueue.clear()
+ queuedBytes.set(0)
isPlaying = false
}
@@ -327,76 +616,85 @@ class DeepgramModule(private val reactContext: ReactApplicationContext) :
try {
val audioData = Base64.decode(base64Audio, Base64.DEFAULT)
if (audioData.isEmpty()) return
-
- // Add audio data to buffer
- synchronized(bufferLock) {
- audioBuffer = audioBuffer + audioData
+
+ // Bound the queue to prevent unbounded memory growth if JS is producing
+ // audio faster than the device can play it. Drop the oldest chunk first
+ // (so we always favor newer audio) when at the limit.
+ while (queuedBytes.get() + audioData.size > MAX_QUEUED_PLAYBACK_BYTES) {
+ val dropped = audioQueue.poll() ?: break
+ queuedBytes.addAndGet(-dropped.size)
+ Log.w(TAG, "feedAudio: dropping ${dropped.size} bytes β playback queue full")
}
-
- // Start playback if we have enough data and aren't already playing
- if (!isPlaying && audioBuffer.size >= playbackThreshold) {
+
+ audioQueue.offer(audioData)
+ queuedBytes.addAndGet(audioData.size)
+
+ // Start playback when we have enough buffered data
+ if (!isPlaying && queuedBytes.get() >= playbackThreshold) {
startStreamingPlayback()
}
-
} catch (e: Exception) {
Log.e(TAG, "feedAudio error", e)
}
}
+ @ReactMethod
+ fun interruptAudio() {
+ try {
+ stopStreamingPlayback(throwOnError = false)
+ } catch (e: Exception) {
+ Log.w(TAG, "interruptAudio error", e)
+ }
+ }
+
private fun startStreamingPlayback() {
if (isPlaying) return
-
+
isPlaying = true
- playbackThread = Thread {
+ registerNoisyReceiver()
+ playbackThread = Thread({
try {
+ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO)
+ requestAudioFocus()
+ startForegroundAudioService()
ensureAudioTrack()
audioTrack?.play()
-
+
while (isPlaying) {
- var dataToPlay = ByteArray(0)
- var shouldContinue = true
-
- synchronized(bufferLock) {
- if (audioBuffer.isEmpty()) {
- // No more data, stop playing
- shouldContinue = false
- } else {
- // Take all available data
- dataToPlay = audioBuffer
- audioBuffer = ByteArray(0)
- }
- }
-
- if (!shouldContinue) {
+ // Block until data is available (up to 100ms), avoids busy-wait
+ val chunk = audioQueue.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS)
+ ?: continue
+
+ queuedBytes.addAndGet(-chunk.size)
+ val written = audioTrack?.write(chunk, 0, chunk.size) ?: 0
+ if (written < 0) {
+ Log.e(TAG, "AudioTrack write error: $written")
break
}
-
- if (dataToPlay.isNotEmpty()) {
- val written = audioTrack?.write(dataToPlay, 0, dataToPlay.size) ?: 0
-
- if (written < 0) {
- Log.e(TAG, "AudioTrack write error: $written")
- break
- }
- } else {
- // Small delay to prevent busy waiting
- Thread.sleep(10)
- }
}
-
+ } catch (e: InterruptedException) {
+ // Normal shutdown
} catch (e: Exception) {
Log.e(TAG, "Streaming playback error", e)
} finally {
isPlaying = false
+ stopForegroundAudioServiceIfInactive()
}
- }
-
+ }, "Deepgram-Playback")
+
playbackThread?.start()
}
private fun stopStreamingPlayback(throwOnError: Boolean = true) {
isPlaying = false
- playbackThread?.interrupt()
+ playbackThread?.let { thread ->
+ thread.interrupt()
+ try {
+ thread.join(500)
+ } catch (_: InterruptedException) {
+ Thread.currentThread().interrupt()
+ }
+ }
playbackThread = null
try {
@@ -404,9 +702,10 @@ class DeepgramModule(private val reactContext: ReactApplicationContext) :
} catch (e: Exception) {
if (throwOnError) throw e else Log.w(TAG, "stopStreamingPlayback: error stopping audio", e)
} finally {
- synchronized(bufferLock) {
- audioBuffer = ByteArray(0)
- }
+ audioQueue.clear()
+ queuedBytes.set(0)
+ unregisterNoisyReceiver()
+ stopForegroundAudioServiceIfInactive()
}
}
@@ -421,19 +720,53 @@ class DeepgramModule(private val reactContext: ReactApplicationContext) :
}
}
- override fun onCatalystInstanceDestroy() {
+ override fun invalidate() {
try {
stopRecordingInternal(false)
} catch (e: Exception) {
- Log.w(TAG, "Error stopping recording on catalyst destroy", e)
+ Log.w(TAG, "Error stopping recording on invalidate", e)
}
try {
stopStreamingPlayback(false)
} catch (e: Exception) {
- Log.w(TAG, "Error stopping playback on catalyst destroy", e)
+ Log.w(TAG, "Error stopping playback on invalidate", e)
+ }
+
+ abandonAudioFocus()
+ unregisterNoisyReceiver()
+ super.invalidate()
+ }
+
+ private fun registerNoisyReceiver() {
+ if (noisyReceiverRegistered) return
+ try {
+ val filter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ reactContext.registerReceiver(
+ becomingNoisyReceiver,
+ filter,
+ Context.RECEIVER_NOT_EXPORTED
+ )
+ } else {
+ @Suppress("UnspecifiedRegisterReceiverFlag")
+ reactContext.registerReceiver(becomingNoisyReceiver, filter)
+ }
+ noisyReceiverRegistered = true
+ } catch (e: Exception) {
+ Log.w(TAG, "registerNoisyReceiver failed", e)
+ }
+ }
+
+ private fun unregisterNoisyReceiver() {
+ if (!noisyReceiverRegistered) return
+ try {
+ reactContext.unregisterReceiver(becomingNoisyReceiver)
+ } catch (e: Exception) {
+ Log.w(TAG, "unregisterNoisyReceiver failed", e)
+ } finally {
+ noisyReceiverRegistered = false
}
- super.onCatalystInstanceDestroy()
}
// -------------------------------------------------------------------
@@ -443,55 +776,67 @@ class DeepgramModule(private val reactContext: ReactApplicationContext) :
@ReactMethod
fun playAudioChunk(chunk: String, promise: Promise) {
try {
-
val audioData = Base64.decode(chunk, Base64.DEFAULT)
if (audioData.isEmpty()) {
promise.reject("invalid_data", "Failed to decode audio data")
return
}
-
- // For one-shot playback, create a temporary AudioTrack
- val channelMask = AudioFormat.CHANNEL_OUT_MONO
- val minBuf = AudioTrack.getMinBufferSize(currentSampleRate, channelMask, AUDIO_FORMAT)
-
+
+ requestAudioFocus()
+ startForegroundAudioService()
+
+ val channelMask = outputChannelMask()
+ val minBuf = minTrackBufferSize(channelMask)
+
val tempAudioTrack = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
- .setUsage(AudioAttributes.USAGE_MEDIA)
+ .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AUDIO_FORMAT)
- .setSampleRate(currentSampleRate)
+ .setSampleRate(playbackSampleRate)
.setChannelMask(channelMask)
.build()
)
.setBufferSizeInBytes(maxOf(minBuf, audioData.size))
.setTransferMode(AudioTrack.MODE_STATIC)
.build()
-
+
tempAudioTrack.write(audioData, 0, audioData.size)
- tempAudioTrack.play()
-
- // Clean up after playback completes
- Thread {
- try {
- // Wait for playback to complete
- while (tempAudioTrack.playState == AudioTrack.PLAYSTATE_PLAYING) {
- Thread.sleep(50)
+
+ // Use notification marker to know when playback finishes,
+ // then resolve the promise (instead of polling with Thread.sleep).
+ val bytesPerFrame = if (currentOutputChannels >= 2) 4 else 2 // PCM16 mono/stereo
+ val frameCount = audioData.size / bytesPerFrame
+ tempAudioTrack.notificationMarkerPosition = frameCount
+ val listener = object : AudioTrack.OnPlaybackPositionUpdateListener {
+ override fun onMarkerReached(track: AudioTrack?) {
+ try {
+ track?.stop()
+ track?.release()
+ } catch (e: Exception) {
+ Log.w(TAG, "playAudioChunk cleanup error", e)
}
- } finally {
- tempAudioTrack.stop()
- tempAudioTrack.release()
+ abandonAudioFocus()
+ stopForegroundAudioServiceIfInactive()
+ promise.resolve(null)
}
- }.start()
-
- promise.resolve(null)
-
+
+ override fun onPeriodicNotification(track: AudioTrack?) {}
+ }
+
+ // minSdk is 24, the Handler-aware overload is always available.
+ tempAudioTrack.setPlaybackPositionUpdateListener(listener, mainHandler)
+
+ tempAudioTrack.play()
} catch (e: Exception) {
Log.e(TAG, "playAudioChunk error", e)
+ abandonAudioFocus()
+ stopForegroundAudioServiceIfInactive()
promise.reject("playback_error", e.message)
}
}
diff --git a/example/.env.example b/example/.env.example
new file mode 100644
index 0000000..3d700bb
--- /dev/null
+++ b/example/.env.example
@@ -0,0 +1,3 @@
+# Copy to `.env` and fill in your Deepgram API key.
+# Expo automatically loads variables prefixed with `EXPO_PUBLIC_` into the JS bundle.
+EXPO_PUBLIC_DEEPGRAM_API_KEY=your_deepgram_api_key_here
diff --git a/example/android/.gitignore b/example/android/.gitignore
new file mode 100644
index 0000000..8a6be07
--- /dev/null
+++ b/example/android/.gitignore
@@ -0,0 +1,16 @@
+# OSX
+#
+.DS_Store
+
+# Android/IntelliJ
+#
+build/
+.idea
+.gradle
+local.properties
+*.iml
+*.hprof
+.cxx/
+
+# Bundle artifacts
+*.jsbundle
diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle
new file mode 100644
index 0000000..dc27cfa
--- /dev/null
+++ b/example/android/app/build.gradle
@@ -0,0 +1,176 @@
+apply plugin: "com.android.application"
+apply plugin: "org.jetbrains.kotlin.android"
+apply plugin: "com.facebook.react"
+
+def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
+
+/**
+ * This is the configuration block to customize your React Native Android app.
+ * By default you don't need to apply any configuration, just uncomment the lines you need.
+ */
+react {
+ entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
+ reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
+ hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
+ codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
+
+ // Use Expo CLI to bundle the app, this ensures the Metro config
+ // works correctly with Expo projects.
+ cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
+ bundleCommand = "export:embed"
+
+ /* Folders */
+ // The root of your project, i.e. where "package.json" lives. Default is '../..'
+ // root = file("../../")
+ // The folder where the react-native NPM package is. Default is ../../node_modules/react-native
+ // reactNativeDir = file("../../node_modules/react-native")
+ // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
+ // codegenDir = file("../../node_modules/@react-native/codegen")
+
+ /* Variants */
+ // The list of variants to that are debuggable. For those we're going to
+ // skip the bundling of the JS bundle and the assets. By default is just 'debug'.
+ // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
+ // debuggableVariants = ["liteDebug", "prodDebug"]
+
+ /* Bundling */
+ // A list containing the node command and its flags. Default is just 'node'.
+ // nodeExecutableAndArgs = ["node"]
+
+ //
+ // The path to the CLI configuration file. Default is empty.
+ // bundleConfig = file(../rn-cli.config.js)
+ //
+ // The name of the generated asset file containing your JS bundle
+ // bundleAssetName = "MyApplication.android.bundle"
+ //
+ // The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
+ // entryFile = file("../js/MyApplication.android.js")
+ //
+ // A list of extra flags to pass to the 'bundle' commands.
+ // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
+ // extraPackagerArgs = []
+
+ /* Hermes Commands */
+ // The hermes compiler command to run. By default it is 'hermesc'
+ // hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
+ //
+ // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
+ // hermesFlags = ["-O", "-output-source-map"]
+
+ /* Autolinking */
+ autolinkLibrariesWithApp()
+}
+
+/**
+ * Set this to true to Run Proguard on Release builds to minify the Java bytecode.
+ */
+def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInReleaseBuilds') ?: false).toBoolean()
+
+/**
+ * The preferred build flavor of JavaScriptCore (JSC)
+ *
+ * For example, to use the international variant, you can use:
+ * `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
+ *
+ * The international variant includes ICU i18n library and necessary data
+ * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
+ * give correct results when using with locales other than en-US. Note that
+ * this variant is about 6MiB larger per architecture than default.
+ */
+def jscFlavor = 'org.webkit:android-jsc:+'
+
+android {
+ ndkVersion rootProject.ext.ndkVersion
+
+ buildToolsVersion rootProject.ext.buildToolsVersion
+ compileSdk rootProject.ext.compileSdkVersion
+
+ namespace 'com.ahhzo.reactnativedeepgramexample'
+ defaultConfig {
+ applicationId 'com.ahhzo.reactnativedeepgramexample'
+ minSdkVersion rootProject.ext.minSdkVersion
+ targetSdkVersion rootProject.ext.targetSdkVersion
+ versionCode 1
+ versionName "1.0.0"
+ }
+ signingConfigs {
+ debug {
+ storeFile file('debug.keystore')
+ storePassword 'android'
+ keyAlias 'androiddebugkey'
+ keyPassword 'android'
+ }
+ }
+ buildTypes {
+ debug {
+ signingConfig signingConfigs.debug
+ }
+ release {
+ // Caution! In production, you need to generate your own keystore file.
+ // see https://reactnative.dev/docs/signed-apk-android.
+ signingConfig signingConfigs.debug
+ shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
+ minifyEnabled enableProguardInReleaseBuilds
+ proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
+ crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true)
+ }
+ }
+ packagingOptions {
+ jniLibs {
+ useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
+ }
+ }
+ androidResources {
+ ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
+ }
+}
+
+// Apply static values from `gradle.properties` to the `android.packagingOptions`
+// Accepts values in comma delimited lists, example:
+// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
+["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
+ // Split option: 'foo,bar' -> ['foo', 'bar']
+ def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
+ // Trim all elements in place.
+ for (i in 0.. 0) {
+ println "android.packagingOptions.$prop += $options ($options.length)"
+ // Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
+ options.each {
+ android.packagingOptions[prop] += it
+ }
+ }
+}
+
+dependencies {
+ // The version of react-native is set by the React Native Gradle Plugin
+ implementation("com.facebook.react:react-android")
+
+ def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
+ def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
+ def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
+
+ if (isGifEnabled) {
+ // For animated gif support
+ implementation("com.facebook.fresco:animated-gif:${reactAndroidLibs.versions.fresco.get()}")
+ }
+
+ if (isWebpEnabled) {
+ // For webp support
+ implementation("com.facebook.fresco:webpsupport:${reactAndroidLibs.versions.fresco.get()}")
+ if (isWebpAnimatedEnabled) {
+ // Animated webp support
+ implementation("com.facebook.fresco:animated-webp:${reactAndroidLibs.versions.fresco.get()}")
+ }
+ }
+
+ if (hermesEnabled.toBoolean()) {
+ implementation("com.facebook.react:hermes-android")
+ } else {
+ implementation jscFlavor
+ }
+}
diff --git a/example/android/app/debug.keystore b/example/android/app/debug.keystore
new file mode 100644
index 0000000..364e105
Binary files /dev/null and b/example/android/app/debug.keystore differ
diff --git a/example/android/app/proguard-rules.pro b/example/android/app/proguard-rules.pro
new file mode 100644
index 0000000..551eb41
--- /dev/null
+++ b/example/android/app/proguard-rules.pro
@@ -0,0 +1,14 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
+# You can edit the include path and order by changing the proguardFiles
+# directive in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# react-native-reanimated
+-keep class com.swmansion.reanimated.** { *; }
+-keep class com.facebook.react.turbomodule.** { *; }
+
+# Add any project specific keep options here:
diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..3ec2507
--- /dev/null
+++ b/example/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..b8cc6a7
--- /dev/null
+++ b/example/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/example/android/app/src/main/java/com/ahhzo/reactnativedeepgramexample/MainActivity.kt b/example/android/app/src/main/java/com/ahhzo/reactnativedeepgramexample/MainActivity.kt
new file mode 100644
index 0000000..fecd63a
--- /dev/null
+++ b/example/android/app/src/main/java/com/ahhzo/reactnativedeepgramexample/MainActivity.kt
@@ -0,0 +1,61 @@
+package com.ahhzo.reactnativedeepgramexample
+
+import android.os.Build
+import android.os.Bundle
+
+import com.facebook.react.ReactActivity
+import com.facebook.react.ReactActivityDelegate
+import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
+import com.facebook.react.defaults.DefaultReactActivityDelegate
+
+import expo.modules.ReactActivityDelegateWrapper
+
+class MainActivity : ReactActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ // Set the theme to AppTheme BEFORE onCreate to support
+ // coloring the background, status bar, and navigation bar.
+ // This is required for expo-splash-screen.
+ setTheme(R.style.AppTheme);
+ super.onCreate(null)
+ }
+
+ /**
+ * Returns the name of the main component registered from JavaScript. This is used to schedule
+ * rendering of the component.
+ */
+ override fun getMainComponentName(): String = "main"
+
+ /**
+ * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
+ * which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
+ */
+ override fun createReactActivityDelegate(): ReactActivityDelegate {
+ return ReactActivityDelegateWrapper(
+ this,
+ BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
+ object : DefaultReactActivityDelegate(
+ this,
+ mainComponentName,
+ fabricEnabled
+ ){})
+ }
+
+ /**
+ * Align the back button behavior with Android S
+ * where moving root activities to background instead of finishing activities.
+ * @see onBackPressed
+ */
+ override fun invokeDefaultOnBackPressed() {
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
+ if (!moveTaskToBack(false)) {
+ // For non-root activities, use the default implementation to finish them.
+ super.invokeDefaultOnBackPressed()
+ }
+ return
+ }
+
+ // Use the default back button implementation on Android S
+ // because it's doing more than [Activity.moveTaskToBack] in fact.
+ super.invokeDefaultOnBackPressed()
+ }
+}
diff --git a/example/android/app/src/main/java/com/ahhzo/reactnativedeepgramexample/MainApplication.kt b/example/android/app/src/main/java/com/ahhzo/reactnativedeepgramexample/MainApplication.kt
new file mode 100644
index 0000000..b5c7b24
--- /dev/null
+++ b/example/android/app/src/main/java/com/ahhzo/reactnativedeepgramexample/MainApplication.kt
@@ -0,0 +1,57 @@
+package com.ahhzo.reactnativedeepgramexample
+
+import android.app.Application
+import android.content.res.Configuration
+
+import com.facebook.react.PackageList
+import com.facebook.react.ReactApplication
+import com.facebook.react.ReactNativeHost
+import com.facebook.react.ReactPackage
+import com.facebook.react.ReactHost
+import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
+import com.facebook.react.defaults.DefaultReactNativeHost
+import com.facebook.react.soloader.OpenSourceMergedSoMapping
+import com.facebook.soloader.SoLoader
+
+import expo.modules.ApplicationLifecycleDispatcher
+import expo.modules.ReactNativeHostWrapper
+
+class MainApplication : Application(), ReactApplication {
+
+ override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
+ this,
+ object : DefaultReactNativeHost(this) {
+ override fun getPackages(): List {
+ val packages = PackageList(this).packages
+ // Packages that cannot be autolinked yet can be added manually here, for example:
+ // packages.add(new MyReactNativePackage());
+ return packages
+ }
+
+ override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
+
+ override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
+
+ override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
+ override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
+ }
+ )
+
+ override val reactHost: ReactHost
+ get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
+
+ override fun onCreate() {
+ super.onCreate()
+ SoLoader.init(this, OpenSourceMergedSoMapping)
+ if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
+ // If you opted-in for the New Architecture, we load the native entry point for this app.
+ load()
+ }
+ ApplicationLifecycleDispatcher.onApplicationCreate(this)
+ }
+
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ super.onConfigurationChanged(newConfig)
+ ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
+ }
+}
diff --git a/example/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/example/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png
new file mode 100644
index 0000000..31df827
Binary files /dev/null and b/example/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ
diff --git a/example/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/example/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png
new file mode 100644
index 0000000..ef243aa
Binary files /dev/null and b/example/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ
diff --git a/example/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/example/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png
new file mode 100644
index 0000000..e9d5474
Binary files /dev/null and b/example/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ
diff --git a/example/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/example/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png
new file mode 100644
index 0000000..d61da15
Binary files /dev/null and b/example/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ
diff --git a/example/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/example/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png
new file mode 100644
index 0000000..4aeed11
Binary files /dev/null and b/example/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ
diff --git a/example/android/app/src/main/res/drawable/ic_launcher_background.xml b/example/android/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..883b2a0
--- /dev/null
+++ b/example/android/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,6 @@
+
+
+ -
+
+
+
\ No newline at end of file
diff --git a/example/android/app/src/main/res/drawable/rn_edit_text_material.xml b/example/android/app/src/main/res/drawable/rn_edit_text_material.xml
new file mode 100644
index 0000000..5c25e72
--- /dev/null
+++ b/example/android/app/src/main/res/drawable/rn_edit_text_material.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..a2f5908
Binary files /dev/null and b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..1b52399
Binary files /dev/null and b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..ff10afd
Binary files /dev/null and b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..115a4c7
Binary files /dev/null and b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..dcd3cd8
Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..459ca60
Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..8ca12fe
Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..8e19b41
Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..b824ebd
Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..4c19a13
Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/example/android/app/src/main/res/values-night/colors.xml b/example/android/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..3c05de5
--- /dev/null
+++ b/example/android/app/src/main/res/values-night/colors.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/example/android/app/src/main/res/values/colors.xml b/example/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..a890727
--- /dev/null
+++ b/example/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,5 @@
+
+ #FFFFFF
+ #023c69
+ #ffffff
+
\ No newline at end of file
diff --git a/example/android/app/src/main/res/values/strings.xml b/example/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..6bf84a3
--- /dev/null
+++ b/example/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ React Native Deepgram Example
+
\ No newline at end of file
diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..da52521
--- /dev/null
+++ b/example/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/example/android/build.gradle b/example/android/build.gradle
new file mode 100644
index 0000000..abbcb8e
--- /dev/null
+++ b/example/android/build.gradle
@@ -0,0 +1,41 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+
+buildscript {
+ ext {
+ buildToolsVersion = findProperty('android.buildToolsVersion') ?: '35.0.0'
+ minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24')
+ compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '35')
+ targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34')
+ kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
+
+ ndkVersion = "26.1.10909125"
+ }
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath('com.android.tools.build:gradle')
+ classpath('com.facebook.react:react-native-gradle-plugin')
+ classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
+ }
+}
+
+apply plugin: "com.facebook.react.rootproject"
+
+allprojects {
+ repositories {
+ maven {
+ // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
+ url(new File(['node', '--print', "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), '../android'))
+ }
+ maven {
+ // Android JSC is installed from npm
+ url(new File(['node', '--print', "require.resolve('jsc-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), '../dist'))
+ }
+
+ google()
+ mavenCentral()
+ maven { url 'https://www.jitpack.io' }
+ }
+}
diff --git a/example/android/gradle.properties b/example/android/gradle.properties
new file mode 100644
index 0000000..7531e9e
--- /dev/null
+++ b/example/android/gradle.properties
@@ -0,0 +1,56 @@
+# Project-wide Gradle settings.
+
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
+org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
+
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+
+# Enable AAPT2 PNG crunching
+android.enablePngCrunchInReleaseBuilds=true
+
+# Use this property to specify which architecture you want to build.
+# You can also override it from the CLI using
+# ./gradlew -PreactNativeArchitectures=x86_64
+reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
+
+# Use this property to enable support to the new architecture.
+# This will allow you to use TurboModules and the Fabric render in
+# your application. You should enable this flag either if you want
+# to write custom TurboModules/Fabric components OR use libraries that
+# are providing them.
+newArchEnabled=true
+
+# Use this property to enable or disable the Hermes JS engine.
+# If set to false, you will be using JSC instead.
+hermesEnabled=true
+
+# Enable GIF support in React Native images (~200 B increase)
+expo.gif.enabled=true
+# Enable webp support in React Native images (~85 KB increase)
+expo.webp.enabled=true
+# Enable animated webp support (~3.4 MB increase)
+# Disabled by default because iOS doesn't support animated webp
+expo.webp.animated=false
+
+# Enable network inspector
+EX_DEV_CLIENT_NETWORK_INSPECTOR=true
+
+# Use legacy packaging to compress native libraries in the resulting APK.
+expo.useLegacyPackaging=false
diff --git a/example/android/gradle/wrapper/gradle-wrapper.jar b/example/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..a4b76b9
Binary files /dev/null and b/example/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..79eb9d0
--- /dev/null
+++ b/example/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/example/android/gradlew b/example/android/gradlew
new file mode 100755
index 0000000..f5feea6
--- /dev/null
+++ b/example/android/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright Β© 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions Β«$varΒ», Β«${var}Β», Β«${var:-default}Β», Β«${var+SET}Β»,
+# Β«${var#prefix}Β», Β«${var%suffix}Β», and Β«$( cmd )Β»;
+# * compound commands having a testable exit status, especially Β«caseΒ»;
+# * various built-in commands including Β«commandΒ», Β«setΒ», and Β«ulimitΒ».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/example/android/gradlew.bat b/example/android/gradlew.bat
new file mode 100644
index 0000000..9d21a21
--- /dev/null
+++ b/example/android/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/example/android/settings.gradle b/example/android/settings.gradle
new file mode 100644
index 0000000..c2c4050
--- /dev/null
+++ b/example/android/settings.gradle
@@ -0,0 +1,38 @@
+pluginManagement {
+ includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().toString())
+}
+plugins { id("com.facebook.react.settings") }
+
+extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
+ if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
+ ex.autolinkLibrariesFromCommand()
+ } else {
+ def command = [
+ 'node',
+ '--no-warnings',
+ '--eval',
+ 'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo/package.json\')] }))(process.argv.slice(1))',
+ 'react-native-config',
+ '--json',
+ '--platform',
+ 'android'
+ ].toList()
+ ex.autolinkLibrariesFromCommand(command)
+ }
+}
+
+rootProject.name = 'React Native Deepgram Example'
+
+dependencyResolutionManagement {
+ versionCatalogs {
+ reactAndroidLibs {
+ from(files(new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), "../gradle/libs.versions.toml")))
+ }
+ }
+}
+
+apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute(null, rootDir).text.trim(), "../scripts/autolinking.gradle");
+useExpoModules()
+
+include ':app'
+includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile())
diff --git a/example/app.json b/example/app.json
index cfd0b48..a3223c7 100644
--- a/example/app.json
+++ b/example/app.json
@@ -21,5 +21,8 @@
}
]
],
+ "android": {
+ "package": "com.ahhzo.reactnativedeepgramexample"
+ }
}
}
diff --git a/example/ios/Podfile b/example/ios/Podfile
index a8090f3..f7ad9e2 100644
--- a/example/ios/Podfile
+++ b/example/ios/Podfile
@@ -52,6 +52,13 @@ target 'ReactNativeDeepgramExample' do
:ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true',
)
+ deployment_target = podfile_properties['ios.deploymentTarget'] || '15.1'
+ installer.pods_project.targets.each do |target|
+ target.build_configurations.each do |config|
+ config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = deployment_target
+ end
+ end
+
# This is necessary for Xcode 14, because it signs resource bundles by default
# when building for devices.
installer.target_installation_results.pod_target_installation_results
diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock
index 5fb78d3..f3dca53 100644
--- a/example/ios/Podfile.lock
+++ b/example/ios/Podfile.lock
@@ -1,6 +1,6 @@
PODS:
- boost (1.84.0)
- - Deepgram (2.0.0):
+ - Deepgram (2.1.0):
- DoubleConversion
- glog
- hermes-engine
@@ -1572,7 +1572,7 @@ PODS:
DEPENDENCIES:
- boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
- - Deepgram (from `../../node_modules/react-native-deepgram`)
+ - Deepgram (from `../node_modules/react-native-deepgram`)
- DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
- EXConstants (from `../node_modules/expo-constants/ios`)
- Expo (from `../node_modules/expo`)
@@ -1654,7 +1654,7 @@ EXTERNAL SOURCES:
boost:
:podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec"
Deepgram:
- :path: "../../node_modules/react-native-deepgram"
+ :path: "../node_modules/react-native-deepgram"
DoubleConversion:
:podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
EXConstants:
@@ -1799,7 +1799,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
boost: 1dca942403ed9342f98334bf4c3621f011aa7946
- Deepgram: 43333e852b373355fe2bec14eeaa43ba1e72f9ab
+ Deepgram: e83cf3ea46d28f2cd8779458649c2e2a6d3136a6
DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385
EXConstants: fcfc75800824ac2d5c592b5bc74130bad17b146b
Expo: 1687edb10c76b0c0f135306d6ae245379f50ed54
@@ -1872,6 +1872,6 @@ SPEC CHECKSUMS:
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
Yoga: be6f55a028e86c83ae066f018e9b5d24ffc45436
-PODFILE CHECKSUM: 9280cc72a22b638a22d2a8bef1fddfd29dffafdf
+PODFILE CHECKSUM: 6e3f0c7c9da02faf99016a44ab68cca9e22aa829
COCOAPODS: 1.16.2
diff --git a/example/ios/Podfile.properties.json b/example/ios/Podfile.properties.json
index 417e2e5..b17a13b 100644
--- a/example/ios/Podfile.properties.json
+++ b/example/ios/Podfile.properties.json
@@ -1,5 +1,6 @@
{
"expo.jsEngine": "hermes",
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
+ "ios.deploymentTarget": "15.1",
"newArchEnabled": "true"
-}
+}
\ No newline at end of file
diff --git a/example/ios/ReactNativeDeepgramExample.xcodeproj/project.pbxproj b/example/ios/ReactNativeDeepgramExample.xcodeproj/project.pbxproj
index ec8f12e..868cab6 100644
--- a/example/ios/ReactNativeDeepgramExample.xcodeproj/project.pbxproj
+++ b/example/ios/ReactNativeDeepgramExample.xcodeproj/project.pbxproj
@@ -7,15 +7,15 @@
objects = {
/* Begin PBXBuildFile section */
+ 0D4AEED458481E5D3F98A665 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 484DE3F78A67BD9FC4832E24 /* PrivacyInfo.xcprivacy */; };
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
- 81D75164BAB6005F10D13C23 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 9518637219D6E071AB8BDAB0 /* PrivacyInfo.xcprivacy */; };
+ 93E3902200844107BFE9C00E /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0761624005B45C189E73256 /* noop-file.swift */; };
96905EF65AED1B983A6B3ABC /* libPods-ReactNativeDeepgramExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-ReactNativeDeepgramExample.a */; };
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
- DBA7412D4BD64F62995B16AD /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0773B8BEBC647858A0F7456 /* noop-file.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -25,14 +25,14 @@
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = ReactNativeDeepgramExample/Images.xcassets; sourceTree = ""; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = ReactNativeDeepgramExample/Info.plist; sourceTree = ""; };
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = ReactNativeDeepgramExample/main.m; sourceTree = ""; };
- 144731C7FB214BFBA60CF378 /* ReactNativeDeepgramExample-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "ReactNativeDeepgramExample-Bridging-Header.h"; path = "ReactNativeDeepgramExample/ReactNativeDeepgramExample-Bridging-Header.h"; sourceTree = ""; };
+ 2DF05CA791C144E895839B4E /* ReactNativeDeepgramExample-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "ReactNativeDeepgramExample-Bridging-Header.h"; path = "ReactNativeDeepgramExample/ReactNativeDeepgramExample-Bridging-Header.h"; sourceTree = ""; };
+ 484DE3F78A67BD9FC4832E24 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = ReactNativeDeepgramExample/PrivacyInfo.xcprivacy; sourceTree = ""; };
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-ReactNativeDeepgramExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-ReactNativeDeepgramExample.a"; sourceTree = BUILT_PRODUCTS_DIR; };
6C2E3173556A471DD304B334 /* Pods-ReactNativeDeepgramExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactNativeDeepgramExample.debug.xcconfig"; path = "Target Support Files/Pods-ReactNativeDeepgramExample/Pods-ReactNativeDeepgramExample.debug.xcconfig"; sourceTree = ""; };
7A4D352CD337FB3A3BF06240 /* Pods-ReactNativeDeepgramExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactNativeDeepgramExample.release.xcconfig"; path = "Target Support Files/Pods-ReactNativeDeepgramExample/Pods-ReactNativeDeepgramExample.release.xcconfig"; sourceTree = ""; };
- 9518637219D6E071AB8BDAB0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = ReactNativeDeepgramExample/PrivacyInfo.xcprivacy; sourceTree = ""; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = ReactNativeDeepgramExample/SplashScreen.storyboard; sourceTree = ""; };
+ B0761624005B45C189E73256 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "ReactNativeDeepgramExample/noop-file.swift"; sourceTree = ""; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; };
- C0773B8BEBC647858A0F7456 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "ReactNativeDeepgramExample/noop-file.swift"; sourceTree = ""; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-ReactNativeDeepgramExample/ExpoModulesProvider.swift"; sourceTree = ""; };
/* End PBXFileReference section */
@@ -59,9 +59,9 @@
13B07FB61A68108700A75B9A /* Info.plist */,
13B07FB71A68108700A75B9A /* main.m */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
- C0773B8BEBC647858A0F7456 /* noop-file.swift */,
- 144731C7FB214BFBA60CF378 /* ReactNativeDeepgramExample-Bridging-Header.h */,
- 9518637219D6E071AB8BDAB0 /* PrivacyInfo.xcprivacy */,
+ B0761624005B45C189E73256 /* noop-file.swift */,
+ 2DF05CA791C144E895839B4E /* ReactNativeDeepgramExample-Bridging-Header.h */,
+ 484DE3F78A67BD9FC4832E24 /* PrivacyInfo.xcprivacy */,
);
name = ReactNativeDeepgramExample;
sourceTree = "";
@@ -147,13 +147,13 @@
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "ReactNativeDeepgramExample" */;
buildPhases = (
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
- 8255C8513667FDC19F67271E /* [Expo] Configure project */,
+ C722AAF33484B7ECBCF87594 /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
- 122CE4D171D462F96CE16607 /* [CP] Embed Pods Frameworks */,
+ 9EA7D110BE7A0DE359F8DA98 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -203,7 +203,7 @@
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
- 81D75164BAB6005F10D13C23 /* PrivacyInfo.xcprivacy in Resources */,
+ 0D4AEED458481E5D3F98A665 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -247,24 +247,6 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
- 122CE4D171D462F96CE16607 /* [CP] Embed Pods Frameworks */ = {
- isa = PBXShellScriptBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- inputPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-ReactNativeDeepgramExample/Pods-ReactNativeDeepgramExample-frameworks.sh",
- "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
- );
- name = "[CP] Embed Pods Frameworks";
- outputPaths = (
- "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
- );
- runOnlyForDeploymentPostprocessing = 0;
- shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReactNativeDeepgramExample/Pods-ReactNativeDeepgramExample-frameworks.sh\"\n";
- showEnvVarsInLog = 0;
- };
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -297,7 +279,25 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReactNativeDeepgramExample/Pods-ReactNativeDeepgramExample-resources.sh\"\n";
showEnvVarsInLog = 0;
};
- 8255C8513667FDC19F67271E /* [Expo] Configure project */ = {
+ 9EA7D110BE7A0DE359F8DA98 /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-ReactNativeDeepgramExample/Pods-ReactNativeDeepgramExample-frameworks.sh",
+ "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputPaths = (
+ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReactNativeDeepgramExample/Pods-ReactNativeDeepgramExample-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ C722AAF33484B7ECBCF87594 /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
@@ -326,7 +326,7 @@
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */,
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
- DBA7412D4BD64F62995B16AD /* noop-file.swift in Sources */,
+ 93E3902200844107BFE9C00E /* noop-file.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -340,8 +340,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = ReactNativeDeepgramExample/ReactNativeDeepgramExample.entitlements;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
- DEVELOPMENT_TEAM = ZG2CA7WRPY;
+ DEVELOPMENT_TEAM = NBFQWNB5GJ;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
@@ -362,6 +364,7 @@
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = "com.ahhzo.react-native-deepgram-example";
PRODUCT_NAME = ReactNativeDeepgramExample;
+ PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "ReactNativeDeepgramExample/ReactNativeDeepgramExample-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -377,8 +380,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = ReactNativeDeepgramExample/ReactNativeDeepgramExample.entitlements;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
- DEVELOPMENT_TEAM = ZG2CA7WRPY;
+ DEVELOPMENT_TEAM = NBFQWNB5GJ;
INFOPLIST_FILE = ReactNativeDeepgramExample/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
@@ -394,6 +399,7 @@
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = "com.ahhzo.react-native-deepgram-example";
PRODUCT_NAME = ReactNativeDeepgramExample;
+ PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "ReactNativeDeepgramExample/ReactNativeDeepgramExample-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
diff --git a/example/ios/ReactNativeDeepgramExample/Info.plist b/example/ios/ReactNativeDeepgramExample/Info.plist
index bbf5503..2813224 100644
--- a/example/ios/ReactNativeDeepgramExample/Info.plist
+++ b/example/ios/ReactNativeDeepgramExample/Info.plist
@@ -46,6 +46,10 @@
NSMicrophoneUsageDescription
Allow $(PRODUCT_NAME) to access your microphone
+ UIBackgroundModes
+
+ audio
+
UILaunchStoryboardName
SplashScreen
UIRequiredDeviceCapabilities
diff --git a/example/metro.config.js b/example/metro.config.js
index cb431ed..ab4f3ce 100644
--- a/example/metro.config.js
+++ b/example/metro.config.js
@@ -4,16 +4,55 @@ const path = require('path');
const projectRoot = __dirname;
const workspaceRoot = path.resolve(__dirname, '..');
+// Toggle this (or set DEEPGRAM_USE_BUILT=1) when you want to verify the
+// published JS build (`lib/module`) instead of TypeScript sources. The default
+// points Metro at `src/` so any change in the library's TypeScript files is
+// hot-reloaded into the example app β no `yarn build` step required.
+const useBuiltLib = process.env.DEEPGRAM_USE_BUILT === '1';
+const libraryEntry = useBuiltLib
+ ? path.join(workspaceRoot, 'lib', 'module', 'index.js')
+ : path.join(workspaceRoot, 'src', 'index.ts');
+
module.exports = (() => {
const config = getDefaultConfig(projectRoot);
- // Let Metro watch files outside the app (monorepo)
+ // Let Metro watch files outside the app (monorepo). Also watch the
+ // library's `src` so file edits trigger Fast Refresh.
config.watchFolders = [workspaceRoot];
// Force Metro to resolve some singletons from the app (avoid duplicate React)
const forceFromApp = (name) => path.join(projectRoot, 'node_modules', name);
const singletons = ['react', 'react-native'];
+ // Override the resolver so `react-native-deepgram` always points at our
+ // chosen entry, regardless of any node_modules symlinks created for the
+ // Expo config-plugin resolver. Without this override, Metro would follow
+ // the symlink at `example/node_modules/react-native-deepgram` to the repo
+ // root and read its `package.json` `main` (`lib/commonjs/index.js`), which
+ // is then blocked by `blockList` below β causing a resolution failure.
+ const upstreamResolveRequest = config.resolver?.resolveRequest;
+ const resolveRequest = (context, moduleName, platform) => {
+ if (
+ moduleName === 'react-native-deepgram' ||
+ moduleName.startsWith('react-native-deepgram/')
+ ) {
+ // For deep imports like `react-native-deepgram/app.plugin.js`, fall
+ // back to the workspace root so subpath access still works.
+ if (moduleName !== 'react-native-deepgram') {
+ const sub = moduleName.slice('react-native-deepgram/'.length);
+ return {
+ type: 'sourceFile',
+ filePath: path.join(workspaceRoot, sub),
+ };
+ }
+ return { type: 'sourceFile', filePath: libraryEntry };
+ }
+ if (upstreamResolveRequest) {
+ return upstreamResolveRequest(context, moduleName, platform);
+ }
+ return context.resolveRequest(context, moduleName, platform);
+ };
+
config.resolver = {
...config.resolver,
unstable_enableSymlinks: true,
@@ -23,13 +62,16 @@ module.exports = (() => {
],
extraNodeModules: {
...(config.resolver?.extraNodeModules || {}),
- 'react-native-deepgram': path.join(workspaceRoot, 'lib', 'module'),
...Object.fromEntries(singletons.map((m) => [m, forceFromApp(m)])),
},
+ resolveRequest,
// Explicitly block resolution from workspace root
blockList: [
new RegExp(`${workspaceRoot}/node_modules/react/.*`),
new RegExp(`${workspaceRoot}/node_modules/react-native/.*`),
+ // When dev-loading from src, ignore the prebuilt lib so Metro doesn't
+ // try to crawl it (and so stale builds can't shadow live edits).
+ ...(useBuiltLib ? [] : [new RegExp(`${workspaceRoot}/lib/.*`)]),
],
};
diff --git a/example/package.json b/example/package.json
index 7233064..f730d96 100644
--- a/example/package.json
+++ b/example/package.json
@@ -6,7 +6,12 @@
"scripts": {
"start": "expo start --clear",
"ios": "expo run:ios",
- "android": "expo run:android"
+ "android": "expo run:android",
+ "ios:device": "expo run:ios --device",
+ "prebuild": "expo prebuild --clean",
+ "pods": "cd ios && pod install && cd ..",
+ "start:built": "DEEPGRAM_USE_BUILT=1 expo start --clear",
+ "clean": "expo prebuild --clean && rm -rf ios/build android/build .expo node_modules/.cache"
},
"dependencies": {
"@expo/metro-runtime": "~4.0.1",
diff --git a/example/src/App.tsx b/example/src/App.tsx
index b01d193..e2c6f31 100644
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -4,8 +4,9 @@ import {
View,
StyleSheet,
ScrollView,
- TouchableOpacity,
+ Pressable,
Text,
+ StatusBar,
} from 'react-native';
import { configure } from 'react-native-deepgram';
import SpeechToText from './SpeechToText';
@@ -13,97 +14,166 @@ import TextIntelligence from './TextIntelligence';
import Management from './Management';
import TextToSpeech from './TextToSpeech';
import VoiceAgent from './VoiceAgent';
+import { colors, radius, spacing, type } from './theme';
-/**
- * Entry point for the Deepgram demo app.
- * Toggles between SpeechToText and TextIntelligence screens.
- */
-
-// Initialize Deepgram with your API key once (can also be moved to a config file)
-// Use an environment variable or placeholder for the API key
+// Initialize Deepgram once at startup. Provide your key via .env (EXPO_PUBLIC_DEEPGRAM_API_KEY).
configure({
apiKey: process.env.EXPO_PUBLIC_DEEPGRAM_API_KEY || 'YOUR_DEEPGRAM_API_KEY',
});
-type ScreenKey = 'voice' | 'speech' | 'text' | 'management' | 'tts';
-const TABS: { key: ScreenKey; label: string }[] = [
- { key: 'voice', label: 'Voice Agent' },
- { key: 'speech', label: 'Speech to Text' },
- { key: 'text', label: 'Text Intelligence' },
- { key: 'tts', label: 'Text to Speech' },
- { key: 'management', label: 'Management' },
+type ScreenKey = 'voice' | 'speech' | 'tts' | 'text' | 'management';
+
+const TABS: { key: ScreenKey; label: string; icon: string }[] = [
+ { key: 'voice', label: 'Voice Agent', icon: 'ποΈ' },
+ { key: 'speech', label: 'Speech-to-Text', icon: 'π' },
+ { key: 'tts', label: 'Text-to-Speech', icon: 'π' },
+ { key: 'text', label: 'Text Intel', icon: 'π§ ' },
+ { key: 'management', label: 'Management', icon: 'βοΈ' },
];
export default function App() {
const [activeScreen, setActiveScreen] = useState('voice');
+ const activeTab = TABS.find((t) => t.key === activeScreen) ?? TABS[0]!;
return (
-
-
-
- {TABS.map(({ key, label }) => {
- const isActive = key === activeScreen;
- return (
- setActiveScreen(key)}
- style={[styles.tabItem, isActive && styles.tabItemActive]}
- activeOpacity={0.7}
- >
-
+
+
+ {/* Header */}
+
+
+ Deepgram
+ React Native SDK Β· Playground
+
+
+ {activeTab.icon}
+ {activeTab.label}
+
+
+
+ {/* Tab bar */}
+
+
+ {TABS.map(({ key, label, icon }) => {
+ const isActive = key === activeScreen;
+ return (
+ setActiveScreen(key)}
+ style={({ pressed }) => [
+ styles.tabItem,
+ isActive && styles.tabItemActive,
+ pressed && styles.tabItemPressed,
+ ]}
>
- {label}
-
-
- );
- })}
-
-
+ {icon}
+
+ {label}
+
+
+ );
+ })}
+
+
-
- {activeScreen === 'voice' && }
- {activeScreen === 'speech' && }
- {activeScreen === 'text' && }
- {activeScreen === 'tts' && }
- {activeScreen === 'management' && }
-
-
+
+ {activeScreen === 'voice' && }
+ {activeScreen === 'speech' && }
+ {activeScreen === 'text' && }
+ {activeScreen === 'tts' && }
+ {activeScreen === 'management' && }
+
+
+
);
}
const styles = StyleSheet.create({
- container: { flex: 1, backgroundColor: '#fff' },
- tabWrapper: {
- height: 56,
- backgroundColor: '#f2f2f2',
- justifyContent: 'center',
+ root: {
+ flex: 1,
+ backgroundColor: colors.bg,
+ },
+ safe: {
+ flex: 1,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.sm,
+ paddingBottom: spacing.md,
+ },
+ brand: {
+ ...type.h1,
+ color: colors.text,
+ },
+ brandSub: {
+ ...type.small,
+ color: colors.textMuted,
+ marginTop: 2,
+ },
+ headerBadge: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: colors.surface,
+ borderWidth: 1,
+ borderColor: colors.border,
+ paddingHorizontal: spacing.md,
+ paddingVertical: 6,
+ borderRadius: radius.pill,
+ gap: 6,
+ },
+ headerBadgeIcon: {
+ fontSize: 14,
+ },
+ headerBadgeLabel: {
+ ...type.smallMedium,
+ color: colors.textMuted,
+ },
+ tabBarWrap: {
+ paddingVertical: spacing.sm,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.divider,
},
tabContent: {
+ paddingHorizontal: spacing.lg,
+ gap: spacing.sm,
alignItems: 'center',
- paddingHorizontal: 12,
},
tabItem: {
- marginHorizontal: 6,
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: spacing.md,
paddingVertical: 8,
- paddingHorizontal: 16,
- backgroundColor: '#ddd',
- borderRadius: 20,
+ borderRadius: radius.pill,
+ backgroundColor: colors.surface,
+ borderWidth: 1,
+ borderColor: colors.border,
+ gap: 6,
},
tabItemActive: {
- backgroundColor: '#007AFF',
+ backgroundColor: colors.primary,
+ borderColor: colors.primary,
},
- tabText: {
+ tabItemPressed: {
+ opacity: 0.85,
+ },
+ tabIcon: {
fontSize: 14,
- color: '#333',
+ },
+ tabText: {
+ ...type.smallMedium,
+ color: colors.textMuted,
},
tabTextActive: {
color: '#fff',
- fontWeight: '600',
},
content: {
flex: 1,
diff --git a/example/src/Management.tsx b/example/src/Management.tsx
index 7439b91..d132f62 100644
--- a/example/src/Management.tsx
+++ b/example/src/Management.tsx
@@ -1,47 +1,36 @@
import { useState } from 'react';
-import {
- View,
- Button,
- Text,
- TextInput,
- ScrollView,
- StyleSheet,
-} from 'react-native';
+import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { useDeepgramManagement } from 'react-native-deepgram';
+import Button from './components/Button';
+import Card from './components/Card';
+import Field from './components/Field';
+import StatusBadge from './components/StatusBadge';
+import { colors, radius, spacing, type } from './theme';
+
+type Status = 'idle' | 'loading' | 'error';
export default function Management() {
- /* ---------- hook ---------- */
const { models, projects } = useDeepgramManagement();
- /* ---------- state ---------- */
- const [modelsJson, setModelsJson] = useState('');
- const [modelsStatus, setModelsStatus] = useState<
- 'idle' | 'loading' | 'error'
- >('idle');
+ const [modelsList, setModelsList] = useState([]);
+ const [modelsStatus, setModelsStatus] = useState('idle');
const [modelsError, setModelsError] = useState(null);
const [modelId, setModelId] = useState('');
const [modelJson, setModelJson] = useState('');
- const [modelStatus, setModelStatus] = useState<'idle' | 'loading' | 'error'>(
- 'idle'
- );
+ const [modelStatus, setModelStatus] = useState('idle');
const [modelError, setModelError] = useState(null);
const [projectsJson, setProjectsJson] = useState('');
- const [projectsStatus, setProjectsStatus] = useState<
- 'idle' | 'loading' | 'error'
- >('idle');
+ const [projectsStatus, setProjectsStatus] = useState('idle');
const [projectsError, setProjectsError] = useState(null);
- /* ---------- handlers ---------- */
const listAllModels = async () => {
setModelsStatus('loading');
setModelsError(null);
try {
const res = await models.list(false);
- setModelsJson(
- res.stt ? res.stt.map((m) => m.name).join('\n') : 'No models found.'
- );
+ setModelsList(res.stt ? res.stt.map((m) => m.name) : []);
setModelsStatus('idle');
} catch (err) {
setModelsStatus('error');
@@ -76,115 +65,197 @@ export default function Management() {
}
};
- /* ---------- UI ---------- */
return (
- {/* MODELS --------------------------------------------------- */}
- Models
-
-
-
- {modelsStatus === 'loading' && (
- Loadingβ¦
- )}
- {modelsError && Error: {modelsError}}
-
- {modelsJson || 'No model list yet.'}
-
+
+
+ βοΈ
+ Management API
+
+ Inspect projects, keys, models, and usage. Requires a Deepgram key
+ with management scopes.
+
+
- {/* Single model by UUID */}
-
-
-
-
- {modelStatus === 'loading' && (
- Fetchingβ¦
- )}
- {modelError && Error: {modelError}}
-
- {modelJson || 'No single-model data yet.'}
-
+ {/* Models */}
+
+ }
+ >
+
+ {modelsError ? (
+
+ β {modelsError}
+
+ ) : null}
+ {modelsList.length > 0 ? (
+
+ {modelsList.map((name) => (
+
+
+ {name}
+
+ ))}
+
+ ) : (
+
+ {modelsStatus === 'loading'
+ ? 'Loadingβ¦'
+ : 'No models loaded yet.'}
+
+ )}
+
- {/* PROJECTS ------------------------------------------------ */}
- Projects
-
-
-
- {projectsStatus === 'loading' && (
- Loadingβ¦
- )}
- {projectsError && (
- Error: {projectsError}
- )}
-
- {projectsJson || 'No project list yet.'}
+
+
+
+ {modelError ? (
+
+ β {modelError}
+
+ ) : null}
+ {modelJson ? (
+
+ {modelJson}
+
+ ) : null}
+
+
+ {/* Projects */}
+
+ }
+ >
+
+ {projectsError ? (
+
+ β {projectsError}
+
+ ) : null}
+ {projectsJson ? (
+
+ {projectsJson}
+
+ ) : (
+ No projects loaded yet.
+ )}
+
);
}
-/* ---------- styles ---------- */
const styles = StyleSheet.create({
- container: {
- flex: 1,
- padding: 16,
- backgroundColor: '#fff',
+ container: { flex: 1, backgroundColor: colors.bg },
+ scrollContent: { padding: spacing.lg, paddingBottom: spacing.xxl },
+ hero: {
+ backgroundColor: colors.surface,
+ borderRadius: radius.xl,
+ borderWidth: 1,
+ borderColor: colors.border,
+ padding: spacing.xl,
+ alignItems: 'center',
+ marginBottom: spacing.lg,
},
- sectionTitle: {
- marginTop: 24,
- marginBottom: 8,
- fontWeight: '600',
- fontSize: 16,
+ heroIcon: { fontSize: 40, marginBottom: spacing.sm },
+ heroTitle: { ...type.h2, color: colors.text },
+ heroSubtitle: {
+ ...type.small,
+ color: colors.textMuted,
+ marginTop: 4,
+ textAlign: 'center',
+ paddingHorizontal: spacing.md,
+ },
+ errorBanner: {
+ backgroundColor: '#3a1418',
+ borderColor: colors.danger,
+ borderWidth: 1,
+ borderRadius: radius.md,
+ padding: spacing.md,
+ marginTop: spacing.md,
},
- buttonRow: {
+ errorText: { color: colors.danger, ...type.smallMedium },
+ list: { marginTop: spacing.md },
+ listRow: {
flexDirection: 'row',
- justifyContent: 'flex-start',
alignItems: 'center',
- gap: 8,
+ paddingVertical: 6,
},
- input: {
- flex: 1,
- borderWidth: 1,
- borderColor: '#ccc',
- borderRadius: 4,
- paddingHorizontal: 8,
- height: 40,
+ bullet: {
+ width: 6,
+ height: 6,
+ borderRadius: 3,
+ backgroundColor: colors.primary,
+ marginRight: spacing.sm,
},
- status: {
- marginVertical: 4,
+ listText: { ...type.body, color: colors.text },
+ placeholder: {
+ ...type.small,
+ color: colors.textDim,
fontStyle: 'italic',
+ marginTop: spacing.md,
},
- error: {
- marginVertical: 4,
- color: 'red',
+ jsonWrap: {
+ marginTop: spacing.md,
+ maxHeight: 220,
+ backgroundColor: colors.surfaceMuted,
+ borderRadius: radius.md,
+ padding: spacing.md,
},
- modelSection: {
- marginTop: 16,
- },
- output: {
- maxHeight: 160,
- marginTop: 4,
- marginBottom: 12,
- padding: 8,
- borderWidth: 1,
- borderColor: '#eee',
- borderRadius: 4,
+ json: {
+ fontFamily: 'Menlo',
+ color: colors.textMuted,
+ fontSize: 12,
},
});
diff --git a/example/src/SpeechToText.tsx b/example/src/SpeechToText.tsx
index 6af5479..4db2def 100644
--- a/example/src/SpeechToText.tsx
+++ b/example/src/SpeechToText.tsx
@@ -1,19 +1,24 @@
-import { useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import {
- View,
- Button,
- Text,
+ Alert,
+ Animated,
ScrollView,
StyleSheet,
- Alert,
+ Text,
+ View,
} from 'react-native';
import * as DocumentPicker from 'expo-document-picker';
import { useDeepgramSpeechToText } from 'react-native-deepgram';
+import Button from './components/Button';
+import Card from './components/Card';
+import StatusBadge from './components/StatusBadge';
+import { colors, radius, spacing, type } from './theme';
export default function SpeechToText() {
const [liveTranscript, setLiveTranscript] = useState('');
const [liveInterimTranscript, setLiveInterimTranscript] = useState('');
const [fileTranscript, setFileTranscript] = useState('');
+ const [pickedFileName, setPickedFileName] = useState(null);
const {
startListening,
@@ -27,18 +32,18 @@ export default function SpeechToText() {
},
onTranscript: (text, info) => {
if (info?.isFinal) {
- setLiveTranscript((prev) => {
- const next = prev ? `${prev} ${text}` : text;
- return next.trim();
- });
+ setLiveTranscript((prev) => (prev ? `${prev} ${text}` : text).trim());
setLiveInterimTranscript('');
} else {
setLiveInterimTranscript(text);
}
},
+ onError: (err) => {
+ console.error('Live STT error', err);
+ },
onEnd: () => setLiveInterimTranscript(''),
live: {
- model: 'nova-2',
+ model: 'nova-3',
interimResults: true,
punctuate: true,
},
@@ -51,27 +56,56 @@ export default function SpeechToText() {
prerecorded: {
punctuate: true,
summarize: 'v2',
+ diarizeModel: 'latest',
+ detectEntities: true,
},
});
+ const isListening = liveState?.status === 'listening';
+ const isTranscribing = fileState?.status === 'transcribing';
+
+ // Mic pulse
+ const pulse = useRef(new Animated.Value(0)).current;
+ useEffect(() => {
+ if (!isListening) {
+ pulse.stopAnimation();
+ pulse.setValue(0);
+ return undefined;
+ }
+ const loop = Animated.loop(
+ Animated.sequence([
+ Animated.timing(pulse, {
+ toValue: 1,
+ duration: 1000,
+ useNativeDriver: true,
+ }),
+ Animated.timing(pulse, {
+ toValue: 0,
+ duration: 1000,
+ useNativeDriver: true,
+ }),
+ ])
+ );
+ loop.start();
+ return () => loop.stop();
+ }, [isListening, pulse]);
+
const pickAndTranscribe = async () => {
try {
const result = await DocumentPicker.getDocumentAsync({
type: 'audio/*',
copyToCacheDirectory: true,
});
-
if (result.assets && result.assets.length > 0) {
+ const asset = result.assets[0]!;
+ setPickedFileName(asset.name ?? 'audio file');
await transcribeFile(
{
- uri: result.assets[0].uri,
- name: result.assets[0].name || 'audio-file',
- type: result.assets[0].mimeType || 'audio/mpeg',
+ uri: asset.uri,
+ name: asset.name || 'audio-file',
+ type: asset.mimeType || 'audio/mpeg',
},
- {
- topics: true,
- intents: true,
- }
+ { topics: true, intents: true }
);
}
} catch (err) {
@@ -79,88 +113,256 @@ export default function SpeechToText() {
}
};
+ const tone = liveState?.error ? 'error' : isListening ? 'live' : 'idle';
+ const toneLabel = liveState?.error
+ ? 'Error'
+ : isListening
+ ? 'Listening'
+ : 'Ready';
+
const combinedLiveTranscript = [liveTranscript, liveInterimTranscript]
.filter(Boolean)
.join(' ');
+ const pulseScale = pulse.interpolate({
+ inputRange: [0, 1],
+ outputRange: [1, 1.5],
+ });
+ const pulseOpacity = pulse.interpolate({
+ inputRange: [0, 1],
+ outputRange: [0.4, 0],
+ });
+
return (
- {/* Live mic controls */}
-
- startListening({ keywords: ['Deepgram'] })}
- disabled={liveState?.status === 'listening'}
- />
-
-
- {liveState?.status === 'listening' && (
- Listeningβ¦
- )}
- {liveState?.error && (
- Error: {liveState.error.message}
- )}
-
-
- π€ Live: {combinedLiveTranscript || 'No live transcript yet.'}
-
-
+
+ {/* Live STT */}
+
+
+
+
+
+
+ {isListening && (
+
+ )}
+
+ π€
+
+
+
+
+ {isListening ? 'Listeningβ¦' : 'Live transcription'}
+
+
+ {isListening
+ ? 'Tap stop when you are done.'
+ : 'Tap start and speak to see live transcripts.'}
+
- {/* File transcription controls */}
-
-
-
- {fileState?.status === 'transcribing' && (
- Transcribing fileβ¦
- )}
- {fileState?.error && (
- File error: {fileState.error.message}
- )}
-
-
- π File: {fileTranscript || 'No file transcript yet.'}
-
+
+ {isListening ? (
+
+ ) : (
+ startListening({ keyterm: ['Deepgram'] })}
+ />
+ )}
+
+
+
+ {liveState?.error ? (
+
+ β {liveState.error.message}
+
+ ) : null}
+
+
+ {combinedLiveTranscript ? (
+
+ {liveTranscript}
+ {liveInterimTranscript ? (
+
+ {liveTranscript ? ' ' : ''}
+ {liveInterimTranscript}
+
+ ) : null}
+
+ ) : (
+
+ π
+
+ Nothing yet. Start listening to see your speech transcribed.
+
+
+ )}
+
+
+ {/* File transcription */}
+
+
+ {pickedFileName ? (
+ {pickedFileName}
+ ) : null}
+
+ {fileState?.error ? (
+
+ β {fileState.error.message}
+
+ ) : null}
+
+
+ {fileTranscript ? (
+ {fileTranscript}
+ ) : (
+
+ π
+
+ Pick an audio file to transcribe with Deepgram.
+
+
+ )}
+
+
);
}
const styles = StyleSheet.create({
- container: {
- flex: 1,
- padding: 16,
- backgroundColor: '#fff',
+ container: { flex: 1, backgroundColor: colors.bg },
+ scrollContent: {
+ padding: spacing.lg,
+ paddingBottom: spacing.xxl,
},
- buttonRow: {
+ hero: {
+ backgroundColor: colors.surface,
+ borderRadius: radius.xl,
+ borderWidth: 1,
+ borderColor: colors.border,
+ padding: spacing.xl,
+ alignItems: 'center',
+ marginBottom: spacing.lg,
+ },
+ heroTop: {
+ width: '100%',
flexDirection: 'row',
justifyContent: 'space-between',
- marginBottom: 12,
+ marginBottom: spacing.md,
},
- status: {
- marginVertical: 8,
- fontStyle: 'italic',
+ micWrap: {
+ width: 120,
+ height: 120,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: spacing.lg,
+ },
+ pulse: {
+ position: 'absolute',
+ width: 120,
+ height: 120,
+ borderRadius: 60,
+ backgroundColor: colors.primary,
+ },
+ micCircle: {
+ width: 88,
+ height: 88,
+ borderRadius: 44,
+ backgroundColor: colors.surfaceMuted,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderWidth: 2,
+ borderColor: colors.border,
},
- error: {
- marginVertical: 8,
- color: 'red',
+ micCircleActive: {
+ backgroundColor: colors.primary,
+ borderColor: colors.primary,
},
- outputContainer: {
- flex: 1,
- marginTop: 8,
+ micIcon: { fontSize: 36 },
+ heroTitle: {
+ ...type.h2,
+ color: colors.text,
+ },
+ heroSubtitle: {
+ ...type.small,
+ color: colors.textMuted,
+ marginTop: 4,
+ marginBottom: spacing.lg,
+ textAlign: 'center',
+ },
+ heroActions: { width: '100%' },
+ errorBanner: {
+ backgroundColor: '#3a1418',
+ borderColor: colors.danger,
+ borderWidth: 1,
+ borderRadius: radius.md,
+ padding: spacing.md,
+ marginBottom: spacing.md,
+ },
+ errorText: {
+ color: colors.danger,
+ ...type.smallMedium,
},
transcript: {
- fontSize: 14,
- lineHeight: 20,
+ ...type.body,
+ color: colors.text,
+ lineHeight: 22,
+ },
+ interim: {
+ color: colors.textMuted,
+ fontStyle: 'italic',
+ },
+ fileName: {
+ ...type.small,
+ color: colors.textMuted,
+ marginTop: spacing.sm,
+ },
+ fileResultWrap: {
+ marginTop: spacing.md,
+ },
+ empty: {
+ paddingVertical: spacing.lg,
+ alignItems: 'center',
+ },
+ emptyIcon: {
+ fontSize: 28,
+ marginBottom: spacing.sm,
},
- streamingSection: {
- marginTop: 24,
+ emptyText: {
+ ...type.small,
+ color: colors.textMuted,
+ textAlign: 'center',
},
});
diff --git a/example/src/TextIntelligence.tsx b/example/src/TextIntelligence.tsx
index 960a3b2..defe98e 100644
--- a/example/src/TextIntelligence.tsx
+++ b/example/src/TextIntelligence.tsx
@@ -1,17 +1,23 @@
import { useState } from 'react';
+import { ScrollView, StyleSheet, Text, View } from 'react-native';
import {
- View,
- TextInput,
- Button,
- ScrollView,
- Text,
- StyleSheet,
-} from 'react-native';
-import { useDeepgramTextIntelligence } from 'react-native-deepgram';
+ useDeepgramTextIntelligence,
+ type DeepgramTextIntelligenceResponse,
+} from 'react-native-deepgram';
+import Button from './components/Button';
+import Card from './components/Card';
+import Field from './components/Field';
+import StatusBadge from './components/StatusBadge';
+import { colors, radius, spacing, type } from './theme';
+
+const SAMPLE_TEXT =
+ 'Yesterday I listened to an incredible podcast about the first commercial spacewalk β really inspiring stuff. I want to start my own podcast about exploration!';
export default function TextIntelligence() {
const [input, setInput] = useState('');
- const [result, setResult] = useState(null);
+ const [result, setResult] = useState(
+ null
+ );
const { analyze, state } = useDeepgramTextIntelligence({
trackState: true,
@@ -25,56 +31,294 @@ export default function TextIntelligence() {
customIntentMode: 'extended',
sentiment: true,
language: 'en-US',
+ tag: 'demo-text-intelligence',
},
onBeforeAnalyze: () => setResult(null),
onAnalyzeSuccess: setResult,
});
- const handleAnalyze = () => {
- analyze({ text: input });
- };
+ const isAnalyzing = state?.status === 'analyzing';
+ const tone = state?.error ? 'error' : isAnalyzing ? 'connecting' : 'idle';
+ const toneLabel = state?.error ? 'Error' : isAnalyzing ? 'Analyzing' : 'Idle';
+
+ const summary = result?.results?.summary?.text;
+ const topicSegments = result?.results?.topics?.segments ?? [];
+ const intentSegments = result?.results?.intents?.segments ?? [];
+ const sentimentAvg = result?.results?.sentiments?.average;
return (
-
+
+
+
+
+
+ π§
+ Text Intelligence
+
+ Run summarization, topic + intent detection, and sentiment analysis
+ in one request.
+
+
+
+
+
+
+ analyze({ text: input })}
+ loading={isAnalyzing}
+ disabled={isAnalyzing || !input.trim()}
+ iconLeft="β¨"
+ />
+ setInput(SAMPLE_TEXT)}
+ />
+
+ {state?.error ? (
+
+ β {state.error.message}
+
+ ) : null}
+
-
+ {result ? (
+ <>
+ {summary ? (
+
+ {summary}
+
+ ) : null}
- {state?.status === 'analyzing' && (
- Analyzing...
- )}
- {state?.error && (
- Error: {state.error.message}
- )}
+ {sentimentAvg ? (
+
+
+
+
+ Score:{' '}
+ {sentimentAvg.sentiment_score != null
+ ? sentimentAvg.sentiment_score.toFixed(3)
+ : 'β'}
+
+
+
+ ) : null}
- {result && (
-
- {JSON.stringify(result, null, 2)}
-
- )}
+ {topicSegments.length > 0 ? (
+
+ {topicSegments.map((seg, i) => (
+
+ {seg.text}
+
+ {seg.topics?.map((t, ti) => (
+
+
+ {t.topic}
+ {t.confidence_score != null
+ ? ` Β· ${(t.confidence_score * 100).toFixed(0)}%`
+ : ''}
+
+
+ ))}
+
+
+ ))}
+
+ ) : null}
+
+ {intentSegments.length > 0 ? (
+
+ {intentSegments.map((seg, i) => (
+
+ {seg.text}
+
+ {seg.intents?.map((t, ti) => (
+
+
+ {t.intent}
+ {t.confidence_score != null
+ ? ` Β· ${(t.confidence_score * 100).toFixed(0)}%`
+ : ''}
+
+
+ ))}
+
+
+ ))}
+
+ ) : null}
+
+
+
+
+ {JSON.stringify(result, null, 2)}
+
+
+
+ >
+ ) : (
+
+
+ π
+
+ Results will appear here after analysis.
+
+
+
+ )}
+
+
+ );
+}
+
+function SentimentBadge({ value }: { value: string | undefined }) {
+ const map: Record = {
+ positive: { color: colors.success, bg: '#0f2f24', label: 'π Positive' },
+ neutral: {
+ color: colors.textMuted,
+ bg: colors.surfaceMuted,
+ label: 'π Neutral',
+ },
+ negative: { color: colors.danger, bg: '#3a1418', label: 'βΉοΈ Negative' },
+ };
+ const style = map[value ?? 'neutral'] ?? map.neutral!;
+ return (
+
+
+ {style.label}
+
);
}
const styles = StyleSheet.create({
- container: { flex: 1, padding: 16, backgroundColor: '#fff' },
- input: {
- height: 150,
- borderColor: '#ccc',
+ container: { flex: 1, backgroundColor: colors.bg },
+ scrollContent: {
+ padding: spacing.lg,
+ paddingBottom: spacing.xxl,
+ },
+ hero: {
+ backgroundColor: colors.surface,
+ borderRadius: radius.xl,
borderWidth: 1,
- borderRadius: 4,
- padding: 8,
- marginBottom: 12,
- textAlignVertical: 'top',
- },
- status: { marginTop: 12, fontStyle: 'italic' },
- error: { marginTop: 12, color: 'red' },
- outputContainer: { marginTop: 16, flex: 1 },
- output: { fontFamily: 'monospace', fontSize: 12 },
+ borderColor: colors.border,
+ padding: spacing.xl,
+ alignItems: 'center',
+ marginBottom: spacing.lg,
+ },
+ heroTop: {
+ width: '100%',
+ flexDirection: 'row',
+ justifyContent: 'flex-start',
+ marginBottom: spacing.md,
+ },
+ heroIcon: { fontSize: 40, marginBottom: spacing.sm },
+ heroTitle: { ...type.h2, color: colors.text },
+ heroSubtitle: {
+ ...type.small,
+ color: colors.textMuted,
+ marginTop: 4,
+ textAlign: 'center',
+ paddingHorizontal: spacing.md,
+ },
+ actionRow: {
+ flexDirection: 'row',
+ gap: spacing.sm,
+ flexWrap: 'wrap',
+ },
+ errorBanner: {
+ backgroundColor: '#3a1418',
+ borderColor: colors.danger,
+ borderWidth: 1,
+ borderRadius: radius.md,
+ padding: spacing.md,
+ marginTop: spacing.md,
+ },
+ errorText: { color: colors.danger, ...type.smallMedium },
+ bodyText: { ...type.body, color: colors.text, lineHeight: 22 },
+ sentimentRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.md,
+ },
+ sentimentBadge: {
+ paddingVertical: 8,
+ paddingHorizontal: spacing.md,
+ borderRadius: radius.pill,
+ },
+ sentimentScore: { ...type.small, color: colors.textMuted },
+ segment: {
+ paddingVertical: spacing.sm,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.divider,
+ },
+ segmentText: {
+ ...type.small,
+ color: colors.textMuted,
+ fontStyle: 'italic',
+ marginBottom: 6,
+ },
+ chipRow: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 6,
+ },
+ chip: {
+ paddingHorizontal: 10,
+ paddingVertical: 4,
+ borderRadius: radius.pill,
+ borderWidth: 1,
+ },
+ chipAccent: {
+ backgroundColor: colors.accentMuted,
+ borderColor: colors.accentMuted,
+ },
+ chipTextAccent: {
+ color: colors.accent,
+ ...type.smallMedium,
+ fontSize: 12,
+ },
+ chipPrimary: {
+ backgroundColor: colors.primaryMuted,
+ borderColor: colors.primaryMuted,
+ },
+ chipTextPrimary: {
+ color: colors.primary,
+ ...type.smallMedium,
+ fontSize: 12,
+ },
+ json: {
+ fontFamily: 'Menlo',
+ color: colors.textMuted,
+ fontSize: 12,
+ },
+ empty: { alignItems: 'center', paddingVertical: spacing.xl },
+ emptyIcon: { fontSize: 28, marginBottom: spacing.sm },
+ emptyText: { ...type.small, color: colors.textMuted },
});
diff --git a/example/src/TextToSpeech.tsx b/example/src/TextToSpeech.tsx
index 823076f..4496abb 100644
--- a/example/src/TextToSpeech.tsx
+++ b/example/src/TextToSpeech.tsx
@@ -1,13 +1,5 @@
import { useMemo, useState } from 'react';
-import {
- View,
- Button,
- Text,
- TextInput,
- ScrollView,
- StyleSheet,
- Switch,
-} from 'react-native';
+import { ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
import {
useDeepgramTextToSpeech,
type DeepgramTextToSpeechStreamMetadataMessage,
@@ -21,6 +13,11 @@ import {
} from 'react-native-deepgram';
import OptionSelect, { type Option } from './components/OptionSelect';
+import Button from './components/Button';
+import Card from './components/Card';
+import Field from './components/Field';
+import StatusBadge from './components/StatusBadge';
+import { colors, radius, spacing, type } from './theme';
const POPULAR_TTS_MODELS = [
'aura-2-asteria-en',
@@ -77,11 +74,11 @@ const formatModelLabel = (model: string) => {
if (segments.length < 2) return model;
const language = segments.pop() ?? '';
const readable = segments
- .map((segment) => {
- if (!segment) return segment;
- if (/^\d+$/.test(segment)) return segment;
- return segment.charAt(0).toUpperCase() + segment.slice(1);
- })
+ .map((segment) =>
+ !segment || /^\d+$/.test(segment)
+ ? segment
+ : segment.charAt(0).toUpperCase() + segment.slice(1)
+ )
.join(' ');
return `${readable} (${language.toUpperCase()})`;
};
@@ -106,7 +103,6 @@ const parseNumber = (value: string): number | undefined => {
export default function TextToSpeech() {
const [text, setText] = useState('');
const [streamText, setStreamText] = useState('');
- // Removed manual status/error states in favor of trackState
const [lastMetadata, setLastMetadata] =
useState(null);
const [lastFlushedSequence, setLastFlushedSequence] = useState(
@@ -127,11 +123,13 @@ export default function TextToSpeech() {
const [httpBitRate, setHttpBitRate] = useState('48000');
const [httpCallbackUrl, setHttpCallbackUrl] = useState('');
const [httpCallbackMethod, setHttpCallbackMethod] = useState('POST');
+ const [httpSpeed, setHttpSpeed] = useState('1');
const [httpMipOptOut, setHttpMipOptOut] = useState(false);
const [streamModel, setStreamModel] = useState('aura-2-asteria-en');
const [streamEncoding, setStreamEncoding] = useState('linear16');
const [streamSampleRate, setStreamSampleRate] = useState('16000');
+ const [streamSpeed, setStreamSpeed] = useState('1');
const [streamMipOptOut, setStreamMipOptOut] = useState(false);
const modelOptions = useModelOptions();
@@ -148,14 +146,14 @@ export default function TextToSpeech() {
const httpBitRateValue =
httpEncodingValue && httpEncodingValue !== 'linear16'
? (parseNumber(httpBitRate) as DeepgramTextToSpeechBitRate | undefined)
- : undefined; // Deepgram rejects bitRate when encoding=linear16
+ : undefined;
const httpCallbackValue = httpCallbackUrl.trim() || undefined;
const httpCallbackMethodValue =
httpCallbackValue && httpCallbackMethod.trim()
? (httpCallbackMethod
.trim()
.toUpperCase() as DeepgramTextToSpeechCallbackMethod)
- : undefined; // Deepgram requires callbackMethod only if callback is set
+ : undefined;
const streamModelValue = streamModel.trim() || undefined;
const streamEncodingValue = streamEncoding.trim()
@@ -178,6 +176,7 @@ export default function TextToSpeech() {
bitRate: httpBitRateValue,
callback: httpCallbackValue,
callbackMethod: httpCallbackMethodValue,
+ speed: parseNumber(httpSpeed),
mipOptOut: httpMipOptOut,
},
},
@@ -209,59 +208,215 @@ export default function TextToSpeech() {
model: streamModelValue,
encoding: streamEncodingValue,
sampleRate: streamSampleRateValue,
+ speed: parseNumber(streamSpeed),
mipOptOut: streamMipOptOut,
autoFlush: streamAutoFlush,
},
},
});
+ const isSynthesizing = httpState?.status === 'loading';
+ const isStreaming = streamState?.status === 'connected';
+
+ const tone = streamState?.error ? 'error' : isStreaming ? 'live' : 'idle';
+ const toneLabel = streamState?.error
+ ? 'Error'
+ : isStreaming
+ ? 'Streaming'
+ : 'Idle';
+
const handleSynthesize = async () => {
try {
await synthesize(text);
} catch (err) {
- if ((err as any)?.name !== 'AbortError') {
+ if ((err as { name?: string })?.name !== 'AbortError') {
console.warn('Synthesize failed', err);
}
}
};
- const handleStream = async () => {
+
+ const handleStartStream = async () => {
try {
await startStreaming(text);
} catch (err) {
console.warn('Start stream failed', err);
}
};
+
const handleSendText = () => {
if (sendText(streamText, { flush: streamAutoFlush })) {
- setStreamText(''); // Clear the input after sending
+ setStreamText('');
}
};
- const handleFlush = () => flushStream();
- const handleClear = () => clearStream();
- const handleCloseGracefully = () => closeStreamGracefully();
return (
-
-
-
-
-
- HTTP options
-
- Configure parameters for single text-to-speech requests.
+
+
+ {/* Hero */}
+
+
+
+
+ π
+ Text-to-Speech
+
+ Synthesize via REST or stream tokens over a WebSocket. Audio plays
+ through the device speaker automatically.
+
+
+ {/* Main input */}
+
+
+
+
+ {isStreaming ? (
+
+ ) : (
+
+ )}
+
+ {httpState?.error ? (
+
+ β {httpState.error.message}
+
+ ) : null}
+ {streamState?.error ? (
+
+
+ β Stream: {streamState.error.message}
+
+
+ ) : null}
+
+
+ {/* Continuous stream */}
+ {isStreaming && (
+
+
+
+ Flush automatically
+
+
+
+
+ flushStream()}
+ />
+ clearStream()}
+ />
+ closeStreamGracefully()}
+ />
+
+
+ )}
+
+ {/* Stream debug */}
+ {(lastMetadata ||
+ lastFlushedSequence != null ||
+ lastClearedSequence != null ||
+ warnings.length > 0) && (
+
+ {lastMetadata ? (
+
+ Last metadata
+
+
+
+
+ ) : null}
+ {lastFlushedSequence != null && (
+
+ )}
+ {lastClearedSequence != null && (
+
+ )}
+ {warnings.length > 0 && (
+
+
+ Warnings
+
+ {warnings.map((w, i) => (
+
+ {w.code}: {w.description}
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* HTTP options */}
+
@@ -270,18 +425,14 @@ export default function TextToSpeech() {
value={httpEncoding}
onChange={setHttpEncoding}
options={HTTP_ENCODING_OPTIONS}
- placeholder="Select encoding"
allowCustom
- customPlaceholder="Enter an encoding"
/>
- Callback URL
-
@@ -317,300 +463,162 @@ export default function TextToSpeech() {
value={httpCallbackMethod}
onChange={setHttpCallbackMethod}
options={CALLBACK_METHOD_OPTIONS}
- placeholder="Select HTTP method"
allowCustom
- customPlaceholder="Enter HTTP method"
/>
-
- Opt out of the MIP
-
+
+
+ Opt out of MIP
+
-
+
-
- Streaming options
-
- These apply to the WebSocket streaming connection.
-
+ {/* Streaming options */}
+
-
- Opt out of the MIP
+
+
+ Opt out of MIP
-
-
- {/* HTTP synthesis */}
-
-
-
- {httpState?.status === 'loading' && (
- Synthesizingβ¦
- )}
- {httpState?.error && (
- Error: {httpState.error.message}
- )}
-
- {/* streaming */}
-
-
-
-
- {streamState?.status === 'connected' && (
- Streamingβ¦
- )}
- {streamState?.error && (
-
- Stream error: {streamState.error.message}
-
- )}
-
- {/* Continuous streaming - send additional text to active stream */}
- {streamState?.status === 'connected' && (
-
- Continuous Streaming
-
-
- Flush automatically
-
-
-
-
-
-
-
-
-
-
-
- )}
+
+
+
+ );
+}
-
-
- Audio is played automatically by the built-in native player; thereβs
- no transcript to display.
-
- {lastMetadata && (
-
- Last metadata
-
- Request: {lastMetadata.request_id}
-
-
- Model: {lastMetadata.model_name}
-
-
- Version: {lastMetadata.model_version}
-
-
- )}
- {(lastFlushedSequence != null || lastClearedSequence != null) && (
-
- Stream events
- {lastFlushedSequence != null && (
-
- Last flushed sequence: {lastFlushedSequence}
-
- )}
- {lastClearedSequence != null && (
-
- Last cleared sequence: {lastClearedSequence}
-
- )}
-
- )}
- {warnings.length > 0 && (
-
-
- Warnings
-
- {warnings.map((warning, index) => (
-
- {warning.code}: {warning.description}
-
- ))}
-
- )}
-
-
-
+function KV({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
);
}
const styles = StyleSheet.create({
- container: { flex: 1, padding: 16, backgroundColor: '#fff' },
- input: {
- minHeight: 100,
- borderWidth: 1,
- borderColor: '#ccc',
- borderRadius: 4,
- padding: 8,
- marginBottom: 16,
- textAlignVertical: 'top',
- },
- buttonRow: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- marginBottom: 12,
- },
- status: { marginVertical: 8, fontStyle: 'italic' },
- error: { marginVertical: 8, color: 'red' },
- optionsSection: {
- borderWidth: 1,
- borderColor: '#eee',
- borderRadius: 8,
- padding: 12,
- marginBottom: 16,
- backgroundColor: '#fafafa',
- },
- optionsSectionSpacing: {
- marginTop: 8,
- },
- optionHint: {
- fontSize: 13,
- color: '#555',
- marginBottom: 12,
- },
- fieldLabel: {
- fontSize: 13,
- fontWeight: '600',
- color: '#444',
- marginBottom: 4,
+ container: { flex: 1, backgroundColor: colors.bg },
+ scrollContent: {
+ padding: spacing.lg,
+ paddingBottom: spacing.xxl,
},
- optionInput: {
+ hero: {
+ backgroundColor: colors.surface,
+ borderRadius: radius.xl,
borderWidth: 1,
- borderColor: '#ccc',
- borderRadius: 4,
- padding: 8,
- marginBottom: 12,
- backgroundColor: '#fff',
+ borderColor: colors.border,
+ padding: spacing.xl,
+ alignItems: 'center',
+ marginBottom: spacing.lg,
},
- continuousSection: {
- borderTopWidth: 1,
- borderTopColor: '#eee',
- paddingTop: 16,
+ heroTop: {
+ width: '100%',
+ flexDirection: 'row',
+ justifyContent: 'flex-start',
+ marginBottom: spacing.md,
},
- sectionTitle: {
- fontSize: 16,
- fontWeight: 'bold',
- marginBottom: 12,
- color: '#333',
+ heroIcon: { fontSize: 40, marginBottom: spacing.sm },
+ heroTitle: { ...type.h2, color: colors.text },
+ heroSubtitle: {
+ ...type.small,
+ color: colors.textMuted,
+ marginTop: 4,
+ textAlign: 'center',
+ paddingHorizontal: spacing.md,
},
- streamInput: {
- minHeight: 60,
- borderWidth: 1,
- borderColor: '#ccc',
- borderRadius: 4,
- padding: 8,
- marginBottom: 12,
- textAlignVertical: 'top',
- backgroundColor: '#f9f9f9',
+ actionRow: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: spacing.sm,
},
- toggleRow: {
+ switchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
- marginBottom: 12,
+ marginBottom: spacing.md,
},
switchLabel: {
- fontSize: 14,
- color: '#333',
+ ...type.body,
+ color: colors.text,
+ flex: 1,
+ marginRight: spacing.md,
},
- streamingSection: {
- marginTop: 24,
- },
- continuousMargin: {
- marginTop: 24,
- },
- outputContainer: { flex: 1, marginTop: 8 },
- note: { fontSize: 14, lineHeight: 20 },
- debugPanel: {
- marginTop: 16,
- padding: 12,
- borderRadius: 8,
- backgroundColor: '#f5f5f5',
- },
- debugTitle: {
- fontWeight: 'bold',
- marginBottom: 4,
- color: '#333',
- },
- debugText: {
- fontSize: 13,
- color: '#444',
- marginBottom: 2,
- },
- warningPanel: {
- backgroundColor: '#fff5f5',
+ errorBanner: {
+ backgroundColor: '#3a1418',
+ borderColor: colors.danger,
borderWidth: 1,
- borderColor: '#ffcdd2',
+ borderRadius: radius.md,
+ padding: spacing.md,
+ marginTop: spacing.md,
},
- warningTitle: {
- color: '#c62828',
+ errorText: { color: colors.danger, ...type.smallMedium },
+ kvBlock: {},
+ kvTitle: {
+ ...type.smallMedium,
+ color: colors.textMuted,
+ textTransform: 'uppercase',
+ letterSpacing: 0.6,
+ marginBottom: 6,
},
- warningText: {
- fontSize: 13,
- color: '#c62828',
- marginBottom: 2,
+ kvRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ paddingVertical: 4,
},
+ kvLabel: { ...type.small, color: colors.textMuted },
+ kvValue: { ...type.small, color: colors.text, fontFamily: 'Menlo' },
+ warningItem: { color: colors.warning, marginTop: 4 },
});
diff --git a/example/src/VoiceAgent.tsx b/example/src/VoiceAgent.tsx
index aea7f47..ef79fc7 100644
--- a/example/src/VoiceAgent.tsx
+++ b/example/src/VoiceAgent.tsx
@@ -1,18 +1,22 @@
-import { useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import {
- View,
- Text,
- Button,
+ Animated,
ScrollView,
StyleSheet,
- TextInput,
Switch,
+ Text,
+ View,
} from 'react-native';
import {
useDeepgramVoiceAgent,
createAgentSettings,
} from 'react-native-deepgram';
import OptionSelect from './components/OptionSelect';
+import Button from './components/Button';
+import Card from './components/Card';
+import Field from './components/Field';
+import StatusBadge from './components/StatusBadge';
+import { colors, radius, spacing, type } from './theme';
const LANGUAGE_OPTIONS = [
{ label: 'English (US)', value: 'en' },
@@ -22,7 +26,7 @@ const LANGUAGE_OPTIONS = [
const LISTEN_MODEL_OPTIONS = [
{ label: 'nova-3 (default)', value: 'nova-3' },
- { label: 'nova-3 general', value: 'nova-3-general-nova' },
+ { label: 'nova-3 general', value: 'nova-3-general' },
{ label: 'nova-2', value: 'nova-2' },
];
@@ -51,27 +55,29 @@ export default function VoiceAgent() {
const [temperature, setTemperature] = useState('0.7');
const [customMessage, setCustomMessage] = useState('');
- const agentSettings = useMemo(() => {
- return createAgentSettings({
- language,
+ const agentSettings = useMemo(
+ () =>
+ createAgentSettings({
+ language,
+ greeting,
+ listenModel,
+ thinkModel,
+ prompt,
+ temperature,
+ tags: tagsInput,
+ sampleRate: inputSampleRate,
+ }),
+ [
greeting,
+ inputSampleRate,
+ language,
listenModel,
- thinkModel,
prompt,
+ tagsInput,
temperature,
- tags: tagsInput,
- sampleRate: inputSampleRate,
- });
- }, [
- greeting,
- inputSampleRate,
- language,
- listenModel,
- prompt,
- tagsInput,
- temperature,
- thinkModel,
- ]);
+ thinkModel,
+ ]
+ );
const {
connect,
@@ -79,8 +85,9 @@ export default function VoiceAgent() {
injectUserMessage,
sendFunctionCallResponse,
updatePrompt,
+ updateListen,
+ updateThink,
sendKeepAlive,
- isConnected,
state,
conversation,
agentStatus,
@@ -93,97 +100,285 @@ export default function VoiceAgent() {
trackAgentStatus: true,
});
+ const connectionState = state?.connectionState ?? 'idle';
+ const connecting = connectionState === 'connecting';
+ const connected = connectionState === 'connected';
+ const conversationRef = useRef(null);
+
+ // Mic pulse animation
+ const pulse = useRef(new Animated.Value(0)).current;
+ useEffect(() => {
+ if (!connected) {
+ pulse.stopAnimation();
+ pulse.setValue(0);
+ return undefined;
+ }
+ const loop = Animated.loop(
+ Animated.sequence([
+ Animated.timing(pulse, {
+ toValue: 1,
+ duration: 1200,
+ useNativeDriver: true,
+ }),
+ Animated.timing(pulse, {
+ toValue: 0,
+ duration: 1200,
+ useNativeDriver: true,
+ }),
+ ])
+ );
+ loop.start();
+ return () => loop.stop();
+ }, [connected, pulse]);
+
+ // Auto-scroll to newest message
+ useEffect(() => {
+ if (conversationRef.current && (conversation?.length ?? 0) > 0) {
+ conversationRef.current.scrollToEnd({ animated: true });
+ }
+ }, [conversation?.length]);
+
const startAgent = async () => {
try {
await connect();
} catch (err) {
- // Error handled by hook state
console.error('Start agent failed', err);
}
};
- const stopAgent = () => {
- disconnect();
- };
+ const stopAgent = () => disconnect();
- const sendGreeting = () => {
- const message = greeting.trim() || 'Hello! How can I help you today?';
+ const sendCustomUserMessage = () => {
+ const message = customMessage.trim();
+ if (!message) return;
injectUserMessage(message);
+ setCustomMessage('');
};
- const sendToolResponse = () => {
+ const sendToolResponse = () =>
sendFunctionCallResponse({
id: 'func_12345',
name: 'get_weather',
client_side: true,
content: JSON.stringify({ temperature: 72, condition: 'sunny' }),
});
- };
const applyPromptUpdate = () => {
- const newPrompt = prompt.trim();
- if (!newPrompt) return;
- updatePrompt(newPrompt);
+ const trimmed = prompt.trim();
+ if (trimmed) updatePrompt(trimmed);
};
- const keepAlive = () => {
- sendKeepAlive();
+ const applyListenModel = () => {
+ const model = listenModel.trim();
+ if (model) updateListen({ provider: { type: 'deepgram', model } });
};
- const sendCustomUserMessage = () => {
- const message = customMessage.trim();
- if (!message) return;
- injectUserMessage(message);
- setCustomMessage('');
+ const applyThinkModel = () => {
+ const model = thinkModel.trim();
+ if (model) updateThink({ provider: { type: 'open_ai', model } });
};
+ const tone = state?.error
+ ? 'error'
+ : connecting
+ ? 'connecting'
+ : connected
+ ? 'live'
+ : 'idle';
+ const toneLabel = state?.error
+ ? 'Error'
+ : connecting
+ ? 'Connecting'
+ : connected
+ ? 'Live'
+ : 'Disconnected';
+
+ const pulseScale = pulse.interpolate({
+ inputRange: [0, 1],
+ outputRange: [1, 1.5],
+ });
+ const pulseOpacity = pulse.interpolate({
+ inputRange: [0, 1],
+ outputRange: [0.4, 0],
+ });
+
return (
-
-
-
-
+ {/* Hero / mic */}
+
+
+
+ {agentStatus?.latency?.total != null && (
+
+ {agentStatus.latency.total.toFixed(2)}s latency
+
+ )}
+
-
-
-
+
+ {connected && (
+
+ )}
+
+ ποΈ
+
+
+
+
+ {connected
+ ? 'Listening β talk to the agent'
+ : connecting
+ ? 'Connectingβ¦'
+ : 'Voice Agent'}
+
+
+ {connected
+ ? `Model Β· ${thinkModel}`
+ : 'Configure below and tap Start'}
+
+
+
+ {connected ? (
+
+ ) : (
+
+ )}
+
-
-
-
+ β {state.error}
+
+ ) : null}
+ {state?.warning ? (
+
+ {state.warning}
+
+ ) : null}
+ {agentStatus?.thinking ? (
+
+ π€ {agentStatus.thinking}
+
+ ) : null}
+
+ {/* Conversation */}
+
+
+ {(conversation?.length ?? 0) === 0 ? (
+
+ π¬
+ No messages yet
+
+ Start the agent and say hello.
+
+
+ ) : (
+ conversation?.map((entry, index) => {
+ const isUser = entry.role === 'user';
+ return (
+
+
+
+ {isUser ? 'You' : 'Agent'}
+
+ {entry.content}
+
+
+ );
+ })
+ )}
+
+
+
+ {/* Send a manual message */}
+
+
-
+
+
+
+
+
+
-
- Agent Persona
+ {/* Persona β collapsible */}
+
- Greeting
-
+
+
- Prompt
-
+
- Temperature (0-2)
-
+
- Tags (comma separated)
-
-
-
-
- Capture Settings
-
-
-
- π§ The agent will respond with voice. Make sure your volume is up!
-
-
+
+ {/* Capture β collapsible */}
+
Auto-start microphone
-
+
-
+
+
-
-
-
- Manual Messages
-
-
-
-
- {state?.connectionState === 'connecting' && (
- Connecting to Deepgramβ¦
- )}
- {agentStatus?.thinking && (
- π€ {agentStatus.thinking}
- )}
- {agentStatus?.latency && (
-
- Latency: total {agentStatus.latency.total?.toFixed(2) ?? 'β'}s Β· TTS{' '}
- {agentStatus.latency.tts?.toFixed(2) ?? 'β'}s Β· TTT{' '}
- {agentStatus.latency.ttt?.toFixed(2) ?? 'β'}s
-
- )}
- {state?.warning && (
- Warning: {state.warning}
- )}
- {state?.error && Error: {state.error}}
-
- Conversation
-
-
- π¬ Text transcripts of the conversation appear here alongside audio
- playback.
-
-
-
- {conversation?.map((entry, index) => (
-
- {entry.role}:
- {entry.content}
-
- ))}
- {(conversation?.length ?? 0) === 0 && (
-
- Conversation will appear here once the agent responds.
-
- )}
-
+
);
@@ -333,112 +495,194 @@ export default function VoiceAgent() {
const styles = StyleSheet.create({
container: {
flex: 1,
- backgroundColor: '#fff',
- },
- scroll: {
- flex: 1,
+ backgroundColor: colors.bg,
},
scrollContent: {
- padding: 16,
- paddingBottom: 32,
+ padding: spacing.lg,
+ paddingBottom: spacing.xxl,
+ },
+ hero: {
+ backgroundColor: colors.surface,
+ borderRadius: radius.xl,
+ borderWidth: 1,
+ borderColor: colors.border,
+ padding: spacing.xl,
+ alignItems: 'center',
+ marginBottom: spacing.lg,
},
- buttonRow: {
+ heroTop: {
flexDirection: 'row',
+ alignItems: 'center',
justifyContent: 'space-between',
- marginBottom: 12,
+ width: '100%',
+ marginBottom: spacing.md,
+ },
+ latency: {
+ ...type.small,
+ color: colors.textMuted,
+ },
+ micWrap: {
+ width: 120,
+ height: 120,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: spacing.lg,
+ },
+ pulse: {
+ position: 'absolute',
+ width: 120,
+ height: 120,
+ borderRadius: 60,
+ backgroundColor: colors.primary,
+ },
+ micCircle: {
+ width: 88,
+ height: 88,
+ borderRadius: 44,
+ backgroundColor: colors.surfaceMuted,
alignItems: 'center',
+ justifyContent: 'center',
+ borderWidth: 2,
+ borderColor: colors.border,
},
- settingsCard: {
+ micCircleActive: {
+ backgroundColor: colors.primary,
+ borderColor: colors.primary,
+ },
+ micCircleConnecting: {
+ borderColor: colors.warning,
+ },
+ micIcon: {
+ fontSize: 36,
+ },
+ heroTitle: {
+ ...type.h2,
+ color: colors.text,
+ textAlign: 'center',
+ },
+ heroSubtitle: {
+ ...type.small,
+ color: colors.textMuted,
+ textAlign: 'center',
+ marginTop: 4,
+ marginBottom: spacing.lg,
+ },
+ heroActions: {
+ width: '100%',
+ alignItems: 'stretch',
+ },
+ errorBanner: {
+ backgroundColor: '#3a1418',
+ borderColor: colors.danger,
borderWidth: 1,
- borderColor: '#e0e0e0',
- borderRadius: 8,
- padding: 12,
- marginBottom: 16,
- backgroundColor: '#fafafa',
- },
- sectionTitle: {
- fontSize: 16,
- fontWeight: '600',
- color: '#222',
- marginBottom: 12,
- },
- inputLabel: {
- fontSize: 13,
- fontWeight: '600',
- color: '#444',
- marginBottom: 6,
- },
- textInput: {
+ borderRadius: radius.md,
+ padding: spacing.md,
+ marginBottom: spacing.md,
+ },
+ errorText: {
+ color: colors.danger,
+ ...type.smallMedium,
+ },
+ warningBanner: {
+ backgroundColor: '#3a2c10',
+ borderColor: colors.warning,
borderWidth: 1,
- borderColor: '#ccc',
- borderRadius: 6,
- paddingHorizontal: 10,
- paddingVertical: 8,
- marginBottom: 12,
- backgroundColor: '#fff',
+ borderRadius: radius.md,
+ padding: spacing.md,
+ marginBottom: spacing.md,
},
- textArea: {
- minHeight: 96,
+ warningText: {
+ color: colors.warning,
+ ...type.smallMedium,
},
- switchRow: {
+ thinkingPill: {
+ backgroundColor: colors.accentMuted,
+ paddingVertical: 6,
+ paddingHorizontal: spacing.md,
+ borderRadius: radius.pill,
+ alignSelf: 'flex-start',
+ marginBottom: spacing.md,
+ },
+ thinkingText: {
+ color: colors.accent,
+ ...type.smallMedium,
+ },
+ conversation: {
+ maxHeight: 320,
+ },
+ conversationContent: {
+ paddingVertical: spacing.xs,
+ },
+ bubbleRow: {
+ marginBottom: spacing.sm,
flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- marginBottom: 12,
},
- switchLabel: {
- flex: 1,
- fontSize: 14,
- color: '#333',
- marginRight: 12,
+ bubbleRowLeft: {
+ justifyContent: 'flex-start',
+ },
+ bubbleRowRight: {
+ justifyContent: 'flex-end',
+ },
+ bubble: {
+ maxWidth: '85%',
+ paddingHorizontal: spacing.md,
+ paddingVertical: 10,
+ borderRadius: radius.lg,
+ },
+ userBubble: {
+ backgroundColor: colors.userBubble,
+ borderBottomRightRadius: 4,
},
- infoBox: {
- backgroundColor: '#d1ecf1',
+ agentBubble: {
+ backgroundColor: colors.agentBubble,
borderWidth: 1,
- borderColor: '#bee5eb',
- borderRadius: 6,
- padding: 12,
- marginBottom: 12,
+ borderColor: colors.border,
+ borderBottomLeftRadius: 4,
},
- infoText: {
- fontSize: 13,
- color: '#0c5460',
- lineHeight: 18,
+ bubbleRole: {
+ ...type.small,
+ color: colors.textMuted,
+ marginBottom: 2,
+ textTransform: 'uppercase',
+ letterSpacing: 0.6,
+ fontSize: 11,
},
- status: {
- marginBottom: 8,
- fontStyle: 'italic',
+ bubbleText: {
+ color: colors.text,
+ ...type.body,
},
- warning: {
- marginBottom: 8,
- color: '#b35d00',
+ emptyState: {
+ paddingVertical: spacing.xl,
+ alignItems: 'center',
},
- error: {
- marginBottom: 8,
- color: '#d22',
+ emptyIcon: {
+ fontSize: 32,
+ marginBottom: spacing.sm,
},
- conversation: {
- borderWidth: 1,
- borderColor: '#e0e0e0',
- borderRadius: 8,
- padding: 12,
- backgroundColor: '#fff',
- marginBottom: 24,
+ emptyTitle: {
+ ...type.h3,
+ color: colors.text,
+ },
+ emptySubtitle: {
+ ...type.small,
+ color: colors.textMuted,
+ marginTop: 2,
},
- messageRow: {
+ actionRow: {
flexDirection: 'row',
- marginBottom: 8,
+ flexWrap: 'wrap',
+ gap: spacing.sm,
},
- messageRole: {
- fontWeight: '600',
- marginRight: 8,
+ switchRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: spacing.md,
},
- messageContent: {
+ switchLabel: {
+ ...type.body,
+ color: colors.text,
flex: 1,
- flexWrap: 'wrap',
- },
- placeholder: {
- fontStyle: 'italic',
- color: '#666',
+ marginRight: spacing.md,
},
});
diff --git a/example/src/components/Button.tsx b/example/src/components/Button.tsx
new file mode 100644
index 0000000..27d33b4
--- /dev/null
+++ b/example/src/components/Button.tsx
@@ -0,0 +1,140 @@
+import {
+ Pressable,
+ StyleSheet,
+ Text,
+ ActivityIndicator,
+ View,
+ type PressableProps,
+} from 'react-native';
+import { colors, radius, spacing, type } from '../theme';
+
+type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
+type Size = 'sm' | 'md' | 'lg';
+
+export interface ButtonProps extends Omit {
+ title: string;
+ variant?: Variant;
+ size?: Size;
+ loading?: boolean;
+ fullWidth?: boolean;
+ iconLeft?: string; // emoji or short string used as icon
+}
+
+export default function Button({
+ title,
+ variant = 'primary',
+ size = 'md',
+ loading,
+ disabled,
+ fullWidth,
+ iconLeft,
+ style,
+ ...rest
+}: ButtonProps) {
+ const isDisabled = disabled || loading;
+
+ return (
+ [
+ styles.base,
+ styles[`size_${size}`],
+ styles[`variant_${variant}`],
+ fullWidth && styles.fullWidth,
+ pressed && !isDisabled && styles.pressed,
+ isDisabled && styles.disabled,
+ typeof style === 'function' ? undefined : style,
+ ]}
+ {...rest}
+ >
+
+ {loading ? (
+
+ ) : (
+ <>
+ {iconLeft ? (
+
+ {iconLeft}
+
+ ) : null}
+
+ {title}
+
+ >
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ base: {
+ borderRadius: radius.md,
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexDirection: 'row',
+ },
+ fullWidth: {
+ width: '100%',
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ },
+ icon: {
+ fontSize: 16,
+ },
+ pressed: {
+ opacity: 0.85,
+ transform: [{ scale: 0.985 }],
+ },
+ disabled: {
+ opacity: 0.4,
+ },
+ size_sm: {
+ paddingVertical: 8,
+ paddingHorizontal: 12,
+ },
+ size_md: {
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ },
+ size_lg: {
+ paddingVertical: 16,
+ paddingHorizontal: 20,
+ },
+ variant_primary: {
+ backgroundColor: colors.primary,
+ },
+ variant_secondary: {
+ backgroundColor: colors.surfaceMuted,
+ borderWidth: 1,
+ borderColor: colors.border,
+ },
+ variant_ghost: {
+ backgroundColor: 'transparent',
+ },
+ variant_danger: {
+ backgroundColor: colors.danger,
+ },
+ text: {
+ ...type.bodyMedium,
+ },
+ textSm: { fontSize: 13 },
+ textLg: { fontSize: 16 },
+ text_primary: { color: '#fff' },
+ text_secondary: { color: colors.text },
+ text_ghost: { color: colors.primary },
+ text_danger: { color: '#fff' },
+});
diff --git a/example/src/components/Card.tsx b/example/src/components/Card.tsx
new file mode 100644
index 0000000..29093a2
--- /dev/null
+++ b/example/src/components/Card.tsx
@@ -0,0 +1,94 @@
+import { type ReactNode, useState } from 'react';
+import {
+ Pressable,
+ StyleSheet,
+ Text,
+ View,
+ type ViewProps,
+} from 'react-native';
+import { colors, radius, spacing, type } from '../theme';
+
+export interface CardProps extends ViewProps {
+ title?: string;
+ subtitle?: string;
+ collapsible?: boolean;
+ defaultCollapsed?: boolean;
+ right?: ReactNode;
+ children?: ReactNode;
+}
+
+export default function Card({
+ title,
+ subtitle,
+ collapsible,
+ defaultCollapsed = false,
+ right,
+ children,
+ style,
+ ...rest
+}: CardProps) {
+ const [collapsed, setCollapsed] = useState(defaultCollapsed);
+
+ const HeaderTag = collapsible ? Pressable : View;
+
+ return (
+
+ {(title || right) && (
+ setCollapsed((c) => !c) : undefined}
+ style={styles.header}
+ >
+
+ {title ? {title} : null}
+ {subtitle ? {subtitle} : null}
+
+ {right}
+ {collapsible ? (
+ {collapsed ? 'βΎ' : 'β΄'}
+ ) : null}
+
+ )}
+ {!collapsed && {children}}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ card: {
+ backgroundColor: colors.surface,
+ borderRadius: radius.lg,
+ borderWidth: 1,
+ borderColor: colors.border,
+ marginBottom: spacing.md,
+ overflow: 'hidden',
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.md,
+ paddingBottom: spacing.sm,
+ },
+ headerText: {
+ flex: 1,
+ },
+ title: {
+ ...type.h3,
+ color: colors.text,
+ },
+ subtitle: {
+ ...type.small,
+ color: colors.textMuted,
+ marginTop: 2,
+ },
+ chevron: {
+ color: colors.textMuted,
+ fontSize: 14,
+ marginLeft: spacing.sm,
+ },
+ body: {
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.sm,
+ paddingBottom: spacing.lg,
+ },
+});
diff --git a/example/src/components/Field.tsx b/example/src/components/Field.tsx
new file mode 100644
index 0000000..9fbf8a4
--- /dev/null
+++ b/example/src/components/Field.tsx
@@ -0,0 +1,79 @@
+import { type ReactNode } from 'react';
+import {
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+ type TextInputProps,
+} from 'react-native';
+import { colors, radius, spacing, type } from '../theme';
+
+export interface FieldProps extends TextInputProps {
+ label?: string;
+ hint?: string;
+ right?: ReactNode;
+}
+
+export default function Field({
+ label,
+ hint,
+ right,
+ style,
+ multiline,
+ ...rest
+}: FieldProps) {
+ return (
+
+ {label ? (
+
+ {label}
+ {right}
+
+ ) : null}
+
+ {hint ? {hint} : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ wrap: {
+ marginBottom: spacing.md,
+ },
+ labelRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: 6,
+ },
+ label: {
+ ...type.smallMedium,
+ color: colors.textMuted,
+ textTransform: 'uppercase',
+ letterSpacing: 0.6,
+ },
+ input: {
+ backgroundColor: colors.surfaceMuted,
+ borderWidth: 1,
+ borderColor: colors.border,
+ borderRadius: radius.md,
+ paddingVertical: 10,
+ paddingHorizontal: 12,
+ color: colors.text,
+ fontSize: 15,
+ },
+ inputMultiline: {
+ minHeight: 96,
+ textAlignVertical: 'top',
+ },
+ hint: {
+ ...type.small,
+ color: colors.textDim,
+ marginTop: 6,
+ },
+});
diff --git a/example/src/components/OptionSelect.tsx b/example/src/components/OptionSelect.tsx
index 7d2c1ce..f182700 100644
--- a/example/src/components/OptionSelect.tsx
+++ b/example/src/components/OptionSelect.tsx
@@ -11,6 +11,7 @@ import {
View,
type TextInputProps,
} from 'react-native';
+import { colors, radius, spacing, type } from '../theme';
export type Option = { label: string; value: string };
@@ -152,46 +153,50 @@ export default OptionSelect;
const styles = StyleSheet.create({
container: {
- marginBottom: 12,
+ marginBottom: spacing.md,
},
label: {
- fontSize: 13,
- fontWeight: '600',
- color: '#444',
- marginBottom: 4,
+ ...type.smallMedium,
+ color: colors.textMuted,
+ textTransform: 'uppercase',
+ letterSpacing: 0.6,
+ marginBottom: 6,
},
dropdown: {
borderWidth: 1,
- borderColor: '#ccc',
- borderRadius: 4,
+ borderColor: colors.border,
+ borderRadius: radius.md,
paddingVertical: 10,
paddingHorizontal: 12,
- backgroundColor: '#fff',
+ backgroundColor: colors.surfaceMuted,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
dropdownValue: {
- fontSize: 14,
- color: '#333',
+ fontSize: 15,
+ color: colors.text,
flex: 1,
},
dropdownPlaceholder: {
- fontSize: 14,
- color: '#888',
+ fontSize: 15,
+ color: colors.textDim,
flex: 1,
},
dropdownIcon: {
fontSize: 16,
- color: '#666',
+ color: colors.textMuted,
marginLeft: 8,
},
input: {
borderWidth: 1,
- borderColor: '#ccc',
- borderRadius: 4,
- padding: 8,
- backgroundColor: '#fff',
+ borderColor: colors.border,
+ borderRadius: radius.md,
+ paddingVertical: 10,
+ paddingHorizontal: 12,
+ backgroundColor: colors.surfaceMuted,
+ color: colors.text,
+ fontSize: 15,
},
customInput: {
marginTop: 8,
@@ -202,28 +207,31 @@ const styles = StyleSheet.create({
},
modalBackdrop: {
flex: 1,
- backgroundColor: 'rgba(0, 0, 0, 0.35)',
+ backgroundColor: 'rgba(0, 0, 0, 0.55)',
},
modalSheet: {
maxHeight: '70%',
- backgroundColor: '#fff',
- borderTopLeftRadius: 16,
- borderTopRightRadius: 16,
- paddingHorizontal: 16,
- paddingTop: 16,
- paddingBottom: 32,
+ backgroundColor: colors.surface,
+ borderTopLeftRadius: radius.xl,
+ borderTopRightRadius: radius.xl,
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.lg,
+ paddingBottom: spacing.xxl,
+ borderTopWidth: 1,
+ borderColor: colors.border,
},
modalTitle: {
- fontSize: 16,
- fontWeight: '600',
- marginBottom: 12,
- color: '#222',
+ ...type.h3,
+ color: colors.text,
+ marginBottom: spacing.md,
},
modalOption: {
- paddingVertical: 12,
+ paddingVertical: 14,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.divider,
},
modalOptionText: {
- fontSize: 15,
- color: '#333',
+ ...type.body,
+ color: colors.text,
},
});
diff --git a/example/src/components/StatusBadge.tsx b/example/src/components/StatusBadge.tsx
new file mode 100644
index 0000000..1eed09e
--- /dev/null
+++ b/example/src/components/StatusBadge.tsx
@@ -0,0 +1,94 @@
+import { Animated, StyleSheet, Text, View, type ViewStyle } from 'react-native';
+import { useEffect, useRef } from 'react';
+import { colors, radius, spacing, type } from '../theme';
+
+type Tone = 'idle' | 'connecting' | 'live' | 'success' | 'error' | 'warning';
+
+const TONE_COLOR: Record = {
+ idle: colors.textDim,
+ connecting: colors.warning,
+ live: colors.success,
+ success: colors.success,
+ error: colors.danger,
+ warning: colors.warning,
+};
+
+export interface StatusBadgeProps {
+ tone: Tone;
+ label: string;
+ style?: ViewStyle;
+}
+
+export default function StatusBadge({ tone, label, style }: StatusBadgeProps) {
+ const pulse = useRef(new Animated.Value(0)).current;
+ const animated = tone === 'live' || tone === 'connecting';
+
+ useEffect(() => {
+ if (!animated) {
+ pulse.stopAnimation();
+ pulse.setValue(0);
+ return undefined;
+ }
+ const loop = Animated.loop(
+ Animated.sequence([
+ Animated.timing(pulse, {
+ toValue: 1,
+ duration: 800,
+ useNativeDriver: true,
+ }),
+ Animated.timing(pulse, {
+ toValue: 0,
+ duration: 800,
+ useNativeDriver: true,
+ }),
+ ])
+ );
+ loop.start();
+ return () => loop.stop();
+ }, [animated, pulse]);
+
+ const opacity = pulse.interpolate({
+ inputRange: [0, 1],
+ outputRange: [1, 0.3],
+ });
+ const dotColor = TONE_COLOR[tone];
+
+ return (
+
+
+ {label}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ wrap: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 4,
+ paddingHorizontal: 10,
+ borderRadius: radius.pill,
+ backgroundColor: colors.surfaceMuted,
+ borderWidth: 1,
+ borderColor: colors.border,
+ alignSelf: 'flex-start',
+ gap: spacing.xs,
+ },
+ dot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ },
+ label: {
+ ...type.smallMedium,
+ fontSize: 12,
+ letterSpacing: 0.4,
+ textTransform: 'uppercase',
+ },
+});
diff --git a/example/src/theme.ts b/example/src/theme.ts
new file mode 100644
index 0000000..714fc41
--- /dev/null
+++ b/example/src/theme.ts
@@ -0,0 +1,74 @@
+/**
+ * Lightweight design tokens for the Deepgram example app.
+ * Inspired by Deepgram's brand palette (deep purple + warm accent).
+ */
+
+export const colors = {
+ // Surfaces
+ bg: '#0B0B12',
+ surface: '#13131C',
+ surfaceElevated: '#1B1B27',
+ surfaceMuted: '#22222F',
+ border: '#2A2A38',
+ divider: '#1F1F2A',
+
+ // Text
+ text: '#F4F4F8',
+ textMuted: '#9A9AAE',
+ textDim: '#6E6E80',
+
+ // Brand
+ primary: '#8B5CF6', // violet
+ primaryHover: '#7C4FE5',
+ primaryMuted: '#3A2A66',
+ accent: '#22D3EE', // cyan accent
+ accentMuted: '#1E3A4A',
+
+ // Semantic
+ success: '#34D399',
+ warning: '#FBBF24',
+ danger: '#F87171',
+ info: '#60A5FA',
+
+ // Bubbles
+ userBubble: '#2A1E4F',
+ agentBubble: '#1B1B27',
+};
+
+export const spacing = {
+ xs: 4,
+ sm: 8,
+ md: 12,
+ lg: 16,
+ xl: 24,
+ xxl: 32,
+};
+
+export const radius = {
+ sm: 6,
+ md: 10,
+ lg: 14,
+ xl: 20,
+ pill: 999,
+};
+
+export const type = {
+ h1: { fontSize: 26, fontWeight: '700' as const, letterSpacing: -0.4 },
+ h2: { fontSize: 20, fontWeight: '700' as const, letterSpacing: -0.2 },
+ h3: { fontSize: 16, fontWeight: '600' as const },
+ body: { fontSize: 15, fontWeight: '400' as const },
+ bodyMedium: { fontSize: 15, fontWeight: '500' as const },
+ small: { fontSize: 13, fontWeight: '400' as const },
+ smallMedium: { fontSize: 13, fontWeight: '600' as const },
+ mono: { fontSize: 12, fontFamily: 'Menlo' as const },
+};
+
+export const shadow = {
+ card: {
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.25,
+ shadowRadius: 12,
+ elevation: 4,
+ },
+};
diff --git a/ios/Deepgram.mm b/ios/Deepgram.mm
index 29eeb0d..a7a7b01 100644
--- a/ios/Deepgram.mm
+++ b/ios/Deepgram.mm
@@ -1,15 +1,15 @@
+#import
+#import
#import
#import
#import
-#import
-#import
#import
#if TARGET_OS_IOS
#import
#endif
+#include
#include
#include
-#include
#define DGNumberBuffers 3
@@ -26,6 +26,38 @@
#define DGLogWarn(...) RCTLogWarn(__VA_ARGS__)
#define DGLogError(...) RCTLogError(__VA_ARGS__)
+static NSError *DGNativeError(NSString *domain, NSInteger code,
+ NSString *message) {
+ return [NSError errorWithDomain:domain
+ code:code
+ userInfo:@{
+ NSLocalizedDescriptionKey : message
+ ?: @"Unknown Deepgram native error"
+ }];
+}
+
+static NSError *DGOSStatusError(NSString *operation, OSStatus status) {
+ NSString *message = [NSString
+ stringWithFormat:@"%@ failed with OSStatus %d", operation, (int)status];
+ return DGNativeError(NSOSStatusErrorDomain, (NSInteger)status, message);
+}
+
+static void DGRejectPromise(RCTPromiseRejectBlock reject, NSString *code,
+ NSString *message, NSError *error) {
+ if (!reject) {
+ return;
+ }
+
+ NSString *safeCode = code ?: @"deepgram_native_error";
+ NSString *safeMessage = message ?: error.localizedDescription
+ ?: @"Deepgram native error";
+ NSError *safeError = error ?: DGNativeError(@"DeepgramNativeError", 0,
+ safeMessage);
+ DGLogError(@"[Deepgram] reject: code=%@ message=%@", safeCode,
+ safeMessage);
+ reject(safeCode, safeMessage, safeError);
+}
+
@class Deepgram;
typedef struct {
@@ -38,26 +70,35 @@
bool isRunning;
} DGRecordState;
-@interface Deepgram : RCTEventEmitter
-{
+@interface Deepgram : RCTEventEmitter {
DGRecordState _recordState;
std::atomic _scheduledBufferCount;
+ std::atomic _playbackGeneration;
}
// Recording
-@property (nonatomic, strong) NSMutableData *pendingPCMBuffer;
-@property (nonatomic, strong) dispatch_queue_t emitterQueue;
-@property (nonatomic, assign) NSUInteger chunkSizeBytes;
-@property (atomic, assign) BOOL hasListeners;
-@property (atomic, assign) BOOL appIsActive;
+@property(nonatomic, strong) NSMutableData *pendingPCMBuffer;
+@property(nonatomic, strong) dispatch_queue_t emitterQueue;
+@property(nonatomic, assign) NSUInteger chunkSizeBytes;
+@property(atomic, assign) BOOL hasListeners;
+@property(atomic, assign) BOOL appIsActive;
// Playback / TTS (AVAudioEngine-based with echo cancellation)
-@property (nonatomic, strong) AVAudioEngine *audioEngine;
-@property (nonatomic, strong) AVAudioPlayerNode *playerNode;
-@property (nonatomic, strong) AVAudioFormat *playbackFormat;
-@property (nonatomic, assign) BOOL isPlaying;
-@property (nonatomic, assign) int currentSampleRate;
-@property (nonatomic, assign) BOOL audioSessionConfigured;
+@property(nonatomic, strong) AVAudioEngine *audioEngine;
+@property(nonatomic, strong) AVAudioPlayerNode *playerNode;
+@property(nonatomic, strong) AVAudioFormat *playbackFormat;
+@property(atomic, assign) BOOL isPlaying;
+@property(atomic, assign) int currentSampleRate;
+@property(nonatomic, assign) BOOL audioSessionConfigured;
+// YES while we're capturing the microphone through `audioEngine.inputNode`
+// (Voice Agent / duplex). When YES, the AudioQueue path is bypassed and the
+// session must use VoiceChat mode so Apple's Voice-Processing I/O Audio Unit
+// engages and performs hardware echo cancellation.
+@property(atomic, assign) BOOL engineCaptureActive;
+@property(atomic, assign) BOOL voiceProcessingRequested;
+@property(atomic, assign) BOOL audioQueueCaptureRequested;
+@property(nonatomic, strong) AVAudioConverter *captureConverter;
+@property(nonatomic, strong) AVAudioFormat *captureOutputFormat;
@end
@interface Deepgram (RecordingPrivate)
@@ -65,16 +106,20 @@ - (void)appendPCMDataAndEmitIfNeeded:(NSData *)pcmData;
- (BOOL)activateAudioSession:(NSError **)outError;
- (BOOL)configureAudioSessionIfNeeded:(NSError **)outError;
- (BOOL)configureAudioSession:(NSError **)outError;
+- (AVAudioSessionCategoryOptions)bluetoothHFPOption;
+- (BOOL)applyAudioSessionCategory:(AVAudioSessionCategory)category
+ mode:(NSString *)mode
+ options:(AVAudioSessionCategoryOptions)options
+ error:(NSError **)outError;
+- (void)stopAndDetachPlayerNode;
+- (void)interruptPlayerPlayback;
- (void)maybeDeactivateAudioSession;
@end
-static void DGHandleInputBuffer(void *inUserData,
- __unused AudioQueueRef inAQ,
- AudioQueueBufferRef inBuffer,
- __unused const AudioTimeStamp *inStartTime,
- __unused UInt32 inNumPackets,
- __unused const AudioStreamPacketDescription *inPacketDesc)
-{
+static void DGHandleInputBuffer(
+ void *inUserData, __unused AudioQueueRef inAQ, AudioQueueBufferRef inBuffer,
+ __unused const AudioTimeStamp *inStartTime, __unused UInt32 inNumPackets,
+ __unused const AudioStreamPacketDescription *inPacketDesc) {
@autoreleasepool {
DGRecordState *state = (DGRecordState *)inUserData;
if (!state || !state->isRunning) {
@@ -94,9 +139,9 @@ static void DGHandleInputBuffer(void *inUserData,
}
DGLogDebug(@"[Deepgram] DGHandleInputBuffer: received %u bytes",
- (unsigned int)inBuffer->mAudioDataByteSize);
+ (unsigned int)inBuffer->mAudioDataByteSize);
NSData *data = [NSData dataWithBytes:inBuffer->mAudioData
- length:inBuffer->mAudioDataByteSize];
+ length:inBuffer->mAudioDataByteSize];
[strongSelf appendPCMDataAndEmitIfNeeded:data];
if (state->queue) {
@@ -109,21 +154,20 @@ static void DGHandleInputBuffer(void *inUserData,
@implementation Deepgram
RCT_EXPORT_MODULE();
-+ (BOOL)requiresMainQueueSetup { return NO; }
++ (BOOL)requiresMainQueueSetup {
+ return NO;
+}
-- (NSArray *)supportedEvents
-{
+- (NSArray *)supportedEvents {
return @[ @"DeepgramAudioPCM" ];
}
-- (void)startObserving
-{
+- (void)startObserving {
DGLogDebug(@"[Deepgram] startObserving: listeners attached");
self.hasListeners = YES;
}
-- (void)stopObserving
-{
+- (void)stopObserving {
DGLogDebug(@"[Deepgram] stopObserving: listeners detached");
self.hasListeners = NO;
}
@@ -131,11 +175,11 @@ - (void)stopObserving
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
-- (instancetype)init
-{
+- (instancetype)init {
if (self = [super init]) {
_chunkSizeBytes = 6400; // β200 ms of 16 kHz mono PCM16 audio
- DGLogDebug(@"[Deepgram] init: chunkSizeBytes=%lu", (unsigned long)_chunkSizeBytes);
+ DGLogDebug(@"[Deepgram] init: chunkSizeBytes=%lu",
+ (unsigned long)_chunkSizeBytes);
_emitterQueue = dispatch_queue_create("com.deepgram.liveaudiostream",
DISPATCH_QUEUE_SERIAL);
memset(&_recordState, 0, sizeof(DGRecordState));
@@ -147,41 +191,51 @@ - (instancetype)init
selector:@selector(handleAppDidBecomeActive:)
name:UIApplicationDidBecomeActiveNotification
object:nil];
- DGLogDebug(@"[Deepgram] init: registered for UIApplicationDidBecomeActiveNotification");
+ DGLogDebug(@"[Deepgram] init: registered for "
+ @"UIApplicationDidBecomeActiveNotification");
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(handleAppDidEnterBackground:)
name:UIApplicationDidEnterBackgroundNotification
object:nil];
- DGLogDebug(@"[Deepgram] init: registered for UIApplicationDidEnterBackgroundNotification");
+ DGLogDebug(@"[Deepgram] init: registered for "
+ @"UIApplicationDidEnterBackgroundNotification");
#endif
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(handleAudioRouteChange:)
- name:AVAudioSessionRouteChangeNotification
- object:nil];
- DGLogDebug(@"[Deepgram] init: registered for AVAudioSessionRouteChangeNotification");
+ name:AVAudioSessionRouteChangeNotification
+ object:nil];
+ DGLogDebug(@"[Deepgram] init: registered for "
+ @"AVAudioSessionRouteChangeNotification");
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(handleAudioInterruption:)
- name:AVAudioSessionInterruptionNotification
- object:nil];
- DGLogDebug(@"[Deepgram] init: registered for AVAudioSessionInterruptionNotification");
+ name:AVAudioSessionInterruptionNotification
+ object:nil];
+ DGLogDebug(@"[Deepgram] init: registered for "
+ @"AVAudioSessionInterruptionNotification");
+ [[NSNotificationCenter defaultCenter]
+ addObserver:self
+ selector:@selector(handleMediaServicesReset:)
+ name:AVAudioSessionMediaServicesWereResetNotification
+ object:nil];
+ DGLogDebug(@"[Deepgram] init: registered for "
+ @"AVAudioSessionMediaServicesWereResetNotification");
}
return self;
}
-- (void)dealloc
-{
- DGLogDebug(@"[Deepgram] dealloc: cleaning up recording queue and removing observers");
+- (void)dealloc {
+ DGLogDebug(@"[Deepgram] dealloc: cleaning up recording queue and removing "
+ @"observers");
[self cleanupRecordingQueue];
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
-- (BOOL)activateAudioSession:(NSError **)outError
-{
+- (BOOL)activateAudioSession:(NSError **)outError {
DGLogDebug(@"[Deepgram] activateAudioSession: begin (configured=%@)",
- self.audioSessionConfigured ? @"YES" : @"NO");
+ self.audioSessionConfigured ? @"YES" : @"NO");
__block BOOL success = YES;
__block NSError *activationError = nil;
@@ -190,9 +244,10 @@ - (BOOL)activateAudioSession:(NSError **)outError
if (!success && activationError) {
DGLogError(@"[Deepgram] Failed to activate audio session: %@",
- activationError.localizedDescription ?: activationError);
+ activationError.localizedDescription ?: activationError);
} else {
- DGLogDebug(@"[Deepgram] activateAudioSession: success=%@", success ? @"YES" : @"NO");
+ DGLogDebug(@"[Deepgram] activateAudioSession: success=%@",
+ success ? @"YES" : @"NO");
}
if (outError) {
@@ -202,135 +257,282 @@ - (BOOL)activateAudioSession:(NSError **)outError
return success;
}
-- (void)deactivateAudioSession
-{
+- (void)deactivateAudioSession {
DGLogDebug(@"[Deepgram] deactivateAudioSession: begin");
NSError *error = nil;
- if (![[AVAudioSession sharedInstance] setActive:NO error:&error] && error) {
+ // Use NotifyOthersOnDeactivation so other audio sessions (expo-av, etc.)
+ // know they can resume.
+ if (![[AVAudioSession sharedInstance]
+ setActive:NO
+ withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation
+ error:&error] &&
+ error) {
DGLogError(@"[Deepgram] Failed to deactivate audio session: %@",
- error.localizedDescription ?: error);
+ error.localizedDescription ?: error);
}
self.audioSessionConfigured = NO;
}
- (BOOL)configureAudioSessionIfNeeded:(NSError **)outError
{
- if (!self.audioSessionConfigured) {
- DGLogDebug(@"[Deepgram] configureAudioSessionIfNeeded: not configured, configuring");
- return [self configureAudioSession:outError];
- }
+ BOOL needsMic = _recordState.isRunning || self.audioQueueCaptureRequested ||
+ self.engineCaptureActive || self.voiceProcessingRequested;
+ AVAudioSession *session = [AVAudioSession sharedInstance];
- DGLogDebug(@"[Deepgram] configureAudioSessionIfNeeded: already configured, ensuring active");
- NSError *activeError = nil;
- BOOL success = [[AVAudioSession sharedInstance] setActive:YES error:&activeError];
- if (!success || activeError) {
- self.audioSessionConfigured = NO;
- if (outError) {
- *outError = activeError;
+ if (self.audioSessionConfigured) {
+ BOOL isCurrentlyPlayAndRecord = [session.category isEqualToString:AVAudioSessionCategoryPlayAndRecord];
+ if (isCurrentlyPlayAndRecord == needsMic) {
+ DGLogDebug(@"[Deepgram] configureAudioSessionIfNeeded: already configured correctly, ensuring active");
+ NSError *activeError = nil;
+ BOOL success = [[AVAudioSession sharedInstance] setActive:YES error:&activeError];
+ if (success && !activeError) {
+ return YES;
+ }
}
}
- return success;
-}
-- (BOOL)configureAudioSession:(NSError **)outError
-{
- DGLogDebug(@"[Deepgram] configureAudioSession: begin");
- AVAudioSession *session = [AVAudioSession sharedInstance];
- NSError *error = nil;
+ DGLogDebug(@"[Deepgram] configureAudioSessionIfNeeded: configuring (needsMic=%@)", needsMic ? @"YES" : @"NO");
+ return [self configureAudioSession:outError];
+}
- AVAudioSessionCategoryOptions options = AVAudioSessionCategoryOptionDuckOthers |
- AVAudioSessionCategoryOptionDefaultToSpeaker |
- AVAudioSessionCategoryOptionAllowBluetooth;
+- (BOOL)configureAudioSession:(NSError **)outError {
+ BOOL needsMic = _recordState.isRunning || self.audioQueueCaptureRequested ||
+ self.engineCaptureActive || self.voiceProcessingRequested;
+ return [self configureAudioSessionForRecording:needsMic error:outError];
+}
- if (@available(iOS 10.0, *)) {
- DGLogDebug(@"[Deepgram] configureAudioSession: enabling AirPlay option");
- options |= AVAudioSessionCategoryOptionAllowAirPlay;
+/**
+ * Build the Bluetooth input option appropriate for the running OS.
+ * iOS 17 deprecated `AllowBluetooth` (HFP route) in favor of
+ * `AllowBluetoothHFP`. Prefer the new symbol when building against the iOS
+ * 17+ SDK and fall back to the legacy spelling on older toolchains.
+ */
+- (AVAudioSessionCategoryOptions)bluetoothHFPOption {
+#if defined(__IPHONE_17_0) && (__IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_17_0)
+ if (@available(iOS 17.0, *)) {
+ return AVAudioSessionCategoryOptionAllowBluetoothHFP;
}
-
- BOOL categorySuccess = NO;
-
- if (@available(iOS 10.0, *)) {
- DGLogDebug(@"[Deepgram] configureAudioSession: setCategory PlayAndRecord voice chat (iOS10+)");
- categorySuccess = [session setCategory:AVAudioSessionCategoryPlayAndRecord
- mode:AVAudioSessionModeVoiceChat
- options:options
- error:&error];
- } else {
+#endif
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
- DGLogDebug(@"[Deepgram] configureAudioSession: setCategory legacy path");
- categorySuccess = [session setCategory:AVAudioSessionCategoryPlayAndRecord
- withOptions:options
- error:&error];
+ return AVAudioSessionCategoryOptionAllowBluetooth;
#pragma clang diagnostic pop
- if (categorySuccess && !error) {
- NSError *modeError = nil;
- categorySuccess = [session setMode:AVAudioSessionModeVoiceChat error:&modeError] && !modeError;
- if (modeError) {
- error = modeError;
- }
- }
- }
+}
+
+/**
+ * Attempt to apply a single category/mode/options tuple and activate the
+ * session. Returns YES on success. On failure, populates `outError` so the
+ * caller can decide whether to try a more conservative configuration.
+ */
+- (BOOL)applyAudioSessionCategory:(AVAudioSessionCategory)category
+ mode:(NSString *)mode
+ options:(AVAudioSessionCategoryOptions)options
+ error:(NSError **)outError {
+ AVAudioSession *session = [AVAudioSession sharedInstance];
+ NSError *error = nil;
+
+ DGLogDebug(@"[Deepgram] configureAudioSession: setCategory %@ mode %@ "
+ @"options 0x%lx",
+ category, mode, (unsigned long)options);
- if (!categorySuccess || error) {
+ if (![session setCategory:category
+ mode:mode
+ options:options
+ error:&error] ||
+ error) {
DGLogError(@"[Deepgram] configureAudioSession: category failed error=%@",
- error.localizedDescription ?: error);
+ error.localizedDescription ?: error);
if (outError) {
*outError = error;
}
- self.audioSessionConfigured = NO;
return NO;
}
NSError *activeError = nil;
- BOOL activeSuccess = [session setActive:YES error:&activeError];
- if (!activeSuccess && activeError.code == AVAudioSessionErrorCodeInsufficientPriority) {
- DGLogWarn(@"[Deepgram] configureAudioSession: insufficient priority, retrying with mix-with-others");
- NSError *retryError = nil;
- BOOL categoryRetry = [session setCategory:session.category
- mode:session.mode
- options:options | AVAudioSessionCategoryOptionMixWithOthers
- error:&retryError];
- if (!categoryRetry) {
- DGLogWarn(@"[Deepgram] configureAudioSession: unable to update category options %@", retryError.localizedDescription ?: retryError);
- }
- activeError = nil;
- activeSuccess = [session setActive:YES error:&activeError];
- }
- if (!activeSuccess || activeError) {
+ if (![session setActive:YES error:&activeError]) {
DGLogError(@"[Deepgram] configureAudioSession: setActive failed error=%@",
- activeError.localizedDescription ?: activeError);
+ activeError.localizedDescription ?: activeError);
if (outError) {
*outError = activeError;
}
- self.audioSessionConfigured = NO;
return NO;
}
- self.audioSessionConfigured = YES;
- DGLogDebug(@"[Deepgram] configureAudioSession: success");
return YES;
}
-- (void)maybeDeactivateAudioSession
-{
- DGLogDebug(@"[Deepgram] maybeDeactivateAudioSession: running=%@ playing=%@",
- _recordState.isRunning ? @"YES" : @"NO",
- self.isPlaying ? @"YES" : @"NO");
- if (!_recordState.isRunning && !self.isPlaying) {
+/**
+ * Configure audio session with mode appropriate to current usage.
+ *
+ * The configuration is attempted from most- to least-capable. If a richer
+ * setup is rejected we automatically fall back to a simpler one so audio still
+ * works instead of hard-failing.
+ *
+ * Two subtle rules drive the ordering below; getting them wrong yields
+ * OSStatus -50 (kAudioSession/paramErr) on `setCategory`, which then leaves the
+ * session in a half-configured state and makes a later AudioQueueStart fail
+ * with 'what' (kAudioSessionUnspecifiedError):
+ *
+ * 1. `DefaultToSpeaker` forces output to the built-in speaker, while
+ * `AllowBluetoothA2DP` / `AllowAirPlay` route output to external devices.
+ * Combining them is contradictory and is rejected with -50, so those
+ * output-routing options are NEVER mixed with a recording configuration.
+ * A2DP/AirPlay devices are output-only and can't supply a mic input
+ * anyway β Bluetooth input uses the HFP route instead.
+ * 2. `MixWithOthers` lets us coexist with other audio packages (expo-av,
+ * etc.) but can prevent us from acquiring the input on some routes. We try
+ * it first to be a good citizen, then degrade to exclusive access so
+ * recording still starts reliably.
+ *
+ * VoiceChat mode (which engages Apple's hardware AEC / VPIO) is used only when
+ * echo cancellation is required and only on a device β VPIO is unavailable on
+ * the Simulator and requesting it there guarantees a -50.
+ */
+- (BOOL)configureAudioSessionForRecording:(BOOL)needsMicrophone
+ error:(NSError **)outError {
+ DGLogDebug(@"[Deepgram] configureAudioSession: begin (mic=%@)",
+ needsMicrophone ? @"YES" : @"NO");
+
+ const AVAudioSessionCategoryOptions mixOption =
+ AVAudioSessionCategoryOptionMixWithOthers;
+
+ // Build an ordered list of attempts, best first.
+ NSMutableArray *attempts = [NSMutableArray array];
+
+ if (needsMicrophone) {
+ BOOL needsAEC = self.engineCaptureActive || self.voiceProcessingRequested ||
+ self.isPlaying;
+
+ // Input-safe options for PlayAndRecord. DefaultToSpeaker + HFP are valid
+ // here; A2DP/AirPlay are intentionally excluded (see rule 1 above).
+ AVAudioSessionCategoryOptions recordOptions =
+ AVAudioSessionCategoryOptionDefaultToSpeaker | [self bluetoothHFPOption];
+
+#if !TARGET_OS_SIMULATOR
+ // Preferred: hardware AEC via VoiceChat (device only). VoiceChat mode
+ // manages the route itself, so DefaultToSpeaker / HFP must NOT be combined
+ // with it β doing so is rejected with -50 on some iOS versions. Only
+ // MixWithOthers is safe to layer on top.
+ if (needsAEC) {
+ [attempts addObject:@{
+ @"category" : AVAudioSessionCategoryPlayAndRecord,
+ @"mode" : AVAudioSessionModeVoiceChat,
+ @"options" : @(mixOption),
+ }];
+ [attempts addObject:@{
+ @"category" : AVAudioSessionCategoryPlayAndRecord,
+ @"mode" : AVAudioSessionModeVoiceChat,
+ @"options" : @(0),
+ }];
+ }
+#endif
+
+ // PlayAndRecord without VoiceChat (no hardware AEC). Try with MixWithOthers
+ // first to coexist with other packages, then exclusively.
+ [attempts addObject:@{
+ @"category" : AVAudioSessionCategoryPlayAndRecord,
+ @"mode" : AVAudioSessionModeDefault,
+ @"options" : @(recordOptions | mixOption),
+ }];
+ [attempts addObject:@{
+ @"category" : AVAudioSessionCategoryPlayAndRecord,
+ @"mode" : AVAudioSessionModeDefault,
+ @"options" : @(recordOptions),
+ }];
+
+ // Last resort: bare PlayAndRecord with no options. Always valid.
+ [attempts addObject:@{
+ @"category" : AVAudioSessionCategoryPlayAndRecord,
+ @"mode" : AVAudioSessionModeDefault,
+ @"options" : @(0),
+ }];
+ } else {
+ // Playback already supports AirPlay and A2DP routes; the explicit route
+ // options are for PlayAndRecord and can be rejected with OSStatus -50.
+ AVAudioSessionCategoryOptions playbackOptions = mixOption;
+ [attempts addObject:@{
+ @"category" : AVAudioSessionCategoryPlayback,
+ @"mode" : AVAudioSessionModeDefault,
+ @"options" : @(playbackOptions),
+ }];
+
+ // Last resort: bare Playback with no options.
+ [attempts addObject:@{
+ @"category" : AVAudioSessionCategoryPlayback,
+ @"mode" : AVAudioSessionModeDefault,
+ @"options" : @(0),
+ }];
+ }
+
+ NSError *lastError = nil;
+ NSUInteger attemptIndex = 0;
+ for (NSDictionary *attempt in attempts) {
+ attemptIndex++;
+ NSError *attemptError = nil;
+ BOOL success = [self
+ applyAudioSessionCategory:attempt[@"category"]
+ mode:attempt[@"mode"]
+ options:(AVAudioSessionCategoryOptions)
+ [attempt[@"options"] unsignedIntegerValue]
+ error:&attemptError];
+ if (success) {
+ if (attemptIndex > 1) {
+ DGLogDebug(@"[Deepgram] configureAudioSession: succeeded on fallback "
+ @"attempt %lu/%lu",
+ (unsigned long)attemptIndex, (unsigned long)attempts.count);
+ }
+ self.audioSessionConfigured = YES;
+ DGLogDebug(@"[Deepgram] configureAudioSession: success");
+ return YES;
+ }
+ lastError = attemptError;
+ DGLogDebug(@"[Deepgram] configureAudioSession: attempt %lu/%lu failed, "
+ @"trying next configuration",
+ (unsigned long)attemptIndex, (unsigned long)attempts.count);
+ }
+
+ DGLogError(@"[Deepgram] configureAudioSession: all %lu configurations failed; "
+ @"last error=%@",
+ (unsigned long)attempts.count,
+ lastError.localizedDescription ?: lastError);
+ if (outError) {
+ *outError = lastError;
+ }
+ self.audioSessionConfigured = NO;
+ return NO;
+}
+
+- (void)maybeDeactivateAudioSession {
+ DGLogDebug(@"[Deepgram] maybeDeactivateAudioSession: running=%@ playing=%@ "
+ @"engineCapture=%@",
+ _recordState.isRunning ? @"YES" : @"NO",
+ self.isPlaying ? @"YES" : @"NO",
+ self.engineCaptureActive ? @"YES" : @"NO");
+ if (!_recordState.isRunning && !self.isPlaying && !self.engineCaptureActive) {
DGLogDebug(@"[Deepgram] maybeDeactivateAudioSession: deactivating");
[self deactivateAudioSession];
}
}
-- (void)handleAudioRouteChange:(NSNotification *)note
-{
+- (void)handleAudioRouteChange:(NSNotification *)note {
DGLogDebug(@"[Deepgram] handleAudioRouteChange: %@", note.userInfo);
NSNumber *reasonValue = note.userInfo[AVAudioSessionRouteChangeReasonKey];
- AVAudioSessionRouteChangeReason reason = reasonValue
- ? (AVAudioSessionRouteChangeReason)
- reasonValue.unsignedIntegerValue
- : AVAudioSessionRouteChangeReasonUnknown;
+ AVAudioSessionRouteChangeReason reason =
+ reasonValue
+ ? (AVAudioSessionRouteChangeReason)reasonValue.unsignedIntegerValue
+ : AVAudioSessionRouteChangeReasonUnknown;
+
+ // Headphones / Bluetooth headset unplugged β pause playback so we don't
+ // surprise the user by suddenly blasting through the loud speaker.
+ // (System would route audio to the speaker automatically; that's the point
+ // of this notification.) Matches expo-audio behavior and Apple's guidance.
+ if (reason == AVAudioSessionRouteChangeReasonOldDeviceUnavailable) {
+ DGLogDebug(@"[Deepgram] handleAudioRouteChange: old device unavailable β "
+ @"pausing playback");
+ if (self.isPlaying && self.playerNode) {
+ [self.playerNode pause];
+ }
+ }
BOOL causedByCategory =
reason == AVAudioSessionRouteChangeReasonCategoryChange ||
@@ -351,7 +553,8 @@ - (void)handleAudioRouteChange:(NSNotification *)note
}
if (causedByCategory) {
- DGLogDebug(@"[Deepgram] handleAudioRouteChange: category change detected, keeping session");
+ DGLogDebug(@"[Deepgram] handleAudioRouteChange: category change detected, "
+ @"keeping session");
return;
}
@@ -359,16 +562,55 @@ - (void)handleAudioRouteChange:(NSNotification *)note
[self activateAudioSession:NULL];
}
-- (void)handleAudioInterruption:(NSNotification *)note
-{
+// AVAudioSession can post this when mediaserverd restarts (e.g. after a
+// rare system audio HAL hiccup). Once it fires, every AudioQueue /
+// AVAudioEngine instance becomes a zombie. Apple's documented fix is to throw
+// everything away and rebuild on demand. Match expo-audio's approach.
+- (void)handleMediaServicesReset:(NSNotification *)note {
+ DGLogDebug(@"[Deepgram] handleMediaServicesReset: tearing down audio stack");
+ self.audioSessionConfigured = NO;
+
+ // Recording queue must be torn down β it's a zombie after a media services
+ // reset.
+ if (_recordState.isRunning || _recordState.queue) {
+ [self cleanupRecordingQueue];
+ }
+
+ // Engine-based capture path also dies with mediaserverd; force a full
+ // teardown so the next startRecording rebuilds VPIO from scratch.
+ self.engineCaptureActive = NO;
+ self.captureConverter = nil;
+ self.captureOutputFormat = nil;
+
+ // AVAudioEngine + player node are no longer valid. Stop and discard so the
+ // next setup call rebuilds from scratch.
+ @try {
+ if (self.playerNode) {
+ [self.playerNode stop];
+ }
+ if (self.audioEngine) {
+ [self.audioEngine stop];
+ }
+ } @catch (NSException *e) {
+ DGLogError(@"[Deepgram] handleMediaServicesReset: teardown exception %@",
+ e);
+ }
+ self.playerNode = nil;
+ self.audioEngine = nil;
+ self.isPlaying = NO;
+}
+
+- (void)handleAudioInterruption:(NSNotification *)note {
DGLogDebug(@"[Deepgram] handleAudioInterruption: %@", note.userInfo);
NSNumber *typeValue = note.userInfo[AVAudioSessionInterruptionTypeKey];
- AVAudioSessionInterruptionType type = (AVAudioSessionInterruptionType)typeValue.unsignedIntegerValue;
+ AVAudioSessionInterruptionType type =
+ (AVAudioSessionInterruptionType)typeValue.unsignedIntegerValue;
if (type == AVAudioSessionInterruptionTypeBegan) {
DGLogDebug(@"[Deepgram] handleAudioInterruption: interruption began");
if (_recordState.isRunning && _recordState.queue) {
- DGLogDebug(@"[Deepgram] handleAudioInterruption: pausing recording queue");
+ DGLogDebug(
+ @"[Deepgram] handleAudioInterruption: pausing recording queue");
AudioQueuePause(_recordState.queue);
}
if (self.isPlaying && self.playerNode) {
@@ -378,13 +620,15 @@ - (void)handleAudioInterruption:(NSNotification *)note
} else if (type == AVAudioSessionInterruptionTypeEnded) {
DGLogDebug(@"[Deepgram] handleAudioInterruption: interruption ended");
NSNumber *optionValue = note.userInfo[AVAudioSessionInterruptionOptionKey];
- AVAudioSessionInterruptionOptions options = (AVAudioSessionInterruptionOptions)optionValue.unsignedIntegerValue;
+ AVAudioSessionInterruptionOptions options =
+ (AVAudioSessionInterruptionOptions)optionValue.unsignedIntegerValue;
if (options & AVAudioSessionInterruptionOptionShouldResume) {
DGLogDebug(@"[Deepgram] handleAudioInterruption: should resume");
[self activateAudioSession:nil];
-
+
if (_recordState.isRunning && _recordState.queue) {
- DGLogDebug(@"[Deepgram] handleAudioInterruption: resuming recording queue");
+ DGLogDebug(
+ @"[Deepgram] handleAudioInterruption: resuming recording queue");
AudioQueueStart(_recordState.queue, NULL);
}
if (self.isPlaying && self.playerNode) {
@@ -396,27 +640,23 @@ - (void)handleAudioInterruption:(NSNotification *)note
}
#if TARGET_OS_IOS
-- (void)handleAppDidBecomeActive:(NSNotification *)note
-{
+- (void)handleAppDidBecomeActive:(NSNotification *)note {
DGLogDebug(@"[Deepgram] handleAppDidBecomeActive: %@", note.userInfo);
self.appIsActive = YES;
- if (_recordState.isRunning || self.isPlaying) {
+ if (_recordState.isRunning || self.isPlaying || self.engineCaptureActive) {
DGLogDebug(@"[Deepgram] handleAppDidBecomeActive: reactivating session");
[self activateAudioSession:NULL];
}
}
-- (void)handleAppDidEnterBackground:(NSNotification *)note
-{
+- (void)handleAppDidEnterBackground:(NSNotification *)note {
DGLogDebug(@"[Deepgram] handleAppDidEnterBackground: %@", note.userInfo);
self.appIsActive = NO;
- [self maybeDeactivateAudioSession];
}
#endif
-- (void)emitPCMChunk:(NSData *)chunk sampleRate:(int)sampleRate
-{
+- (void)emitPCMChunk:(NSData *)chunk sampleRate:(int)sampleRate {
if (!chunk || chunk.length == 0) {
DGLogDebug(@"[Deepgram] emitPCMChunk: empty chunk, skipping");
return;
@@ -433,42 +673,47 @@ - (void)emitPCMChunk:(NSData *)chunk sampleRate:(int)sampleRate
if (!weakSelf.hasListeners) {
DGLogDebug(@"[Deepgram] emitPCMChunk: no listeners, dropping %lu bytes",
- (unsigned long)chunkCopy.length);
+ (unsigned long)chunkCopy.length);
return;
}
if (!weakSelf.bridge || !weakSelf.callableJSModules) {
- DGLogDebug(@"[Deepgram] Skipping DeepgramAudioPCM event (bridge not ready)");
+ DGLogDebug(
+ @"[Deepgram] Skipping DeepgramAudioPCM event (bridge not ready)");
return;
}
DGLogDebug(@"[Deepgram] emitPCMChunk: sending %lu bytes sampleRate=%d",
- (unsigned long)chunkCopy.length,
- sampleRate);
+ (unsigned long)chunkCopy.length, sampleRate);
NSString *b64 = [chunkCopy base64EncodedStringWithOptions:0];
[weakSelf sendEventWithName:@"DeepgramAudioPCM"
- body:@{ @"b64": b64, @"sampleRate": @(sampleRate) }];
+ body:@{
+ @"b64" : b64,
+ @"sampleRate" : @(sampleRate)
+ }];
});
}
-- (void)appendPCMDataAndEmitIfNeeded:(NSData *)pcmData
-{
+- (void)appendPCMDataAndEmitIfNeeded:(NSData *)pcmData {
if (!pcmData || pcmData.length == 0) {
DGLogDebug(@"[Deepgram] appendPCMDataAndEmitIfNeeded: empty PCM, skipping");
return;
}
if (!self.pendingPCMBuffer) {
- DGLogDebug(@"[Deepgram] appendPCMDataAndEmitIfNeeded: allocate pending buffer");
+ DGLogDebug(
+ @"[Deepgram] appendPCMDataAndEmitIfNeeded: allocate pending buffer");
self.pendingPCMBuffer = [[NSMutableData alloc] init];
}
- DGLogDebug(@"[Deepgram] appendPCMDataAndEmitIfNeeded: append %lu bytes (pending=%lu)",
- (unsigned long)pcmData.length,
- (unsigned long)self.pendingPCMBuffer.length);
+ DGLogDebug(@"[Deepgram] appendPCMDataAndEmitIfNeeded: append %lu bytes "
+ @"(pending=%lu)",
+ (unsigned long)pcmData.length,
+ (unsigned long)self.pendingPCMBuffer.length);
[self.pendingPCMBuffer appendData:pcmData];
- NSUInteger chunkSize = self.chunkSizeBytes > 0 ? self.chunkSizeBytes : pcmData.length;
+ NSUInteger chunkSize =
+ self.chunkSizeBytes > 0 ? self.chunkSizeBytes : pcmData.length;
while (self.pendingPCMBuffer.length >= chunkSize) {
NSData *chunk =
@@ -476,15 +721,15 @@ - (void)appendPCMDataAndEmitIfNeeded:(NSData *)pcmData
[self.pendingPCMBuffer replaceBytesInRange:NSMakeRange(0, chunkSize)
withBytes:NULL
length:0];
- DGLogDebug(@"[Deepgram] appendPCMDataAndEmitIfNeeded: emitting chunk %lu bytes remaining=%lu",
- (unsigned long)chunk.length,
- (unsigned long)self.pendingPCMBuffer.length);
+ DGLogDebug(@"[Deepgram] appendPCMDataAndEmitIfNeeded: emitting chunk %lu "
+ @"bytes remaining=%lu",
+ (unsigned long)chunk.length,
+ (unsigned long)self.pendingPCMBuffer.length);
[self emitPCMChunk:chunk sampleRate:self.currentSampleRate];
}
}
-- (void)flushPendingPCM
-{
+- (void)flushPendingPCM {
if (self.pendingPCMBuffer.length == 0) {
DGLogDebug(@"[Deepgram] flushPendingPCM: nothing to flush");
return;
@@ -493,13 +738,13 @@ - (void)flushPendingPCM
NSData *remaining = [self.pendingPCMBuffer copy];
[self.pendingPCMBuffer setLength:0];
DGLogDebug(@"[Deepgram] flushPendingPCM: flushing %lu bytes",
- (unsigned long)remaining.length);
+ (unsigned long)remaining.length);
[self emitPCMChunk:remaining sampleRate:self.currentSampleRate];
}
-- (void)cleanupRecordingQueue
-{
+- (void)cleanupRecordingQueue {
DGLogDebug(@"[Deepgram] cleanupRecordingQueue: begin");
+ self.audioQueueCaptureRequested = NO;
_recordState.isRunning = false;
if (_recordState.queue) {
@@ -526,24 +771,297 @@ - (void)cleanupRecordingQueue
/* ================================================================== */
/* 1. MICROPHONE CAPTURE (16 kHz PCM16 emission) */
/* ================================================================== */
-RCT_EXPORT_METHOD(startRecording
- :(RCTPromiseResolveBlock)resolve
- rejecter:(RCTPromiseRejectBlock)reject)
-{
+
+/**
+ * Engine-based microphone capture path used when the JS side opts in to
+ * hardware voice processing (Voice Agent / duplex). This routes through
+ * `AVAudioEngine.inputNode` with `setVoiceProcessingEnabled:YES` on both
+ * input and output nodes β the only Apple-supported way to actually engage
+ * the VPIO Audio Unit's hardware echo cancellation on iOS. The legacy
+ * AudioQueue path is preserved for STT-only usage where AEC is undesirable.
+ */
+- (BOOL)startEngineCaptureAndReturnError:(NSError **)outError {
+ self.currentSampleRate = 16000;
+
+ if (!self.audioEngine) {
+ NSError *engineError = nil;
+ if (![self setupAudioEngineWithSampleRate:self.currentSampleRate
+ channels:1
+ enableVoiceProcessing:YES
+ error:&engineError]) {
+ if (outError)
+ *outError = engineError;
+ return NO;
+ }
+ } else {
+#if TARGET_IPHONE_SIMULATOR
+ // VPIO is unsupported on the simulator and toggling VP corrupts the
+ // input node's format (sampleRate becomes 0), which would later fail
+ // the IsFormatSampleRateAndChannelCountValid check. Skip entirely.
+ DGLogWarn(@"[Deepgram] NOTE: Voice Processing I/O (Echo Cancellation) is "
+ @"NOT supported on the iOS Simulator. Audio output may be picked "
+ @"up by the microphone. Please test on a physical device for "
+ @"proper AEC behavior.");
+#else
+ @try {
+ NSError *vpError = nil;
+ if (self.audioEngine.inputNode &&
+ ![self.audioEngine.inputNode setVoiceProcessingEnabled:YES
+ error:&vpError]) {
+ DGLogWarn(@"[Deepgram] inputNode VP enable failed: %@", vpError);
+ }
+ if (self.audioEngine.outputNode &&
+ ![self.audioEngine.outputNode setVoiceProcessingEnabled:YES
+ error:&vpError]) {
+ DGLogWarn(@"[Deepgram] outputNode VP enable failed: %@", vpError);
+ }
+ } @catch (NSException *e) {
+ DGLogWarn(@"[Deepgram] VP enable threw: %@", e);
+ }
+#endif
+ }
+
+ AVAudioInputNode *inputNode = self.audioEngine.inputNode;
+ if (!inputNode) {
+ if (outError) {
+ *outError = [NSError
+ errorWithDomain:@"DeepgramAudioEngine"
+ code:-2
+ userInfo:@{
+ NSLocalizedDescriptionKey : @"No input node available"
+ }];
+ }
+ return NO;
+ }
+
+ AVAudioFormat *hwFormat = [inputNode inputFormatForBus:0];
+ if (!hwFormat || hwFormat.sampleRate <= 0) {
+ if (outError) {
+ *outError =
+ [NSError errorWithDomain:@"DeepgramAudioEngine"
+ code:-3
+ userInfo:@{
+ NSLocalizedDescriptionKey : @"Invalid input format"
+ }];
+ }
+ return NO;
+ }
+
+ AVAudioFormat *outputFormat =
+ [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatInt16
+ sampleRate:self.currentSampleRate
+ channels:1
+ interleaved:YES];
+ self.captureOutputFormat = outputFormat;
+ self.captureConverter =
+ [[AVAudioConverter alloc] initFromFormat:hwFormat toFormat:outputFormat];
+ if (!self.captureConverter) {
+ if (outError) {
+ *outError = [NSError errorWithDomain:@"DeepgramAudioEngine"
+ code:-4
+ userInfo:@{
+ NSLocalizedDescriptionKey :
+ @"Failed to build AVAudioConverter"
+ }];
+ }
+ return NO;
+ }
+
+ AVAudioFrameCount tapFrames = (AVAudioFrameCount)MAX(
+ 1024, (NSUInteger)round(hwFormat.sampleRate * 0.2));
+
+ __weak __typeof(self) weakSelf = self;
+ @try {
+ [inputNode removeTapOnBus:0];
+ } @catch (__unused NSException *e) {
+ // No prior tap β fine.
+ }
+
+ @
+ try {
+ [inputNode
+ installTapOnBus:0
+ bufferSize:tapFrames
+ format:hwFormat
+ block:^(AVAudioPCMBuffer *_Nonnull inBuf,
+ __unused AVAudioTime *_Nonnull when) {
+ __strong __typeof(weakSelf) strongSelf = weakSelf;
+ if (!strongSelf || !strongSelf.engineCaptureActive)
+ return;
+ AVAudioConverter *converter = strongSelf.captureConverter;
+ AVAudioFormat *outFmt = strongSelf.captureOutputFormat;
+ if (!converter || !outFmt || inBuf.frameLength == 0)
+ return;
+
+ AVAudioFrameCount outCapacity =
+ (AVAudioFrameCount)ceil((double)inBuf.frameLength *
+ outFmt.sampleRate /
+ inBuf.format.sampleRate) +
+ 16;
+ AVAudioPCMBuffer *outBuf = [[AVAudioPCMBuffer alloc]
+ initWithPCMFormat:outFmt
+ frameCapacity:outCapacity];
+ if (!outBuf)
+ return;
+
+ __block BOOL provided = NO;
+ NSError *cvtError = nil;
+ AVAudioConverterInputBlock inputBlock =
+ ^AVAudioBuffer *_Nullable(
+ __unused AVAudioPacketCount inNumPackets,
+ AVAudioConverterInputStatus *_Nonnull outStatus) {
+ if (provided) {
+ *outStatus = AVAudioConverterInputStatus_NoDataNow;
+ return nil;
+ }
+ provided = YES;
+ *outStatus = AVAudioConverterInputStatus_HaveData;
+ return inBuf;
+ };
+
+ AVAudioConverterOutputStatus status =
+ [converter convertToBuffer:outBuf
+ error:&cvtError
+ withInputFromBlock:inputBlock];
+ if (status == AVAudioConverterOutputStatus_Error ||
+ cvtError) {
+ DGLogWarn(@"[Deepgram] capture convert failed: %@",
+ cvtError);
+ return;
+ }
+ if (outBuf.frameLength == 0 || !outBuf.int16ChannelData)
+ return;
+
+ NSUInteger byteCount = (NSUInteger)outBuf.frameLength * 2;
+ NSData *pcm =
+ [NSData dataWithBytes:outBuf.int16ChannelData[0]
+ length:byteCount];
+ [strongSelf appendPCMDataAndEmitIfNeeded:pcm];
+ }];
+ } @catch (NSException *e) {
+ DGLogError(@"[Deepgram] installTapOnBus exception: %@", e);
+ if (outError) {
+ *outError = [NSError
+ errorWithDomain:@"DeepgramAudioEngine"
+ code:-5
+ userInfo:@{
+ NSLocalizedDescriptionKey : e.reason ?: @"Tap install failed"
+ }];
+ }
+ return NO;
+ }
+
+ self.chunkSizeBytes =
+ (NSUInteger)MAX(1, (int)round(self.currentSampleRate * 2 * 0.2));
+ self.engineCaptureActive = YES;
+
+ if (!self.audioEngine.isRunning) {
+ NSError *startError = nil;
+ if (![self.audioEngine startAndReturnError:&startError]) {
+ DGLogError(@"[Deepgram] engine start failed: %@", startError);
+ @try {
+ [inputNode removeTapOnBus:0];
+ } @catch (__unused NSException *e) {
+ }
+ self.engineCaptureActive = NO;
+ if (outError)
+ *outError = startError;
+ return NO;
+ }
+ }
+ return YES;
+}
+
+- (void)stopEngineCapture {
+ if (!self.engineCaptureActive && !self.captureConverter)
+ return;
+ DGLogDebug(@"[Deepgram] stopEngineCapture");
+ self.engineCaptureActive = NO;
+
+ @try {
+ [self.audioEngine.inputNode removeTapOnBus:0];
+ } @catch (NSException *e) {
+ DGLogWarn(@"[Deepgram] removeTapOnBus exception: %@", e);
+ }
+
+#if !TARGET_IPHONE_SIMULATOR
+ @
+ try {
+ NSError *vpError = nil;
+ if (self.audioEngine.inputNode) {
+ [self.audioEngine.inputNode setVoiceProcessingEnabled:NO error:&vpError];
+ }
+ } @catch (__unused NSException *e) {
+ // best-effort
+ }
+#endif
+
+ self.captureConverter = nil;
+ self.captureOutputFormat = nil;
+
+ if (!self.isPlaying && self.audioEngine.isRunning) {
+ @try {
+ [self.audioEngine stop];
+ } @catch (__unused NSException *e) {
+ }
+ }
+}
+
+RCT_EXPORT_METHOD(startRecording : (NSDictionary *)options resolver : (
+ RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) {
@try {
DGLogDebug(@"[Deepgram] startRecording: begin");
+
+ BOOL enableVoiceProcessing = NO;
+ if ([options isKindOfClass:[NSDictionary class]]) {
+ id raw = options[@"enableVoiceProcessing"];
+ if ([raw isKindOfClass:[NSNumber class]]) {
+ enableVoiceProcessing = [(NSNumber *)raw boolValue];
+ }
+ }
+ self.voiceProcessingRequested = enableVoiceProcessing;
+ self.audioQueueCaptureRequested = !enableVoiceProcessing;
+
self.currentSampleRate = 16000;
- DGLogDebug(@"[Deepgram] startRecording: targetSampleRate=%d", self.currentSampleRate);
+ DGLogDebug(@"[Deepgram] startRecording: targetSampleRate=%d vp=%@",
+ self.currentSampleRate, enableVoiceProcessing ? @"YES" : @"NO");
NSError *sessionError = nil;
if (![self activateAudioSession:&sessionError]) {
- NSString *message = sessionError.localizedDescription ?: @"Failed to activate audio session";
+ NSString *message = sessionError.localizedDescription
+ ?: @"Failed to activate audio session";
DGLogError(@"[Deepgram] startRecording: activation failed %@", message);
- if (reject) reject(@"record_start_error", message, sessionError);
+ self.voiceProcessingRequested = NO;
+ self.audioQueueCaptureRequested = NO;
+ DGRejectPromise(reject, @"record_start_error", message, sessionError);
+ return;
+ }
+
+ if (enableVoiceProcessing) {
+ if (_recordState.isRunning) {
+ [self cleanupRecordingQueue];
+ }
+ [self stopEngineCapture];
+ self.pendingPCMBuffer = [[NSMutableData alloc] init];
+
+ NSError *engineError = nil;
+ if (![self startEngineCaptureAndReturnError:&engineError]) {
+ NSString *message = engineError.localizedDescription
+ ?: @"Failed to start engine capture";
+ DGLogError(@"[Deepgram] startRecording: %@", message);
+ self.voiceProcessingRequested = NO;
+ [self maybeDeactivateAudioSession];
+ DGRejectPromise(reject, @"record_start_error", message, engineError);
+ return;
+ }
+ DGLogDebug(@"[Deepgram] startRecording: engine capture started");
+ if (resolve)
+ resolve(nil);
return;
}
if (_recordState.isRunning) {
- DGLogDebug(@"[Deepgram] startRecording: record state already running, cleaning up");
+ DGLogDebug(@"[Deepgram] startRecording: record state already running, "
+ @"cleaning up");
[self cleanupRecordingQueue];
}
@@ -572,28 +1090,24 @@ - (void)cleanupRecordingQueue
const double targetSecondsPerChunk = 0.2; // β200 ms of audio
UInt32 defaultChunkSize =
(UInt32)MAX(1, (int)round(_recordState.dataFormat.mSampleRate *
- _recordState.dataFormat.mBytesPerFrame *
- targetSecondsPerChunk));
+ _recordState.dataFormat.mBytesPerFrame *
+ targetSecondsPerChunk));
self.chunkSizeBytes = defaultChunkSize;
_recordState.bufferByteSize = (UInt32)self.chunkSizeBytes;
DGLogDebug(@"[Deepgram] startRecording: bufferByteSize=%u (chunkSize=%lu)",
- (unsigned int)_recordState.bufferByteSize,
- (unsigned long)self.chunkSizeBytes);
+ (unsigned int)_recordState.bufferByteSize,
+ (unsigned long)self.chunkSizeBytes);
- OSStatus status = AudioQueueNewInput(&_recordState.dataFormat,
- DGHandleInputBuffer,
- &_recordState,
- NULL,
- NULL,
- 0,
- &_recordState.queue);
+ OSStatus status =
+ AudioQueueNewInput(&_recordState.dataFormat, DGHandleInputBuffer,
+ &_recordState, NULL, NULL, 0, &_recordState.queue);
if (status != noErr) {
- NSString *message =
- [NSString stringWithFormat:@"AudioQueueNewInput failed: %d", (int)status];
+ NSError *error = DGOSStatusError(@"AudioQueueNewInput", status);
+ NSString *message = error.localizedDescription;
DGLogError(@"[Deepgram] startRecording: %@", message);
[self cleanupRecordingQueue];
- if (reject) reject(@"record_start_error", message, nil);
+ DGRejectPromise(reject, @"record_start_error", message, error);
return;
}
@@ -601,30 +1115,31 @@ - (void)cleanupRecordingQueue
UInt32 actualFormatSize = sizeof(actualFormat);
status = AudioQueueGetProperty(_recordState.queue,
kAudioQueueProperty_StreamDescription,
- &actualFormat,
- &actualFormatSize);
+ &actualFormat, &actualFormatSize);
if (status == noErr) {
int resolvedSampleRate = (int)llround(actualFormat.mSampleRate);
DGLogDebug(@"[Deepgram] startRecording: actual sampleRate=%f (%d)",
- actualFormat.mSampleRate,
- resolvedSampleRate);
+ actualFormat.mSampleRate, resolvedSampleRate);
if (resolvedSampleRate > 0) {
self.currentSampleRate = resolvedSampleRate;
}
UInt32 bytesPerFrame = actualFormat.mBytesPerFrame
- ? actualFormat.mBytesPerFrame
- : _recordState.dataFormat.mBytesPerFrame;
+ ? actualFormat.mBytesPerFrame
+ : _recordState.dataFormat.mBytesPerFrame;
UInt32 adaptiveChunk =
- (UInt32)MAX(1, (int)round(actualFormat.mSampleRate * bytesPerFrame * targetSecondsPerChunk));
+ (UInt32)MAX(1, (int)round(actualFormat.mSampleRate * bytesPerFrame *
+ targetSecondsPerChunk));
if (adaptiveChunk > 0 && adaptiveChunk != self.chunkSizeBytes) {
self.chunkSizeBytes = adaptiveChunk;
_recordState.bufferByteSize = adaptiveChunk;
DGLogDebug(@"[Deepgram] startRecording: adjusted chunkSize=%lu",
- (unsigned long)self.chunkSizeBytes);
+ (unsigned long)self.chunkSizeBytes);
}
} else {
- DGLogError(@"[Deepgram] startRecording: failed to read stream description %d", (int)status);
+ DGLogError(
+ @"[Deepgram] startRecording: failed to read stream description %d",
+ (int)status);
}
for (int i = 0; i < DGNumberBuffers; i++) {
@@ -632,105 +1147,109 @@ - (void)cleanupRecordingQueue
_recordState.bufferByteSize,
&_recordState.buffers[i]);
if (status != noErr) {
- NSString *message = [NSString
- stringWithFormat:@"AudioQueueAllocateBuffer failed: %d", (int)status];
+ NSError *error = DGOSStatusError(@"AudioQueueAllocateBuffer", status);
+ NSString *message = error.localizedDescription;
DGLogError(@"[Deepgram] startRecording: %@", message);
[self cleanupRecordingQueue];
- if (reject) reject(@"record_start_error", message, nil);
+ DGRejectPromise(reject, @"record_start_error", message, error);
return;
}
status = AudioQueueEnqueueBuffer(_recordState.queue,
- _recordState.buffers[i],
- 0,
- NULL);
+ _recordState.buffers[i], 0, NULL);
if (status != noErr) {
- NSString *message = [NSString
- stringWithFormat:@"AudioQueueEnqueueBuffer failed: %d", (int)status];
+ NSError *error = DGOSStatusError(@"AudioQueueEnqueueBuffer", status);
+ NSString *message = error.localizedDescription;
DGLogError(@"[Deepgram] startRecording: %@", message);
[self cleanupRecordingQueue];
- if (reject) reject(@"record_start_error", message, nil);
+ DGRejectPromise(reject, @"record_start_error", message, error);
return;
}
}
status = AudioQueueStart(_recordState.queue, NULL);
if (status != noErr) {
- NSString *message =
- [NSString stringWithFormat:@"AudioQueueStart failed: %d", (int)status];
+ NSError *error = DGOSStatusError(@"AudioQueueStart", status);
+ NSString *message = error.localizedDescription;
DGLogError(@"[Deepgram] startRecording: %@", message);
[self cleanupRecordingQueue];
- if (reject) reject(@"record_start_error", message, nil);
+ DGRejectPromise(reject, @"record_start_error", message, error);
return;
}
DGLogDebug(@"[Deepgram] startRecording: success");
- if (resolve) resolve(nil);
- }
- @catch (NSException *e) {
+ self.audioQueueCaptureRequested = NO;
+ if (resolve)
+ resolve(nil);
+ } @catch (NSException *e) {
DGLogError(@"[Deepgram] startRecording: exception %@", e);
[self cleanupRecordingQueue];
- if (reject) reject(@"record_start_error", e.reason, nil);
+ NSString *message = e.reason ?: @"Deepgram native exception";
+ NSError *error = DGNativeError(@"DeepgramNativeException", 0, message);
+ DGRejectPromise(reject, @"record_start_error", message, error);
}
}
-RCT_EXPORT_METHOD(stopRecording
- :(RCTPromiseResolveBlock)resolve
- rejecter:(RCTPromiseRejectBlock)reject)
-{
+RCT_EXPORT_METHOD(stopRecording : (RCTPromiseResolveBlock)
+ resolve rejecter : (RCTPromiseRejectBlock)reject) {
@try {
DGLogDebug(@"[Deepgram] stopRecording: begin");
+ [self stopEngineCapture];
[self cleanupRecordingQueue];
[self flushPendingPCM];
self.pendingPCMBuffer = nil;
+ self.voiceProcessingRequested = NO;
+ [self maybeDeactivateAudioSession];
DGLogDebug(@"[Deepgram] stopRecording: finished");
- if (resolve) resolve(nil);
- }
- @catch (NSException *e) {
+ if (resolve)
+ resolve(nil);
+ } @catch (NSException *e) {
DGLogError(@"[Deepgram] stopRecording: exception %@", e);
- if (reject) reject(@"record_stop_error", e.reason, nil);
+ if (reject)
+ reject(@"record_stop_error", e.reason, nil);
}
}
-RCT_EXPORT_METHOD(startAudio
- :(RCTPromiseResolveBlock)resolve
- rejecter:(RCTPromiseRejectBlock)reject)
-{
+RCT_EXPORT_METHOD(startAudio : (RCTPromiseResolveBlock)
+ resolve rejecter : (RCTPromiseRejectBlock)reject) {
@try {
DGLogDebug(@"[Deepgram] startAudio: begin");
NSError *sessionError = nil;
if (![self activateAudioSession:&sessionError]) {
- NSString *message = sessionError.localizedDescription ?: @"Failed to activate audio session";
+ NSString *message = sessionError.localizedDescription
+ ?: @"Failed to activate audio session";
DGLogError(@"[Deepgram] startAudio: activation failed %@", message);
- if (reject) reject(@"audio_start_error", message, sessionError);
+ if (reject)
+ reject(@"audio_start_error", message, sessionError);
return;
}
if (self.currentSampleRate <= 0) {
self.currentSampleRate = 16000;
- DGLogDebug(@"[Deepgram] startAudio: default sample rate applied %d", self.currentSampleRate);
+ DGLogDebug(@"[Deepgram] startAudio: default sample rate applied %d",
+ self.currentSampleRate);
}
DGLogDebug(@"[Deepgram] startAudio: success");
- if (resolve) resolve(nil);
- }
- @catch (NSException *e) {
+ if (resolve)
+ resolve(nil);
+ } @catch (NSException *e) {
DGLogError(@"[Deepgram] startAudio: exception %@", e);
- if (reject) reject(@"audio_start_error", e.reason, nil);
+ if (reject)
+ reject(@"audio_start_error", e.reason, nil);
}
}
-RCT_EXPORT_METHOD(stopAudio
- :(RCTPromiseResolveBlock)resolve
- rejecter:(RCTPromiseRejectBlock)reject)
-{
+RCT_EXPORT_METHOD(stopAudio : (RCTPromiseResolveBlock)
+ resolve rejecter : (RCTPromiseRejectBlock)reject) {
@try {
DGLogDebug(@"[Deepgram] stopAudio: begin");
[self stopPlayer:nil rejecter:nil];
DGLogDebug(@"[Deepgram] stopAudio: success");
- if (resolve) resolve(nil);
- }
- @catch (NSException *e) {
+ if (resolve)
+ resolve(nil);
+ } @catch (NSException *e) {
DGLogError(@"[Deepgram] stopAudio: exception %@", e);
- if (reject) reject(@"audio_stop_error", e.reason, nil);
+ if (reject)
+ reject(@"audio_stop_error", e.reason, nil);
}
}
@@ -739,65 +1258,155 @@ - (void)cleanupRecordingQueue
/* ================================================================== */
/**
- * Setup AVAudioEngine with Voice Processing I/O for echo cancellation.
- * This enables hardware-level echo cancellation during simultaneous recording + playback.
+ * Setup AVAudioEngine for output. When `enableVoiceProcessing` is YES we also
+ * configure the input node for hardware echo cancellation (Voice Agent /
+ * duplex use case). When NO (pure TTS playback), we deliberately avoid
+ * touching `inputNode` so we don't request the microphone or interfere with
+ * other audio libraries.
*/
+- (void)stopAndDetachPlayerNode {
+ if (self.playerNode) {
+ _playbackGeneration.fetch_add(1);
+
+ @try {
+ [self.playerNode stop];
+ } @catch (NSException *e) {
+ DGLogWarn(@"[Deepgram] playerNode stop exception: %@", e);
+ }
+
+ if (self.audioEngine) {
+ @try {
+ [self.audioEngine detachNode:self.playerNode];
+ } @catch (NSException *e) {
+ DGLogWarn(@"[Deepgram] playerNode detach exception: %@", e);
+ }
+ }
+ }
+
+ self.playerNode = nil;
+ self.playbackFormat = nil;
+ self.isPlaying = NO;
+ _scheduledBufferCount = 0;
+}
+
+- (void)interruptPlayerPlayback {
+ if (self.playerNode) {
+ _playbackGeneration.fetch_add(1);
+
+ @try {
+ [self.playerNode stop];
+ } @catch (NSException *e) {
+ DGLogWarn(@"[Deepgram] playerNode interrupt exception: %@", e);
+ }
+ }
+
+ self.isPlaying = NO;
+ _scheduledBufferCount = 0;
+}
+
- (BOOL)setupAudioEngineWithSampleRate:(int)sampleRate
channels:(int)channels
+ enableVoiceProcessing:(BOOL)enableVoiceProcessing
error:(NSError **)outError {
+ BOOL reuseCaptureEngine = self.audioEngine && self.engineCaptureActive;
+
if (self.audioEngine && self.audioEngine.isRunning) {
[self.audioEngine stop];
}
-
- self.audioEngine = [[AVAudioEngine alloc] init];
+
+ [self stopAndDetachPlayerNode];
+
+ if (!reuseCaptureEngine) {
+ if (self.audioEngine) {
+ [self.audioEngine reset];
+ }
+ self.audioEngine = [[AVAudioEngine alloc] init];
+ }
+
+ if (!self.audioEngine) {
+ self.audioEngine = [[AVAudioEngine alloc] init];
+ }
+
self.playerNode = [[AVAudioPlayerNode alloc] init];
-
- self.playbackFormat = [[AVAudioFormat alloc]
- initWithCommonFormat:AVAudioPCMFormatInt16
- sampleRate:sampleRate
- channels:channels
- interleaved:YES];
-
+
+ self.playbackFormat =
+ [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatInt16
+ sampleRate:sampleRate
+ channels:channels
+ interleaved:YES];
+
if (!self.playbackFormat) {
if (outError) {
- *outError = [NSError errorWithDomain:@"DeepgramAudioEngine"
- code:-1
- userInfo:@{NSLocalizedDescriptionKey: @"Failed to create audio format"}];
+ *outError = [NSError
+ errorWithDomain:@"DeepgramAudioEngine"
+ code:-1
+ userInfo:@{
+ NSLocalizedDescriptionKey : @"Failed to create audio format"
+ }];
}
return NO;
}
-
+
[self.audioEngine attachNode:self.playerNode];
[self.audioEngine connect:self.playerNode
to:self.audioEngine.mainMixerNode
format:self.playbackFormat];
-
- @try {
- AVAudioInputNode *inputNode = self.audioEngine.inputNode;
- if (inputNode) {
- NSError *voiceProcessingError = nil;
- [inputNode setVoiceProcessingEnabled:YES error:&voiceProcessingError];
- }
- } @catch (NSException *exception) {
- // Continue - voice processing not critical for basic playback
- }
+ if (enableVoiceProcessing) {
#if TARGET_IPHONE_SIMULATOR
- DGLogWarn(@"[Deepgram] NOTE: Voice Processing I/O (Echo Cancellation) is NOT supported on the iOS Simulator. Audio output may be picked up by the microphone. Please test on a physical device for proper AEC behavior.");
+ // VPIO is not implemented on the simulator. Calling
+ // setVoiceProcessingEnabled:YES there raises an internal AVAEInternal
+ // exception about IsFormatSampleRateAndChannelCountValid because the
+ // simulator's input node cannot satisfy the VPIO format constraints.
+ // Skip VP entirely; AEC has to be tested on a physical device.
+ DGLogWarn(@"[Deepgram] NOTE: Voice Processing I/O (Echo Cancellation) is "
+ @"NOT supported on the iOS Simulator. Audio output may be picked "
+ @"up by the microphone. Please test on a physical device for "
+ @"proper AEC behavior.");
+#else
+ @try {
+ AVAudioInputNode *inputNode = self.audioEngine.inputNode;
+ AVAudioOutputNode *outputNode = self.audioEngine.outputNode;
+ // Apple's hardware AEC (VPIO Audio Unit) only engages when *both*
+ // input and output flow through a voice-processing-enabled audio
+ // unit. Enabling VP only on the input node is a no-op for capture
+ // unless rendering goes through the same unit, so we enable both.
+ if (inputNode) {
+ NSError *voiceProcessingError = nil;
+ if (![inputNode setVoiceProcessingEnabled:YES
+ error:&voiceProcessingError]) {
+ DGLogWarn(@"[Deepgram] inputNode VP enable failed: %@",
+ voiceProcessingError);
+ }
+ }
+ if (outputNode) {
+ NSError *voiceProcessingError = nil;
+ if (![outputNode setVoiceProcessingEnabled:YES
+ error:&voiceProcessingError]) {
+ DGLogWarn(@"[Deepgram] outputNode VP enable failed: %@",
+ voiceProcessingError);
+ }
+ }
+ } @catch (NSException *exception) {
+ // Continue - voice processing not critical for basic playback
+ DGLogWarn(@"[Deepgram] VP enable threw: %@", exception);
+ }
#endif
-
+ }
+
[self.audioEngine prepare];
NSError *startError = nil;
BOOL started = [self.audioEngine startAndReturnError:&startError];
-
+
if (!started) {
- DGLogError(@"[Deepgram] Failed to start audio engine: %@", startError.localizedDescription);
+ DGLogError(@"[Deepgram] Failed to start audio engine: %@",
+ startError.localizedDescription);
if (outError) {
*outError = startError;
}
return NO;
}
-
+
return YES;
}
@@ -808,33 +1417,36 @@ - (AVAudioPCMBuffer *)createPCMBufferFromData:(NSData *)data {
if (!data || data.length == 0 || !self.playbackFormat) {
return nil;
}
-
+
// Calculate frame count (data length / bytes per frame)
- const AudioStreamBasicDescription *asbd = self.playbackFormat.streamDescription;
+ const AudioStreamBasicDescription *asbd =
+ self.playbackFormat.streamDescription;
int bytesPerFrame = asbd->mBytesPerFrame;
- AVAudioFrameCount frameCount = (AVAudioFrameCount)(data.length / bytesPerFrame);
-
+ AVAudioFrameCount frameCount =
+ (AVAudioFrameCount)(data.length / bytesPerFrame);
+
if (frameCount == 0) {
return nil;
}
-
- AVAudioPCMBuffer *buffer = [[AVAudioPCMBuffer alloc]
- initWithPCMFormat:self.playbackFormat
- frameCapacity:frameCount];
-
+
+ AVAudioPCMBuffer *buffer =
+ [[AVAudioPCMBuffer alloc] initWithPCMFormat:self.playbackFormat
+ frameCapacity:frameCount];
+
buffer.frameLength = frameCount;
-
+
// Copy audio data to buffer
memcpy(buffer.int16ChannelData[0], data.bytes, data.length);
-
+
return buffer;
}
-RCT_EXPORT_METHOD(startPlayer:(nonnull NSNumber *)sampleRate
- channels:(nonnull NSNumber *)channels)
-{
- [self stopPlayer:nil rejecter:nil];
-
+RCT_EXPORT_METHOD(startPlayer : (nonnull NSNumber *)
+ sampleRate channels : (nonnull NSNumber *)channels) {
+ if (!self.engineCaptureActive) {
+ [self stopPlayer:nil rejecter:nil];
+ }
+
NSError *sessionError = nil;
if (![self activateAudioSession:&sessionError]) {
DGLogWarn(@"[Deepgram] Unable to activate audio session: %@", sessionError);
@@ -842,8 +1454,16 @@ - (AVAudioPCMBuffer *)createPCMBufferFromData:(NSData *)data {
}
NSError *engineError = nil;
+ // Voice processing (hardware echo cancellation) is only useful when we
+ // are also recording the microphone (Voice Agent duplex). Enabling it
+ // for pure TTS playback would force the input node open and can trip
+ // the system microphone indicator on some iOS versions.
+ BOOL needsVoiceProcessing = _recordState.isRunning ||
+ self.engineCaptureActive ||
+ self.voiceProcessingRequested;
if (![self setupAudioEngineWithSampleRate:sampleRate.intValue
channels:channels.intValue
+ enableVoiceProcessing:needsVoiceProcessing
error:&engineError]) {
DGLogError(@"[Deepgram] Failed to setup audio engine: %@", engineError);
return;
@@ -857,8 +1477,7 @@ - (AVAudioPCMBuffer *)createPCMBufferFromData:(NSData *)data {
* Feed base64-encoded PCM audio data for playback using AVAudioEngine.
* Uses real-time PCM buffer streaming instead of WAV files.
*/
-RCT_EXPORT_METHOD(feedAudio:(NSString *)b64)
-{
+RCT_EXPORT_METHOD(feedAudio : (NSString *)b64) {
@try {
if (!self.playerNode || !self.audioEngine || !self.audioEngine.isRunning) {
return;
@@ -870,90 +1489,112 @@ - (AVAudioPCMBuffer *)createPCMBufferFromData:(NSData *)data {
if (_scheduledBufferCount > 500) {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
- DGLogWarn(@"[Deepgram] feedAudio: dropping audio chunks (buffer full > 500). App might be receiving audio faster than playback.");
+ DGLogWarn(@"[Deepgram] feedAudio: dropping audio chunks (buffer full > "
+ @"500). App might be receiving audio faster than playback.");
});
return;
}
-
- NSData *pcmData = [[NSData alloc] initWithBase64EncodedString:b64 options:0];
+
+ NSData *pcmData = [[NSData alloc] initWithBase64EncodedString:b64
+ options:0];
if (!pcmData || pcmData.length == 0) {
return;
}
-
+
AVAudioPCMBuffer *buffer = [self createPCMBufferFromData:pcmData];
if (!buffer) {
DGLogError(@"[Deepgram] feedAudio: failed to create PCM buffer");
return;
}
-
+
_scheduledBufferCount++;
+ int playbackGeneration = _playbackGeneration.load();
__weak Deepgram *weakSelf = self;
- [self.playerNode scheduleBuffer:buffer completionHandler:^{
- Deepgram *strongSelf = weakSelf;
- if (strongSelf) {
- strongSelf->_scheduledBufferCount--;
- }
- }];
-
+ [self.playerNode scheduleBuffer:buffer
+ completionHandler:^{
+ Deepgram *strongSelf = weakSelf;
+ if (strongSelf) {
+ if (strongSelf->_playbackGeneration.load() !=
+ playbackGeneration) {
+ return;
+ }
+
+ int remaining = --strongSelf->_scheduledBufferCount;
+ if (remaining <= 0) {
+ strongSelf.isPlaying = NO;
+ [strongSelf maybeDeactivateAudioSession];
+ }
+ }
+ }];
+
if (!self.playerNode.isPlaying) {
+ self.isPlaying = YES;
[self.playerNode play];
+ } else {
+ self.isPlaying = YES;
}
- }
- @catch (NSException *e) {
+ } @catch (NSException *e) {
DGLogError(@"[Deepgram] feedAudio: exception %@", e);
}
}
+RCT_EXPORT_METHOD(interruptAudio) {
+ @try {
+ [self interruptPlayerPlayback];
+ [self maybeDeactivateAudioSession];
+ } @catch (NSException *e) {
+ DGLogError(@"[Deepgram] interruptAudio: exception %@", e);
+ }
+}
+
/**
* Stop audio playback and cleanup AVAudioEngine.
*/
-RCT_EXPORT_METHOD(stopPlayer
- :(RCTPromiseResolveBlock)resolve
- rejecter:(RCTPromiseRejectBlock)reject)
-{
+RCT_EXPORT_METHOD(stopPlayer : (RCTPromiseResolveBlock)
+ resolve rejecter : (RCTPromiseRejectBlock)reject) {
@try {
-
- // Stop player node
- if (self.playerNode) {
- [self.playerNode stop];
- if (self.audioEngine) {
- [self.audioEngine detachNode:self.playerNode];
- }
- }
-
- // Stop and cleanup audio engine
+ BOOL preserveCaptureEngine = self.engineCaptureActive;
+
if (self.audioEngine) {
if (self.audioEngine.isRunning) {
[self.audioEngine stop];
}
- [self.audioEngine reset];
+
+ [self stopAndDetachPlayerNode];
+
+ if (preserveCaptureEngine) {
+ [self.audioEngine prepare];
+ NSError *restartError = nil;
+ if (![self.audioEngine startAndReturnError:&restartError]) {
+ DGLogWarn(@"[Deepgram] stopPlayer: failed to restart capture engine: %@",
+ restartError);
+ }
+ } else {
+ [self.audioEngine reset];
+ self.audioEngine = nil;
+ }
+ } else {
+ [self stopAndDetachPlayerNode];
}
-
- // Clear properties
- self.playerNode = nil;
- self.audioEngine = nil;
- self.playbackFormat = nil;
- self.isPlaying = NO;
- _scheduledBufferCount = 0;
[self maybeDeactivateAudioSession];
- if (resolve) resolve(nil);
- }
- @catch (NSException *e) {
+ if (resolve)
+ resolve(nil);
+ } @catch (NSException *e) {
DGLogError(@"[Deepgram] stopPlayer: exception %@", e);
- if (reject) reject(@"player_stop_error", e.reason, nil);
+ if (reject)
+ reject(@"player_stop_error", e.reason, nil);
}
}
/**
* Set audio configuration (compatibility method).
*/
-RCT_EXPORT_METHOD(setAudioConfig
- :(nonnull NSNumber *)sampleRate
- channels:(nonnull NSNumber *)channels)
-{
- DGLogDebug(@"[Deepgram] setAudioConfig: sampleRate=%@ channels=%@", sampleRate, channels);
+RCT_EXPORT_METHOD(setAudioConfig : (nonnull NSNumber *)
+ sampleRate channels : (nonnull NSNumber *)channels) {
+ DGLogDebug(@"[Deepgram] setAudioConfig: sampleRate=%@ channels=%@",
+ sampleRate, channels);
[self startPlayer:sampleRate channels:channels];
}
@@ -961,80 +1602,97 @@ - (AVAudioPCMBuffer *)createPCMBufferFromData:(NSData *)data {
* Play a single audio chunk (base64-encoded PCM).
* This is used for one-shot TTS playback (HTTP mode).
*/
-RCT_EXPORT_METHOD(playAudioChunk:(NSString *)b64
- resolver:(RCTPromiseResolveBlock)resolve
- rejecter:(RCTPromiseRejectBlock)reject)
-{
+RCT_EXPORT_METHOD(playAudioChunk : (NSString *)b64 resolver : (
+ RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) {
@try {
if (!b64 || b64.length == 0) {
- if (reject) reject(@"audio_chunk_error", @"Empty audio chunk", nil);
+ if (reject)
+ reject(@"audio_chunk_error", @"Empty audio chunk", nil);
return;
}
-
- NSData *pcmData = [[NSData alloc] initWithBase64EncodedString:b64 options:0];
+
+ NSData *pcmData = [[NSData alloc] initWithBase64EncodedString:b64
+ options:0];
if (!pcmData || pcmData.length == 0) {
- if (reject) reject(@"audio_chunk_error", @"Failed to decode audio chunk", nil);
+ if (reject)
+ reject(@"audio_chunk_error", @"Failed to decode audio chunk", nil);
return;
}
-
+
// Activate audio session
NSError *sessionError = nil;
if (![self activateAudioSession:&sessionError]) {
- NSString *message = sessionError.localizedDescription ?: @"Failed to activate audio session";
+ NSString *message = sessionError.localizedDescription
+ ?: @"Failed to activate audio session";
DGLogError(@"[Deepgram] playAudioChunk: activation failed %@", message);
- if (reject) reject(@"audio_chunk_error", message, sessionError);
+ if (reject)
+ reject(@"audio_chunk_error", message, sessionError);
return;
}
-
+
// Determine sample rate and channels from the audio data
// For linear16 PCM, assume 24kHz mono (2 bytes per sample)
- int sampleRate = self.currentSampleRate > 0 ? self.currentSampleRate : 24000;
+ int sampleRate =
+ self.currentSampleRate > 0 ? self.currentSampleRate : 24000;
int channels = 1;
-
+
// Setup audio engine if needed
if (!self.audioEngine || !self.audioEngine.isRunning) {
NSError *engineError = nil;
+ // One-shot HTTP TTS playback never needs voice processing.
if (![self setupAudioEngineWithSampleRate:sampleRate
channels:channels
+ enableVoiceProcessing:NO
error:&engineError]) {
- DGLogError(@"[Deepgram] playAudioChunk: failed to setup audio engine: %@", engineError);
- if (reject) reject(@"audio_chunk_error", @"Failed to setup audio engine", engineError);
+ DGLogError(
+ @"[Deepgram] playAudioChunk: failed to setup audio engine: %@",
+ engineError);
+ if (reject)
+ reject(@"audio_chunk_error", @"Failed to setup audio engine",
+ engineError);
return;
}
}
-
+
// Create PCM buffer and schedule for playback
AVAudioPCMBuffer *buffer = [self createPCMBufferFromData:pcmData];
if (!buffer) {
DGLogError(@"[Deepgram] playAudioChunk: failed to create PCM buffer");
- if (reject) reject(@"audio_chunk_error", @"Failed to create PCM buffer", nil);
+ if (reject)
+ reject(@"audio_chunk_error", @"Failed to create PCM buffer", nil);
return;
}
-
+
self.isPlaying = YES;
-
+
// Schedule buffer with completion handler to resolve promise
- [self.playerNode scheduleBuffer:buffer completionHandler:^{
- self.isPlaying = NO;
- [self maybeDeactivateAudioSession];
- if (resolve) resolve(nil);
- }];
-
+ __weak Deepgram *weakSelf = self;
+ [self.playerNode scheduleBuffer:buffer
+ completionHandler:^{
+ Deepgram *strongSelf = weakSelf;
+ if (strongSelf) {
+ strongSelf.isPlaying = NO;
+ [strongSelf maybeDeactivateAudioSession];
+ }
+ if (resolve)
+ resolve(nil);
+ }];
+
// Start playing if not already playing
if (!self.playerNode.isPlaying) {
[self.playerNode play];
}
-
- DGLogDebug(@"[Deepgram] playAudioChunk: scheduled %lu bytes for playback", (unsigned long)pcmData.length);
- }
- @catch (NSException *e) {
+
+ DGLogDebug(@"[Deepgram] playAudioChunk: scheduled %lu bytes for playback",
+ (unsigned long)pcmData.length);
+ } @catch (NSException *e) {
DGLogError(@"[Deepgram] playAudioChunk: exception %@", e);
- if (reject) reject(@"audio_chunk_error", e.reason, nil);
+ if (reject)
+ reject(@"audio_chunk_error", e.reason, nil);
}
}
-- (void)invalidate
-{
+- (void)invalidate {
DGLogDebug(@"[Deepgram] invalidate: begin");
@try {
@@ -1049,7 +1707,7 @@ - (void)invalidate
[self.playerNode stop];
self.playerNode = nil;
}
-
+
if (self.audioEngine && self.audioEngine.isRunning) {
[self.audioEngine stop];
}
diff --git a/ios/MicPermission.m b/ios/MicPermission.m
index c8bfdfd..a81ecd9 100644
--- a/ios/MicPermission.m
+++ b/ios/MicPermission.m
@@ -11,8 +11,31 @@ @implementation MicPermission
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
+ // iOS 17+ deprecated `[AVAudioSession requestRecordPermission:]` in favor of
+ // `[AVAudioApplication requestRecordPermissionWithCompletionHandler:]`.
+ // Resolve the new API dynamically so we keep working on older iOS versions
+ // without raising a deprecation warning when building against newer SDKs.
+ Class audioApplicationClass = NSClassFromString(@"AVAudioApplication");
+ SEL newSelector = NSSelectorFromString(@"requestRecordPermissionWithCompletionHandler:");
+
+ if (audioApplicationClass && [audioApplicationClass respondsToSelector:newSelector]) {
+ void (^handler)(BOOL) = ^(BOOL granted) {
+ resolve(@(granted));
+ };
+ NSMethodSignature *signature = [audioApplicationClass methodSignatureForSelector:newSelector];
+ NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
+ invocation.target = audioApplicationClass;
+ invocation.selector = newSelector;
+ [invocation setArgument:&handler atIndex:2];
+ [invocation invoke];
+ return;
+ }
+
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wdeprecated-declarations"
[[AVAudioSession sharedInstance] requestRecordPermission:^(BOOL granted) {
resolve(@(granted));
}];
+#pragma clang diagnostic pop
}
@end
diff --git a/package.json b/package.json
index f5058d7..3463dc5 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,8 @@
{
"name": "react-native-deepgram",
- "version": "2.0.0",
+ "version": "2.1.0",
"description": "React Native SDK for Deepgram's AI-powered speech-to-text, real-time transcription, and text intelligence APIs. Supports live audio streaming, file transcription, sentiment analysis, and topic detection for iOS and Android.",
- "source": "./src/index.tsx",
+ "source": "./src/index.ts",
"main": "./lib/commonjs/index.js",
"module": "./lib/module/index.js",
"types": "./lib/typescript/commonjs/src/index.d.ts",
@@ -22,15 +22,18 @@
"files": [
"src",
"lib",
- "plugin",
+ "plugin/build",
+ "plugin/src",
+ "plugin/tsconfig.json",
"app.plugin.js",
"android",
"ios",
- "cpp",
- "*.podspec",
"react-native.config.js",
+ "*.podspec",
"!ios/build",
+ "!ios/generated/build",
"!android/build",
+ "!android/generated",
"!android/gradle",
"!android/gradlew",
"!android/gradlew.bat",
@@ -38,14 +41,14 @@
"!**/__tests__",
"!**/__fixtures__",
"!**/__mocks__",
- "!**/.*",
- "metro"
+ "!**/.*"
],
"scripts": {
"test": "jest",
"typecheck": "tsc",
"lint": "eslint \"**/*.{js,ts,tsx}\" --fix",
"clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib",
+ "postinstall": "node -e \"const fs=require('fs'),p=require('path'),l=p.join('example','node_modules','react-native-deepgram');fs.mkdirSync(p.dirname(l),{recursive:true});try{fs.unlinkSync(l)}catch{}fs.symlinkSync(p.join('..','..'),l,'dir')\"",
"prepare": "bob build",
"release": "release-it --only-version",
"build": "npx react-native-builder-bob build",
@@ -55,6 +58,10 @@
"example": "yarn workspace react-native-deepgram-example start",
"example:ios": "yarn workspace react-native-deepgram-example ios",
"example:android": "yarn workspace react-native-deepgram-example android",
+ "example:pods": "yarn workspace react-native-deepgram-example pods",
+ "example:prebuild": "yarn workspace react-native-deepgram-example prebuild",
+ "example:start:built": "yarn workspace react-native-deepgram-example start:built",
+ "example:clean": "yarn workspace react-native-deepgram-example clean",
"build:all": "yarn install && yarn build && yarn build:plugin && yarn build:pod"
},
"keywords": [
@@ -91,7 +98,7 @@
"registry": "https://registry.npmjs.org/"
},
"dependencies": {
- "@deepgram/sdk": "^4.7.0"
+ "buffer": "^6.0.3"
},
"devDependencies": {
"@commitlint/config-conventional": "^19.6.0",
@@ -126,8 +133,11 @@
"typescript": "^5.5.4"
},
"peerDependencies": {
- "react": "*",
- "react-native": "*"
+ "react": ">=18.0.0",
+ "react-native": ">=0.72.0"
+ },
+ "engines": {
+ "node": ">=18"
},
"workspaces": [
"example"
@@ -206,7 +216,7 @@
"android": "android/generated"
},
"android": {
- "javaPackageName": "com.ahhzo.react-native-deepgram"
+ "javaPackageName": "com.deepgram"
}
},
"create-react-native-library": {
@@ -226,4 +236,4 @@
"metro-symbolicate": "0.81.5",
"metro-transform-worker": "0.81.5"
}
-}
\ No newline at end of file
+}
diff --git a/plugin/build/index.d.ts b/plugin/build/index.d.ts
index eca52a6..2c78a99 100644
--- a/plugin/build/index.d.ts
+++ b/plugin/build/index.d.ts
@@ -1,6 +1,7 @@
import type { ConfigPlugin } from '@expo/config-plugins';
type DeepgramPluginOptions = {
microphonePermission?: string;
+ backgroundAudio?: boolean;
};
declare const withDeepgram: ConfigPlugin;
-export default withDeepgram;
\ No newline at end of file
+export default withDeepgram;
diff --git a/plugin/build/index.js b/plugin/build/index.js
index b304c54..a89c20c 100644
--- a/plugin/build/index.js
+++ b/plugin/build/index.js
@@ -1,27 +1,42 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const config_plugins_1 = require("@expo/config-plugins");
-const withAndroidDeepgram = (config) => {
+const withAndroidDeepgram = (config, options = {}) => {
+ const resolvedOptions = (options ?? {});
return (0, config_plugins_1.withAndroidManifest)(config, (cfg) => {
const recordPermission = 'android.permission.RECORD_AUDIO';
+ const foregroundServicePermission = 'android.permission.FOREGROUND_SERVICE';
+ const foregroundMicPermission = 'android.permission.FOREGROUND_SERVICE_MICROPHONE';
+ const foregroundPlaybackPermission = 'android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK';
const { manifest } = cfg.modResults;
const permissions = manifest['uses-permission'] ?? [];
- if (!permissions.some((p) => p.$?.['android:name'] === recordPermission)) {
- permissions.push({ $: { 'android:name': recordPermission } });
- manifest['uses-permission'] = permissions;
+ const ensurePermission = (permission) => {
+ if (!permissions.some((p) => p.$?.['android:name'] === permission)) {
+ permissions.push({ $: { 'android:name': permission } });
+ }
+ };
+ ensurePermission(recordPermission);
+ if (resolvedOptions.backgroundAudio !== false) {
+ ensurePermission(foregroundServicePermission);
+ ensurePermission(foregroundMicPermission);
+ ensurePermission(foregroundPlaybackPermission);
}
+ manifest['uses-permission'] = permissions;
return cfg;
});
};
const withIosDeepgram = (config, options = {}) => {
- const fallbackPermissionMessage = 'Allow $(PRODUCT_NAME) to access your microphone.';
return (0, config_plugins_1.withInfoPlist)(config, (cfg) => {
- if (options.microphonePermission) {
- cfg.modResults.NSMicrophoneUsageDescription =
- options.microphonePermission;
- }
- else if (!cfg.modResults.NSMicrophoneUsageDescription) {
- cfg.modResults.NSMicrophoneUsageDescription = fallbackPermissionMessage;
+ cfg.modResults.NSMicrophoneUsageDescription =
+ options.microphonePermission ??
+ cfg.modResults.NSMicrophoneUsageDescription ??
+ 'Allow $(PRODUCT_NAME) to access the microphone';
+ if (options.backgroundAudio !== false) {
+ const modes = cfg.modResults.UIBackgroundModes ?? [];
+ if (!modes.includes('audio')) {
+ modes.push('audio');
+ }
+ cfg.modResults.UIBackgroundModes = modes;
}
return cfg;
});
@@ -30,4 +45,4 @@ const withDeepgram = (config, options) => (0, config_plugins_1.withPlugins)(conf
[withAndroidDeepgram, options],
[withIosDeepgram, options],
]);
-exports.default = withDeepgram;
\ No newline at end of file
+exports.default = withDeepgram;
diff --git a/plugin/src/index.ts b/plugin/src/index.ts
index f3dda1a..66afd8c 100644
--- a/plugin/src/index.ts
+++ b/plugin/src/index.ts
@@ -6,21 +6,41 @@ import {
import type { ConfigPlugin } from '@expo/config-plugins';
type DeepgramPluginOptions = {
microphonePermission?: string;
+ backgroundAudio?: boolean;
};
const withAndroidDeepgram: ConfigPlugin = (
- config
+ config,
+ options = {}
) => {
+ const resolvedOptions = (options ?? {}) as DeepgramPluginOptions;
+
return withAndroidManifest(config, (cfg) => {
const recordPermission = 'android.permission.RECORD_AUDIO';
+ const foregroundServicePermission = 'android.permission.FOREGROUND_SERVICE';
+ const foregroundMicPermission =
+ 'android.permission.FOREGROUND_SERVICE_MICROPHONE';
+ const foregroundPlaybackPermission =
+ 'android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK';
const { manifest } = cfg.modResults;
const permissions = manifest['uses-permission'] ?? [];
- if (!permissions.some((p) => p.$?.['android:name'] === recordPermission)) {
- permissions.push({ $: { 'android:name': recordPermission } });
- manifest['uses-permission'] = permissions;
+ const ensurePermission = (permission: string) => {
+ if (!permissions.some((p) => p.$?.['android:name'] === permission)) {
+ permissions.push({ $: { 'android:name': permission } });
+ }
+ };
+
+ ensurePermission(recordPermission);
+
+ if (resolvedOptions.backgroundAudio !== false) {
+ ensurePermission(foregroundServicePermission);
+ ensurePermission(foregroundMicPermission);
+ ensurePermission(foregroundPlaybackPermission);
}
+ manifest['uses-permission'] = permissions;
+
return cfg;
});
};
@@ -29,20 +49,18 @@ const withIosDeepgram: ConfigPlugin = (
config,
options = {}
) => {
- const message =
- options.microphonePermission ??
- 'Allow $(PRODUCT_NAME) to access the microphone';
- const fallbackPermissionMessage =
- 'Allow $(PRODUCT_NAME) to access your microphone.';
-
return withInfoPlist(config, (cfg) => {
cfg.modResults.NSMicrophoneUsageDescription =
- cfg.modResults.NSMicrophoneUsageDescription || message;
- if (options.microphonePermission) {
- cfg.modResults.NSMicrophoneUsageDescription =
- options.microphonePermission;
- } else if (!cfg.modResults.NSMicrophoneUsageDescription) {
- cfg.modResults.NSMicrophoneUsageDescription = fallbackPermissionMessage;
+ options.microphonePermission ??
+ cfg.modResults.NSMicrophoneUsageDescription ??
+ 'Allow $(PRODUCT_NAME) to access the microphone';
+
+ if (options.backgroundAudio !== false) {
+ const modes: string[] = cfg.modResults.UIBackgroundModes ?? [];
+ if (!modes.includes('audio')) {
+ modes.push('audio');
+ }
+ cfg.modResults.UIBackgroundModes = modes;
}
return cfg;
diff --git a/react-native.config.js b/react-native.config.js
new file mode 100644
index 0000000..9859473
--- /dev/null
+++ b/react-native.config.js
@@ -0,0 +1,14 @@
+module.exports = {
+ dependency: {
+ platforms: {
+ android: {
+ sourceDir: 'android',
+ packageImportPath: 'import com.deepgram.DeepgramPackage;',
+ packageInstance: 'new DeepgramPackage()',
+ },
+ ios: {
+ podspecPath: require('path').resolve(__dirname, 'Deepgram.podspec'),
+ },
+ },
+ },
+};
diff --git a/src/NativeDeepgram.ts b/src/NativeDeepgram.ts
index a99b0b9..b491827 100644
--- a/src/NativeDeepgram.ts
+++ b/src/NativeDeepgram.ts
@@ -1,20 +1,36 @@
import { NativeModules } from 'react-native';
+export interface StartRecordingOptions {
+ /**
+ * When `true`, the native module configures the platform's hardware echo
+ * cancellation (Apple VPIO on iOS, AudioSource.VOICE_COMMUNICATION +
+ * MODE_IN_COMMUNICATION on Android). Use this for full-duplex Voice Agent
+ * sessions where the speaker output would otherwise be picked up by the
+ * microphone and re-transcribed.
+ *
+ * Defaults to `false` β pure speech-to-text usage benefits from raw
+ * (un-AEC'd) audio because Deepgram's models perform their own noise
+ * handling and prefer the unprocessed signal.
+ */
+ enableVoiceProcessing?: boolean;
+}
+
interface DeepgramNative {
- startRecording(): Promise;
+ startRecording(options?: StartRecordingOptions): Promise;
stopRecording(): Promise;
startAudio(): Promise;
stopAudio(): Promise;
playAudioChunk(chunk: string): Promise;
- setAudioConfig?(sampleRate: number, channels?: number): void;
- feedAudio?(base64Chunk: string): void;
- stopPlayer?(): void;
- startPlayer?(sampleRate: number, channels?: number): void;
+ setAudioConfig(sampleRate: number, channels?: number): void;
+ feedAudio(base64Chunk: string): void;
+ interruptAudio?: () => void;
+ stopPlayer(): void;
+ startPlayer(sampleRate: number, channels?: number): void;
}
const LINKING_ERROR = `react-native-deepgram: Native code not linkedβdid you run βpod installβ & rebuild?`;
-export const Deepgram: DeepgramNative =
+const NativeDeepgramModule: DeepgramNative =
NativeModules.Deepgram ??
(new Proxy(
{},
@@ -24,3 +40,39 @@ export const Deepgram: DeepgramNative =
},
}
) as any);
+
+export const Deepgram: DeepgramNative = {
+ startRecording(options: StartRecordingOptions = {}) {
+ return NativeDeepgramModule.startRecording(options);
+ },
+ stopRecording() {
+ return NativeDeepgramModule.stopRecording();
+ },
+ startAudio() {
+ return NativeDeepgramModule.startAudio();
+ },
+ stopAudio() {
+ return NativeDeepgramModule.stopAudio();
+ },
+ playAudioChunk(chunk: string) {
+ return NativeDeepgramModule.playAudioChunk(chunk);
+ },
+ setAudioConfig(sampleRate: number, channels?: number) {
+ return NativeDeepgramModule.setAudioConfig(sampleRate, channels);
+ },
+ feedAudio(base64Chunk: string) {
+ return NativeDeepgramModule.feedAudio(base64Chunk);
+ },
+ interruptAudio() {
+ if (typeof NativeDeepgramModule.interruptAudio === 'function') {
+ return NativeDeepgramModule.interruptAudio();
+ }
+ return NativeDeepgramModule.stopPlayer();
+ },
+ stopPlayer() {
+ return NativeDeepgramModule.stopPlayer();
+ },
+ startPlayer(sampleRate: number, channels?: number) {
+ return NativeDeepgramModule.startPlayer(sampleRate, channels);
+ },
+};
diff --git a/src/helpers/audioUtils.ts b/src/helpers/audioUtils.ts
index 4cf0362..032e441 100644
--- a/src/helpers/audioUtils.ts
+++ b/src/helpers/audioUtils.ts
@@ -11,7 +11,7 @@ export function createAudioPlayerController() {
return {
start: async (sampleRate = 16000, channels: 1 | 2 = 1) => {
const { Deepgram } = await import('../NativeDeepgram');
- Deepgram.startPlayer?.(sampleRate, channels);
+ Deepgram.startPlayer(sampleRate, channels);
isActive = true;
},
@@ -19,12 +19,12 @@ export function createAudioPlayerController() {
const { Deepgram } = await import('../NativeDeepgram');
const b64 =
typeof audio === 'string' ? audio : arrayBufferToBase64(audio);
- await Deepgram.feedAudio?.(b64);
+ await Deepgram.feedAudio(b64);
},
stop: async () => {
const { Deepgram } = await import('../NativeDeepgram');
- Deepgram.stopPlayer?.();
+ Deepgram.stopPlayer();
isActive = false;
},
diff --git a/src/index.ts b/src/index.ts
index 2f09ddc..d283863 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -10,27 +10,7 @@ export {
useAsyncCall,
createAgentSettings,
} from './helpers';
-export type {
- DeepgramTextToSpeechModel,
- DeepgramTextToSpeechEncoding,
- DeepgramTextToSpeechHttpEncoding,
- DeepgramTextToSpeechStreamEncoding,
- DeepgramTextToSpeechSampleRate,
- DeepgramTextToSpeechCallbackMethod,
- DeepgramTextToSpeechContainer,
- DeepgramTextToSpeechBitRate,
- DeepgramTextToSpeechHttpOptions,
- DeepgramTextToSpeechStreamOptions,
- DeepgramTextToSpeechStreamInputMessage,
- DeepgramTextToSpeechStreamMetadataMessage,
- DeepgramTextToSpeechStreamFlushedMessage,
- DeepgramTextToSpeechStreamClearedMessage,
- DeepgramTextToSpeechStreamWarningMessage,
- DeepgramTextToSpeechStreamResponseMessage,
- UseDeepgramTextToSpeechOptions,
- UseDeepgramTextToSpeechProps,
- UseDeepgramTextToSpeechReturn,
-} from './types';
+export type * from './types';
export const configure = (opts: { apiKey: string }) => {
(globalThis as any).__DEEPGRAM_API_KEY__ = opts.apiKey;
diff --git a/src/rn-missing.d.ts b/src/rn-missing.d.ts
new file mode 100644
index 0000000..2def89e
--- /dev/null
+++ b/src/rn-missing.d.ts
@@ -0,0 +1,19 @@
+/**
+ * Ambient type augmentations for React Native globals that exist at runtime
+ * but are missing from RN's bundled TypeScript definitions.
+ */
+
+interface WebSocket {
+ binaryType: 'blob' | 'arraybuffer';
+}
+
+interface Blob {
+ arrayBuffer(): Promise;
+}
+
+interface FormData {
+ append(name: string, value: any, filename?: string): void;
+}
+
+declare function atob(encoded: string): string;
+declare function btoa(input: string): string;
diff --git a/src/types/deepgram/management.ts b/src/types/deepgram/management.ts
index 7dbbd89..42a32f9 100644
--- a/src/types/deepgram/management.ts
+++ b/src/types/deepgram/management.ts
@@ -44,73 +44,172 @@ export interface DeepgramListModelsResponse {
tts: DeepgramTtsModel[] | null;
}
+/* ---------- projects ---------- */
+
/**
* Represents a Deepgram Project.
*/
export interface DeepgramProject {
project_id: string;
name: string;
+ /** Model Improvement Program opt-out flag (returned by Get a Project). */
+ mip_opt_out?: boolean;
created?: string;
balance?: number;
}
/**
- * Represents a Deepgram API Key.
+ * Generic confirmation message returned by several Manage endpoints
+ * (e.g. update project, create invite, update scopes).
*/
-export interface DeepgramKey {
- key_id: string;
- project_id: string;
+export interface DeepgramMessageResponse {
+ message: string;
+}
+
+/* ---------- keys ---------- */
+
+/**
+ * The API key portion of a project key record.
+ */
+export interface DeepgramApiKey {
+ api_key_id: string;
comment?: string;
scopes?: string[];
+ tags?: string[];
+ expiration_date?: string;
created?: string;
}
+/**
+ * The member a project key belongs to.
+ */
+export interface DeepgramKeyMember {
+ member_id: string;
+ email: string;
+ first_name?: string;
+ last_name?: string;
+}
+
+/**
+ * Represents a Deepgram API Key as returned by list/get
+ * (the secret value is never included here).
+ */
+export interface DeepgramKey {
+ member: DeepgramKeyMember;
+ api_key: DeepgramApiKey;
+}
+
+/**
+ * Response returned when creating a key. This is the only time the secret
+ * `key` value is exposed by the API.
+ */
+export interface DeepgramCreatedKey {
+ api_key_id: string;
+ key: string;
+ comment?: string;
+ scopes?: string[];
+ tags?: string[];
+ expiration_date?: string;
+}
+
+/* ---------- members ---------- */
+
/**
* Represents a member of a Deepgram Project.
*/
export interface DeepgramMember {
member_id: string;
email: string;
- role: string;
- invited?: boolean;
+ first_name?: string;
+ last_name?: string;
+ scopes?: string[];
}
export type DeepgramScope = string;
+/* ---------- invitations ---------- */
+
/**
* Represents an invitation to join a Deepgram Project.
*/
export interface DeepgramInvitation {
- invitation_id: string;
- project_id: string;
email: string;
- status?: string;
- created?: string;
+ scope: string;
}
+/* ---------- requests ---------- */
+
/**
- * Represents a usage request log.
+ * Represents a usage request log entry.
*/
export interface DeepgramRequest {
request_id: string;
- project_id: string;
- model_id?: string;
+ project_uuid?: string;
created?: string;
+ path?: string;
+ api_key_id?: string;
+ response?: Record | null;
+ code?: number;
+ deployment?: string;
+ callback?: string | null;
+}
+
+/* ---------- usage ---------- */
+
+/**
+ * A model entry returned by the usage fields endpoint.
+ */
+export interface DeepgramUsageFieldModel {
+ name: string;
+ language: string;
+ version: string;
+ model_id: string;
+}
+
+/**
+ * Lists the features, models, tags and processing methods used by a project.
+ */
+export interface DeepgramUsageFields {
+ tags: string[];
+ models: DeepgramUsageFieldModel[];
+ processing_methods: string[];
+ features: string[];
+}
+
+/**
+ * Time resolution descriptor used by usage responses.
+ */
+export interface DeepgramUsageResolution {
+ units: string;
+ amount: number;
}
-export interface DeepgramUsageField {
- field: string;
- description?: string;
+/**
+ * A single bucket of a usage breakdown response.
+ */
+export interface DeepgramUsageBreakdownResult {
+ hours?: number;
+ total_hours?: number;
+ agent_hours?: number;
+ tokens_in?: number;
+ tokens_out?: number;
+ tts_characters?: number;
+ requests?: number;
+ grouping?: Record;
}
/**
- * Breakdown of usage statistics.
+ * Breakdown of usage statistics over a date range.
*/
export interface DeepgramUsageBreakdown {
- total: number;
- breakdown: Record;
+ start: string;
+ end: string;
+ resolution: DeepgramUsageResolution;
+ results: DeepgramUsageBreakdownResult[];
}
+/* ---------- purchases ---------- */
+
/**
* Represents a purchase or balance credit.
*/
@@ -121,14 +220,26 @@ export interface DeepgramPurchase {
created?: string;
}
+/* ---------- balances ---------- */
+
/**
- * Represents the current balance of a project.
+ * Represents an outstanding balance of a project.
*/
export interface DeepgramBalance {
balance_id: string;
- project_id: string;
- balance: number;
- currency?: string;
+ amount: number;
+ units?: string;
+ purchase_order_id?: string;
+}
+
+/* ---------- temporary tokens ---------- */
+
+/**
+ * Response from granting a short-lived auth token (`POST /auth/grant`).
+ */
+export interface DeepgramGrantTokenResponse {
+ access_token: string;
+ expires_in: number;
}
/**
@@ -144,7 +255,10 @@ export interface UseDeepgramManagementReturn {
list(): Promise;
get(id: string): Promise;
delete(id: string): Promise;
- patch(id: string, body: Record): Promise;
+ patch(
+ id: string,
+ body: Record
+ ): Promise;
listModels(id: string): Promise;
getModel(
projectId: string,
@@ -156,7 +270,7 @@ export interface UseDeepgramManagementReturn {
create(
projectId: string,
body: Record
- ): Promise;
+ ): Promise;
get(projectId: string, keyId: string): Promise;
delete(projectId: string, keyId: string): Promise;
};
@@ -170,21 +284,21 @@ export interface UseDeepgramManagementReturn {
projectId: string,
memberId: string,
body: Record
- ): Promise;
+ ): Promise;
};
invitations: {
list(projectId: string): Promise;
create(
projectId: string,
body: Record
- ): Promise;
- delete(projectId: string, invitationId: string): Promise;
- leave(projectId: string): Promise;
+ ): Promise;
+ delete(projectId: string, email: string): Promise;
+ leave(projectId: string): Promise;
};
usage: {
listRequests(projectId: string): Promise;
getRequest(projectId: string, requestId: string): Promise;
- listFields(projectId: string): Promise;
+ listFields(projectId: string): Promise;
getBreakdown(projectId: string): Promise;
};
purchases: {
@@ -194,4 +308,7 @@ export interface UseDeepgramManagementReturn {
list(projectId: string): Promise;
get(projectId: string, balanceId: string): Promise;
};
+ auth: {
+ grant(body?: { ttl_seconds?: number }): Promise;
+ };
}
diff --git a/src/types/deepgram/speech-to-text.ts b/src/types/deepgram/speech-to-text.ts
index 2568fdc..aee2986 100644
--- a/src/types/deepgram/speech-to-text.ts
+++ b/src/types/deepgram/speech-to-text.ts
@@ -55,6 +55,7 @@ export type DeepgramLiveListenModel =
| 'video'
| 'custom'
| 'flux-general-en'
+ | 'flux-general-multi'
| (string & {});
/**
@@ -75,6 +76,13 @@ export type DeepgramLiveListenCallbackMethod =
| 'PUT'
| 'DELETE';
+/**
+ * Diarization model versions available for live streaming. Setting this
+ * enables diarization without also passing `diarize`. Streaming supports
+ * `v1` and `latest`.
+ */
+export type DeepgramLiveListenDiarizeModel = 'latest' | 'v1' | (string & {});
+
/**
* Configuration options for Deepgram Live Listen (Streaming) sessions.
* @example
@@ -96,8 +104,21 @@ export type DeepgramLiveListenOptions = {
callbackMethod?: DeepgramLiveListenCallbackMethod;
/** Number of audio channels in the input. */
channels?: number;
- /** Enable speaker diarization. */
+ /**
+ * Enable speaker diarization.
+ * @deprecated Use `diarizeModel` instead.
+ */
diarize?: boolean;
+ /**
+ * Select a diarization model version. Setting this enables diarization
+ * without also passing `diarize`. Streaming supports `v1` and `latest`.
+ */
+ diarizeModel?: DeepgramLiveListenDiarizeModel;
+ /**
+ * Identify and extract key entities from the submitted audio. Entities
+ * appear in final results. Enables punctuation by default.
+ */
+ detectEntities?: boolean;
/** Enable dictation intelligence. */
dictation?: boolean;
/** Expected encoding for the submitted audio. */
@@ -113,12 +134,18 @@ export type DeepgramLiveListenOptions = {
fillerWords?: boolean;
/** Emit interim transcripts while audio is streaming. */
interimResults?: boolean;
- /** Key term prompting (Nova-3 only). */
+ /** Key term prompting for Nova-3 and Flux. */
keyterm?: string | string[];
- /** Keyword boosting/suppression. */
+ /** Keyword boosting/suppression for legacy/non-Nova-3 models. */
keywords?: string | string[];
/** Primary spoken language hint (BCP-47). */
language?: string;
+ /**
+ * Language hints that constrain and prioritize language detection. Only
+ * valid with the `flux-general-multi` model on the v2/Flux API. Pass an
+ * array to specify multiple language codes.
+ */
+ languageHint?: string | string[];
/** Opt out of the Model Improvement Program. */
mipOptOut?: boolean;
/** Model to use for live transcription. */
@@ -188,6 +215,17 @@ export type DeepgramPrerecordedRedaction =
| 'numbers'
| (string & {});
+/**
+ * Diarization model versions available for pre-recorded transcription.
+ * Setting this enables diarization without also passing `diarize`. Batch
+ * supports `latest`, `v1`, and `v2`.
+ */
+export type DeepgramPrerecordedDiarizeModel =
+ | 'latest'
+ | 'v1'
+ | 'v2'
+ | (string & {});
+
export type DeepgramPrerecordedCustomMode = DeepgramCustomMode;
export type DeepgramPrerecordedSummarize =
@@ -244,22 +282,32 @@ export type DeepgramPrerecordedOptions = {
detectEntities?: boolean;
/** Detect the dominant language (or limit detection to specific languages). */
detectLanguage?: boolean | string | string[];
- /** Enable speaker diarization. */
+ /**
+ * Enable speaker diarization.
+ * @deprecated Use `diarizeModel` instead.
+ */
diarize?: boolean;
+ /**
+ * Select a diarization model version. Setting this enables diarization
+ * without also passing `diarize`. Batch supports `latest`, `v1`, and `v2`.
+ */
+ diarizeModel?: DeepgramPrerecordedDiarizeModel;
/** Enable dictation intelligence. */
dictation?: boolean;
/** Expected encoding for the submitted audio. */
encoding?: DeepgramPrerecordedEncoding;
/** Include filler words such as βumβ and βuhβ. */
fillerWords?: boolean;
- /** Key term prompting (Nova-3 only). */
+ /** Key term prompting for Nova-3 and Flux. */
keyterm?: string | string[];
- /** Keyword boosting/suppression. */
+ /** Keyword boosting/suppression for legacy/non-Nova-3 models. */
keywords?: string | string[];
/** Primary spoken language hint (BCP-47). */
language?: string;
/** Convert spoken measurements into abbreviations. */
measurements?: boolean;
+ /** Opt out of the Deepgram Model Improvement Program. */
+ mipOptOut?: boolean;
/** Model to use for the transcription. */
model?: DeepgramPrerecordedModel;
/** Transcribe each channel independently. */
diff --git a/src/types/deepgram/text-intelligence.ts b/src/types/deepgram/text-intelligence.ts
index 1789ee7..cad157a 100644
--- a/src/types/deepgram/text-intelligence.ts
+++ b/src/types/deepgram/text-intelligence.ts
@@ -106,12 +106,113 @@ export interface UseDeepgramTextIntelligenceOptions {
sentiment?: boolean;
/** BCP-47 language tag hint (defaults to 'en') */
language?: DeepgramTextIntelligenceLanguage;
+ /** Label your requests for the purpose of identification during usage reporting */
+ tag?: string | string[];
/** URL to receive a webhook callback with the analysis */
callback?: string;
/** HTTP method to use for the callback (defaults to 'POST') */
callbackMethod?: DeepgramTextIntelligenceCallbackMethod;
}
+/** Sentiment label returned by the sentiment analysis feature. */
+export type DeepgramTextIntelligenceSentimentLabel =
+ | 'positive'
+ | 'neutral'
+ | 'negative';
+
+/** Token usage / model info reported for an individual analysis feature. */
+export interface DeepgramTextIntelligenceModelInfo {
+ model_uuid: string;
+ input_tokens: number;
+ output_tokens: number;
+}
+
+/** Metadata describing the analysis request and per-feature model usage. */
+export interface DeepgramTextIntelligenceMetadata {
+ request_id: string;
+ created: string;
+ language: string;
+ summary_info?: DeepgramTextIntelligenceModelInfo;
+ sentiment_info?: DeepgramTextIntelligenceModelInfo;
+ topics_info?: DeepgramTextIntelligenceModelInfo;
+ intents_info?: DeepgramTextIntelligenceModelInfo;
+}
+
+/** Result of the `summarize` feature. */
+export interface DeepgramTextIntelligenceSummary {
+ text: string;
+}
+
+/** A single detected topic with its confidence score. */
+export interface DeepgramTextIntelligenceTopic {
+ topic: string;
+ confidence_score: number;
+}
+
+/** A segment of the input annotated with detected topics. */
+export interface DeepgramTextIntelligenceTopicSegment {
+ text: string;
+ start_word: number;
+ end_word: number;
+ topics: DeepgramTextIntelligenceTopic[];
+}
+
+/** Result of the `topics` feature. */
+export interface DeepgramTextIntelligenceTopics {
+ segments: DeepgramTextIntelligenceTopicSegment[];
+}
+
+/** A single detected intent with its confidence score. */
+export interface DeepgramTextIntelligenceIntent {
+ intent: string;
+ confidence_score: number;
+}
+
+/** A segment of the input annotated with detected intents. */
+export interface DeepgramTextIntelligenceIntentSegment {
+ text: string;
+ start_word: number;
+ end_word: number;
+ intents: DeepgramTextIntelligenceIntent[];
+}
+
+/** Result of the `intents` feature. */
+export interface DeepgramTextIntelligenceIntents {
+ segments: DeepgramTextIntelligenceIntentSegment[];
+}
+
+/** A segment of the input annotated with a sentiment label and score. */
+export interface DeepgramTextIntelligenceSentimentSegment {
+ text: string;
+ start_word: number;
+ end_word: number;
+ sentiment: DeepgramTextIntelligenceSentimentLabel;
+ sentiment_score: number;
+}
+
+/** Result of the `sentiment` feature. */
+export interface DeepgramTextIntelligenceSentiments {
+ segments: DeepgramTextIntelligenceSentimentSegment[];
+ average: {
+ sentiment: DeepgramTextIntelligenceSentimentLabel;
+ sentiment_score: number;
+ };
+}
+
+/** The `results` object of an analysis response. Each field is present only when the corresponding feature was requested. */
+export interface DeepgramTextIntelligenceResults {
+ summary?: DeepgramTextIntelligenceSummary;
+ topics?: DeepgramTextIntelligenceTopics;
+ intents?: DeepgramTextIntelligenceIntents;
+ sentiments?: DeepgramTextIntelligenceSentiments;
+}
+
+/** Full response returned by the Deepgram Read (Text Intelligence) API. */
+export interface DeepgramTextIntelligenceResponse {
+ metadata: DeepgramTextIntelligenceMetadata;
+ results: DeepgramTextIntelligenceResults;
+}
+
/**
* Props for the `useDeepgramTextIntelligence` hook.
*/
@@ -119,7 +220,7 @@ export interface UseDeepgramTextIntelligenceProps {
/** Called before analysis begins (e.g. show spinner) */
onBeforeAnalyze?: () => void;
/** Called with the analysis results on success */
- onAnalyzeSuccess?: (results: any) => void;
+ onAnalyzeSuccess?: (results: DeepgramTextIntelligenceResponse) => void;
/** Called if the analysis request fails */
onAnalyzeError?: (error: Error) => void;
/** Configuration for which analyses to run */
diff --git a/src/types/deepgram/text-to-speech.ts b/src/types/deepgram/text-to-speech.ts
index ef6db97..2aa92c4 100644
--- a/src/types/deepgram/text-to-speech.ts
+++ b/src/types/deepgram/text-to-speech.ts
@@ -127,9 +127,16 @@ export interface DeepgramTextToSpeechHttpOptions {
/** @deprecated Use `container`. */
format?: 'mp3' | 'wav' | 'opus' | 'pcm' | (string & {});
bitRate?: DeepgramTextToSpeechBitRate;
+ /**
+ * Speaking rate multiplier (`0.7`β`1.5`). Adjusts the pace of generated
+ * speech while preserving natural prosody. Defaults to `1` server-side.
+ */
+ speed?: number;
callback?: string;
callbackMethod?: DeepgramTextToSpeechCallbackMethod;
mipOptOut?: boolean;
+ /** Label(s) attached to the request for usage reporting/identification. */
+ tag?: string | string[];
queryParams?: Record;
}
@@ -148,6 +155,11 @@ export interface DeepgramTextToSpeechStreamOptions {
model?: DeepgramTextToSpeechModel | (string & {});
encoding?: DeepgramTextToSpeechStreamEncoding;
sampleRate?: DeepgramTextToSpeechSampleRate;
+ /**
+ * Speaking rate multiplier (`0.7`β`1.5`). Adjusts the pace of generated
+ * speech while preserving natural prosody. Defaults to `1` server-side.
+ */
+ speed?: number;
mipOptOut?: boolean;
/** Additional query parameters appended to the streaming URL. */
queryParams?: Record;
@@ -168,6 +180,10 @@ export interface UseDeepgramTextToSpeechOptions {
sampleRate?: DeepgramTextToSpeechSampleRate;
/** @deprecated Use `http.bitRate`. */
bitRate?: DeepgramTextToSpeechBitRate;
+ /** @deprecated Use `http.speed` / `stream.speed`. */
+ speed?: number;
+ /** @deprecated Use `http.tag`. */
+ tag?: string | string[];
/** @deprecated Use `http.container`. */
container?: DeepgramTextToSpeechContainer;
/** @deprecated Use `http.format`. */
@@ -190,12 +206,20 @@ export interface UseDeepgramTextToSpeechOptions {
* Message sent to the TTS WebSocket stream.
*/
export type DeepgramTextToSpeechStreamTextMessage = {
+ /** @deprecated Prefer `type: 'Speak'` (Deepgram API canonical message type). */
type: 'Text';
text: string;
/** Optional identifier for advanced sequencing use cases. */
sequence_id?: number;
};
+export type DeepgramTextToSpeechStreamSpeakMessage = {
+ type: 'Speak';
+ text: string;
+ /** Optional identifier for advanced sequencing use cases. */
+ sequence_id?: number;
+};
+
export type DeepgramTextToSpeechStreamFlushMessage = {
type: 'Flush';
};
@@ -212,6 +236,7 @@ export type DeepgramTextToSpeechStreamCloseMessage = {
* Union of all possible messages sent to the TTS WebSocket.
*/
export type DeepgramTextToSpeechStreamInputMessage =
+ | DeepgramTextToSpeechStreamSpeakMessage
| DeepgramTextToSpeechStreamTextMessage
| DeepgramTextToSpeechStreamFlushMessage
| DeepgramTextToSpeechStreamClearMessage
diff --git a/src/types/deepgram/voice-agent.ts b/src/types/deepgram/voice-agent.ts
index 796eb5e..dbae258 100644
--- a/src/types/deepgram/voice-agent.ts
+++ b/src/types/deepgram/voice-agent.ts
@@ -2,97 +2,212 @@
* Message in the conversation history.
*/
export interface DeepgramVoiceAgentContextMessage {
+ /** Message type discriminator (e.g. `'History'`). */
type: string;
+ /** Role of the speaker for the message. */
role?: 'user' | 'assistant' | 'system';
+ /** Text content of the message. */
content?: string;
[key: string]: unknown;
}
+/**
+ * Feature flags for the Voice Agent session.
+ */
export interface DeepgramVoiceAgentFlags {
+ /**
+ * Whether to include function call history in the conversation context.
+ * Defaults to `true`; set to `false` to disable function call history.
+ */
history?: boolean;
[key: string]: unknown;
}
+/**
+ * Encoding/format configuration for a single audio stream (input or output).
+ */
export interface DeepgramVoiceAgentAudioConfig {
+ /** Audio encoding format (e.g. `'linear16'`, `'mp3'`). */
encoding?: string;
+ /** Sample rate in Hz. */
sample_rate?: number;
+ /** Bitrate in bits per second (output audio only). */
bitrate?: number;
+ /** Container format for the output audio (e.g. `'none'`, `'wav'`). */
container?: string;
[key: string]: unknown;
}
+/**
+ * Input and output audio media configuration for the agent.
+ */
export interface DeepgramVoiceAgentAudioSettings {
+ /** Speech-to-text audio media input configuration. */
input?: DeepgramVoiceAgentAudioConfig;
+ /** Text-to-speech audio media output configuration. */
output?: DeepgramVoiceAgentAudioConfig;
[key: string]: unknown;
}
+/**
+ * Speech-to-text provider configuration for the agent's `listen` stage.
+ */
export interface DeepgramVoiceAgentListenProvider {
+ /** Speech-to-text provider type. Currently only `'deepgram'` is supported. */
type?: string;
+ /** Deepgram speech-to-text model to use. */
model?: string;
+ /** Listen API version, e.g. `'v2'` for Flux models (`'v1'` otherwise). */
+ version?: string;
+ /** Key terms to increase recognition accuracy for. */
keyterms?: string[];
+ /**
+ * Array of BCP-47 language codes that bias detection toward specific
+ * languages. Only supported with the `flux-general-multi` model.
+ */
+ language_hints?: string[];
+ /**
+ * Confidence threshold required to trigger an end-of-turn event. Higher
+ * values reduce false positives but increase latency. Valid range
+ * `0.5`β`0.9`, defaults to `0.7`. Flux models only.
+ */
+ eot_threshold?: number;
+ /**
+ * Confidence threshold for eager end-of-turn detection, triggering events
+ * before the user fully finishes speaking. Must be β€ `eot_threshold`. Valid
+ * range `0.3`β`0.9`. Flux models only.
+ */
+ eager_eot_threshold?: number;
+ /**
+ * Hard timeout in milliseconds β a turn finishes once this much time passes
+ * after speech, regardless of EOT confidence. Defaults to `5000`. Flux only.
+ */
+ eot_timeout_ms?: number;
+ /**
+ * Apply smart formatting to improve transcript readability (Deepgram
+ * providers only). Defaults to `false`. Not available with Flux.
+ */
smart_format?: boolean;
[key: string]: unknown;
}
+/**
+ * LLM provider configuration for the agent's `think` stage.
+ */
export interface DeepgramVoiceAgentThinkProvider {
+ /** LLM provider type (e.g. `'open_ai'`, `'anthropic'`). */
type?: string;
+ /** LLM model to use. */
model?: string;
+ /** Controls randomness of the LLM output. Range depends on the provider. */
temperature?: number;
[key: string]: unknown;
}
+/**
+ * A function the agent may call, as referenced in a function call request.
+ */
export interface DeepgramVoiceAgentFunctionDefinition {
+ /** Unique identifier for this function call. */
id?: string;
+ /** Name of the function. */
name: string;
+ /** Human-readable description of what the function does. */
description?: string;
+ /** JSON-encoded arguments for the function call. */
arguments?: string;
+ /** Whether the function is executed on the client rather than via endpoint. */
client_side?: boolean;
[key: string]: unknown;
}
+/**
+ * HTTP endpoint Deepgram calls to execute a server-side function.
+ */
export interface DeepgramVoiceAgentFunctionEndpoint {
+ /** URL of the function endpoint. */
url: string;
+ /** HTTP method to use for the request. */
method?: string;
+ /** HTTP headers to include with the request. */
headers?: Record;
[key: string]: unknown;
}
+/**
+ * Definition of a function the agent can call during the conversation.
+ */
export interface DeepgramVoiceAgentFunctionConfig {
+ /** Name of the function. */
name: string;
+ /** Human-readable description of what the function does. */
description?: string;
+ /** JSON Schema describing the function's parameters. */
parameters?: Record;
+ /** Endpoint to call; if omitted, the function is executed client-side. */
endpoint?: DeepgramVoiceAgentFunctionEndpoint;
[key: string]: unknown;
}
+/**
+ * Speech-to-text configuration for the agent's `listen` stage.
+ */
export interface DeepgramVoiceAgentListenConfig {
+ /** Speech-to-text provider configuration. */
provider?: DeepgramVoiceAgentListenProvider;
[key: string]: unknown;
}
+/**
+ * LLM configuration for the agent's `think` stage.
+ */
export interface DeepgramVoiceAgentThinkConfig {
+ /** LLM provider configuration. */
provider?: DeepgramVoiceAgentThinkProvider;
+ /** Functions the agent can call during the conversation. */
functions?: DeepgramVoiceAgentFunctionConfig[];
+ /** System prompt that defines the agent's behavior and personality. */
prompt?: string;
+ /**
+ * Number of characters retained in context between messages, responses, and
+ * function calls. Use `'max'` for the provider's maximum context length.
+ */
context_length?: number;
[key: string]: unknown;
}
+/**
+ * Text-to-speech configuration for the agent's `speak` stage.
+ */
export interface DeepgramVoiceAgentSpeakConfig {
+ /** Text-to-speech provider configuration. */
provider?: Record;
[key: string]: unknown;
}
+/**
+ * Core agent configuration covering language, context, and the
+ * listen/think/speak provider pipeline.
+ */
export interface DeepgramVoiceAgentAgentConfig {
+ /**
+ * Language code for the agent. Defaults to `'en'`.
+ * @deprecated Use `listen.provider.language` and `speak.provider.language`.
+ */
language?: string;
+ /** Optional conversation context, including prior messages and function calls. */
context?: {
+ /** Previous conversation messages and function calls for agent context. */
messages?: DeepgramVoiceAgentContextMessage[];
[key: string]: unknown;
};
+ /** Speech-to-text configuration. */
listen?: DeepgramVoiceAgentListenConfig;
+ /** LLM (reasoning) configuration. */
think?: DeepgramVoiceAgentThinkConfig;
+ /** Text-to-speech configuration. */
speak?: DeepgramVoiceAgentSpeakConfig;
+ /** Initial message the agent speaks when the conversation starts. */
greeting?: string;
[key: string]: unknown;
}
@@ -113,54 +228,116 @@ export interface DeepgramVoiceAgentAgentConfig {
* @see https://developers.deepgram.com/docs/voice-agent
*/
export interface DeepgramVoiceAgentSettings {
+ /** Tags to associate with the request for filtered searching. */
tags?: string[];
+ /** Enables experimental features. Defaults to `false`. */
experimental?: boolean;
+ /** Feature flags for the session. */
flags?: DeepgramVoiceAgentFlags;
+ /** Opt out of the Model Improvement Program. Defaults to `false`. */
mip_opt_out?: boolean;
+ /** Input and output audio media configuration. */
audio?: DeepgramVoiceAgentAudioSettings;
+ /** Core agent (listen/think/speak) configuration. */
agent?: DeepgramVoiceAgentAgentConfig;
[key: string]: unknown;
}
+/**
+ * Settings message sent immediately after connecting to initialize the agent.
+ */
export interface DeepgramVoiceAgentSettingsMessage
extends DeepgramVoiceAgentSettings {
type: 'Settings';
}
+/**
+ * Update the `listen` (speech-to-text) configuration mid-conversation.
+ */
+export interface DeepgramVoiceAgentUpdateListenMessage {
+ type: 'UpdateListen';
+ /** New speech-to-text configuration to apply. */
+ listen: DeepgramVoiceAgentListenConfig;
+ [key: string]: unknown;
+}
+
+/**
+ * Update the `think` (LLM) configuration mid-conversation.
+ */
+export interface DeepgramVoiceAgentUpdateThinkMessage {
+ type: 'UpdateThink';
+ /** New LLM configuration to apply. */
+ think: DeepgramVoiceAgentThinkConfig;
+ [key: string]: unknown;
+}
+
+/**
+ * Update the `speak` (text-to-speech) configuration mid-conversation.
+ */
export interface DeepgramVoiceAgentUpdateSpeakMessage {
type: 'UpdateSpeak';
+ /** New text-to-speech configuration to apply. */
speak: DeepgramVoiceAgentSpeakConfig;
[key: string]: unknown;
}
+/**
+ * Inject a text-based user message into the conversation.
+ */
export interface DeepgramVoiceAgentInjectUserMessage {
type: 'InjectUserMessage';
+ /** Text content to inject as if spoken by the user. */
content: string;
[key: string]: unknown;
}
+/**
+ * Immediately trigger an agent statement mid-conversation.
+ */
export interface DeepgramVoiceAgentInjectAgentMessage {
type: 'InjectAgentMessage';
+ /** The statement the agent should say. */
message: string;
+ /**
+ * How the injection interacts with the current turn. `'default'` speaks only
+ * when neither party is mid-turn (otherwise the server replies with
+ * `InjectionRefused`); `'queue'` appends after any queued speech without
+ * interrupting. Defaults to `'default'`.
+ */
+ behavior?: string;
[key: string]: unknown;
}
+/**
+ * Client-sent response to a server `FunctionCallRequest`.
+ */
export interface DeepgramVoiceAgentFunctionCallResponseMessage {
type: 'FunctionCallResponse';
+ /** Identifier matching the originating function call request. */
id: string;
+ /** Name of the function that was called. */
name: string;
+ /** Result of the function call. */
content: string;
+ /** Whether the function was executed on the client. */
client_side?: boolean;
[key: string]: unknown;
}
+/**
+ * Keep-alive ping to prevent the WebSocket from timing out.
+ */
export interface DeepgramVoiceAgentKeepAliveMessage {
type: 'KeepAlive';
[key: string]: unknown;
}
+/**
+ * Update the agent's system prompt mid-conversation.
+ */
export interface DeepgramVoiceAgentUpdatePromptMessage {
type: 'UpdatePrompt';
+ /** New system prompt for the agent. */
prompt: string;
[key: string]: unknown;
}
@@ -170,6 +347,8 @@ export interface DeepgramVoiceAgentUpdatePromptMessage {
*/
export type DeepgramVoiceAgentClientMessage =
| DeepgramVoiceAgentSettingsMessage
+ | DeepgramVoiceAgentUpdateListenMessage
+ | DeepgramVoiceAgentUpdateThinkMessage
| DeepgramVoiceAgentUpdateSpeakMessage
| DeepgramVoiceAgentInjectUserMessage
| DeepgramVoiceAgentInjectAgentMessage
@@ -177,107 +356,223 @@ export type DeepgramVoiceAgentClientMessage =
| DeepgramVoiceAgentKeepAliveMessage
| DeepgramVoiceAgentUpdatePromptMessage;
+/**
+ * First message from the server confirming the connection is established.
+ */
export interface DeepgramVoiceAgentWelcomeMessage {
type: 'Welcome';
+ /** Unique identifier for the agent session. */
request_id: string;
[key: string]: unknown;
}
+/**
+ * Confirms the `Settings` message was received and applied.
+ */
export interface DeepgramVoiceAgentSettingsAppliedMessage {
type: 'SettingsApplied';
[key: string]: unknown;
}
+/**
+ * A finalized line of conversation text from either party.
+ */
export interface DeepgramVoiceAgentConversationTextMessage {
type: 'ConversationText';
+ /** Role of the speaker (e.g. `'user'`, `'assistant'`). */
role: string;
+ /** Text content of the conversation turn. */
content: string;
[key: string]: unknown;
}
+/**
+ * Emitted while the agent is generating a response.
+ */
export interface DeepgramVoiceAgentAgentThinkingMessage {
type: 'AgentThinking';
+ /** Intermediate reasoning content from the agent. */
content: string;
[key: string]: unknown;
}
+/**
+ * Emitted when the agent begins speaking, with latency diagnostics.
+ */
export interface DeepgramVoiceAgentAgentStartedSpeakingMessage {
type: 'AgentStartedSpeaking';
+ /** Total end-to-end latency before speech started, in seconds. */
total_latency?: number;
+ /** Text-to-speech latency component, in seconds. */
tts_latency?: number;
+ /** Time-to-think (LLM) latency component, in seconds. */
ttt_latency?: number;
[key: string]: unknown;
}
+/**
+ * Emitted after the agent has finished sending all audio for a turn.
+ */
export interface DeepgramVoiceAgentAgentAudioDoneMessage {
type: 'AgentAudioDone';
[key: string]: unknown;
}
+/**
+ * Emitted when the server detects the user has started speaking.
+ */
export interface DeepgramVoiceAgentUserStartedSpeakingMessage {
type: 'UserStartedSpeaking';
[key: string]: unknown;
}
+/**
+ * Server request asking the client to execute one or more functions.
+ */
export interface DeepgramVoiceAgentFunctionCallRequestMessage {
type: 'FunctionCallRequest';
+ /** Functions the client should execute. */
functions: DeepgramVoiceAgentFunctionDefinition[];
[key: string]: unknown;
}
+/**
+ * Server echo of a function call response (received form).
+ */
export interface DeepgramVoiceAgentReceiveFunctionCallResponseMessage {
type: 'FunctionCallResponse';
+ /** Identifier matching the originating function call request. */
id: string;
+ /** Name of the function that was called. */
name: string;
+ /** Result of the function call. */
content: string;
+ /** Whether the function was executed on the client. */
client_side?: boolean;
[key: string]: unknown;
}
+/**
+ * Confirms an `UpdatePrompt` message was applied.
+ */
export interface DeepgramVoiceAgentPromptUpdatedMessage {
type: 'PromptUpdated';
[key: string]: unknown;
}
+/**
+ * Confirms an `UpdateListen` message was applied.
+ */
+export interface DeepgramVoiceAgentListenUpdatedMessage {
+ type: 'ListenUpdated';
+ [key: string]: unknown;
+}
+
+/**
+ * Confirms an `UpdateThink` message was applied.
+ */
+export interface DeepgramVoiceAgentThinkUpdatedMessage {
+ type: 'ThinkUpdated';
+ [key: string]: unknown;
+}
+
+/**
+ * Confirms an `UpdateSpeak` message was applied.
+ */
export interface DeepgramVoiceAgentSpeakUpdatedMessage {
type: 'SpeakUpdated';
[key: string]: unknown;
}
+/**
+ * Emitted when an `InjectAgentMessage` is refused because a turn is in progress.
+ */
export interface DeepgramVoiceAgentInjectionRefusedMessage {
type: 'InjectionRefused';
+ /** Explanation of why the injection was refused. */
message: string;
[key: string]: unknown;
}
+/**
+ * Error message from the server.
+ */
export interface DeepgramVoiceAgentErrorMessage {
type: 'Error';
+ /** Human-readable description of the error. */
description: string;
+ /** Machine-readable error code, when available. */
code?: string;
[key: string]: unknown;
}
+/**
+ * Warning message from the server.
+ */
export interface DeepgramVoiceAgentWarningMessage {
type: 'Warning';
+ /** Human-readable description of the warning. */
description: string;
+ /** Machine-readable warning code, when available. */
code?: string;
[key: string]: unknown;
}
+/**
+ * Audio configuration descriptor accompanying the audio stream.
+ */
export interface DeepgramVoiceAgentAudioConfigMessage {
type: 'AudioConfig';
+ /** Sample rate in Hz. */
sample_rate?: number;
+ /** Number of audio channels. */
channels?: number;
+ /** Audio encoding format. */
encoding?: string;
[key: string]: unknown;
}
+/**
+ * Wrapper for a binary audio payload received over the WebSocket.
+ */
export interface DeepgramVoiceAgentAudioMessage {
type: 'Audio';
// Binary audio data will be in the WebSocket message payload
[key: string]: unknown;
}
+/**
+ * A single function call record inside a History message.
+ */
+export interface DeepgramVoiceAgentHistoryFunctionCall {
+ /** Unique identifier for the function call. */
+ id?: string;
+ /** Name of the function that was called. */
+ name?: string;
+ /** Whether the function was executed on the client. */
+ client_side?: boolean;
+ /** JSON-encoded arguments passed to the function. */
+ arguments?: string;
+ /** Result returned by the function. */
+ response?: string;
+ [key: string]: unknown;
+}
+
+/**
+ * Conversation history replay message. Each entry is either a conversation text
+ * (with role and content) or a function call record (with function_calls).
+ */
+export interface DeepgramVoiceAgentHistoryMessage {
+ type: 'History';
+ /** Role of the speaker for a conversation text entry. */
+ role?: string;
+ /** Text content for a conversation text entry. */
+ content?: string;
+ /** Function call records for a function call entry. */
+ function_calls?: DeepgramVoiceAgentHistoryFunctionCall[];
+ [key: string]: unknown;
+}
+
/**
* Union of all messages that can be received from the Voice Agent Server.
*/
@@ -292,10 +587,13 @@ export type DeepgramVoiceAgentServerMessage =
| DeepgramVoiceAgentFunctionCallRequestMessage
| DeepgramVoiceAgentReceiveFunctionCallResponseMessage
| DeepgramVoiceAgentPromptUpdatedMessage
+ | DeepgramVoiceAgentListenUpdatedMessage
+ | DeepgramVoiceAgentThinkUpdatedMessage
| DeepgramVoiceAgentSpeakUpdatedMessage
| DeepgramVoiceAgentInjectionRefusedMessage
| DeepgramVoiceAgentErrorMessage
| DeepgramVoiceAgentWarningMessage
+ | DeepgramVoiceAgentHistoryMessage
| DeepgramVoiceAgentAudioConfigMessage
| DeepgramVoiceAgentAudioMessage
| DeepgramVoiceAgentSettingsMessage
diff --git a/src/useDeepgramManagement.ts b/src/useDeepgramManagement.ts
index 9d6e08e..412432b 100644
--- a/src/useDeepgramManagement.ts
+++ b/src/useDeepgramManagement.ts
@@ -4,15 +4,18 @@ import type {
DeepgramTtsModel,
DeepgramListModelsResponse,
DeepgramProject,
+ DeepgramMessageResponse,
DeepgramKey,
+ DeepgramCreatedKey,
DeepgramMember,
DeepgramScope,
DeepgramInvitation,
DeepgramRequest,
- DeepgramUsageField,
+ DeepgramUsageFields,
DeepgramUsageBreakdown,
DeepgramPurchase,
DeepgramBalance,
+ DeepgramGrantTokenResponse,
UseDeepgramManagementReturn,
} from './types/deepgram';
import { DEEPGRAM_BASEURL } from './constants';
@@ -63,9 +66,12 @@ export function useDeepgramManagement(): UseDeepgramManagementReturn {
const models = useMemo(
() => ({
list: (includeOutdated = false, query?: Record) => {
- let path = dgPath('models');
- if (includeOutdated) path += '?include_outdated=true';
- return dgRequest(buildUrl(path, query));
+ const mergedQuery = includeOutdated
+ ? { ...query, include_outdated: true }
+ : query;
+ return dgRequest(
+ buildUrl(dgPath('models'), mergedQuery)
+ );
},
get: (modelId: string, query?: Record) =>
dgRequest(
@@ -98,11 +104,14 @@ export function useDeepgramManagement(): UseDeepgramManagementReturn {
body: Record,
query?: Record
) =>
- dgRequest(buildUrl(dgPath('projects', id), query), {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- }),
+ dgRequest(
+ buildUrl(dgPath('projects', id), query),
+ {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }
+ ),
listModels: (id: string, query?: Record) =>
dgRequest(
@@ -120,20 +129,24 @@ export function useDeepgramManagement(): UseDeepgramManagementReturn {
/** ------------------- KEYS ------------------- */
const keys = useMemo(
() => ({
- list: (pid: string, query?: Record) =>
- dgRequest(
+ list: async (pid: string, query?: Record) => {
+ const res = await dgRequest<{ api_keys: DeepgramKey[] }>(
buildUrl(dgPath('projects', pid, 'keys'), query)
- ),
+ );
+ return res.api_keys;
+ },
create: (pid: string, body: Record) =>
- dgRequest(dgPath('projects', pid, 'keys'), {
+ dgRequest(dgPath('projects', pid, 'keys'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
- get: (pid: string, kid: string, query?: Record) =>
- dgRequest(
+ get: async (pid: string, kid: string, query?: Record) => {
+ const res = await dgRequest<{ item: DeepgramKey }>(
buildUrl(dgPath('projects', pid, 'keys', kid), query)
- ),
+ );
+ return res.item;
+ },
delete: (pid: string, kid: string, query?: Record) =>
dgRequest(buildUrl(dgPath('projects', pid, 'keys', kid), query), {
method: 'DELETE',
@@ -145,10 +158,12 @@ export function useDeepgramManagement(): UseDeepgramManagementReturn {
/** ------------------- MEMBERS ------------------- */
const members = useMemo(
() => ({
- list: (pid: string, query?: Record) =>
- dgRequest(
+ list: async (pid: string, query?: Record) => {
+ const res = await dgRequest<{ members: DeepgramMember[] }>(
buildUrl(dgPath('projects', pid, 'members'), query)
- ),
+ );
+ return res.members;
+ },
delete: (pid: string, mid: string) =>
dgRequest(dgPath('projects', pid, 'members', mid), {
method: 'DELETE',
@@ -160,12 +175,14 @@ export function useDeepgramManagement(): UseDeepgramManagementReturn {
/** ------------------- SCOPES ------------------- */
const scopes = useMemo(
() => ({
- list: (pid: string, mid: string, query?: Record) =>
- dgRequest(
+ list: async (pid: string, mid: string, query?: Record) => {
+ const res = await dgRequest<{ scopes: DeepgramScope[] }>(
buildUrl(dgPath('projects', pid, 'members', mid, 'scopes'), query)
- ),
+ );
+ return res.scopes;
+ },
update: (pid: string, mid: string, body: Record) =>
- dgRequest(
+ dgRequest(
dgPath('projects', pid, 'members', mid, 'scopes'),
{
method: 'PUT',
@@ -180,25 +197,26 @@ export function useDeepgramManagement(): UseDeepgramManagementReturn {
/** ------------------- INVITATIONS ------------------- */
const invitations = useMemo(
() => ({
- list: (pid: string, query?: Record) =>
- dgRequest(
- buildUrl(dgPath('projects', pid, 'invitations'), query)
- ),
+ list: async (pid: string, query?: Record) => {
+ const res = await dgRequest<{ invites: DeepgramInvitation[] }>(
+ buildUrl(dgPath('projects', pid, 'invites'), query)
+ );
+ return res.invites;
+ },
create: (pid: string, body: Record) =>
- dgRequest(dgPath('projects', pid, 'invitations'), {
+ dgRequest(dgPath('projects', pid, 'invites'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
- delete: (pid: string, inviteId: string) =>
- dgRequest(dgPath('projects', pid, 'invitations', inviteId), {
+ delete: (pid: string, email: string) =>
+ dgRequest(dgPath('projects', pid, 'invites', email), {
+ method: 'DELETE',
+ }),
+ leave: (pid: string) =>
+ dgRequest(dgPath('projects', pid, 'leave'), {
method: 'DELETE',
}),
- leave: (pid: string, query?: Record) =>
- dgRequest(
- buildUrl(dgPath('projects', pid, 'invitations'), query),
- { method: 'DELETE' }
- ),
}),
[dgRequest]
);
@@ -206,16 +224,24 @@ export function useDeepgramManagement(): UseDeepgramManagementReturn {
/** ------------------- USAGE ------------------- */
const usage = useMemo(
() => ({
- listRequests: (pid: string, query?: Record) =>
- dgRequest(
+ listRequests: async (pid: string, query?: Record) => {
+ const res = await dgRequest<{ requests: DeepgramRequest[] }>(
buildUrl(dgPath('projects', pid, 'requests'), query)
- ),
- getRequest: (pid: string, rid: string, query?: Record) =>
- dgRequest(
+ );
+ return res.requests;
+ },
+ getRequest: async (
+ pid: string,
+ rid: string,
+ query?: Record
+ ) => {
+ const res = await dgRequest<{ request: DeepgramRequest }>(
buildUrl(dgPath('projects', pid, 'requests', rid), query)
- ),
+ );
+ return res.request;
+ },
listFields: (pid: string, query?: Record) =>
- dgRequest(
+ dgRequest(
buildUrl(dgPath('projects', pid, 'usage', 'fields'), query)
),
getBreakdown: (pid: string, query?: Record) =>
@@ -240,10 +266,12 @@ export function useDeepgramManagement(): UseDeepgramManagementReturn {
/** ------------------- BALANCES ------------------- */
const balances = useMemo(
() => ({
- list: (pid: string, query?: Record) =>
- dgRequest(
+ list: async (pid: string, query?: Record) => {
+ const res = await dgRequest<{ balances: DeepgramBalance[] }>(
buildUrl(dgPath('projects', pid, 'balances'), query)
- ),
+ );
+ return res.balances;
+ },
get: (pid: string, bid: string, query?: Record) =>
dgRequest(
buildUrl(dgPath('projects', pid, 'balances', bid), query)
@@ -252,6 +280,19 @@ export function useDeepgramManagement(): UseDeepgramManagementReturn {
[dgRequest]
);
+ /** ------------------- AUTH (TEMPORARY TOKENS) ------------------- */
+ const auth = useMemo(
+ () => ({
+ grant: (body?: { ttl_seconds?: number }) =>
+ dgRequest(dgPath('auth', 'grant'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body ?? {}),
+ }),
+ }),
+ [dgRequest]
+ );
+
useEffect(() => {
const controllers = controllersRef.current;
return () => {
@@ -270,5 +311,6 @@ export function useDeepgramManagement(): UseDeepgramManagementReturn {
usage,
purchases,
balances,
+ auth,
};
}
diff --git a/src/useDeepgramSpeechToText.ts b/src/useDeepgramSpeechToText.ts
index 1db98ed..b22a3c4 100644
--- a/src/useDeepgramSpeechToText.ts
+++ b/src/useDeepgramSpeechToText.ts
@@ -1,4 +1,4 @@
-import { useRef, useCallback, useState } from 'react';
+import { useRef, useCallback, useState, useEffect } from 'react';
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
import { Deepgram } from './NativeDeepgram';
import { askMicPermission } from './helpers/askMicPermission';
@@ -20,6 +20,19 @@ import { buildParams } from './helpers';
const DEFAULT_SAMPLE_RATE = 16_000;
const BASE_NATIVE_SAMPLE_RATE = 16_000;
+let cachedEmitter: NativeEventEmitter | null = null;
+const getEmitter = (): NativeEventEmitter => {
+ if (!cachedEmitter) {
+ cachedEmitter = new NativeEventEmitter(NativeModules.Deepgram);
+ }
+ return cachedEmitter;
+};
+const AUDIO_EVENT = Platform.select({
+ ios: 'DeepgramAudioPCM',
+ android: 'AudioChunk',
+ default: 'DeepgramAudioPCM',
+}) as string;
+
const computeDownsampleFactor = (
target: number | undefined,
base: number = BASE_NATIVE_SAMPLE_RATE
@@ -41,7 +54,7 @@ const downsampleInt16 = (
const downsampled = new Int16Array(Math.floor(data.length / factor));
for (let i = 0; i < downsampled.length; i++) {
- downsampled[i] = data[i * factor];
+ downsampled[i] = data[i * factor]!;
}
return downsampled as Int16Array;
};
@@ -78,12 +91,31 @@ export function useDeepgramSpeechToText({
const lastPartialTranscriptRef = useRef('');
const lastFinalTranscriptRef = useRef('');
- // Transcript tracking state
+ const onTranscriptRef = useRef(onTranscript);
+ const onErrorRef = useRef(onError);
+ const onEndRef = useRef(onEnd);
+ const onStartRef = useRef(onStart);
+ const onBeforeStartRef = useRef(onBeforeStart);
+ const onBeforeTranscribeRef = useRef(onBeforeTranscribe);
+ const onTranscribeSuccessRef = useRef(onTranscribeSuccess);
+ const onTranscribeErrorRef = useRef(onTranscribeError);
+
+ onTranscriptRef.current = onTranscript;
+ onErrorRef.current = onError;
+ onEndRef.current = onEnd;
+ onStartRef.current = onStart;
+ onBeforeStartRef.current = onBeforeStart;
+ onBeforeTranscribeRef.current = onBeforeTranscribe;
+ onTranscribeSuccessRef.current = onTranscribeSuccess;
+ onTranscribeErrorRef.current = onTranscribeError;
+
const [internalTranscript, setInternalTranscript] = useState('');
const [internalInterimTranscript, setInternalInterimTranscript] =
useState('');
- const closeEverything = useCallback(() => {
+ const endFiredRef = useRef(false);
+
+ const closeResources = useCallback(() => {
if (audioSub.current) {
audioSub.current.remove();
audioSub.current = null;
@@ -100,9 +132,7 @@ export function useDeepgramSpeechToText({
) {
try {
ws.current.send(JSON.stringify({ type: 'CloseStream' }));
- } catch {
- // ignore close errors
- }
+ } catch {}
}
ws.current?.close(1000, 'cleanup');
ws.current = null;
@@ -116,12 +146,14 @@ export function useDeepgramSpeechToText({
}
}, [trackState, trackTranscript]);
+ const fireEnd = useCallback(() => {
+ if (endFiredRef.current) return;
+ endFiredRef.current = true;
+ onEndRef.current();
+ }, []);
+
const emitTranscript = useCallback(
(transcript: unknown, isFinal: boolean, raw: unknown) => {
- if (typeof onTranscript !== 'function') {
- return;
- }
-
if (typeof transcript !== 'string') {
return;
}
@@ -158,19 +190,20 @@ export function useDeepgramSpeechToText({
}
}
- onTranscript(normalized, info);
+ onTranscriptRef.current(normalized, info);
if (isFinal) {
lastPartialTranscriptRef.current = '';
}
},
- [onTranscript, trackTranscript]
+ [trackTranscript]
);
const startListening = useCallback(
async (overrideOptions: DeepgramLiveListenOptions = {}) => {
try {
- onBeforeStart();
+ onBeforeStartRef.current();
+ endFiredRef.current = false;
if (trackState) {
setInternalState({ status: 'loading', error: null });
}
@@ -185,11 +218,14 @@ export function useDeepgramSpeechToText({
const apiKey = (globalThis as any).__DEEPGRAM_API_KEY__;
if (!apiKey) throw new Error('Deepgram API key missing');
+ const requestedVersion: 'v1' | 'v2' =
+ overrideOptions.apiVersion ?? live.apiVersion ?? 'v1';
+
const merged: DeepgramLiveListenOptions = {
encoding: 'linear16',
sampleRate: DEFAULT_SAMPLE_RATE,
- model: 'nova-2',
- apiVersion: 'v1',
+ model: requestedVersion === 'v2' ? 'flux-general-en' : 'nova-3',
+ apiVersion: requestedVersion,
...live,
...overrideOptions,
};
@@ -203,13 +239,20 @@ export function useDeepgramSpeechToText({
nativeInputSampleRateRef.current
);
- if (merged.apiVersion === 'v2' && !merged.model) {
- merged.model = 'flux-general-en';
- }
-
const isV2 = merged.apiVersion === 'v2';
apiVersionRef.current = isV2 ? 'v2' : 'v1';
-
+ const usesKeyterm =
+ isV2 ||
+ (typeof merged.model === 'string' &&
+ merged.model.startsWith('nova-3'));
+ const keyterm =
+ merged.keyterm ?? (usesKeyterm ? merged.keywords : undefined);
+ const keywords = usesKeyterm ? undefined : merged.keywords;
+
+ // Per Deepgram API reference, Flux (v2) only accepts a small set of
+ // query parameters. Sending v1-only params can cause the server to
+ // reject the connection or be silently ignored. Build the query map
+ // separately for each API version.
const query: Record<
string,
| string
@@ -218,42 +261,52 @@ export function useDeepgramSpeechToText({
| null
| undefined
| Array
- > = {
- callback: merged.callback,
- callback_method: merged.callbackMethod,
- channels: merged.channels,
- diarize: merged.diarize,
- dictation: merged.dictation,
- encoding: merged.encoding,
- endpointing: merged.endpointing,
- filler_words: merged.fillerWords,
- interim_results: merged.interimResults,
- keyterm: merged.keyterm,
- keywords: merged.keywords,
- language: merged.language,
- mip_opt_out: merged.mipOptOut,
- model: merged.model,
- multichannel: merged.multichannel,
- numerals: merged.numerals,
- profanity_filter: merged.profanityFilter,
- punctuate: merged.punctuate,
- replace: merged.replace,
- sample_rate: merged.sampleRate,
- search: merged.search,
- smart_format: merged.smartFormat,
- tag: merged.tag,
- utterance_end_ms: merged.utteranceEndMs,
- vad_events: merged.vadEvents,
- version: merged.version,
- };
-
- if (isV2) {
- query.eager_eot_threshold = merged.eagerEotThreshold;
- query.eot_threshold = merged.eotThreshold;
- query.eot_timeout_ms = merged.eotTimeoutMs;
- }
-
- if (merged.redact) {
+ > = isV2
+ ? {
+ model: merged.model,
+ encoding: merged.encoding,
+ sample_rate: merged.sampleRate,
+ eager_eot_threshold: merged.eagerEotThreshold,
+ eot_threshold: merged.eotThreshold,
+ eot_timeout_ms: merged.eotTimeoutMs,
+ keyterm,
+ language_hint: merged.languageHint,
+ profanity_filter: merged.profanityFilter,
+ mip_opt_out: merged.mipOptOut,
+ tag: merged.tag,
+ }
+ : {
+ callback: merged.callback,
+ callback_method: merged.callbackMethod,
+ channels: merged.channels,
+ detect_entities: merged.detectEntities,
+ diarize: merged.diarize,
+ diarize_model: merged.diarizeModel,
+ dictation: merged.dictation,
+ encoding: merged.encoding,
+ endpointing: merged.endpointing,
+ filler_words: merged.fillerWords,
+ interim_results: merged.interimResults,
+ keyterm,
+ keywords,
+ language: merged.language,
+ mip_opt_out: merged.mipOptOut,
+ model: merged.model,
+ multichannel: merged.multichannel,
+ numerals: merged.numerals,
+ profanity_filter: merged.profanityFilter,
+ punctuate: merged.punctuate,
+ replace: merged.replace,
+ sample_rate: merged.sampleRate,
+ search: merged.search,
+ smart_format: merged.smartFormat,
+ tag: merged.tag,
+ utterance_end_ms: merged.utteranceEndMs,
+ vad_events: merged.vadEvents,
+ version: merged.version,
+ };
+
+ if (!isV2 && merged.redact) {
query.redact = Array.isArray(merged.redact)
? merged.redact
: [merged.redact];
@@ -276,98 +329,72 @@ export function useDeepgramSpeechToText({
});
ws.current.onopen = () => {
- onStart();
+ onStartRef.current();
if (trackState) {
setInternalState({ status: 'listening', error: null });
}
};
- const emitter = new NativeEventEmitter(NativeModules.Deepgram);
- audioSub.current = emitter.addListener(
- Platform.select({
- ios: 'DeepgramAudioPCM',
- android: 'AudioChunk',
- }) as string,
- (ev: any) => {
- if (typeof ev?.sampleRate === 'number' && ev.sampleRate > 0) {
- if (ev.sampleRate !== nativeInputSampleRateRef.current) {
- nativeInputSampleRateRef.current = ev.sampleRate;
- downsampleFactorRef.current = computeDownsampleFactor(
- targetSampleRateRef.current,
- nativeInputSampleRateRef.current
- );
- }
- }
-
- const factor = downsampleFactorRef.current;
- let chunk: ArrayBufferLike | undefined;
- if (typeof ev?.b64 === 'string') {
- const bytes = Uint8Array.from(atob(ev.b64), (c) =>
- c.charCodeAt(0)
- );
- let int16: Int16Array = new Int16Array(
- bytes.buffer
+ audioSub.current = getEmitter().addListener(AUDIO_EVENT, (ev: any) => {
+ if (typeof ev?.sampleRate === 'number' && ev.sampleRate > 0) {
+ if (ev.sampleRate !== nativeInputSampleRateRef.current) {
+ nativeInputSampleRateRef.current = ev.sampleRate;
+ downsampleFactorRef.current = computeDownsampleFactor(
+ targetSampleRateRef.current,
+ nativeInputSampleRateRef.current
);
- int16 = downsampleInt16(int16, factor);
- chunk = int16.buffer;
- } else if (Array.isArray(ev?.data)) {
- const bytes = new Uint8Array(ev.data.length);
- for (let i = 0; i < ev.data.length; i++) {
- const v = ev.data[i];
- bytes[i] = v < 0 ? v + 256 : v;
- }
- const view = new DataView(bytes.buffer);
- const int16 = new Int16Array(bytes.length / 2);
- for (let i = 0; i < int16.length; i++) {
- int16[i] = view.getInt16(i * 2, true);
- }
- const downsampled = downsampleInt16(
- int16 as Int16Array,
- factor
- );
- chunk = downsampled.buffer;
}
+ }
- if (chunk && ws.current?.readyState === WebSocket.OPEN) {
- ws.current.send(chunk);
- }
+ const factor = downsampleFactorRef.current;
+ let chunk: ArrayBuffer | undefined;
+ if (typeof ev?.b64 === 'string') {
+ const bytes = Uint8Array.from(atob(ev.b64), (c) => c.charCodeAt(0));
+ let int16 = new Int16Array(bytes.buffer);
+ int16 = downsampleInt16(int16, factor) as Int16Array;
+ chunk = int16.buffer as ArrayBuffer;
}
- );
- ws.current.onmessage = (ev) => {
+ if (chunk && ws.current?.readyState === WebSocket.OPEN) {
+ ws.current.send(chunk);
+ }
+ });
+
+ ws.current.onmessage = (ev: any) => {
if (typeof ev.data === 'string') {
try {
const msg = JSON.parse(ev.data);
if (isV2) {
+ // Flux (v2) message envelope reference:
+ // https://developers.deepgram.com/reference/speech-to-text-api/listen-flux
+ // - "Connected" β handshake ack (no transcript)
+ // - "TurnInfo" β carries transcript + an `event` field
+ // ("Update" | "StartOfTurn" | "EagerEndOfTurn"
+ // | "TurnResumed" | "EndOfTurn")
+ // - "ConfigureSuccess" / "ConfigureFailure"
+ // - "Error" β fatal stream error
if (msg.type === 'Error') {
const description =
msg.description || 'Deepgram stream error';
- onError(new Error(description));
+ onErrorRef.current(new Error(description));
if (trackState) {
setInternalState({
status: 'error',
error: new Error(description),
});
}
- closeEverything();
+ closeResources();
+ return;
+ }
+
+ if (msg.type !== 'TurnInfo') {
return;
}
const transcript = msg.transcript;
if (typeof transcript === 'string' && transcript.length > 0) {
- const type =
- typeof msg.type === 'string'
- ? msg.type.toLowerCase()
- : undefined;
- const isFinal =
- msg.is_final === true ||
- msg.speech_final === true ||
- msg.finished === true ||
- type === 'utteranceend' ||
- type === 'speechfinal' ||
- type === 'speech.end' ||
- (typeof type === 'string' && type.includes('final'));
- emitTranscript(transcript, Boolean(isFinal), msg);
+ const isFinal = msg.event === 'EndOfTurn';
+ emitTranscript(transcript, isFinal, msg);
}
return;
}
@@ -378,14 +405,12 @@ export function useDeepgramSpeechToText({
msg.is_final === true || msg.speech_final === true;
emitTranscript(transcript, Boolean(isFinal), msg);
}
- } catch {
- // non-JSON or unexpected format
- }
+ } catch {}
}
};
- ws.current.onerror = (err) => {
- onError(err);
+ ws.current.onerror = (err: any) => {
+ onErrorRef.current(err);
if (trackState) {
setInternalState({
status: 'error',
@@ -394,38 +419,29 @@ export function useDeepgramSpeechToText({
}
};
ws.current.onclose = () => {
- onEnd();
- closeEverything();
+ closeResources();
+ fireEnd();
};
} catch (err) {
- onError(err);
+ onErrorRef.current(err);
if (trackState) {
setInternalState({
status: 'error',
error: err instanceof Error ? err : new Error(String(err)),
});
}
- closeEverything();
+ closeResources();
}
},
- [
- emitTranscript,
- onBeforeStart,
- onStart,
- onError,
- onEnd,
- live,
- closeEverything,
- trackState,
- ]
+ [emitTranscript, live, closeResources, trackState, fireEnd]
);
const stopListening = useCallback(() => {
try {
- closeEverything();
- onEnd();
+ closeResources();
+ fireEnd();
} catch (err) {
- onError(err);
+ onErrorRef.current(err);
if (trackState) {
setInternalState({
status: 'error',
@@ -433,14 +449,14 @@ export function useDeepgramSpeechToText({
});
}
}
- }, [onEnd, onError, closeEverything, trackState]);
+ }, [closeResources, trackState, fireEnd]);
const transcribeFile = useCallback(
async (
file: DeepgramPrerecordedSource,
overrideOptions: DeepgramPrerecordedOptions = {}
) => {
- onBeforeTranscribe();
+ onBeforeTranscribeRef.current();
if (trackState) {
setInternalState({ status: 'transcribing', error: null });
}
@@ -473,6 +489,7 @@ export function useDeepgramSpeechToText({
custom_intent_mode: merged.customIntentMode,
detect_entities: merged.detectEntities,
diarize: merged.diarize,
+ diarize_model: merged.diarizeModel,
dictation: merged.dictation,
encoding: merged.encoding,
filler_words: merged.fillerWords,
@@ -480,6 +497,7 @@ export function useDeepgramSpeechToText({
keywords: merged.keywords,
language: merged.language,
measurements: merged.measurements,
+ mip_opt_out: merged.mipOptOut,
model: merged.model,
multichannel: merged.multichannel,
numerals: merged.numerals,
@@ -503,11 +521,7 @@ export function useDeepgramSpeechToText({
}
if (merged.detectLanguage !== undefined) {
- if (typeof merged.detectLanguage === 'boolean') {
- query.detect_language = merged.detectLanguage;
- } else {
- query.detect_language = merged.detectLanguage;
- }
+ query.detect_language = merged.detectLanguage;
}
if (merged.redact) {
@@ -571,7 +585,7 @@ export function useDeepgramSpeechToText({
const transcript =
json.results?.channels?.[0]?.alternatives?.[0]?.transcript;
if (transcript) {
- onTranscribeSuccess(transcript);
+ onTranscribeSuccessRef.current(transcript);
if (trackState) {
setInternalState({ status: 'idle', error: null });
}
@@ -579,7 +593,7 @@ export function useDeepgramSpeechToText({
throw new Error('No transcript present in Deepgram response');
}
} catch (err) {
- onTranscribeError(err);
+ onTranscribeErrorRef.current(err);
if (trackState) {
setInternalState({
status: 'error',
@@ -588,15 +602,11 @@ export function useDeepgramSpeechToText({
}
}
},
- [
- onBeforeTranscribe,
- onTranscribeSuccess,
- onTranscribeError,
- prerecorded,
- trackState,
- ]
+ [prerecorded, trackState]
);
+ useEffect(() => () => closeResources(), [closeResources]);
+
return {
startListening,
stopListening,
diff --git a/src/useDeepgramTextIntelligence.ts b/src/useDeepgramTextIntelligence.ts
index 23a5061..0f107e0 100644
--- a/src/useDeepgramTextIntelligence.ts
+++ b/src/useDeepgramTextIntelligence.ts
@@ -14,6 +14,16 @@ export function useDeepgramTextIntelligence({
options = {},
trackState = false,
}: UseDeepgramTextIntelligenceProps = {}): UseDeepgramTextIntelligenceReturn {
+ const onBeforeAnalyzeRef = useRef(onBeforeAnalyze);
+ const onAnalyzeSuccessRef = useRef(onAnalyzeSuccess);
+ const onAnalyzeErrorRef = useRef(onAnalyzeError);
+
+ useEffect(() => {
+ onBeforeAnalyzeRef.current = onBeforeAnalyze;
+ onAnalyzeSuccessRef.current = onAnalyzeSuccess;
+ onAnalyzeErrorRef.current = onAnalyzeError;
+ });
+
const [internalState, setInternalState] = useState<{
status: 'idle' | 'loading' | 'analyzing' | 'error';
error: Error | null;
@@ -33,13 +43,14 @@ export function useDeepgramTextIntelligence({
customIntentMode,
sentiment,
language,
+ tag,
callback,
callbackMethod,
} = options;
const analyze = useCallback(
async (input: DeepgramTextIntelligenceInput) => {
- onBeforeAnalyze();
+ onBeforeAnalyzeRef.current();
if (trackState) {
setInternalState({ status: 'analyzing', error: null });
@@ -63,6 +74,7 @@ export function useDeepgramTextIntelligence({
intents,
sentiment,
language,
+ tag,
custom_topic: customTopic,
custom_topic_mode: customTopicMode,
custom_intent: customIntent,
@@ -96,13 +108,13 @@ export function useDeepgramTextIntelligence({
}
const json = await res.json();
- onAnalyzeSuccess(json);
+ onAnalyzeSuccessRef.current(json);
if (trackState) {
setInternalState({ status: 'idle', error: null });
}
} catch (err: any) {
if (err.name === 'AbortError') return;
- onAnalyzeError(err);
+ onAnalyzeErrorRef.current(err);
if (trackState) {
setInternalState({
status: 'error',
@@ -112,9 +124,6 @@ export function useDeepgramTextIntelligence({
}
},
[
- onBeforeAnalyze,
- onAnalyzeSuccess,
- onAnalyzeError,
summarize,
topics,
customTopic,
@@ -124,6 +133,7 @@ export function useDeepgramTextIntelligence({
customIntentMode,
sentiment,
language,
+ tag,
callback,
callbackMethod,
trackState,
diff --git a/src/useDeepgramTextToSpeech.ts b/src/useDeepgramTextToSpeech.ts
index d82020e..ad3f253 100644
--- a/src/useDeepgramTextToSpeech.ts
+++ b/src/useDeepgramTextToSpeech.ts
@@ -1,7 +1,5 @@
-import { Buffer } from 'buffer';
-if (!globalThis.Buffer) globalThis.Buffer = Buffer;
import { useRef, useCallback, useEffect, useMemo, useState } from 'react';
-import { NativeModules } from 'react-native';
+import { Deepgram } from './NativeDeepgram';
import type {
UseDeepgramTextToSpeechProps,
UseDeepgramTextToSpeechReturn,
@@ -16,7 +14,7 @@ import type {
DeepgramTextToSpeechStreamEncoding,
} from './types';
import { DEEPGRAM_BASEURL, DEEPGRAM_BASEWSS } from './constants';
-import { buildParams } from './helpers';
+import { buildParams, arrayBufferToBase64 } from './helpers';
const DEFAULT_TTS_MODEL = 'aura-2-asteria-en';
const DEFAULT_TTS_SAMPLE_RATE = 24_000;
@@ -57,6 +55,14 @@ const ensureQueryParam = (
params[key] = value;
};
+const getAuthorizationHeader = (apiKeyOrToken: string): string => {
+ const trimmed = apiKeyOrToken.trim();
+ if (/^(Token|Bearer)\s+/i.test(trimmed)) {
+ return trimmed;
+ }
+ return `Token ${trimmed}`;
+};
+
const isMetadataMessage = (
message: DeepgramTextToSpeechStreamResponseMessage
): message is DeepgramTextToSpeechStreamMetadataMessage =>
@@ -94,53 +100,26 @@ const asErrorMessage = (
? (message as DeepgramTextToSpeechStreamErrorMessage)
: null;
-/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- Wrap the unified native module
- ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
-const Deepgram = (() => {
- /** Throws if the native side isnβt linked */
- function getModule() {
- const mod = NativeModules.Deepgram;
- if (!mod) {
- throw new Error(
- 'Deepgram native module not found. ' +
- 'Did you rebuild the app after installing / adding the module?'
- );
- }
- return mod as {
- /** Initialise playback engine */
- startPlayer(sampleRate: number, channels: 1 | 2): void;
- /** Set audio configuration */
- setAudioConfig(sampleRate: number, channels: 1 | 2): void;
- /** Feed a base-64 PCM chunk */
- feedAudio(base64Pcm: string): void;
- /** Play a single audio chunk */
- playAudioChunk(base64Pcm: string): Promise;
- /** Stop / reset the player */
- stopPlayer(): void;
- };
- }
-
- return {
- startPlayer: (sr = 16_000, ch: 1 | 2 = 1) =>
- getModule().startPlayer(sr, ch),
-
- setAudioConfig: (sr = 16_000, ch: 1 | 2 = 1) =>
- getModule().setAudioConfig(sr, ch),
+/* Native player helpers β delegates to shared Deepgram import */
+const NativePlayer = {
+ startPlayer: (sr = 16_000, ch: 1 | 2 = 1) => Deepgram.startPlayer(sr, ch),
- feedAudio: (chunk: ArrayBuffer | Uint8Array) => {
- const u8 = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
- getModule().feedAudio(Buffer.from(u8).toString('base64'));
- },
+ feedAudio: (chunk: ArrayBuffer | Uint8Array) => {
+ const b64 = arrayBufferToBase64(
+ chunk instanceof Uint8Array ? (chunk.buffer as ArrayBuffer) : chunk
+ );
+ Deepgram.feedAudio(b64);
+ },
- playAudioChunk: (chunk: ArrayBuffer | Uint8Array) => {
- const u8 = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
- return getModule().playAudioChunk(Buffer.from(u8).toString('base64'));
- },
+ playAudioChunk: (chunk: ArrayBuffer | Uint8Array) => {
+ const b64 = arrayBufferToBase64(
+ chunk instanceof Uint8Array ? (chunk.buffer as ArrayBuffer) : chunk
+ );
+ return Deepgram.playAudioChunk(b64);
+ },
- stopPlayer: () => getModule().stopPlayer(),
- };
-})();
+ stopPlayer: () => Deepgram.stopPlayer(),
+};
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Hook: useDeepgramTextToSpeech
@@ -162,6 +141,36 @@ export function useDeepgramTextToSpeech({
autoPlayAudio = true,
trackState = false,
}: UseDeepgramTextToSpeechProps = {}): UseDeepgramTextToSpeechReturn {
+ /* ---------- Stable refs for callbacks ---------- */
+ const onBeforeSynthesizeRef = useRef(onBeforeSynthesize);
+ const onSynthesizeSuccessRef = useRef(onSynthesizeSuccess);
+ const onSynthesizeErrorRef = useRef(onSynthesizeError);
+ const onBeforeStreamRef = useRef(onBeforeStream);
+ const onStreamStartRef = useRef(onStreamStart);
+ const onAudioChunkRef = useRef(onAudioChunk);
+ const onStreamErrorRef = useRef(onStreamError);
+ const onStreamEndRef = useRef(onStreamEnd);
+ const onStreamMetadataRef = useRef(onStreamMetadata);
+ const onStreamFlushedRef = useRef(onStreamFlushed);
+ const onStreamClearedRef = useRef(onStreamCleared);
+ const onStreamWarningRef = useRef(onStreamWarning);
+
+ useEffect(() => {
+ onBeforeSynthesizeRef.current = onBeforeSynthesize;
+ onSynthesizeSuccessRef.current = onSynthesizeSuccess;
+ onSynthesizeErrorRef.current = onSynthesizeError;
+ onBeforeStreamRef.current = onBeforeStream;
+ onStreamStartRef.current = onStreamStart;
+ onAudioChunkRef.current = onAudioChunk;
+ onStreamErrorRef.current = onStreamError;
+ onStreamEndRef.current = onStreamEnd;
+ onStreamMetadataRef.current = onStreamMetadata;
+ onStreamFlushedRef.current = onStreamFlushed;
+ onStreamClearedRef.current = onStreamCleared;
+ onStreamWarningRef.current = onStreamWarning;
+ });
+
+ const streamEndFiredRef = useRef(false);
const [internalState, setInternalState] = useState<{
status: 'idle' | 'loading' | 'connecting' | 'connected' | 'error';
error: Error | null;
@@ -214,6 +223,8 @@ export function useDeepgramTextToSpeech({
container,
format: options.http?.format ?? options.format,
bitRate,
+ speed: options.http?.speed ?? options.speed,
+ tag: options.http?.tag ?? options.tag,
callback: options.http?.callback ?? options.callback,
callbackMethod: options.http?.callbackMethod ?? options.callbackMethod,
mipOptOut: options.http?.mipOptOut ?? options.mipOptOut,
@@ -240,6 +251,7 @@ export function useDeepgramTextToSpeech({
model,
sampleRate,
encoding,
+ speed: options.stream?.speed ?? options.speed,
mipOptOut: options.stream?.mipOptOut ?? options.mipOptOut,
queryParams: {
...(options.queryParams ?? {}),
@@ -254,7 +266,7 @@ export function useDeepgramTextToSpeech({
const synthesize = useCallback(
async (text: string) => {
- onBeforeSynthesize();
+ onBeforeSynthesizeRef.current();
if (trackState) {
setInternalState({ status: 'loading', error: null });
}
@@ -281,6 +293,8 @@ export function useDeepgramTextToSpeech({
);
ensureQueryParam(httpParams, 'format', resolvedHttpOptions.format);
ensureQueryParam(httpParams, 'bit_rate', resolvedHttpOptions.bitRate);
+ ensureQueryParam(httpParams, 'speed', resolvedHttpOptions.speed);
+ ensureQueryParam(httpParams, 'tag', resolvedHttpOptions.tag);
ensureQueryParam(httpParams, 'callback', resolvedHttpOptions.callback);
ensureQueryParam(
httpParams,
@@ -304,7 +318,7 @@ export function useDeepgramTextToSpeech({
const res = await fetch(url, {
method: 'POST',
headers: {
- 'Authorization': `Token ${apiKey}`,
+ 'Authorization': getAuthorizationHeader(apiKey),
'Content-Type': 'application/json',
'Accept': 'application/octet-stream',
},
@@ -318,9 +332,9 @@ export function useDeepgramTextToSpeech({
}
const audio = await res.arrayBuffer();
- await Deepgram.playAudioChunk(audio);
+ await NativePlayer.playAudioChunk(audio);
- onSynthesizeSuccess(audio);
+ onSynthesizeSuccessRef.current(audio);
if (trackState) {
setInternalState({ status: 'idle', error: null });
}
@@ -330,7 +344,7 @@ export function useDeepgramTextToSpeech({
throw err;
}
- onSynthesizeError(err);
+ onSynthesizeErrorRef.current(err);
if (trackState) {
setInternalState({
status: 'error',
@@ -340,13 +354,7 @@ export function useDeepgramTextToSpeech({
throw err;
}
},
- [
- onBeforeSynthesize,
- onSynthesizeSuccess,
- onSynthesizeError,
- resolvedHttpOptions,
- trackState,
- ]
+ [resolvedHttpOptions, trackState]
);
/* ---------- WebSocket (streaming synth) ---------- */
@@ -356,7 +364,7 @@ export function useDeepgramTextToSpeech({
ws.current?.close(1000, 'cleanup');
ws.current = null;
if (autoPlayAudio) {
- Deepgram.stopPlayer();
+ NativePlayer.stopPlayer();
}
if (trackState) {
setInternalState((prev) => ({ ...prev, status: 'idle' }));
@@ -370,10 +378,14 @@ export function useDeepgramTextToSpeech({
}
try {
- ws.current.send(JSON.stringify(message));
+ const normalizedMessage =
+ message.type === 'Text'
+ ? { ...message, type: 'Speak' as const }
+ : message;
+ ws.current.send(JSON.stringify(normalizedMessage));
return true;
} catch (err) {
- onStreamError(err);
+ onStreamErrorRef.current(err);
if (trackState) {
setInternalState({
status: 'error',
@@ -383,7 +395,7 @@ export function useDeepgramTextToSpeech({
return false;
}
},
- [onStreamError, trackState]
+ [trackState]
);
const flushStream = useCallback(
@@ -413,7 +425,7 @@ export function useDeepgramTextToSpeech({
}
const didSend = sendMessage({
- type: 'Text',
+ type: 'Speak',
text: trimmed,
...(config?.sequenceId != null
? { sequence_id: config.sequenceId }
@@ -433,7 +445,8 @@ export function useDeepgramTextToSpeech({
const startStreaming = useCallback(
async (text: string) => {
- onBeforeStream();
+ streamEndFiredRef.current = false;
+ onBeforeStreamRef.current();
if (trackState) {
setInternalState({ status: 'connecting', error: null });
}
@@ -453,6 +466,7 @@ export function useDeepgramTextToSpeech({
'sample_rate',
resolvedStreamOptions.sampleRate
);
+ ensureQueryParam(wsParams, 'speed', resolvedStreamOptions.speed);
ensureQueryParam(
wsParams,
'mip_opt_out',
@@ -464,7 +478,7 @@ export function useDeepgramTextToSpeech({
? `${DEEPGRAM_BASEWSS}/speak?${wsParamString}`
: `${DEEPGRAM_BASEWSS}/speak`;
ws.current = new (WebSocket as any)(url, undefined, {
- headers: { Authorization: `Token ${apiKey}` },
+ headers: { Authorization: getAuthorizationHeader(apiKey) },
});
// Ensure WebSocket receives binary data as ArrayBuffer
@@ -472,14 +486,14 @@ export function useDeepgramTextToSpeech({
ws.current.onopen = () => {
if (autoPlayAudio) {
- Deepgram.startPlayer(
+ NativePlayer.startPlayer(
Number(resolvedStreamOptions.sampleRate) ||
DEFAULT_TTS_SAMPLE_RATE,
1
);
}
sendText(text);
- onStreamStart();
+ onStreamStartRef.current();
if (trackState) {
setInternalState({ status: 'connected', error: null });
}
@@ -488,15 +502,15 @@ export function useDeepgramTextToSpeech({
ws.current.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) {
if (autoPlayAudio) {
- Deepgram.feedAudio(ev.data);
+ NativePlayer.feedAudio(ev.data);
}
- onAudioChunk(ev.data);
+ onAudioChunkRef.current(ev.data);
} else if (ev.data instanceof Blob) {
ev.data.arrayBuffer().then((buffer) => {
if (autoPlayAudio) {
- Deepgram.feedAudio(buffer);
+ NativePlayer.feedAudio(buffer);
}
- onAudioChunk(buffer);
+ onAudioChunkRef.current(buffer);
});
} else if (typeof ev.data === 'string') {
try {
@@ -507,22 +521,22 @@ export function useDeepgramTextToSpeech({
switch (message.type) {
case 'Metadata':
if (isMetadataMessage(message)) {
- onStreamMetadata(message);
+ onStreamMetadataRef.current(message);
}
break;
case 'Flushed':
if (isFlushedMessage(message)) {
- onStreamFlushed(message);
+ onStreamFlushedRef.current(message);
}
break;
case 'Cleared':
if (isClearedMessage(message)) {
- onStreamCleared(message);
+ onStreamClearedRef.current(message);
}
break;
case 'Warning':
if (isWarningMessage(message)) {
- onStreamWarning(message);
+ onStreamWarningRef.current(message);
}
break;
case 'Error': {
@@ -534,7 +548,9 @@ export function useDeepgramTextToSpeech({
const code =
err && typeof err.code === 'string' ? err.code : undefined;
- onStreamError(new Error(description ?? code ?? 'TTS error'));
+ onStreamErrorRef.current(
+ new Error(description ?? code ?? 'TTS error')
+ );
if (trackState) {
setInternalState({
status: 'error',
@@ -554,7 +570,7 @@ export function useDeepgramTextToSpeech({
};
ws.current.onerror = (err) => {
- onStreamError(err);
+ onStreamErrorRef.current(err);
if (trackState) {
setInternalState({
status: 'error',
@@ -563,11 +579,14 @@ export function useDeepgramTextToSpeech({
}
};
ws.current.onclose = () => {
- onStreamEnd();
+ if (!streamEndFiredRef.current) {
+ streamEndFiredRef.current = true;
+ onStreamEndRef.current();
+ }
closeStream();
};
} catch (err) {
- onStreamError(err);
+ onStreamErrorRef.current(err);
if (trackState) {
setInternalState({
status: 'error',
@@ -578,30 +597,18 @@ export function useDeepgramTextToSpeech({
throw err;
}
},
- [
- onBeforeStream,
- onStreamStart,
- onAudioChunk,
- onStreamError,
- onStreamEnd,
- onStreamMetadata,
- onStreamFlushed,
- onStreamCleared,
- onStreamWarning,
- resolvedStreamOptions,
- sendText,
- autoPlayAudio,
- closeStream,
- trackState,
- ]
+ [resolvedStreamOptions, sendText, autoPlayAudio, closeStream, trackState]
);
const stopStreaming = useCallback(() => {
try {
closeStream();
- onStreamEnd();
+ if (!streamEndFiredRef.current) {
+ streamEndFiredRef.current = true;
+ onStreamEndRef.current();
+ }
} catch (err) {
- onStreamError(err);
+ onStreamErrorRef.current(err);
if (trackState) {
setInternalState({
status: 'error',
@@ -609,7 +616,7 @@ export function useDeepgramTextToSpeech({
});
}
}
- }, [onStreamEnd, onStreamError, closeStream, trackState]);
+ }, [closeStream, trackState]);
/* ---------- cleanup on unmount ---------- */
useEffect(
diff --git a/src/useDeepgramVoiceAgent.ts b/src/useDeepgramVoiceAgent.ts
index e7da6a9..50819e0 100644
--- a/src/useDeepgramVoiceAgent.ts
+++ b/src/useDeepgramVoiceAgent.ts
@@ -1,8 +1,8 @@
import { useRef, useCallback, useEffect, useState } from 'react';
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
-import { Buffer } from 'buffer';
import { Deepgram } from './NativeDeepgram';
import { askMicPermission } from './helpers/askMicPermission';
+import { arrayBufferToBase64 } from './helpers';
import type {
DeepgramVoiceAgentSettings,
DeepgramVoiceAgentSettingsMessage,
@@ -19,11 +19,17 @@ import type {
DeepgramVoiceAgentFunctionCallRequestMessage,
DeepgramVoiceAgentReceiveFunctionCallResponseMessage,
DeepgramVoiceAgentPromptUpdatedMessage,
+ DeepgramVoiceAgentListenUpdatedMessage,
+ DeepgramVoiceAgentThinkUpdatedMessage,
DeepgramVoiceAgentSpeakUpdatedMessage,
DeepgramVoiceAgentInjectionRefusedMessage,
+ DeepgramVoiceAgentHistoryMessage,
DeepgramVoiceAgentWarningMessage,
DeepgramVoiceAgentErrorMessage,
DeepgramVoiceAgentAudioConfigMessage,
+ DeepgramVoiceAgentListenConfig,
+ DeepgramVoiceAgentThinkConfig,
+ DeepgramVoiceAgentSpeakConfig,
} from './types';
const DEFAULT_AGENT_ENDPOINT = 'wss://agent.deepgram.com/v1/agent/converse';
@@ -36,6 +42,14 @@ const eventName = Platform.select({
default: 'DeepgramAudioPCM',
});
+let cachedEmitter: NativeEventEmitter | null = null;
+const getEmitter = (): NativeEventEmitter => {
+ if (!cachedEmitter) {
+ cachedEmitter = new NativeEventEmitter(NativeModules.Deepgram);
+ }
+ return cachedEmitter;
+};
+
const ensureArrayBuffer = (data: any): ArrayBuffer | null => {
if (!data) return null;
if (data instanceof ArrayBuffer) return data;
@@ -203,10 +217,13 @@ export interface UseDeepgramVoiceAgentProps {
message: DeepgramVoiceAgentReceiveFunctionCallResponseMessage
) => void;
onPromptUpdated?: (message: DeepgramVoiceAgentPromptUpdatedMessage) => void;
+ onListenUpdated?: (message: DeepgramVoiceAgentListenUpdatedMessage) => void;
+ onThinkUpdated?: (message: DeepgramVoiceAgentThinkUpdatedMessage) => void;
onSpeakUpdated?: (message: DeepgramVoiceAgentSpeakUpdatedMessage) => void;
onInjectionRefused?: (
message: DeepgramVoiceAgentInjectionRefusedMessage
) => void;
+ onHistory?: (message: DeepgramVoiceAgentHistoryMessage) => void;
onWarning?: (message: DeepgramVoiceAgentWarningMessage) => void;
onServerError?: (message: DeepgramVoiceAgentErrorMessage) => void;
onAudioConfig?: (message: DeepgramVoiceAgentAudioConfigMessage) => void;
@@ -219,12 +236,15 @@ export interface UseDeepgramVoiceAgentReturn {
sendMessage: (message: DeepgramVoiceAgentClientMessage) => boolean;
sendSettings: (settings: DeepgramVoiceAgentSettings) => boolean;
injectUserMessage: (content: string) => boolean;
- injectAgentMessage: (message: string) => boolean;
+ injectAgentMessage: (message: string, behavior?: string) => boolean;
sendFunctionCallResponse: (
response: Omit
) => boolean;
sendKeepAlive: () => boolean;
updatePrompt: (prompt: string) => boolean;
+ updateListen: (listen: DeepgramVoiceAgentListenConfig) => boolean;
+ updateThink: (think: DeepgramVoiceAgentThinkConfig) => boolean;
+ updateSpeak: (speak: DeepgramVoiceAgentSpeakConfig) => boolean;
sendMedia: (chunk: ArrayBuffer | Uint8Array | number[]) => boolean;
isConnected: () => boolean;
state?: {
@@ -264,8 +284,11 @@ export function useDeepgramVoiceAgent({
onFunctionCallRequest,
onFunctionCallResponse,
onPromptUpdated,
+ onListenUpdated,
+ onThinkUpdated,
onSpeakUpdated,
onInjectionRefused,
+ onHistory,
onWarning,
onServerError,
onAudioConfig,
@@ -303,8 +326,11 @@ export function useDeepgramVoiceAgent({
const onFunctionCallRequestRef = useRef(onFunctionCallRequest);
const onFunctionCallResponseRef = useRef(onFunctionCallResponse);
const onPromptUpdatedRef = useRef(onPromptUpdated);
+ const onListenUpdatedRef = useRef(onListenUpdated);
+ const onThinkUpdatedRef = useRef(onThinkUpdated);
const onSpeakUpdatedRef = useRef(onSpeakUpdated);
const onInjectionRefusedRef = useRef(onInjectionRefused);
+ const onHistoryRef = useRef(onHistory);
const onWarningRef = useRef(onWarning);
const onServerErrorRef = useRef(onServerError);
const onAudioConfigRef = useRef(onAudioConfig);
@@ -463,8 +489,11 @@ export function useDeepgramVoiceAgent({
onFunctionCallRequestRef.current = onFunctionCallRequest;
onFunctionCallResponseRef.current = onFunctionCallResponse;
onPromptUpdatedRef.current = onPromptUpdated;
+ onListenUpdatedRef.current = onListenUpdated;
+ onThinkUpdatedRef.current = onThinkUpdated;
onSpeakUpdatedRef.current = onSpeakUpdated;
onInjectionRefusedRef.current = onInjectionRefused;
+ onHistoryRef.current = onHistory;
onWarningRef.current = onWarning;
onServerErrorRef.current = onServerError;
onAudioConfigRef.current = onAudioConfig;
@@ -537,19 +566,7 @@ export function useDeepgramVoiceAgent({
}
int16 = downsampled;
}
- chunk = int16.buffer;
- } else if (Array.isArray(ev?.data)) {
- const bytes = new Uint8Array(ev.data.length);
- for (let i = 0; i < ev.data.length; i++) {
- const value = ev.data[i];
- bytes[i] = value < 0 ? value + 256 : value;
- }
- const view = new DataView(bytes.buffer);
- const int16 = new Int16Array(bytes.length / 2);
- for (let i = 0; i < int16.length; i++) {
- int16[i] = view.getInt16(i * 2, true);
- }
- chunk = int16.buffer;
+ chunk = int16.buffer as ArrayBuffer;
}
if (!chunk) {
@@ -562,7 +579,7 @@ export function useDeepgramVoiceAgent({
onErrorRef.current?.(err);
}
},
- [downsampleFactor, onErrorRef]
+ [downsampleFactor]
);
const handleSocketMessage = useCallback(
@@ -652,6 +669,10 @@ export function useDeepgramVoiceAgent({
}
break;
case 'UserStartedSpeaking':
+ if (autoPlayAudio) {
+ Deepgram.interruptAudio?.();
+ }
+
onUserStartedSpeakingRef.current?.(
message as DeepgramVoiceAgentUserStartedSpeakingMessage
);
@@ -675,6 +696,16 @@ export function useDeepgramVoiceAgent({
message as DeepgramVoiceAgentPromptUpdatedMessage
);
break;
+ case 'ListenUpdated':
+ onListenUpdatedRef.current?.(
+ message as DeepgramVoiceAgentListenUpdatedMessage
+ );
+ break;
+ case 'ThinkUpdated':
+ onThinkUpdatedRef.current?.(
+ message as DeepgramVoiceAgentThinkUpdatedMessage
+ );
+ break;
case 'SpeakUpdated':
onSpeakUpdatedRef.current?.(
message as DeepgramVoiceAgentSpeakUpdatedMessage
@@ -692,7 +723,7 @@ export function useDeepgramVoiceAgent({
const sampleRate =
configMsg.sample_rate || DEFAULT_INPUT_SAMPLE_RATE;
const channels = configMsg.channels || 1;
- Deepgram.startPlayer?.(sampleRate, channels);
+ Deepgram.startPlayer(sampleRate, channels);
}
onAudioConfigRef.current?.(configMsg);
@@ -705,6 +736,13 @@ export function useDeepgramVoiceAgent({
);
}
break;
+ case 'History':
+ {
+ const historyMsg = message as DeepgramVoiceAgentHistoryMessage;
+
+ onHistoryRef.current?.(historyMsg);
+ }
+ break;
case 'Warning':
if (hasKeys(message, ['description'])) {
const warnMsg = message as DeepgramVoiceAgentWarningMessage;
@@ -762,9 +800,8 @@ export function useDeepgramVoiceAgent({
if (buffer) {
if (autoPlayAudio) {
try {
- const bytes = new Uint8Array(buffer);
- const b64 = Buffer.from(bytes).toString('base64');
- Deepgram.feedAudio?.(b64);
+ const b64 = arrayBufferToBase64(buffer);
+ Deepgram.feedAudio(b64);
} catch (err) {
console.warn('[VoiceAgent] Auto-feed audio error:', err);
}
@@ -773,27 +810,7 @@ export function useDeepgramVoiceAgent({
onAudioRef.current?.(buffer);
}
},
- [
- onAgentAudioDoneRef,
- onAgentStartedSpeakingRef,
- onAgentThinkingRef,
- onConversationTextRef,
- onErrorRef,
- onFunctionCallRequestRef,
- onFunctionCallResponseRef,
- onInjectionRefusedRef,
- onMessageRef,
- onPromptUpdatedRef,
- onServerErrorRef,
- onSettingsAppliedRef,
- onSpeakUpdatedRef,
- onUserStartedSpeakingRef,
- onWarningRef,
- autoPlayAudio,
- trackAgentStatus,
- trackConversation,
- trackState,
- ]
+ [autoPlayAudio, trackAgentStatus, trackConversation, trackState]
);
const sendJsonMessage = useCallback(
@@ -886,12 +903,14 @@ export function useDeepgramVoiceAgent({
if (!granted) {
throw new Error('Microphone permission denied');
}
- await Deepgram.startRecording();
+ await Deepgram.startRecording({ enableVoiceProcessing: true });
microphoneActive.current = true;
- const emitter = new NativeEventEmitter(NativeModules.Deepgram);
if (eventName) {
- audioSub.current = emitter.addListener(eventName, handleMicChunk);
+ audioSub.current = getEmitter().addListener(
+ eventName,
+ handleMicChunk
+ );
}
} else {
// Only initialize audio session for playback if not recording
@@ -941,7 +960,7 @@ export function useDeepgramVoiceAgent({
const sampleRate =
merged?.audio?.output?.sample_rate ?? DEFAULT_INPUT_SAMPLE_RATE;
const channels = 1;
- Deepgram.startPlayer?.(sampleRate, channels);
+ Deepgram.startPlayer(sampleRate, channels);
}
onConnectRef.current?.();
@@ -997,8 +1016,12 @@ export function useDeepgramVoiceAgent({
);
const injectAgentMessage = useCallback(
- (message: string) =>
- sendJsonMessage({ type: 'InjectAgentMessage', message }),
+ (message: string, behavior?: string) =>
+ sendJsonMessage({
+ type: 'InjectAgentMessage',
+ message,
+ ...(behavior !== undefined ? { behavior } : {}),
+ }),
[sendJsonMessage]
);
@@ -1021,6 +1044,24 @@ export function useDeepgramVoiceAgent({
[sendJsonMessage]
);
+ const updateListen = useCallback(
+ (listen: DeepgramVoiceAgentListenConfig) =>
+ sendJsonMessage({ type: 'UpdateListen', listen }),
+ [sendJsonMessage]
+ );
+
+ const updateThink = useCallback(
+ (think: DeepgramVoiceAgentThinkConfig) =>
+ sendJsonMessage({ type: 'UpdateThink', think }),
+ [sendJsonMessage]
+ );
+
+ const updateSpeak = useCallback(
+ (speak: DeepgramVoiceAgentSpeakConfig) =>
+ sendJsonMessage({ type: 'UpdateSpeak', speak }),
+ [sendJsonMessage]
+ );
+
const sendMessage = useCallback(
(message: DeepgramVoiceAgentClientMessage) => sendJsonMessage(message),
[sendJsonMessage]
@@ -1047,6 +1088,9 @@ export function useDeepgramVoiceAgent({
sendFunctionCallResponse,
sendKeepAlive,
updatePrompt,
+ updateListen,
+ updateThink,
+ updateSpeak,
sendMedia: sendBinary,
isConnected,
...(trackState ? { state: internalState } : {}),
diff --git a/yarn.lock b/yarn.lock
index 114f882..7d0b394 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2009,29 +2009,6 @@ __metadata:
languageName: node
linkType: hard
-"@deepgram/captions@npm:^1.1.1":
- version: 1.2.0
- resolution: "@deepgram/captions@npm:1.2.0"
- dependencies:
- dayjs: ^1.11.10
- checksum: 6b41c64b7dd5081ff9608877e50588e446924b79b12ddd87a5d8c5393d9425c89ff23ede485094c20515efd2d6da00a79926e258f4f27af1b4fc0c18f800225a
- languageName: node
- linkType: hard
-
-"@deepgram/sdk@npm:^4.7.0":
- version: 4.7.0
- resolution: "@deepgram/sdk@npm:4.7.0"
- dependencies:
- "@deepgram/captions": ^1.1.1
- "@types/node": ^18.19.39
- cross-fetch: ^3.1.5
- deepmerge: ^4.3.1
- events: ^3.3.0
- ws: ^8.17.0
- checksum: 0e3f0c80e404523ba6f9ce38673ccdc5e81053dfd272329c254ee997082c8b22ebdd9bf948831d2ad2d8f54258710a0886c8dfa2f863d557c5611d8bab73d83c
- languageName: node
- linkType: hard
-
"@eslint-community/eslint-utils@npm:^4.1.2, @eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.5.0, @eslint-community/eslint-utils@npm:^4.7.0":
version: 4.7.0
resolution: "@eslint-community/eslint-utils@npm:4.7.0"
@@ -4242,15 +4219,6 @@ __metadata:
languageName: node
linkType: hard
-"@types/node@npm:^18.19.39":
- version: 18.19.115
- resolution: "@types/node@npm:18.19.115"
- dependencies:
- undici-types: ~5.26.4
- checksum: fc39f51a17364c00fdfda35494dc71b84d3eef2d5f8ee97103a9af55492784c20963bbc21b5b7f419b29fe97eecf9276cdf766673036efa3660b241396189a1b
- languageName: node
- linkType: hard
-
"@types/normalize-package-data@npm:^2.4.0, @types/normalize-package-data@npm:^2.4.3":
version: 2.4.4
resolution: "@types/normalize-package-data@npm:2.4.4"
@@ -5637,6 +5605,16 @@ __metadata:
languageName: node
linkType: hard
+"buffer@npm:^6.0.3":
+ version: 6.0.3
+ resolution: "buffer@npm:6.0.3"
+ dependencies:
+ base64-js: ^1.3.1
+ ieee754: ^1.2.1
+ checksum: 5ad23293d9a731e4318e420025800b42bf0d264004c0286c8cc010af7a270c7a0f6522e84f54b9ad65cbd6db20b8badbfd8d2ebf4f80fa03dab093b89e68c3f9
+ languageName: node
+ linkType: hard
+
"bundle-name@npm:^4.1.0":
version: 4.1.0
resolution: "bundle-name@npm:4.1.0"
@@ -6697,7 +6675,7 @@ __metadata:
languageName: node
linkType: hard
-"dayjs@npm:^1.11.10, dayjs@npm:^1.8.15":
+"dayjs@npm:^1.8.15":
version: 1.11.13
resolution: "dayjs@npm:1.11.13"
checksum: f388db88a6aa93956c1f6121644e783391c7b738b73dbc54485578736565c8931bdfba4bb94e9b1535c6e509c97d5deb918bbe1ae6b34358d994de735055cca9
@@ -7957,13 +7935,6 @@ __metadata:
languageName: node
linkType: hard
-"events@npm:^3.3.0":
- version: 3.3.0
- resolution: "events@npm:3.3.0"
- checksum: f6f487ad2198aa41d878fa31452f1a3c00958f46e9019286ff4787c84aac329332ab45c9cdc8c445928fc6d7ded294b9e005a7fce9426488518017831b272780
- languageName: node
- linkType: hard
-
"exec-async@npm:^2.2.0":
version: 2.2.0
resolution: "exec-async@npm:2.2.0"
@@ -9321,7 +9292,7 @@ __metadata:
languageName: node
linkType: hard
-"ieee754@npm:^1.1.13":
+"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1":
version: 1.2.1
resolution: "ieee754@npm:1.2.1"
checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e
@@ -13476,7 +13447,6 @@ __metadata:
resolution: "react-native-deepgram@workspace:."
dependencies:
"@commitlint/config-conventional": ^19.6.0
- "@deepgram/sdk": ^4.7.0
"@eslint/compat": ^1.2.7
"@eslint/eslintrc": ^3.3.0
"@eslint/js": ^9.22.0
@@ -13489,6 +13459,7 @@ __metadata:
"@types/react": ^19.0.0
"@typescript-eslint/eslint-plugin": latest
"@typescript-eslint/parser": latest
+ buffer: ^6.0.3
commitlint: ^19.6.1
del-cli: ^5.1.0
eslint: ^9.22.0
@@ -13507,8 +13478,8 @@ __metadata:
turbo: ^1.10.7
typescript: ^5.5.4
peerDependencies:
- react: "*"
- react-native: "*"
+ react: ">=18.0.0"
+ react-native: ">=0.72.0"
languageName: unknown
linkType: soft
@@ -15776,13 +15747,6 @@ __metadata:
languageName: node
linkType: hard
-"undici-types@npm:~5.26.4":
- version: 5.26.5
- resolution: "undici-types@npm:5.26.5"
- checksum: 3192ef6f3fd5df652f2dc1cd782b49d6ff14dc98e5dced492aa8a8c65425227da5da6aafe22523c67f035a272c599bb89cfe803c1db6311e44bed3042fc25487
- languageName: node
- linkType: hard
-
"undici-types@npm:~7.8.0":
version: 7.8.0
resolution: "undici-types@npm:7.8.0"
@@ -16420,7 +16384,7 @@ __metadata:
languageName: node
linkType: hard
-"ws@npm:^8.11.0, ws@npm:^8.12.1, ws@npm:^8.17.0":
+"ws@npm:^8.11.0, ws@npm:^8.12.1":
version: 8.18.3
resolution: "ws@npm:8.18.3"
peerDependencies: