feat: add expo-two-way-audio module to workspace
This commit is contained in:
@@ -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
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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": "../.."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user