feat: add expo-two-way-audio module to workspace
@@ -20,7 +20,7 @@ From repo root:
|
||||
```bash
|
||||
npm run android:development # Debug build
|
||||
npm run android:production # Release build
|
||||
npm run android:clean # Clean native project
|
||||
npm run android:clear # Remove generated Android project
|
||||
```
|
||||
|
||||
Or from `packages/app`:
|
||||
@@ -34,8 +34,8 @@ APP_VARIANT=development npx expo run:android --variant=debug
|
||||
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=production npx expo run:android --variant=release
|
||||
|
||||
# Clean
|
||||
npx expo prebuild --platform android --clean --non-interactive
|
||||
# Clear generated Android project
|
||||
rm -rf android
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
|
||||
3827
package-lock.json
generated
@@ -3,6 +3,7 @@
|
||||
"version": "0.1.27",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/server",
|
||||
"packages/app",
|
||||
"packages/relay",
|
||||
@@ -28,6 +29,7 @@
|
||||
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
||||
"android:production": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:release": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
|
||||
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
|
||||
@@ -100,6 +100,9 @@ export default {
|
||||
output: "single",
|
||||
favicon: "./assets/images/favicon.png",
|
||||
},
|
||||
autolinking: {
|
||||
searchPaths: ["../../node_modules", "./node_modules"],
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
[
|
||||
@@ -143,6 +146,7 @@ export default {
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
reactCompiler: true,
|
||||
autolinkingModuleResolution: true,
|
||||
},
|
||||
extra: {
|
||||
router: {},
|
||||
|
||||
@@ -7,11 +7,10 @@
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "npm run android:development",
|
||||
"android:clear-autolinking-cache": "node -e \"require('node:fs').rmSync('android/build/generated/autolinking', { recursive: true, force: true })\"",
|
||||
"android:development": "npm run android:clear-autolinking-cache && APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "npm run android:clear-autolinking-cache && APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:release": "npm run android:production",
|
||||
"android:clean": "expo prebuild --platform android --clean --non-interactive",
|
||||
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
|
||||
"ios": "expo run:ios",
|
||||
"ios:release": "expo run:ios --configuration Release",
|
||||
"web": "expo start --web",
|
||||
@@ -27,7 +26,7 @@
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||
"@getpaseo/expo-two-way-audio": "file:../expo-two-way-audio",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
ScrollView,
|
||||
Alert,
|
||||
Platform,
|
||||
Pressable,
|
||||
} from "react-native";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { Buffer } from "buffer";
|
||||
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import type { HostProfile, HostConnection } from "@/types/host-connection";
|
||||
@@ -43,6 +45,8 @@ import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -484,6 +488,7 @@ function DesktopAppUpdateRow() {
|
||||
export default function SettingsScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
||||
const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>();
|
||||
const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
|
||||
@@ -500,6 +505,8 @@ export default function SettingsScreen() {
|
||||
const [isRemovingHost, setIsRemovingHost] = useState(false);
|
||||
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false);
|
||||
const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null);
|
||||
const isLoading = settingsLoading;
|
||||
const isMountedRef = useRef(true);
|
||||
const lastHandledEditHostRef = useRef<string | null>(null);
|
||||
@@ -659,6 +666,35 @@ export default function SettingsScreen() {
|
||||
const restartConfirmationMessage =
|
||||
"This will restart the daemon. The app will reconnect automatically.";
|
||||
|
||||
const handlePlaybackTest = useCallback(async () => {
|
||||
if (!voiceAudioEngine || isPlaybackTestRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPlaybackTestRunning(true);
|
||||
setPlaybackTestResult(null);
|
||||
|
||||
try {
|
||||
const bytes = Buffer.from(THINKING_TONE_NATIVE_PCM_BASE64, "base64");
|
||||
await voiceAudioEngine.initialize();
|
||||
voiceAudioEngine.stop();
|
||||
await voiceAudioEngine.play({
|
||||
type: "audio/pcm;rate=16000;bits=16",
|
||||
size: bytes.byteLength,
|
||||
async arrayBuffer() {
|
||||
return Uint8Array.from(bytes).buffer;
|
||||
},
|
||||
});
|
||||
setPlaybackTestResult("Playback command completed.");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error("[Settings] Playback test failed", error);
|
||||
setPlaybackTestResult(`Playback failed: ${message}`);
|
||||
} finally {
|
||||
setIsPlaybackTestRunning(false);
|
||||
}
|
||||
}, [isPlaybackTestRunning, voiceAudioEngine]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
@@ -869,6 +905,31 @@ export default function SettingsScreen() {
|
||||
{isDesktop ? <DesktopPermissionsSection /> : null}
|
||||
{isDesktop ? <LocalDaemonSection appVersion={appVersion} /> : null}
|
||||
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Diagnostics</Text>
|
||||
<View style={[settingsStyles.card, styles.audioCard]}>
|
||||
<View style={styles.audioRow}>
|
||||
<View style={styles.audioRowContent}>
|
||||
<Text style={styles.audioRowTitle}>Test audio playback</Text>
|
||||
<Text style={styles.aboutHintText}>
|
||||
Plays a built-in PCM sample through the current app audio engine.
|
||||
</Text>
|
||||
{playbackTestResult ? (
|
||||
<Text style={styles.aboutHintText}>{playbackTestResult}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={() => void handlePlaybackTest()}
|
||||
disabled={!voiceAudioEngine || isPlaybackTestRunning}
|
||||
>
|
||||
{isPlaybackTestRunning ? "Playing..." : "Play test"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* About */}
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>About</Text>
|
||||
@@ -1393,18 +1454,22 @@ function DaemonCard({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
<Pressable
|
||||
style={styles.hostSettingsButton}
|
||||
textStyle={{ fontSize: 0, lineHeight: 0 }}
|
||||
leftIcon={<Settings size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||
onPress={() => onOpenSettings(daemon)}
|
||||
testID={`daemon-card-settings-${daemon.serverId}`}
|
||||
accessibilityLabel={`Open settings for ${daemon.label}`}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
{" "}
|
||||
</Button>
|
||||
{({ hovered = false, pressed = false }) => (
|
||||
<Settings
|
||||
size={theme.iconSize.md}
|
||||
color={
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
|
||||
|
||||
@@ -23,7 +23,4 @@ export interface AudioEngine {
|
||||
stop(): void;
|
||||
clearQueue(): void;
|
||||
isPlaying(): boolean;
|
||||
|
||||
playLooping(pcm: Uint8Array, gapMs: number): void;
|
||||
stopLooping(): void;
|
||||
}
|
||||
|
||||
@@ -1,35 +1,31 @@
|
||||
import { Audio } from "expo-av";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import {
|
||||
THINKING_TONE_REPEAT_GAP_MS,
|
||||
} from "@/utils/thinking-tone";
|
||||
import {
|
||||
THINKING_TONE_NATIVE_PCM_DURATION_MS,
|
||||
} from "@/utils/thinking-tone.native-pcm";
|
||||
import type {
|
||||
AudioEngine,
|
||||
AudioEngineCallbacks,
|
||||
AudioPlaybackSource,
|
||||
} from "@/voice/audio-engine-types";
|
||||
|
||||
// Use expo-av for playback instead of expo-two-way-audio (temporary test)
|
||||
const USE_EXPO_AV_PLAYBACK = true;
|
||||
|
||||
interface QueuedAudio {
|
||||
audio: AudioPlaybackSource;
|
||||
resolve: (duration: number) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface CuePcm {
|
||||
pcm16k: Uint8Array;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
interface AudioEngineTraceOptions {
|
||||
traceLabel?: string;
|
||||
}
|
||||
|
||||
let nextAudioEngineInstanceId = 1;
|
||||
|
||||
interface BridgeStats {
|
||||
windowStartedAtMs: number;
|
||||
captureEvents: number;
|
||||
captureBytes: number;
|
||||
playbackEvents: number;
|
||||
playbackInputBytes: number;
|
||||
playbackResampledBytes: number;
|
||||
playbackDurationMs: number;
|
||||
}
|
||||
|
||||
function parsePcmSampleRate(mimeType: string): number | null {
|
||||
const match = /rate=(\d+)/i.exec(mimeType);
|
||||
if (!match) {
|
||||
@@ -39,10 +35,92 @@ function parsePcmSampleRate(mimeType: string): number | null {
|
||||
return Number.isFinite(rate) && rate > 0 ? rate : null;
|
||||
}
|
||||
|
||||
function resamplePcm16(pcm: Uint8Array, fromRate: number, toRate: number): Uint8Array {
|
||||
if (fromRate === toRate) {
|
||||
return pcm;
|
||||
}
|
||||
|
||||
const inputSamples = Math.floor(pcm.length / 2);
|
||||
const outputSamples = Math.floor((inputSamples * toRate) / fromRate);
|
||||
const out = new Uint8Array(outputSamples * 2);
|
||||
const ratio = fromRate / toRate;
|
||||
|
||||
const readInt16 = (sampleIndex: number): number => {
|
||||
const i = sampleIndex * 2;
|
||||
if (i + 1 >= pcm.length) {
|
||||
return 0;
|
||||
}
|
||||
const lo = pcm[i]!;
|
||||
const hi = pcm[i + 1]!;
|
||||
let value = (hi << 8) | lo;
|
||||
if (value & 0x8000) {
|
||||
value = value - 0x10000;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const writeInt16 = (sampleIndex: number, value: number): void => {
|
||||
const clamped = Math.max(-32768, Math.min(32767, Math.round(value)));
|
||||
const i = sampleIndex * 2;
|
||||
out[i] = clamped & 0xff;
|
||||
out[i + 1] = (clamped >> 8) & 0xff;
|
||||
};
|
||||
|
||||
for (let i = 0; i < outputSamples; i++) {
|
||||
const srcPos = i * ratio;
|
||||
const i0 = Math.floor(srcPos);
|
||||
const frac = srcPos - i0;
|
||||
const s0 = readInt16(i0);
|
||||
const s1 = readInt16(Math.min(inputSamples - 1, i0 + 1));
|
||||
writeInt16(i, s0 + (s1 - s0) * frac);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createAudioEngine(
|
||||
callbacks: AudioEngineCallbacks,
|
||||
_options?: AudioEngineTraceOptions
|
||||
): AudioEngine {
|
||||
const native = require("@getpaseo/expo-two-way-audio");
|
||||
const instanceId = nextAudioEngineInstanceId++;
|
||||
const bridgeStats: BridgeStats = {
|
||||
windowStartedAtMs: Date.now(),
|
||||
captureEvents: 0,
|
||||
captureBytes: 0,
|
||||
playbackEvents: 0,
|
||||
playbackInputBytes: 0,
|
||||
playbackResampledBytes: 0,
|
||||
playbackDurationMs: 0,
|
||||
};
|
||||
|
||||
const toHexPreview = (bytes: Uint8Array, count = 12): string =>
|
||||
Array.from(bytes.slice(0, count))
|
||||
.map((value) => value.toString(16).padStart(2, "0"))
|
||||
.join(" ");
|
||||
|
||||
const maybeFlushBridgeStats = (reason: string): void => {
|
||||
const now = Date.now();
|
||||
const elapsedMs = now - bridgeStats.windowStartedAtMs;
|
||||
if (elapsedMs < 1000) {
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`[AudioEngine.native#${instanceId}][bridge] ${reason} ` +
|
||||
`capture=${bridgeStats.captureEvents}ev/${bridgeStats.captureBytes}B ` +
|
||||
`play=${bridgeStats.playbackEvents}ev/${bridgeStats.playbackInputBytes}B->${bridgeStats.playbackResampledBytes}B ` +
|
||||
`playMs=${bridgeStats.playbackDurationMs.toFixed(1)} ` +
|
||||
`windowMs=${elapsedMs}`
|
||||
);
|
||||
bridgeStats.windowStartedAtMs = now;
|
||||
bridgeStats.captureEvents = 0;
|
||||
bridgeStats.captureBytes = 0;
|
||||
bridgeStats.playbackEvents = 0;
|
||||
bridgeStats.playbackInputBytes = 0;
|
||||
bridgeStats.playbackResampledBytes = 0;
|
||||
bridgeStats.playbackDurationMs = 0;
|
||||
};
|
||||
|
||||
const refs: {
|
||||
initialized: boolean;
|
||||
captureActive: boolean;
|
||||
@@ -55,14 +133,7 @@ export function createAudioEngine(
|
||||
reject: (error: Error) => void;
|
||||
settled: boolean;
|
||||
} | null;
|
||||
looping: {
|
||||
active: boolean;
|
||||
token: number;
|
||||
timeout: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
thinkingTone: CuePcm | null;
|
||||
destroyed: boolean;
|
||||
mockVolumeInterval: ReturnType<typeof setInterval> | null;
|
||||
} = {
|
||||
initialized: false,
|
||||
captureActive: false,
|
||||
@@ -71,62 +142,53 @@ export function createAudioEngine(
|
||||
processingQueue: false,
|
||||
playbackTimeout: null,
|
||||
activePlayback: null,
|
||||
looping: {
|
||||
active: false,
|
||||
token: 0,
|
||||
timeout: null,
|
||||
},
|
||||
thinkingTone: null,
|
||||
destroyed: false,
|
||||
mockVolumeInterval: null,
|
||||
};
|
||||
|
||||
// Only subscribe to native events if not mocking
|
||||
let microphoneSubscription: { remove(): void };
|
||||
let volumeSubscription: { remove(): void };
|
||||
const microphoneSubscription = native.addExpoTwoWayAudioEventListener(
|
||||
"onMicrophoneData",
|
||||
(event: any) => {
|
||||
if (!refs.captureActive || refs.muted) {
|
||||
return;
|
||||
}
|
||||
const pcm = event.data as Uint8Array;
|
||||
bridgeStats.captureEvents += 1;
|
||||
bridgeStats.captureBytes += pcm.byteLength;
|
||||
maybeFlushBridgeStats("capture");
|
||||
callbacks.onCaptureData(pcm);
|
||||
}
|
||||
);
|
||||
const volumeSubscription = native.addExpoTwoWayAudioEventListener(
|
||||
"onInputVolumeLevelData",
|
||||
(event: any) => {
|
||||
if (!refs.captureActive) {
|
||||
return;
|
||||
}
|
||||
const level = refs.muted ? 0 : event.data;
|
||||
callbacks.onVolumeLevel(level);
|
||||
}
|
||||
);
|
||||
|
||||
if (MOCK_MODE) {
|
||||
microphoneSubscription = { remove() {} };
|
||||
volumeSubscription = { remove() {} };
|
||||
} else {
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
microphoneSubscription = native.addExpoTwoWayAudioEventListener(
|
||||
"onMicrophoneData",
|
||||
(event: any) => {
|
||||
if (!refs.captureActive || refs.muted) {
|
||||
return;
|
||||
}
|
||||
callbacks.onCaptureData(event.data);
|
||||
}
|
||||
);
|
||||
volumeSubscription = native.addExpoTwoWayAudioEventListener(
|
||||
"onInputVolumeLevelData",
|
||||
(event: any) => {
|
||||
if (!refs.captureActive) {
|
||||
return;
|
||||
}
|
||||
const level = refs.muted ? 0 : event.data;
|
||||
callbacks.onVolumeLevel(level);
|
||||
}
|
||||
);
|
||||
}
|
||||
const outputVolumeSubscription = native.addExpoTwoWayAudioEventListener(
|
||||
"onOutputVolumeLevelData",
|
||||
(event: any) => {
|
||||
console.log(`[AudioEngine.native#${instanceId}] outputVolume=${event.data}`);
|
||||
}
|
||||
);
|
||||
|
||||
async function ensureInitialized(): Promise<void> {
|
||||
if (refs.initialized) {
|
||||
return;
|
||||
}
|
||||
if (!MOCK_MODE) {
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
await native.initialize();
|
||||
const success = await native.initialize();
|
||||
if (!success) {
|
||||
throw new Error("expo-two-way-audio: native initialize() returned false");
|
||||
}
|
||||
console.log(`[AudioEngine.native#${instanceId}] initialized successfully`);
|
||||
refs.initialized = true;
|
||||
}
|
||||
|
||||
async function ensureMicrophonePermission(): Promise<void> {
|
||||
if (MOCK_MODE) {
|
||||
return;
|
||||
}
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
let permission = await native.getMicrophonePermissionsAsync().catch(() => null);
|
||||
if (!permission?.granted) {
|
||||
permission = await native.requestMicrophonePermissionsAsync().catch(() => null);
|
||||
@@ -138,15 +200,6 @@ export function createAudioEngine(
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureThinkingTone(): Promise<CuePcm> {
|
||||
if (refs.thinkingTone) {
|
||||
return refs.thinkingTone;
|
||||
}
|
||||
const durationMs = THINKING_TONE_NATIVE_PCM_DURATION_MS;
|
||||
refs.thinkingTone = { pcm16k: new Uint8Array(0), durationMs };
|
||||
return refs.thinkingTone;
|
||||
}
|
||||
|
||||
function clearPlaybackTimeout(): void {
|
||||
if (refs.playbackTimeout) {
|
||||
clearTimeout(refs.playbackTimeout);
|
||||
@@ -164,15 +217,24 @@ export function createAudioEngine(
|
||||
const arrayBuffer = await audio.arrayBuffer();
|
||||
const pcm = new Uint8Array(arrayBuffer);
|
||||
const inputRate = parsePcmSampleRate(audio.type || "") ?? 24000;
|
||||
const durationSec = pcm.length / 2 / inputRate;
|
||||
|
||||
console.log(`[AudioEngine.native] playPCMData: MOCK_MODE=${MOCK_MODE} inputRate=${inputRate} inputBytes=${pcm.length} durationSec=${durationSec.toFixed(3)}`);
|
||||
// Native AudioEngine expects 16kHz PCM16
|
||||
const pcm16k = resamplePcm16(pcm, inputRate, 16000);
|
||||
const durationSec = pcm16k.length / 2 / 16000;
|
||||
bridgeStats.playbackEvents += 1;
|
||||
bridgeStats.playbackInputBytes += pcm.length;
|
||||
bridgeStats.playbackResampledBytes += pcm16k.length;
|
||||
bridgeStats.playbackDurationMs += durationSec * 1000;
|
||||
|
||||
if (!MOCK_MODE) {
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
native.resumePlayback();
|
||||
native.playPCMData(pcm);
|
||||
}
|
||||
console.log(
|
||||
`[AudioEngine.native#${instanceId}] playPCMData: inputRate=${inputRate} inputBytes=${pcm.length} ` +
|
||||
`resampled=${pcm16k.length} durationSec=${durationSec.toFixed(3)} ` +
|
||||
`pcmHead=${toHexPreview(pcm)} resampledHead=${toHexPreview(pcm16k)}`
|
||||
);
|
||||
maybeFlushBridgeStats("play");
|
||||
|
||||
native.resumePlayback();
|
||||
native.playPCMData(pcm16k);
|
||||
|
||||
clearPlaybackTimeout();
|
||||
refs.playbackTimeout = setTimeout(() => {
|
||||
@@ -215,27 +277,9 @@ export function createAudioEngine(
|
||||
refs.processingQueue = false;
|
||||
}
|
||||
|
||||
function nativeStopPlayback(): void {
|
||||
if (!MOCK_MODE) {
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
native.stopPlayback();
|
||||
}
|
||||
}
|
||||
|
||||
function stopLooping(): void {
|
||||
refs.looping.active = false;
|
||||
refs.looping.token += 1;
|
||||
if (refs.looping.timeout) {
|
||||
clearTimeout(refs.looping.timeout);
|
||||
refs.looping.timeout = null;
|
||||
}
|
||||
nativeStopPlayback();
|
||||
}
|
||||
|
||||
return {
|
||||
async initialize() {
|
||||
await ensureInitialized();
|
||||
await ensureThinkingTone();
|
||||
},
|
||||
|
||||
async destroy() {
|
||||
@@ -243,30 +287,22 @@ export function createAudioEngine(
|
||||
return;
|
||||
}
|
||||
refs.destroyed = true;
|
||||
stopLooping();
|
||||
this.stop();
|
||||
this.clearQueue();
|
||||
if (refs.captureActive) {
|
||||
if (!MOCK_MODE) {
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
native.toggleRecording(false);
|
||||
}
|
||||
if (refs.mockVolumeInterval) {
|
||||
clearInterval(refs.mockVolumeInterval);
|
||||
refs.mockVolumeInterval = null;
|
||||
}
|
||||
native.toggleRecording(false);
|
||||
refs.captureActive = false;
|
||||
}
|
||||
clearPlaybackTimeout();
|
||||
refs.muted = false;
|
||||
callbacks.onVolumeLevel(0);
|
||||
if (refs.initialized && !MOCK_MODE) {
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
if (refs.initialized) {
|
||||
native.tearDown();
|
||||
refs.initialized = false;
|
||||
}
|
||||
microphoneSubscription.remove();
|
||||
volumeSubscription.remove();
|
||||
outputVolumeSubscription.remove();
|
||||
},
|
||||
|
||||
async startCapture() {
|
||||
@@ -277,22 +313,7 @@ export function createAudioEngine(
|
||||
try {
|
||||
await ensureMicrophonePermission();
|
||||
await ensureInitialized();
|
||||
|
||||
if (MOCK_MODE) {
|
||||
// Emit fake volume levels and empty PCM data periodically
|
||||
refs.mockVolumeInterval = setInterval(() => {
|
||||
if (!refs.captureActive) {
|
||||
return;
|
||||
}
|
||||
const fakeVolume = refs.muted ? 0 : 0.15 + Math.random() * 0.5;
|
||||
callbacks.onVolumeLevel(fakeVolume);
|
||||
// Send empty PCM chunk to keep the pipeline alive
|
||||
callbacks.onCaptureData(new Uint8Array(3200));
|
||||
}, 100);
|
||||
} else {
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
native.toggleRecording(true);
|
||||
}
|
||||
native.toggleRecording(true);
|
||||
refs.captureActive = true;
|
||||
} catch (error) {
|
||||
const wrapped = error instanceof Error ? error : new Error(String(error));
|
||||
@@ -303,15 +324,7 @@ export function createAudioEngine(
|
||||
|
||||
async stopCapture() {
|
||||
if (refs.captureActive) {
|
||||
if (MOCK_MODE) {
|
||||
if (refs.mockVolumeInterval) {
|
||||
clearInterval(refs.mockVolumeInterval);
|
||||
refs.mockVolumeInterval = null;
|
||||
}
|
||||
} else {
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
native.toggleRecording(false);
|
||||
}
|
||||
native.toggleRecording(false);
|
||||
}
|
||||
refs.captureActive = false;
|
||||
refs.muted = false;
|
||||
@@ -340,7 +353,7 @@ export function createAudioEngine(
|
||||
},
|
||||
|
||||
stop() {
|
||||
nativeStopPlayback();
|
||||
native.stopPlayback();
|
||||
clearPlaybackTimeout();
|
||||
const active = refs.activePlayback;
|
||||
refs.activePlayback = null;
|
||||
@@ -360,45 +373,5 @@ export function createAudioEngine(
|
||||
isPlaying() {
|
||||
return refs.activePlayback !== null;
|
||||
},
|
||||
|
||||
playLooping(audio, gapMs) {
|
||||
if (refs.looping.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
refs.looping.active = true;
|
||||
const token = refs.looping.token + 1;
|
||||
refs.looping.token = token;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await ensureInitialized();
|
||||
const cue = await ensureThinkingTone();
|
||||
|
||||
const loop = () => {
|
||||
if (!refs.looping.active || refs.looping.token !== token) {
|
||||
return;
|
||||
}
|
||||
if (!MOCK_MODE) {
|
||||
const native = require("@boudra/expo-two-way-audio");
|
||||
native.resumePlayback();
|
||||
native.playPCMData(cue.pcm16k);
|
||||
}
|
||||
refs.looping.timeout = setTimeout(
|
||||
loop,
|
||||
cue.durationMs + (gapMs || THINKING_TONE_REPEAT_GAP_MS)
|
||||
);
|
||||
};
|
||||
|
||||
loop();
|
||||
} catch (error) {
|
||||
callbacks.onError?.(
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
})();
|
||||
},
|
||||
|
||||
stopLooping,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
import {
|
||||
loadThinkingToneArrayBuffer,
|
||||
THINKING_TONE_REPEAT_GAP_MS,
|
||||
} from "@/utils/thinking-tone";
|
||||
import type {
|
||||
AudioEngine,
|
||||
AudioEngineCallbacks,
|
||||
@@ -117,16 +113,6 @@ export function createAudioEngine(
|
||||
reject: (error: Error) => void;
|
||||
settled: boolean;
|
||||
} | null;
|
||||
looping: {
|
||||
token: number;
|
||||
source: AudioBufferSourceNode | null;
|
||||
gain: GainNode | null;
|
||||
timeout: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
thinkingTone: {
|
||||
bytes: Uint8Array | null;
|
||||
buffer: AudioBuffer | null;
|
||||
};
|
||||
} = {
|
||||
playbackContext: null,
|
||||
captureContext: null,
|
||||
@@ -139,16 +125,6 @@ export function createAudioEngine(
|
||||
queue: [],
|
||||
processingQueue: false,
|
||||
activePlayback: null,
|
||||
looping: {
|
||||
token: 0,
|
||||
source: null,
|
||||
gain: null,
|
||||
timeout: null,
|
||||
},
|
||||
thinkingTone: {
|
||||
bytes: null,
|
||||
buffer: null,
|
||||
},
|
||||
};
|
||||
|
||||
async function ensurePlaybackContext(): Promise<AudioContext> {
|
||||
@@ -193,48 +169,6 @@ export function createAudioEngine(
|
||||
return context;
|
||||
}
|
||||
|
||||
async function ensureThinkingToneBytes(): Promise<Uint8Array> {
|
||||
if (refs.thinkingTone.bytes) {
|
||||
return refs.thinkingTone.bytes;
|
||||
}
|
||||
const wav = await loadThinkingToneArrayBuffer();
|
||||
refs.thinkingTone.bytes = new Uint8Array(wav);
|
||||
return refs.thinkingTone.bytes;
|
||||
}
|
||||
|
||||
async function ensureThinkingToneBuffer(): Promise<AudioBuffer> {
|
||||
if (refs.thinkingTone.buffer) {
|
||||
return refs.thinkingTone.buffer;
|
||||
}
|
||||
const context = await ensurePlaybackContext();
|
||||
const wav = await loadThinkingToneArrayBuffer();
|
||||
const audioBuffer = await decodeAudioData(context, wav);
|
||||
refs.thinkingTone.buffer = audioBuffer;
|
||||
return audioBuffer;
|
||||
}
|
||||
|
||||
function stopLooping(): void {
|
||||
refs.looping.token += 1;
|
||||
if (refs.looping.timeout) {
|
||||
clearTimeout(refs.looping.timeout);
|
||||
refs.looping.timeout = null;
|
||||
}
|
||||
if (refs.looping.source) {
|
||||
try {
|
||||
refs.looping.source.onended = null;
|
||||
refs.looping.source.stop();
|
||||
} catch {
|
||||
// Ignore best-effort stop errors.
|
||||
}
|
||||
refs.looping.source.disconnect();
|
||||
refs.looping.source = null;
|
||||
}
|
||||
if (refs.looping.gain) {
|
||||
refs.looping.gain.disconnect();
|
||||
refs.looping.gain = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function playAudio(audio: AudioPlaybackSource): Promise<number> {
|
||||
const context = await ensurePlaybackContext();
|
||||
const arrayBuffer = await audio.arrayBuffer();
|
||||
@@ -335,18 +269,15 @@ export function createAudioEngine(
|
||||
return {
|
||||
async initialize() {
|
||||
await ensurePlaybackContext();
|
||||
await Promise.all([ensureThinkingToneBytes(), ensureThinkingToneBuffer()]);
|
||||
},
|
||||
|
||||
async destroy() {
|
||||
stopLooping();
|
||||
this.stop();
|
||||
this.clearQueue();
|
||||
await stopCapture();
|
||||
|
||||
const playbackContext = refs.playbackContext;
|
||||
refs.playbackContext = null;
|
||||
refs.thinkingTone.buffer = null;
|
||||
if (playbackContext && playbackContext.state !== "closed") {
|
||||
await playbackContext.close().catch(() => undefined);
|
||||
}
|
||||
@@ -486,63 +417,5 @@ export function createAudioEngine(
|
||||
isPlaying() {
|
||||
return refs.activePlayback !== null;
|
||||
},
|
||||
|
||||
playLooping(audio, gapMs) {
|
||||
if (refs.looping.source || refs.looping.timeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = refs.looping.token + 1;
|
||||
refs.looping.token = token;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const context = await ensurePlaybackContext();
|
||||
const audioBuffer =
|
||||
audio.byteLength > 0
|
||||
? pcm16LeToAudioBuffer(context, audio, 16000)
|
||||
: await ensureThinkingToneBuffer();
|
||||
if (refs.looping.token !== token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gain = context.createGain();
|
||||
gain.gain.value = 1;
|
||||
gain.connect(context.destination);
|
||||
refs.looping.gain = gain;
|
||||
|
||||
const playOnce = () => {
|
||||
if (refs.looping.token !== token) {
|
||||
return;
|
||||
}
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(gain);
|
||||
refs.looping.source = source;
|
||||
source.onended = () => {
|
||||
if (refs.looping.source === source) {
|
||||
refs.looping.source = null;
|
||||
}
|
||||
if (refs.looping.token !== token) {
|
||||
return;
|
||||
}
|
||||
refs.looping.timeout = setTimeout(
|
||||
playOnce,
|
||||
gapMs || THINKING_TONE_REPEAT_GAP_MS
|
||||
);
|
||||
};
|
||||
source.start();
|
||||
};
|
||||
|
||||
playOnce();
|
||||
} catch (error) {
|
||||
callbacks.onError?.(
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
})();
|
||||
},
|
||||
|
||||
stopLooping,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ function createAudioEngineMock(): AudioEngine {
|
||||
stop: vi.fn(),
|
||||
clearQueue: vi.fn(),
|
||||
isPlaying: vi.fn().mockReturnValue(false),
|
||||
playLooping: vi.fn(),
|
||||
stopLooping: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,7 +135,6 @@ describe("voice runtime", () => {
|
||||
runtime.onAssistantAudioStarted("server-1");
|
||||
|
||||
expect(runtime.getSnapshot().phase).toBe("playing");
|
||||
expect(engine.stopLooping).toHaveBeenCalled();
|
||||
expect(adapter.setAssistantAudioPlaying).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
@@ -256,7 +253,7 @@ describe("voice runtime", () => {
|
||||
runtime.onAssistantAudioFinished("server-1");
|
||||
|
||||
expect(runtime.getSnapshot().phase).toBe("waiting");
|
||||
expect(engine.playLooping).toHaveBeenCalled();
|
||||
expect(engine.play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts the thinking tone when an agent turn begins before playback", async () => {
|
||||
@@ -268,7 +265,7 @@ describe("voice runtime", () => {
|
||||
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
|
||||
|
||||
expect(runtime.getSnapshot().phase).toBe("waiting");
|
||||
expect(engine.playLooping).toHaveBeenCalled();
|
||||
expect(engine.play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns to listening after assistant playback once the turn is complete", async () => {
|
||||
@@ -283,8 +280,7 @@ describe("voice runtime", () => {
|
||||
runtime.onAssistantAudioFinished("server-1");
|
||||
|
||||
expect(runtime.getSnapshot().phase).toBe("listening");
|
||||
expect(engine.playLooping).toHaveBeenCalledTimes(1);
|
||||
expect(engine.stopLooping).toHaveBeenCalled();
|
||||
expect(engine.play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps local volume alone non-authoritative for playback interruption", async () => {
|
||||
|
||||
@@ -7,6 +7,10 @@ import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities
|
||||
import type { DaemonServerInfo } from "@/stores/session-store";
|
||||
import type { AudioEngine } from "@/voice/audio-engine-types";
|
||||
import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config";
|
||||
import {
|
||||
THINKING_TONE_NATIVE_PCM_BASE64,
|
||||
THINKING_TONE_NATIVE_PCM_DURATION_MS,
|
||||
} from "@/utils/thinking-tone.native-pcm";
|
||||
|
||||
const PCM_MIME_TYPE = "audio/pcm;rate=16000;bits=16";
|
||||
const KEEP_AWAKE_TAG = "paseo:voice";
|
||||
@@ -114,6 +118,26 @@ interface RuntimePlaybackState {
|
||||
generation: number;
|
||||
}
|
||||
|
||||
interface CueState {
|
||||
active: boolean;
|
||||
token: number;
|
||||
timeout: ReturnType<typeof setTimeout> | null;
|
||||
playing: boolean;
|
||||
}
|
||||
|
||||
interface RealtimeBridgeStats {
|
||||
windowStartedAtMs: number;
|
||||
captureEvents: number;
|
||||
captureBytes: number;
|
||||
uplinkEvents: number;
|
||||
uplinkRawBytes: number;
|
||||
uplinkBase64Chars: number;
|
||||
outputEvents: number;
|
||||
outputBytes: number;
|
||||
outputGroups: number;
|
||||
jsLagMaxMs: number;
|
||||
}
|
||||
|
||||
const INITIAL_SNAPSHOT: VoiceRuntimeSnapshot = {
|
||||
phase: "disabled",
|
||||
isVoiceMode: false,
|
||||
@@ -130,6 +154,8 @@ const INITIAL_TELEMETRY: VoiceRuntimeTelemetrySnapshot = {
|
||||
segmentDuration: 0,
|
||||
};
|
||||
|
||||
let nextVoiceRuntimeInstanceId = 1;
|
||||
|
||||
function snapshotsEqual(
|
||||
left: VoiceRuntimeSnapshot,
|
||||
right: VoiceRuntimeSnapshot
|
||||
@@ -180,6 +206,7 @@ export interface VoiceRuntime {
|
||||
}
|
||||
|
||||
export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
const instanceId = nextVoiceRuntimeInstanceId++;
|
||||
const listeners = new Set<() => void>();
|
||||
const telemetryListeners = new Set<() => void>();
|
||||
const sessions = new Map<string, RuntimeSessionState>();
|
||||
@@ -201,6 +228,69 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
processing: false,
|
||||
generation: 0,
|
||||
};
|
||||
const bridgeStats: RealtimeBridgeStats = {
|
||||
windowStartedAtMs: Date.now(),
|
||||
captureEvents: 0,
|
||||
captureBytes: 0,
|
||||
uplinkEvents: 0,
|
||||
uplinkRawBytes: 0,
|
||||
uplinkBase64Chars: 0,
|
||||
outputEvents: 0,
|
||||
outputBytes: 0,
|
||||
outputGroups: 0,
|
||||
jsLagMaxMs: 0,
|
||||
};
|
||||
const cue: CueState = {
|
||||
active: false,
|
||||
token: 0,
|
||||
timeout: null,
|
||||
playing: false,
|
||||
};
|
||||
const cuePcm16 = Uint8Array.from(Buffer.from(THINKING_TONE_NATIVE_PCM_BASE64, "base64"));
|
||||
const cueSource = {
|
||||
size: cuePcm16.byteLength,
|
||||
type: "audio/pcm;rate=16000;bits=16",
|
||||
async arrayBuffer() {
|
||||
return cuePcm16.buffer.slice(
|
||||
cuePcm16.byteOffset,
|
||||
cuePcm16.byteOffset + cuePcm16.byteLength
|
||||
);
|
||||
},
|
||||
};
|
||||
let lagProbeLastMs = Date.now();
|
||||
const lagProbe = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const lagMs = Math.max(0, now - lagProbeLastMs - 100);
|
||||
lagProbeLastMs = now;
|
||||
if (lagMs > bridgeStats.jsLagMaxMs) {
|
||||
bridgeStats.jsLagMaxMs = lagMs;
|
||||
}
|
||||
}, 100);
|
||||
|
||||
function flushBridgeStats(reason: string): void {
|
||||
const now = Date.now();
|
||||
const elapsedMs = now - bridgeStats.windowStartedAtMs;
|
||||
if (elapsedMs < 1000) {
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`[VoiceRuntime#${instanceId}][bridge] ${reason} ` +
|
||||
`capture=${bridgeStats.captureEvents}ev/${bridgeStats.captureBytes}B ` +
|
||||
`uplink=${bridgeStats.uplinkEvents}ev/${bridgeStats.uplinkRawBytes}B/${bridgeStats.uplinkBase64Chars}c ` +
|
||||
`output=${bridgeStats.outputEvents}ev/${bridgeStats.outputBytes}B groups=${bridgeStats.outputGroups} ` +
|
||||
`jsLagMaxMs=${bridgeStats.jsLagMaxMs} windowMs=${elapsedMs}`
|
||||
);
|
||||
bridgeStats.windowStartedAtMs = now;
|
||||
bridgeStats.captureEvents = 0;
|
||||
bridgeStats.captureBytes = 0;
|
||||
bridgeStats.uplinkEvents = 0;
|
||||
bridgeStats.uplinkRawBytes = 0;
|
||||
bridgeStats.uplinkBase64Chars = 0;
|
||||
bridgeStats.outputEvents = 0;
|
||||
bridgeStats.outputBytes = 0;
|
||||
bridgeStats.outputGroups = 0;
|
||||
bridgeStats.jsLagMaxMs = 0;
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) {
|
||||
@@ -441,7 +531,17 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
}
|
||||
|
||||
function stopCue(): void {
|
||||
deps.engine.stopLooping();
|
||||
const hadActive = cue.active || cue.timeout !== null || cue.playing;
|
||||
cue.active = false;
|
||||
cue.token += 1;
|
||||
if (cue.timeout) {
|
||||
clearTimeout(cue.timeout);
|
||||
cue.timeout = null;
|
||||
}
|
||||
if (hadActive) {
|
||||
deps.engine.stop();
|
||||
deps.engine.clearQueue();
|
||||
}
|
||||
}
|
||||
|
||||
function resetCaptureTelemetry(): void {
|
||||
@@ -455,7 +555,36 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
stopCue();
|
||||
return;
|
||||
}
|
||||
deps.engine.playLooping(new Uint8Array(0), THINKING_TONE_REPEAT_GAP_MS);
|
||||
if (cue.active) {
|
||||
return;
|
||||
}
|
||||
cue.active = true;
|
||||
cue.token += 1;
|
||||
const token = cue.token;
|
||||
|
||||
const playNext = () => {
|
||||
if (!cue.active || cue.token !== token) {
|
||||
return;
|
||||
}
|
||||
cue.playing = true;
|
||||
void deps.engine
|
||||
.play(cueSource)
|
||||
.catch((error) => {
|
||||
if (cue.token !== token) {
|
||||
return;
|
||||
}
|
||||
console.warn(`[VoiceRuntime#${instanceId}] Cue playback failed:`, error);
|
||||
})
|
||||
.finally(() => {
|
||||
cue.playing = false;
|
||||
if (!cue.active || cue.token !== token) {
|
||||
return;
|
||||
}
|
||||
cue.timeout = setTimeout(playNext, THINKING_TONE_NATIVE_PCM_DURATION_MS + THINKING_TONE_REPEAT_GAP_MS);
|
||||
});
|
||||
};
|
||||
|
||||
playNext();
|
||||
}
|
||||
|
||||
const uploader: ContinuousVoiceUploader = {
|
||||
@@ -471,10 +600,18 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
return;
|
||||
}
|
||||
|
||||
const base64 = Buffer.from(chunk).toString("base64");
|
||||
bridgeStats.captureEvents += 1;
|
||||
bridgeStats.captureBytes += chunk.byteLength;
|
||||
bridgeStats.uplinkEvents += 1;
|
||||
bridgeStats.uplinkRawBytes += chunk.byteLength;
|
||||
bridgeStats.uplinkBase64Chars += base64.length;
|
||||
flushBridgeStats("uplink");
|
||||
|
||||
void activeSession.adapter
|
||||
.sendVoiceAudioChunk(Buffer.from(chunk).toString("base64"), PCM_MIME_TYPE)
|
||||
.sendVoiceAudioChunk(base64, PCM_MIME_TYPE)
|
||||
.catch((error) => {
|
||||
console.error("[VoiceRuntime] Failed to send audio chunk:", error);
|
||||
console.error(`[VoiceRuntime#${instanceId}] Failed to send audio chunk:`, error);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -651,13 +788,22 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
!state.snapshot.isVoiceMode ||
|
||||
!payload.isVoiceMode
|
||||
) {
|
||||
console.log(`[VoiceRuntime] audio_output DROPPED: activeServer=${state.snapshot.activeServerId} serverId=${serverId} isVoiceMode=${state.snapshot.isVoiceMode} payloadVoice=${payload.isVoiceMode}`);
|
||||
console.log(`[VoiceRuntime#${instanceId}] audio_output DROPPED: activeServer=${state.snapshot.activeServerId} serverId=${serverId} isVoiceMode=${state.snapshot.isVoiceMode} payloadVoice=${payload.isVoiceMode}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const groupId = payload.groupId ?? payload.id;
|
||||
const chunkIndex = payload.chunkIndex ?? 0;
|
||||
console.log(`[VoiceRuntime] audio_output groupId=${groupId} chunk=${chunkIndex} isLast=${payload.isLastChunk} size=${payload.audio.length} format=${payload.format}`);
|
||||
const decoded = decodeAudioChunk(payload.audio);
|
||||
bridgeStats.outputEvents += 1;
|
||||
bridgeStats.outputBytes += decoded.byteLength;
|
||||
bridgeStats.outputGroups += playback.groups.has(groupId) ? 0 : 1;
|
||||
console.log(
|
||||
`[VoiceRuntime#${instanceId}] audio_output groupId=${groupId} chunk=${chunkIndex} isLast=${payload.isLastChunk} ` +
|
||||
`base64Chars=${payload.audio.length} decodedBytes=${decoded.byteLength} format=${payload.format} ` +
|
||||
`head=${Array.from(decoded.slice(0, 12)).map((value) => value.toString(16).padStart(2, "0")).join(" ")}`
|
||||
);
|
||||
flushBridgeStats("audio_output");
|
||||
|
||||
let group = playback.groups.get(groupId);
|
||||
if (!group) {
|
||||
@@ -683,7 +829,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
group.chunks.set(chunkIndex, {
|
||||
id: payload.id,
|
||||
chunkIndex,
|
||||
source: toPlaybackSource(decodeAudioChunk(payload.audio), payload.format),
|
||||
source: toPlaybackSource(decoded, payload.format),
|
||||
});
|
||||
if (payload.isLastChunk) {
|
||||
group.finalChunkIndex = chunkIndex;
|
||||
@@ -796,6 +942,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
|
||||
async destroy() {
|
||||
await this.stopVoice().catch(() => undefined);
|
||||
clearInterval(lagProbe);
|
||||
await deps.engine.destroy();
|
||||
listeners.clear();
|
||||
telemetryListeners.clear();
|
||||
|
||||
5
packages/expo-two-way-audio/.eslintrc.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["universe/native", "universe/web"],
|
||||
ignorePatterns: ["build"],
|
||||
};
|
||||
29
packages/expo-two-way-audio/.github/workflows/cd.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Publish to NPM
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: ⎔ Get Node Version
|
||||
id: node_version
|
||||
run: echo "NVMRC=$(cat .nvmrc)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: ⎔ Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "${{ steps.node_version.outputs.NVMRC }}"
|
||||
cache: npm
|
||||
|
||||
- name: 📦 Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: ⬆️ Publish
|
||||
run: npm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}" && npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{secrets.NPM_AUTH_TOKEN}}
|
||||
36
packages/expo-two-way-audio/.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: 🧪 Test
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: ⎔ Get Node Version
|
||||
id: node_version
|
||||
run: echo "NVMRC=$(cat .nvmrc)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: ⎔ Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "${{ steps.node_version.outputs.NVMRC }}"
|
||||
cache: npm
|
||||
|
||||
- name: 📦 Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: 🧪 Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: 🧪 Format
|
||||
run: npm run format
|
||||
|
||||
- name: 🧪 Typecheck
|
||||
run: npm run typecheck
|
||||
56
packages/expo-two-way-audio/.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
# OSX
|
||||
#
|
||||
.DS_Store
|
||||
|
||||
# VSCode
|
||||
.vscode/
|
||||
jsconfig.json
|
||||
|
||||
# Xcode
|
||||
#
|
||||
build/
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
*.mode1v3
|
||||
!default.mode1v3
|
||||
*.mode2v3
|
||||
!default.mode2v3
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
xcuserdata
|
||||
*.xccheckout
|
||||
*.moved-aside
|
||||
DerivedData
|
||||
*.hmap
|
||||
*.ipa
|
||||
*.xcuserstate
|
||||
project.xcworkspace
|
||||
|
||||
# Android/IJ
|
||||
#
|
||||
.classpath
|
||||
.cxx
|
||||
.gradle
|
||||
.idea
|
||||
.project
|
||||
.settings
|
||||
local.properties
|
||||
android.iml
|
||||
android/app/libs
|
||||
android/keystores/debug.keystore
|
||||
|
||||
# Ruby
|
||||
examples/*/vendor/
|
||||
|
||||
# node.js
|
||||
#
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Expo
|
||||
.expo/*
|
||||
|
||||
examples/*/ios
|
||||
examples/*/android
|
||||
14
packages/expo-two-way-audio/.npmignore
Normal file
@@ -0,0 +1,14 @@
|
||||
# Exclude all top-level hidden directories by convention
|
||||
/.*/
|
||||
|
||||
# Exclude tarballs generated by `npm pack`
|
||||
/*.tgz
|
||||
|
||||
__mocks__
|
||||
__tests__
|
||||
|
||||
/babel.config.js
|
||||
/android/src/androidTest/
|
||||
/android/src/test/
|
||||
/android/build/
|
||||
/examples/
|
||||
1
packages/expo-two-way-audio/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
20.17
|
||||
128
packages/expo-two-way-audio/CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
devx@speechmatics.com.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
54
packages/expo-two-way-audio/CONTRIBUTING.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Contributing
|
||||
|
||||
Welcome to the Speechmatics Expo Two Way Audio Module! We're open to contributions from anyone. We hope you can find everything you need in here to get started contributing to this repo.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Useful Links](#useful-links)
|
||||
- [How to Submit Changes](#how-to-submit-changes)
|
||||
- [How to Report a Bug](#how-to-report-a-bug)
|
||||
- [How to Request a Feature](#how-to-request-a-feature)
|
||||
- [Style Guide](#style-guide)
|
||||
|
||||
## Useful Links
|
||||
|
||||
- [Speechmatics Website](https://www.speechmatics.com/)
|
||||
- [Portal (for generating API keys)](https://portal.speechmatics.com/manage-access/)
|
||||
- [Docs](https://docs.speechmatics.com/)
|
||||
|
||||
### Installing dependencies
|
||||
|
||||
After cloning this repo, run
|
||||
|
||||
```sh
|
||||
npm i
|
||||
```
|
||||
|
||||
## How to Submit Changes
|
||||
|
||||
We try not to be too prescreptive about how people work, but we also believe in helping make things easier by following a couple of basic steps. If you follow these recommendations, it should make the review process simpler and quicker:
|
||||
|
||||
1. If your change is large, consider reaching out to us beforehand and discussing the issue. This could save you a lot of time if there are good reasons why we haven't done something.
|
||||
2. Fork the repo and work on a branch on your fork. Try to give your branches short, descriptive names in the format {type}/{description} e.g. bugfix/missing-try-catch.
|
||||
3. Make sure your changes are tested - ideally both manually and in the unit tests.
|
||||
4. When opening a PR back into our repo, provide some simple descriptive comments that list the changes being made in the PR.
|
||||
5. Give your PR a short, descriptive title.
|
||||
|
||||
## How to Report a Bug
|
||||
|
||||
If you are experiencing a bug, you can report it via the [issues](https://github.com/speechmatics/expo-two-way-audio/issues) page. The more details you give us, the better we can understand and fix your problem!
|
||||
|
||||
## How to Request a Feature
|
||||
|
||||
If you want a feature, you can open a discussion via the [issues](https://github.com/speechmatics/expo-two-way-audio/issues) page. Try to tag your issue with the most appropriate label available so that we can track it more easily.
|
||||
|
||||
## Style Guide
|
||||
|
||||
We use Biome for linting and formatting: https://www.npmjs.com/package/@biomejs/biome
|
||||
|
||||
You can run linting and formatting using the scripts:
|
||||
|
||||
```
|
||||
npm run lint
|
||||
npm run format
|
||||
```
|
||||
21
packages/expo-two-way-audio/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023, Cantab Research Ltd.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
95
packages/expo-two-way-audio/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Speechmatics Two Way Audio
|
||||
|
||||
Expo module for capturing and playing pcm audio data in react-native apps (iOS and Android).
|
||||
|
||||
The aim of the module is to facilitate creating real-time conversational apps. The following features are provided:
|
||||
|
||||
- Request audio recording permissions
|
||||
- Get clean (applying Acoustic Echo Cancelling) microphone samples in PCM format (1 channel 16 bit at 16kHz)
|
||||
- Play audio samples in PCM format (1 channel 16 bit at 16kHz). Playback happens through main speaker unless external audio sources are connected.
|
||||
- Provide volume level both for the input and output samples. Float between 0 and 1.
|
||||
- [iOS only] Get microphone mode and prompt user to select a microphone mode.
|
||||
|
||||
Check out our [examples/](./examples) to see the module in action.
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
npm i @speechmatics/expo-two-way-audio
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Please check out our [examples/](./examples) to get full sample code.
|
||||
|
||||
1. Request permissions for recording audio
|
||||
|
||||
```JSX
|
||||
import {useMicrophonePermissions} from "@speechmatics/expo-two-way-audio";
|
||||
|
||||
const [micPermission, requestMicPermission] = useMicrophonePermissions();
|
||||
console.log(micPermission);
|
||||
```
|
||||
|
||||
1. Initialize the module before calling any audio functionality.
|
||||
|
||||
```JSX
|
||||
useEffect(() => {
|
||||
const initializeAudio = async () => {
|
||||
await initialize();
|
||||
};
|
||||
initializeAudio();
|
||||
}, []);
|
||||
|
||||
```
|
||||
|
||||
1. Play audio
|
||||
|
||||
> [!NOTE]
|
||||
> The sample below uses the `buffer` module:
|
||||
> `npm install buffer`
|
||||
|
||||
```JSX
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
// As an example, let's play pcm data hardcoded in a variable.
|
||||
// The examples/basic-usage does this. Check it out for real base64 data.
|
||||
const audioChunk = "SOME PCM DATA BASE64 ENCODED HERE"
|
||||
const buffer = Buffer.from(audioChunk, "base64");
|
||||
const pcmData = new Uint8Array(buffer);
|
||||
playPCMData(pcmData);
|
||||
```
|
||||
|
||||
1. Get microphone samples
|
||||
|
||||
```JSX
|
||||
// Set up a function to deal with microphone sample events.
|
||||
// In this case just print the data in the console.
|
||||
useExpoTwoWayAudioEventListener(
|
||||
"onMicrophoneData",
|
||||
useCallback<MicrophoneDataCallback>((event) => {
|
||||
console.log(`MIC DATA: ${event.data}`);
|
||||
}, []),
|
||||
);
|
||||
|
||||
// Unmute the microphone to get microphone data events
|
||||
toggleRecording(true);
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
Some audio features of expo-two-way-audio like Acoustic Echo Cancelling, noise reduction or microphone modes (iOS) don't work on simulator. Run the example on a real device to test these features.
|
||||
|
||||
```bash
|
||||
# iOS
|
||||
npx expo run:ios --device --configuration Release
|
||||
|
||||
# Android
|
||||
npx expo run:android --device --variant release
|
||||
```
|
||||
|
||||
For Android, the following permissions are needed: `RECORD_AUDIO`, `MODIFY_AUDIO_SETTINGS`. In Expo apps they can bee added in your `app.json` file:
|
||||
|
||||
```javascript
|
||||
expo.android.permissions: ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"]
|
||||
```
|
||||
43
packages/expo-two-way-audio/android/build.gradle
Normal file
@@ -0,0 +1,43 @@
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
group = 'expo.modules.twowayaudio'
|
||||
version = '0.1.0'
|
||||
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
apply from: expoModulesCorePlugin
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
useCoreDependencies()
|
||||
useExpoPublishing()
|
||||
|
||||
// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
|
||||
// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
|
||||
// Most of the time, you may like to manage the Android SDK versions yourself.
|
||||
def useManagedAndroidSdkVersions = false
|
||||
if (useManagedAndroidSdkVersions) {
|
||||
useDefaultAndroidSdkVersions()
|
||||
} else {
|
||||
buildscript {
|
||||
// Simple helper that allows the root project to override versions declared by this library.
|
||||
ext.safeExtGet = { prop, fallback ->
|
||||
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||
}
|
||||
}
|
||||
project.android {
|
||||
compileSdkVersion safeExtGet("compileSdkVersion", 34)
|
||||
defaultConfig {
|
||||
minSdkVersion safeExtGet("minSdkVersion", 21)
|
||||
targetSdkVersion safeExtGet("targetSdkVersion", 34)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "expo.modules.twowayaudio"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<manifest>
|
||||
</manifest>
|
||||
@@ -0,0 +1,425 @@
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioRecord
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaRecorder
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import java.util.LinkedList
|
||||
import java.util.Queue
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.math.pow
|
||||
|
||||
|
||||
class AudioEngine (context: Context) {
|
||||
private val SAMPLE_RATE = 16000
|
||||
private val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
|
||||
private val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO
|
||||
|
||||
private lateinit var audioRecord: AudioRecord
|
||||
private lateinit var audioManager: AudioManager
|
||||
private lateinit var audioTrack: AudioTrack
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private val audioSampleQueue: Queue<ByteArray> = LinkedList()
|
||||
private var echoCanceler: AcousticEchoCanceler? = null
|
||||
private var noiseSuppressor: NoiseSuppressor? = null
|
||||
private val executorServiceMicrophone = Executors.newFixedThreadPool(1)
|
||||
private val executorServicePlayback = Executors.newFixedThreadPool(1)
|
||||
private var speakerDevice: AudioDeviceInfo? = null
|
||||
private var bridgeWindowStartedAtMs = System.currentTimeMillis()
|
||||
private var micEvents = 0
|
||||
private var micBytes = 0L
|
||||
private var playbackEvents = 0
|
||||
private var playbackQueuedBytes = 0L
|
||||
private var playbackWrites = 0
|
||||
private var playbackWriteBytes = 0L
|
||||
|
||||
var isRecording = false
|
||||
private var isRecordingBeforePause = false
|
||||
var isPlaying = false
|
||||
|
||||
// Callbacks
|
||||
var onMicDataCallback: ((ByteArray) -> Unit)? = null
|
||||
var onInputVolumeCallback: ((Float) -> Unit)? = null
|
||||
var onOutputVolumeCallback: ((Float) -> Unit)? = null
|
||||
var onAudioInterruptionCallback: ((String) -> Unit)? = null
|
||||
|
||||
init {
|
||||
initializeAudio(context)
|
||||
}
|
||||
|
||||
private fun flushBridgeStats(reason: String) {
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsedMs = now - bridgeWindowStartedAtMs
|
||||
if (elapsedMs < 1000) {
|
||||
return
|
||||
}
|
||||
Log.d(
|
||||
"AudioEngine",
|
||||
"[bridge] $reason mic=$micEvents" +
|
||||
"ev/${micBytes}B playbackQueue=$playbackEvents" +
|
||||
"ev/${playbackQueuedBytes}B playbackWrites=$playbackWrites" +
|
||||
"ev/${playbackWriteBytes}B windowMs=$elapsedMs"
|
||||
)
|
||||
bridgeWindowStartedAtMs = now
|
||||
micEvents = 0
|
||||
micBytes = 0
|
||||
playbackEvents = 0
|
||||
playbackQueuedBytes = 0
|
||||
playbackWrites = 0
|
||||
playbackWriteBytes = 0
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun initializeAudio(context:Context) {
|
||||
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
requestAudioFocus()
|
||||
|
||||
// Route audio to external device if connected, otherwise route to speaker
|
||||
updateAudioRouting()
|
||||
|
||||
// Listen for changes in audio routing
|
||||
audioManager.registerAudioDeviceCallback(object:android.media.AudioDeviceCallback(){
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
|
||||
Log.d("AudioEngine", "onAudioDevicesAdded")
|
||||
super.onAudioDevicesAdded(addedDevices)
|
||||
updateAudioRouting()
|
||||
}
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
|
||||
Log.d("AudioEngine", "onAudioDevicesRemoved")
|
||||
super.onAudioDevicesRemoved(removedDevices)
|
||||
updateAudioRouting()
|
||||
}
|
||||
}, null)
|
||||
|
||||
val bufferSize = AudioTrack.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AUDIO_FORMAT
|
||||
)
|
||||
|
||||
audioTrack = AudioTrack(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build(),
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AUDIO_FORMAT)
|
||||
.setSampleRate(SAMPLE_RATE)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build(),
|
||||
bufferSize,
|
||||
AudioTrack.MODE_STREAM,
|
||||
audioManager.generateAudioSessionId()
|
||||
).apply {
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAudioRouting() {
|
||||
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
||||
var isExternalDeviceConnected = false
|
||||
var selectedDevice: AudioDeviceInfo? = null
|
||||
|
||||
for (device in devices) {
|
||||
if (device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) {
|
||||
speakerDevice = device
|
||||
}
|
||||
if (device.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
|
||||
device.type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
|
||||
device.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
|
||||
device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
|
||||
isExternalDeviceConnected = true
|
||||
selectedDevice = device
|
||||
break
|
||||
} else if (device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) {
|
||||
selectedDevice = device
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
// Use the modern API for Android S and above
|
||||
try {
|
||||
selectedDevice?.let {
|
||||
audioManager.setCommunicationDevice(it)
|
||||
}
|
||||
}catch (e:Exception){
|
||||
Log.e("AudioEngine", "Error setting communication device. Using speaker")
|
||||
speakerDevice?.let {
|
||||
audioManager.setCommunicationDevice(it)
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
// Fall back to deprecated method for older Android versions
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.isSpeakerphoneOn = !isExternalDeviceConnected
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun requestAudioFocus() {
|
||||
val focusRequest =
|
||||
AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
)
|
||||
.setAcceptsDelayedFocusGain(true)
|
||||
.setOnAudioFocusChangeListener { focusChange ->
|
||||
when (focusChange) {
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
Log.d("AudioEngine", "Audio focus lost")
|
||||
onAudioInterruptionCallback?.let { it("blocked") }
|
||||
}
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
audioFocusRequest = focusRequest
|
||||
val result = audioManager.requestAudioFocus(focusRequest)
|
||||
|
||||
if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
throw RuntimeException("Audio focus request failed")
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startRecording(){
|
||||
val bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)
|
||||
audioRecord = AudioRecord(
|
||||
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_CONFIG,
|
||||
AUDIO_FORMAT,
|
||||
bufferSize
|
||||
)
|
||||
|
||||
if (audioRecord.state != AudioRecord.STATE_INITIALIZED) {
|
||||
throw RuntimeException("Audio Record can't initialize!")
|
||||
}
|
||||
|
||||
if (AcousticEchoCanceler.isAvailable()){
|
||||
echoCanceler = AcousticEchoCanceler.create(audioRecord.audioSessionId)
|
||||
if (echoCanceler != null) {
|
||||
echoCanceler?.enabled = true
|
||||
Log.i("AudioEngine", "Echo Canceler enabled")
|
||||
}
|
||||
}
|
||||
|
||||
if (NoiseSuppressor.isAvailable()){
|
||||
noiseSuppressor = NoiseSuppressor.create(audioRecord.audioSessionId)
|
||||
if (noiseSuppressor != null) {
|
||||
noiseSuppressor?.enabled = true
|
||||
Log.i("AudioEngine", "Noise Suppressor enabled")
|
||||
}
|
||||
}
|
||||
|
||||
audioRecord.startRecording()
|
||||
isRecording = true
|
||||
startMicSampleTap()
|
||||
}
|
||||
|
||||
private fun startMicSampleTap(){
|
||||
executorServiceMicrophone.execute {
|
||||
val buffer = ByteArray(1024)
|
||||
try {
|
||||
while (isRecording) {
|
||||
val read = audioRecord.read(buffer, 0, buffer.size)
|
||||
if (read > 0) {
|
||||
val data = buffer.copyOf(read)
|
||||
micEvents += 1
|
||||
micBytes += data.size.toLong()
|
||||
flushBridgeStats("mic")
|
||||
val micVolume = calculateRMSLevel(data)
|
||||
onInputVolumeCallback?.invoke(micVolume)
|
||||
onMicDataCallback?.invoke(data)
|
||||
}
|
||||
}
|
||||
Log.d("AudioEngine", "Mic sample tap stopped.")
|
||||
}catch (e: Exception){
|
||||
Log.e("AudioEngine", "Error reading mic sample data", e)
|
||||
isRecording = false
|
||||
tearDown()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRecording() {
|
||||
if (!isRecording) return
|
||||
isRecording = false
|
||||
if (audioRecord.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
|
||||
audioRecord.stop()
|
||||
audioRecord.release()
|
||||
}
|
||||
onInputVolumeCallback?.invoke(0.0F)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun toggleRecording(value: Boolean): Boolean {
|
||||
if (value == isRecording) return isRecording
|
||||
|
||||
if (value) {
|
||||
startRecording()
|
||||
} else {
|
||||
stopRecording()
|
||||
}
|
||||
|
||||
isRecording = value
|
||||
return isRecording
|
||||
}
|
||||
|
||||
fun playPCMData(data: ByteArray) {
|
||||
audioSampleQueue.add(data)
|
||||
playbackEvents += 1
|
||||
playbackQueuedBytes += data.size.toLong()
|
||||
Log.d(
|
||||
"AudioEngine",
|
||||
"playPCMData queued bytes=${data.size} queueSize=${audioSampleQueue.size} " +
|
||||
"head=${data.take(12).joinToString(" ") { byte -> "%02x".format(byte.toInt() and 0xff) }}"
|
||||
)
|
||||
flushBridgeStats("queue")
|
||||
if (!isPlaying) {
|
||||
playAudioFromSampleQueue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun playAudioFromSampleQueue() {
|
||||
executorServicePlayback.execute{
|
||||
isPlaying = true
|
||||
try {
|
||||
while (audioSampleQueue.isNotEmpty()){
|
||||
val data = audioSampleQueue.poll()
|
||||
if (data != null){
|
||||
playSample(data)
|
||||
val audioVolume = calculateRMSLevel(data)
|
||||
onOutputVolumeCallback?.invoke(audioVolume)
|
||||
}else{
|
||||
break
|
||||
}
|
||||
}
|
||||
}catch (e: Exception){
|
||||
Log.e("AudioEngine", "Error playing audio", e)
|
||||
e.printStackTrace()
|
||||
}finally {
|
||||
isPlaying = false
|
||||
onOutputVolumeCallback?.invoke(0.0F)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playSample(data: ByteArray) {
|
||||
val written = audioTrack.write(data, 0, data.size)
|
||||
playbackWrites += 1
|
||||
playbackWriteBytes += written.coerceAtLeast(0).toLong()
|
||||
Log.d(
|
||||
"AudioEngine",
|
||||
"playSample wrote=$written requested=${data.size} playState=${audioTrack.playState} " +
|
||||
"queueSize=${audioSampleQueue.size}"
|
||||
)
|
||||
flushBridgeStats("write")
|
||||
}
|
||||
|
||||
fun bypassVoiceProcessing(bypass: Boolean) {
|
||||
if (bypass) {
|
||||
echoCanceler?.enabled = false
|
||||
noiseSuppressor?.enabled = false
|
||||
} else {
|
||||
echoCanceler?.enabled = true
|
||||
noiseSuppressor?.enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun pauseRecordingAndPlayer() {
|
||||
isRecordingBeforePause = isRecording
|
||||
isRecording = toggleRecording(false)
|
||||
audioTrack.pause()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun resumeRecordingAndPlayer() {
|
||||
requestAudioFocus()
|
||||
isRecording = toggleRecording(isRecordingBeforePause)
|
||||
audioTrack.play()
|
||||
}
|
||||
|
||||
fun stopPlayback() {
|
||||
audioSampleQueue.clear()
|
||||
audioTrack.pause()
|
||||
audioTrack.flush()
|
||||
isPlaying = false
|
||||
onOutputVolumeCallback?.invoke(0.0F)
|
||||
Log.d("AudioEngine", "Playback stopped")
|
||||
}
|
||||
|
||||
fun pausePlayback() {
|
||||
audioTrack.pause()
|
||||
Log.d("AudioEngine", "Playback paused")
|
||||
}
|
||||
|
||||
fun resumePlayback() {
|
||||
audioTrack.play()
|
||||
Log.d("AudioEngine", "Playback resumed")
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
fun tearDown() {
|
||||
stopRecording()
|
||||
audioTrack.stop()
|
||||
audioManager.mode = AudioManager.MODE_NORMAL
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
audioManager.clearCommunicationDevice()
|
||||
}
|
||||
audioFocusRequest?.let { request ->
|
||||
audioManager.abandonAudioFocusRequest(request)
|
||||
}
|
||||
executorServiceMicrophone.shutdownNow()
|
||||
}
|
||||
|
||||
|
||||
private fun calculateRMSLevel(buffer: ByteArray): Float {
|
||||
val epsilon = 1e-5f // To avoid log(0)
|
||||
|
||||
// Convert ByteArray to FloatArray by treating each pair of bytes as a single 16-bit PCM sample
|
||||
val floatBuffer = FloatArray(buffer.size / 2)
|
||||
for (i in floatBuffer.indices) {
|
||||
// Combine two bytes into a 16-bit signed integer
|
||||
val sample = (buffer[i * 2].toInt() or (buffer[i * 2 + 1].toInt() shl 8)).toShort()
|
||||
// Normalize sample to -1.0 to 1.0 range for FloatArray
|
||||
floatBuffer[i] = sample / 32768.0f
|
||||
}
|
||||
|
||||
// Calculate RMS value
|
||||
val rmsValue = kotlin.math.sqrt(floatBuffer.fold(0f) { acc, sample -> acc + sample * sample } / floatBuffer.size)
|
||||
|
||||
// Convert to decibels
|
||||
val dbValue = 20 * kotlin.math.log10(maxOf(rmsValue, epsilon))
|
||||
|
||||
// Normalize decibel value to 0-1 range
|
||||
// Assuming minimum audible is -80dB and maximum is 0dB
|
||||
val minDb = -80.0f
|
||||
val normalizedValue = maxOf(0.0f, minOf(1.0f, (dbValue - minDb) / kotlin.math.abs(minDb)))
|
||||
|
||||
// Optional: Apply exponential factor to push smaller values down
|
||||
val expFactor = 2.0f // Adjust this value to change the curve
|
||||
val adjustedValue = normalizedValue.pow(expFactor)
|
||||
|
||||
return adjustedValue
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package expo.modules.twowayaudio
|
||||
|
||||
import android.app.Activity
|
||||
import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
||||
|
||||
class ExpoTwoWayAudioLifeCycleListener : ReactActivityLifecycleListener {
|
||||
override fun onPause(activity: Activity?) {
|
||||
super.onPause(activity)
|
||||
// At the moment background audio recording and playback is not supported
|
||||
ExpoTwoWayAudioModule.audioEngine?.pauseRecordingAndPlayer()
|
||||
}
|
||||
|
||||
override fun onResume(activity: Activity?) {
|
||||
super.onResume(activity)
|
||||
// At the moment background audio recording and playback is not supported
|
||||
ExpoTwoWayAudioModule.audioEngine?.resumeRecordingAndPlayer()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package expo.modules.twowayaudio
|
||||
|
||||
import AudioEngine
|
||||
import androidx.core.os.bundleOf
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import expo.modules.kotlin.Promise
|
||||
import expo.modules.interfaces.permissions.Permissions
|
||||
|
||||
class ExpoTwoWayAudioModule : Module() {
|
||||
companion object {
|
||||
private const val ON_MIC_DATA_EVENT = "onMicrophoneData"
|
||||
private const val ON_INPUT_VOLUME_LEVEL_EVENT = "onInputVolumeLevelData"
|
||||
private const val ON_OUTPUT_VOLUME_LEVEL_EVENT = "onOutputVolumeLevelData"
|
||||
private const val ON_RECORDING_CHANGE_EVENT = "onRecordingChange"
|
||||
private const val ON_AUDIO_INTERRUPTION_EVENT = "onAudioInterruption"
|
||||
var audioEngine: AudioEngine? = null
|
||||
}
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("ExpoTwoWayAudio")
|
||||
AsyncFunction("initialize") { promise: Promise ->
|
||||
try {
|
||||
if (audioEngine != null) {
|
||||
promise.resolve(true)
|
||||
return@AsyncFunction
|
||||
}
|
||||
audioEngine = appContext.reactContext?.let { AudioEngine(it) }
|
||||
setupCallbacks()
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.resolve(false)
|
||||
}
|
||||
}
|
||||
|
||||
Function("isRecording") {
|
||||
audioEngine?.isRecording ?: false
|
||||
}
|
||||
|
||||
Function("toggleRecording") { value: Boolean ->
|
||||
audioEngine?.let { engine ->
|
||||
val isRecording = engine.toggleRecording(value)
|
||||
sendEvent(ON_RECORDING_CHANGE_EVENT, mapOf("data" to isRecording))
|
||||
isRecording
|
||||
} ?: false
|
||||
}
|
||||
|
||||
Function("tearDown") {
|
||||
audioEngine?.tearDown()
|
||||
audioEngine = null
|
||||
null
|
||||
}
|
||||
|
||||
Function("restart") {
|
||||
audioEngine?.resumeRecordingAndPlayer()
|
||||
sendEvent(ON_RECORDING_CHANGE_EVENT, mapOf(
|
||||
"data" to (audioEngine?.isRecording ?: false)
|
||||
))
|
||||
}
|
||||
|
||||
Function("playPCMData") { data: kotlin.ByteArray ->
|
||||
audioEngine?.playPCMData(data)
|
||||
}
|
||||
|
||||
Function("bypassVoiceProcessing") { bypass: Boolean ->
|
||||
audioEngine?.bypassVoiceProcessing(bypass)
|
||||
}
|
||||
|
||||
Function("isPlaying") {
|
||||
audioEngine?.isPlaying ?: false
|
||||
}
|
||||
|
||||
Function("stopPlayback") {
|
||||
audioEngine?.stopPlayback()
|
||||
}
|
||||
|
||||
Function("pausePlayback") {
|
||||
audioEngine?.pausePlayback()
|
||||
}
|
||||
|
||||
Function("resumePlayback") {
|
||||
audioEngine?.resumePlayback()
|
||||
}
|
||||
|
||||
Function("getMicrophoneModeIOS") {
|
||||
throw UnsupportedOperationException("getMicrophoneModeIOS is only supported on iOS")
|
||||
}
|
||||
|
||||
Function ("setMicrophoneModeIOS") {
|
||||
throw UnsupportedOperationException("setMicrophoneModeIOS is only supported on iOS")
|
||||
}
|
||||
|
||||
AsyncFunction("getMicrophonePermissionsAsync") { promise: Promise ->
|
||||
Permissions.getPermissionsWithPermissionsManager(
|
||||
appContext.permissions,
|
||||
promise,
|
||||
android.Manifest.permission.RECORD_AUDIO
|
||||
)
|
||||
}
|
||||
|
||||
AsyncFunction("requestMicrophonePermissionsAsync") { promise: Promise ->
|
||||
Permissions.askForPermissionsWithPermissionsManager(
|
||||
appContext.permissions,
|
||||
promise,
|
||||
android.Manifest.permission.RECORD_AUDIO
|
||||
)
|
||||
}
|
||||
|
||||
// Register events
|
||||
Events(
|
||||
ON_MIC_DATA_EVENT,
|
||||
ON_INPUT_VOLUME_LEVEL_EVENT,
|
||||
ON_OUTPUT_VOLUME_LEVEL_EVENT,
|
||||
ON_RECORDING_CHANGE_EVENT,
|
||||
ON_AUDIO_INTERRUPTION_EVENT
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupCallbacks() {
|
||||
audioEngine?.apply {
|
||||
onMicDataCallback = { data ->
|
||||
sendEvent(ON_MIC_DATA_EVENT, bundleOf("data" to data))
|
||||
}
|
||||
onInputVolumeCallback = { level ->
|
||||
sendEvent(ON_INPUT_VOLUME_LEVEL_EVENT, bundleOf("data" to level))
|
||||
}
|
||||
onOutputVolumeCallback = { level ->
|
||||
sendEvent(ON_OUTPUT_VOLUME_LEVEL_EVENT, bundleOf("data" to level))
|
||||
}
|
||||
onAudioInterruptionCallback = { data ->
|
||||
sendEvent(ON_AUDIO_INTERRUPTION_EVENT, bundleOf("data" to data))
|
||||
sendEvent(ON_RECORDING_CHANGE_EVENT, bundleOf(
|
||||
"data" to (audioEngine?.isRecording ?: false)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package expo.modules.twowayaudio
|
||||
|
||||
import android.content.Context
|
||||
import expo.modules.core.interfaces.Package
|
||||
import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
||||
|
||||
class ExpoTwoWayAudioPackage : Package {
|
||||
override fun createReactActivityLifecycleListeners(activityContext: Context): List<ReactActivityLifecycleListener> {
|
||||
return listOf(ExpoTwoWayAudioLifeCycleListener())
|
||||
}
|
||||
}
|
||||
28
packages/expo-two-way-audio/biome.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.5.3/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"files": {
|
||||
"ignore": [
|
||||
"./build",
|
||||
"./node_modules/",
|
||||
".expo",
|
||||
"ios",
|
||||
"android",
|
||||
"expo-env.d.ts",
|
||||
"./examples/*/node_modules",
|
||||
"./examples/*/.expo",
|
||||
"./examples/*/ios",
|
||||
"./examples/*/android"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"lineWidth": 100
|
||||
},
|
||||
"linter": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
35
packages/expo-two-way-audio/examples/basic-usage/.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
|
||||
# Native
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
676
packages/expo-two-way-audio/examples/basic-usage/App.tsx
Normal file
@@ -0,0 +1,676 @@
|
||||
import { Buffer } from "buffer";
|
||||
import {
|
||||
MicrophoneDataCallback,
|
||||
VolumeLevelCallback,
|
||||
getMicrophoneModeIOS,
|
||||
initialize,
|
||||
playPCMData,
|
||||
setMicrophoneModeIOS,
|
||||
toggleRecording,
|
||||
useExpoTwoWayAudioEventListener,
|
||||
useIsRecording,
|
||||
useMicrophonePermissions,
|
||||
} from "@speechmatics/expo-two-way-audio";
|
||||
import { Button, StyleSheet, Text, View } from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export default function App() {
|
||||
const [micPermission, requestMicPermission] = useMicrophonePermissions();
|
||||
console.log(micPermission);
|
||||
|
||||
if (!micPermission?.granted) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Mic permission: {micPermission?.status}</Text>
|
||||
<Button
|
||||
title={micPermission?.canAskAgain ? "Request permission" : "Cannot request permissions"}
|
||||
disabled={!micPermission?.canAskAgain}
|
||||
onPress={requestMicPermission}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <Testbed />;
|
||||
}
|
||||
|
||||
export function Testbed() {
|
||||
const [audioInitialized, setAudioInitialized] = useState(false);
|
||||
const [inputVolumeLevel, setInputVolumeLevel] = useState(0.0);
|
||||
const [outputVolumeLevel, setOutputVolumeLevel] = useState(0.0);
|
||||
const micMode = Platform.OS === "ios" ? getMicrophoneModeIOS() : "NO_MIC_MODE_IN_ANDROID";
|
||||
|
||||
const playAudio = () => {
|
||||
for (const dataChunk of audioData) {
|
||||
for (const audioChunk of dataChunk.audio) {
|
||||
const buffer = Buffer.from(audioChunk, "base64");
|
||||
const pcmData = new Uint8Array(buffer);
|
||||
playPCMData(pcmData);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isRecording = useIsRecording();
|
||||
|
||||
useExpoTwoWayAudioEventListener(
|
||||
"onMicrophoneData",
|
||||
useCallback<MicrophoneDataCallback>((event) => {
|
||||
console.log(`MIC DATA: ${event.data}`);
|
||||
}, []),
|
||||
);
|
||||
|
||||
useExpoTwoWayAudioEventListener(
|
||||
"onInputVolumeLevelData",
|
||||
useCallback<VolumeLevelCallback>((event) => {
|
||||
setInputVolumeLevel(event.data);
|
||||
}, []),
|
||||
);
|
||||
|
||||
useExpoTwoWayAudioEventListener(
|
||||
"onOutputVolumeLevelData",
|
||||
useCallback<VolumeLevelCallback>((event) => {
|
||||
setOutputVolumeLevel(event.data);
|
||||
}, []),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
initialize().then(() => setAudioInitialized(true));
|
||||
}, []);
|
||||
|
||||
const handleToggleMute = useCallback(() => {
|
||||
toggleRecording(!isRecording);
|
||||
}, [isRecording]);
|
||||
|
||||
const handleMicToggle = useCallback(() => {
|
||||
if (Platform.OS === "ios") {
|
||||
setMicrophoneModeIOS();
|
||||
} else {
|
||||
console.log("No mic mode in android");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.buttonContainer}>
|
||||
<Button title="Play audio" onPress={playAudio} />
|
||||
<Button title={isRecording ? "Mute" : "Unmute"} onPress={handleToggleMute} />
|
||||
{isRecording && <Button title="mic mode" onPress={handleMicToggle} />}
|
||||
</View>
|
||||
<View style={styles.volumeContainer}>
|
||||
<Text>{`input:${inputVolumeLevel}`}</Text>
|
||||
<Text>{`output:${outputVolumeLevel}`}</Text>
|
||||
<Text>{isRecording ? micMode : "ready"}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const audioData = [
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"5uYt5V3jGONn41PkhuXk5cHmReb05Bvl5uRT5Irkoef66/fsOe3F7prvAO947qfwjPJH8iDzR/Xm9mz2p/Uw9dbzUfTA9kn5vfrO+2D9rP3G/ZD/5AA3ACsAQAINBM0D2AMiBIsD/AE1AQADHwMuBQgJvAnmCesJqA2eEUsPpQ43EjgWmReDFbYWAhoGG+EYABawFcEW3RmGGwwb4xuWHOMc3BzKHEwe4h+PIE8h1CCBH8oiliTFHOQTUxEoFT4W7hK0FFsWWxE8CxQKaQolB6YE7gV/CLALOA9FEvQQ2wzXC7oJLQY/Br8IZwrrCTEJXQr6CagGagRnAgcApf4h/g==",
|
||||
"GgCCAhECVABS/mD9Cf3s+6f7bPvO+r76Gfzg/Tr+p/1v+2n4nfZy9k/37Pa39Sv1gvSm8zLzjfIQ8B3tL+s+6mrqrekR6eTo7+fe52fnNudH6LLn6OX+5LPlB+cZ51PnIuih51LmH+Yg59LmguUH5T7lTeZf5yboj+gZ6GnnouaD5v7n9Ome6pHrYO6O8ALxd/A38Gvw1+978DHz+/U7+L/5kfo/+jD5hvgm+Iv4Xfno+l39CACJAkwDAAIiAML/NgCCAGgCdwUECKYI3geVCC4JjgjiCAEJIQpzDE0O6A/XD58QkBGvEKIRWBRfFrYWxBarF88XHBbBFTEXeBjDGw==",
|
||||
"Nh8OIVkhtCCPIHEd/BpSHFkdnB7CHjkioSaOJAgi8xqFE+sSsxOWFr8VKhaGGtYWvhH5D6QOwgqnBVoJfw7LD34S1hS7E/kMQwmsCYQFiwJTBSkJhwk7CD8JiQixA13/1/yR+kr6Dv0n/07/9wCmApb/LPqH95f2ZPRL8zv2UPlN+rX7F/zM+IzzbvHM8ZvwAvCV8XjzZvQf9dr0jPFJ7XbqNulB6FXo9+r17PvsaOwh61fp3ufB5ivlj+Tx5m/qg+uJ6oDqYerw58vlJuYU5xvntuet6e3q3uro6g7rKerM6K/ovelB69rsDe7G7invPe/p7m/ube5x7qDuRPCT8g==",
|
||||
"O/Q79RX2u/Zj9lP24/du+QH6BPs0/ST/3v9fAIAA7f+K/6YAvQJ/A4sErgbuB6wHvgZNB80H5wZHBxQJvgr1C9YMIw6XDh4Opw5wD/8QRBL8EkkTghLLEw8VKhWgFVUWDBniGa0WKhUFFhkXqBdxF1wZmhtfGwkb0BrLGckYSRhrGDEYFRiiGeEbpB0kHAUZKhecFNoTPBSuFMIVeBQpFEYUlRFkD9sNzAyHC0gKrQvXDNwMTwzBCpMJlAdSBrEF9QP0A2cELAQrBHMDzgJlAej+lv1i/LH7hfze/OT8ZPzb+4v7gPlT9/f1s/T0883zffTk9Nv0q/Rn8+/xp/Aa8A==",
|
||||
"+O/77rruYu+T77bvbe+T7jvtnOvQ6mHqLepd6mDrrOzy7P7sCe1Y7S/tU+sy6c3oT+qU6wXskuzr7JnsnOtH61Drberb6WzqJuwR7oDvofBD8CTvRe6Q7bntXO5D7y3w+fAh8vLyZPNh8+XyivLH8pb0p/YA+B75rPnc+SX5S/iJ+B/5svmI+uz7dP2w/oL/dv89/4f/KgDvAJcBVAPUBTIHiAfrB0sIBghRCEIJCwrqCuULcg25DggPmA+YDzIOdQ0gDpUPchAKEc8RnhHmEUcS6BHtEO0PhBB8EW4SfBOdEzETHRM+E3oStRHgEWkSBRKrEVETYBRwFJoT+RKTEw==",
|
||||
"QRLAETMSnBHmERoSXRM6FFETSBOLES4PoA0UDUEOHQ3dDF0NbQyxDM4LgApeCO8FbwbWBsIGZgedB5QHIAadBOUDQQJZAcAAlQBIAe4AUABo/y3+WP0Y/B377vpl+kX6w/o/+1v7Evq++Ij3FvZW9cb0xPSk9Dj0XvRl9Gn06PO58u/xavFQ8Urx/fBC8anxpvFH8aDwJfCL79Puau6K7l7vLvCX8IrwIPAA8CDwMvAK8Nrv7u8w8FTwffC68IjwI/D9707wr/D88IDxvvEj8uTylvMt9Fr0efTI9OX0HvVw9Zr1rPWR9ef10vbq92z4JPgC+A/4QPii+DD58PmB+g==",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"wP7V/ur++v4T/xb/Ef8U/xX/Dv8G/wn//f7y/gD///4F/xj/Gv8c/yH/I/8m/0T/WP9S/2X/aP9e/2H/aP9v/3L/eP99/4b/jv+j/7f/vf/b/+X/6/8IAAwAEQAfACQAKQA3AEwATwBZAHYAfwB5AHIAdgCBAHwAewCHAJkAqACjAJkAlwCiAKEAjgCYAJ0AnQCxALsAwADDAMoA1wDVANQA3ADlAO8A/AAIAQYB/QD3APwAAQH9APsA+QD5APEA5wDZAL4ArQCrALMAswCsAKcAmgCbAJ8AlACbAJ4AnwCrAKsApgCjAKYApACgAJ4AmQCYAIsAkgCUAIMAjQCLAA==",
|
||||
"ewBwAHEAdQBnAF4ATgA8AD4ALgAgAB4ADwAQAAcA/P8BAPr/9f/w/+T/4v/i/+T/7v/t/93/2//h/9v/z//M/8z/xP+8/7H/qP+q/6H/mf+X/4v/ef9v/3b/Zv9b/2P/WP9T/0z/QP9F/z//Q/9O/1D/XP9V/03/VP9R/1r/Vv9G/03/V/9Z/17/ZP9j/2X/b/9z/23/a/9z/3z/gP+D/3//dP91/27/Zv90/3n/d/94/3n/gP+M/5L/mv+l/6n/pf+l/6v/pf+j/6z/tf++/7//xP/M/8H/vf/D/7z/q/+c/6//yP/M/9P/2//l//H/6//m//b/CAAGAAQAGwArAA==",
|
||||
"MAA2ACwAJQAgABwAKwAwADIAPwBRAFEAPgBEAEIAOgA9ADoARwBTAF8AZgBnAGoAbABvAGsAcwCCAIUAkACQAI0AkgCPAIcAeAByAIIAiwCGAHYAbwB4AHIAcwCJAIQAeQCJAIcAfACAAH0AgQCDAH4AjACNAIkAlgCEAHcAgwCAAHwAfACEAIkAdwB3AHoAagBjAGwAaQBcAF8AZgBlAFgAVgBVAD0AQABGADoAOgA6ADEAMgAxACkAJgAbABQAEwAKAA0ADQAQABcABAD6//n/8//1/+7/6//p/+r/9v/v/+P/5v/g/9//2v/V/+H/3P/e/+f/2v/a/9r/z//H/w==",
|
||||
"v//C/7b/p/+u/63/qP+n/6H/lv+U/5n/kv+Q/5D/hv97/3b/ff9+/3T/cP9x/2b/Yf9q/2n/bP9z/3P/cP9n/2X/af9j/2D/Zv9r/2//bv97/4n/gf97/3X/cv94/3D/bf94/4P/hf95/3X/gP+C/3X/df98/3r/hP+J/43/l/+O/5T/n/+d/6P/pv+w/8P/0P/a/9z/0v/W/+D/4f/t/+v/7f/4//L/8//u/+D/3f/l/+7/8P/6//n/8f/9//j/9v/+//n/AwAHAAcACgAQABkAHAAlACwANQAyADcAUgBMAE8AZwBiAFoAYQBsAHIAeQB+AHsAfwB+AHoAggCAAA==",
|
||||
"ggCKAIYAiwCVAJkAlwCWAJoAmwCbAKQAswCzALEAvQDBALwAvQDEAMEAvwC9ALMAtwC3ALIAuAC2ALAArACtAK8ApwCnAKYAogCeAJgAmgCUAIsAjwCOAI0AiQCBAIEAdwB4AHwAbABlAGQAYwBhAF0AWgBaAFsAWgBaAE8ASwBNAEYARABFADkAMwAyAB8AHQAjABkAEAAJAAIABQAGAPz/AAADAPj/7f/p/+X/2v/V/9L/0f/K/8T/xP+4/6//rf+l/53/jv+G/5L/k/+O/4//j/+O/4n/ff95/4L/e/9p/2//dP9z/3n/ef9z/3H/a/9q/2//aP9o/3X/dv9u/w==",
|
||||
"cv95/3T/cP9v/27/cP9y/3L/d/95/3n/ff93/3//iv+I/4j/iv+I/4r/k/+R/47/kf+Q/5v/n/+V/5T/lv+c/5b/l/+m/6P/rP+y/6z/tv+5/7X/vf/B/8X/0f/T/9j/1//V/9z/3//l/+P/5P/q/+b/7f/4//r/9/////r/8/8FAP//9/8HAAAA+P/7////AQAFAAoABQAEAAcABgANAAoACQATABUAGgAfACwAKgAhACsAKQArADEAMAA2ADQAMQA0ADEANABDAEcARgBJAEsASABEAEUARwBBAD4ASQBLAEEAPABEAEMAQABJAEYARQBJAEkATABNAE4ASQBHAA==",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"SABEAEkASgBJAEwASABEAEkATQBJAE0AVABNAFAAUwBIAE0ASwBEAEkARABGAEoAPgA9AEMAQgA8ADIANgA8ADgAOgA7ADwAPQA3ADYAOAAzADEALgArACoAKgAnACQAIwAdABUAFQAYABYAGAAbABMADgAKAAMABAD///v//v////v/+v8AAPv/9v/z/+f/7P/u/+r/6P/l/+b/5f/j/+L/5P/e/9b/2//W/9T/2P/R/9P/0P/M/8//x//H/8j/vP+6/7j/uf++/7r/u/+5/7X/sv+x/7L/r/+3/7T/p/+p/6r/rP+v/7D/r/+w/7n/uP+4/7n/tf+2/7T/uv+6/w==",
|
||||
"s/+9/73/tf+6/77/vP+7/7v/tP+w/7P/sv+x/7f/v/+8/7b/vP++/7T/t//E/8j/xP/C/8P/wf/B/8b/yv/Q/9X/0f/S/9f/1P/W/9j/zv/S/+D/3//k/+f/5P/v//P/9P/8//r/+v8CAAYABwALAAwADQAPAAsAFQAeAB0AIQAaABsAKAAkACcALAApACsALQAsACsAMAAwADEANgA2ADoAPAA4ADsAOgA2ADkAPQBBAEUASwBOAEwAVQBZAFUAVgBaAGEAXABaAGAAXABkAGUAXwBhAF4AXwBjAGQAYgBfAGAAYQBZAFoAXABcAFwAWABaAFkAVABUAFIAUABRAA==",
|
||||
"UQBPAFIAUQBOAFAATQBIAEoARwBDAEAAQABHAEEAPAA6ADYANwA0ADMALwAtACsAIwAjACMAIQAbABcAHAATAA0AFgATAA8AEAAHAAMABgAHAP//AAABAPf//v/8//H/8v/v/+3/6f/c/+L/5v/W/9z/4P/Z/9f/zP/O/8//yv/K/8H/wP/A/7v/xP/D/7n/uf+y/6//uP+x/6z/tv+y/6b/qv+s/6n/qv+u/7X/s/+v/7D/rP+y/7f/tP+3/7r/u//C/8P/wP/C/8T/yP/H/8X/x//E/8b/zf/L/8r/0f/R/9D/0f/U/9z/4P/Z/9j/2//Y/+D/4P/a/9//3f/c/w==",
|
||||
"4//h/+P/6f/u/+//7P/s/+3/8P/x//H/+v/2//T/AQAAAAIABgAFAAcABgAFAAkADwARAA8ADgARABkAFwAaACAAFwAbACAAHAAgAB8AJQAmACQAJwAlACgAKwAsACoAKQAyACwAKgAzADEANAAyAC4AMAAsACwAKwApACUAJQApACYAIgAkACYAJQAjACQAIAAfACEAIAAeABoAGAAaABsAGQAZABgAEAAQABcAFwATABcAFgAMAA0AEAALAA0ADQAKAA0ACwAFAAIA/v8CAAMA//8CAAMABAADAPz/+P/9//3/+P/2//T/9//5//n/9v/z//P/8f/x//D/7//y/w==",
|
||||
"8f/v//L/9P/r/+z/7f/n/+n/5//q/+v/5f/m/+f/6v/p/+L/5v/m/+P/6//n/+b/6v/o/+z/7v/r/+3/8P/x/+//7f/t//H/7v/q//H/9P/s/+7/8P/u//L/9f/1//H/8f/x//P/+P/y//H/9//2//X/9P/z//f/9//5//r/+f/4//j//f/8//f/+P////3///8GAP//BwAJAP3/AwADAAEAAAD9/wEAAQD+/wEAAwAAAAEAAwAEAAQAAQADAAIA/v8CAAcACgAGAAMABwAHAAQABwAHAAkADgALAAsADQALAA0AEgASABAADQAOABIADgAPABMAEQATABIADwATAA==",
|
||||
"EgAPAA8AEgATABEAEwATAA4ADwATABYAEwAOAA0ADgATABEADwAUAA8ACwAMAAwADQAOAAsADwAPAAgACQAJAAMABQAJAAcABgAIAAkACQAEAAYACAACAAEAAgD9//7/AgD///z/+v/5//z/+P/0//j/9f/2//f/8//1//L/7//v//L/8P/o/+z/7//r/+n/6//t/+n/6P/q/+j/6P/p/+b/5//q/+f/4v/n/+z/5//n/+n/5//k/+L/6f/p/+L/6P/m/+T/6f/m/+j/6f/h/+T/6P/o/+3/7f/q/+z/7v/s//D/9f/v/+//8//x//L/8P/z//T/8//4//n/9//4/w==",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"9v/1//b/7v/w//f/8//0//L/8//3//H/8v/1//H/8//z//H/9P/0//P/9P/x//D/8//y//L/9f/w//L/9//1//P/9v/4//r//P/7//v//f///wIAAwACAAUABgADAAcABwAGAAwACwALAAoACgAOAA0ADwAPAA0ADwAOAA8AEQASABMAEgAVABMAEAAUABgAGQAWABYAGQAZABsAHAAcAB0AHAAiACAAHAAgACAAHgAhACMAJQAlACQAJwAkACEAIQAgACYAJQAjACYAJgAnACgAJQAkACcAKAAmACQAIwAkACYAJQAmACUAIAAgAB8AIwAiAB0AIAAfABsAGwAYAA==",
|
||||
"GAAbABkAGAAbABUAFAATABIAEAAQABMADAANABEACwANAAwABgAIAAYABAAEAPz/AAAGAPn/+/8BAPn/+v/7//f/+P/3//b/8//y//P/8v/z/+7/7v/s/+b/6P/l/+L/5f/j/+P/4P/d/9z/2P/W/9b/1v/W/9L/0v/T/9L/0f/O/8//zv/J/8v/yf/J/8//x//F/83/xP/F/8z/wv/F/8b/wv/I/8X/xP/I/8T/xv/C/8L/xP/B/8T/xf/H/8P/wP/I/8b/xv/G/8f/zP/E/8T/yP/G/8X/w//J/87/yf/K/87/0P/Q/9L/1P/S/9T/2P/Y/9r/2//c/+H/4//j/w==",
|
||||
"5P/k/+T/6P/r/+z/7//w/+//8f/2//j//P/9//7/AwABAAMABwAEAAoAEgAOABMAFwAXAB4AHAAbACIAIwAkACgAKQAqACwALgAxADIAMgAxADYAOgA5ADoAPgA+ADwAPAA8AEEARgA+AD0AQAA8AD4AQQA/AD8APwA/AEAAPAA7ADwANgA2ADwANwAyADUANQA1ADMAMAAzADAALAAvAC0AKgArACkAKwArACcAKgAnACMAKAAlACMAIgAdAB4AIQAeABsAHAAaABQAEwAVABQAEAAOAA4ADAAIAAQAAwABAPz//f/8//v/+v/2//r/9f/s//P/8v/r/+n/6f/q/w==",
|
||||
"6f/n/+P/4P/j/+H/4f/f/9r/3f/a/9v/4P/e/9z/3v/c/9z/3//d/9z/4P/f/97/4P/e/9//5//n/+P/5P/l/+f/6//x//H/8v/z//T/+v/4//b/9//1//b/9v/3//j/9//6//v/+//+//////8AAP///v8EAAMAAwAFAAEABAAFAAQABAADAAQABAAIAAkABAAGAAgABwAJAAgABQAEAAMABQAFAAEA/f/+/wEAAAD//wEAAwAAAAAAAgD8//7/AAD6//r/+v/8//r/9//6//f/8//2//T/9P/2//L/8P/z//D/8P/x/+//8v/v/+//8v/w/+//7f/v//L/8P/z/w==",
|
||||
"9P/z//X/9f/z//P/9f/2//T/9P/1//T/9v/1//X/9v/1//X/9f/3//j/+v/7//j/9//5//z//v/9//7//v/9////AQACAAQABgAFAAQAAwADAAMABQAGAAUABQAHAAkADgALAAgADAAOAA8ADwAOAA4ADAAPABAADAAMAAwADgAOAA4AEQASAA8ACgAMAAkACwAaAA0A/f8CAAIAAAAGAAUABAAIAAYABQAFAAIAAgD/////AAD+////AgAEAAIAAAD///7//v/+//z//P/8//3//f/9//z//f/+//7//f/8//7////9//3//P/9//7//v/+///////+/////////w==",
|
||||
"//8AAAEAAAAAAAAA//8AAAEAAAAAAAAAAAABAAEAAAAAAAAAAAAAAAAAAQAAAAAAAQAAAAAAAAAAAAAAAAAAAAAA//////////8AAP//////////AAAAAP//AAAAAAEAAAAAAAAAAAAAAAAAAAABAAEAAAABAAAAAAABAAAAAQABAAAAAQAAAAAAAAAAAAAAAAABAAEAAQAAAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAAAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAA==",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"AAABAAEAAQABAAEAAQABAAEAAwACAAIAAwACAAIAAgABAAIAAQABAAIABQAGAAQAAwAEAAQABAAEAAMAAwADAAMAAwADAAMAAgACAAIAAgACAAEAAQACAAEAAQACAAIAAQABAAEAAgABAAIAAgABAAIAAQABAAIAAgABAAIAAgACAAIAAQABAAIAAgACAAEAAQACAAIAAgACAAIAAgACAAEAAQACAAIAAQABAAIAAgACAAIAAgACAAIAAQABAAEAAQACAAIAAQABAAEAAgACAAEAAQACAAIAAQABAAIAAQACAAIAAQABAAIAAgACAAIAAgABAAEAAgACAAIAAgABAA==",
|
||||
"AQABAAIAAQABAAEAAgABAAIAAQABAAEAAQACAAIAAQABAAEAAgABAAEAAgACAAIAAgACAAEAAQABAAIAAgACAAEAAQABAAIAAgACAAEAAgACAAEAAQACAAEAAQABAAEAAQABAAEAAQACAAEAAQABAAEAAQABAAEAAQABAAEAAgACAAEAAQABAAIAAgABAAIAAQABAAIAAgABAAIAAgABAAEAAgABAAIAAgACAAIAAgACAAIAAgABAAEAAgACAAEAAQACAAIAAgABAAIAAgABAAEAAQABAAIAAQABAAEAAgABAAEAAgACAAIAAgABAAEAAgABAAEAAgABAAEAAgABAA==",
|
||||
"AQACAAIAAQABAAIAAQABAAIAAQACAAIAAQACAAIAAgABAAEAAgABAAEAAQABAAIAAgABAAIAAQABAAEAAQABAAEAAQABAAEAAQACAAEAAQABAAIAAgABAAEAAQABAAEAAQACAAEAAQABAAEAAQABAAEAAQABAAEAAAAAAAEAAQABAAEAAQABAAEAAQABAAEAAgACAAEAAgACAAEAAgACAAIAAgABAAEAAQACAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAAAAAAAAAAAAAAAQAAAAEAAQABAAEA/////wAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"BAAGAAUAAAACAP7//P8AAAIABwAJAAoAAwD7/////v/6//n/+v8AAPz/+/8GAAMA9f/s//H///8BAP7/AwAFAAIA///5//H/9P/6//f/+P/9/wQABQD5//f/+v/1//P/7//5/wcAAQD+/wIA/v/4//f/9v/x//P/AwAIAAEA/f8BAAUA/P/7//3/AQAJAAYABgAOABUAHgASAAEA+//4////CQAUABYACwAHAAYACwAPAAwACQD///v/DgAfACIAEgAEAAUA/v/0//j/CAASABQAGAAVAAkA+f/5/wQAAQAAABEAIAAeACAAJQAWAPz/6f/u//v/AgASABkAFwAWAA==",
|
||||
"BgD3/+7/6f/y//r/AAAPABgADwAEAAIA/f/y/+v/7f/y//f/CAASAAYA9f/1//P/5//l/wAAHQAGAAQAIQAKAOn/2//n/+7/3f/0/w0ABAACAAIA9//X/8z/5P/7/wMA+f8GABEA//8GAP7/6P/o/9z/2v/n/wEAGQD9/+P/5//g/93/4v/u/wQAEAAOAPf/6v/4//z/5P/E/9f/DgAbAA0ACAAfABYA6P/i/+H/6v8GAAQACgAZADAALADi/8D/7v8RAP//5//3/xEAIQAgABYA///k/wYAEADd/+7/IAAjAAEA9v8hABoA8P8AACAAFAAFAC4ANwD5/+P/EgAqAA==",
|
||||
"/P/1/ysAEwDP/8j/7/8PAAkAEgAJAOb/BgA1AEQAHADg/9j/6f8FABkAIwAxAB4A4v+y/8//EgAZAAIAEwAzACAA/v/0/+L/zP/a/xMAKQAwAFcAOgDT/5H/lP+a/3X/iP8MAHcAbQBBACoACwDa/6n/of/T/xcAQABIADsARwBMANv/Vv9L/6//FgATABoAUAAzAPv/7f/3/+D/tP/w/y0AGAAkAEQAJAC2/4z/s//V/+z/CAA7AB8AFQBhAEYA8f/T/x8ARADy/+z/JgA2AOT/wf/w/+H/+P8FAOv/7f8OAFwAQwDv//X/AgD0/yMAdQBbAPz/2v///+b/mP/C/w==",
|
||||
"BQAFAPf/HQB5AIgAVADY/17/mv8NAB8A7f8GAF0AKADn/yEAKADQ/4X/q//l/y0AuwCmAPT/g/+u/8n/bP+T/woAAwC9/8f/LgAwAPD/7f/S/8n/AgAdAPj/8f9iAMwAYQDO/9H/r/9k/4n/8P84ADkAKQAnABMAJgBeACcAu//M/xkASABZADUA5P++/7//nv+J/5L/0//v/8z/AABnAKAAhgA+AN7/3P9gAJkAUAD7/w0ANgDe/77/UAB3AAMA4v8JABgALwCBAHwAxf9t/wAAVwAmAF0AmAAMALn/JwBXAPb/uP8lAD8A5v9qAPIAeQDx//3/BQBy/1H/LwCQAA==",
|
||||
"OAAoAJEAkAAzACkA7v+//9z/WQBuAPT/mAAGAUUAbf9s/3cAbQCa/6L/HQCcAHkAFQAsAFkAbQAOAKP/t//U/w8At/9H/2r/j/8eAO7/Zv+3/8n/2P+j/y3/bf+r/7//qf9Y/6D/EgAMAJT/Lv9y/5P/cf8x/zD/3//7/9r/uf+K/9j/uP+T/5z/sf8aAKT/HP9Y/77/7f9v/37/4f+H/2D/qP8HABcA0f+5/5z/nf8UAGgAQgDx//z/6f9g/53/XQCWABAAav/d/3EAbwCbAHsAWQBwAIIAfQAFADUAoQA4APP/CgBnAJEAcQCWAEQAEQDTAFwBCgGDAMgA/wCgAA==",
|
||||
"uAAvAXABxwBjALUAcABDAGsAowCMAKQAVAE1AeoALAFSAcgA+P9+AA4BjQCOAB8BbAElAfMApACZ/0j/DwCDABAADgBNAfABVAEAAQUBWQA1/6r+HP+n/yUAwQBrAH//+v7z/nf+iP2h/Vz+2f48/6z/wf9N/9r+Qf4k/cL8vf1q/tH9P/0t/h//av5B/bT8ZvxW/MD8dP0Q/iL+/v2F/Yr8mfwr/bL8FPxK/Jv9TP7F/c79zv1k/TD9wf3H/v/+Nf9i/wb/wP4G/53/Ef9n/i//wQDNAcIBSQF9AKv/Zf9q/6D/jgBXApoDBAMzAmYCWQIuAdL/OADtAXgDrgRzBQ==",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"wP7V/ur++v4T/xb/Ef8U/xX/Dv8G/wn//f7y/gD///4F/xj/Gv8c/yH/I/8m/0T/WP9S/2X/aP9e/2H/aP9v/3L/eP99/4b/jv+j/7f/vf/b/+X/6/8IAAwAEQAfACQAKQA3AEwATwBZAHYAfwB5AHIAdgCBAHwAewCHAJkAqACjAJkAlwCiAKEAjgCYAJ0AnQCxALsAwADDAMoA1wDVANQA3ADlAO8A/AAIAQYB/QD3APwAAQH9APsA+QD5APEA5wDZAL4ArQCrALMAswCsAKcAmgCbAJ8AlACbAJ4AnwCrAKsApgCjAKYApACgAJ4AmQCYAIsAkgCUAIMAjQCLAA==",
|
||||
"ewBwAHEAdQBnAF4ATgA8AD4ALgAgAB4ADwAQAAcA/P8BAPr/9f/w/+T/4v/i/+T/7v/t/93/2//h/9v/z//M/8z/xP+8/7H/qP+q/6H/mf+X/4v/ef9v/3b/Zv9b/2P/WP9T/0z/QP9F/z//Q/9O/1D/XP9V/03/VP9R/1r/Vv9G/03/V/9Z/17/ZP9j/2X/b/9z/23/a/9z/3z/gP+D/3//dP91/27/Zv90/3n/d/94/3n/gP+M/5L/mv+l/6n/pf+l/6v/pf+j/6z/tf++/7//xP/M/8H/vf/D/7z/q/+c/6//yP/M/9P/2//l//H/6//m//b/CAAGAAQAGwArAA==",
|
||||
"MAA2ACwAJQAgABwAKwAwADIAPwBRAFEAPgBEAEIAOgA9ADoARwBTAF8AZgBnAGoAbABvAGsAcwCCAIUAkACQAI0AkgCPAIcAeAByAIIAiwCGAHYAbwB4AHIAcwCJAIQAeQCJAIcAfACAAH0AgQCDAH4AjACNAIkAlgCEAHcAgwCAAHwAfACEAIkAdwB3AHoAagBjAGwAaQBcAF8AZgBlAFgAVgBVAD0AQABGADoAOgA6ADEAMgAxACkAJgAbABQAEwAKAA0ADQAQABcABAD6//n/8//1/+7/6//p/+r/9v/v/+P/5v/g/9//2v/V/+H/3P/e/+f/2v/a/9r/z//H/w==",
|
||||
"v//C/7b/p/+u/63/qP+n/6H/lv+U/5n/kv+Q/5D/hv97/3b/ff9+/3T/cP9x/2b/Yf9q/2n/bP9z/3P/cP9n/2X/af9j/2D/Zv9r/2//bv97/4n/gf97/3X/cv94/3D/bf94/4P/hf95/3X/gP+C/3X/df98/3r/hP+J/43/l/+O/5T/n/+d/6P/pv+w/8P/0P/a/9z/0v/W/+D/4f/t/+v/7f/4//L/8//u/+D/3f/l/+7/8P/6//n/8f/9//j/9v/+//n/AwAHAAcACgAQABkAHAAlACwANQAyADcAUgBMAE8AZwBiAFoAYQBsAHIAeQB+AHsAfwB+AHoAggCAAA==",
|
||||
"ggCKAIYAiwCVAJkAlwCWAJoAmwCbAKQAswCzALEAvQDBALwAvQDEAMEAvwC9ALMAtwC3ALIAuAC2ALAArACtAK8ApwCnAKYAogCeAJgAmgCUAIsAjwCOAI0AiQCBAIEAdwB4AHwAbABlAGQAYwBhAF0AWgBaAFsAWgBaAE8ASwBNAEYARABFADkAMwAyAB8AHQAjABkAEAAJAAIABQAGAPz/AAADAPj/7f/p/+X/2v/V/9L/0f/K/8T/xP+4/6//rf+l/53/jv+G/5L/k/+O/4//j/+O/4n/ff95/4L/e/9p/2//dP9z/3n/ef9z/3H/a/9q/2//aP9o/3X/dv9u/w==",
|
||||
"cv95/3T/cP9v/27/cP9y/3L/d/95/3n/ff93/3//iv+I/4j/iv+I/4r/k/+R/47/kf+Q/5v/n/+V/5T/lv+c/5b/l/+m/6P/rP+y/6z/tv+5/7X/vf/B/8X/0f/T/9j/1//V/9z/3//l/+P/5P/q/+b/7f/4//r/9/////r/8/8FAP//9/8HAAAA+P/7////AQAFAAoABQAEAAcABgANAAoACQATABUAGgAfACwAKgAhACsAKQArADEAMAA2ADQAMQA0ADEANABDAEcARgBJAEsASABEAEUARwBBAD4ASQBLAEEAPABEAEMAQABJAEYARQBJAEkATABNAE4ASQBHAA==",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"SABEAEkASgBJAEwASABEAEkATQBJAE0AVABNAFAAUwBIAE0ASwBEAEkARABGAEoAPgA9AEMAQgA8ADIANgA8ADgAOgA7ADwAPQA3ADYAOAAzADEALgArACoAKgAnACQAIwAdABUAFQAYABYAGAAbABMADgAKAAMABAD///v//v////v/+v8AAPv/9v/z/+f/7P/u/+r/6P/l/+b/5f/j/+L/5P/e/9b/2//W/9T/2P/R/9P/0P/M/8//x//H/8j/vP+6/7j/uf++/7r/u/+5/7X/sv+x/7L/r/+3/7T/p/+p/6r/rP+v/7D/r/+w/7n/uP+4/7n/tf+2/7T/uv+6/w==",
|
||||
"s/+9/73/tf+6/77/vP+7/7v/tP+w/7P/sv+x/7f/v/+8/7b/vP++/7T/t//E/8j/xP/C/8P/wf/B/8b/yv/Q/9X/0f/S/9f/1P/W/9j/zv/S/+D/3//k/+f/5P/v//P/9P/8//r/+v8CAAYABwALAAwADQAPAAsAFQAeAB0AIQAaABsAKAAkACcALAApACsALQAsACsAMAAwADEANgA2ADoAPAA4ADsAOgA2ADkAPQBBAEUASwBOAEwAVQBZAFUAVgBaAGEAXABaAGAAXABkAGUAXwBhAF4AXwBjAGQAYgBfAGAAYQBZAFoAXABcAFwAWABaAFkAVABUAFIAUABRAA==",
|
||||
"UQBPAFIAUQBOAFAATQBIAEoARwBDAEAAQABHAEEAPAA6ADYANwA0ADMALwAtACsAIwAjACMAIQAbABcAHAATAA0AFgATAA8AEAAHAAMABgAHAP//AAABAPf//v/8//H/8v/v/+3/6f/c/+L/5v/W/9z/4P/Z/9f/zP/O/8//yv/K/8H/wP/A/7v/xP/D/7n/uf+y/6//uP+x/6z/tv+y/6b/qv+s/6n/qv+u/7X/s/+v/7D/rP+y/7f/tP+3/7r/u//C/8P/wP/C/8T/yP/H/8X/x//E/8b/zf/L/8r/0f/R/9D/0f/U/9z/4P/Z/9j/2//Y/+D/4P/a/9//3f/c/w==",
|
||||
"4//h/+P/6f/u/+//7P/s/+3/8P/x//H/+v/2//T/AQAAAAIABgAFAAcABgAFAAkADwARAA8ADgARABkAFwAaACAAFwAbACAAHAAgAB8AJQAmACQAJwAlACgAKwAsACoAKQAyACwAKgAzADEANAAyAC4AMAAsACwAKwApACUAJQApACYAIgAkACYAJQAjACQAIAAfACEAIAAeABoAGAAaABsAGQAZABgAEAAQABcAFwATABcAFgAMAA0AEAALAA0ADQAKAA0ACwAFAAIA/v8CAAMA//8CAAMABAADAPz/+P/9//3/+P/2//T/9//5//n/9v/z//P/8f/x//D/7//y/w==",
|
||||
"8f/v//L/9P/r/+z/7f/n/+n/5//q/+v/5f/m/+f/6v/p/+L/5v/m/+P/6//n/+b/6v/o/+z/7v/r/+3/8P/x/+//7f/t//H/7v/q//H/9P/s/+7/8P/u//L/9f/1//H/8f/x//P/+P/y//H/9//2//X/9P/z//f/9//5//r/+f/4//j//f/8//f/+P////3///8GAP//BwAJAP3/AwADAAEAAAD9/wEAAQD+/wEAAwAAAAEAAwAEAAQAAQADAAIA/v8CAAcACgAGAAMABwAHAAQABwAHAAkADgALAAsADQALAA0AEgASABAADQAOABIADgAPABMAEQATABIADwATAA==",
|
||||
"EgAPAA8AEgATABEAEwATAA4ADwATABYAEwAOAA0ADgATABEADwAUAA8ACwAMAAwADQAOAAsADwAPAAgACQAJAAMABQAJAAcABgAIAAkACQAEAAYACAACAAEAAgD9//7/AgD///z/+v/5//z/+P/0//j/9f/2//f/8//1//L/7//v//L/8P/o/+z/7//r/+n/6//t/+n/6P/q/+j/6P/p/+b/5//q/+f/4v/n/+z/5//n/+n/5//k/+L/6f/p/+L/6P/m/+T/6f/m/+j/6f/h/+T/6P/o/+3/7f/q/+z/7v/s//D/9f/v/+//8//x//L/8P/z//T/8//4//n/9//4/w==",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"9v/1//b/7v/w//f/8//0//L/8//3//H/8v/1//H/8//z//H/9P/0//P/9P/x//D/8//y//L/9f/w//L/9//1//P/9v/4//r//P/7//v//f///wIAAwACAAUABgADAAcABwAGAAwACwALAAoACgAOAA0ADwAPAA0ADwAOAA8AEQASABMAEgAVABMAEAAUABgAGQAWABYAGQAZABsAHAAcAB0AHAAiACAAHAAgACAAHgAhACMAJQAlACQAJwAkACEAIQAgACYAJQAjACYAJgAnACgAJQAkACcAKAAmACQAIwAkACYAJQAmACUAIAAgAB8AIwAiAB0AIAAfABsAGwAYAA==",
|
||||
"GAAbABkAGAAbABUAFAATABIAEAAQABMADAANABEACwANAAwABgAIAAYABAAEAPz/AAAGAPn/+/8BAPn/+v/7//f/+P/3//b/8//y//P/8v/z/+7/7v/s/+b/6P/l/+L/5f/j/+P/4P/d/9z/2P/W/9b/1v/W/9L/0v/T/9L/0f/O/8//zv/J/8v/yf/J/8//x//F/83/xP/F/8z/wv/F/8b/wv/I/8X/xP/I/8T/xv/C/8L/xP/B/8T/xf/H/8P/wP/I/8b/xv/G/8f/zP/E/8T/yP/G/8X/w//J/87/yf/K/87/0P/Q/9L/1P/S/9T/2P/Y/9r/2//c/+H/4//j/w==",
|
||||
"5P/k/+T/6P/r/+z/7//w/+//8f/2//j//P/9//7/AwABAAMABwAEAAoAEgAOABMAFwAXAB4AHAAbACIAIwAkACgAKQAqACwALgAxADIAMgAxADYAOgA5ADoAPgA+ADwAPAA8AEEARgA+AD0AQAA8AD4AQQA/AD8APwA/AEAAPAA7ADwANgA2ADwANwAyADUANQA1ADMAMAAzADAALAAvAC0AKgArACkAKwArACcAKgAnACMAKAAlACMAIgAdAB4AIQAeABsAHAAaABQAEwAVABQAEAAOAA4ADAAIAAQAAwABAPz//f/8//v/+v/2//r/9f/s//P/8v/r/+n/6f/q/w==",
|
||||
"6f/n/+P/4P/j/+H/4f/f/9r/3f/a/9v/4P/e/9z/3v/c/9z/3//d/9z/4P/f/97/4P/e/9//5//n/+P/5P/l/+f/6//x//H/8v/z//T/+v/4//b/9//1//b/9v/3//j/9//6//v/+//+//////8AAP///v8EAAMAAwAFAAEABAAFAAQABAADAAQABAAIAAkABAAGAAgABwAJAAgABQAEAAMABQAFAAEA/f/+/wEAAAD//wEAAwAAAAAAAgD8//7/AAD6//r/+v/8//r/9//6//f/8//2//T/9P/2//L/8P/z//D/8P/x/+//8v/v/+//8v/w/+//7f/v//L/8P/z/w==",
|
||||
"9P/z//X/9f/z//P/9f/2//T/9P/1//T/9v/1//X/9v/1//X/9f/3//j/+v/7//j/9//5//z//v/9//7//v/9////AQACAAQABgAFAAQAAwADAAMABQAGAAUABQAHAAkADgALAAgADAAOAA8ADwAOAA4ADAAPABAADAAMAAwADgAOAA4AEQASAA8ACgAMAAkACwAaAA0A/f8CAAIAAAAGAAUABAAIAAYABQAFAAIAAgD/////AAD+////AgAEAAIAAAD///7//v/+//z//P/8//3//f/9//z//f/+//7//f/8//7////9//3//P/9//7//v/+///////+/////////w==",
|
||||
"//8AAAEAAAAAAAAA//8AAAEAAAAAAAAAAAABAAEAAAAAAAAAAAAAAAAAAQAAAAAAAQAAAAAAAAAAAAAAAAAAAAAA//////////8AAP//////////AAAAAP//AAAAAAEAAAAAAAAAAAAAAAAAAAABAAEAAAABAAAAAAABAAAAAQABAAAAAQAAAAAAAAAAAAAAAAABAAEAAQAAAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAAAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAA==",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"AAABAAEAAQABAAEAAQABAAEAAwACAAIAAwACAAIAAgABAAIAAQABAAIABQAGAAQAAwAEAAQABAAEAAMAAwADAAMAAwADAAMAAgACAAIAAgACAAEAAQACAAEAAQACAAIAAQABAAEAAgABAAIAAgABAAIAAQABAAIAAgABAAIAAgACAAIAAQABAAIAAgACAAEAAQACAAIAAgACAAIAAgACAAEAAQACAAIAAQABAAIAAgACAAIAAgACAAIAAQABAAEAAQACAAIAAQABAAEAAgACAAEAAQACAAIAAQABAAIAAQACAAIAAQABAAIAAgACAAIAAgABAAEAAgACAAIAAgABAA==",
|
||||
"AQABAAIAAQABAAEAAgABAAIAAQABAAEAAQACAAIAAQABAAEAAgABAAEAAgACAAIAAgACAAEAAQABAAIAAgACAAEAAQABAAIAAgACAAEAAgACAAEAAQACAAEAAQABAAEAAQABAAEAAQACAAEAAQABAAEAAQABAAEAAQABAAEAAgACAAEAAQABAAIAAgABAAIAAQABAAIAAgABAAIAAgABAAEAAgABAAIAAgACAAIAAgACAAIAAgABAAEAAgACAAEAAQACAAIAAgABAAIAAgABAAEAAQABAAIAAQABAAEAAgABAAEAAgACAAIAAgABAAEAAgABAAEAAgABAAEAAgABAA==",
|
||||
"AQACAAIAAQABAAIAAQABAAIAAQACAAIAAQACAAIAAgABAAEAAgABAAEAAQABAAIAAgABAAIAAQABAAEAAQABAAEAAQABAAEAAQACAAEAAQABAAIAAgABAAEAAQABAAEAAQACAAEAAQABAAEAAQABAAEAAQABAAEAAAAAAAEAAQABAAEAAQABAAEAAQABAAEAAgACAAEAAgACAAEAAgACAAIAAgABAAEAAQACAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAAAAAAAAAAAAAAAQAAAAEAAQABAAEA/////wAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"TgVHBGID0AIpAt8BdAIVBNcF1QYJBxUG6gRRBN0DlgPtA1wFuQYmB+sG5QbvBtIF1QR1BLIEDgXABAYFMgXsBBkE+wJzAvcBlgHdAHwArQCGAL0A2AByAO7/V/9+//T+Lv2f/PH83/wn/A/8wPz4+5T6cfrd+tv5Nfhc+AD51viV+HT43ves9qH2Nfd19pX1Hvb49mz29/X19or3pPb39Y72tfZV9j/3//hq+cH4dPnN+t/6P/oo+rn60/ok+zf80fxE/d39jv4K/3f/ugCiAe0BhgJeAzoEOQQ2BK8E7QSMBT4G6AYQB7IGNAdPCDkJXQl+CQcKRgo7CnwKggvsCw==",
|
||||
"7guXDHMNDw7KDQ8OhQ7aDWsNpAyHDCUNtg2YDrAMCwq6CEwIGQlPCOQGsQV3BJMEKAQCAxsCFwGLAM//5/96AbwC2wIpAXL/1/6G/tH9UvwA/DP9Ff6m/Q78gPo/+ZT3ePX587L01PfG+dv36PSI9BX2PfXU8WPw/PFi9Cn1uvQJ9e71YfbT9CXy1vFB9ED2yfXw9Nb15vaw9oL18fPg8onzx/Xj9hz2a/Zc+Mb4xvab9Sz3//iA+cb5W/p/+/v8Ev6r/fz8sP4TAbUBAgJpBDcHjAciB+kHHgmTCb4KCQ19DcMMkA01D8kPLg/CD4gR/RFzEoATahSoFCwUWxQBEw==",
|
||||
"ThHKEQ0UfRQdDvAGmgZ7C1UNZAgDBMwEJQb3AwMBagApAbcAcf+1/0sD/wiZCuYFvABPAVgEMQOoADkBewK6ATAB+wHNAOH85fnb91H03PIB9/f6xfgs9ILzhfXQ9FTyEvHg8Fnxa/JP9En2NvhQ+Gr0LfAv8IzzK/Xa863z5fRe9S315vQT9LHxJPAp8cTy6fOO9F31BPUn85DyrfNT9U/13fMt9E73yPvf/VH8E/ok+v77OP0Q/tv/IQLVAnoCNgN1BAsFdAXjBrYIiQm5CnINCRBEEAcPMQ9jEcAThRRWFEoU5RS5FTUWsxaTF/IYLhmzF7sWcRbQEpwJbwJOBQ==",
|
||||
"1grrCIoCVAIgB6gEvf32+tz7Q/z3+5MAIAb3CPcM9g7tCq4EowRqCd4IAQXcBTcKDAyHCX4Ebv2e96D2OPYT8x3zK/pf/pv3Ne/w7/T0ePOL7Z3sfPEX92T5WPi39rz1N/X28ufvdPGq9jH6z/hz9EvyRPIP8pbv1uoW6evsPPL48qzvb+4F8LjvsOy17IvxV/ay9373nvlZ/Ab9pvtI+RH5pfun/zcCOAKaAnkDAAPEAGP/CAFeAwQFYgbgBz4JGQqLC0kMjgviC2UPZxTDFswXNBopHNYaJBhtGWMdnB/fHqwcYxu3GjYYeA5bACX8XgRYCuYCh/rO/5cFw/698w==",
|
||||
"F/I9+G/6S/zhAjMJ5Q7+EkISEQvnBIsJaw61CksHowvGEkARIwmRAR37s/eK9lL0MfFe8z37W/xx893sOvCl8ybujumJ7pz3Zftb+SX4DPiB9xP2FPNY8SvzO/hD+zf4IvRm877ztvCB6xrq/uy174/vdO6i7gPvue4Q7vPt2e5D8SX14feQ+Cj5CPtJ/ML6mvnY+0j/LwCv/sH+IwFqApoANP5t/hoBbwPpA9UEmQY7CJIJ5wplDXwP0BFVFQEYVBqqHJ8eEh5oG7Yc3iCfIosgXh3mHHQbDRdoDWX/gvni/5oInwRE+Sn6uQCu/DPwk+sp9Gr6FfzxAU8KURFHEw==",
|
||||
"ERHEC+kGlArmDx8PWw2uEOMVzhIFCgwCmPpv9Sf0pfRG8/zz2Pn/+uDxvegF6qvusuyA6rHvSvg4/OL6H/mP9kT0SvS/9Ff1nvfL/I//C/th9MHwbu8x7ZXqlup27MrukPD770jsBehq5x/qx+xL7srw0vRr99H3t/Yf9vr3g/qt/OX+cwJyBpUFwQCh/hQAKwDB/Vj/6QUhCfkF7AJVBFIGRwZqB1YLQQ9OEhsWwhmrG28brxr6GpsbxR2pIR0kISWeIpEedBt/EAkB2fhhAAMO1wrH/v77UP+a+lDtjuni8DL3afwLBEYLkgwEDHoOqAzbB2sJ9BBtF6YY1RgXGA==",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"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",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"wP7V/ur++v4T/xb/Ef8U/xX/Dv8G/wn//f7y/gD///4F/xj/Gv8c/yH/I/8m/0T/WP9S/2X/aP9e/2H/aP9v/3L/eP99/4b/jv+j/7f/vf/b/+X/6/8IAAwAEQAfACQAKQA3AEwATwBZAHYAfwB5AHIAdgCBAHwAewCHAJkAqACjAJkAlwCiAKEAjgCYAJ0AnQCxALsAwADDAMoA1wDVANQA3ADlAO8A/AAIAQYB/QD3APwAAQH9APsA+QD5APEA5wDZAL4ArQCrALMAswCsAKcAmgCbAJ8AlACbAJ4AnwCrAKsApgCjAKYApACgAJ4AmQCYAIsAkgCUAIMAjQCLAA==",
|
||||
"ewBwAHEAdQBnAF4ATgA8AD4ALgAgAB4ADwAQAAcA/P8BAPr/9f/w/+T/4v/i/+T/7v/t/93/2//h/9v/z//M/8z/xP+8/7H/qP+q/6H/mf+X/4v/ef9v/3b/Zv9b/2P/WP9T/0z/QP9F/z//Q/9O/1D/XP9V/03/VP9R/1r/Vv9G/03/V/9Z/17/ZP9j/2X/b/9z/23/a/9z/3z/gP+D/3//dP91/27/Zv90/3n/d/94/3n/gP+M/5L/mv+l/6n/pf+l/6v/pf+j/6z/tf++/7//xP/M/8H/vf/D/7z/q/+c/6//yP/M/9P/2//l//H/6//m//b/CAAGAAQAGwArAA==",
|
||||
"MAA2ACwAJQAgABwAKwAwADIAPwBRAFEAPgBEAEIAOgA9ADoARwBTAF8AZgBnAGoAbABvAGsAcwCCAIUAkACQAI0AkgCPAIcAeAByAIIAiwCGAHYAbwB4AHIAcwCJAIQAeQCJAIcAfACAAH0AgQCDAH4AjACNAIkAlgCEAHcAgwCAAHwAfACEAIkAdwB3AHoAagBjAGwAaQBcAF8AZgBlAFgAVgBVAD0AQABGADoAOgA6ADEAMgAxACkAJgAbABQAEwAKAA0ADQAQABcABAD6//n/8//1/+7/6//p/+r/9v/v/+P/5v/g/9//2v/V/+H/3P/e/+f/2v/a/9r/z//H/w==",
|
||||
"v//C/7b/p/+u/63/qP+n/6H/lv+U/5n/kv+Q/5D/hv97/3b/ff9+/3T/cP9x/2b/Yf9q/2n/bP9z/3P/cP9n/2X/af9j/2D/Zv9r/2//bv97/4n/gf97/3X/cv94/3D/bf94/4P/hf95/3X/gP+C/3X/df98/3r/hP+J/43/l/+O/5T/n/+d/6P/pv+w/8P/0P/a/9z/0v/W/+D/4f/t/+v/7f/4//L/8//u/+D/3f/l/+7/8P/6//n/8f/9//j/9v/+//n/AwAHAAcACgAQABkAHAAlACwANQAyADcAUgBMAE8AZwBiAFoAYQBsAHIAeQB+AHsAfwB+AHoAggCAAA==",
|
||||
"ggCKAIYAiwCVAJkAlwCWAJoAmwCbAKQAswCzALEAvQDBALwAvQDEAMEAvwC9ALMAtwC3ALIAuAC2ALAArACtAK8ApwCnAKYAogCeAJgAmgCUAIsAjwCOAI0AiQCBAIEAdwB4AHwAbABlAGQAYwBhAF0AWgBaAFsAWgBaAE8ASwBNAEYARABFADkAMwAyAB8AHQAjABkAEAAJAAIABQAGAPz/AAADAPj/7f/p/+X/2v/V/9L/0f/K/8T/xP+4/6//rf+l/53/jv+G/5L/k/+O/4//j/+O/4n/ff95/4L/e/9p/2//dP9z/3n/ef9z/3H/a/9q/2//aP9o/3X/dv9u/w==",
|
||||
"cv95/3T/cP9v/27/cP9y/3L/d/95/3n/ff93/3//iv+I/4j/iv+I/4r/k/+R/47/kf+Q/5v/n/+V/5T/lv+c/5b/l/+m/6P/rP+y/6z/tv+5/7X/vf/B/8X/0f/T/9j/1//V/9z/3//l/+P/5P/q/+b/7f/4//r/9/////r/8/8FAP//9/8HAAAA+P/7////AQAFAAoABQAEAAcABgANAAoACQATABUAGgAfACwAKgAhACsAKQArADEAMAA2ADQAMQA0ADEANABDAEcARgBJAEsASABEAEUARwBBAD4ASQBLAEEAPABEAEMAQABJAEYARQBJAEkATABNAE4ASQBHAA==",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"SABEAEkASgBJAEwASABEAEkATQBJAE0AVABNAFAAUwBIAE0ASwBEAEkARABGAEoAPgA9AEMAQgA8ADIANgA8ADgAOgA7ADwAPQA3ADYAOAAzADEALgArACoAKgAnACQAIwAdABUAFQAYABYAGAAbABMADgAKAAMABAD///v//v////v/+v8AAPv/9v/z/+f/7P/u/+r/6P/l/+b/5f/j/+L/5P/e/9b/2//W/9T/2P/R/9P/0P/M/8//x//H/8j/vP+6/7j/uf++/7r/u/+5/7X/sv+x/7L/r/+3/7T/p/+p/6r/rP+v/7D/r/+w/7n/uP+4/7n/tf+2/7T/uv+6/w==",
|
||||
"s/+9/73/tf+6/77/vP+7/7v/tP+w/7P/sv+x/7f/v/+8/7b/vP++/7T/t//E/8j/xP/C/8P/wf/B/8b/yv/Q/9X/0f/S/9f/1P/W/9j/zv/S/+D/3//k/+f/5P/v//P/9P/8//r/+v8CAAYABwALAAwADQAPAAsAFQAeAB0AIQAaABsAKAAkACcALAApACsALQAsACsAMAAwADEANgA2ADoAPAA4ADsAOgA2ADkAPQBBAEUASwBOAEwAVQBZAFUAVgBaAGEAXABaAGAAXABkAGUAXwBhAF4AXwBjAGQAYgBfAGAAYQBZAFoAXABcAFwAWABaAFkAVABUAFIAUABRAA==",
|
||||
"UQBPAFIAUQBOAFAATQBIAEoARwBDAEAAQABHAEEAPAA6ADYANwA0ADMALwAtACsAIwAjACMAIQAbABcAHAATAA0AFgATAA8AEAAHAAMABgAHAP//AAABAPf//v/8//H/8v/v/+3/6f/c/+L/5v/W/9z/4P/Z/9f/zP/O/8//yv/K/8H/wP/A/7v/xP/D/7n/uf+y/6//uP+x/6z/tv+y/6b/qv+s/6n/qv+u/7X/s/+v/7D/rP+y/7f/tP+3/7r/u//C/8P/wP/C/8T/yP/H/8X/x//E/8b/zf/L/8r/0f/R/9D/0f/U/9z/4P/Z/9j/2//Y/+D/4P/a/9//3f/c/w==",
|
||||
"4//h/+P/6f/u/+//7P/s/+3/8P/x//H/+v/2//T/AQAAAAIABgAFAAcABgAFAAkADwARAA8ADgARABkAFwAaACAAFwAbACAAHAAgAB8AJQAmACQAJwAlACgAKwAsACoAKQAyACwAKgAzADEANAAyAC4AMAAsACwAKwApACUAJQApACYAIgAkACYAJQAjACQAIAAfACEAIAAeABoAGAAaABsAGQAZABgAEAAQABcAFwATABcAFgAMAA0AEAALAA0ADQAKAA0ACwAFAAIA/v8CAAMA//8CAAMABAADAPz/+P/9//3/+P/2//T/9//5//n/9v/z//P/8f/x//D/7//y/w==",
|
||||
"8f/v//L/9P/r/+z/7f/n/+n/5//q/+v/5f/m/+f/6v/p/+L/5v/m/+P/6//n/+b/6v/o/+z/7v/r/+3/8P/x/+//7f/t//H/7v/q//H/9P/s/+7/8P/u//L/9f/1//H/8f/x//P/+P/y//H/9//2//X/9P/z//f/9//5//r/+f/4//j//f/8//f/+P////3///8GAP//BwAJAP3/AwADAAEAAAD9/wEAAQD+/wEAAwAAAAEAAwAEAAQAAQADAAIA/v8CAAcACgAGAAMABwAHAAQABwAHAAkADgALAAsADQALAA0AEgASABAADQAOABIADgAPABMAEQATABIADwATAA==",
|
||||
"EgAPAA8AEgATABEAEwATAA4ADwATABYAEwAOAA0ADgATABEADwAUAA8ACwAMAAwADQAOAAsADwAPAAgACQAJAAMABQAJAAcABgAIAAkACQAEAAYACAACAAEAAgD9//7/AgD///z/+v/5//z/+P/0//j/9f/2//f/8//1//L/7//v//L/8P/o/+z/7//r/+n/6//t/+n/6P/q/+j/6P/p/+b/5//q/+f/4v/n/+z/5//n/+n/5//k/+L/6f/p/+L/6P/m/+T/6f/m/+j/6f/h/+T/6P/o/+3/7f/q/+z/7v/s//D/9f/v/+//8//x//L/8P/z//T/8//4//n/9//4/w==",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"9v/1//b/7v/w//f/8//0//L/8//3//H/8v/1//H/8//z//H/9P/0//P/9P/x//D/8//y//L/9f/w//L/9//1//P/9v/4//r//P/7//v//f///wIAAwACAAUABgADAAcABwAGAAwACwALAAoACgAOAA0ADwAPAA0ADwAOAA8AEQASABMAEgAVABMAEAAUABgAGQAWABYAGQAZABsAHAAcAB0AHAAiACAAHAAgACAAHgAhACMAJQAlACQAJwAkACEAIQAgACYAJQAjACYAJgAnACgAJQAkACcAKAAmACQAIwAkACYAJQAmACUAIAAgAB8AIwAiAB0AIAAfABsAGwAYAA==",
|
||||
"GAAbABkAGAAbABUAFAATABIAEAAQABMADAANABEACwANAAwABgAIAAYABAAEAPz/AAAGAPn/+/8BAPn/+v/7//f/+P/3//b/8//y//P/8v/z/+7/7v/s/+b/6P/l/+L/5f/j/+P/4P/d/9z/2P/W/9b/1v/W/9L/0v/T/9L/0f/O/8//zv/J/8v/yf/J/8//x//F/83/xP/F/8z/wv/F/8b/wv/I/8X/xP/I/8T/xv/C/8L/xP/B/8T/xf/H/8P/wP/I/8b/xv/G/8f/zP/E/8T/yP/G/8X/w//J/87/yf/K/87/0P/Q/9L/1P/S/9T/2P/Y/9r/2//c/+H/4//j/w==",
|
||||
"5P/k/+T/6P/r/+z/7//w/+//8f/2//j//P/9//7/AwABAAMABwAEAAoAEgAOABMAFwAXAB4AHAAbACIAIwAkACgAKQAqACwALgAxADIAMgAxADYAOgA5ADoAPgA+ADwAPAA8AEEARgA+AD0AQAA8AD4AQQA/AD8APwA/AEAAPAA7ADwANgA2ADwANwAyADUANQA1ADMAMAAzADAALAAvAC0AKgArACkAKwArACcAKgAnACMAKAAlACMAIgAdAB4AIQAeABsAHAAaABQAEwAVABQAEAAOAA4ADAAIAAQAAwABAPz//f/8//v/+v/2//r/9f/s//P/8v/r/+n/6f/q/w==",
|
||||
"6f/n/+P/4P/j/+H/4f/f/9r/3f/a/9v/4P/e/9z/3v/c/9z/3//d/9z/4P/f/97/4P/e/9//5//n/+P/5P/l/+f/6//x//H/8v/z//T/+v/4//b/9//1//b/9v/3//j/9//6//v/+//+//////8AAP///v8EAAMAAwAFAAEABAAFAAQABAADAAQABAAIAAkABAAGAAgABwAJAAgABQAEAAMABQAFAAEA/f/+/wEAAAD//wEAAwAAAAAAAgD8//7/AAD6//r/+v/8//r/9//6//f/8//2//T/9P/2//L/8P/z//D/8P/x/+//8v/v/+//8v/w/+//7f/v//L/8P/z/w==",
|
||||
"9P/z//X/9f/z//P/9f/2//T/9P/1//T/9v/1//X/9v/1//X/9f/3//j/+v/7//j/9//5//z//v/9//7//v/9////AQACAAQABgAFAAQAAwADAAMABQAGAAUABQAHAAkADgALAAgADAAOAA8ADwAOAA4ADAAPABAADAAMAAwADgAOAA4AEQASAA8ACgAMAAkACwAaAA0A/f8CAAIAAAAGAAUABAAIAAYABQAFAAIAAgD/////AAD+////AgAEAAIAAAD///7//v/+//z//P/8//3//f/9//z//f/+//7//f/8//7////9//3//P/9//7//v/+///////+/////////w==",
|
||||
"//8AAAEAAAAAAAAA//8AAAEAAAAAAAAAAAABAAEAAAAAAAAAAAAAAAAAAQAAAAAAAQAAAAAAAAAAAAAAAAAAAAAA//////////8AAP//////////AAAAAP//AAAAAAEAAAAAAAAAAAAAAAAAAAABAAEAAAABAAAAAAABAAAAAQABAAAAAQAAAAAAAAAAAAAAAAABAAEAAQAAAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAAAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAA==",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
{
|
||||
message: "audio",
|
||||
audio: [
|
||||
"AAABAAEAAQABAAEAAQABAAEAAwACAAIAAwACAAIAAgABAAIAAQABAAIABQAGAAQAAwAEAAQABAAEAAMAAwADAAMAAwADAAMAAgACAAIAAgACAAEAAQACAAEAAQACAAIAAQABAAEAAgABAAIAAgABAAIAAQABAAIAAgABAAIAAgACAAIAAQABAAIAAgACAAEAAQACAAIAAgACAAIAAgACAAEAAQACAAIAAQABAAIAAgACAAIAAgACAAIAAQABAAEAAQACAAIAAQABAAEAAgACAAEAAQACAAIAAQABAAIAAQACAAIAAQABAAIAAgACAAIAAgABAAEAAgACAAIAAgABAA==",
|
||||
"AQABAAIAAQABAAEAAgABAAIAAQABAAEAAQACAAIAAQABAAEAAgABAAEAAgACAAIAAgACAAEAAQABAAIAAgACAAEAAQABAAIAAgACAAEAAgACAAEAAQACAAEAAQABAAEAAQABAAEAAQACAAEAAQABAAEAAQABAAEAAQABAAEAAgACAAEAAQABAAIAAgABAAIAAQABAAIAAgABAAIAAgABAAEAAgABAAIAAgACAAIAAgACAAIAAgABAAEAAgACAAEAAQACAAIAAgABAAIAAgABAAEAAQABAAIAAQABAAEAAgABAAEAAgACAAIAAgABAAEAAgABAAEAAgABAAEAAgABAA==",
|
||||
"AQACAAIAAQABAAIAAQABAAIAAQACAAIAAQACAAIAAgABAAEAAgABAAEAAQABAAIAAgABAAIAAQABAAEAAQABAAEAAQABAAEAAQACAAEAAQABAAIAAgABAAEAAQABAAEAAQACAAEAAQABAAEAAQABAAEAAQABAAEAAAAAAAEAAQABAAEAAQABAAEAAQABAAEAAgACAAEAAgACAAEAAgACAAIAAgABAAEAAQACAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAAAAAAAAAAAAAAAQAAAAEAAQABAAEA/////wAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
],
|
||||
utterance_idx: 0,
|
||||
text: "Hey there!",
|
||||
},
|
||||
];
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#fff",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
paddingTop: 50,
|
||||
paddingBottom: 50,
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-around",
|
||||
width: "100%",
|
||||
marginBottom: 20,
|
||||
},
|
||||
volumeContainer: {
|
||||
justifyContent: "space-around",
|
||||
width: "100%",
|
||||
marginBottom: 20,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
width: "90%",
|
||||
borderWidth: 1,
|
||||
borderColor: "#ccc",
|
||||
},
|
||||
});
|
||||
44
packages/expo-two-way-audio/examples/basic-usage/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Expo Two-Way-Audio Using Flow API
|
||||
|
||||
This example showcases the basic functions of the expo-two-way-audio module:
|
||||
|
||||
- Capture input raw pcm data
|
||||
- Play raw pcm data (from a hardcoded base64 string)
|
||||
- Display volume level for both input and output
|
||||
- In iOS: show/set microphone mode while recording.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js
|
||||
- Expo CLI
|
||||
- [iOS](https://docs.expo.dev/workflow/ios-simulator/) and/or [Android](https://docs.expo.dev/workflow/android-studio-emulator/) dev environment setup.
|
||||
|
||||
## Run the app
|
||||
|
||||
In the `examples/basic-usage` directory:
|
||||
|
||||
1. Install dependencies:
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Run the app:
|
||||
```sh
|
||||
# Run iOS
|
||||
npm run ios
|
||||
|
||||
# Run Android
|
||||
npm run android
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
Some audio features of expo-two-way-audio like Acoustic Echo Cancelling, noise reduction or microphone modes (iOS) don't work on simulator. Run the example on a real device to test these features.
|
||||
|
||||
```bash
|
||||
# iOS
|
||||
npx expo run:ios --device --configuration Release
|
||||
|
||||
# Android
|
||||
npx expo run:android --device --variant release
|
||||
```
|
||||
47
packages/expo-two-way-audio/examples/basic-usage/app.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "expo-two-way-audio-example",
|
||||
"slug": "expo-two-way-audio-example",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "expo.modules.twowayaudio.example",
|
||||
"infoPlist": {
|
||||
"UIBackgroundModes": ["audio"],
|
||||
"NSMicrophoneUsageDescription": "Allow Speechmatics to access your microphone"
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
|
||||
"package": "expo.modules.twowayaudio.example"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"android": {
|
||||
"compileSdkVersion": 34,
|
||||
"targetSdkVersion": 31,
|
||||
"minSdkVersion": 26,
|
||||
"buildToolsVersion": "34.0.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
BIN
packages/expo-two-way-audio/examples/basic-usage/assets/icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,6 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const path = require("path");
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// npm v7+ will install ../node_modules/react and ../node_modules/react-native because of peerDependencies.
|
||||
// To prevent the incompatible react-native between ./node_modules/react-native and ../node_modules/react-native,
|
||||
// excludes the one from the parent folder when bundling.
|
||||
config.resolver.blockList = [
|
||||
...Array.from(config.resolver.blockList ?? []),
|
||||
new RegExp(path.resolve("../..", "node_modules", "react")),
|
||||
new RegExp(path.resolve("../..", "node_modules", "react-native")),
|
||||
];
|
||||
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(__dirname, "./node_modules"),
|
||||
path.resolve(__dirname, "../../node_modules"),
|
||||
];
|
||||
|
||||
config.resolver.extraNodeModules = {
|
||||
"expo-two-way-audio": "../..",
|
||||
};
|
||||
|
||||
config.watchFolders = [path.resolve(__dirname, "../../")];
|
||||
|
||||
config.transformer.getTransformOptions = async () => ({
|
||||
transform: {
|
||||
experimentalImportSupport: false,
|
||||
inlineRequires: true,
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = config;
|
||||
12343
packages/expo-two-way-audio/examples/basic-usage/package-lock.json
generated
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "expo-two-way-audio-example",
|
||||
"version": "1.0.0",
|
||||
"main": "expo/AppEntry.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "^52.0.0",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.76.2",
|
||||
"expo-dev-client": "~5.0.3",
|
||||
"expo-build-properties": "~0.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~18.3.12",
|
||||
"typescript": "^5.1.3"
|
||||
},
|
||||
"private": true,
|
||||
"expo": {
|
||||
"autolinking": {
|
||||
"nativeModulesDir": "../.."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"module": "ESNext",
|
||||
"paths": {
|
||||
"@speechmatics/expo-two-way-audio": ["../../src/index"],
|
||||
"@speechmatics/expo-two-way-audio/*": ["../../src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
const createConfigAsync = require("@expo/webpack-config");
|
||||
const path = require("path");
|
||||
|
||||
module.exports = async (env, argv) => {
|
||||
const config = await createConfigAsync(
|
||||
{
|
||||
...env,
|
||||
babel: {
|
||||
dangerouslyAddModulePathsToTranspile: ["expo-two-way-audio"],
|
||||
},
|
||||
},
|
||||
argv,
|
||||
);
|
||||
config.resolve.modules = [
|
||||
path.resolve(__dirname, "./node_modules"),
|
||||
path.resolve(__dirname, "../../node_modules"),
|
||||
];
|
||||
|
||||
return config;
|
||||
};
|
||||
35
packages/expo-two-way-audio/examples/flow-api/.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
|
||||
# Native
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
270
packages/expo-two-way-audio/examples/flow-api/App.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
// EventTarget polyfill is required for the Flow SDK to work in React Native
|
||||
import "event-target-polyfill";
|
||||
import { Buffer } from "buffer";
|
||||
import { Button, StyleSheet, Text, View } from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
import {
|
||||
type MicrophoneDataCallback,
|
||||
type VolumeLevelCallback,
|
||||
getMicrophoneModeIOS,
|
||||
initialize,
|
||||
playPCMData,
|
||||
toggleRecording,
|
||||
useExpoTwoWayAudioEventListener,
|
||||
useIsRecording,
|
||||
useMicrophonePermissions,
|
||||
} from "@speechmatics/expo-two-way-audio";
|
||||
|
||||
import {
|
||||
FlowProvider,
|
||||
type RecognitionAlternative,
|
||||
useFlow,
|
||||
useFlowEventListener,
|
||||
} from "@speechmatics/flow-client-react";
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { getFlowAPIJwt } from "./auth";
|
||||
import VolumeDisplay from "./volume-display";
|
||||
|
||||
export default function App() {
|
||||
const [micPermission, requestMicPermission] = useMicrophonePermissions();
|
||||
console.log(micPermission);
|
||||
|
||||
if (!micPermission?.granted) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Mic permission: {micPermission?.status}</Text>
|
||||
<Button
|
||||
title={micPermission?.canAskAgain ? "Request permission" : "Cannot request permissions"}
|
||||
disabled={!micPermission?.canAskAgain}
|
||||
onPress={requestMicPermission}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlowProvider appId="flow-api-example" websocketBinaryType="arraybuffer">
|
||||
<FlowTest />
|
||||
</FlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlowTest() {
|
||||
const [isConnectedToFlow, setIsConnectedToFlow] = useState(false);
|
||||
const [isConnectingToFlow, setIsConnectingToFlow] = useState(false);
|
||||
const [audioInitialized, setAudioInitialized] = useState(false);
|
||||
const inputVolumeLevel = useSharedValue(0.0);
|
||||
const outputVolumeLevel = useSharedValue(0.0);
|
||||
const isRecording = useIsRecording();
|
||||
const { startConversation, endConversation, sendAudio } = useFlow();
|
||||
|
||||
// Initialize Expo Two Way Audio
|
||||
useEffect(() => {
|
||||
const initializeAudio = async () => {
|
||||
await initialize();
|
||||
setAudioInitialized(true);
|
||||
};
|
||||
|
||||
initializeAudio();
|
||||
}, []);
|
||||
|
||||
// Setup a handler for the "message" event from Flow API
|
||||
useFlowEventListener("message", ({ data }) => {
|
||||
// For the purpose of this example, just print the content of the transcript
|
||||
if (data.message === "AddTranscript") {
|
||||
for (const result of data.results as {
|
||||
alternatives: Array<RecognitionAlternative>;
|
||||
}[]) {
|
||||
for (const alternative of result.alternatives as Array<RecognitionAlternative>) {
|
||||
console.log(alternative.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Setup a handler for the "agentAudio" event from Flow API
|
||||
useFlowEventListener("agentAudio", (audio) => {
|
||||
// Even though Int16Array is a more natural representation for PCM16_sle,
|
||||
// Expo Modules API uses a convertible type for arrays of bytes and it needs Uint8Array in the JS side.
|
||||
// This is converted to a `Data` type in Swift and to a `kotlin.ByteArray` in Kotlin.
|
||||
// More info here: https://docs.expo.dev/modules/module-api/#convertibles
|
||||
// For this reason, the Expo Two Way Audio library requires a Uint8Array argument for the `playPCMData` function.
|
||||
const byteArray = new Uint8Array(audio.data.buffer);
|
||||
playPCMData(byteArray);
|
||||
});
|
||||
|
||||
// Setup a handler for the "onMicrophoneData" event from Expo Two Way Audio module
|
||||
useExpoTwoWayAudioEventListener(
|
||||
"onMicrophoneData",
|
||||
useCallback<MicrophoneDataCallback>(
|
||||
(event) => {
|
||||
// We send the audio bytes to the Flow API
|
||||
sendAudio(event.data.buffer);
|
||||
},
|
||||
[sendAudio],
|
||||
),
|
||||
);
|
||||
|
||||
// Setup a handler for the "onInputVolumeLevelData" event from Expo Two Way Audio module.
|
||||
// This event is triggered when the input volume level changes.
|
||||
useExpoTwoWayAudioEventListener(
|
||||
"onInputVolumeLevelData",
|
||||
useCallback<VolumeLevelCallback>(
|
||||
(event) => {
|
||||
// We update the react-native-reanimated shared value for the input volume (microphone).
|
||||
// This allow us to create animations for the microphone volume based on this shared value.
|
||||
inputVolumeLevel.value = event.data;
|
||||
},
|
||||
[inputVolumeLevel],
|
||||
),
|
||||
);
|
||||
|
||||
// Setup a handler for the "onOutputVolumeLevelData" event from Expo Two Way Audio module.
|
||||
// This event is triggered when the output volume level changes.
|
||||
useExpoTwoWayAudioEventListener(
|
||||
"onOutputVolumeLevelData",
|
||||
useCallback<VolumeLevelCallback>(
|
||||
(event) => {
|
||||
// We update the react-native-reanimated shared value for the output volume (speaker).
|
||||
// This allow us to create animations for the speaker volume based on this shared value.
|
||||
outputVolumeLevel.value = event.data;
|
||||
},
|
||||
[outputVolumeLevel],
|
||||
),
|
||||
);
|
||||
|
||||
// Handle clicks to the 'Connect/Disconnect' button
|
||||
const handleToggleConnect = useCallback(async () => {
|
||||
setIsConnectingToFlow(true);
|
||||
if (isConnectedToFlow) {
|
||||
endConversation();
|
||||
setIsConnectedToFlow(false);
|
||||
} else {
|
||||
let jwt;
|
||||
try {
|
||||
// WARNING: This function is used for the purpose of this example only.
|
||||
// In a real-world scenario, you should not expose your API key in your client-side code.
|
||||
// Instead, you should create a server-side endpoint that generates the JWT for you.
|
||||
jwt = await getFlowAPIJwt(
|
||||
// EXPO_PUBLIC_SPEECHMATICS_API_KEY could be set in your .env file
|
||||
process.env.EXPO_PUBLIC_SPEECHMATICS_API_KEY,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to get JWT:", error);
|
||||
setIsConnectingToFlow(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await startConversation(jwt, {
|
||||
config: {
|
||||
template_id: "flow-service-assistant-humphrey",
|
||||
template_variables: {
|
||||
timezone: "Europe/London",
|
||||
},
|
||||
},
|
||||
// `audioFormat` is optional. The value below is the default:
|
||||
audioFormat: {
|
||||
type: "raw",
|
||||
encoding: "pcm_s16le", // this can also be set to 'pcm_f32le' for 32-bit Float
|
||||
sample_rate: 16000,
|
||||
},
|
||||
});
|
||||
setIsConnectedToFlow(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to start conversation:", error);
|
||||
}
|
||||
}
|
||||
setIsConnectingToFlow(false);
|
||||
}, [isConnectedToFlow, startConversation, endConversation]);
|
||||
|
||||
// Handle clicks to the 'Mute/Unmute' button
|
||||
const handleToggleMute = useCallback(() => {
|
||||
toggleRecording(!isRecording);
|
||||
}, [isRecording]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.VolumeDisplayContainer}>
|
||||
<View style={styles.volumeDisplay}>
|
||||
<VolumeDisplay
|
||||
volumeLevel={outputVolumeLevel}
|
||||
color="#7ECFBD"
|
||||
minSize={130}
|
||||
maxSize={180}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.volumeDisplay}>
|
||||
<VolumeDisplay
|
||||
volumeLevel={inputVolumeLevel}
|
||||
color="#4CBBA5"
|
||||
minSize={70}
|
||||
maxSize={120}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<Text>
|
||||
{isConnectedToFlow
|
||||
? isRecording
|
||||
? "I'm ready to listen. Try saying something!"
|
||||
: "Muted. Unmute to start a conversation"
|
||||
: isConnectingToFlow
|
||||
? "Connecting..."
|
||||
: "Disconnected"}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.bottomBar}>
|
||||
<View style={styles.buttonContainer}>
|
||||
<Button
|
||||
title={isConnectedToFlow ? "Disconnect" : "Connect"}
|
||||
disabled={isConnectingToFlow || !audioInitialized}
|
||||
onPress={handleToggleConnect}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title={isRecording ? "Mute" : "Unmute"}
|
||||
disabled={!isConnectedToFlow || !audioInitialized}
|
||||
onPress={handleToggleMute}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#fff",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-evenly",
|
||||
padding: 50,
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-around",
|
||||
width: "100%",
|
||||
marginBottom: 20,
|
||||
},
|
||||
bottomBar: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
width: "100%",
|
||||
padding: 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "lightgray",
|
||||
},
|
||||
VolumeDisplayContainer: {
|
||||
position: "relative",
|
||||
width: 150,
|
||||
height: 150,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
volumeDisplay: {
|
||||
position: "absolute",
|
||||
},
|
||||
});
|
||||
59
packages/expo-two-way-audio/examples/flow-api/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Expo Two-Way-Audio Using Flow API
|
||||
|
||||
This example shows how expo-two-way-audio can be used to communicate with [Speechmatics Flow API](https://www.speechmatics.com/flow)
|
||||
|
||||
Flow is a conversational AI API and therefore, we need to send audio data in real time and play back the audio responses that we'll get from the API.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js
|
||||
- Expo CLI
|
||||
- [iOS](https://docs.expo.dev/workflow/ios-simulator/) and/or [Android](https://docs.expo.dev/workflow/android-studio-emulator/) dev environment setup.
|
||||
- [Speechmatics Flow Client (React)](https://github.com/speechmatics/speechmatics-js-sdk/tree/main/packages/flow-client-react)
|
||||
- [`event-target-polyfill`](https://www.npmjs.com/package/event-target-polyfill) package, or any other polyfill for the [`EventTarget` class](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget)
|
||||
|
||||
A Speechmatics API key is also needed in order to run the example. The key is injected into the app via environment variable. It can be passed by creating a `.env` file in the same directory as the example with the following content:
|
||||
|
||||
```
|
||||
EXPO_PUBLIC_SPEECHMATICS_API_KEY='YOUR-API-KEY-HERE'
|
||||
```
|
||||
|
||||
An API key can be obtained from the [Speechmatics Portal](https://portal.speechmatics.com)
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> The example uses the API key to request a temporary token (needed for communicating with flow).
|
||||
> In a real-world scenario, you should not expose your API key in your client-side code.
|
||||
> Instead, you should create a server-side endpoint that generates the temporary token (JWT) for you.
|
||||
|
||||
## Run the app
|
||||
|
||||
In the `examples/flow-api` directory:
|
||||
|
||||
1. Install dependencies:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Run the app:
|
||||
|
||||
```sh
|
||||
# Run iOS
|
||||
npm run ios
|
||||
|
||||
# Run Android
|
||||
npm run android
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
Some audio features of expo-two-way-audio like Acoustic Echo Cancelling, noise reduction or microphone modes (iOS) don't work on simulator. Run the example on a real device to test these features.
|
||||
|
||||
```bash
|
||||
# iOS
|
||||
npx expo run:ios --device --configuration Release
|
||||
|
||||
# Android
|
||||
npx expo run:android --device --variant release
|
||||
```
|
||||
47
packages/expo-two-way-audio/examples/flow-api/app.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Flow Example",
|
||||
"slug": "expo-two-way-audio-flow-example",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "expo.modules.twowayaudio.flow.example",
|
||||
"infoPlist": {
|
||||
"UIBackgroundModes": ["audio"],
|
||||
"NSMicrophoneUsageDescription": "Allow Speechmatics to access your microphone"
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
|
||||
"package": "expo.modules.twowayaudio.flow.example"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"android": {
|
||||
"compileSdkVersion": 34,
|
||||
"targetSdkVersion": 31,
|
||||
"minSdkVersion": 26,
|
||||
"buildToolsVersion": "34.0.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
BIN
packages/expo-two-way-audio/examples/flow-api/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
packages/expo-two-way-audio/examples/flow-api/assets/icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
packages/expo-two-way-audio/examples/flow-api/assets/splash.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
15
packages/expo-two-way-audio/examples/flow-api/auth.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createSpeechmaticsJWT } from "@speechmatics/auth";
|
||||
|
||||
// WARNING: This function is used for the purpose of this example only.
|
||||
// In a real-world scenario, you should not expose your API key in your client-side code.
|
||||
// Instead, you should create a server-side endpoint that generates the JWT for you.
|
||||
export async function getFlowAPIJwt(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
throw new Error("API key is required.");
|
||||
}
|
||||
return await createSpeechmaticsJWT({
|
||||
type: "flow",
|
||||
apiKey,
|
||||
ttl: 60, // 1 minute
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
const { wrapWithReanimatedMetroConfig } = require("react-native-reanimated/metro-config");
|
||||
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const path = require("path");
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// npm v7+ will install ../node_modules/react and ../node_modules/react-native because of peerDependencies.
|
||||
// To prevent the incompatible react-native between ./node_modules/react-native and ../node_modules/react-native,
|
||||
// excludes the one from the parent folder when bundling.
|
||||
config.resolver.blockList = [
|
||||
...Array.from(config.resolver.blockList ?? []),
|
||||
new RegExp(path.resolve("../..", "node_modules", "react")),
|
||||
new RegExp(path.resolve("../..", "node_modules", "react-native")),
|
||||
];
|
||||
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(__dirname, "./node_modules"),
|
||||
path.resolve(__dirname, "../../node_modules"),
|
||||
];
|
||||
|
||||
config.resolver.extraNodeModules = {
|
||||
"expo-two-way-audio": "../..",
|
||||
};
|
||||
|
||||
config.watchFolders = [path.resolve(__dirname, "../../")];
|
||||
|
||||
config.transformer.getTransformOptions = async () => ({
|
||||
transform: {
|
||||
experimentalImportSupport: false,
|
||||
inlineRequires: true,
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = wrapWithReanimatedMetroConfig(config);
|
||||
config;
|
||||
11110
packages/expo-two-way-audio/examples/flow-api/package-lock.json
generated
Normal file
34
packages/expo-two-way-audio/examples/flow-api/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "expo-two-way-audio-flow-example",
|
||||
"version": "1.0.0",
|
||||
"main": "expo/AppEntry.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@speechmatics/auth": "^0.0.1",
|
||||
"@speechmatics/flow-client-react": "^0.2.0",
|
||||
"buffer": "^6.0.3",
|
||||
"event-target-polyfill": "^0.0.4",
|
||||
"expo": "^52.0.25",
|
||||
"expo-build-properties": "~0.13.2",
|
||||
"expo-dev-client": "~5.0.9",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.76.6",
|
||||
"react-native-reanimated": "~3.16.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~18.3.12",
|
||||
"typescript": "^5.1.3"
|
||||
},
|
||||
"private": true,
|
||||
"expo": {
|
||||
"autolinking": {
|
||||
"nativeModulesDir": "../.."
|
||||
}
|
||||
}
|
||||
}
|
||||
11
packages/expo-two-way-audio/examples/flow-api/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"module": "ESNext",
|
||||
"paths": {
|
||||
"@speechmatics/expo-two-way-audio": ["../../src/index"],
|
||||
"@speechmatics/expo-two-way-audio/*": ["../../src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
export default function VolumeDisplay({
|
||||
volumeLevel,
|
||||
color,
|
||||
minSize,
|
||||
maxSize,
|
||||
}: {
|
||||
volumeLevel: SharedValue<number>;
|
||||
color: string;
|
||||
minSize: number;
|
||||
maxSize: number;
|
||||
}) {
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const size = interpolate(volumeLevel.value, [0, 1], [minSize, maxSize]);
|
||||
|
||||
return {
|
||||
width: withTiming(size, {
|
||||
duration: 100,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
height: withTiming(size, {
|
||||
duration: 100,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
borderRadius: withTiming(size / 2, {
|
||||
duration: 100,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Animated.View style={[{ backgroundColor: color }, animatedStyle]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
const createConfigAsync = require("@expo/webpack-config");
|
||||
const path = require("path");
|
||||
|
||||
module.exports = async (env, argv) => {
|
||||
const config = await createConfigAsync(
|
||||
{
|
||||
...env,
|
||||
babel: {
|
||||
dangerouslyAddModulePathsToTranspile: ["expo-two-way-audio"],
|
||||
},
|
||||
},
|
||||
argv,
|
||||
);
|
||||
config.resolve.modules = [
|
||||
path.resolve(__dirname, "./node_modules"),
|
||||
path.resolve(__dirname, "../../node_modules"),
|
||||
];
|
||||
|
||||
return config;
|
||||
};
|
||||
9
packages/expo-two-way-audio/expo-module.config.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"platforms": ["ios", "android"],
|
||||
"ios": {
|
||||
"modules": ["ExpoTwoWayAudioModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["expo.modules.twowayaudio.ExpoTwoWayAudioModule"]
|
||||
}
|
||||
}
|
||||
374
packages/expo-two-way-audio/ios/AudioEngine.swift
Normal file
@@ -0,0 +1,374 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
class AudioEngine {
|
||||
private var avAudioEngine = AVAudioEngine()
|
||||
private var speechPlayer = AVAudioPlayerNode()
|
||||
private var engineConfigChangeObserver: Any?
|
||||
private var sessionInterruptionObserver: Any?
|
||||
private var mediaServicesResetObserver: Any?
|
||||
|
||||
public private(set) var voiceIOFormat: AVAudioFormat
|
||||
public private(set) var isRecording = false
|
||||
|
||||
public var onMicDataCallback: ((Data) -> Void)?
|
||||
public var onInputVolumeCallback: ((Float) -> Void)?
|
||||
public var onOutputVolumeCallback: ((Float) -> Void)?
|
||||
public var onAudioInterruptionCallback: ((String) -> Void)?
|
||||
|
||||
private var inputLevelTimer: Timer?
|
||||
private var outputLevelTimer: Timer?
|
||||
|
||||
private var inputBuffer = [Float](repeating: 0, count: 2048)
|
||||
private var outputBuffer = [Float](repeating: 0, count: 2048)
|
||||
private var inputBufferIndex = 0
|
||||
private var outputBufferIndex = 0
|
||||
|
||||
private var hasFirstInputBeenDiscarded = false
|
||||
private var discardRecording = false
|
||||
private var discardFirstInputMillis = 2000
|
||||
|
||||
enum AudioEngineError: Error {
|
||||
case audioFormatError
|
||||
}
|
||||
|
||||
init() throws {
|
||||
avAudioEngine.attach(speechPlayer)
|
||||
|
||||
guard let format = AVAudioFormat(standardFormatWithSampleRate: 16000, channels: 1) else {
|
||||
throw AudioEngineError.audioFormatError
|
||||
}
|
||||
voiceIOFormat = format
|
||||
print("Voice IO format: \(String(describing: voiceIOFormat))")
|
||||
|
||||
engineConfigChangeObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVAudioEngineConfigurationChange,
|
||||
object: avAudioEngine,
|
||||
queue: .main) { [weak self] _ in
|
||||
self?.checkEngineIsRunning()
|
||||
}
|
||||
sessionInterruptionObserver = NotificationCenter.default.addObserver(
|
||||
forName: AVAudioSession.interruptionNotification,
|
||||
object: AVAudioSession.sharedInstance(),
|
||||
queue: .main) { [weak self] notification in
|
||||
self?.handleAudioSessionInterruption(notification)
|
||||
}
|
||||
mediaServicesResetObserver = NotificationCenter.default.addObserver(
|
||||
forName: AVAudioSession.mediaServicesWereResetNotification,
|
||||
object: AVAudioSession.sharedInstance(),
|
||||
queue: .main) { [weak self] _ in
|
||||
self?.handleMediaServicesWereReset()
|
||||
}
|
||||
|
||||
self.setupAudioSession()
|
||||
self.setup()
|
||||
self.start()
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let observer = engineConfigChangeObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
if let observer = sessionInterruptionObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
if let observer = mediaServicesResetObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
func setupAudioSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
|
||||
do {
|
||||
try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP])
|
||||
} catch {
|
||||
print("Could not set the audio category: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
do {
|
||||
try session.setPreferredSampleRate(voiceIOFormat.sampleRate)
|
||||
} catch {
|
||||
print("Could not set the preferred sample rate: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
do {
|
||||
try session.setActive(true)
|
||||
} catch {
|
||||
print("Could not set the audio session as active")
|
||||
}
|
||||
}
|
||||
|
||||
func setup() {
|
||||
let input = avAudioEngine.inputNode
|
||||
do {
|
||||
try input.setVoiceProcessingEnabled(true)
|
||||
} catch {
|
||||
print("Could not enable voice processing \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
avAudioEngine.inputNode.isVoiceProcessingInputMuted = !isRecording
|
||||
|
||||
let output = avAudioEngine.outputNode
|
||||
let mainMixer = avAudioEngine.mainMixerNode
|
||||
|
||||
avAudioEngine.connect(speechPlayer, to: mainMixer, format: voiceIOFormat)
|
||||
avAudioEngine.connect(mainMixer, to: output, format: voiceIOFormat)
|
||||
|
||||
input.installTap(onBus: 0, bufferSize: 2048, format: voiceIOFormat) { [weak self] buffer, when in
|
||||
// We don't do any input processing (no volume calculation or passing mic data to the callback) if discardRecording == true
|
||||
// See comment in the playPCMData function
|
||||
if self?.isRecording == true && self?.discardRecording == false {
|
||||
self?.processMicrophoneBuffer(buffer)
|
||||
self?.updateInputVolume()
|
||||
}
|
||||
}
|
||||
|
||||
mainMixer.installTap(onBus: 0, bufferSize: 2048, format: voiceIOFormat) { [weak self] buffer, when in
|
||||
self?.processOutputBuffer(buffer)
|
||||
self?.updateOutputVolume()
|
||||
}
|
||||
|
||||
avAudioEngine.prepare()
|
||||
}
|
||||
|
||||
func processMicrophoneBuffer(_ buffer: AVAudioPCMBuffer) {
|
||||
guard let channelData = buffer.floatChannelData?[0] else {
|
||||
print("Error: Could not access channel data")
|
||||
return
|
||||
}
|
||||
|
||||
let frameCount = Int(buffer.frameLength)
|
||||
var int16Samples = [Int16](repeating: 0, count: frameCount)
|
||||
|
||||
// Convert float samples to Int16 and update input buffer for volume calculation
|
||||
for i in 0..<frameCount {
|
||||
let floatSample = max(-1.0, min(1.0, channelData[i]))
|
||||
int16Samples[i] = Int16(floatSample * Float(Int16.max))
|
||||
|
||||
inputBuffer[inputBufferIndex] = floatSample
|
||||
inputBufferIndex = (inputBufferIndex + 1) % inputBuffer.count
|
||||
}
|
||||
|
||||
// Create Data object from Int16 samples
|
||||
let data = Data(bytes: int16Samples, count: frameCount * MemoryLayout<Int16>.size)
|
||||
|
||||
// Send the data to the callback
|
||||
onMicDataCallback?(data)
|
||||
}
|
||||
|
||||
func processOutputBuffer(_ buffer: AVAudioPCMBuffer) {
|
||||
guard let channelData = buffer.floatChannelData?[0] else {
|
||||
print("Error: Could not access channel data")
|
||||
return
|
||||
}
|
||||
|
||||
let frameCount = Int(buffer.frameLength)
|
||||
|
||||
// Update output buffer for volume calculation
|
||||
for i in 0..<frameCount {
|
||||
let floatSample = max(-1.0, min(1.0, channelData[i]))
|
||||
outputBuffer[outputBufferIndex] = floatSample
|
||||
outputBufferIndex = (outputBufferIndex + 1) % outputBuffer.count
|
||||
}
|
||||
}
|
||||
|
||||
func start() {
|
||||
do {
|
||||
try avAudioEngine.start()
|
||||
} catch {
|
||||
print("Could not start audio engine: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func playPCMData(_ pcmData: Data) {
|
||||
// Looks like we don't get a proper AEC for the very first chunks of audio that we play.
|
||||
// To work around this, we will discard microphone input for the first few milliseconds.
|
||||
// This will give the AEC time to adapt to the playback audio.
|
||||
// We achieve this by setting discardRecording to true for a short time (this doesn't actually mute the input). It's just not processed in the tap.
|
||||
// Audio that is not processed by the input tap is not sent to the callback and therefore not sent to the server.
|
||||
if !hasFirstInputBeenDiscarded {
|
||||
self.hasFirstInputBeenDiscarded = true
|
||||
self.discardRecording = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(discardFirstInputMillis)) {
|
||||
self.discardRecording = false
|
||||
}
|
||||
}
|
||||
|
||||
guard let buffer = createBuffer(from: pcmData) else {
|
||||
print("Failed to create audio buffer")
|
||||
return
|
||||
}
|
||||
speechPlayer.scheduleBuffer(buffer)
|
||||
|
||||
if !speechPlayer.isPlaying {
|
||||
speechPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func createBuffer(from data: Data) -> AVAudioPCMBuffer? {
|
||||
let frameCount = UInt32(data.count) / 2 // 16-bit input = 2 bytes per frame
|
||||
|
||||
let format = AVAudioFormat(commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
interleaved: false)!
|
||||
|
||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
buffer.frameLength = frameCount
|
||||
|
||||
data.withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) in
|
||||
if let sourcePtr = rawBufferPointer.baseAddress?.assumingMemoryBound(to: Int16.self),
|
||||
let destPtr = buffer.floatChannelData?[0] {
|
||||
for i in 0..<Int(frameCount) {
|
||||
destPtr[i] = Float(sourcePtr[i]) / Float(Int16.max)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
func bypassVoiceProcessing(_ bypass: Bool) {
|
||||
let input = avAudioEngine.inputNode
|
||||
input.isVoiceProcessingBypassed = bypass
|
||||
}
|
||||
|
||||
func toggleRecording(_ val: Bool) -> Bool {
|
||||
isRecording = val
|
||||
if !isRecording {
|
||||
avAudioEngine.inputNode.isVoiceProcessingInputMuted = true
|
||||
// Reset input buffer, so that volume levels report 0
|
||||
inputBuffer = [Float](repeating: 0, count: 2048)
|
||||
updateInputVolume()
|
||||
} else {
|
||||
avAudioEngine.inputNode.isVoiceProcessingInputMuted = false
|
||||
}
|
||||
print("Recording \(isRecording ? "started" : "stopped")")
|
||||
|
||||
return isRecording
|
||||
}
|
||||
|
||||
func stopRecordingAndPlayer(){
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(false)
|
||||
} catch {
|
||||
print("Could not set the audio session to inactive: \(error)")
|
||||
}
|
||||
toggleRecording(false)
|
||||
speechPlayer.stop()
|
||||
updateOutputVolume()
|
||||
}
|
||||
|
||||
func resumeRecordingAndPlayer(){
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
print("Could not set the audio session to active: \(error)")
|
||||
}
|
||||
self.checkEngineIsRunning()
|
||||
isRecording = toggleRecording(true)
|
||||
speechPlayer.play()
|
||||
}
|
||||
|
||||
func tearDown() {
|
||||
stopRecordingAndPlayer()
|
||||
avAudioEngine.stop()
|
||||
}
|
||||
|
||||
var isPlaying: Bool {
|
||||
return speechPlayer.isPlaying
|
||||
}
|
||||
|
||||
func stopPlayback() {
|
||||
speechPlayer.stop()
|
||||
// Clear any scheduled buffers
|
||||
outputBuffer = [Float](repeating: 0, count: 2048)
|
||||
updateOutputVolume()
|
||||
print("Playback stopped")
|
||||
}
|
||||
|
||||
func pausePlayback() {
|
||||
speechPlayer.pause()
|
||||
print("Playback paused")
|
||||
}
|
||||
|
||||
func resumePlayback() {
|
||||
if !speechPlayer.isPlaying {
|
||||
speechPlayer.play()
|
||||
print("Playback resumed")
|
||||
}
|
||||
}
|
||||
|
||||
private func checkEngineIsRunning() {
|
||||
if !avAudioEngine.isRunning {
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAudioSessionInterruption(_ notification: Notification) {
|
||||
guard let userInfo = notification.userInfo,
|
||||
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
|
||||
|
||||
switch type {
|
||||
case .began:
|
||||
self.stopRecordingAndPlayer()
|
||||
onAudioInterruptionCallback?("began")
|
||||
case .ended:
|
||||
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
|
||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||
if options.contains(.shouldResume) {
|
||||
// Interruption ended. Resume playback.
|
||||
self.resumeRecordingAndPlayer()
|
||||
onAudioInterruptionCallback?("ended")
|
||||
} else {
|
||||
// Interruption ends. Don't resume playback.
|
||||
onAudioInterruptionCallback?("blocked")
|
||||
}
|
||||
}
|
||||
@unknown default:
|
||||
fatalError("Unknown type: \(type)")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMediaServicesWereReset() {
|
||||
self.avAudioEngine.stop()
|
||||
self.setup()
|
||||
self.start()
|
||||
}
|
||||
|
||||
private func updateInputVolume() {
|
||||
let volume = calculateRMSLevel(from: inputBuffer)
|
||||
onInputVolumeCallback?(volume)
|
||||
}
|
||||
|
||||
private func updateOutputVolume() {
|
||||
let volume = calculateRMSLevel(from: outputBuffer)
|
||||
onOutputVolumeCallback?(volume)
|
||||
}
|
||||
|
||||
private func calculateRMSLevel(from buffer: [Float]) -> Float {
|
||||
let epsilon: Float = 1e-5 // To avoid log(0)
|
||||
let rmsValue = sqrt(buffer.reduce(0) { $0 + $1 * $1 } / Float(buffer.count))
|
||||
|
||||
// Convert to decibels
|
||||
let dbValue = 20 * log10(max(rmsValue, epsilon))
|
||||
|
||||
// Normalize decibel value to 0-1 range
|
||||
// Assuming minimum audible is -60dB and maximum is 0dB
|
||||
let minDb: Float = -80.0
|
||||
let normalizedValue = max(0.0, min(1.0, (dbValue - minDb) / abs(minDb)))
|
||||
|
||||
// Optional: Apply exponential factor to push smaller values down
|
||||
let expFactor: Float = 2.0 // Adjust this value to change the curve
|
||||
let adjustedValue = pow(normalizedValue, expFactor)
|
||||
|
||||
return adjustedValue
|
||||
}
|
||||
}
|
||||
27
packages/expo-two-way-audio/ios/ExpoTwoWayAudio.podspec
Normal file
@@ -0,0 +1,27 @@
|
||||
require 'json'
|
||||
|
||||
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ExpoTwoWayAudio'
|
||||
s.version = package['version']
|
||||
s.summary = package['description']
|
||||
s.description = package['description']
|
||||
s.license = package['license']
|
||||
s.author = package['author']
|
||||
s.homepage = package['homepage']
|
||||
s.platforms = { :ios => '13.4', :tvos => '13.4' }
|
||||
s.swift_version = '5.4'
|
||||
s.source = { git: 'https://github.com/speechmatics/expo-two-way-audio' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,swift}"
|
||||
end
|
||||
211
packages/expo-two-way-audio/ios/ExpoTwoWayAudioModule.swift
Normal file
@@ -0,0 +1,211 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
let ON_MIC_DATA_EVENT_NAME = "onMicrophoneData"
|
||||
let ON_INPUT_VOLUME_LEVEL_EVENT_NAME = "onInputVolumeLevelData"
|
||||
let ON_OUTPUT_VOLUME_LEVEL_EVENT_NAME = "onOutputVolumeLevelData"
|
||||
let ON_RECORDING_CHANGE_EVENT_NAME = "onRecordingChange"
|
||||
let ON_AUDIO_INTERRUPTION_EVENT_NAME = "onAudioInterruption"
|
||||
|
||||
public class ExpoTwoWayAudioModule: Module {
|
||||
private var audioEngine: AudioEngine?
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoTwoWayAudio")
|
||||
|
||||
OnCreate {
|
||||
let permissionsManager = self.appContext?.permissions
|
||||
EXPermissionsMethodsDelegate.register(
|
||||
[
|
||||
MicrophonePermissionRequester()
|
||||
],
|
||||
withPermissionsManager: permissionsManager
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
AsyncFunction("initialize") { () -> Bool in
|
||||
do {
|
||||
if self.audioEngine != nil {
|
||||
return true
|
||||
}
|
||||
self.audioEngine = try AudioEngine()
|
||||
self.setupMicrophoneCallback()
|
||||
self.setupInputAudioLevelCallback()
|
||||
self.setupOutputAudioLevelCallback()
|
||||
self.setupAudioInterruptionCallback()
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to initialize AudioEngine: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Function("isRecording") { () -> Bool in
|
||||
guard let audioEngine = self.audioEngine else {
|
||||
print("AudioEngine not initialized")
|
||||
return false
|
||||
}
|
||||
return audioEngine.isRecording
|
||||
}
|
||||
|
||||
Function("toggleRecording") { (val: Bool) -> Bool in
|
||||
guard let audioEngine = self.audioEngine else {
|
||||
print("AudioEngine not initialized")
|
||||
return false
|
||||
}
|
||||
let isRecording = audioEngine.toggleRecording(val)
|
||||
self.sendEvent(
|
||||
ON_RECORDING_CHANGE_EVENT_NAME,
|
||||
[
|
||||
"data": isRecording
|
||||
])
|
||||
return isRecording
|
||||
}
|
||||
|
||||
Function("getMicrophoneModeIOS") { () -> String in
|
||||
if #available(iOS 15.0, *) {
|
||||
let mode = AVCaptureDevice.preferredMicrophoneMode.rawValue
|
||||
var micMode = ""
|
||||
|
||||
switch mode {
|
||||
case 1:
|
||||
micMode = MicrophoneMode.wideSpectrum.rawValue
|
||||
case 2:
|
||||
micMode = MicrophoneMode.voiceIsolation.rawValue
|
||||
default:
|
||||
micMode = MicrophoneMode.standard.rawValue
|
||||
}
|
||||
return micMode
|
||||
}
|
||||
print("Please update your ios")
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
Function("setMicrophoneModeIOS") {
|
||||
if #available(iOS 15, *) {
|
||||
if AVCaptureDevice.preferredMicrophoneMode != .voiceIsolation {
|
||||
AVCaptureDevice.showSystemUserInterface(.microphoneModes)
|
||||
}
|
||||
} else {
|
||||
print("This code only runs on iOS 14 and lower")
|
||||
}
|
||||
}
|
||||
|
||||
Function("tearDown") {
|
||||
self.audioEngine?.tearDown()
|
||||
self.audioEngine = nil
|
||||
}
|
||||
|
||||
Function("restart") {
|
||||
self.audioEngine?.resumeRecordingAndPlayer()
|
||||
self.sendEvent(
|
||||
ON_RECORDING_CHANGE_EVENT_NAME,
|
||||
[
|
||||
"data": audioEngine?.isRecording
|
||||
])
|
||||
|
||||
}
|
||||
|
||||
Function("playPCMData") { (pcmData: Data) in
|
||||
self.audioEngine?.playPCMData(pcmData)
|
||||
}
|
||||
|
||||
Function("bypassVoiceProcessing") { (bypass: Bool) in
|
||||
self.audioEngine?.bypassVoiceProcessing(bypass)
|
||||
}
|
||||
|
||||
Function("isPlaying") { () -> Bool in
|
||||
return self.audioEngine?.isPlaying ?? false
|
||||
}
|
||||
|
||||
Function("stopPlayback") {
|
||||
self.audioEngine?.stopPlayback()
|
||||
}
|
||||
|
||||
Function("pausePlayback") {
|
||||
self.audioEngine?.pausePlayback()
|
||||
}
|
||||
|
||||
Function("resumePlayback") {
|
||||
self.audioEngine?.resumePlayback()
|
||||
}
|
||||
|
||||
AsyncFunction("getMicrophonePermissionsAsync") { (promise: Promise) in
|
||||
EXPermissionsMethodsDelegate.getPermissionWithPermissionsManager(
|
||||
self.appContext?.permissions,
|
||||
withRequester: MicrophonePermissionRequester.self,
|
||||
resolve: promise.resolver,
|
||||
reject: promise.legacyRejecter
|
||||
)
|
||||
}
|
||||
|
||||
AsyncFunction("requestMicrophonePermissionsAsync") { (promise: Promise) in
|
||||
EXPermissionsMethodsDelegate.askForPermission(
|
||||
withPermissionsManager: self.appContext?.permissions,
|
||||
withRequester: MicrophonePermissionRequester.self,
|
||||
resolve: promise.resolver,
|
||||
reject: promise.legacyRejecter
|
||||
)
|
||||
}
|
||||
|
||||
// Define the events that can be emitted
|
||||
Events([
|
||||
ON_MIC_DATA_EVENT_NAME,
|
||||
ON_INPUT_VOLUME_LEVEL_EVENT_NAME,
|
||||
ON_OUTPUT_VOLUME_LEVEL_EVENT_NAME,
|
||||
ON_RECORDING_CHANGE_EVENT_NAME,
|
||||
ON_AUDIO_INTERRUPTION_EVENT_NAME,
|
||||
])
|
||||
}
|
||||
|
||||
private func setupMicrophoneCallback() {
|
||||
audioEngine?.onMicDataCallback = { [weak self] data in
|
||||
self?.sendEvent(
|
||||
ON_MIC_DATA_EVENT_NAME,
|
||||
[
|
||||
"data": data
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private func setupInputAudioLevelCallback() {
|
||||
audioEngine?.onInputVolumeCallback = { [weak self] level in
|
||||
self?.sendEvent(
|
||||
ON_INPUT_VOLUME_LEVEL_EVENT_NAME,
|
||||
[
|
||||
"data": level
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private func setupOutputAudioLevelCallback() {
|
||||
audioEngine?.onOutputVolumeCallback = { [weak self] level in
|
||||
self?.sendEvent(
|
||||
ON_OUTPUT_VOLUME_LEVEL_EVENT_NAME,
|
||||
[
|
||||
"data": level
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private func setupAudioInterruptionCallback() {
|
||||
audioEngine?.onAudioInterruptionCallback = { [weak self] data in
|
||||
self?.sendEvent(
|
||||
ON_AUDIO_INTERRUPTION_EVENT_NAME,
|
||||
[
|
||||
"data": data
|
||||
])
|
||||
self?.sendEvent(
|
||||
ON_RECORDING_CHANGE_EVENT_NAME,
|
||||
[
|
||||
"data": self?.audioEngine?.isRecording
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
enum MicrophoneMode: String, Enumerable {
|
||||
case standard
|
||||
case voiceIsolation
|
||||
case wideSpectrum
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import ExpoModulesCore
|
||||
import AVFoundation
|
||||
|
||||
let microphoneKey = "NSMicrophoneUsageDescription"
|
||||
|
||||
class MicrophonePermissionRequester: NSObject, EXPermissionsRequester {
|
||||
let mediaType: AVMediaType = .audio
|
||||
|
||||
static func permissionType() -> String {
|
||||
"microphone"
|
||||
}
|
||||
|
||||
func getPermissions() -> [AnyHashable: Any] {
|
||||
let systemStatus = AVAudioSession.sharedInstance().recordPermission
|
||||
var status: EXPermissionStatus
|
||||
|
||||
guard (Bundle.main.infoDictionary?["NSMicrophoneUsageDescription"]) != nil else {
|
||||
EXFatal(EXErrorWithMessage("""
|
||||
This app is missing NSMicrophoneUsageDescription, so audio services will fail.
|
||||
Add one of these keys to your bundle's Info.plist.
|
||||
"""))
|
||||
return ["status": EXPermissionStatusDenied]
|
||||
}
|
||||
|
||||
switch systemStatus {
|
||||
case .granted:
|
||||
status = EXPermissionStatusGranted
|
||||
case .denied:
|
||||
status = EXPermissionStatusDenied
|
||||
case .undetermined:
|
||||
status = EXPermissionStatusUndetermined
|
||||
@unknown default:
|
||||
status = EXPermissionStatusUndetermined
|
||||
}
|
||||
|
||||
return [
|
||||
"status": status.rawValue
|
||||
]
|
||||
}
|
||||
|
||||
func requestPermissions(resolver resolve: @escaping EXPromiseResolveBlock, rejecter reject: EXPromiseRejectBlock) {
|
||||
if #available(iOS 17.0, *) {
|
||||
AVAudioApplication.requestRecordPermission { _ in
|
||||
resolve(self.getPermissions())
|
||||
}
|
||||
} else {
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { _ in
|
||||
resolve(self.getPermissions())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17569
packages/expo-two-way-audio/package-lock.json
generated
Normal file
43
packages/expo-two-way-audio/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.4",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "expo-module build",
|
||||
"clean": "expo-module clean",
|
||||
"lint": "expo-module lint",
|
||||
"test": "expo-module test",
|
||||
"format": "biome check .",
|
||||
"format:fix": "biome check --write .",
|
||||
"typecheck": "tsc",
|
||||
"expo-module": "expo-module",
|
||||
"open:ios": "xed examples/basic-usage/ios",
|
||||
"open:android": "open -a \"Android Studio\" examples/basic-usage/android"
|
||||
},
|
||||
"keywords": [
|
||||
"react-native",
|
||||
"expo",
|
||||
"expo-two-way-audio",
|
||||
"ExpoTwoWayAudio"
|
||||
],
|
||||
"repository": "https://github.com/boudra/expo-two-way-audio",
|
||||
"bugs": {
|
||||
"url": "https://github.com/speechmatics/expo-two-way-audio/issues"
|
||||
},
|
||||
"author": "Speechmatics <devrel@speechmatics.com> (https://github.com/speechmatics)",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/boudra/expo-two-way-audio",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@types/react": "^18.0.25",
|
||||
"expo-module-scripts": "^3.5.2",
|
||||
"expo-modules-core": "^2.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
}
|
||||
5
packages/expo-two-way-audio/src/ExpoTwoWayAudioModule.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
|
||||
// It loads the native module object from the JSI or falls back to
|
||||
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
|
||||
export default requireNativeModule("ExpoTwoWayAudio");
|
||||
62
packages/expo-two-way-audio/src/core.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { type PermissionResponse, createPermissionHook } from "expo-modules-core";
|
||||
import ExpoTwoWayAudioModule from "./ExpoTwoWayAudioModule";
|
||||
|
||||
export async function initialize() {
|
||||
return await ExpoTwoWayAudioModule.initialize();
|
||||
}
|
||||
|
||||
export function playPCMData(audioData: Uint8Array) {
|
||||
return ExpoTwoWayAudioModule.playPCMData(audioData);
|
||||
}
|
||||
|
||||
export function bypassVoiceProcessing(bypass: boolean) {
|
||||
return ExpoTwoWayAudioModule.bypassVoiceProcessing(bypass);
|
||||
}
|
||||
|
||||
export function toggleRecording(val: boolean): boolean {
|
||||
return ExpoTwoWayAudioModule.toggleRecording(val);
|
||||
}
|
||||
|
||||
export function isRecording(): boolean {
|
||||
return ExpoTwoWayAudioModule.isRecording();
|
||||
}
|
||||
|
||||
export function tearDown() {
|
||||
return ExpoTwoWayAudioModule.tearDown();
|
||||
}
|
||||
|
||||
export function restart() {
|
||||
return ExpoTwoWayAudioModule.restart();
|
||||
}
|
||||
|
||||
export async function getMicrophonePermissionsAsync(): Promise<PermissionResponse> {
|
||||
return ExpoTwoWayAudioModule.getMicrophonePermissionsAsync();
|
||||
}
|
||||
|
||||
export async function requestMicrophonePermissionsAsync(): Promise<PermissionResponse> {
|
||||
return ExpoTwoWayAudioModule.requestMicrophonePermissionsAsync();
|
||||
}
|
||||
|
||||
export function getMicrophoneModeIOS() {
|
||||
return ExpoTwoWayAudioModule.getMicrophoneModeIOS();
|
||||
}
|
||||
|
||||
export function setMicrophoneModeIOS() {
|
||||
return ExpoTwoWayAudioModule.setMicrophoneModeIOS();
|
||||
}
|
||||
|
||||
export function isPlaying(): boolean {
|
||||
return ExpoTwoWayAudioModule.isPlaying();
|
||||
}
|
||||
|
||||
export function stopPlayback() {
|
||||
return ExpoTwoWayAudioModule.stopPlayback();
|
||||
}
|
||||
|
||||
export function pausePlayback() {
|
||||
return ExpoTwoWayAudioModule.pausePlayback();
|
||||
}
|
||||
|
||||
export function resumePlayback() {
|
||||
return ExpoTwoWayAudioModule.resumePlayback();
|
||||
}
|
||||
38
packages/expo-two-way-audio/src/events.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import ExpoTwoWayAudioModule from "./ExpoTwoWayAudioModule";
|
||||
|
||||
export type MicrophoneDataEvent = {
|
||||
data: Uint8Array;
|
||||
};
|
||||
|
||||
export type VolumeLevelEvent = {
|
||||
data: number;
|
||||
};
|
||||
|
||||
export type RecordingChangeEvent = {
|
||||
data: boolean;
|
||||
};
|
||||
|
||||
export type AudioInterruptionEvent = {
|
||||
data: string;
|
||||
};
|
||||
|
||||
export interface ExpoTwoWayAudioEventMap {
|
||||
onMicrophoneData: MicrophoneDataEvent;
|
||||
onInputVolumeLevelData: VolumeLevelEvent;
|
||||
onOutputVolumeLevelData: VolumeLevelEvent;
|
||||
onRecordingChange: RecordingChangeEvent;
|
||||
onAudioInterruption: AudioInterruptionEvent;
|
||||
}
|
||||
|
||||
// These are useful for defining `useCallback` types inline
|
||||
export type MicrophoneDataCallback = (event: MicrophoneDataEvent) => void;
|
||||
export type VolumeLevelCallback = (event: VolumeLevelEvent) => void;
|
||||
export type RecordingChangeCallback = (event: RecordingChangeEvent) => void;
|
||||
export type AudioInterruptionCallback = (event: AudioInterruptionEvent) => void;
|
||||
|
||||
export function addExpoTwoWayAudioEventListener<K extends keyof ExpoTwoWayAudioEventMap>(
|
||||
eventName: K,
|
||||
handler: (ev: ExpoTwoWayAudioEventMap[K]) => void,
|
||||
) {
|
||||
return ExpoTwoWayAudioModule.addListener(eventName, handler);
|
||||
}
|
||||
34
packages/expo-two-way-audio/src/hooks.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createPermissionHook } from "expo-modules-core";
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import {
|
||||
getMicrophonePermissionsAsync,
|
||||
isRecording,
|
||||
requestMicrophonePermissionsAsync,
|
||||
} from "./core";
|
||||
import { ExpoTwoWayAudioEventMap, addExpoTwoWayAudioEventListener } from "./events";
|
||||
|
||||
export const useMicrophonePermissions = createPermissionHook({
|
||||
getMethod: getMicrophonePermissionsAsync,
|
||||
requestMethod: requestMicrophonePermissionsAsync,
|
||||
});
|
||||
|
||||
export function useIsRecording() {
|
||||
const subscribe = (cb: () => void) => {
|
||||
const sub = addExpoTwoWayAudioEventListener("onRecordingChange", cb);
|
||||
return () => sub.remove();
|
||||
};
|
||||
const getSnapshot = () => isRecording();
|
||||
const getServerSnapshot = () => false;
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
export function useExpoTwoWayAudioEventListener<K extends keyof ExpoTwoWayAudioEventMap>(
|
||||
eventName: K,
|
||||
listener: (ev: ExpoTwoWayAudioEventMap[K]) => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const sub = addExpoTwoWayAudioEventListener(eventName, listener);
|
||||
return () => sub.remove();
|
||||
}, [listener]);
|
||||
}
|
||||
3
packages/expo-two-way-audio/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./core";
|
||||
export * from "./events";
|
||||
export * from "./hooks";
|
||||
9
packages/expo-two-way-audio/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
// @generated by expo-module-scripts
|
||||
{
|
||||
"extends": "expo-module-scripts/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"outDir": "./build"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"exclude": ["**/__mocks__/*", "**/__tests__/*"]
|
||||
}
|
||||
@@ -2130,7 +2130,12 @@ export class Session {
|
||||
agentId?: string,
|
||||
requestId?: string
|
||||
): Promise<void> {
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
this.sessionLogger.info(
|
||||
{ enabled, requestedAgentId: agentId ?? null, requestId: requestId ?? null },
|
||||
'set_voice_mode started'
|
||||
)
|
||||
if (enabled) {
|
||||
const unavailable = this.resolveVoiceFeatureUnavailableContext('voice_mode')
|
||||
if (unavailable) {
|
||||
@@ -2144,19 +2149,44 @@ export class Session {
|
||||
this.voiceModeAgentId &&
|
||||
this.voiceModeAgentId !== normalizedAgentId
|
||||
) {
|
||||
this.sessionLogger.info(
|
||||
{
|
||||
previousAgentId: this.voiceModeAgentId,
|
||||
nextAgentId: normalizedAgentId,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
},
|
||||
'set_voice_mode disabling previous active voice agent'
|
||||
)
|
||||
await this.disableVoiceModeForActiveAgent(true)
|
||||
}
|
||||
|
||||
if (!this.isVoiceMode || this.voiceModeAgentId !== normalizedAgentId) {
|
||||
this.sessionLogger.info(
|
||||
{ agentId: normalizedAgentId, elapsedMs: Date.now() - startedAt },
|
||||
'set_voice_mode enabling voice for agent'
|
||||
)
|
||||
const refreshedAgentId = await this.enableVoiceModeForAgent(normalizedAgentId)
|
||||
this.voiceModeAgentId = refreshedAgentId
|
||||
this.sessionLogger.info(
|
||||
{ agentId: refreshedAgentId, elapsedMs: Date.now() - startedAt },
|
||||
'set_voice_mode agent enable complete'
|
||||
)
|
||||
}
|
||||
|
||||
this.sessionLogger.info(
|
||||
{ agentId: this.voiceModeAgentId, elapsedMs: Date.now() - startedAt },
|
||||
'set_voice_mode starting voice turn controller'
|
||||
)
|
||||
await this.startVoiceTurnController()
|
||||
this.sessionLogger.info(
|
||||
{ agentId: this.voiceModeAgentId, elapsedMs: Date.now() - startedAt },
|
||||
'set_voice_mode voice turn controller started'
|
||||
)
|
||||
this.isVoiceMode = true
|
||||
this.sessionLogger.info(
|
||||
{
|
||||
agentId: this.voiceModeAgentId,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
},
|
||||
'Voice mode enabled for existing agent'
|
||||
)
|
||||
@@ -2175,9 +2205,13 @@ export class Session {
|
||||
return
|
||||
}
|
||||
|
||||
this.sessionLogger.info(
|
||||
{ agentId: this.voiceModeAgentId, elapsedMs: Date.now() - startedAt },
|
||||
'set_voice_mode disabling active voice mode'
|
||||
)
|
||||
await this.disableVoiceModeForActiveAgent(true)
|
||||
this.isVoiceMode = false
|
||||
this.sessionLogger.info('Voice mode disabled')
|
||||
this.sessionLogger.info({ elapsedMs: Date.now() - startedAt }, 'Voice mode disabled')
|
||||
if (requestId) {
|
||||
this.emit({
|
||||
type: 'set_voice_mode_response',
|
||||
@@ -2198,6 +2232,7 @@ export class Session {
|
||||
err: error,
|
||||
enabled,
|
||||
requestedAgentId: agentId ?? null,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
},
|
||||
'set_voice_mode failed'
|
||||
)
|
||||
@@ -2256,14 +2291,25 @@ export class Session {
|
||||
}
|
||||
|
||||
private async enableVoiceModeForAgent(agentId: string): Promise<string> {
|
||||
const startedAt = Date.now()
|
||||
const ensureVoiceSocket = this.ensureVoiceMcpSocketForAgent
|
||||
if (!ensureVoiceSocket) {
|
||||
throw new Error('Voice MCP socket bridge is not configured')
|
||||
}
|
||||
|
||||
this.sessionLogger.info({ agentId }, 'enableVoiceModeForAgent.ensureAgentLoaded.start')
|
||||
const existing = await this.ensureAgentLoaded(agentId)
|
||||
this.sessionLogger.info(
|
||||
{ agentId, elapsedMs: Date.now() - startedAt },
|
||||
'enableVoiceModeForAgent.ensureAgentLoaded.done'
|
||||
)
|
||||
|
||||
this.sessionLogger.info({ agentId }, 'enableVoiceModeForAgent.ensureVoiceSocket.start')
|
||||
const socketPath = await ensureVoiceSocket(agentId)
|
||||
this.sessionLogger.info(
|
||||
{ agentId, socketPath, elapsedMs: Date.now() - startedAt },
|
||||
'enableVoiceModeForAgent.ensureVoiceSocket.done'
|
||||
)
|
||||
this.registerVoiceBridgeForAgent(agentId)
|
||||
|
||||
const baseConfig: VoiceModeBaseConfig = {
|
||||
@@ -2277,7 +2323,15 @@ export class Session {
|
||||
}
|
||||
|
||||
try {
|
||||
this.sessionLogger.info(
|
||||
{ agentId, elapsedMs: Date.now() - startedAt },
|
||||
'enableVoiceModeForAgent.reloadAgentSession.start'
|
||||
)
|
||||
const refreshed = await this.agentManager.reloadAgentSession(agentId, refreshOverrides)
|
||||
this.sessionLogger.info(
|
||||
{ agentId, refreshedAgentId: refreshed.id, elapsedMs: Date.now() - startedAt },
|
||||
'enableVoiceModeForAgent.reloadAgentSession.done'
|
||||
)
|
||||
return refreshed.id
|
||||
} catch (error) {
|
||||
this.unregisterVoiceSpeakHandler?.(agentId)
|
||||
@@ -2331,6 +2385,7 @@ export class Session {
|
||||
|
||||
private async startVoiceTurnController(): Promise<void> {
|
||||
if (this.voiceTurnController) {
|
||||
this.sessionLogger.info('startVoiceTurnController skipped: already running')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2339,6 +2394,11 @@ export class Session {
|
||||
throw new Error('Voice turn detection is not configured')
|
||||
}
|
||||
|
||||
this.sessionLogger.info(
|
||||
{ providerId: turnDetection.id },
|
||||
'startVoiceTurnController creating controller'
|
||||
)
|
||||
|
||||
const controller = createVoiceTurnController({
|
||||
logger: this.sessionLogger.child({ component: 'voice-turn-controller' }),
|
||||
turnDetection,
|
||||
@@ -2381,8 +2441,10 @@ export class Session {
|
||||
},
|
||||
})
|
||||
|
||||
this.sessionLogger.info('startVoiceTurnController connecting controller')
|
||||
await controller.start()
|
||||
this.voiceTurnController = controller
|
||||
this.sessionLogger.info('startVoiceTurnController connected')
|
||||
}
|
||||
|
||||
private async stopVoiceTurnController(): Promise<void> {
|
||||
@@ -6368,12 +6430,9 @@ export class Session {
|
||||
}
|
||||
|
||||
private async processCompletedAudio(audio: Buffer, format: string): Promise<void> {
|
||||
const shouldBuffer =
|
||||
this.processingPhase === 'transcribing' && this.pendingAudioSegments.length === 0
|
||||
|
||||
if (shouldBuffer) {
|
||||
if (this.processingPhase === 'transcribing') {
|
||||
this.sessionLogger.debug(
|
||||
{ phase: this.processingPhase },
|
||||
{ phase: this.processingPhase, segmentCount: this.pendingAudioSegments.length + 1 },
|
||||
`Buffering audio segment (phase: ${this.processingPhase})`
|
||||
)
|
||||
this.pendingAudioSegments.push({
|
||||
@@ -6408,6 +6467,26 @@ export class Session {
|
||||
await this.processAudio(audio, format)
|
||||
}
|
||||
|
||||
private async flushPendingAudioSegments(reason: string): Promise<void> {
|
||||
if (this.processingPhase === 'transcribing' || this.pendingAudioSegments.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const pendingSegments = [...this.pendingAudioSegments]
|
||||
this.pendingAudioSegments = []
|
||||
this.clearBufferTimeout()
|
||||
|
||||
this.sessionLogger.debug(
|
||||
{ reason, segmentCount: pendingSegments.length },
|
||||
`Flushing ${pendingSegments.length} buffered audio segment(s)`
|
||||
)
|
||||
|
||||
const combinedAudio = Buffer.concat(pendingSegments.map((segment) => segment.audio))
|
||||
const combinedFormat = pendingSegments[pendingSegments.length - 1]!.format
|
||||
|
||||
await this.processAudio(combinedAudio, combinedFormat)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process audio through STT and then LLM
|
||||
*/
|
||||
@@ -6456,6 +6535,7 @@ export class Session {
|
||||
} catch (error: any) {
|
||||
this.setPhase('idle')
|
||||
this.clearSpeechInProgress('transcription error')
|
||||
await this.flushPendingAudioSegments('transcription error')
|
||||
this.emit({
|
||||
type: 'activity_log',
|
||||
payload: {
|
||||
@@ -6495,6 +6575,7 @@ export class Session {
|
||||
this.sessionLogger.debug('Empty transcription (false positive), not aborting')
|
||||
this.setPhase('idle')
|
||||
this.clearSpeechInProgress('empty transcription')
|
||||
await this.flushPendingAudioSegments('empty transcription')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6539,6 +6620,7 @@ export class Session {
|
||||
{ requestId: result.requestId },
|
||||
'Skipping voice agent processing because voice mode is disabled'
|
||||
)
|
||||
await this.flushPendingAudioSegments('voice mode disabled')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6548,12 +6630,14 @@ export class Session {
|
||||
{ requestId: result.requestId },
|
||||
'Skipping voice agent processing because no agent is currently voice-enabled'
|
||||
)
|
||||
await this.flushPendingAudioSegments('no active voice agent')
|
||||
return
|
||||
}
|
||||
|
||||
// Route voice utterances through the same send path as regular text input:
|
||||
// interrupt-if-running, record message, then start a new stream.
|
||||
await this.handleSendAgentMessage(agentId, result.text)
|
||||
await this.flushPendingAudioSegments('transcription complete')
|
||||
}
|
||||
|
||||
private registerVoiceBridgeForAgent(agentId: string): void {
|
||||
@@ -6723,6 +6807,15 @@ export class Session {
|
||||
this.bufferTimeout = setTimeout(async () => {
|
||||
this.sessionLogger.debug('Buffer timeout reached, processing pending segments')
|
||||
|
||||
if (this.processingPhase === 'transcribing') {
|
||||
this.sessionLogger.debug(
|
||||
{ segmentCount: this.pendingAudioSegments.length },
|
||||
'Buffer timeout deferred because transcription is still in progress'
|
||||
)
|
||||
this.setBufferTimeout()
|
||||
return
|
||||
}
|
||||
|
||||
if (this.pendingAudioSegments.length > 0) {
|
||||
const segments = [...this.pendingAudioSegments]
|
||||
this.pendingAudioSegments = []
|
||||
|
||||
@@ -278,4 +278,5 @@ describe("voice turn controller", () => {
|
||||
deferred.resolve();
|
||||
await settleSerialQueue();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -117,6 +117,14 @@ export function createVoiceTurnController(params: {
|
||||
rollingPrefixBytes: prefixBuffer.byteLength,
|
||||
utteranceBytes: prefix.length,
|
||||
};
|
||||
params.logger.info(
|
||||
{
|
||||
utteranceId: state.utteranceId,
|
||||
prefixBytes: prefix.length,
|
||||
rollingPrefixBytes: prefixBuffer.byteLength,
|
||||
},
|
||||
"voice_turn.speech_started"
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSpeechStopped(): Promise<void> {
|
||||
@@ -133,6 +141,14 @@ export function createVoiceTurnController(params: {
|
||||
|
||||
await params.callbacks.onSpeechStopped();
|
||||
|
||||
params.logger.info(
|
||||
{
|
||||
utteranceBytes: utterance.length,
|
||||
utteranceAgeMs: Math.max(0, endedAt - startedAt),
|
||||
},
|
||||
"voice_turn.speech_stopped"
|
||||
);
|
||||
|
||||
if (utterance.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||