Merge pull request #256 from benxu3/react-native-app
Add React Native App Websocket Audio
|
@ -169,3 +169,7 @@ cython_debug/
|
|||
_.aifs
|
||||
software/output_audio.wav
|
||||
.DS_Store
|
||||
|
||||
# ignore node modules and .expo files
|
||||
node_modules/
|
||||
.expo/
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
# iOS/Android Client
|
||||
|
||||
[WORK IN PROGRESS]
|
||||
|
||||
This repository contains the source code for the 01 iOS/Android app. Work in progress, we will continue to improve this application to get it working properly.
|
||||
|
||||
Feel free to improve this and make a pull request!
|
||||
|
||||
If you want to run it on your own, you will need expo.
|
||||
|
||||
1. Install dependencies `npm install`
|
||||
2. Run the app `npx expo start`
|
||||
3. Open the app in your simulator or on your device with the expo app by scanning the QR code
|
|
@ -1,22 +0,0 @@
|
|||
import * as React from "react";
|
||||
import { NavigationContainer } from "@react-navigation/native";
|
||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||
import HomeScreen from "./src/screens/HomeScreen";
|
||||
import CameraScreen from "./src/screens/Camera";
|
||||
import Main from "./src/screens/Main";
|
||||
|
||||
const Stack = createNativeStackNavigator();
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<Stack.Navigator initialRouteName="Home">
|
||||
<Stack.Screen name="Home" component={HomeScreen} />
|
||||
<Stack.Screen name="Camera" component={CameraScreen} />
|
||||
<Stack.Screen name="Main" component={Main} />
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
|
@ -1,171 +0,0 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
|
||||
import { Audio } from "expo-av";
|
||||
|
||||
interface MainProps {
|
||||
route: {
|
||||
params: {
|
||||
scannedData: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const Main: React.FC<MainProps> = ({ route }) => {
|
||||
const { scannedData } = route.params;
|
||||
|
||||
const [connectionStatus, setConnectionStatus] =
|
||||
useState<string>("Connecting...");
|
||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||
const [recording, setRecording] = useState<Audio.Recording | null>(null);
|
||||
const [audioQueue, setAudioQueue] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const playNextAudio = async () => {
|
||||
if (audioQueue.length > 0) {
|
||||
const uri = audioQueue.shift();
|
||||
const { sound } = await Audio.Sound.createAsync(
|
||||
{ uri: uri! },
|
||||
{ shouldPlay: true }
|
||||
);
|
||||
sound.setOnPlaybackStatusUpdate(async (status) => {
|
||||
if (status.didJustFinish && !status.isLooping) {
|
||||
await sound.unloadAsync();
|
||||
playNextAudio();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let websocket: WebSocket;
|
||||
try {
|
||||
console.log("Connecting to WebSocket at " + scannedData);
|
||||
websocket = new WebSocket(scannedData);
|
||||
|
||||
websocket.onopen = () => {
|
||||
setConnectionStatus(`Connected to ${scannedData}`);
|
||||
console.log("WebSocket connected");
|
||||
};
|
||||
websocket.onmessage = async (e) => {
|
||||
console.log("Received message: ", e.data);
|
||||
setAudioQueue((prevQueue) => [...prevQueue, e.data]);
|
||||
if (audioQueue.length === 1) {
|
||||
playNextAudio();
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
setConnectionStatus("Error connecting to WebSocket.");
|
||||
console.error("WebSocket error: ", error);
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
setConnectionStatus("Disconnected.");
|
||||
console.log("WebSocket disconnected");
|
||||
};
|
||||
|
||||
setWs(websocket);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
setConnectionStatus("Error creating WebSocket.");
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (websocket) {
|
||||
websocket.close();
|
||||
}
|
||||
};
|
||||
}, [scannedData, audioQueue]);
|
||||
|
||||
const startRecording = async () => {
|
||||
if (recording) {
|
||||
console.log("A recording is already in progress.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Requesting permissions..");
|
||||
await Audio.requestPermissionsAsync();
|
||||
await Audio.setAudioModeAsync({
|
||||
allowsRecordingIOS: true,
|
||||
playsInSilentModeIOS: true,
|
||||
});
|
||||
console.log("Starting recording..");
|
||||
const { recording: newRecording } = await Audio.Recording.createAsync(
|
||||
Audio.RECORDING_OPTIONS_PRESET_HIGH_QUALITY
|
||||
);
|
||||
setRecording(newRecording);
|
||||
console.log("Recording started");
|
||||
} catch (err) {
|
||||
console.error("Failed to start recording", err);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = async () => {
|
||||
console.log("Stopping recording..");
|
||||
setRecording(null);
|
||||
if (recording) {
|
||||
await recording.stopAndUnloadAsync();
|
||||
const uri = recording.getURI();
|
||||
console.log("Recording stopped and stored at", uri);
|
||||
if (ws && uri) {
|
||||
ws.send(uri);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusText,
|
||||
{ color: connectionStatus.startsWith("Connected") ? "green" : "red" },
|
||||
]}
|
||||
>
|
||||
{connectionStatus}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPressIn={startRecording}
|
||||
onPressOut={stopRecording}
|
||||
>
|
||||
<View style={styles.circle}>
|
||||
<Text style={styles.buttonText}>Record</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
circle: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: "black",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
button: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
buttonText: {
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
},
|
||||
statusText: {
|
||||
marginBottom: 20,
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export default Main;
|
|
@ -0,0 +1,32 @@
|
|||
# iOS/Android Client
|
||||
|
||||
**_WORK IN PROGRESS_**
|
||||
|
||||
This repository contains the source code for the 01 iOS/Android app. Work in progress, we will continue to improve this application to get it working properly.
|
||||
|
||||
Feel free to improve this and make a pull request!
|
||||
|
||||
If you want to run it on your own, you will need to install Expo Go on your mobile device.
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
Follow the **[software setup steps](https://github.com/OpenInterpreter/01?tab=readme-ov-file#software)** in the main repo's README first before you read this
|
||||
|
||||
```shell
|
||||
cd software/source/clients/mobile/react-native # cd into `react-native`
|
||||
npm install # install dependencies
|
||||
npx expo start # start local development server
|
||||
```
|
||||
|
||||
In **Expo Go** select _Scan QR code_ to scan the QR code produced by the `npx expo start` command
|
||||
|
||||
## Using the App
|
||||
|
||||
```shell
|
||||
cd software # cd into `software`
|
||||
poetry run 01 --mobile # exposes QR code for 01 Light server
|
||||
```
|
||||
|
||||
In the app, select _Scan Code_ to scan the QR code produced by the `poetry run 01 --mobile` command
|
||||
|
||||
Press and hold the button to speak, release to make the request. To rescan the QR code, swipe left on the screen to go back.
|
|
@ -0,0 +1,31 @@
|
|||
import * as React from "react";
|
||||
import { NavigationContainer } from "@react-navigation/native";
|
||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||
import HomeScreen from "./src/screens/HomeScreen";
|
||||
import CameraScreen from "./src/screens/Camera";
|
||||
import Main from "./src/screens/Main";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
|
||||
const Stack = createNativeStackNavigator();
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="light" />
|
||||
<NavigationContainer>
|
||||
<Stack.Navigator
|
||||
initialRouteName="Home"
|
||||
screenOptions={{
|
||||
headerShown: false, // This hides the navigation bar globally
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="Home" component={HomeScreen} />
|
||||
<Stack.Screen name="Camera" component={CameraScreen} />
|
||||
<Stack.Screen name="Main" component={Main} />
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
@ -14,15 +14,22 @@
|
|||
"expo-av": "~13.10.5",
|
||||
"expo-barcode-scanner": "~12.9.3",
|
||||
"expo-camera": "~14.0.5",
|
||||
"expo-haptics": "~12.8.1",
|
||||
"expo-permissions": "^14.4.0",
|
||||
"expo-status-bar": "~1.11.1",
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.73.4",
|
||||
"react-native-base64": "^0.2.1",
|
||||
"react-native-polyfill-globals": "^3.1.0",
|
||||
"react-native-safe-area-context": "4.8.2",
|
||||
"react-native-screens": "~3.29.0"
|
||||
"react-native-screens": "~3.29.0",
|
||||
"text-encoding": "^0.7.0",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~18.2.45",
|
||||
"@types/react-native-base64": "^0.2.2",
|
||||
"typescript": "^5.1.3"
|
||||
}
|
||||
},
|
||||
|
@ -6089,24 +6096,30 @@
|
|||
"version": "15.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
|
||||
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.2.63",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.63.tgz",
|
||||
"integrity": "sha512-ppaqODhs15PYL2nGUOaOu2RSCCB4Difu4UFrP4I3NHLloXC/ESQzQMi9nvjfT1+rudd0d2L3fQPJxRSey+rGlQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"@types/scheduler": "*",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-native-base64": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-native-base64/-/react-native-base64-0.2.2.tgz",
|
||||
"integrity": "sha512-obr+/L9Jaxdr+xCVS/IQcYgreg5xtnui4Wqw/G1acBUtW2CnqVJj6lK6F/5F3+5d2oZEo5xDDLqy8GVn2HbEmw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/scheduler": {
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
|
||||
"integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@types/stack-utils": {
|
||||
"version": "2.0.3",
|
||||
|
@ -6484,6 +6497,12 @@
|
|||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||
},
|
||||
"node_modules/base-64": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz",
|
||||
"integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
|
@ -7227,7 +7246,7 @@
|
|||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/dag-map": {
|
||||
"version": "1.0.2",
|
||||
|
@ -7702,6 +7721,14 @@
|
|||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-haptics": {
|
||||
"version": "12.8.1",
|
||||
"resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-12.8.1.tgz",
|
||||
"integrity": "sha512-ntLsHkfle8K8w9MW8pZEw92ZN3sguaGUSSIxv30fPKNeQFu7Cq/h47Qv3tONv2MO3wU48N9FbKnant6XlfptpA==",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image-loader": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-4.6.0.tgz",
|
||||
|
@ -7839,11 +7866,25 @@
|
|||
"invariant": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-permissions": {
|
||||
"version": "14.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expo-permissions/-/expo-permissions-14.4.0.tgz",
|
||||
"integrity": "sha512-oAcnJ7dlZhpBydK73cwomA2xofizayVUz+FW5REl7dMu7MYyeN/3aqhlpZ3mYddrxvG161bqu97MQr01UixUnw==",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-status-bar": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-1.11.1.tgz",
|
||||
"integrity": "sha512-ddQEtCOgYHTLlFUe/yH67dDBIoct5VIULthyT3LRJbEwdpzAgueKsX2FYK02ldh440V87PWKCamh7R9evk1rrg=="
|
||||
},
|
||||
"node_modules/fast-base64-decode": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz",
|
||||
"integrity": "sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
|
@ -10774,6 +10815,15 @@
|
|||
"os-tmpdir": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/p-defer": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-defer/-/p-defer-3.0.0.tgz",
|
||||
"integrity": "sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-finally": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
|
||||
|
@ -11492,6 +11542,45 @@
|
|||
"react": "18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-base64": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-base64/-/react-native-base64-0.2.1.tgz",
|
||||
"integrity": "sha512-eHgt/MA8y5ZF0aHfZ1aTPcIkDWxza9AaEk4GcpIX+ZYfZ04RcaNahO+527KR7J44/mD3efYfM23O2C1N44ByWA=="
|
||||
},
|
||||
"node_modules/react-native-fetch-api": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-fetch-api/-/react-native-fetch-api-3.0.0.tgz",
|
||||
"integrity": "sha512-g2rtqPjdroaboDKTsJCTlcmtw54E25OjyaunUP0anOZn4Fuo2IKs8BVfe02zVggA/UysbmfSnRJIqtNkAgggNA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"p-defer": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-get-random-values": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-1.11.0.tgz",
|
||||
"integrity": "sha512-4BTbDbRmS7iPdhYLRcz3PGFIpFJBwNZg9g42iwa2P6FOv9vZj/xJc678RZXnLNZzd0qd7Q3CCF6Yd+CU2eoXKQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-base64-decode": "^1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-native": ">=0.56"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-polyfill-globals": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-polyfill-globals/-/react-native-polyfill-globals-3.1.0.tgz",
|
||||
"integrity": "sha512-6ACmV1SjXvZP2LN6J2yK58yNACKddcvoiKLrSQdISx32IdYStfdmGXrbAfpd+TANrTlIaZ2SLoFXohNwhnqm/w==",
|
||||
"peerDependencies": {
|
||||
"base-64": "*",
|
||||
"react-native-fetch-api": "*",
|
||||
"react-native-get-random-values": "*",
|
||||
"react-native-url-polyfill": "*",
|
||||
"text-encoding": "*",
|
||||
"web-streams-polyfill": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-safe-area-context": {
|
||||
"version": "4.8.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.8.2.tgz",
|
||||
|
@ -11514,6 +11603,18 @@
|
|||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-url-polyfill": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz",
|
||||
"integrity": "sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"whatwg-url-without-unicode": "8.0.0-3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
|
@ -12576,6 +12677,12 @@
|
|||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
|
||||
},
|
||||
"node_modules/text-encoding": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz",
|
||||
"integrity": "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==",
|
||||
"deprecated": "no longer maintained"
|
||||
},
|
||||
"node_modules/text-table": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||
|
@ -12868,6 +12975,14 @@
|
|||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
|
||||
"integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
@ -12936,6 +13051,15 @@
|
|||
"defaults": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0.tgz",
|
||||
"integrity": "sha512-0zJXHRAYEjM2tUfZ2DiSOHAa2aw1tisnnhU3ufD57R8iefL+DcdJyRBRyJpG+NUimDgbTI/lH+gAE1PAvV3Cgw==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
|
@ -13204,6 +13328,33 @@
|
|||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.2.tgz",
|
||||
"integrity": "sha512-2cN1tPkDVkwCy5ickKrI7vijSjPksFRfqS6237NzT0vqSsztTNnQdHw9mmN7uBdk3gceVXU0a+21jFzFzAc9+g==",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,18 +13,25 @@
|
|||
"@react-navigation/native": "^6.1.14",
|
||||
"@react-navigation/native-stack": "^6.9.22",
|
||||
"expo": "~50.0.8",
|
||||
"expo-av": "~13.10.5",
|
||||
"expo-barcode-scanner": "~12.9.3",
|
||||
"expo-camera": "~14.0.5",
|
||||
"expo-haptics": "~12.8.1",
|
||||
"expo-permissions": "^14.4.0",
|
||||
"expo-status-bar": "~1.11.1",
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.73.4",
|
||||
"react-native-base64": "^0.2.1",
|
||||
"react-native-polyfill-globals": "^3.1.0",
|
||||
"react-native-safe-area-context": "4.8.2",
|
||||
"react-native-screens": "~3.29.0",
|
||||
"expo-barcode-scanner": "~12.9.3",
|
||||
"expo-av": "~13.10.5"
|
||||
"text-encoding": "^0.7.0",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~18.2.45",
|
||||
"@types/react-native-base64": "^0.2.2",
|
||||
"typescript": "^5.1.3"
|
||||
},
|
||||
"ios": {
|
|
@ -3,9 +3,11 @@ import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
|||
import { Camera } from "expo-camera";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { BarCodeScanner } from "expo-barcode-scanner";
|
||||
// import useSoundEffect from "../lib/useSoundEffect";
|
||||
|
||||
export default function CameraScreen() {
|
||||
const [permission, requestPermission] = Camera.useCameraPermissions();
|
||||
// const playYay = useSoundEffect(require("../../assets/yay.wav"));
|
||||
|
||||
const [scanned, setScanned] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
|
@ -31,18 +33,20 @@ export default function CameraScreen() {
|
|||
// setFacing((current) => (current === "back" ? "front" : "back"));
|
||||
// }
|
||||
|
||||
const handleBarCodeScanned = ({
|
||||
const handleBarCodeScanned = async ({
|
||||
type,
|
||||
data,
|
||||
}: {
|
||||
type: string;
|
||||
data: string;
|
||||
}) => {
|
||||
// await playYay();
|
||||
setScanned(true);
|
||||
console.log(
|
||||
`Bar code with type ${type} and data ${data} has been scanned!`
|
||||
);
|
||||
alert(`Scanned URL: ${data}`);
|
||||
// alert(`Scanned URL: ${data}`);
|
||||
|
||||
navigation.navigate("Main", { scannedData: data });
|
||||
};
|
||||
return (
|
||||
|
@ -64,7 +68,9 @@ export default function CameraScreen() {
|
|||
onPress={() => setScanned(false)}
|
||||
style={styles.button}
|
||||
>
|
||||
<Text style={styles.text}>Scan Again</Text>
|
||||
<Text numberOfLines={1} style={styles.text}>
|
||||
Scan Again
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
@ -78,6 +84,7 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
flexDirection: "column",
|
||||
justifyContent: "flex-end",
|
||||
position: "relative",
|
||||
},
|
||||
camera: {
|
||||
flex: 1,
|
||||
|
@ -85,18 +92,22 @@ const styles = StyleSheet.create({
|
|||
buttonContainer: {
|
||||
backgroundColor: "transparent",
|
||||
flexDirection: "row",
|
||||
margin: 20,
|
||||
margin: 2,
|
||||
},
|
||||
button: {
|
||||
position: "absolute",
|
||||
top: 44,
|
||||
left: 4,
|
||||
flex: 0.1,
|
||||
alignSelf: "flex-end",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#000",
|
||||
borderRadius: 10,
|
||||
padding: 15,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
text: {
|
||||
fontSize: 18,
|
||||
fontSize: 14,
|
||||
color: "white",
|
||||
},
|
||||
});
|
|
@ -7,7 +7,7 @@ const HomeScreen = () => {
|
|||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.circle} />
|
||||
{/* <View style={styles.circle} /> */}
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={() => navigation.navigate("Camera")}
|
||||
|
@ -23,23 +23,23 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#fff",
|
||||
backgroundColor: "#000",
|
||||
},
|
||||
circle: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: "black",
|
||||
backgroundColor: "#fff",
|
||||
marginBottom: 20,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "black",
|
||||
backgroundColor: "#fff",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 5,
|
||||
},
|
||||
buttonText: {
|
||||
color: "white",
|
||||
color: "#000",
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
|
@ -0,0 +1,312 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
BackHandler,
|
||||
} from "react-native";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { Audio } from "expo-av";
|
||||
import { polyfill as polyfillEncoding } from "react-native-polyfill-globals/src/encoding";
|
||||
import { create } from "zustand";
|
||||
import { Animated } from "react-native";
|
||||
import useSoundEffect from "../utils/useSoundEffect";
|
||||
import RecordButton from "../utils/RecordButton";
|
||||
import { useNavigation } from "@react-navigation/core";
|
||||
|
||||
interface MainProps {
|
||||
route: {
|
||||
params: {
|
||||
scannedData: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface AudioQueueState {
|
||||
audioQueue: string[]; // Define the audio queue type
|
||||
addToQueue: (uri: string) => void; // Function to set audio queue
|
||||
}
|
||||
|
||||
const useAudioQueueStore = create<AudioQueueState>((set) => ({
|
||||
audioQueue: [], // initial state
|
||||
addToQueue: (uri) =>
|
||||
set((state) => ({ audioQueue: [...state.audioQueue, uri] })), // action to set audio queue
|
||||
}));
|
||||
|
||||
interface SoundState {
|
||||
sound: Audio.Sound | null; // Define the sound type
|
||||
setSound: (newSound: Audio.Sound | null) => void; // Function to set sound
|
||||
}
|
||||
|
||||
const useSoundStore = create<SoundState>((set) => ({
|
||||
sound: null, // initial state
|
||||
setSound: (newSound) => set({ sound: newSound }), // action to set sound
|
||||
}));
|
||||
|
||||
const Main: React.FC<MainProps> = ({ route }) => {
|
||||
const { scannedData } = route.params;
|
||||
const [connectionStatus, setConnectionStatus] =
|
||||
useState<string>("Connecting...");
|
||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||
const [wsUrl, setWsUrl] = useState("");
|
||||
const [rescan, setRescan] = useState(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [recording, setRecording] = useState<Audio.Recording | null>(null);
|
||||
const addToQueue = useAudioQueueStore((state) => state.addToQueue);
|
||||
const audioQueue = useAudioQueueStore((state) => state.audioQueue);
|
||||
const setSound = useSoundStore((state) => state.setSound);
|
||||
const sound = useSoundStore((state) => state.sound);
|
||||
const [soundUriMap, setSoundUriMap] = useState<Map<Audio.Sound, string>>(
|
||||
new Map()
|
||||
);
|
||||
const audioDir = FileSystem.documentDirectory + "01/audio/";
|
||||
const [permissionResponse, requestPermission] = Audio.usePermissions();
|
||||
polyfillEncoding();
|
||||
const backgroundColorAnim = useRef(new Animated.Value(0)).current;
|
||||
const buttonBackgroundColorAnim = useRef(new Animated.Value(0)).current;
|
||||
const playPip = useSoundEffect(require("../../assets/pip.mp3"));
|
||||
const playPop = useSoundEffect(require("../../assets/pop.mp3"));
|
||||
const navigation = useNavigation();
|
||||
const backgroundColor = backgroundColorAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ["black", "white"],
|
||||
});
|
||||
const buttonBackgroundColor = backgroundColorAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ["white", "black"],
|
||||
});
|
||||
const constructTempFilePath = async (buffer: string) => {
|
||||
try {
|
||||
await dirExists();
|
||||
if (!buffer) {
|
||||
console.log("Buffer is undefined or empty.");
|
||||
return null;
|
||||
}
|
||||
const tempFilePath = `${audioDir}${Date.now()}.wav`;
|
||||
|
||||
await FileSystem.writeAsStringAsync(tempFilePath, buffer, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
|
||||
return tempFilePath;
|
||||
} catch (error) {
|
||||
console.log("Failed to construct temp file path:", error);
|
||||
return null; // Return null to prevent crashing, error is logged
|
||||
}
|
||||
};
|
||||
|
||||
async function dirExists() {
|
||||
/**
|
||||
* Checks if audio directory exists in device storage, if not creates it.
|
||||
*/
|
||||
try {
|
||||
const dirInfo = await FileSystem.getInfoAsync(audioDir);
|
||||
if (!dirInfo.exists) {
|
||||
console.error("audio directory doesn't exist, creating...");
|
||||
await FileSystem.makeDirectoryAsync(audioDir, { intermediates: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking or creating directory:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const playNextAudio = useCallback(async () => {
|
||||
if (audioQueue.length > 0 && sound == null) {
|
||||
const uri = audioQueue.shift() as string;
|
||||
|
||||
try {
|
||||
const { sound: newSound } = await Audio.Sound.createAsync({ uri });
|
||||
setSound(newSound);
|
||||
setSoundUriMap(new Map(soundUriMap.set(newSound, uri)));
|
||||
await newSound.playAsync();
|
||||
newSound.setOnPlaybackStatusUpdate(_onPlayBackStatusUpdate);
|
||||
} catch (error) {
|
||||
console.log("Error playing audio", error);
|
||||
playNextAudio();
|
||||
}
|
||||
} else {
|
||||
// audioQueue is empty or sound is not null
|
||||
return;
|
||||
}
|
||||
}, [audioQueue, sound, soundUriMap]);
|
||||
|
||||
const _onPlayBackStatusUpdate = useCallback(
|
||||
async (status: any) => {
|
||||
if (status.didJustFinish) {
|
||||
await sound?.unloadAsync();
|
||||
soundUriMap.delete(sound);
|
||||
setSoundUriMap(new Map(soundUriMap));
|
||||
setSound(null);
|
||||
playNextAudio();
|
||||
}
|
||||
},
|
||||
[sound, soundUriMap, playNextAudio]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const backAction = () => {
|
||||
navigation.navigate("Home"); // Always navigate back to Home
|
||||
return true; // Prevent default action
|
||||
};
|
||||
|
||||
// Add event listener for hardware back button on Android
|
||||
const backHandler = BackHandler.addEventListener(
|
||||
"hardwareBackPress",
|
||||
backAction
|
||||
);
|
||||
|
||||
return () => backHandler.remove();
|
||||
}, [navigation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioQueue.length > 0 && !sound) {
|
||||
playNextAudio();
|
||||
}
|
||||
}, [audioQueue, sound, playNextAudio]);
|
||||
|
||||
useEffect(() => {}, [sound]);
|
||||
|
||||
useEffect(() => {
|
||||
let websocket: WebSocket;
|
||||
try {
|
||||
// console.log("Connecting to WebSocket at " + scannedData);
|
||||
setWsUrl(scannedData);
|
||||
websocket = new WebSocket(scannedData);
|
||||
websocket.binaryType = "blob";
|
||||
|
||||
websocket.onopen = () => {
|
||||
setConnectionStatus(`Connected`);
|
||||
};
|
||||
|
||||
websocket.onmessage = async (e) => {
|
||||
try {
|
||||
const message = JSON.parse(e.data);
|
||||
|
||||
if (message.content && message.type == "audio") {
|
||||
const buffer = message.content;
|
||||
if (buffer && buffer.length > 0) {
|
||||
const filePath = await constructTempFilePath(buffer);
|
||||
if (filePath !== null) {
|
||||
addToQueue(filePath);
|
||||
} else {
|
||||
console.error("Failed to create file path");
|
||||
}
|
||||
} else {
|
||||
console.error("Received message is empty or undefined");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
setConnectionStatus("Error connecting to WebSocket.");
|
||||
console.error("WebSocket error: ", error);
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
setConnectionStatus("Disconnected.");
|
||||
};
|
||||
|
||||
setWs(websocket);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
setConnectionStatus("Error creating WebSocket.");
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (websocket) {
|
||||
websocket.close();
|
||||
}
|
||||
};
|
||||
}, [scannedData, rescan]);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, { backgroundColor }]}>
|
||||
<View style={styles.middle}>
|
||||
<RecordButton
|
||||
playPip={playPip}
|
||||
playPop={playPop}
|
||||
recording={recording}
|
||||
setRecording={setRecording}
|
||||
ws={ws}
|
||||
backgroundColorAnim={backgroundColorAnim}
|
||||
buttonBackgroundColorAnim={buttonBackgroundColorAnim}
|
||||
backgroundColor={backgroundColor}
|
||||
buttonBackgroundColor={buttonBackgroundColor}
|
||||
setIsPressed={setIsPressed}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.statusButton}
|
||||
onPress={() => {
|
||||
setRescan(!rescan);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusText,
|
||||
{
|
||||
color: connectionStatus.startsWith("Connected")
|
||||
? "green"
|
||||
: "red",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{connectionStatus}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
},
|
||||
middle: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 10,
|
||||
position: "relative",
|
||||
},
|
||||
circle: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
qr: {
|
||||
position: "absolute",
|
||||
top: 30,
|
||||
left: 10,
|
||||
padding: 10,
|
||||
zIndex: 100,
|
||||
},
|
||||
icon: {
|
||||
height: 40,
|
||||
width: 40,
|
||||
},
|
||||
topBar: {
|
||||
height: 40,
|
||||
backgroundColor: "#000",
|
||||
paddingTop: 50,
|
||||
},
|
||||
|
||||
statusText: {
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
statusButton: {
|
||||
position: "absolute",
|
||||
bottom: 20,
|
||||
alignSelf: "center",
|
||||
},
|
||||
});
|
||||
|
||||
export default Main;
|
|
@ -0,0 +1,151 @@
|
|||
import React, { useEffect, useCallback } from "react";
|
||||
import { TouchableOpacity, StyleSheet } from "react-native";
|
||||
import { Audio } from "expo-av";
|
||||
import { Animated } from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
interface RecordButtonProps {
|
||||
playPip: () => void;
|
||||
playPop: () => void;
|
||||
recording: Audio.Recording | null;
|
||||
setRecording: (recording: Audio.Recording | null) => void;
|
||||
ws: WebSocket | null;
|
||||
buttonBackgroundColorAnim: Animated.Value;
|
||||
backgroundColorAnim: Animated.Value;
|
||||
backgroundColor: Animated.AnimatedInterpolation<string | number>;
|
||||
buttonBackgroundColor: Animated.AnimatedInterpolation<string | number>;
|
||||
setIsPressed: (isPressed: boolean) => void;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
circle: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
button: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
|
||||
const RecordButton: React.FC<RecordButtonProps> = ({
|
||||
playPip,
|
||||
playPop,
|
||||
recording,
|
||||
setRecording,
|
||||
ws,
|
||||
backgroundColorAnim,
|
||||
buttonBackgroundColorAnim,
|
||||
backgroundColor,
|
||||
buttonBackgroundColor,
|
||||
setIsPressed,
|
||||
}: RecordButtonProps) => {
|
||||
const [permissionResponse, requestPermission] = Audio.usePermissions();
|
||||
|
||||
useEffect(() => {
|
||||
if (permissionResponse?.status !== "granted") {
|
||||
requestPermission();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (recording) {
|
||||
console.log("A recording is already in progress.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
permissionResponse !== null &&
|
||||
permissionResponse.status !== `granted`
|
||||
) {
|
||||
await requestPermission();
|
||||
}
|
||||
|
||||
await Audio.setAudioModeAsync({
|
||||
allowsRecordingIOS: true,
|
||||
playsInSilentModeIOS: true,
|
||||
});
|
||||
|
||||
const newRecording = new Audio.Recording();
|
||||
await newRecording.prepareToRecordAsync(
|
||||
Audio.RecordingOptionsPresets.HIGH_QUALITY
|
||||
);
|
||||
await newRecording.startAsync();
|
||||
|
||||
setRecording(newRecording);
|
||||
} catch (err) {
|
||||
console.error("Failed to start recording", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopRecording = useCallback(async () => {
|
||||
if (recording) {
|
||||
await recording.stopAndUnloadAsync();
|
||||
await Audio.setAudioModeAsync({
|
||||
allowsRecordingIOS: false,
|
||||
});
|
||||
const uri = recording.getURI();
|
||||
setRecording(null);
|
||||
|
||||
if (ws && uri) {
|
||||
const response = await fetch(uri);
|
||||
const blob = await response.blob();
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsArrayBuffer(blob);
|
||||
reader.onloadend = () => {
|
||||
const audioBytes = reader.result;
|
||||
if (audioBytes) {
|
||||
ws.send(audioBytes);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [recording]);
|
||||
|
||||
const toggleRecording = (shouldPress: boolean) => {
|
||||
Animated.timing(backgroundColorAnim, {
|
||||
toValue: shouldPress ? 1 : 0,
|
||||
duration: 400,
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
Animated.timing(buttonBackgroundColorAnim, {
|
||||
toValue: shouldPress ? 1 : 0,
|
||||
duration: 400,
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPressIn={() => {
|
||||
playPip();
|
||||
setIsPressed(true);
|
||||
toggleRecording(true);
|
||||
startRecording();
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
|
||||
}}
|
||||
onPressOut={() => {
|
||||
playPop();
|
||||
setIsPressed(false);
|
||||
toggleRecording(false);
|
||||
stopRecording();
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={[styles.circle, { backgroundColor: buttonBackgroundColor }]}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecordButton;
|
|
@ -0,0 +1,10 @@
|
|||
// store.js
|
||||
import { create } from "zustand";
|
||||
|
||||
const useStore = create((set: any) => ({
|
||||
count: 0,
|
||||
increase: () => set((state: any) => ({ count: state.count + 1 })),
|
||||
decrease: () => set((state: any) => ({ count: state.count - 1 })),
|
||||
}));
|
||||
|
||||
export default useStore;
|
|
@ -0,0 +1,29 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Audio } from "expo-av";
|
||||
|
||||
const useSoundEffect = (soundFile: any) => {
|
||||
const [sound, setSound] = useState<Audio.Sound | null>(null); // Explicitly set initial state to null
|
||||
|
||||
useEffect(() => {
|
||||
const loadSound = async () => {
|
||||
const { sound: newSound } = await Audio.Sound.createAsync(soundFile);
|
||||
setSound(newSound);
|
||||
};
|
||||
|
||||
loadSound();
|
||||
|
||||
return () => {
|
||||
sound?.unloadAsync();
|
||||
};
|
||||
}, [soundFile, sound]); // Include sound in the dependency array
|
||||
|
||||
const playSound = async () => {
|
||||
if (sound) {
|
||||
await sound.playAsync();
|
||||
}
|
||||
};
|
||||
|
||||
return playSound;
|
||||
};
|
||||
|
||||
export default useSoundEffect;
|
|
@ -20,7 +20,8 @@ from interpreter import interpreter
|
|||
from ..utils.accumulator import Accumulator
|
||||
from .utils.logs import setup_logging
|
||||
from .utils.logs import logger
|
||||
|
||||
import base64
|
||||
import shutil
|
||||
from ..utils.print_markdown import print_markdown
|
||||
|
||||
os.environ["STT_RUNNER"] = "server"
|
||||
|
@ -38,7 +39,7 @@ print("")
|
|||
|
||||
setup_logging()
|
||||
|
||||
accumulator = Accumulator()
|
||||
accumulator_global = Accumulator()
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
@ -194,12 +195,13 @@ async def receive_messages(websocket: WebSocket):
|
|||
async def send_messages(websocket: WebSocket):
|
||||
while True:
|
||||
message = await to_device.get()
|
||||
# print(f"Sending to the device: {type(message)} {str(message)[:100]}")
|
||||
|
||||
try:
|
||||
if isinstance(message, dict):
|
||||
# print(f"Sending to the device: {type(message)} {str(message)[:100]}")
|
||||
await websocket.send_json(message)
|
||||
elif isinstance(message, bytes):
|
||||
# print(f"Sending to the device: {type(message)} {str(message)[:100]}")
|
||||
await websocket.send_bytes(message)
|
||||
else:
|
||||
raise TypeError("Message must be a dict or bytes")
|
||||
|
@ -209,9 +211,12 @@ async def send_messages(websocket: WebSocket):
|
|||
raise
|
||||
|
||||
|
||||
async def listener():
|
||||
async def listener(mobile: bool):
|
||||
while True:
|
||||
try:
|
||||
if mobile:
|
||||
accumulator_mobile = Accumulator()
|
||||
|
||||
while True:
|
||||
if not from_user.empty():
|
||||
chunk = await from_user.get()
|
||||
|
@ -221,7 +226,11 @@ async def listener():
|
|||
break
|
||||
await asyncio.sleep(1)
|
||||
|
||||
message = accumulator.accumulate(chunk)
|
||||
if mobile:
|
||||
message = accumulator_mobile.accumulate_mobile(chunk)
|
||||
else:
|
||||
message = accumulator_global.accumulate(chunk)
|
||||
|
||||
if message == None:
|
||||
# Will be None until we have a full message ready
|
||||
continue
|
||||
|
@ -241,7 +250,9 @@ async def listener():
|
|||
# Convert bytes to audio file
|
||||
# Format will be bytes.wav or bytes.opus
|
||||
mime_type = "audio/" + message["format"].split(".")[1]
|
||||
# print("input audio file content", message["content"][:100])
|
||||
audio_file_path = bytes_to_wav(message["content"], mime_type)
|
||||
# print("Audio file path:", audio_file_path)
|
||||
|
||||
# For microphone debugging:
|
||||
if False:
|
||||
|
@ -287,6 +298,7 @@ async def listener():
|
|||
|
||||
# Send it to the user
|
||||
await to_device.put(chunk)
|
||||
|
||||
# Yield to the event loop, so you actually send it out
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
@ -309,11 +321,11 @@ async def listener():
|
|||
|
||||
if is_full_sentence(sentences[-1]):
|
||||
for sentence in sentences:
|
||||
await stream_tts_to_device(sentence)
|
||||
await stream_tts_to_device(sentence, mobile)
|
||||
accumulated_text = ""
|
||||
else:
|
||||
for sentence in sentences[:-1]:
|
||||
await stream_tts_to_device(sentence)
|
||||
await stream_tts_to_device(sentence, mobile)
|
||||
accumulated_text = sentences[-1]
|
||||
|
||||
# If we're going to speak, say we're going to stop sending text.
|
||||
|
@ -357,7 +369,7 @@ async def listener():
|
|||
traceback.print_exc()
|
||||
|
||||
|
||||
async def stream_tts_to_device(sentence):
|
||||
async def stream_tts_to_device(sentence, mobile: bool):
|
||||
force_task_completion_responses = [
|
||||
"the task is done",
|
||||
"the task is impossible",
|
||||
|
@ -366,26 +378,44 @@ async def stream_tts_to_device(sentence):
|
|||
if sentence.lower().strip().strip(".!?").strip() in force_task_completion_responses:
|
||||
return
|
||||
|
||||
for chunk in stream_tts(sentence):
|
||||
for chunk in stream_tts(sentence, mobile):
|
||||
await to_device.put(chunk)
|
||||
|
||||
|
||||
def stream_tts(sentence):
|
||||
audio_file = tts(sentence)
|
||||
def stream_tts(sentence, mobile: bool):
|
||||
audio_file = tts(sentence, mobile)
|
||||
|
||||
# Read the entire WAV file
|
||||
with open(audio_file, "rb") as f:
|
||||
audio_bytes = f.read()
|
||||
os.remove(audio_file)
|
||||
|
||||
file_type = "bytes.raw"
|
||||
chunk_size = 1024
|
||||
if mobile:
|
||||
file_type = "audio/wav"
|
||||
|
||||
# Stream the audio
|
||||
yield {"role": "assistant", "type": "audio", "format": file_type, "start": True}
|
||||
for i in range(0, len(audio_bytes), chunk_size):
|
||||
chunk = audio_bytes[i : i + chunk_size]
|
||||
yield chunk
|
||||
yield {"role": "assistant", "type": "audio", "format": file_type, "end": True}
|
||||
os.remove(audio_file)
|
||||
|
||||
# stream the audio as a single sentence
|
||||
yield {
|
||||
"role": "assistant",
|
||||
"type": "audio",
|
||||
"format": file_type,
|
||||
"content": base64.b64encode(audio_bytes).decode("utf-8"),
|
||||
"start": True,
|
||||
"end": True,
|
||||
}
|
||||
|
||||
else:
|
||||
# stream the audio in chunk sizes
|
||||
os.remove(audio_file)
|
||||
|
||||
file_type = "bytes.raw"
|
||||
chunk_size = 1024
|
||||
|
||||
yield {"role": "assistant", "type": "audio", "format": file_type, "start": True}
|
||||
for i in range(0, len(audio_bytes), chunk_size):
|
||||
chunk = audio_bytes[i : i + chunk_size]
|
||||
yield chunk
|
||||
yield {"role": "assistant", "type": "audio", "format": file_type, "end": True}
|
||||
|
||||
|
||||
from uvicorn import Config, Server
|
||||
|
@ -422,6 +452,7 @@ async def main(
|
|||
temperature,
|
||||
tts_service,
|
||||
stt_service,
|
||||
mobile,
|
||||
):
|
||||
global HOST
|
||||
global PORT
|
||||
|
@ -473,7 +504,7 @@ async def main(
|
|||
interpreter.llm.completions = llm
|
||||
|
||||
# Start listening
|
||||
asyncio.create_task(listener())
|
||||
asyncio.create_task(listener(mobile))
|
||||
|
||||
# Start watching the kernel if it's your job to do that
|
||||
if True: # in the future, code can run on device. for now, just server.
|
||||
|
|
|
@ -25,7 +25,7 @@ class Tts:
|
|||
def __init__(self, config):
|
||||
pass
|
||||
|
||||
def tts(self, text):
|
||||
def tts(self, text, mobile):
|
||||
response = client.audio.speech.create(
|
||||
model="tts-1",
|
||||
voice=os.getenv("OPENAI_VOICE_NAME", "alloy"),
|
||||
|
@ -36,9 +36,15 @@ class Tts:
|
|||
response.stream_to_file(temp_file.name)
|
||||
|
||||
# TODO: hack to format audio correctly for device
|
||||
outfile = tempfile.gettempdir() + "/" + "raw.dat"
|
||||
ffmpeg.input(temp_file.name).output(
|
||||
outfile, f="s16le", ar="16000", ac="1", loglevel="panic"
|
||||
).run()
|
||||
if mobile:
|
||||
outfile = tempfile.gettempdir() + "/" + "output.wav"
|
||||
ffmpeg.input(temp_file.name).output(
|
||||
outfile, f="wav", ar="16000", ac="1", loglevel="panic"
|
||||
).run()
|
||||
else:
|
||||
outfile = tempfile.gettempdir() + "/" + "raw.dat"
|
||||
ffmpeg.input(temp_file.name).output(
|
||||
outfile, f="s16le", ar="16000", ac="1", loglevel="panic"
|
||||
).run()
|
||||
|
||||
return outfile
|
||||
|
|
|
@ -100,7 +100,7 @@ def create_tunnel(
|
|||
# If ngrok is installed, start it on the specified port
|
||||
# process = subprocess.Popen(f'ngrok http {server_port} --log=stdout', shell=True, stdout=subprocess.PIPE)
|
||||
process = subprocess.Popen(
|
||||
f"ngrok http {server_port} --scheme http,https --domain=marten-advanced-dragon.ngrok-free.app --log=stdout",
|
||||
f"ngrok http {server_port} --scheme http,https --log=stdout",
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
|
|
|
@ -36,7 +36,7 @@ def export_audio_to_wav_ffmpeg(audio: bytearray, mime_type: str) -> str:
|
|||
output_path = os.path.join(
|
||||
temp_dir, f"output_{datetime.now().strftime('%Y%m%d%H%M%S%f')}.wav"
|
||||
)
|
||||
print(mime_type, input_path, output_path)
|
||||
# print(mime_type, input_path, output_path)
|
||||
if mime_type == "audio/raw":
|
||||
ffmpeg.input(
|
||||
input_path,
|
||||
|
|
|
@ -45,3 +45,49 @@ class Accumulator:
|
|||
self.message["content"] = b""
|
||||
self.message["content"] += chunk
|
||||
return None
|
||||
|
||||
def accumulate_mobile(self, chunk):
|
||||
# print(str(chunk)[:100])
|
||||
if type(chunk) == dict:
|
||||
if "format" in chunk and chunk["format"] == "active_line":
|
||||
# We don't do anything with these
|
||||
return None
|
||||
|
||||
if "start" in chunk:
|
||||
self.message = chunk
|
||||
self.message.pop("start")
|
||||
return None
|
||||
|
||||
if "content" in chunk:
|
||||
if any(
|
||||
self.message[key] != chunk[key]
|
||||
for key in self.message
|
||||
if key != "content"
|
||||
):
|
||||
self.message = chunk
|
||||
if "content" not in self.message:
|
||||
self.message["content"] = chunk["content"]
|
||||
else:
|
||||
if type(chunk["content"]) == dict:
|
||||
# dict concatenation cannot happen, so we see if chunk is a dict
|
||||
self.message["content"]["content"] += chunk["content"][
|
||||
"content"
|
||||
]
|
||||
else:
|
||||
self.message["content"] += chunk["content"]
|
||||
return None
|
||||
|
||||
if "end" in chunk:
|
||||
# We will proceed
|
||||
message = self.message
|
||||
self.message = self.template
|
||||
return message
|
||||
|
||||
if type(chunk) == bytes:
|
||||
if "content" not in self.message or type(self.message["content"]) != bytes:
|
||||
self.message["content"] = b""
|
||||
self.message["content"] += chunk
|
||||
|
||||
self.message["type"] = "audio"
|
||||
self.message["format"] = "bytes.wav"
|
||||
return self.message
|
||||
|
|
|
@ -72,13 +72,16 @@ def run(
|
|||
False, "--local", help="Use recommended local services for LLM, STT, and TTS"
|
||||
),
|
||||
qr: bool = typer.Option(False, "--qr", help="Print the QR code for the server URL"),
|
||||
mobile: bool = typer.Option(
|
||||
False, "--mobile", help="Toggle server to support mobile app"
|
||||
),
|
||||
):
|
||||
_run(
|
||||
server=server,
|
||||
server=server or mobile,
|
||||
server_host=server_host,
|
||||
server_port=server_port,
|
||||
tunnel_service=tunnel_service,
|
||||
expose=expose,
|
||||
expose=expose or mobile,
|
||||
client=client,
|
||||
server_url=server_url,
|
||||
client_type=client_type,
|
||||
|
@ -92,7 +95,8 @@ def run(
|
|||
tts_service=tts_service,
|
||||
stt_service=stt_service,
|
||||
local=local,
|
||||
qr=qr,
|
||||
qr=qr or mobile,
|
||||
mobile=mobile,
|
||||
)
|
||||
|
||||
|
||||
|
@ -116,6 +120,7 @@ def _run(
|
|||
stt_service: str = "openai",
|
||||
local: bool = False,
|
||||
qr: bool = False,
|
||||
mobile: bool = False,
|
||||
):
|
||||
if local:
|
||||
tts_service = "piper"
|
||||
|
@ -136,6 +141,7 @@ def _run(
|
|||
signal.signal(signal.SIGINT, handle_exit)
|
||||
|
||||
if server:
|
||||
# print(f"Starting server with mobile = {mobile}")
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
server_thread = threading.Thread(
|
||||
|
@ -153,6 +159,7 @@ def _run(
|
|||
temperature,
|
||||
tts_service,
|
||||
stt_service,
|
||||
mobile,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|