From e29f82ee77c06e148ddd408bc0c4db1571ad26c8 Mon Sep 17 00:00:00 2001 From: Maki Date: Wed, 20 Mar 2024 01:14:46 +0900 Subject: [PATCH 01/39] Create README_JP.md --- docs/README_JP.md | 192 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 docs/README_JP.md diff --git a/docs/README_JP.md b/docs/README_JP.md new file mode 100644 index 0000000..bd7c689 --- /dev/null +++ b/docs/README_JP.md @@ -0,0 +1,192 @@ +はい、以下が自然な日本語に変換した文章になります。 + +* * * + +[The 01 Project](https://twitter.com/hellokillian/status/1745875973583896950)の公式プレリリースリポジトリ。 + +> 発売まで残り**3**日 + +

+ +

+ Discord Partner +
+
+ オープンソースの言語モデルコンピューター
+ +

+ +
+ + | [日本語](README_JP.md) | [English](../README.md) | + +
+ +
+ +![ポスター](https://pbs.twimg.com/media/GDqTVYzbgAIfLJf?format=png&name=4096x4096) + +
+ + + +```shell +git clone https://github.com/OpenInterpreter/01 +cd 01/01OS +``` + + + +```shell +poetry install +poetry run 01 +``` + +
+ +**The 01 Project**は、AIデバイス向けのエコシステムを構築しています。 + +私たちの主力オペレーティングシステムは、Rabbit R1、Humane Pin、[Star Trekコンピューター](https://www.youtube.com/watch?v=1ZXugicgn6U)のような会話型デバイスを動作させることができます。 + +私たちは、オープンソース、モジュール性、無料であり続けることを約束することで、この分野のGNU/Linuxになることを目指しています。 + +## 統一API + +統一APIは、01で使用される主要サービスの標準的なPythonインターフェースです。 + +- `/stt` 音声認識用 +- `/llm` 言語モデル用 +- `/tts` 音声合成用 + +## ボディ + +01OSは、さまざまなボディに収容できます。このリストに追加するPRを大歓迎します。 + +**01 Light**は、ESP32ベースの音声インターフェースで、インターネット経由でホームコンピューターを制御します。**01 Server**と組み合わせて使用します。 + +**01 Heavy**は、すべてをローカルで実行するデバイスです。 + +## セットアップ + +### 依存関係のインストール + +```bash +# MacOS +brew install portaudio ffmpeg cmake + +# Ubuntu +sudo apt-get install portaudio19-dev ffmpeg cmake +``` + +Whisperを使用してローカルで音声認識を行う場合は、Rustをインストールしてください。[ここ](https://www.rust-lang.org/tools/install)に記載されている手順に従ってください。 + +### 01 CLIのインストールと実行 + +```shell +pip install 01OS +``` + +```shell +01 --server # ハードウェアデバイスがリスンするサーバーを起動します。 +``` + +# クライアントのセットアップ + +### ESP32ボード用 + +[ESP32セットアップドキュメント](https://github.com/OpenInterpreter/01/tree/main/01OS/01OS/clients/esp32)をご覧ください。 + +### Mac、Windows、Ubuntuマシン用 + +``` +01 # サーバーとクライアントを起動します。 + +01 --server --expose # サーバーを起動し、Ngrok経由で公開します。クライアントが接続するための`server_url`が表示されます。 + +01 --client --server_url your-server.com # クライアントのみを起動します。 +``` + +### サービスプロバイダーの切り替え + +01は、音声認識、音声合成、言語モデルのプロバイダーに依存しません。 + +以下のコマンドを実行して、プロバイダーを選択します。 + +```shell +01 --tts-service openai +01 --llm-service openai +01 --stt-service openai +``` + +[すべてのプロバイダーを見る ↗](https://docs.litellm.ai/docs/providers/)、または[サービスプロバイダーを追加して01チームに参加する。↗]() + +### 01をローカルで実行する + +一部のサービスプロバイダーはインターネット接続を必要としません。 + +次のコマンドを実行すると、ハードウェアに最適なプロバイダーをダウンロードして使用しようとします。 + +```shell +01 --local +``` + +## 仕組み + +01は、言語モデル(音声インターフェースでラップされている)に`exec()`関数を装備し、コードを書いて実行してコンピューターを制御できるようにします。 + +音声はエンドユーザーのデバイスとの間でのみストリーミングされます。 + +# 貢献 + +詳細については、[コントリビューションガイドライン](docs/CONTRIBUTING.md)をご覧ください。 + +### 開発のためのセットアップ + +```bash +# リポジトリをクローン +git clone https://github.com/KillianLucas/01.git + +# 01OSディレクトリに移動 +cd 01OS + +# Pythonの依存関係をインストール +poetry install + +# 実行 +poetry run 01 +``` + +
+ +# ロードマップ + +01の未来を見るには、[私たちのロードマップ](https://github.com/KillianLucas/open-interpreter/blob/main/docs/ROADMAP.md)をご覧ください。 + +
+ +## 背景 + +### [コンテキスト ↗](https://github.com/KillianLucas/01/blob/main/CONTEXT.md) + +01以前のデバイスの物語。 + +### [インスピレーション ↗](https://github.com/KillianLucas/01/tree/main/INSPIRATION.md) + +素晴らしいアイデアを盗みたいもの。 + +
+ +## 方向性 + +### [目標 ↗](https://github.com/KillianLucas/01/blob/main/GOALS.md) + +私たちがやろうとしていること。 + +### [ユースケース ↗](https://github.com/KillianLucas/01/blob/main/USE_CASES.md) + +01ができるようになること。 + +
\ No newline at end of file From a559c4272124170d18195f0612352c7971652cb0 Mon Sep 17 00:00:00 2001 From: Maki Date: Wed, 20 Mar 2024 01:14:48 +0900 Subject: [PATCH 02/39] Update README.md --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 56d43bb..97f4a09 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,13 @@ Official pre-release repository for [The 01 Project](https://twitter.com/helloki

+
+ + | [日本語](docs/README_JP.md) | [English](README.md) | + +
+ +
![poster](https://pbs.twimg.com/media/GDqTVYzbgAIfLJf?format=png&name=4096x4096) From be1193267a9d8beeb27e3cac42bfba2460cc9885 Mon Sep 17 00:00:00 2001 From: Maki Date: Wed, 20 Mar 2024 01:15:23 +0900 Subject: [PATCH 03/39] Update README_JP.md --- docs/README_JP.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/README_JP.md b/docs/README_JP.md index bd7c689..ceda391 100644 --- a/docs/README_JP.md +++ b/docs/README_JP.md @@ -1,7 +1,3 @@ -はい、以下が自然な日本語に変換した文章になります。 - -* * * - [The 01 Project](https://twitter.com/hellokillian/status/1745875973583896950)の公式プレリリースリポジトリ。 > 発売まで残り**3**日 From 17e1c3ef3b06da4d7ead6f77b988e9df7e87e8e0 Mon Sep 17 00:00:00 2001 From: Tasha Upchurch Date: Tue, 26 Mar 2024 14:24:11 -0400 Subject: [PATCH 04/39] Added documentation links for youtube videos explaining installation and usage --- docs/video_documentation/collection.md | 83 ++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/video_documentation/collection.md diff --git a/docs/video_documentation/collection.md b/docs/video_documentation/collection.md new file mode 100644 index 0000000..c0390c4 --- /dev/null +++ b/docs/video_documentation/collection.md @@ -0,0 +1,83 @@ + +## For End Users +[Announcment video](https://www.youtube.com/watch?v=jWr-WeXAdeI) +[Wes Roth](https://www.youtube.com/@WesRoth) + +
+Details + +No technical coverage + +
+ +--- + +[Announcment video](https://www.youtube.com/watch?v=JaBFT3fF2fk) +[TheAIGRID](https://www.youtube.com/@TheAiGrid) + +
+Details + +[here](https://youtu.be/JaBFT3fF2fk?si=8zPGO-U6WdLNnISw&t=656) +mentions the current lack of windows support + +
+ +--- + +[Announcment video](https://www.youtube.com/watch?v=Q_p82HtBqoc) +[Matt Berman](https://www.youtube.com/@matthew_berman) + +
+Details + +[here](https://youtu.be/Q_p82HtBqoc?si=aAxjWZnBdwBbaOUr&t=579) +Berman shows an install of 01 using conda and python 3.9 +in.. looks like linux.. shows how to get openai keys. + +
+ +--- + +[Announcment video](https://www.youtube.com/watch?v=q0dJ7T7au2Y) +[WorldofAI](https://www.youtube.com/@intheworldofai) + +
+Details + + + +
+ +--- + +[Breakdown video](https://www.youtube.com/watch?v=W-VwN0n4d9Y) +[Mervin Praison](https://www.youtube.com/@MervinPraison) +
+Details +- uses conda to install 01 and uses python 3.11 on linux.. maybe mac +- 0:00 Introduction to Open Interpreter +- 0:47 Creating Apps and Summarizing Documents +- 1:20 Image Modifications and Game Creation +- 2:55 Exploratory Data Analysis and Charting +- 4:00 Server Log Analysis +- 5:01 Image and Video Editing +- 6:00 Composing Music with AI +- 7:18 Calendar Management and Email Automation +- 9:01 Integrating with Fast API and LM Studio + +
+ +--- + +[Breakdown video](https://www.youtube.com/watch?v=uyfoHQVgeY0) +[Gary Explains](https://www.youtube.com/@GaryExplains) +
for **open interpreter** not **01** +
+Details +- 3:45 states that it will run on mac/linux and windows and requires python 3.10 +
+ +## For Developers +
+Coming soon \ No newline at end of file From 991cba8d09ca6731ea4e930984fd12aa3b8539e3 Mon Sep 17 00:00:00 2001 From: Robert Brisita <986796+rbrisita@users.noreply.github.com> Date: Wed, 10 Apr 2024 14:53:16 -0400 Subject: [PATCH 05/39] Adding loglevel panic to suppress FFmpeg logs when running STT locally. --- software/source/server/services/stt/local-whisper/stt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/software/source/server/services/stt/local-whisper/stt.py b/software/source/server/services/stt/local-whisper/stt.py index 1c2743b..a92a703 100644 --- a/software/source/server/services/stt/local-whisper/stt.py +++ b/software/source/server/services/stt/local-whisper/stt.py @@ -156,7 +156,7 @@ def stt_wav(service_directory, wav_file_path: str): temp_dir, f"output_stt_{datetime.now().strftime('%Y%m%d%H%M%S%f')}.wav" ) ffmpeg.input(wav_file_path).output( - output_path, acodec="pcm_s16le", ac=1, ar="16k" + output_path, acodec="pcm_s16le", ac=1, ar="16k", loglevel="panic" ).run() try: transcript = get_transcription_file(service_directory, output_path) From b24d48fd89b77a5bf5df5100c88061bc83a69462 Mon Sep 17 00:00:00 2001 From: Robert Brisita <986796+rbrisita@users.noreply.github.com> Date: Wed, 10 Apr 2024 15:02:50 -0400 Subject: [PATCH 06/39] Adding check to true on subprocess run calls to expose command errors that would otherwise fail silently. --- software/source/server/services/stt/local-whisper/stt.py | 2 +- software/source/server/services/stt/openai/stt.py | 2 +- software/source/server/utils/bytes_to_wav.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/software/source/server/services/stt/local-whisper/stt.py b/software/source/server/services/stt/local-whisper/stt.py index 1c2743b..d0719b7 100644 --- a/software/source/server/services/stt/local-whisper/stt.py +++ b/software/source/server/services/stt/local-whisper/stt.py @@ -125,7 +125,7 @@ def export_audio_to_wav_ffmpeg(audio: bytearray, mime_type: str) -> str: def run_command(command): result = subprocess.run( - command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True ) return result.stdout, result.stderr diff --git a/software/source/server/services/stt/openai/stt.py b/software/source/server/services/stt/openai/stt.py index 4cb1e4b..32dc9e2 100644 --- a/software/source/server/services/stt/openai/stt.py +++ b/software/source/server/services/stt/openai/stt.py @@ -70,7 +70,7 @@ def export_audio_to_wav_ffmpeg(audio: bytearray, mime_type: str) -> str: def run_command(command): result = subprocess.run( - command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True ) return result.stdout, result.stderr diff --git a/software/source/server/utils/bytes_to_wav.py b/software/source/server/utils/bytes_to_wav.py index a189257..a789792 100644 --- a/software/source/server/utils/bytes_to_wav.py +++ b/software/source/server/utils/bytes_to_wav.py @@ -57,7 +57,7 @@ def export_audio_to_wav_ffmpeg(audio: bytearray, mime_type: str) -> str: def run_command(command): result = subprocess.run( - command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True ) return result.stdout, result.stderr From 3fbf2aa36223ab806e8e367adbfbb991550d4be2 Mon Sep 17 00:00:00 2001 From: Abdullah Gohar Date: Fri, 12 Apr 2024 09:03:48 +0500 Subject: [PATCH 07/39] Fixed the ctrl-c bug --- software/source/clients/base_device.py | 27 +++++++++++++++---- software/source/server/utils/process_utils.py | 6 ++++- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/software/source/clients/base_device.py b/software/source/clients/base_device.py index 3bf900e..a614498 100644 --- a/software/source/clients/base_device.py +++ b/software/source/clients/base_device.py @@ -72,6 +72,7 @@ class Device: self.captured_images = [] self.audiosegments = [] self.server_url = "" + self.ctrl_pressed = False def fetch_image_from_camera(self, camera_index=CAMERA_DEVICE_INDEX): """Captures an image from the specified camera device and saves it to a temporary file. Adds the image to the captured_images list.""" @@ -256,23 +257,39 @@ class Device: def on_press(self, key): """Detect spacebar press and Ctrl+C combination.""" self.pressed_keys.add(key) # Add the pressed key to the set + if keyboard.Key.space in self.pressed_keys: self.toggle_recording(True) - elif {keyboard.Key.ctrl, keyboard.KeyCode.from_char("c")} <= self.pressed_keys: + elif {keyboard.Key.ctrl, keyboard.KeyCode.from_char('c')} <= self.pressed_keys: logger.info("Ctrl+C pressed. Exiting...") kill_process_tree() os._exit(0) + + # Windows alternative to the above + if key == keyboard.Key.ctrl_l: + self.ctrl_pressed = True + + try: + if key.vk == 67 and self.ctrl_pressed: + logger.info("Ctrl+C pressed. Exiting...") + kill_process_tree() + os._exit(0) + # For non-character keys + except: + pass + + def on_release(self, key): """Detect spacebar release and 'c' key press for camera, and handle key release.""" - self.pressed_keys.discard( - key - ) # Remove the released key from the key press tracking set + self.pressed_keys.discard(key) # Remove the released key from the key press tracking set + if key == keyboard.Key.ctrl_l: + self.ctrl_pressed = False if key == keyboard.Key.space: self.toggle_recording(False) - elif CAMERA_ENABLED and key == keyboard.KeyCode.from_char("c"): + elif CAMERA_ENABLED and key == keyboard.KeyCode.from_char('c'): self.fetch_image_from_camera() async def message_sender(self, websocket): diff --git a/software/source/server/utils/process_utils.py b/software/source/server/utils/process_utils.py index 586e4c6..5337bae 100644 --- a/software/source/server/utils/process_utils.py +++ b/software/source/server/utils/process_utils.py @@ -7,7 +7,11 @@ def kill_process_tree(): pid = os.getpid() # Get the current process ID try: # Send SIGTERM to the entire process group to ensure all processes are targeted - os.killpg(os.getpgid(pid), signal.SIGKILL) + try: + os.killpg(os.getpgid(pid), signal.SIGKILL) + # Windows implementation + except AttributeError: + os.kill(pid, signal.SIGTERM) parent = psutil.Process(pid) children = parent.children(recursive=True) for child in children: From f365b6a515b3b1b5125277a66256cccd551400e3 Mon Sep 17 00:00:00 2001 From: Abdullah Gohar Date: Sun, 14 Apr 2024 07:50:03 +0500 Subject: [PATCH 08/39] Fixed non-connection issue --- software/source/clients/base_device.py | 15 +++++++++++++-- software/start.py | 6 ++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/software/source/clients/base_device.py b/software/source/clients/base_device.py index a614498..5d60c50 100644 --- a/software/source/clients/base_device.py +++ b/software/source/clients/base_device.py @@ -3,6 +3,7 @@ from dotenv import load_dotenv load_dotenv() # take environment variables from .env. import os +import sys import asyncio import threading import pyaudio @@ -58,7 +59,17 @@ CAMERA_WARMUP_SECONDS = float(os.getenv("CAMERA_WARMUP_SECONDS", 0)) # Specify OS current_platform = get_system_info() -is_win10 = lambda: platform.system() == "Windows" and "10" in platform.version() + +def is_win11(): + return sys.getwindowsversion().build >= 22000 + +def is_win10(): + try: + return platform.system() == "Windows" and "10" in platform.version() and not is_win11() + except: + return False + +print(platform.system(), platform.version()) # Initialize PyAudio p = pyaudio.PyAudio() @@ -359,7 +370,7 @@ class Device: code = message["content"] result = interpreter.computer.run(language, code) send_queue.put(result) - + if is_win10(): logger.info("Windows 10 detected") # Workaround for Windows 10 not latching to the websocket server. diff --git a/software/start.py b/software/start.py index 4f3377f..6016d17 100644 --- a/software/start.py +++ b/software/start.py @@ -122,6 +122,10 @@ def _run( # llm_service = "llamafile" stt_service = "local-whisper" select_local_model() + + system_type = platform.system() + if system_type == "Windows": + server_host = "localhost" if not server_url: server_url = f"{server_host}:{server_port}" @@ -129,6 +133,8 @@ def _run( if not server and not client: server = True client = True + + def handle_exit(signum, frame): os._exit(0) From 325fcea15bb8d2b1cf2cbd3ff98a39a0d17d7666 Mon Sep 17 00:00:00 2001 From: Abdullah Gohar Date: Sun, 14 Apr 2024 07:58:16 +0500 Subject: [PATCH 09/39] Removed redundant logging lines --- software/source/clients/base_device.py | 1 - 1 file changed, 1 deletion(-) diff --git a/software/source/clients/base_device.py b/software/source/clients/base_device.py index 5d60c50..087ca55 100644 --- a/software/source/clients/base_device.py +++ b/software/source/clients/base_device.py @@ -69,7 +69,6 @@ def is_win10(): except: return False -print(platform.system(), platform.version()) # Initialize PyAudio p = pyaudio.PyAudio() From 471b5f8a8440123f1a40726c2e7f38f13ad348bf Mon Sep 17 00:00:00 2001 From: Robert Brisita <986796+rbrisita@users.noreply.github.com> Date: Thu, 4 Apr 2024 17:40:46 -0400 Subject: [PATCH 10/39] Adding checks for dmesg log access and creation of it if not accessible. --- software/source/server/utils/kernel.py | 32 +++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/software/source/server/utils/kernel.py b/software/source/server/utils/kernel.py index fcca107..d3ce75c 100644 --- a/software/source/server/utils/kernel.py +++ b/software/source/server/utils/kernel.py @@ -5,12 +5,17 @@ load_dotenv() # take environment variables from .env. import asyncio import subprocess import platform +import os +import shutil from .logs import setup_logging from .logs import logger setup_logging() +# dmesg process created at boot time +dmesg_proc = None + def get_kernel_messages(): """ @@ -25,12 +30,37 @@ def get_kernel_messages(): output, _ = process.communicate() return output.decode("utf-8") elif current_platform == "Linux": - with open("/var/log/dmesg", "r") as file: + log_path = get_dmesg_log_path() + with open(log_path, 'r') as file: return file.read() else: logger.info("Unsupported platform.") +def get_dmesg_log_path(): + """ + Check for the existence of a readable dmesg log file and return its path. + Create an accessible path if not found. + """ + if os.access('/var/log/dmesg', os.F_OK | os.R_OK): + return '/var/log/dmesg' + + global dmesg_proc + dmesg_log_path = '/tmp/dmesg' + if dmesg_proc: + return dmesg_log_path + + logger.info("Created /tmp/dmesg.") + subprocess.run(['touch', dmesg_log_path]) + dmesg_path = shutil.which('dmesg') + if dmesg_path: + logger.info(f"Writing to {dmesg_log_path} from dmesg.") + dmesg_proc = subprocess.Popen([dmesg_path, '--follow'], text=True, stdout=subprocess.PIPE) + subprocess.Popen(['tee', dmesg_log_path], text=True, stdin=dmesg_proc.stdout, stdout=subprocess.DEVNULL) + + return dmesg_log_path + + def custom_filter(message): # Check for {TO_INTERPRETER{ message here }TO_INTERPRETER} pattern if "{TO_INTERPRETER{" in message and "}TO_INTERPRETER}" in message: From 3fcd0fb2386f31b3dd8510daff488871736f2969 Mon Sep 17 00:00:00 2001 From: Ikko Eltociear Ashimine Date: Tue, 16 Apr 2024 15:50:02 +0900 Subject: [PATCH 11/39] Add README_JA.md --- docs/README_JA.md | 155 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/README_JA.md diff --git a/docs/README_JA.md b/docs/README_JA.md new file mode 100644 index 0000000..f49a56c --- /dev/null +++ b/docs/README_JA.md @@ -0,0 +1,155 @@ +

+ +

+ Discord +
+
+ オープンソースの言語モデルコンピュータ。
+
Light の予約‎ ‎ |‎ ‎ 最新情報‎ ‎ |‎ ‎ ドキュメント
+

+ +
+ +![OI-O1-BannerDemo-2](https://www.openinterpreter.com/OI-O1-BannerDemo-3.jpg) + +あなたのビルドをサポートします。[1対1のサポートを申し込む。](https://0ggfznkwh4j.typeform.com/to/kkStE8WF) + +
+ +> [!IMPORTANT] +> この実験的なプロジェクトは急速に開発が進んでおり、基本的な安全策が欠けています。安定した `1.0` リリースまでは、機密情報や有料サービスへのアクセスがないデバイスでのみこのリポジトリを実行してください。 +> +> **これらの懸念やその他の懸念に対処するための大幅な書き換えが[ここ](https://github.com/KillianLucas/01-rewrite/tree/main)で行われています。** + +
+ +**01 プロジェクト** は、AI 機器のためのオープンソースのエコシステムを構築しています。 + +私たちの主力オペレーティングシステムは、Rabbit R1、Humane Pin、[Star Trek computer](https://www.youtube.com/watch?v=1ZXugicgn6U) のような会話デバイスを動かすことができます。 + +私たちは、オープンでモジュラーでフリーであり続けることで、この分野の GNU/Linux になるつもりです。 + +
+ +# ソフトウェア + +```shell +git clone https://github.com/OpenInterpreter/01 # リポジトリのクローン +cd 01/software # CD でソースディレクトリに移動 +``` + + + +```shell +brew install portaudio ffmpeg cmake # Mac OSXの依存関係のインストール +poetry install # Pythonの依存関係のインストール +export OPENAI_API_KEY=sk... # または、`poetry run 01 --local` を実行し、ローカルですべてを実行 +poetry run 01 # 01 Light シミュレーターを作動させる(スペースバーを押しながら話し、放す) +``` + + + +
+ +# ハードウェア + +- **01 Light** は ESP32 ベースの音声インターフェースです。ビルド手順は[こちら](https://github.com/OpenInterpreter/01/tree/main/hardware/light)。買うべきもののリストは[こちら](https://github.com/OpenInterpreter/01/blob/main/hardware/light/BOM.md)。 +- ご自宅のコンピューターで動作している **01 サーバー**([下記のセットアップガイド](https://github.com/OpenInterpreter/01/blob/main/README.md#01-server))と連動して動作します。 +- **Mac OSX** と **Ubuntu** は `poetry run 01` を実行することでサポートされます(**Windows** は実験的にサポートされている)。これはスペースキーを使って 01 Light をシミュレートします。 +- (近日発表) **01 Heavy** は、ローカルですべてを実行するスタンドアローンデバイスです。 + +**より多くのハードウェアをサポートし、構築するためには、皆さんの協力が必要です。** 01 は、入力(マイク、キーボードなど)、出力(スピーカー、スクリーン、モーターなど)、インターネット接続(またはローカルですべてを実行するのに十分な計算能力)があれば、どのようなデバイスでも実行できるはずです。[コントリビューションガイド →](https://github.com/OpenInterpreter/01/blob/main/CONTRIBUTING.md) + +
+ +# 何をするのか? + +01 は、`localhost:10001` で音声合成ウェブソケットを公開しています。 + +生のオーディオバイトを[ストリーミング LMC フォーマット](https://docs.openinterpreter.com/guides/streaming-response)で `/` にストリーミングすると、同じフォーマットで応答を受け取ります。 + +[Andrej Karpathy の LLM OS](https://twitter.com/karpathy/status/1723140519554105733) に一部インスパイアされ、[コード解釈言語モデル](https://github.com/OpenInterpreter/open-interpreter)を実行し、コンピュータの[カーネル](https://github.com/OpenInterpreter/01/blob/main/software/source/server/utils/kernel.py)で特定のイベントが発生したときにそれを呼び出します。 + +01 はこれを音声インターフェースで包んでいます: + +
+ +LMC

+ +# プロトコル + +## LMC メッセージ + +このシステムのさまざまなコンポーネントと通信するために、[LMC メッセージ](https://docs.openinterpreter.com/protocols/lmc-messages)フォーマットを導入します。これは、OpenAI のメッセージフォーマットを拡張し、"computer" の役割を含むようにしたものです: + +https://github.com/OpenInterpreter/01/assets/63927363/8621b075-e052-46ba-8d2e-d64b9f2a5da9 + +## ダイナミックシステムメッセージ + +ダイナミックシステムメッセージは、LLM のシステムメッセージが AI に表示される一瞬前に、その中でコードを実行することを可能にします。 + +```python +# i.py の以下の設定を編集 +interpreter.system_message = r" The time is {{time.time()}}. " # 二重括弧の中は Python として実行されます +interpreter.chat("What time is it?") # ツール/API を呼び出すことなく、次のことが分かります +``` + +# ガイド + +## 01 サーバー + +デスクトップ上でサーバーを起動し、01 Light に接続するには、以下のコマンドを実行します: + +```shell +brew install ngrok/ngrok/ngrok +ngrok authtoken ... # ngrok authtoken を使用 +poetry run 01 --server --expose +``` + +最後のコマンドは、サーバーの URL を表示します。これを 01 Light のキャプティブ WiFi ポータルに入力すると、01 Server に接続できます。 + +## ローカルモード + +``` +poetry run 01 --local +``` + +Whisper を使ってローカル音声合成を実行したい場合、Rust をインストールする必要があります。[こちら](https://www.rust-lang.org/tools/install)の指示に従ってください。 + +## カスタマイズ + +システムの動作をカスタマイズするには、`i.py` 内の[システムメッセージ、モデル、スキルライブラリのパス](https://docs.openinterpreter.com/settings/all-settings)などを編集します。このファイルはインタープリターをセットアップするもので、Open Interpreter によって動作します。 + +## Ubuntu 依存関係 + +```bash +sudo apt-get install portaudio19-dev ffmpeg cmake +``` + +# コントリビューター + +[![01 project contributors](https://contrib.rocks/image?repo=OpenInterpreter/01&max=2000)](https://github.com/OpenInterpreter/01/graphs/contributors) + +参加方法の詳細については、[コントリビューションガイド](CONTRIBUTING.md)をご覧ください。 + +
+ +# ロードマップ + +01 の未来を見るには、[私達のロードマップ](/ROADMAP.md)をご覧ください。 + +
+ +## バックグラウンド + +### [コンテキスト ↗](https://github.com/KillianLucas/01/blob/main/CONTEXT.md) + +01 以前のデバイスの物語。 + +### [インスピレーション ↗](https://github.com/KillianLucas/01/tree/main/INSPIRATION.md) + +素晴らしいアイデアは盗みたいと思うもの。 + +
+ +○ From 59e32562a8bb6bc63c371459ca31ea76dfc6045b Mon Sep 17 00:00:00 2001 From: Ikko Eltociear Ashimine Date: Tue, 16 Apr 2024 15:51:15 +0900 Subject: [PATCH 12/39] Update README_JA.md --- docs/README_JA.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/README_JA.md b/docs/README_JA.md index f49a56c..868dae8 100644 --- a/docs/README_JA.md +++ b/docs/README_JA.md @@ -130,7 +130,7 @@ sudo apt-get install portaudio19-dev ffmpeg cmake [![01 project contributors](https://contrib.rocks/image?repo=OpenInterpreter/01&max=2000)](https://github.com/OpenInterpreter/01/graphs/contributors) -参加方法の詳細については、[コントリビューションガイド](CONTRIBUTING.md)をご覧ください。 +参加方法の詳細については、[コントリビューションガイド](/CONTRIBUTING.md)をご覧ください。
From e397f8819f37d88e7af320ba1c7b5c6788c36ead Mon Sep 17 00:00:00 2001 From: Ben Xu Date: Wed, 24 Apr 2024 12:54:18 -0400 Subject: [PATCH 13/39] add binary audio parsing --- .../ios/react-native/package-lock.json | 13 +++ .../clients/ios/react-native/package.json | 8 +- .../ios/react-native/src/screens/Main.tsx | 99 +++++++++++++++---- software/source/server/server.py | 18 +++- software/source/server/tunnel.py | 2 +- software/source/utils/accumulator.py | 4 +- 6 files changed, 115 insertions(+), 29 deletions(-) diff --git a/software/source/clients/ios/react-native/package-lock.json b/software/source/clients/ios/react-native/package-lock.json index 81e2031..97b3864 100644 --- a/software/source/clients/ios/react-native/package-lock.json +++ b/software/source/clients/ios/react-native/package-lock.json @@ -17,12 +17,14 @@ "expo-status-bar": "~1.11.1", "react": "18.2.0", "react-native": "0.73.4", + "react-native-base64": "^0.2.1", "react-native-safe-area-context": "4.8.2", "react-native-screens": "~3.29.0" }, "devDependencies": { "@babel/core": "^7.20.0", "@types/react": "~18.2.45", + "@types/react-native-base64": "^0.2.2", "typescript": "^5.1.3" } }, @@ -6102,6 +6104,12 @@ "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", @@ -11492,6 +11500,11 @@ "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-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", diff --git a/software/source/clients/ios/react-native/package.json b/software/source/clients/ios/react-native/package.json index c031609..86faf84 100644 --- a/software/source/clients/ios/react-native/package.json +++ b/software/source/clients/ios/react-native/package.json @@ -13,18 +13,20 @@ "@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-status-bar": "~1.11.1", "react": "18.2.0", "react-native": "0.73.4", + "react-native-base64": "^0.2.1", "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" + "react-native-screens": "~3.29.0" }, "devDependencies": { "@babel/core": "^7.20.0", "@types/react": "~18.2.45", + "@types/react-native-base64": "^0.2.2", "typescript": "^5.1.3" }, "ios": { diff --git a/software/source/clients/ios/react-native/src/screens/Main.tsx b/software/source/clients/ios/react-native/src/screens/Main.tsx index 3823c43..966c8af 100644 --- a/software/source/clients/ios/react-native/src/screens/Main.tsx +++ b/software/source/clients/ios/react-native/src/screens/Main.tsx @@ -1,6 +1,9 @@ import React, { useState, useEffect } from "react"; import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; -import { Audio } from "expo-av"; +import * as FileSystem from 'expo-file-system'; +import { AVPlaybackStatus, Audio } from "expo-av"; +import { Buffer } from "buffer"; +import base64 from 'react-native-base64'; interface MainProps { route: { @@ -18,39 +21,83 @@ const Main: React.FC = ({ route }) => { const [ws, setWs] = useState(null); const [recording, setRecording] = useState(null); const [audioQueue, setAudioQueue] = useState([]); + const [isPlaying, setIsPlaying] = useState(false); + const Buffer = require('buffer/').Buffer; + + const constructTempFilePath = async (buffer: Buffer) => { + const tempFilePath = `${FileSystem.cacheDirectory}${Date.now()}` + "speech.mp3"; + await FileSystem.writeAsStringAsync( + tempFilePath, + buffer.toString("base64"), + { + encoding: FileSystem.EncodingType.Base64, + } + ); - 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(); - } - }); + return tempFilePath; + }; + + const playNextAudio = async () => { + console.log("in playNextAudio audioQueue is", audioQueue); + console.log("isPlaying is", isPlaying); + + if (audioQueue.length > 0) { + const uri = audioQueue.shift() as string; + console.log("load audio from", uri); + setIsPlaying(true); + + try { + const { sound } = await Audio.Sound.createAsync({ uri }); + await sound.playAsync(); + console.log("playing audio from", uri); + + sound.setOnPlaybackStatusUpdate(_onPlaybackStatusUpdate); + } catch (error){ + console.log("Error playing audio", error); + setIsPlaying(false); + playNextAudio(); } - }; + } + }; + + const _onPlaybackStatusUpdate = (status: AVPlaybackStatus) => { + if (status.isLoaded && status.didJustFinish) { + setIsPlaying(false); + playNextAudio(); + } + }; + + useEffect(() => { let websocket: WebSocket; try { console.log("Connecting to WebSocket at " + scannedData); websocket = new WebSocket(scannedData); + websocket.binaryType = "blob"; 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) { + const message = JSON.parse(e.data); + + if (message.content) { + + const parsedMessage = message.content.replace(/^b'|['"]|['"]$/g, ""); + const buffer = Buffer.from(parsedMessage, 'base64') + console.log("parsed message", buffer.toString()); + + const uri = await constructTempFilePath(buffer); + setAudioQueue((prevQueue) => [...prevQueue, uri]); + } + + if (message.format === "bytes.raw" && message.end) { + console.log("calling playNextAudio"); playNextAudio(); } + }; websocket.onerror = (error) => { @@ -74,7 +121,7 @@ const Main: React.FC = ({ route }) => { websocket.close(); } }; - }, [scannedData, audioQueue]); + }, [scannedData]); const startRecording = async () => { if (recording) { @@ -108,7 +155,17 @@ const Main: React.FC = ({ route }) => { const uri = recording.getURI(); console.log("Recording stopped and stored at", uri); if (ws && uri) { - ws.send(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); + console.log("sent audio bytes to WebSocket"); + } + }; } } }; diff --git a/software/source/server/server.py b/software/source/server/server.py index c4dd036..444298d 100644 --- a/software/source/server/server.py +++ b/software/source/server/server.py @@ -20,6 +20,7 @@ from interpreter import interpreter from ..utils.accumulator import Accumulator from .utils.logs import setup_logging from .utils.logs import logger +import base64 from ..utils.print_markdown import print_markdown @@ -194,13 +195,24 @@ 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): - await websocket.send_bytes(message) + message = base64.b64encode(message) + str_bytes = str(message) + json_bytes = { + "role": "assistant", + "type": "audio", + "format": "message", + "content": str_bytes, + } + print( + f"Sending to the device: {type(json_bytes)} {str(json_bytes)[:100]}" + ) + await websocket.send_json(json_bytes) else: raise TypeError("Message must be a dict or bytes") except: @@ -286,7 +298,7 @@ async def listener(): logger.debug("Got chunk:", chunk) # Send it to the user - await to_device.put(chunk) + # await to_device.put(chunk) # Yield to the event loop, so you actually send it out await asyncio.sleep(0.01) diff --git a/software/source/server/tunnel.py b/software/source/server/tunnel.py index 809db08..0e0ad17 100644 --- a/software/source/server/tunnel.py +++ b/software/source/server/tunnel.py @@ -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 --domain=sterling-snail-conversely.ngrok-free.app --log=stdout", shell=True, stdout=subprocess.PIPE, ) diff --git a/software/source/utils/accumulator.py b/software/source/utils/accumulator.py index 37912b5..9f66e89 100644 --- a/software/source/utils/accumulator.py +++ b/software/source/utils/accumulator.py @@ -44,4 +44,6 @@ class Accumulator: if "content" not in self.message or type(self.message["content"]) != bytes: self.message["content"] = b"" self.message["content"] += chunk - return None + self.message["type"] = "audio" + self.message["format"] = "bytes.wav" + return self.message From 163b28383cd3f49ea6d29e43be56f8c322e0a733 Mon Sep 17 00:00:00 2001 From: Ben Xu Date: Wed, 24 Apr 2024 17:47:20 -0400 Subject: [PATCH 14/39] integrate working toy --- .../ios/react-native/src/screens/Main.tsx | 97 +++++++++++-------- 1 file changed, 54 insertions(+), 43 deletions(-) diff --git a/software/source/clients/ios/react-native/src/screens/Main.tsx b/software/source/clients/ios/react-native/src/screens/Main.tsx index 966c8af..4bbde42 100644 --- a/software/source/clients/ios/react-native/src/screens/Main.tsx +++ b/software/source/clients/ios/react-native/src/screens/Main.tsx @@ -1,9 +1,7 @@ import React, { useState, useEffect } from "react"; import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; import * as FileSystem from 'expo-file-system'; -import { AVPlaybackStatus, Audio } from "expo-av"; -import { Buffer } from "buffer"; -import base64 from 'react-native-base64'; +import { Audio } from "expo-av"; interface MainProps { route: { @@ -15,58 +13,58 @@ interface MainProps { const Main: React.FC = ({ route }) => { const { scannedData } = route.params; - - const [connectionStatus, setConnectionStatus] = - useState("Connecting..."); + const [connectionStatus, setConnectionStatus] = useState("Connecting..."); const [ws, setWs] = useState(null); const [recording, setRecording] = useState(null); const [audioQueue, setAudioQueue] = useState([]); - const [isPlaying, setIsPlaying] = useState(false); - const Buffer = require('buffer/').Buffer; - - const constructTempFilePath = async (buffer: Buffer) => { - const tempFilePath = `${FileSystem.cacheDirectory}${Date.now()}` + "speech.mp3"; - await FileSystem.writeAsStringAsync( - tempFilePath, - buffer.toString("base64"), - { - encoding: FileSystem.EncodingType.Base64, - } - ); - - return tempFilePath; - }; + const [sound, setSound] = useState(); + const audioDir = FileSystem.documentDirectory + '01/audio/'; + + + async function dirExists() { + /** + * Checks if audio directory exists in device storage, if not creates it. + */ + const dirInfo = await FileSystem.getInfoAsync(audioDir); + if (!dirInfo.exists) { + console.log("audio directory doesn't exist, creating..."); + await FileSystem.makeDirectoryAsync(audioDir, { intermediates: true }); + } + } const playNextAudio = async () => { - console.log("in playNextAudio audioQueue is", audioQueue); - console.log("isPlaying is", isPlaying); + await dirExists(); + console.log("in playNextAudio audioQueue is", audioQueue.length); if (audioQueue.length > 0) { const uri = audioQueue.shift() as string; console.log("load audio from", uri); - setIsPlaying(true); try { const { sound } = await Audio.Sound.createAsync({ uri }); - await sound.playAsync(); - console.log("playing audio from", uri); + setSound(sound); - sound.setOnPlaybackStatusUpdate(_onPlaybackStatusUpdate); + console.log("playing audio from", uri); + await sound?.playAsync(); } catch (error){ console.log("Error playing audio", error); - setIsPlaying(false); playNextAudio(); } } }; - const _onPlaybackStatusUpdate = (status: AVPlaybackStatus) => { - if (status.isLoaded && status.didJustFinish) { - setIsPlaying(false); - playNextAudio(); - } - }; + useEffect(() => { + return sound + ? () => { + console.log('Unloading Sound'); + sound.unloadAsync(); + setSound(null); + playNextAudio(); + } + : undefined; + }, [sound]); + useEffect(() => { let websocket: WebSocket; @@ -84,13 +82,21 @@ const Main: React.FC = ({ route }) => { const message = JSON.parse(e.data); if (message.content) { - const parsedMessage = message.content.replace(/^b'|['"]|['"]$/g, ""); - const buffer = Buffer.from(parsedMessage, 'base64') - console.log("parsed message", buffer.toString()); + console.log("parsedMessage", parsedMessage.slice(0, 30)); + + const filePath = `${audioDir}${Date.now()}.mp3`; + await FileSystem.writeAsStringAsync( + filePath, + parsedMessage, + { + encoding: FileSystem.EncodingType.Base64, + } + ); + + console.log("audio file written to", filePath); - const uri = await constructTempFilePath(buffer); - setAudioQueue((prevQueue) => [...prevQueue, uri]); + setAudioQueue((prevQueue) => [...prevQueue, filePath]); } if (message.format === "bytes.raw" && message.end) { @@ -138,7 +144,7 @@ const Main: React.FC = ({ route }) => { }); console.log("Starting recording.."); const { recording: newRecording } = await Audio.Recording.createAsync( - Audio.RECORDING_OPTIONS_PRESET_HIGH_QUALITY + Audio.RecordingOptionsPresets.HIGH_QUALITY ); setRecording(newRecording); console.log("Recording started"); @@ -152,8 +158,12 @@ const Main: React.FC = ({ route }) => { setRecording(null); if (recording) { await recording.stopAndUnloadAsync(); + await Audio.setAudioModeAsync({ + allowsRecordingIOS: false, + }); const uri = recording.getURI(); console.log("Recording stopped and stored at", uri); + if (ws && uri) { const response = await fetch(uri); const blob = await response.blob(); @@ -191,14 +201,15 @@ const Main: React.FC = ({ route }) => { ); -}; +} const styles = StyleSheet.create({ container: { flex: 1, - justifyContent: "center", + justifyContent: 'center', alignItems: "center", - backgroundColor: "#fff", + backgroundColor: '#ecf0f1', + padding: 10, }, circle: { width: 100, From f673744f1be0d41f57e2d02223e55857f5ec7b50 Mon Sep 17 00:00:00 2001 From: Ben Xu Date: Wed, 24 Apr 2024 20:46:36 -0400 Subject: [PATCH 15/39] upload file to gcloud storage --- .../ios/react-native/src/screens/Main.tsx | 69 +++++++++++++++---- software/source/server/server.py | 20 ++++++ 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/software/source/clients/ios/react-native/src/screens/Main.tsx b/software/source/clients/ios/react-native/src/screens/Main.tsx index 4bbde42..d6a69cb 100644 --- a/software/source/clients/ios/react-native/src/screens/Main.tsx +++ b/software/source/clients/ios/react-native/src/screens/Main.tsx @@ -19,6 +19,37 @@ const Main: React.FC = ({ route }) => { const [audioQueue, setAudioQueue] = useState([]); const [sound, setSound] = useState(); const audioDir = FileSystem.documentDirectory + '01/audio/'; + const Buffer = require('buffer').Buffer; + + const toBuffer = async (blob: Blob) => { + + const uri = await toDataURI(blob); + const base64 = uri.replace(/^.*,/g, ""); + return Buffer.from(base64, "base64"); + }; + + const toDataURI = (blob: Blob) => + new Promise((resolve) => { + const reader = new FileReader(); + reader.readAsDataURL(blob); + reader.onloadend = () => { + const uri = reader.result?.toString(); + resolve(uri); + }; + }); + + const constructTempFilePath = async (buffer: Buffer) => { + const tempFilePath = `${audioDir}${Date.now()}.wav`; + await FileSystem.writeAsStringAsync( + tempFilePath, + buffer.toString(), + { + encoding: FileSystem.EncodingType.Base64, + } + ); + + return tempFilePath; + }; async function dirExists() { @@ -33,7 +64,6 @@ const Main: React.FC = ({ route }) => { } const playNextAudio = async () => { - await dirExists(); console.log("in playNextAudio audioQueue is", audioQueue.length); if (audioQueue.length > 0) { @@ -65,6 +95,12 @@ const Main: React.FC = ({ route }) => { : undefined; }, [sound]); + useEffect(() => { + console.log("audioQueue has been updated:", audioQueue.length); + if (audioQueue.length == 1) { + playNextAudio(); + } + }, [audioQueue]); useEffect(() => { let websocket: WebSocket; @@ -79,31 +115,38 @@ const Main: React.FC = ({ route }) => { }; websocket.onmessage = async (e) => { + console.log("Received message from WebSocket", e.data); + + const blob = await e.data; + const buffer = await toBuffer(blob); + const filePath = await constructTempFilePath(buffer); + setAudioQueue((prevQueue) => [...prevQueue, filePath]); + console.log("audio file written to", filePath); + + if (e.data.format === "bytes.raw" && e.data.end && audioQueue.length > 1) { + console.log("calling playNextAudio"); + playNextAudio(); + } + + /** const message = JSON.parse(e.data); if (message.content) { const parsedMessage = message.content.replace(/^b'|['"]|['"]$/g, ""); console.log("parsedMessage", parsedMessage.slice(0, 30)); - const filePath = `${audioDir}${Date.now()}.mp3`; - await FileSystem.writeAsStringAsync( - filePath, - parsedMessage, - { - encoding: FileSystem.EncodingType.Base64, - } - ); - - console.log("audio file written to", filePath); - + const filePath = await constructFilePath(parsedMessage); setAudioQueue((prevQueue) => [...prevQueue, filePath]); + console.log("audio file written to", filePath); } - if (message.format === "bytes.raw" && message.end) { + if (message.format === "bytes.raw" && message.end && audioQueue.length > 1) { console.log("calling playNextAudio"); playNextAudio(); } + */ + }; websocket.onerror = (error) => { diff --git a/software/source/server/server.py b/software/source/server/server.py index 444298d..2328f52 100644 --- a/software/source/server/server.py +++ b/software/source/server/server.py @@ -21,6 +21,7 @@ from ..utils.accumulator import Accumulator from .utils.logs import setup_logging from .utils.logs import logger import base64 +from google.cloud import storage from ..utils.print_markdown import print_markdown @@ -202,6 +203,10 @@ async def send_messages(websocket: WebSocket): await websocket.send_json(message) elif isinstance(message, bytes): message = base64.b64encode(message) + print(f"Sending to the device: {type(message)} {str(message)[:100]}") + await websocket.send_bytes(message) + + """ str_bytes = str(message) json_bytes = { "role": "assistant", @@ -213,6 +218,7 @@ async def send_messages(websocket: WebSocket): f"Sending to the device: {type(json_bytes)} {str(json_bytes)[:100]}" ) await websocket.send_json(json_bytes) + """ else: raise TypeError("Message must be a dict or bytes") except: @@ -254,6 +260,7 @@ async def listener(): # Format will be bytes.wav or bytes.opus mime_type = "audio/" + message["format"].split(".")[1] audio_file_path = bytes_to_wav(message["content"], mime_type) + print("Audio file path:", audio_file_path) # For microphone debugging: if False: @@ -387,6 +394,19 @@ def stream_tts(sentence): with open(audio_file, "rb") as f: audio_bytes = f.read() + + storage_client = storage.Client(project="react-native-421323") + bucket = storage_client.bucket("01-audio") + blob = bucket.blob(f"{datetime.datetime.now().strftime('%Y%m%d%H%M%S%f')}.wav") + generation_match_precondition = 0 + + blob.upload_from_filename( + audio_file, if_generation_match=generation_match_precondition + ) + print( + f"Audio file {audio_file} uploaded to {datetime.datetime.now().strftime('%Y%m%d%H%M%S%f')}.wav" + ) + os.remove(audio_file) file_type = "bytes.raw" From 0602348f1c377445a4c648dca4bbb20f0a585934 Mon Sep 17 00:00:00 2001 From: Ty Fiero Date: Wed, 24 Apr 2024 17:59:41 -0700 Subject: [PATCH 16/39] send wav files --- .gitignore | 3 ++ software/source/server/server.py | 45 +++++++++---------- .../source/server/services/tts/openai/tts.py | 4 +- software/source/server/tunnel.py | 2 +- 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index aeaed36..da03d3f 100644 --- a/.gitignore +++ b/.gitignore @@ -169,3 +169,6 @@ cython_debug/ _.aifs software/output_audio.wav .DS_Store + +node_modules/ +.expo/ \ No newline at end of file diff --git a/software/source/server/server.py b/software/source/server/server.py index 2328f52..dd71b06 100644 --- a/software/source/server/server.py +++ b/software/source/server/server.py @@ -21,8 +21,7 @@ from ..utils.accumulator import Accumulator from .utils.logs import setup_logging from .utils.logs import logger import base64 -from google.cloud import storage - +import shutil from ..utils.print_markdown import print_markdown os.environ["STT_RUNNER"] = "server" @@ -394,31 +393,31 @@ def stream_tts(sentence): with open(audio_file, "rb") as f: audio_bytes = f.read() - - storage_client = storage.Client(project="react-native-421323") - bucket = storage_client.bucket("01-audio") - blob = bucket.blob(f"{datetime.datetime.now().strftime('%Y%m%d%H%M%S%f')}.wav") - generation_match_precondition = 0 - - blob.upload_from_filename( - audio_file, if_generation_match=generation_match_precondition - ) - print( - f"Audio file {audio_file} uploaded to {datetime.datetime.now().strftime('%Y%m%d%H%M%S%f')}.wav" - ) + desktop_path = os.path.join(os.path.expanduser('~'), 'Desktop') + desktop_audio_file = os.path.join(desktop_path, os.path.basename(audio_file)) + shutil.copy(audio_file, desktop_audio_file) + print(f"Audio file saved to Desktop: {desktop_audio_file}") + # storage_client = storage.Client(project="react-native-421323") + # bucket = storage_client.bucket("01-audio") + # blob = bucket.blob(f"{datetime.datetime.now().strftime('%Y%m%d%H%M%S%f')}.wav") + # generation_match_precondition = 0 + + # blob.upload_from_filename( + # audio_file, if_generation_match=generation_match_precondition + # ) + # print( + # f"Audio file {audio_file} uploaded to {datetime.datetime.now().strftime('%Y%m%d%H%M%S%f')}.wav" + # ) os.remove(audio_file) - file_type = "bytes.raw" - chunk_size = 1024 - - # 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} + file_type = "audio/wav" + # Read the entire WAV file + with open(audio_file, "rb") as f: + audio_bytes = f.read() + # Stream the audio as a single message + yield {"role": "assistant", "type": "audio", "format": file_type, "content": base64.b64encode(audio_bytes).decode('utf-8'), "start": True, "end": True} from uvicorn import Config, Server import os diff --git a/software/source/server/services/tts/openai/tts.py b/software/source/server/services/tts/openai/tts.py index 07e1eec..021353b 100644 --- a/software/source/server/services/tts/openai/tts.py +++ b/software/source/server/services/tts/openai/tts.py @@ -36,9 +36,9 @@ class Tts: response.stream_to_file(temp_file.name) # TODO: hack to format audio correctly for device - outfile = tempfile.gettempdir() + "/" + "raw.dat" + outfile = tempfile.gettempdir() + "/" + "output.wav" ffmpeg.input(temp_file.name).output( - outfile, f="s16le", ar="16000", ac="1", loglevel="panic" + outfile, f="wav", ar="16000", ac="1", loglevel="panic" ).run() return outfile diff --git a/software/source/server/tunnel.py b/software/source/server/tunnel.py index 0e0ad17..f25a0b3 100644 --- a/software/source/server/tunnel.py +++ b/software/source/server/tunnel.py @@ -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=sterling-snail-conversely.ngrok-free.app --log=stdout", + f"ngrok http {server_port} --scheme http,https --log=stdout", shell=True, stdout=subprocess.PIPE, ) From 1e1db0836cd739923f63a037f2bc4e0f20ef4851 Mon Sep 17 00:00:00 2001 From: Ben Xu Date: Wed, 24 Apr 2024 22:09:59 -0400 Subject: [PATCH 17/39] queue audio --- .gitignore | 3 +- .../ios/react-native/src/screens/Main.tsx | 63 +++++++++---------- software/source/server/server.py | 20 ++++-- 3 files changed, 46 insertions(+), 40 deletions(-) diff --git a/.gitignore b/.gitignore index da03d3f..c0e95ec 100644 --- a/.gitignore +++ b/.gitignore @@ -170,5 +170,6 @@ _.aifs software/output_audio.wav .DS_Store +# ignore node modules and .expo files node_modules/ -.expo/ \ No newline at end of file +.expo/ diff --git a/software/source/clients/ios/react-native/src/screens/Main.tsx b/software/source/clients/ios/react-native/src/screens/Main.tsx index d6a69cb..a9a5ed3 100644 --- a/software/source/clients/ios/react-native/src/screens/Main.tsx +++ b/software/source/clients/ios/react-native/src/screens/Main.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from "react"; import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; import * as FileSystem from 'expo-file-system'; -import { Audio } from "expo-av"; +import { AVPlaybackStatus, AVPlaybackStatusSuccess, Audio } from "expo-av"; interface MainProps { route: { @@ -19,30 +19,14 @@ const Main: React.FC = ({ route }) => { const [audioQueue, setAudioQueue] = useState([]); const [sound, setSound] = useState(); const audioDir = FileSystem.documentDirectory + '01/audio/'; - const Buffer = require('buffer').Buffer; - const toBuffer = async (blob: Blob) => { + const constructTempFilePath = async (buffer: string) => { + await dirExists(); - const uri = await toDataURI(blob); - const base64 = uri.replace(/^.*,/g, ""); - return Buffer.from(base64, "base64"); - }; - - const toDataURI = (blob: Blob) => - new Promise((resolve) => { - const reader = new FileReader(); - reader.readAsDataURL(blob); - reader.onloadend = () => { - const uri = reader.result?.toString(); - resolve(uri); - }; - }); - - const constructTempFilePath = async (buffer: Buffer) => { const tempFilePath = `${audioDir}${Date.now()}.wav`; await FileSystem.writeAsStringAsync( tempFilePath, - buffer.toString(), + buffer, { encoding: FileSystem.EncodingType.Base64, } @@ -66,6 +50,12 @@ const Main: React.FC = ({ route }) => { const playNextAudio = async () => { console.log("in playNextAudio audioQueue is", audioQueue.length); + if (sound != null){ + console.log('Unloading Sound'); + await sound.unloadAsync(); + setSound(null); + } + if (audioQueue.length > 0) { const uri = audioQueue.shift() as string; console.log("load audio from", uri); @@ -76,6 +66,9 @@ const Main: React.FC = ({ route }) => { console.log("playing audio from", uri); await sound?.playAsync(); + + sound.setOnPlaybackStatusUpdate(_onPlayBackStatusUpdate); + } catch (error){ console.log("Error playing audio", error); playNextAudio(); @@ -84,16 +77,17 @@ const Main: React.FC = ({ route }) => { } }; - useEffect(() => { - return sound - ? () => { - console.log('Unloading Sound'); - sound.unloadAsync(); - setSound(null); - playNextAudio(); - } - : undefined; - }, [sound]); + const isAVPlaybackStatusSuccess = ( + status: AVPlaybackStatus + ): status is AVPlaybackStatusSuccess => { + return (status as AVPlaybackStatusSuccess).isLoaded !== undefined; + }; + + const _onPlayBackStatusUpdate = (status: AVPlaybackStatus) => { + if (isAVPlaybackStatusSuccess(status) && status.didJustFinish){ + playNextAudio(); + } + } useEffect(() => { console.log("audioQueue has been updated:", audioQueue.length); @@ -115,15 +109,16 @@ const Main: React.FC = ({ route }) => { }; websocket.onmessage = async (e) => { - console.log("Received message from WebSocket", e.data); - const blob = await e.data; - const buffer = await toBuffer(blob); + const message = JSON.parse(e.data); + console.log(message.content); + + const buffer = await message.content; const filePath = await constructTempFilePath(buffer); setAudioQueue((prevQueue) => [...prevQueue, filePath]); console.log("audio file written to", filePath); - if (e.data.format === "bytes.raw" && e.data.end && audioQueue.length > 1) { + if (message.format === "bytes.raw" && message.end && audioQueue.length >= 1) { console.log("calling playNextAudio"); playNextAudio(); } diff --git a/software/source/server/server.py b/software/source/server/server.py index dd71b06..a347026 100644 --- a/software/source/server/server.py +++ b/software/source/server/server.py @@ -393,8 +393,10 @@ def stream_tts(sentence): with open(audio_file, "rb") as f: audio_bytes = f.read() - desktop_path = os.path.join(os.path.expanduser('~'), 'Desktop') - desktop_audio_file = os.path.join(desktop_path, os.path.basename(audio_file)) + desktop_path = os.path.join(os.path.expanduser("~"), "Desktop") + desktop_audio_file = os.path.join( + desktop_path, f"{datetime.datetime.now()}" + os.path.basename(audio_file) + ) shutil.copy(audio_file, desktop_audio_file) print(f"Audio file saved to Desktop: {desktop_audio_file}") # storage_client = storage.Client(project="react-native-421323") @@ -409,15 +411,23 @@ def stream_tts(sentence): # f"Audio file {audio_file} uploaded to {datetime.datetime.now().strftime('%Y%m%d%H%M%S%f')}.wav" # ) - os.remove(audio_file) - file_type = "audio/wav" # Read the entire WAV file with open(audio_file, "rb") as f: audio_bytes = f.read() + os.remove(audio_file) + # Stream the audio as a single message - yield {"role": "assistant", "type": "audio", "format": file_type, "content": base64.b64encode(audio_bytes).decode('utf-8'), "start": True, "end": True} + yield { + "role": "assistant", + "type": "audio", + "format": file_type, + "content": base64.b64encode(audio_bytes).decode("utf-8"), + "start": True, + "end": True, + } + from uvicorn import Config, Server import os From d80bdbafe4918654c2451c9fbfcb3539e68e43db Mon Sep 17 00:00:00 2001 From: Ben Xu Date: Thu, 25 Apr 2024 06:15:15 -0400 Subject: [PATCH 18/39] reset accumulator on server to process multiple requests --- .../ios/react-native/package-lock.json | 86 ++++++++++++++++- .../clients/ios/react-native/package.json | 4 +- .../ios/react-native/src/screens/Main.tsx | 96 ++++++++++--------- software/source/server/server.py | 5 +- 4 files changed, 142 insertions(+), 49 deletions(-) diff --git a/software/source/clients/ios/react-native/package-lock.json b/software/source/clients/ios/react-native/package-lock.json index 97b3864..40bb500 100644 --- a/software/source/clients/ios/react-native/package-lock.json +++ b/software/source/clients/ios/react-native/package-lock.json @@ -18,8 +18,10 @@ "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" }, "devDependencies": { "@babel/core": "^7.20.0", @@ -6492,6 +6494,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", @@ -7852,6 +7860,12 @@ "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", @@ -10782,6 +10796,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", @@ -11505,6 +11528,40 @@ "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", @@ -11527,6 +11584,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", @@ -12589,6 +12658,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", @@ -12949,6 +13024,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", diff --git a/software/source/clients/ios/react-native/package.json b/software/source/clients/ios/react-native/package.json index 86faf84..6c2649a 100644 --- a/software/source/clients/ios/react-native/package.json +++ b/software/source/clients/ios/react-native/package.json @@ -20,8 +20,10 @@ "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" }, "devDependencies": { "@babel/core": "^7.20.0", diff --git a/software/source/clients/ios/react-native/src/screens/Main.tsx b/software/source/clients/ios/react-native/src/screens/Main.tsx index a9a5ed3..28d3e75 100644 --- a/software/source/clients/ios/react-native/src/screens/Main.tsx +++ b/software/source/clients/ios/react-native/src/screens/Main.tsx @@ -1,7 +1,8 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; import * as FileSystem from 'expo-file-system'; import { AVPlaybackStatus, AVPlaybackStatusSuccess, Audio } from "expo-av"; +import { polyfill as polyfillEncoding } from 'react-native-polyfill-globals/src/encoding'; interface MainProps { route: { @@ -19,18 +20,23 @@ const Main: React.FC = ({ route }) => { const [audioQueue, setAudioQueue] = useState([]); const [sound, setSound] = useState(); const audioDir = FileSystem.documentDirectory + '01/audio/'; + const [permissionResponse, requestPermission] = Audio.usePermissions(); + polyfillEncoding(); + const reader = new FileReader(); const constructTempFilePath = async (buffer: string) => { await dirExists(); - const tempFilePath = `${audioDir}${Date.now()}.wav`; - await FileSystem.writeAsStringAsync( - tempFilePath, - buffer, - { - encoding: FileSystem.EncodingType.Base64, - } - ); + + + await FileSystem.writeAsStringAsync( + tempFilePath, + buffer, + { + encoding: FileSystem.EncodingType.Base64, + } + ); + return tempFilePath; }; @@ -111,9 +117,9 @@ const Main: React.FC = ({ route }) => { websocket.onmessage = async (e) => { const message = JSON.parse(e.data); - console.log(message.content); + console.log(message.content.slice(0, 50)); - const buffer = await message.content; + const buffer = await message.content as string; const filePath = await constructTempFilePath(buffer); setAudioQueue((prevQueue) => [...prevQueue, filePath]); console.log("audio file written to", filePath); @@ -122,26 +128,6 @@ const Main: React.FC = ({ route }) => { console.log("calling playNextAudio"); playNextAudio(); } - - /** - const message = JSON.parse(e.data); - - if (message.content) { - const parsedMessage = message.content.replace(/^b'|['"]|['"]$/g, ""); - console.log("parsedMessage", parsedMessage.slice(0, 30)); - - const filePath = await constructFilePath(parsedMessage); - setAudioQueue((prevQueue) => [...prevQueue, filePath]); - console.log("audio file written to", filePath); - } - - if (message.format === "bytes.raw" && message.end && audioQueue.length > 1) { - console.log("calling playNextAudio"); - playNextAudio(); - } - - */ - }; websocket.onerror = (error) => { @@ -167,56 +153,76 @@ const Main: React.FC = ({ route }) => { }; }, [scannedData]); - const startRecording = async () => { + const startRecording = useCallback(async () => { if (recording) { console.log("A recording is already in progress."); return; } try { - console.log("Requesting permissions.."); - await Audio.requestPermissionsAsync(); + if (permissionResponse !== null && permissionResponse.status !== `granted`) { + console.log("Requesting permission.."); + await requestPermission(); + } + await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true, }); + console.log("Starting recording.."); - const { recording: newRecording } = await Audio.Recording.createAsync( - Audio.RecordingOptionsPresets.HIGH_QUALITY - ); + const newRecording = new Audio.Recording(); + await newRecording.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY); + await newRecording.startAsync(); + setRecording(newRecording); - console.log("Recording started"); } catch (err) { console.error("Failed to start recording", err); } - }; + }, []); - const stopRecording = async () => { + const stopRecording = useCallback(async () => { console.log("Stopping recording.."); - setRecording(null); + if (recording) { await recording.stopAndUnloadAsync(); await Audio.setAudioModeAsync({ allowsRecordingIOS: false, }); const uri = recording.getURI(); - console.log("Recording stopped and stored at", uri); + console.log("recording uri at ", uri); + setRecording(null); + + // sanity check play the audio recording locally + // recording is working fine; is the server caching the audio file somewhere? + /** + if (uri) { + const { sound } = await Audio.Sound.createAsync({ uri }); + sound.playAsync(); + console.log("playing audio recording from", uri); + } + */ + if (ws && uri) { const response = await fetch(uri); + console.log("fetched audio file", response); const blob = await response.blob(); - const reader = new FileReader(); + reader.readAsArrayBuffer(blob); reader.onloadend = () => { const audioBytes = reader.result; if (audioBytes) { ws.send(audioBytes); - console.log("sent audio bytes to WebSocket"); + const audioArray = new Uint8Array(audioBytes as ArrayBuffer); + const decoder = new TextDecoder("utf-8"); + console.log("sent audio bytes to WebSocket", decoder.decode(audioArray).slice(0, 50)); } }; } + } - }; + }, [recording]); return ( diff --git a/software/source/server/server.py b/software/source/server/server.py index a347026..a1a7ef2 100644 --- a/software/source/server/server.py +++ b/software/source/server/server.py @@ -39,8 +39,6 @@ print("") setup_logging() -accumulator = Accumulator() - app = FastAPI() app_dir = user_data_dir("01") @@ -229,6 +227,8 @@ async def send_messages(websocket: WebSocket): async def listener(): while True: try: + accumulator = Accumulator() + while True: if not from_user.empty(): chunk = await from_user.get() @@ -258,6 +258,7 @@ 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) From cd22f223000491216e77dd07b28643c426477ad8 Mon Sep 17 00:00:00 2001 From: Ben Xu Date: Thu, 25 Apr 2024 08:24:02 -0400 Subject: [PATCH 19/39] add onPlayBackStatusUpdate --- .../ios/react-native/package-lock.json | 46 ++++++++++-- .../clients/ios/react-native/package.json | 3 +- .../ios/react-native/src/screens/Main.tsx | 74 +++++++++++++------ 3 files changed, 93 insertions(+), 30 deletions(-) diff --git a/software/source/clients/ios/react-native/package-lock.json b/software/source/clients/ios/react-native/package-lock.json index 40bb500..86dd5f3 100644 --- a/software/source/clients/ios/react-native/package-lock.json +++ b/software/source/clients/ios/react-native/package-lock.json @@ -21,7 +21,8 @@ "react-native-polyfill-globals": "^3.1.0", "react-native-safe-area-context": "4.8.2", "react-native-screens": "~3.29.0", - "text-encoding": "^0.7.0" + "text-encoding": "^0.7.0", + "zustand": "^4.5.2" }, "devDependencies": { "@babel/core": "^7.20.0", @@ -6093,13 +6094,13 @@ "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": "*", @@ -6116,7 +6117,7 @@ "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", @@ -7243,7 +7244,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", @@ -12956,6 +12957,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", @@ -13301,6 +13310,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 + } + } } } } diff --git a/software/source/clients/ios/react-native/package.json b/software/source/clients/ios/react-native/package.json index 6c2649a..58772a2 100644 --- a/software/source/clients/ios/react-native/package.json +++ b/software/source/clients/ios/react-native/package.json @@ -23,7 +23,8 @@ "react-native-polyfill-globals": "^3.1.0", "react-native-safe-area-context": "4.8.2", "react-native-screens": "~3.29.0", - "text-encoding": "^0.7.0" + "text-encoding": "^0.7.0", + "zustand": "^4.5.2" }, "devDependencies": { "@babel/core": "^7.20.0", diff --git a/software/source/clients/ios/react-native/src/screens/Main.tsx b/software/source/clients/ios/react-native/src/screens/Main.tsx index 28d3e75..0e0ad62 100644 --- a/software/source/clients/ios/react-native/src/screens/Main.tsx +++ b/software/source/clients/ios/react-native/src/screens/Main.tsx @@ -3,6 +3,7 @@ import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; import * as FileSystem from 'expo-file-system'; import { AVPlaybackStatus, AVPlaybackStatusSuccess, Audio } from "expo-av"; import { polyfill as polyfillEncoding } from 'react-native-polyfill-globals/src/encoding'; +import { create } from 'zustand'; interface MainProps { route: { @@ -12,23 +13,43 @@ interface MainProps { }; } +interface AudioQueueState { + audioQueue: string[]; // Define the audio queue type + addToQueue: (uri: string) => void; // Function to set audio queue +} + +const useAudioQueueStore = create((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((set) => ({ + sound: null, // initial state + setSound: (newSound) => set({ sound: newSound }), // action to set sound +})); + const Main: React.FC = ({ route }) => { const { scannedData } = route.params; const [connectionStatus, setConnectionStatus] = useState("Connecting..."); const [ws, setWs] = useState(null); const [recording, setRecording] = useState(null); - const [audioQueue, setAudioQueue] = useState([]); - const [sound, setSound] = useState(); + 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 audioDir = FileSystem.documentDirectory + '01/audio/'; const [permissionResponse, requestPermission] = Audio.usePermissions(); polyfillEncoding(); - const reader = new FileReader(); const constructTempFilePath = async (buffer: string) => { await dirExists(); const tempFilePath = `${audioDir}${Date.now()}.wav`; - await FileSystem.writeAsStringAsync( tempFilePath, buffer, @@ -37,7 +58,6 @@ const Main: React.FC = ({ route }) => { } ); - return tempFilePath; }; @@ -54,15 +74,10 @@ const Main: React.FC = ({ route }) => { } const playNextAudio = async () => { - console.log("in playNextAudio audioQueue is", audioQueue.length); + console.log(`in playNextAudio audioQueue is ${audioQueue.length} and sound is ${sound}`); - if (sound != null){ - console.log('Unloading Sound'); - await sound.unloadAsync(); - setSound(null); - } - if (audioQueue.length > 0) { + if (audioQueue.length > 0 && sound == null) { const uri = audioQueue.shift() as string; console.log("load audio from", uri); @@ -80,21 +95,32 @@ const Main: React.FC = ({ route }) => { playNextAudio(); } + } else { + console.log("audioQueue is empty or sound is not null"); + return; } }; + const _onPlayBackStatusUpdate = async (status: AVPlaybackStatus) => { + if (isAVPlaybackStatusSuccess(status) && status.didJustFinish === true){ + console.log("on playback status update sound is ", sound); + if (sound != null){ + console.log('Unloading Sound'); + await sound.unloadAsync(); + } + setSound(null); + console.log("audio has finished playing, playing next audio"); + console.log(audioQueue); + playNextAudio(); + } + } + const isAVPlaybackStatusSuccess = ( status: AVPlaybackStatus ): status is AVPlaybackStatusSuccess => { return (status as AVPlaybackStatusSuccess).isLoaded !== undefined; }; - const _onPlayBackStatusUpdate = (status: AVPlaybackStatus) => { - if (isAVPlaybackStatusSuccess(status) && status.didJustFinish){ - playNextAudio(); - } - } - useEffect(() => { console.log("audioQueue has been updated:", audioQueue.length); if (audioQueue.length == 1) { @@ -102,6 +128,10 @@ const Main: React.FC = ({ route }) => { } }, [audioQueue]); + useEffect(() => { + console.log("sound has been updated:", sound); + }, [sound]); + useEffect(() => { let websocket: WebSocket; try { @@ -121,13 +151,8 @@ const Main: React.FC = ({ route }) => { const buffer = await message.content as string; const filePath = await constructTempFilePath(buffer); - setAudioQueue((prevQueue) => [...prevQueue, filePath]); + addToQueue(filePath); console.log("audio file written to", filePath); - - if (message.format === "bytes.raw" && message.end && audioQueue.length >= 1) { - console.log("calling playNextAudio"); - playNextAudio(); - } }; websocket.onerror = (error) => { @@ -209,6 +234,7 @@ const Main: React.FC = ({ route }) => { console.log("fetched audio file", response); const blob = await response.blob(); + const reader = new FileReader(); reader.readAsArrayBuffer(blob); reader.onloadend = () => { const audioBytes = reader.result; From 3efe9aca8c43b9696d8f79b399b7148a9dbe9aed Mon Sep 17 00:00:00 2001 From: Ikko Eltociear Ashimine Date: Fri, 26 Apr 2024 00:40:39 +0900 Subject: [PATCH 20/39] Update server.py recieved -> received --- software/source/server/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/software/source/server/server.py b/software/source/server/server.py index c4dd036..d5dbb39 100644 --- a/software/source/server/server.py +++ b/software/source/server/server.py @@ -343,7 +343,7 @@ async def listener(): json.dump(interpreter.messages, file, indent=4) # TODO: is triggering seemingly randomly - # logger.info("New user message recieved. Breaking.") + # logger.info("New user message received. Breaking.") # break # Also check if there's any new computer messages @@ -351,7 +351,7 @@ async def listener(): with open(conversation_history_path, "w") as file: json.dump(interpreter.messages, file, indent=4) - logger.info("New computer message recieved. Breaking.") + logger.info("New computer message received. Breaking.") break except: traceback.print_exc() From 4647b24ccc0180548ee1804f192424859cab42db Mon Sep 17 00:00:00 2001 From: Ty Fiero Date: Thu, 25 Apr 2024 12:05:12 -0700 Subject: [PATCH 21/39] It works!! --- .../ios/react-native/package-lock.json | 9 + .../clients/ios/react-native/package.json | 3 +- .../react-native/src/screens/HomeScreen.tsx | 2 +- .../ios/react-native/src/screens/Main.tsx | 252 ++++++++++++------ 4 files changed, 183 insertions(+), 83 deletions(-) diff --git a/software/source/clients/ios/react-native/package-lock.json b/software/source/clients/ios/react-native/package-lock.json index 86dd5f3..e78cdd4 100644 --- a/software/source/clients/ios/react-native/package-lock.json +++ b/software/source/clients/ios/react-native/package-lock.json @@ -14,6 +14,7 @@ "expo-av": "~13.10.5", "expo-barcode-scanner": "~12.9.3", "expo-camera": "~14.0.5", + "expo-haptics": "~12.8.1", "expo-status-bar": "~1.11.1", "react": "18.2.0", "react-native": "0.73.4", @@ -7719,6 +7720,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", diff --git a/software/source/clients/ios/react-native/package.json b/software/source/clients/ios/react-native/package.json index 58772a2..6d5cbe2 100644 --- a/software/source/clients/ios/react-native/package.json +++ b/software/source/clients/ios/react-native/package.json @@ -24,7 +24,8 @@ "react-native-safe-area-context": "4.8.2", "react-native-screens": "~3.29.0", "text-encoding": "^0.7.0", - "zustand": "^4.5.2" + "zustand": "^4.5.2", + "expo-haptics": "~12.8.1" }, "devDependencies": { "@babel/core": "^7.20.0", diff --git a/software/source/clients/ios/react-native/src/screens/HomeScreen.tsx b/software/source/clients/ios/react-native/src/screens/HomeScreen.tsx index cb0644b..27ddd4f 100644 --- a/software/source/clients/ios/react-native/src/screens/HomeScreen.tsx +++ b/software/source/clients/ios/react-native/src/screens/HomeScreen.tsx @@ -7,7 +7,7 @@ const HomeScreen = () => { return ( - + {/* */} navigation.navigate("Camera")} diff --git a/software/source/clients/ios/react-native/src/screens/Main.tsx b/software/source/clients/ios/react-native/src/screens/Main.tsx index 0e0ad62..5c360ab 100644 --- a/software/source/clients/ios/react-native/src/screens/Main.tsx +++ b/software/source/clients/ios/react-native/src/screens/Main.tsx @@ -1,9 +1,12 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; -import * as FileSystem from 'expo-file-system'; +import * as FileSystem from "expo-file-system"; import { AVPlaybackStatus, AVPlaybackStatusSuccess, Audio } from "expo-av"; -import { polyfill as polyfillEncoding } from 'react-native-polyfill-globals/src/encoding'; -import { create } from 'zustand'; +import { polyfill as polyfillEncoding } from "react-native-polyfill-globals/src/encoding"; +import { create } from "zustand"; +import useStore from "../lib/state"; +import { Animated } from "react-native"; +import * as Haptics from "expo-haptics"; interface MainProps { route: { @@ -20,7 +23,8 @@ interface AudioQueueState { const useAudioQueueStore = create((set) => ({ audioQueue: [], // initial state - addToQueue: (uri) => set((state) => ({ audioQueue: [...state.audioQueue, uri] })), // action to set audio queue + addToQueue: (uri) => + set((state) => ({ audioQueue: [...state.audioQueue, uri] })), // action to set audio queue })); interface SoundState { @@ -35,85 +39,105 @@ const useSoundStore = create((set) => ({ const Main: React.FC = ({ route }) => { const { scannedData } = route.params; - const [connectionStatus, setConnectionStatus] = useState("Connecting..."); + const [connectionStatus, setConnectionStatus] = + useState("Connecting..."); const [ws, setWs] = useState(null); + const [isPressed, setIsPressed] = useState(false); const [recording, setRecording] = useState(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 audioDir = FileSystem.documentDirectory + '01/audio/'; + const [soundUriMap, setSoundUriMap] = useState>( + new Map() + ); + const audioDir = FileSystem.documentDirectory + "01/audio/"; const [permissionResponse, requestPermission] = Audio.usePermissions(); polyfillEncoding(); - - const constructTempFilePath = async (buffer: string) => { + const backgroundColorAnim = useRef(new Animated.Value(0)).current; + const buttonBackgroundColorAnim = useRef(new Animated.Value(0)).current; + + const backgroundColor = backgroundColorAnim.interpolate({ + inputRange: [0, 1], + outputRange: ["black", "white"], // Change as needed + }); + const buttonBackgroundColor = backgroundColorAnim.interpolate({ + inputRange: [0, 1], + outputRange: ["white", "black"], // Inverse of the container + }); + + 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, - } - ); + 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. */ - const dirInfo = await FileSystem.getInfoAsync(audioDir); - if (!dirInfo.exists) { - console.log("audio directory doesn't exist, creating..."); - await FileSystem.makeDirectoryAsync(audioDir, { intermediates: true }); + 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 = async () => { - console.log(`in playNextAudio audioQueue is ${audioQueue.length} and sound is ${sound}`); - + const playNextAudio = useCallback(async () => { + console.log( + `in playNextAudio audioQueue is ${audioQueue.length} and sound is ${sound}` + ); if (audioQueue.length > 0 && sound == null) { const uri = audioQueue.shift() as string; console.log("load audio from", uri); try { - const { sound } = await Audio.Sound.createAsync({ uri }); - setSound(sound); - - console.log("playing audio from", uri); - await sound?.playAsync(); - - sound.setOnPlaybackStatusUpdate(_onPlayBackStatusUpdate); - - } catch (error){ + 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 { console.log("audioQueue is empty or sound is not null"); return; } - }; - - const _onPlayBackStatusUpdate = async (status: AVPlaybackStatus) => { - if (isAVPlaybackStatusSuccess(status) && status.didJustFinish === true){ - console.log("on playback status update sound is ", sound); - if (sound != null){ - console.log('Unloading Sound'); - await sound.unloadAsync(); + }, [audioQueue, sound, soundUriMap]); + + const _onPlayBackStatusUpdate = useCallback( + async (status) => { + if (status.didJustFinish) { + await sound?.unloadAsync(); + soundUriMap.delete(sound); + setSoundUriMap(new Map(soundUriMap)); + setSound(null); + playNextAudio(); } - setSound(null); - console.log("audio has finished playing, playing next audio"); - console.log(audioQueue); - playNextAudio(); - } - } + }, + [sound, soundUriMap, playNextAudio] + ); const isAVPlaybackStatusSuccess = ( status: AVPlaybackStatus @@ -121,13 +145,17 @@ const Main: React.FC = ({ route }) => { return (status as AVPlaybackStatusSuccess).isLoaded !== undefined; }; + // useEffect(() => { + // console.log("audioQueue has been updated:", audioQueue.length); + // if (audioQueue.length == 1) { + // playNextAudio(); + // } + // }, [audioQueue]); useEffect(() => { - console.log("audioQueue has been updated:", audioQueue.length); - if (audioQueue.length == 1) { + if (audioQueue.length > 0 && !sound) { playNextAudio(); } - }, [audioQueue]); - + }, [audioQueue, sound, playNextAudio]); useEffect(() => { console.log("sound has been updated:", sound); }, [sound]); @@ -145,14 +173,34 @@ const Main: React.FC = ({ route }) => { }; websocket.onmessage = async (e) => { - - const message = JSON.parse(e.data); - console.log(message.content.slice(0, 50)); - - const buffer = await message.content as string; - const filePath = await constructTempFilePath(buffer); - addToQueue(filePath); - console.log("audio file written to", filePath); + try { + const message = JSON.parse(e.data); + + if (message.content && typeof message.content === "string") { + console.log("✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅ Audio message"); + + const buffer = message.content; + console.log(buffer.length); + if (buffer && buffer.length > 0) { + const filePath = await constructTempFilePath(buffer); + if (filePath !== null) { + addToQueue(filePath); + console.log("audio file written to", filePath); + } else { + console.error("Failed to create file path"); + } + } else { + console.error("Received message is empty or undefined"); + } + } else { + // console.log(typeof message); + // console.log(typeof message.content); + console.log("Received message content is not a string."); + console.log(message); + } + } catch (error) { + console.error("Error handling WebSocket message:", error); + } }; websocket.onerror = (error) => { @@ -185,7 +233,10 @@ const Main: React.FC = ({ route }) => { } try { - if (permissionResponse !== null && permissionResponse.status !== `granted`) { + if ( + permissionResponse !== null && + permissionResponse.status !== `granted` + ) { console.log("Requesting permission.."); await requestPermission(); } @@ -197,7 +248,9 @@ const Main: React.FC = ({ route }) => { console.log("Starting recording.."); const newRecording = new Audio.Recording(); - await newRecording.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY); + await newRecording.prepareToRecordAsync( + Audio.RecordingOptionsPresets.HIGH_QUALITY + ); await newRecording.startAsync(); setRecording(newRecording); @@ -228,10 +281,9 @@ const Main: React.FC = ({ route }) => { } */ - if (ws && uri) { const response = await fetch(uri); - console.log("fetched audio file", response); + // console.log("fetched audio file", response); const blob = await response.blob(); const reader = new FileReader(); @@ -242,16 +294,30 @@ const Main: React.FC = ({ route }) => { ws.send(audioBytes); const audioArray = new Uint8Array(audioBytes as ArrayBuffer); const decoder = new TextDecoder("utf-8"); - console.log("sent audio bytes to WebSocket", decoder.decode(audioArray).slice(0, 50)); + console.log( + "sent audio bytes to WebSocket", + decoder.decode(audioArray).slice(0, 50) + ); } }; } - } }, [recording]); + const toggleRecording = (shouldPress: boolean) => { + Animated.timing(backgroundColorAnim, { + toValue: shouldPress ? 1 : 0, + duration: 400, + useNativeDriver: false, // 'backgroundColor' does not support native driver + }).start(); + Animated.timing(buttonBackgroundColorAnim, { + toValue: shouldPress ? 1 : 0, + duration: 400, + useNativeDriver: false, // 'backgroundColor' does not support native driver + }).start(); + }; return ( - + = ({ route }) => { { + setIsPressed(true); + toggleRecording(true); // Pass true when pressed + startRecording(); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + }} + onPressOut={() => { + setIsPressed(false); + toggleRecording(false); // Pass false when released + stopRecording(); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + }} > - - Record - + + {/* + Record + */} + - + ); -} +}; const styles = StyleSheet.create({ container: { flex: 1, - justifyContent: 'center', + justifyContent: "center", alignItems: "center", - backgroundColor: '#ecf0f1', padding: 10, + position: "relative", }, circle: { width: 100, height: 100, borderRadius: 50, - backgroundColor: "black", justifyContent: "center", alignItems: "center", }, @@ -296,13 +379,20 @@ const styles = StyleSheet.create({ justifyContent: "center", alignItems: "center", }, - buttonText: { + buttonTextDefault: { + color: "black", + fontSize: 16, + }, + buttonTextRecording: { color: "white", fontSize: 16, }, statusText: { - marginBottom: 20, - fontSize: 16, + position: "absolute", + bottom: 10, + alignSelf: "center", + fontSize: 12, + fontWeight: "bold", }, }); From 9e60fee6ee649ce4e1cd1e2e260e2aa7e0f5a1be Mon Sep 17 00:00:00 2001 From: Ty Fiero Date: Thu, 25 Apr 2024 15:17:38 -0700 Subject: [PATCH 22/39] UI changes and audio fixes --- .../source/clients/ios/react-native/App.tsx | 7 +- .../clients/ios/react-native/assets/pip.mp3 | Bin 0 -> 33024 bytes .../clients/ios/react-native/assets/pop.mp3 | Bin 0 -> 33024 bytes .../clients/ios/react-native/assets/qr.png | Bin 0 -> 13745 bytes .../clients/ios/react-native/assets/yay.wav | Bin 0 -> 91924 bytes .../ios/react-native/src/screens/Camera.tsx | 23 +++- .../react-native/src/screens/HomeScreen.tsx | 8 +- .../ios/react-native/src/screens/Main.tsx | 115 ++++++++++++------ software/source/server/server.py | 4 +- 9 files changed, 109 insertions(+), 48 deletions(-) create mode 100644 software/source/clients/ios/react-native/assets/pip.mp3 create mode 100644 software/source/clients/ios/react-native/assets/pop.mp3 create mode 100644 software/source/clients/ios/react-native/assets/qr.png create mode 100644 software/source/clients/ios/react-native/assets/yay.wav diff --git a/software/source/clients/ios/react-native/App.tsx b/software/source/clients/ios/react-native/App.tsx index 7881ba3..5fa7a05 100644 --- a/software/source/clients/ios/react-native/App.tsx +++ b/software/source/clients/ios/react-native/App.tsx @@ -10,7 +10,12 @@ const Stack = createNativeStackNavigator(); function App() { return ( - + diff --git a/software/source/clients/ios/react-native/assets/pip.mp3 b/software/source/clients/ios/react-native/assets/pip.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..bc15afa36bf4cd2dbb03f1bdd10104f645b349d2 GIT binary patch literal 33024 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 literal 0 HcmV?d00001 diff --git a/software/source/clients/ios/react-native/assets/pop.mp3 b/software/source/clients/ios/react-native/assets/pop.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..dedc4d152a2329622cb46ad323d4a8d42dd8a0f3 GIT binary patch literal 33024 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 z≻)l>6m;8)V#&OlhK1;mRnl)XX+Q+8~P7z}D(WyoJ&;Jr9K)lf8ZL1e~Vj(Ue)u z$CR9iI^4Rf1mX;<=C4mvIT0T%=)R6kq@XTU^$_DkO0cz6wY@fAL7B*FW0g_nG$t^4 z^RSmfMPLa_QKmz|j;6%vbo?-Nrm%}v<79R%CJxY$V}>fk5$W!sz_O~m5sF!s0cA-w zUpR^qb4oED&X2(0ZmsE|mV(^k5s@TFLUSTGm$r%$XCuE(%PV8doLZdANyjL4KW_p# z4rC_ymZ9`?w4iiYirdasL%RnxVsaVhKk#;`Wah6Wpiy4K!!yD~jXI5rbRa1!CxyD9 zPolH{q>P9`TS{E4HL!{zkDdmng_R>8B!rcS^5lx`gcjSms$Tz>@}p!;@2h!H6|X%* zpJq6ba8Y#?g(xgoWn&N;1W4MrPyDP;GVrHLPL4&#k&Lo^!k2&eB)n+Jbu#;8X>Pn7 zIR?Qhr?!hS81qDHpZGpSLc#i#HpILj|2aWHUbQ?pP12-ZP3p{aew|c{v4!_EXV?ni z7NqEgJ^58lT5BBd))siV&W%4S%jua>vs?7d4tmovKn`7ZoU6%kT3zZ6?z`Ff=zvP+BMC?QG*wox`N%;?S_C zo{-}T25mit&T@|e zBdX$6(}kW`I2Me3kL#;5W?B@r3he@Jb`CC=-Znk(vh|E79Yl<&cP2fc)sht7#ETs$ z#a&ir(-tk%81u>{awo;^6*Sg*OtA5H0q(y_&As59(6%zV;~d6k2r~9W@R7A&lOdMF^MqZXu>t6_~N@_ z@Pi2<;x09QH-^GF+va`pLnwzDnCS(ONl>69U_NnRV{(j5K%Ohz5{#833IxQ;oFVIw zBDU1krH{2Xlz1?r_AYrm-V~9Zn#LJbLnn>jkfgcx=<3S1h?C7xB~o%ziQR)=G_$8@ zSg!EV;C&V6FVcKj21S~X!@1V$aeiLR%Y~wL+sVf79Akery|+(cpikja=gVe?ie==^ zr>3I0olIDJ@GIQTHVZARfKWx9a3>vOaoSY3-xMj?L|Orqj*g<4tE+hmE0$<}&YqF0 zWBO!}KGZsRA{`OZ>B6=v6nE*B_#N?`WiBWAJp$mMVR{$C7n~*tEdC3gkWDha@VkIs zPvYyDNGuMS(Q6Mhz0QiLdAr6;r~MwR(bpg%*3l%Rc`ERO`1Gaac4N+^(Q9n$-mN41 zBPKcZ_BMO^LlN-fS~HGc?-^L6jf3*|ss@ww6Sg8N>nHjyYByD((m8GxoPA|n|C|5) ziMy%@lj)h}9~y5R4Th)evy&}#>dM`qpzT{8$boHYSX-&E=MeZMOnu4~_xC!gA?KF3 zi3a5lOV-)5s1JIRk7_B~`hu*mpMMi|_A=`eQ&Yq8cNCtw*H8*lGKr)Pc(@cLc+^ud zNSViGL*gCPH|m!I%IIZ4C3Qjw3!>1?hAN1<<5R3khhZg#eTzDsJMxW_pHJnqgVyHU zxyagu@e2qaIki3g46W2VMQ05{9Yy!Kpw=0VABs*M=KvAt=8{LJRJ%YrI5PHsv+Gv) zLdNzvLHup}WVal{c zghy>Y!-j+QQo14qc0~j!gs$=XudR}p%j^f`%hJH38xp00*|H0T{+HvYu_ z)GK4%&5?z+e&m6a_F+l8x%s=y>lpI%9 zkvQ=L&Nh0{i3eeZ4|4yLe_jwK!XqAO1PBGUE!K7M${zESuXVtRD@+K%UUMU|91 ziW!TaP6&9Y)6uDtmT6ZPXD-(wtL%2k#XZ=^u*{BIeP_>}2THvcVUoD{k8GucfpQXy z0~@y*e|}t|n8C+$kH_CvWzXRrpDPfOj>El$=Wppn;7X*FJJSGD?16kqLn1p%T&AP8HBJJ6Kri1}`X`!<~33WA1ySsds z-^}va1e*`%a%EPQUeaaHSWL!PN)xPmRi&B(wJ!e=VxjM7;+dt&-`An1iYaJ8kM~Lb zV@pZfW_+hS1OXZ=L5=bI81SzcSSs<}VqNtPWezlVuT#k(x^@D7P&86^))$LCGws^0 zwy@{%tL=Uz;Hv_zwHaTV#mrP(NxqkE*YJtCbx1oiht87YHxtshaDU>zrQ$F;xzgg$ zv2U6A=K47Y;}2zfZa+K`=u!pGQk2|@zWku9ayv9wBS=qoygirC#m=eB(eO39X}jgi z_MJ>N{XfD70=}~dc zbX(|rz3V6IlK+zwmSq1z&$HwX3UnJwAdl<(D1ttR0}WJClTE#==NO>fatbEGdoQRmgqZf$S z*L36JS^F4UHVF2w(mU_wyOx_)7gdSda>(Px_VG4hC&aLs`|a6$0>SVtB>cI%vf82O z=ihehR-?K_MdS9aF-0pasmmNuL)w}f86=mNZ8W>HBhRTDOG=e|1|_rjMh|t|r<j9<rOMQ~Ppyc$)1-(Gv_^~xaKN0Z3hCusLR9hMn5I(xV8G|>P zNY91YBo@dzBvl_4@^fU!dgdK&!e?cfK>OfPL4;@M9v9JPs1&Bf2^O?{<`nAw?4wqH z$9>O7+_#-}0!PnMzJ@><|5PAx+_W*_F)F}XPT~t~hJI+}W|~Z>rtpL(UR~$BX~#CM zjmPnRgDy}7TlWtP<7Yj(nJhn=TWtE9O@syIg$tOrJoyhfRA;t%YQ7*QN`QZUvMHE@ zE{rb}D$vKs)9ALiyzr1Fk5y{=lQe2jju4H8_Q6{peOY7urKKbiat$?qS=wTMvsJM- z&Smsj#JdRy7mC4M-J_iO3WG_FC3m7)V{i416o2I=(0X=Jd*<(*yH$qmhq%4JI&V1s z-nrc?J?8<@zZg4&>)~T=0q_^qiuYN}S!R-w3t0%bwC@*<7O6>BHF=brSWXpVhwBDw z3Oj#G&!;8`G$*CpBDISyCD0g_wqyzcU!<{j2}qnUfZg*ZDv0^HAnQ z7jN=6F3egFf>&j9Pm2V=b$wqs%cUQ7%ig_D))i$W0MKmN+-|$f`XX*)Qt5zDgmP|@ zXW@1a;6v(!nYB2slNZk@S~9(Zu`5JuDamM2V$@vG3(;@p0=cEQ@IA=s z37?qAmVQDGjIfp5Jc!6!up}2pRs?N~(RaavWWZQx54mbDMO3#_;-fylx$(CeJDJn= zB%?KijIrhbb!%J|mRFCo!4i@*LyK1e zRAjHYVpeN8(oWB3(8^66$@*{Q&>7RhKb$nyWP$QcVP76Xh2suBf2Hcr#bNJ8g=za@ zTjZoEAohRjglV9&T0&NGi|wnOEysQsrw&l^=Db6>!W`h^y>%qNg2P>fHPzax=q{;} zWv7JN5B26tFRwu;DGXmIGQY)e+b7%E8nU2cZKij8Nw zt|YB0gg??qb${OzRg5qN@XV-fO>??ZWw9U*1Z^qniMhYp&V!cQH%_yp zH}^lYT>J%m`@)m}An)M4l5%nYE}2s~ZH{QS=qOo}pZ{XHulfx!3Vnft-*TUy^5I}N-78VO&Reb4C&mZ~dzJbO=9 zZD0vYo;Qm6`4S8n=V{ar`#&J{;lQd){6TJpoO3ioj13k`{^ZjrZS)8*vR=$ zWiKbqQ3%)RZ}AKwh)dGgCxVXyqCPFMy;p0^wk(1LgY6+$oS#|!c%8#Q4wPovAz0gp zsyf?pM6^kSX}3Fjj~WcWCy#px6Je_;ws-D)FTXyr^(C9cN?h|wbaAQ~Ir+xPU8;Ty zjxU*2tjpCJgXTSMet6p5&vg4{=QUzkEs2zSR`q31wya!9pN|2u(%Zs7*J{fVRKupj z^xg0mTs*4mwoU%G*1eprF;Dov`oH!+V&MvkzUuqs?Y(cA6Hoato2`JltTES{oN>4g z4O1HLW-)=>1sC%N5wlp?8oOwUZ-{7$qN%(sc0zr&a3-uaVZhfaGEoJj^^R6%aM;PY zlX@L&x)thU(xVsdM)=UpEIvWi$u_d;jkR&h-sk-5XCv<0A)fA8#-o-S-W%iA35SR* zlD&e^mY!uV7GZIR`P5Z+e|B|VDSSmlR_Iy&;J|Re0(qD}FJ+mQ_D@gjo+G!ei%n;6 zg}ZlX;ab@M3?j`N_3UJ(c!hUnoAUhoX!IS%vnk0!&Rs}RMd|k|W7>ma#aqGO{%~>@ zGeQYur@=;cgTWOtx*bEaSQ;inslsXj+c)DmU5N&RtrDE9?0N8qd;C1=+ZsEgEgvo1H&-+SuL4btPF$s6pPK6YtP?vnbW#yh4#Zl?Zs5 zDj0RoQu)g|?Dj<5V!5twqS!#B!076I9}^`>>HIqYX^5&Lf)AHuV3CQ z*s*cBG-QuTx0_F;WCo^q)D-8^C&52?jw+svbg|V?2WtO;WlybjpP5>|{z4AJ)XX^C zCJPBSc)=q9TSUU+dEG%ut<36ivfF9J4!#=AnQJv{t@(~+6d;MgzyEpX%qSHv`!zK4 zqGbL=YEYiH>qPgD7mi6?E2K(n)i{#poXLDeaGcb(Lq0P&%W-Wxdn8WWKh?GZPH~83dzA>3+ z&d){MQf@UbtS?F5_W5D^A9zibdG~pvx-M_a`d4asQKKEcMhMV7vqeollyObSn&~(# z&+Vh$p3v;D#Z8C0E*ZtR|$!?-4EehbQFaOVQGH7?rKR5aDTrh$2-0bXQq`np8^qvWj z%|1rtn(n2MHP?T)v9NfCqqwo z?}(cVwq7MqcsS>_OVl*D&qE3_=ZGkP2g>lBOr8i`TI&s8NnU1(v4ahGVCLmG)J<3P}ZeFH_ z#v~f6h=O<+JyT7pG7hF{Zp5={=A4h^s%0xu0D%>&N|`0~Y}j=}O%)}i9mCSqcYmIm zE9L-AcUrB2`!Z9;tva8Y5|UO02UJg-y_twg2vlsW1{|GSXQe`r%`Z}E2Dx>{$lkCB zB|03DJhfTMUcLVz>s%G1z_El%aQk)Q{;u|bR`<>C|wEy zT1yAkTW?*QUwNymN!bQTFkC}*7pdU*+>Am)4Kdr}`=M2b2~}oAZT>~lH&u|* zQ-qo33t}!O)51fl{E3X8R#w+2DA=pNzUMdmpo1Cv5KOx1$wWiVW&j^hB6(oYB5Ws( zWMxL45_d6?pJ~yG@gT%_4$RjPJs6xf-fBx629r{`SSElhD5`AoUI!al9Z~vTBmV7F z>l4j>Hi*GYxN)N$Qb|6$UY?vTDWiunsG%}sg#O3>{F5~~5Z*#5`$yNfd~YzXY`b0Y zW(W1P|0x*UK&qB%Jgzbf(nHLdwYi9fXY!b?+QR@ie2|UomQ=6egXz@r?&@T@gwXo@ zm0=&G6nw6c!BCZcsB4vz$Sdz#E9t-w75*8J&g&IokK|cK#KR4FxE?)asAsEnbjXl3 zhs6l6AE+-@0~I?1O02g_aVunikS_w%EBG_XjPIS6%M>3t4csTBDs+d6`6wjdY>dScXgkok|3z17q+GW)C z%kf53c^7=N>%DfYZ7QgM1>!(Wbl2_Nad!eGw#+Zrm9nkm(vH?NvCm70zxMDdD4@Ch z*H-e&lv4K!X$G$7s4#!$hkZT!PO5{8fzNoXRt=;0@Qk)r^ckMK`ka*i7Y zN&mDJ-?6h1++J3$bohnn4J}BuST3qY!)qmRSr7c_R}@-m7$t)nImLAZEdNxuN!Dn} z4gO1L>XknI0)X;hXR!D_7@PU-aN#BxEQ=nvY0N?swYkKT1>e+-S4H}RLs@A5o0U$Xpg{NAW9 zl1}=$xm4k|-m8suuW+xqdDp;?aw@ccvE1VJ>p*OnSgR?KdRMX+h4t8ZyJD=RSna-T*HcxiZkdt-xassj8#XEAvweKe< z7Td0RCMJ3(!Qr0|=#<0V?PUJrf8r*rBHG#JZw$Bo+y5rNvU41L4pn^Z577EI|M?yb zn;wdDwPlo`)25s3+)esNCG^fn@b^yLurHojDW&fd52m*$_*0w9yqI!LpR`TT1L>gw z2>7oOzSPmrw?}d5JYcp1iqwr&nzBZq-iDB7!Il zddxACw|TU~xt*)Tu+c=3gAzUYiWcFSXBZ=6E$|EU8S5*}PO*(FCnXhujUu_%8UwSA z0||?1p{sJ2MdhfXbJ&4`W@Rm)UjNrkM2 z=@S){W_u)awSoAYA@1j@x{091Y~#9+d@2f5F0EMA?1RU*5t<5$#h9{$W#RkYJmNZN z#DnfmDgYWhPZO4#y2>NqV6PA2E{opp`N;ZqI4^XVe8|U-KPpF~#i|@~hq{&+HHFbJ zepY{1fAJ6?A6nUpn`GFDyNJ;QG1{`oI5(9tW>ib4+qvQzr}y94X=uDNor)>XTCNnA zgD@B5aB&M_3{4T*6kI)az|J7uQLdt|A_2a~)z>YsAdwduT)-1jm%l_l{_BaEQ4X0o z4DFFhi}-aX&HlNyFI5`$$2Fu4O=>Czjxvs!P`vuwih^2w>%@objtY`FkILIN~-(1;BESN}g* zj_kr)h*G$V=kgP|ys}qZX8)dl$r~LUe0j}36!ng$tkjY0SiBl_ox*1i+8+1;0MI#M z_v>GYC9hL5;$8TAMpN z(y7R4{)wtr0`u19x9lwxz=Dn0PAYZj3O&-q`~qGVrkAGYFzMMB-S^goJI^)*Hot@S zwXV*#d|x^#h=RC6sV0p5H!4^p>OO0kAdojjEu-{rgdFYGlE@0>j|i=ojLx2aJR4xq ztu5oVlB@*p_XEk~tRu_A0xsmbrUMJet$VlaHbXoqPo|RnX_kc?%ms48R5U$_ z?}%rn4S>F78uDAwpwW82JzQL)3RoS#%!GJ$(}^9mT5*%W}h?WEWN|=l85uGMWVVT+!Ip!DpxZe5Ep1yYfd0({s!E z>@I8!;!+sYB~6mwHeReE)2NR-8^*&*D+Kr-!6lX&V>K%#+~YM1n+Mp^d+(=&$Jf26 zC}RV!)oYSaWBN?7pf!AxdoJ0#1ElJapD%|FUoC$rRy2_Z%f!p z8H;?ZeuY_2lrm&cWDv2{iT#7$9IHi@}7;JAH zm%Y$Psea|~T4davhJM4*8$`clIY@)qR;6CCK+D%ISB2C#NF|r3=SL~1CW(&Y7jqa! zG1<8{oOj#QjYPRE5W|ns;F_-S%OYExj+XKg?X3Um|1v#EJnRvVm)U3^OYh{r`A_G| zKK;7FgtMDmNL=@(5Rto_i^%p!N5@^9WH&Vteq1=)I>gz`C+jZs{GKDB7VfV0?2pFS zLkgPPIbjT1AU|3OT7f2vAjUJB6UcYvld5){OkE={KxP)nsGZmB8SDH?OalfwY8t}& z;Ud&MSlbM~+PdQt-W6yn2-XD6^`#oAflAn78-KBM~Ex@I=KkS}<$Y zf-)Bc+_A`yQ86ID92AzH!Jv|S;mu>oRHmk*nci$huYYi`pZJ$m>>%~!7$Mz#aDU@o zLg){^y=D$x_Tt)Df&WszUdY zDfbNz=0=XbvVmcZ0mqVZIcql^UH_JKU@1BHI0>QQ;0r7tM&2bK@VO;Q;Ie4* zseJbA8R%fxT{1z&eLCnCkhGUi>#5d0RCkp;b>Sc*z+cHlk7UTJ0%tnfz(iT8JH9%L zm1rmmzhN0SNCboVzRnuG^`1NSACG?>J$%h{*P(xTnGxwR65_)AxeWBq4#lDYyM3lo zGC#n!GR|8VoD84f>AB)ywn^k<(|UvO0x54xz9zhX*|fcxmNB)OK;wjlz;`#!^0M1f z>lf0?l{3SySDz|Qb{Y!{%@m-viI_`+6)PkxJuQ-HtpeF>v{bKcG#;Xk4xryRbrC4! zlWl2$F>xu*L`CVPXYsnoAwC4ortN*}r{@!5qI7rwn}8(NC80=0LL#EBzE%lh*_>Sg z0y%C^0+@5y_UQGPOzCrS^r28HQY%3yn2bl)0#sin&!{^M%N1L3V!Z@ce(wk%w)WtAAW zoZSBklTyY5~HiVuMv=B%Iw#xwtf0|;ETweF1(^)zYf_iL|3lU)d++pPl*n4KQER{C~{BE%5J*N$4=Ukx(Lfl zyV>&ak-Tqx_J@ye+BXD5V%C*zb`06}F0GY4>+IA%qz{RTw$f#Yu1nHjTHJ@W3zy_W zm*eNW(`IWN$C=9NZ$*f@8A@@?F&9`EBvx;*lcv-}ssRAq$Clr_m4qx4CnUkogZwqc z_)FlJf^_!G_fIyerKwFpEv^W{;#0d{IW$QOu#jHE>N;0X&eqQZ@fypQr+Ko-C5EIU z!|4)aK~Lg-s7>Pje6=Xu@yB+>4C{n^_ZsNwjRXUCir@l$A`kTwt;SNes|kkTY8$Ev zP`1P*Ngs!_c@EEGD{jcj%ux)8-_0)O)oPNF>(MRXRmL9|nVDWpr|eg**WR-*Q_S_) z4h<$WhNCTqdT{=h1!{{d^KXTx9@7H1a+}6;e;GMB3goRTd1{`oC{z3eHkdBIDXpps zrlZvVt_Jj)OF%Wn2opH{9{b$8t-0CvQ~l66JgoC-S`~8nh>#~?7xSvz-KW95g=jXUVKOXsP}BPm?1Br6lSXEEL}jdHS2vaz0` zBPBoCO-x>QiOss8y_wxJ?kmA`ROEnE7{Pz_|4;hiafEF4yv69^;)I)+Y&#e4HUFvj z8vgMK7a&hXWc)@!jn5(tnqf^>(diC*HsQ=z@%$hh#>1;IlpKl_eEp_}SH^*CT+`rK zKXYq}L$#&V)Q8`r!UT5lr&SKbjQ5v>mUeow7^)ab^hS)AvbIEIJJ`GrjKw^Ff_tHd z)j86MgD=KSAZy4sJZ;nz?0nTkY8W#0A4wR z@=pUz^gz2g!}BuO)rRz$JO%5iVoIKS&Q)}dpOofo{W5Laqz6Me<_f7k`zSHq5TP6RV;xj- z3Co@JQOpgvf+;-~r^_9Q$@9Ud#V7EtT61%xGer&OaZ(3pDTj^cU}uy^zdo(pszoC= zT^vGs^2!{2X0-f9Jp3gPXRs{+f~NIdxDR2@$hms|AWR-VnAia_k|eN+#5%aVC-C`V zd&duVoDA?)6m$}{*<7aFhBS#&Qncb|{PZS+s7&KX_Z#MOqbD_$H+z;XzvVXGV;=C8 za5OxoG0Li^fykg(a8q0r5L0FwjuOC&p5;KoHFWcrKaMcN;Sw4N7R1;=)|kaHl-dj( zE`3(EM)w5UpiaKz5UWu##&Ft+xQt(|fH~H5L9hw zt3jmn5XFe502j4Q7aQX#_b$X4q~Yc>;Ri({pL5&ddS6isL@J3EBm#X9h#^?>D;04? zx44+hz%6kMKB2rO7P}%dHbqGxxEEy}%Z79{?Ig^w*xq=)3f?RU)mK|)RR$0cN9I|I z+buxEfB2tsrbEt%_H5r9mQOP4;(2D|ZHw3X^~pPl0#7)H^Tgb!MC$-}x5EolbcyE->Xx*LukVAgQVUW`Nqsp^2lH)kYOo`LeM00RB@K^qN?eE$ zH;9J0A7j4l1&$5}*r!!V(E^vWEeGGh6h|(6^g#)dL^eIe1egk!c-IQcbp1>2 z+EYJ{PTeM1fTjDBdJf0u?2v!0J+6l&l9>On!ia zyd$rhK7i60x(;|ZssHu`?USEcqS($NHQMBUKez6LbuU8)p60@hMJZ6)y%ifKi#$y` zs7FBS8aHBoHTnr$^&+lG)E$mU&h4UO&udc+MXj&6i?#MfFS3E8=yQP%;MDX2Dg7H| z+QVA8w40Rg8{6_BiE=E-MY5t)6Q&S{J)RS|;#k%Ur3Z49NU#?^q1oMJ2ZPXPSmRc_ zMaY}tjVNrA6dRzkJgaQ6UTZO=I-d zUioW*>PxE`9a^w6gW9mVyc1a z(*Y-ABRScy*9sMmmw(+RoS>prgAGzroy^2xxO1W5rzy@#Fn%7AhxVPurJW*kt9C+% zW*(_cPH4{@AeNLUUcvU_{Q#{>Y|?~3iMPGzkEAlP=#T4b$I~hi=2H;(}>ZM1b zx;NnhS<%n1^1-!_KlZ(( z<;C%IcBd-U88?4T=!s^$)Yu!zoDY1azp6$=F8mvo!JyV1Yp^Jnjp-A~Rb@@R@1S-Y zyQuO=%-d-^4GmPTx~9Gg{iGrY)+Wd)#E_6Uj%OB@4vqRx{`r+@LM{AS|3drM^KTq% z{(z@$vmH=ZJi72d`>(j5dO7jKhKzR1%wrW|g3R@LZt4W}Tl5PSi#EerkkLdR|ALb1 z6J|mRH=KFOCE!x3&Pl*oGRKO}41Q^5M6zH0N_J2Q2iy1VeS2kD5P?;6DJ)v@^x zk&i%Mipz?(4ejC$62|uzUbAoZ!4!&~JCqc>=@X1T<=uJ`?7Vw!LT&^<(n8EhbOLq= z@566jHEW_*&%BoT_6I!YFxU`&&jglI~I@Rfa?v9a*&caQrU{gw*MUm z{(C5ZqWq4kcFpmJhck9oK|WTWJpoLz+di@t7^L|YMu3E>oeKUI5~q@Hz9fdsTCtaW zvkz%QX$TAVSoi@^Y(H(meB9bO@VUOrIRh|R9^CH5n;X@@q<1E-LW%xpIY(dFH!(j` zA3N7aB^&L+n3|SV3zv3C&p)M4HfvRvHxkXU-qqjdpl&c4tbI>rb<&@<4F(*3#SlFs1us1o{6{Rsednzw+R5?5e5^gy+Qi+<9ElkS4 z;JIz3h+2ZwO-T&D@+6P%5#}q4YZ*5!y=hg*@nW{7sNujMSNx=O-U(6mi<{zWDSg;7 zmublhN4!lnS3OOg0HSLoE=f(80uit0HnM85t38t&!NVF(37)VA`+rF-HhIPNyh1?9V4H&(>TFZq8)=&2P-A}y*}Cu z7wQ7qdcjY|k2qkgIm+i;kLClf%gLLSgl7tTPGEV%TDHDNEFx=Iy;&8PdfZb){849Y z`nTooCh)6UH4GO{wP!|`oY>zt(UA*Wb}NoQ8e>|^MBY_2h88E{VKeQV1uOm6{TG#@1`m=WD`GsI9Ao76P$s)n(43p9 z;cn?RIX>DYv z?HDgdv6-JtKEntbM|(=?oub1}xBj$;2Ii=D7#%{kB-VlgBi;u0-TidB@2?P)raCEb zy=2*B2ULfVE(Jq(hIf-IydcWE(gGWSb zt5$EoJxmKHt?W>NO{hcBNSG8#umUY;Osz$6NK6Ob*!vc~XZ~kxjzx#hQT3ghfztm~ zI)(A;=^nj=Mvhby284)1KU)=a6v58eEk=`!Fe@{1%gz!&HwR=x#f#R8dtXP4K^X)$ z!cTKw73Z;p{Hzda^8@P z82>0OQ=$RHVW0_hydXj4b(#38rZ249gG#=!9s@h=@;goPc`1K`O)?7^lJ?$mx8h!& znCFY$;aYzAe$;8}{_!KuSI9fZLh-TpG&~&x`z&h`ljWg-_lcM5VZ}Ym5LMNET^)Fy zLS*MAJv(dIh)%mReZ)=SF~ zh!=s@rBw11alcfQkk=HKi|u?vM zf))}+hl>lF-wfJOxYbdL)m;p#XXce#^9|8%%h8w;lV4*7Vs zNR@^cVkx$|kxDAtgovq$IoJmeo|mO6b3n*_sQa5h3@0;o@OeImCJ*ROPMnK} z%)GHUzYYh?td{q-C?N~Ei^mhe&T-ubu?1?gJi+m!P#`;e89Fd&QAc1DcBwGk7U!%x z;?`{%rJcVN}4KZXXW=IVlGIAT(>CsRCE`D@davRCAwK!uZ9B!5^|)nQH{+H#?Ry` ztKwGnR=^@+c{$l`qm)|FxKT1SzJ7V3N0z~AbhJ9h-hgE(jU|oD07)I96-WmS@dXAq zjRfvPcypYud}Ilu2DHgy)%&%3HkA3wV_tqv{S52=E^?09 zAyx0+Vf|JipMKIZD z`qKEAiU!CWU>&C+YmUt51$gNvMpYtTG>3O5Cijs9+&TM<^#ZVC|0ChE92)|ZP(f@apYkwsJa$Cown(wwDvd2A-# zqKyaJ^qlX^r1)BMH~!}RUguDWfRwU}ZatdcX7-0KzE^$;s{G^V9zgSD zIEHX#VN4ko0D|g``3g5$vZ0OZQ7Q^_@8lX}Dk}ZQ7Bh{r6eaRrlC_)Yqfg`Cp{AX0 zW=WtldSb}P_)XLQ%)gmOy^%7MQl6iCKlaw}%*1esUGx9E1*x@zcGI}hy%{MJh)7&; zI1G7u&tpYj4wTYKDS1yrUqLtOv4djZl+P!@@r%DhTD?Qu+fEyj1d()0Jm7kqoq#IqpOiP@I&^i7U@fq>$L&&~)MA%|rgyJ1X>7>bje zd4p3^ACY)usyuN!=K|4=`2A5LG^6j`D4mlg-puI%ImY?Kb2mfNxabeD$OW@_g7v+(!oGm5I6v!h?FmUHmNiC@{T zU6x$EM_PS~Gs^VS)L&)lq*fRmzOj2{7h^9e_n6vOINFIsQXu?%WV}iX_w!XY3?{=74QrL!veBBeN)aA4?=b5DZdgF=CtLJxO)tCQj{IRjwL;KHjA-;$WUuEu$5qVq6PAQv@qF?Q# z1;D8@#B5a`0wLA3KTpv3HX32GwG0m&3E_3*VN=gpgM*;R#IKbM%$XZuH1>>M`7&CO z;Q_WC4+7N&MWs>g%^CEm@`KkW!~)kV=q;q(`o5pUQE0u zhv7H2gO5x0*6n3sL$GvaOXw1PhY60%nzRT(65eLjw|Cu2NYmTCAs|D!Lbp`q zaDt^mxAU?y`pkf(Dn$6fJ>4pNDi~G#Z|IBRPUDu<8;ANOwTEdY?*L?X)GBo=oz!B! zg476Q^OLgH+S*!^+0*hiBnLvQ=PB>#MfvZ8iA$>Ox3F=0I<>T!gKAq-fX%qu@eLT= zp}}X!QVzX)vwqRRkVZ(2pVEiW(x!X#WPMoCa{ug7d144EjIEdO_r!Ls=MKx~+M4Yc zXQB5}`J+6&k${n9+N_jUuFa8^xAsIhU#u%W{;x^;?>KOML6IktrbE_i*C_ z=ly1+*FV?TWI&XPEzuh!Va5mX^$QfkmR`i$5WxlpdLBKzOn%GJ1Pe$y-EN2GQ*}k% zJsMKxTBJ(aYuV3hgT5Ag?KNi4n?*2OL4Ops-6a(=#&JAXi_8BxT+35TgGrIVgi3W+ zi3R}$Iegb4`)y)iZES&BE>I%JnK=1@<>^=YWXoQE3qW4e4^i_pVrw zAfhP7lv=k-Pm+YgF>cWmv4hBe^}kP;cpcFSobej2F;9BSG&5=KviNL-{D(AIYXhiS z+L$XUU)~1eYaB})1^6sjRxgOFn~O^zhy}|j=|zs>gHj@7u`D&n7IWmLkl+ZWI#YX| zhw{<9*a(zY}_nXD6m>rw<7kFG(8B zfS@WW$U-w1Y_`fYAj;;KEk9#EYQJN8FIUvrP=ek{Hr$eiEkuN%BJZq<%ivWBke3wU z%x2PouW|(dz&G{c;ut84n;0g!D04a8Hm8Mj=#v?80VqR&;zWz7O@*Kg+-L+fs(}st zNi{mDEHF8TNSrOE^w7|dsgP4K3ZK&@qHQ-Omfy(|XN5V_yrs)MRSF8`8hic2O(*!Q zW?Yy|^5rCAv6kpTCK$US;QXMX zK7^fE6=G@G&;YBq8A}WJ5K4&!!c?`d^sOON3#G`)dhrlG=4cd;xLY|Sq3hf-xG|Sl zJ7#BILz_m`+#eyJP|?JMY5?>x`l1Xkd_%BfUj9H6mTYhyKK?{REuhH6Rj1jfjisJd z2laigQrFIkN~&*{FCvYsFR=9C3V*v8)ckyqA+{?pI&r2KGLecsu6IwBeE5Lns`fJC z&2N62-kt7yq(S#cKNr(jD;bQmAT}dkc_*-aP^SF}>@QA73*C7}!SLh$AA(TN82mci z0oD_ZqtUWeX~>toRcX`DnMF8F^iZu$;Z2&w*&{yy3Oz3?^^9-=V zD`^ufRD0QOUybW)`*+{4iT!bJ4bzSgZ5HM*3mfro5Rjf;Rt!mGm$dwrhk*?60ZO1R z_4WvO^h6F(nO^XXqp%eH1>{F0%3D^TFtbn={mk^-h&i_L9J0THS3Xium&V%O>)Lt# zsKS>R_v6`5UTAayBU{#M`~>*&rCoF)Ok3LwGiql57|0n@#T@QXzJ3!x_U_( zE1PHF_Zr|=56bZ0CP@z>5EU>s>{%h>+tnaW`bzB6d8S?vFVN7+eR(;WQ_pE=y5-ZH z|E=CZGd1KPmi_$c&LXK?99}Vxew_XAuP$_gbLQ48w>~SKKZ`3QTHMa(F+^J0SNW!{ zXXyHxZpdwS-kdVJBd=vavVAL0fxFMLpT?cMe#_09?keLJ%2Dl%bSlkug-cc>u)M6e zhLCZ*<#EipPJ#x>=!`g@dakZYC@Csx^*RTH@dByXZpdXCQ$=-{ONdH{$3IFmMgBlM z*f&W<#(xW?&*CxzT>m#~-1BKNAR#YQ{9e$*3IF%6b<0R&4Z5M&pKnRXH!FKK_nIBw zd5He;#5NrD+U?Ablmbn4rh}jO7TEqn_amvUCeaO>coku`mjND5agzGg4op zL-WmNT|VpS$O*>6FmNX^^xd@<0GzG6$5mL zCokXTP*3pN4~nsOnx`4hJEPuW$JWdi*m5&&7`(+3%*xmwfw1Q8AEo=ktS7g9!EFy* zHi`=JgwbCKi%sZ^sN>k)Z-B#S4os!_gP2m4KUD%!>Fzzc>#sTV^x5DUUtxCw*lgM} zw|h~s;ly-4iHd42IebX^NY+vo@SxR3E4M^=w!Ht6=~V3}*qaCQYIN~f;OxD;f+Js0 z%sVSDotqpldj7aV2hWUre^sot>MNikUwC!2UP$7rSWleoyUeP724nTFWV8dn6kth$ z9RcGojStNc%-JdXM|4t%Jy@Jh5$8s6nT)oku(5Ke#DDgme`ZR`jF9KK=HDuV48H^C zOKne<2C*IY)0nRNFI=}}A&?5fTSQVCtEy&*1H6xo_g@R#JE!9{&v$aR3>XVU*9g5GKL0#-gTcOC+S5Kq1VW)P{hDi*dYTIW$$ejdQ zHWlsE11A$AbWw?lz3!P?@^jXy7d|K_x7@iB4QEq9Q_BK<{k@yw6uV_DFUBrcYi5V^ zkzsSb19#k;&9|(`Uvi9#R5jmL!N2F_m-C&rywowDQ|ZH?rfS z^&?AXtGd+|O*Pn4dPm0$S5Ik`!-jVzF9oW%+^OsGkN1B3*WvuX@(=&};s0lEfRfFB z-S$rWbuFoI+I(KPw{Gj@_e6!AFOKNyh!eN-e_&WfnqUGJH{B%y>*zsh)%YA!X*05D zt`*p71Z$$yRLzm9&w(uIp8%2zUxmKq3e90iv=DWZr7d=_@wG$fVL~vf(}vPidO4qY znAIC;s3m9+`j#`&AQO^Ktho8^BY2JR>+7_IDpY-EPT{Ew+{C$7dHoxmtHZ$)9W=>a-Y9Qx; z@4X8oA!P2v^PO|P``lmm&wZYI?+HKHnb~{I>@~CZde{4|ywOyDaFYHUJpceF zRg~{O0RR}(gaNeF(7!)kzX;GjN}Jniw*jCyih*cB1%2nURDPlc06u~M5Eu*q?Dz$jRO6Hto+`U6630m{E^0Dvq( zpA%^m|2~@r`{!90kaptVZF0x6ml6{pBL=qG`tJH_kK`m%|H3H#skbi+_8_L2dG3@r!(aySUpcT+~<7!MH(Q8#Z# zcMC63M>qa|82P82yH;+NuC~tZwoZL~2Qe}= zXLGV^@-cf?!Vxq~GSR*aa870pl{dPOnawFJ=kelNxCbeG0=gN_e850v${Ne90M$G)R^;|21 zxq54oHa&mTljX{e#y7PBDlTsyV!*nbwQA%{V~Fs&HPX1{Xh%0RHA|MTnp2myv8}K07=`mR zGiH`~UnMd}DXGNF>Wv91sQx$Y4QZcu&S{A4VCCq1A{UNd-4UrjyN8-x<_flXTRLl_ zOhe%~QN54>cbSaxznD^co^dgr;ip61b4iAUXOT(qpd_>69xBqsI;NSjz@%oe8NN3T zFS-pc{w~O+9?f;W&px1m&mgcFCJAz#^*e6v7d{UQWniQ}Aw;^rNJ_a+2+fTpeuKd>VQS}xR?5GtV!6iK#Z`2`rn&+tT z3gI(;ig0b6QvvI)Xo8*sNrxi0SSl1Z`p3{GZpYJK5Ixq(TAXPL)u}FZ^?vsWRONni zykJ#6=DVu^oSc_tJoZxgZkKjN?U8scjcBAu{Zw`18Qt==zWsm^@3ImgWkaS1 zO)3pt7?%X~64oNFZH6U-A|EUEOt*rV6-N~DZMT=pUC{w5mb;|mqmGEWenn<;QlVc@ z6t(6rqSLPal4zjpji&bS6ONXVY7YG=2#GjKHf~f)Ek|@V%2!e(DmMHRsFvAe1W4K? z5G7xVI_*bTIL21AajajN+0pYq7_}LsSB|aj3_gxqjh^laiwD(eL;!1+n|K?nVRsG_ zEZ1dfa&4^;Gr5xcbP=Sd8FsIc0<+wFs$*f(S6XI}k4R1atUjLn_3%VZd9~2@yDH_S zn~SJ{rpmH|YDJJML<@M-AWO{f{y{T&#piL4@K)_!4`0BY8hZtvl;HQ9L#ZHfsi!It zRAH_<(cs}h;$41zg29j0nZDV1dLrTJ&0GON7LsC}*4(Waz0b)<*V6E*Bh4w-lM1@Q zD>VuOb_KxshMV%sJjJnDTI{q{Tp4GEVG>pJWx%9-Vgk6A{k2$)W(hS{L?5W692zrq z`;aBQIW=ci@4Fz6<#6NL+X{T@ao-IvO4%Yy>>ba~;ZLbdG(a55MpxA-s1QT)IiBx` z@$h-qefqK3T(L-;soPG91N+AcPrXGH*C#hB^Ott<#A7-QgQQQ`PI1h-u)4LZkw@hs zm?POYPU;3bV(9}Vq?$UmG9+3B2kz-?f;I>uVaj;jEzBe78y>EHY0YZ17cBq70nAw? z6WeN@)qKy%$#WxYR7_c0rfTJ}X_oScuo1NX%#JUij5@S{^%##8CthVFkM3}RNv`Wg z4|2*sPHkDXrJ8q;I3?cysm%bgSCR(ET+SuHpZCAHwA7p0D^;P9BHtlMBikZJeRE9W zdZzg42-{oyTwAY9O zy40rECuDeoeT^Ia2DS*Ao}06dq%l0gSjfxm z%|D_jn$uj64!&2L7MLVXJMy_ZB82IZ+mC{F??B;tp#2yw_Q|!#$l)zE{XtZYM7XH1 zGL|TQdGx5-jwAw*ck5BepO?>}@PD70|*y-7}-;t8t#Wql48SZjpd7 z0hXHdM$br`vD@!96F~aFcQezDwlGd{)4Ydkr6g9qB7SqoU>DV22%(vQ*x`jS8XO8C z?lB#2sCW8TRZVSFNfe1Zp4^&sD+De(^{u(Qg0)3*cM7g1J?*B}NuxAm(FFeZ=r9x{ z2=rxRcjnr-916QzUBhn5Xge4_PelBH;cN4h4P3m72Rp|G@A&4N{_W$-D_4`&OOIEZjKe}-L7v+Z^o4k8TMq%Y%5F~eF`=#%+JqXVzKAr z4wlJ1|MX<$XcyYJRd_)6em#B)X0L8p7P5IHh?UYTQ~3)9o^WoT4g4i3p!j!YeJ=xH zX;j*3tj|@_9KV@#v+`-CK}WC|YuC}Isicfv7!6R;-ng6wJZI6;qWsNX>9d~`W1ooq zEvOj}^(zWqbF7D9a4q7iztWjgb2hVh(s8sbBd0WhGjx=@tl4Z#!yIA3G5j}RM^x;< z(LH&==pyV=Dr!?8(K#UaKA#W;6+(TK)qV%&RJ(JZn`KtRu0kyi)KY!pMSEThS~TNr zrIhd4Dv?{Ju6h*pP#Beyy-ab6&pA5~Hm%8{!K$n=J*a1vucUp2gCa3YJvmIG>J(q} zyFeIU`ZKzZ*aGaft8d+rR|sYf`Rh=x5>+FJFjuFb!X`OO@fX>lo$B#|A-TY?-iA3y zN!ulq2E_Mv>r(29ZTG8+56(o@gmz>IK4 zz7%>uiNt8&F9Gx=y30T;rGqqO>=hP`p&6TZchZ=499yLt^FiWXZ(ExqC9S(M)&`LK z@W|vskr`h~ZBq#pvkr6hOufIJ^ClTrVvZ_##4tRh&Y9|*oJqG{g^HRNxDRH~I5h-C zU)kGLX0!yX$!RsWP2TDh1uTT#iy^JAc}~AZJb2MUEk;=bu+|+ z-HESG=js|deE!+LgxaCOI0I+qZtVeZ zHv*(%zpBYOez;K}0zBlUApG^|J;CL+SI-YUMx;PIST|`{wSpSj>#Ap*H<;cO70@J10ph{KpCh_KX!m_p z5Dyjr>r8Pzhmlh)wC&y!-xN#ITN~fqO*XpSqx3Kw#lXk3b~x$lBuVlVd6 zg}M(B;gnf58lS)m6_YSp+9+OlK_oAn2g5p&&q5Ig(yRPopSS^LAy!9(7eVzqCKR+x zy>QTvJD`QP;yUH(_3CeN1GMqrz-14+ zCi=?$frbpJUtg;~d)|mC2*ktRb3uW(bz`jJWaKGkB;5xAb5h&~2mc7zBy2>QsnH6H z8wLjJTX;P0Viz!x0wrG^<3Z5|!TvlvM*0-wpZ#Tj;!axuY+Z7tPpN7{^ZM9w7=G%N z-F1fq@aV}5JytOq*Owb8ahY2`gRq5Qjq9V@L(#R1jD+A(EE61oL4tMF1v+7t#@EdR zviRxVng#4lHw3LhIC!xADKZF(D` zofgjAzWyo;`yEoHk%F<80!eJNi^v-p>_`;$A@RoEp~6xo<5rg!;>-^Av21>>hUe9v z-HY67FxLzjyWg&J)23c(&hye~2uvjL3pR8=Oa=!s8jS$959%rM2H*@cNYk?ah9Km8>=R@ruS8zutN~{UU1U%~d;6Z_E`c zEW6##LN9x+!6gawd$Hr=De%~AzZWF-7VDd|=4YSC#be%H7$hn_grMcBH-kBS6)wjn zY%uSAGy*4pl#pc*$|kXDkR&>}TvF-2)(mQPfx6yCwzV1fDf+bghxC5i4KzFcIu-Ji z1mDu(i3b;Y?PXS9>ulvrx$4^&Zdi5?|%_Q`B{9pV+|fuVDdp+8@xd4_1BFvU0`_#ryV96 zkBR=}Y?(?C|JcQ0iNQ+BX1r^4gMs}fK2+$-flk&)Q&hm8^rr=3pFl0f*MoUapu8eX zpW<3*MiBP=b5Y0LnNZ#WX1D%X(|%jI2EEUNkS{>(71}bcH~6W7t-Sm=DbQxWI6xKF zByLRCW?7*_H(u*qYImisL#jr2E&U+zs=g}v6ZjzF^+Bq*3rSiw$)7rnqWfCc(dSQ3 z#e$>|LwDIcye^eb-c^zySnnQYqL^CUZ}>xa|8Kq53?fR!;nVtIF6DFk@Wcu zj-$JwwlW5cULI%E?lir*Fk)DG4KzyU9&?`AL*;eUGMG@r>p3=hnx1?VXlw0qygzK_ z)VgKslvzbIq?@xBS;)p5AB^p@38{ZbN)2f5z5he@hfFz?xeZjgQKeZtE~Gw3Jz*>6 zSFBfeoP*hpL18#1sJZG~7aj)XFARUcPd)5`9;*J$-#Lidb1Y`XRo$Z3Dm^z02kqWp zx0AoTcrAtc?7Q-Fc7pf>Uso{;r9I5xsdJ>Px zT47?p4 zCo=J%^G1h**#-MW5ck=hv?LYZQWGB-)G##N|5cTYm2+DJI!AnnE|PU$t)5x#m?nUW z;B5Ny;?u@CMqP!)`CC&MvxzFVEHZOom+{lB=_tMPeu_H5QGs=0-wW=C$WwG%e6;mv zWuWdo{EFi2^??bmoK)K&j{J4ez|om*aJ2iy?LYv1k#d&qV%;!djz6sU^gH%sf#|A0 z_=zrgbfdTuAJN}mio5&D&l40=oz#Nsoa5K6Z0%rS+Z=(L%_|6C6N(!BrzjPg+2XsHHXIXjw+IW5& z0wJ&*K{_&hgII;`=YNH)WQq^{3xbv3Y?S@}+W6O;{@&C#hReW{-e?Gql5H?RHco5a z{3i(31Ts=C?40^*fhLWo46OQsbxsbyTrY+d}j> zw$3Y$nRyV!qqF06&fm;u&7rzjP+#*aA zA!Mr7%k@KV4bKHRm#nl+Id;$pvcVCxQpN$A>yV!ZdP_lJy}A|jLRzJGMd{4A55 z>bqIx_x+;tTx*0dW8-a{i>hkc)Wb$LTX&hwepb%zT39kq?%tzMz-TI`Tcyc( z%Gz6;;)!dQMj-mDLW2lD8ttXFh_U<8omVc8K1uU(!D4LS4y@H~I$ZB@QxbQxHy2N2 z{c0+Wv?J!!7<~mv{^WFwY^$mTN<7SeeK=d~fx zsxGOZ7Rq{)BE{?6>jn8Z%tP+PGznDU}XDxz3W-C6pMBHej3{P#_co*be>sprq^y1-7SBDyRe?X zZyxxA@(@DSETtl0IX&lVX1IeC(bhSqrQ?a)7hq&Yu|nJ9mQfYf33X&d6Zp~Q zHZVX-+>OI@Ip$e!$J*VD@E;z246HvJF<&Mq!$;t<@aIoe5}=ok!5hDq3L4ki?@%GA zKiV-UnZ-QD9tLyKRM9?@(T%~_xj+GC-tvX7-)S@8`^mgxcdEw2U``aEEPd-D7e~8o zp3mc(302>>AU37W%{EKfow6LB9fk(b)>JvxiJgTvsSp9OY%XZn%+Utco@Zj!v45orq)Vm z<#PI^R$dWY?i!Ay6Ij{!f`ggIvj*L)D<1W{JGwxeZ3%Zsd=6JbuhV0oltvsM@8(O< z*V?BIP!4XJH3`D?$CLwR987!jK_T^|Y^AQ9wg^N&Yz8&q+7-gBE^5#W`i0^{rbV8J>~L-uz6U9&3J7JUa7zl>k* zJ(eE0h8DR?y%twHP78LHKLOsp4%INYRTSyv{>Skk1~elrn5{-9;^u}N(Cck>FU9ku z+{AJccsA|Tq~m>kXTTDkU`6e!LnWdmm7F9VBn)G8;e?-2v-%!unFyO!JDJP_r7)={ z%33n`TT2lFuvXgpwMWZ8(}jt>_Dp$4#?{+J_A&6UC+s;}A3XG5p4L~J2^q9@-pKl} zq|-Jl8t8xaHOkK8=wM^tKU|^9csjSRe$`k#l%}-1ON*aa2lK!&d=!fu9Le%=(u*Us zcc2(6VCIZJpeSSzs!`4(Dq&Vz$%t9)>9MEy&2@CvPFZ*po};@hHX}_-PuMr>$)pAlzUAtAFP2mmpX>j1@G($d zGhY_Zt9XGO2t9fM=VC6HQ9obg!2EmT7;kd{O_;3nU294b{iW&11^&r`Pbe;jR7MQQ zzt;mdoKPaJd9C=f0Yipoay_mFD-Eo9Gg3UZ7Ge>vc*R@soswGGaXh<(>yaq+`F^&x zZ^a2iG7&!+6_3^>Ej^yCOJ7r`nQ3)zc2B4&hPfqb@iG0L$y{pOvh$iMT%i5cG+_)b zCOWOgnflls?zkSL{4_m?7&zno@iESXwk$FkBJNff#RGQ2H;#6S$8MwjEz9I8TL&)f zZ>QkF56=#6(FcxpGfm7k1kvcoT-YaL0#lq@%0{Y^^!EH`GPAkXlFCck4z1%f^D9Q~ z&UX;{PRh%c1s2QQ9Ml#$m#ZbwUEALj%AD&ZAJGPckh=V&$l&Tf8tngJC!q5vhf2cA zn;H~-{$y7}yy;d?@~ygXPI29)f)uWm-OZAB_d2HXLrv1H{g zr(kU|gn=`sD1pfPbm0mtpFtji1mu{^!PFJBt+ndFA~)&8T{F#XT<=#EEHNY<*~+}T z%o;#FlhrAW=S>Yz)ndCmA1L43V9g@6niX4ke$E@WZ9yk=tYVpmlVCS9ZmzmPmp#`R z-V0uByXr*I_2uSLb%orh zHIm4CX8hg)h}g=~-`3E|v;26LiaMy_;L?cmE)tRQ)3IxnYX*DT=~$xIfSr;Ah25sM9ioZ*C}KS?pA$o><~`y8Z1cFX+9!zOmyx;+stpz~#goiju2kmqKWBuxO%75Ug+p{^mn3J+o@cPA#z-)R z_X#Fmt;!d!=;=hIpY0R_BMt&a;$i0WgE=|WT6xwVgDD$9wyBn+WYyO)N1C(X;5&aI zcXLMyJTo}+d)z1Fg-l*`9idlpD!ICgv-Rm!yBv9~ch7zqL_|e{`2rk!Cxvr~6%Fh3 z*b6!8q|oX49c>QG7uhJytgsSQ|4%thx)%{?3XsmgRIKB|`!eRImKo}JpU@E_wD$H6 z4_D5POsv(r5Q93{2k_n#1<$d(U3fVSgP1#&Ic$1+5Dc~v?NKqu!EY?G@1phRDlpvo z&nckI{4|u#zir@3A=Yid|EZv2E2aVxuhbRMu_)^xD6T0=s*~nSN=(j?-t(^|1%Stg zqrVxF0Q3#A0<=5Z4=P4)%ICR~N_nXeNa`;sEXwx~6wcS!3@}gRN4Qlzsh})GyrSkz z02`lXZ6M728Liho{N>N?n!$XdiX2BzwZcV~O2sy*-0=+4g`QiLTjRS^Ui9ohM2y`v zE3znBO&{(0GWLuSj(3KjI&Q*NBTNb&q?~o+I=hIH{?i&D0vtS%lZE)&+4P+7Kp z20t>;JoNn*+R3g7FqWB+%odA`HtNX?Prek)R|8=#i9TaZKSM^E+)}UMj^PZ?;=q;~ zzalCstYnN^?`HL|6`D+=d`uA&(x0MbiGPTo*na!X2U1fb8MGnlJ9aBl2w$|hKp;8c z?p!W3W>#JQ(7TWTspiuAcJzUWg)`J2$ zM@=O}V4{ zeVqh^hUgigzA9qW+I(JTm^dJ2T&dX|W_|1kwwZ+0PrXL?afI)Ssj&ERT!+S!30}&s^3N9U*blG{C5y|iTj4ra& zch2|b6Uq+(H`CgQQM??X;tHO)fs{)SIV1&3Y>F|LqLAiZb_AQ^5;vqw`VURmQ^AQ0 zjiWJ+K-0p6)15w)v;sn!;ED#ykXfkSYfXrAA`BLGx+%4cnExNYa zMuXg%%%4qF1XuMiIkR)lohuI4Aj;ps5)=l`rN5~AEca-Bf9S9u%hb5gcKdlIxd7(b zWtLW3zZL_VGd@d70Z{ge+$%Y%+4CQ2=zvs<_!o2JOpJxE&j8AbN2>^hKBfJ zqH`b3vJ0X^sCX27wEdy&ux_lv=`i4EY)bcYgByc%4%!7aJlCk9h|dUslz4H4Z~uxId!Dad-$3ZVq7ckz+occJV^$@wyGy zOiSOU`l|C10m6|eBzkH9B5E@$N~{klS2sOWqJFc}3Z(=*ho|5s`dPB?oY`4@-qn$L z$GkiP$B2DlIwXilyM{&)=Xl4QFd;FI{6<&iQyodtPa_-iQypL1-pd4NzIh&d zneR}wxyMB>s3A?xbA5!b*>vFy>a&DXv+(p{ccQCjGoT5ar*w&+~F!lC5Nw4Uxu*W@j{3Q=Np0@6H?IbWHq)_)OQZN5H5C1MH z2J-%xQ-MvKcIGe_!q5lK$bj8W0a{4k_O*$Ez)rA+HGrAQm{p2B42reuYQA-cpC53u zJAcgzQW?Ailcd*%Y6r8e$1~j^o%8+^pQ8f@p-{3O>u!QQ0!@0i=Sd+>uw>%13%!?t z`>e-M(mOO`L3CNmhHP`ItrI8Yq`}PcE`w=^$!WptrvoF>FX1Gdr5?q>C--HG@~V^I zB?l?n_J7MDSjAuDvNsQXcGS3>VDw%t2njYeF%kc*kmv2~{582Ty)esa%b$^mAzA;K z5YCbgOZ8&wAYo0IC~aO@pS4f|BQMjr;7IM{<+tiD7Ky7UX@84HjvJF2cw|w}V&&76 zEcy3_QTa?t9(DV50<|af06~Ezi1+RxaLffea#U3^2!R*bvY;A}@4`?a3f7mLWiz92 zNj;H=kAkx41g%O)s&lYD_P-w*ZaGAK;|z38BmAn)WH(_?L%@s z{GYbD#IK5W;&-y($5%QlwYVu~e>~u;vGX7NJ~Ma+-mFPPDi_KzvmH$B$Dp=Kdlhp{ zAq=Zsc-LwzmGJQ+-Q2{iEY(n>Lyy|6vQHv_WTxcX#DI}zkymFYVvBsrg;EV(yxKAv zDt%F!;o){DofUUOlXWD*m^lXWuH_A3*doH-C59PJboP&rIeqwEeE=eXRX2eHsG?|a zg|?fg!>nt)Yrr$jyvr31wC}4WaPkn7McJc@g7Cq&h_^Lp+;fad#NV zvvpUoZt|Oad}p}YkLHTWKdUIGSH=`0YZAlhGigr@dcH~@-QN*O2*MNAjXdqc|5hwX zAnR0fnyQSE-!up%qSO5tbYDHfkLpyw`0rC^BY&Wd1V86y6Fh{z+kg_Nxr9D$lN((*-`b-%66kyc%ggF%20}L zzIxpf>cdM}zYm)Iy-rM1{VH{`#=@^o z#fE08oz~x*Z=Gqgi~xt#p1uQeebn|nfjS0GF>U_DWUfhG6RBPIe&pJ_ z<$|^&y#nKJy-BhIWb%It!sY3`QF*J5Trd~Ey4@clgUVwrFE$hlLOTmXLD$NHoSegP zx84mgF2#l60JJmdTO;S={Yi*CvBQmLuaE{6OpVicY-fFNewzLyBHq+173Gd^7v5z3 s4{7#S5cm&s_`mhT1#)L>2_=b_X~eX#u}MFa{EH@)d+K)!?wG&)A2SE?)&Kwi literal 0 HcmV?d00001 diff --git a/software/source/clients/ios/react-native/assets/yay.wav b/software/source/clients/ios/react-native/assets/yay.wav new file mode 100644 index 0000000000000000000000000000000000000000..67548702aef5e1100e94daa72fe5bae822fb6fca GIT binary patch literal 91924 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%0AZPVJL!P?m=;q@@4 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 l5e?d6-~Q} (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} > - Scan Again + + Scan Again + )} @@ -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", }, }); diff --git a/software/source/clients/ios/react-native/src/screens/HomeScreen.tsx b/software/source/clients/ios/react-native/src/screens/HomeScreen.tsx index 27ddd4f..270fdbc 100644 --- a/software/source/clients/ios/react-native/src/screens/HomeScreen.tsx +++ b/software/source/clients/ios/react-native/src/screens/HomeScreen.tsx @@ -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, }, }); diff --git a/software/source/clients/ios/react-native/src/screens/Main.tsx b/software/source/clients/ios/react-native/src/screens/Main.tsx index 5c360ab..08519b4 100644 --- a/software/source/clients/ios/react-native/src/screens/Main.tsx +++ b/software/source/clients/ios/react-native/src/screens/Main.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; -import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; +import { View, Text, TouchableOpacity, StyleSheet, Image } from "react-native"; import * as FileSystem from "expo-file-system"; import { AVPlaybackStatus, AVPlaybackStatusSuccess, Audio } from "expo-av"; import { polyfill as polyfillEncoding } from "react-native-polyfill-globals/src/encoding"; @@ -7,6 +7,9 @@ import { create } from "zustand"; import useStore from "../lib/state"; import { Animated } from "react-native"; import * as Haptics from "expo-haptics"; +import useSoundEffect from "../lib/useSoundEffect"; +import IconImage from "../../assets/qr.png"; +import { useNavigation } from "@react-navigation/native"; interface MainProps { route: { @@ -56,7 +59,9 @@ const Main: React.FC = ({ route }) => { 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"], // Change as needed @@ -168,7 +173,8 @@ const Main: React.FC = ({ route }) => { websocket.binaryType = "blob"; websocket.onopen = () => { - setConnectionStatus(`Connected to ${scannedData}`); + setConnectionStatus(`Connected`); + // setConnectionStatus(`Connected to ${scannedData}`); console.log("WebSocket connected"); }; @@ -318,47 +324,69 @@ const Main: React.FC = ({ route }) => { }; return ( - - {connectionStatus} - - { - setIsPressed(true); - toggleRecording(true); // Pass true when pressed - startRecording(); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); - }} - onPressOut={() => { - setIsPressed(false); - toggleRecording(false); // Pass false when released - stopRecording(); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + {/* { + console.log("hi!"); + + navigation.navigate("Camera"); }} > - + + + */} + {/* */} + + - {/* + { + playPip(); + setIsPressed(true); + toggleRecording(true); // Pass true when pressed + startRecording(); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + }} + onPressOut={() => { + playPop(); + setIsPressed(false); + toggleRecording(false); // Pass false when released + stopRecording(); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + }} + > + - Record - */} - - + {/* + Record + */} + + + ); }; const styles = StyleSheet.create({ container: { + flex: 1, + position: "relative", + }, + middle: { flex: 1, justifyContent: "center", alignItems: "center", @@ -372,6 +400,23 @@ const styles = StyleSheet.create({ 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, + }, + button: { width: 100, height: 100, @@ -389,7 +434,7 @@ const styles = StyleSheet.create({ }, statusText: { position: "absolute", - bottom: 10, + bottom: 20, alignSelf: "center", fontSize: 12, fontWeight: "bold", diff --git a/software/source/server/server.py b/software/source/server/server.py index a1a7ef2..e49c220 100644 --- a/software/source/server/server.py +++ b/software/source/server/server.py @@ -196,11 +196,11 @@ async def send_messages(websocket: WebSocket): try: if isinstance(message, dict): - print(f"Sending to the device: {type(message)} {str(message)[:100]}") + # print(f"Sending to the device: {type(message)} {str(message)[:100]}") await websocket.send_json(message) elif isinstance(message, bytes): message = base64.b64encode(message) - print(f"Sending to the device: {type(message)} {str(message)[:100]}") + # print(f"Sending to the device: {type(message)} {str(message)[:100]}") await websocket.send_bytes(message) """ From 34207314f548ee4244d1b48b3bb36b7097fc770f Mon Sep 17 00:00:00 2001 From: Ty Fiero Date: Thu, 25 Apr 2024 15:26:42 -0700 Subject: [PATCH 23/39] utils --- .../ios/react-native/src/utils/state.ts | 10 +++++ .../react-native/src/utils/useSoundEffect.ts | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 software/source/clients/ios/react-native/src/utils/state.ts create mode 100644 software/source/clients/ios/react-native/src/utils/useSoundEffect.ts diff --git a/software/source/clients/ios/react-native/src/utils/state.ts b/software/source/clients/ios/react-native/src/utils/state.ts new file mode 100644 index 0000000..2047977 --- /dev/null +++ b/software/source/clients/ios/react-native/src/utils/state.ts @@ -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; diff --git a/software/source/clients/ios/react-native/src/utils/useSoundEffect.ts b/software/source/clients/ios/react-native/src/utils/useSoundEffect.ts new file mode 100644 index 0000000..250353c --- /dev/null +++ b/software/source/clients/ios/react-native/src/utils/useSoundEffect.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; +import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from "expo-av"; + +const useSoundEffect = (soundFile) => { + const [sound, setSound] = useState(null); // Explicitly set initial state to null + + useEffect(() => { + const loadSound = async () => { + // await Audio.setAudioModeAsync({ + // staysActiveInBackground: true, + // shouldDuckAndroid: true, + // playThroughEarpieceAndroid: false, + // interruptionModeIOS: InterruptionModeIOS.DoNotMix, + // interruptionModeAndroid: InterruptionModeAndroid.DoNotMix, + // allowsRecordingIOS: false, + // playsInSilentModeIOS: true, + // }); + 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; From 2e0ab15e5bbc304c3a4e2009bc7112938f04566d Mon Sep 17 00:00:00 2001 From: Ty Fiero Date: Thu, 25 Apr 2024 16:18:18 -0700 Subject: [PATCH 24/39] More changes --- .../source/clients/ios/react-native/App.tsx | 28 +++++++++++-------- .../ios/react-native/package-lock.json | 9 ++++++ .../clients/ios/react-native/package.json | 5 ++-- .../ios/react-native/src/screens/Main.tsx | 20 ++++++++++--- 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/software/source/clients/ios/react-native/App.tsx b/software/source/clients/ios/react-native/App.tsx index 5fa7a05..171108e 100644 --- a/software/source/clients/ios/react-native/App.tsx +++ b/software/source/clients/ios/react-native/App.tsx @@ -4,23 +4,27 @@ 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 ( - - - - - - - + <> + + + + + + + + + ); } diff --git a/software/source/clients/ios/react-native/package-lock.json b/software/source/clients/ios/react-native/package-lock.json index e78cdd4..47618dc 100644 --- a/software/source/clients/ios/react-native/package-lock.json +++ b/software/source/clients/ios/react-native/package-lock.json @@ -15,6 +15,7 @@ "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", @@ -7865,6 +7866,14 @@ "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", diff --git a/software/source/clients/ios/react-native/package.json b/software/source/clients/ios/react-native/package.json index 6d5cbe2..1b0d566 100644 --- a/software/source/clients/ios/react-native/package.json +++ b/software/source/clients/ios/react-native/package.json @@ -16,6 +16,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", @@ -24,8 +26,7 @@ "react-native-safe-area-context": "4.8.2", "react-native-screens": "~3.29.0", "text-encoding": "^0.7.0", - "zustand": "^4.5.2", - "expo-haptics": "~12.8.1" + "zustand": "^4.5.2" }, "devDependencies": { "@babel/core": "^7.20.0", diff --git a/software/source/clients/ios/react-native/src/screens/Main.tsx b/software/source/clients/ios/react-native/src/screens/Main.tsx index 08519b4..ba6991d 100644 --- a/software/source/clients/ios/react-native/src/screens/Main.tsx +++ b/software/source/clients/ios/react-native/src/screens/Main.tsx @@ -4,11 +4,11 @@ import * as FileSystem from "expo-file-system"; import { AVPlaybackStatus, AVPlaybackStatusSuccess, Audio } from "expo-av"; import { polyfill as polyfillEncoding } from "react-native-polyfill-globals/src/encoding"; import { create } from "zustand"; -import useStore from "../lib/state"; +import useStore from "../utils/state"; import { Animated } from "react-native"; import * as Haptics from "expo-haptics"; -import useSoundEffect from "../lib/useSoundEffect"; -import IconImage from "../../assets/qr.png"; +import useSoundEffect from "../utils/useSoundEffect"; +// import IconImage from "../../assets/qr.png"; import { useNavigation } from "@react-navigation/native"; interface MainProps { @@ -132,7 +132,7 @@ const Main: React.FC = ({ route }) => { }, [audioQueue, sound, soundUriMap]); const _onPlayBackStatusUpdate = useCallback( - async (status) => { + async (status: any) => { if (status.didJustFinish) { await sound?.unloadAsync(); soundUriMap.delete(sound); @@ -232,6 +232,14 @@ const Main: React.FC = ({ route }) => { }; }, [scannedData]); + useEffect(() => { + console.log("Permission Response:", permissionResponse); + if (permissionResponse?.status !== "granted") { + console.log("Requesting permission.."); + requestPermission(); + } + }, []); + const startRecording = useCallback(async () => { if (recording) { console.log("A recording is already in progress."); @@ -239,6 +247,10 @@ const Main: React.FC = ({ route }) => { } try { + console.log("🌶️🌶️🌶️🌶️🌶️🌶️🌶️🌶️🌶️🌶️"); + + console.log(permissionResponse); + if ( permissionResponse !== null && permissionResponse.status !== `granted` From bd3df36417c336eda81e34206b5b20c5484d65f8 Mon Sep 17 00:00:00 2001 From: killian <63927363+KillianLucas@users.noreply.github.com> Date: Sat, 27 Apr 2024 12:24:21 -0700 Subject: [PATCH 25/39] Credit to Kolja for voice interface development --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index a95c808..24b1fa7 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ poetry run 01 # Runs the 01 Light simulator (hold your spacebar, speak, release)
+**The [RealtimeTTS](https://github.com/KoljaB/RealtimeTTS) and [RealtimeSTT](https://github.com/KoljaB/RealtimeSTT) libraries in the incoming 01-rewrite are thanks to the state-of-the-art voice interface work of [Kolja Beigel](https://github.com/KoljaB). Please star those repos and consider contributing to / utilizing those projects!** + # Hardware - The **01 Light** is an ESP32-based voice interface. Build instructions are [here](https://github.com/OpenInterpreter/01/tree/main/hardware/light). A list of what to buy [here](https://github.com/OpenInterpreter/01/blob/main/hardware/light/BOM.md). From 11ed12d218286e6974ac7de497865e12352f34f4 Mon Sep 17 00:00:00 2001 From: killian <63927363+KillianLucas@users.noreply.github.com> Date: Sat, 27 Apr 2024 12:28:32 -0700 Subject: [PATCH 26/39] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 24b1fa7..2686f74 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ We want to help you build. [Apply for 1-on-1 support.](https://0ggfznkwh4j.typef > [!IMPORTANT] > This experimental project is under rapid development and lacks basic safeguards. Until a stable `1.0` release, only run this repository on devices without sensitive information or access to paid services. > -> **A substantial rewrite to address these concerns and more is occurring [here](https://github.com/KillianLucas/01-rewrite/tree/main).** +> **A substantial rewrite to address these concerns and more, including the addition of [RealtimeTTS](https://github.com/KoljaB/RealtimeTTS) and [RealtimeSTT](https://github.com/KoljaB/RealtimeSTT), is occurring [here](https://github.com/KillianLucas/01-rewrite/tree/main).**
From e9273daee26fb360859de1bf40daccba1a36e3cd Mon Sep 17 00:00:00 2001 From: Moaaz Sidat Date: Sat, 27 Apr 2024 21:21:05 -0400 Subject: [PATCH 27/39] Update README.md --- hardware/light/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/hardware/light/README.md b/hardware/light/README.md index 9ec534a..5964a32 100644 --- a/hardware/light/README.md +++ b/hardware/light/README.md @@ -11,6 +11,7 @@ To set up audio recording + playback on the ESP32 (M5 Atom), do the following: - M5Atom by M5Stack [Reference](https://www.arduino.cc/reference/en/libraries/m5atom/) - WebSockets by Markus Sattler [Reference](https://www.arduino.cc/reference/en/libraries/websockets/) - AsyncTCP by dvarrel [Reference](https://github.com/dvarrel/AsyncTCP) +- ESPAsyncWebServer by lacamera [Reference](https://github.com/me-no-dev/ESPAsyncWebServer) Finally, to flash the .ino to the board, connect the board to the USB port, select the port from the dropdown on the IDE, then select the M5Atom board (or M5Stack-ATOM if you have that). Click on upload to flash the board. From 3dea99470a8a43c731853e9276555beb4a9679d5 Mon Sep 17 00:00:00 2001 From: Ben Xu Date: Thu, 2 May 2024 12:48:32 -0400 Subject: [PATCH 28/39] add mobile flag --- .../ios/react-native/src/screens/Main.tsx | 2 +- software/source/server/server.py | 109 ++++++++---------- .../source/server/services/tts/openai/tts.py | 16 ++- software/source/utils/accumulator.py | 12 +- software/start.py | 13 ++- 5 files changed, 79 insertions(+), 73 deletions(-) diff --git a/software/source/clients/ios/react-native/src/screens/Main.tsx b/software/source/clients/ios/react-native/src/screens/Main.tsx index ba6991d..0bd7c8f 100644 --- a/software/source/clients/ios/react-native/src/screens/Main.tsx +++ b/software/source/clients/ios/react-native/src/screens/Main.tsx @@ -182,7 +182,7 @@ const Main: React.FC = ({ route }) => { try { const message = JSON.parse(e.data); - if (message.content && typeof message.content === "string") { + if (message.content && message.type === "audio") { console.log("✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅ Audio message"); const buffer = message.content; diff --git a/software/source/server/server.py b/software/source/server/server.py index e49c220..b2f8936 100644 --- a/software/source/server/server.py +++ b/software/source/server/server.py @@ -39,6 +39,8 @@ print("") setup_logging() +accumulator_global = Accumulator() + app = FastAPI() app_dir = user_data_dir("01") @@ -196,26 +198,11 @@ async def send_messages(websocket: WebSocket): try: if isinstance(message, dict): - # print(f"Sending to the device: {type(message)} {str(message)[:100]}") + print(f"Sending to the device: {type(message)} {str(message)[:100]}") await websocket.send_json(message) elif isinstance(message, bytes): - message = base64.b64encode(message) - # print(f"Sending to the device: {type(message)} {str(message)[:100]}") + print(f"Sending to the device: {type(message)} {str(message)[:100]}") await websocket.send_bytes(message) - - """ - str_bytes = str(message) - json_bytes = { - "role": "assistant", - "type": "audio", - "format": "message", - "content": str_bytes, - } - print( - f"Sending to the device: {type(json_bytes)} {str(json_bytes)[:100]}" - ) - await websocket.send_json(json_bytes) - """ else: raise TypeError("Message must be a dict or bytes") except: @@ -224,10 +211,11 @@ async def send_messages(websocket: WebSocket): raise -async def listener(): +async def listener(mobile: bool): while True: try: - accumulator = Accumulator() + if mobile: + accumulator_mobile = Accumulator() while True: if not from_user.empty(): @@ -238,7 +226,11 @@ async def listener(): break await asyncio.sleep(1) - message = accumulator.accumulate(chunk) + if mobile: + message = accumulator_mobile.accumulate(chunk, mobile) + else: + message = accumulator_global.accumulate(chunk, mobile) + if message == None: # Will be None until we have a full message ready continue @@ -305,8 +297,9 @@ async def listener(): logger.debug("Got chunk:", chunk) # Send it to the user - # await to_device.put(chunk) - # Yield to the event loop, so you actually send it out + await to_device.put(chunk) + + # Yield to the event loop, so you actxually send it out await asyncio.sleep(0.01) if os.getenv("TTS_RUNNER") == "server": @@ -328,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. @@ -376,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", @@ -385,49 +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) - with open(audio_file, "rb") as f: - audio_bytes = f.read() - desktop_path = os.path.join(os.path.expanduser("~"), "Desktop") - desktop_audio_file = os.path.join( - desktop_path, f"{datetime.datetime.now()}" + os.path.basename(audio_file) - ) - shutil.copy(audio_file, desktop_audio_file) - print(f"Audio file saved to Desktop: {desktop_audio_file}") - # storage_client = storage.Client(project="react-native-421323") - # bucket = storage_client.bucket("01-audio") - # blob = bucket.blob(f"{datetime.datetime.now().strftime('%Y%m%d%H%M%S%f')}.wav") - # generation_match_precondition = 0 - - # blob.upload_from_filename( - # audio_file, if_generation_match=generation_match_precondition - # ) - # print( - # f"Audio file {audio_file} uploaded to {datetime.datetime.now().strftime('%Y%m%d%H%M%S%f')}.wav" - # ) - - file_type = "audio/wav" # Read the entire WAV file with open(audio_file, "rb") as f: audio_bytes = f.read() - os.remove(audio_file) + if mobile: + file_type = "audio/wav" + + 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 - # Stream the audio as a single message - yield { - "role": "assistant", - "type": "audio", - "format": file_type, - "content": base64.b64encode(audio_bytes).decode("utf-8"), - "start": True, - "end": True, - } + 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 @@ -464,6 +452,7 @@ async def main( temperature, tts_service, stt_service, + mobile, ): global HOST global PORT @@ -515,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. diff --git a/software/source/server/services/tts/openai/tts.py b/software/source/server/services/tts/openai/tts.py index 021353b..27deaf6 100644 --- a/software/source/server/services/tts/openai/tts.py +++ b/software/source/server/services/tts/openai/tts.py @@ -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() + "/" + "output.wav" - ffmpeg.input(temp_file.name).output( - outfile, f="wav", 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 diff --git a/software/source/utils/accumulator.py b/software/source/utils/accumulator.py index 9f66e89..13ed953 100644 --- a/software/source/utils/accumulator.py +++ b/software/source/utils/accumulator.py @@ -3,7 +3,7 @@ class Accumulator: self.template = {"role": None, "type": None, "format": None, "content": None} self.message = self.template - def accumulate(self, chunk): + def accumulate(self, chunk, mobile): # print(str(chunk)[:100]) if type(chunk) == dict: if "format" in chunk and chunk["format"] == "active_line": @@ -44,6 +44,10 @@ class Accumulator: 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 + + if mobile: + self.message["type"] = "audio" + self.message["format"] = "bytes.wav" + return self.message + else: + return None diff --git a/software/start.py b/software/start.py index 4f3377f..d15e78a 100644 --- a/software/start.py +++ b/software/start.py @@ -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, ), ), ) From a7b160eec179884d98289fd095d3562912485bf8 Mon Sep 17 00:00:00 2001 From: Ben Xu Date: Thu, 2 May 2024 14:14:34 -0400 Subject: [PATCH 29/39] add accumulate mobile --- software/source/server/server.py | 6 ++-- software/source/utils/accumulator.py | 52 ++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/software/source/server/server.py b/software/source/server/server.py index b2f8936..6483160 100644 --- a/software/source/server/server.py +++ b/software/source/server/server.py @@ -227,15 +227,15 @@ async def listener(mobile: bool): await asyncio.sleep(1) if mobile: - message = accumulator_mobile.accumulate(chunk, mobile) + message = accumulator_mobile.accumulate_mobile(chunk) else: - message = accumulator_global.accumulate(chunk, mobile) + message = accumulator_global.accumulate(chunk) if message == None: # Will be None until we have a full message ready continue - # print(str(message)[:1000]) + print(str(message)[:1000]) # At this point, we have our message diff --git a/software/source/utils/accumulator.py b/software/source/utils/accumulator.py index 13ed953..d4715e1 100644 --- a/software/source/utils/accumulator.py +++ b/software/source/utils/accumulator.py @@ -3,7 +3,7 @@ class Accumulator: self.template = {"role": None, "type": None, "format": None, "content": None} self.message = self.template - def accumulate(self, chunk, mobile): + def accumulate(self, chunk): # print(str(chunk)[:100]) if type(chunk) == dict: if "format" in chunk and chunk["format"] == "active_line": @@ -44,10 +44,50 @@ class Accumulator: if "content" not in self.message or type(self.message["content"]) != bytes: self.message["content"] = b"" self.message["content"] += chunk + return None - if mobile: - self.message["type"] = "audio" - self.message["format"] = "bytes.wav" - return self.message - else: + 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 From f732ca185d1136528b22351c1b941bcc082591da Mon Sep 17 00:00:00 2001 From: Ben Xu Date: Thu, 2 May 2024 16:53:34 -0400 Subject: [PATCH 30/39] remove logging --- software/start.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/software/start.py b/software/start.py index d15e78a..d02fcd1 100644 --- a/software/start.py +++ b/software/start.py @@ -141,7 +141,7 @@ def _run( signal.signal(signal.SIGINT, handle_exit) if server: - print(f"Starting server with mobile = {mobile}") + # print(f"Starting server with mobile = {mobile}") loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) server_thread = threading.Thread( From 6b7b6ab65d791d965aa117f3d251ed4666db067c Mon Sep 17 00:00:00 2001 From: Ben Xu Date: Thu, 2 May 2024 16:55:05 -0400 Subject: [PATCH 31/39] remove logging --- .../ios/react-native/src/screens/Main.tsx | 58 +++++-------------- software/source/server/server.py | 12 ++-- software/source/server/utils/bytes_to_wav.py | 2 +- 3 files changed, 22 insertions(+), 50 deletions(-) diff --git a/software/source/clients/ios/react-native/src/screens/Main.tsx b/software/source/clients/ios/react-native/src/screens/Main.tsx index 0bd7c8f..3f37ac2 100644 --- a/software/source/clients/ios/react-native/src/screens/Main.tsx +++ b/software/source/clients/ios/react-native/src/screens/Main.tsx @@ -107,13 +107,13 @@ const Main: React.FC = ({ route }) => { } const playNextAudio = useCallback(async () => { - console.log( - `in playNextAudio audioQueue is ${audioQueue.length} and sound is ${sound}` - ); + // console.log( + // `in playNextAudio audioQueue is ${audioQueue.length} and sound is ${sound}` + //); if (audioQueue.length > 0 && sound == null) { const uri = audioQueue.shift() as string; - console.log("load audio from", uri); + // console.log("load audio from", uri); try { const { sound: newSound } = await Audio.Sound.createAsync({ uri }); @@ -126,7 +126,7 @@ const Main: React.FC = ({ route }) => { playNextAudio(); } } else { - console.log("audioQueue is empty or sound is not null"); + // console.log("audioQueue is empty or sound is not null"); return; } }, [audioQueue, sound, soundUriMap]); @@ -144,26 +144,13 @@ const Main: React.FC = ({ route }) => { [sound, soundUriMap, playNextAudio] ); - const isAVPlaybackStatusSuccess = ( - status: AVPlaybackStatus - ): status is AVPlaybackStatusSuccess => { - return (status as AVPlaybackStatusSuccess).isLoaded !== undefined; - }; - - // useEffect(() => { - // console.log("audioQueue has been updated:", audioQueue.length); - // if (audioQueue.length == 1) { - // playNextAudio(); - // } - // }, [audioQueue]); useEffect(() => { if (audioQueue.length > 0 && !sound) { playNextAudio(); } }, [audioQueue, sound, playNextAudio]); - useEffect(() => { - console.log("sound has been updated:", sound); - }, [sound]); + + useEffect(() => {}, [sound]); useEffect(() => { let websocket: WebSocket; @@ -182,27 +169,22 @@ const Main: React.FC = ({ route }) => { try { const message = JSON.parse(e.data); - if (message.content && message.type === "audio") { + if (message.content && message.type == "audio") { console.log("✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅ Audio message"); const buffer = message.content; - console.log(buffer.length); + // console.log(buffer.length); if (buffer && buffer.length > 0) { const filePath = await constructTempFilePath(buffer); if (filePath !== null) { addToQueue(filePath); - console.log("audio file written to", filePath); + // console.log("audio file written to", filePath); } else { console.error("Failed to create file path"); } } else { console.error("Received message is empty or undefined"); } - } else { - // console.log(typeof message); - // console.log(typeof message.content); - console.log("Received message content is not a string."); - console.log(message); } } catch (error) { console.error("Error handling WebSocket message:", error); @@ -286,19 +268,9 @@ const Main: React.FC = ({ route }) => { allowsRecordingIOS: false, }); const uri = recording.getURI(); - console.log("recording uri at ", uri); + // console.log("recording uri at ", uri); setRecording(null); - // sanity check play the audio recording locally - // recording is working fine; is the server caching the audio file somewhere? - /** - if (uri) { - const { sound } = await Audio.Sound.createAsync({ uri }); - sound.playAsync(); - console.log("playing audio recording from", uri); - } - */ - if (ws && uri) { const response = await fetch(uri); // console.log("fetched audio file", response); @@ -312,10 +284,10 @@ const Main: React.FC = ({ route }) => { ws.send(audioBytes); const audioArray = new Uint8Array(audioBytes as ArrayBuffer); const decoder = new TextDecoder("utf-8"); - console.log( - "sent audio bytes to WebSocket", - decoder.decode(audioArray).slice(0, 50) - ); + // console.log( + // "sent audio bytes to WebSocket", + // decoder.decode(audioArray).slice(0, 50) + // ); } }; } diff --git a/software/source/server/server.py b/software/source/server/server.py index 6483160..b40dc71 100644 --- a/software/source/server/server.py +++ b/software/source/server/server.py @@ -198,10 +198,10 @@ async def send_messages(websocket: WebSocket): try: if isinstance(message, dict): - print(f"Sending to the device: {type(message)} {str(message)[:100]}") + # 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]}") + # 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") @@ -235,7 +235,7 @@ async def listener(mobile: bool): # Will be None until we have a full message ready continue - print(str(message)[:1000]) + # print(str(message)[:1000]) # At this point, we have our message @@ -250,9 +250,9 @@ async def listener(mobile: bool): # 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]) + # 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) + # print("Audio file path:", audio_file_path) # For microphone debugging: if False: @@ -299,7 +299,7 @@ async def listener(mobile: bool): # Send it to the user await to_device.put(chunk) - # Yield to the event loop, so you actxually send it out + # Yield to the event loop, so you actually send it out await asyncio.sleep(0.01) if os.getenv("TTS_RUNNER") == "server": diff --git a/software/source/server/utils/bytes_to_wav.py b/software/source/server/utils/bytes_to_wav.py index a789792..286ae4d 100644 --- a/software/source/server/utils/bytes_to_wav.py +++ b/software/source/server/utils/bytes_to_wav.py @@ -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, From a21bddab772a727048b4ca2deab27ecbabff0fab Mon Sep 17 00:00:00 2001 From: Ben Xu Date: Thu, 2 May 2024 19:24:57 -0400 Subject: [PATCH 32/39] add record button --- .../ios/react-native/src/screens/Main.tsx | 12 +- .../react-native/src/utils/RecordButton.ts | 180 ++++++++++++++++++ 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 software/source/clients/ios/react-native/src/utils/RecordButton.ts diff --git a/software/source/clients/ios/react-native/src/screens/Main.tsx b/software/source/clients/ios/react-native/src/screens/Main.tsx index 3f37ac2..26f0d97 100644 --- a/software/source/clients/ios/react-native/src/screens/Main.tsx +++ b/software/source/clients/ios/react-native/src/screens/Main.tsx @@ -8,7 +8,7 @@ import useStore from "../utils/state"; import { Animated } from "react-native"; import * as Haptics from "expo-haptics"; import useSoundEffect from "../utils/useSoundEffect"; -// import IconImage from "../../assets/qr.png"; +import RecordButton from "../utils/RecordButton"; import { useNavigation } from "@react-navigation/native"; interface MainProps { @@ -331,6 +331,16 @@ const Main: React.FC = ({ route }) => { > {connectionStatus} + { diff --git a/software/source/clients/ios/react-native/src/utils/RecordButton.ts b/software/source/clients/ios/react-native/src/utils/RecordButton.ts new file mode 100644 index 0000000..30f6192 --- /dev/null +++ b/software/source/clients/ios/react-native/src/utils/RecordButton.ts @@ -0,0 +1,180 @@ +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { View, Text, TouchableOpacity, StyleSheet, Image, Touchable } from "react-native"; +import * as FileSystem from "expo-file-system"; +import { AVPlaybackStatus, AVPlaybackStatusSuccess, Audio } from "expo-av"; +import { create } from "zustand"; +import useStore from "../utils/state"; +import { Animated } from "react-native"; +import * as Haptics from "expo-haptics"; +import useSoundEffect from "../utils/useSoundEffect"; + +import { useNavigation } from "@react-navigation/native"; + +interface RecordButtonProps { + playPip: () => void; + playPop: () => void; + recording: Audio.Recording | null; + setRecording: (recording: Audio.Recording | null) => void; + ws: WebSocket | null; + backgroundColorAnim: Animated.Value; + buttonBackgroundColorAnim: Animated.Value; + setIsPressed: (isPressed: boolean) => void; +} + + +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, + }, + + button: { + width: 100, + height: 100, + borderRadius: 50, + justifyContent: "center", + alignItems: "center", + }, + buttonTextDefault: { + color: "black", + fontSize: 16, + }, + buttonTextRecording: { + color: "white", + fontSize: 16, + }, + statusText: { + position: "absolute", + bottom: 20, + alignSelf: "center", + fontSize: 12, + fontWeight: "bold", + }, + }); + + +const RecordButton = ({ playPip, playPop, recording, setRecording, ws, backgroundColorAnim, buttonBackgroundColorAnim, setIsPressed}: RecordButtonProps) => { + const [permissionResponse, requestPermission] = Audio.usePermissions(); + + useEffect(() => { + console.log("Permission Response:", permissionResponse); + if (permissionResponse?.status !== "granted") { + console.log("Requesting permission.."); + requestPermission(); + } + }, []); + + const startRecording = useCallback(async () => { + if (recording) { + console.log("A recording is already in progress."); + return; + } + + try { + console.log("🌶️🌶️🌶️🌶️🌶️🌶️🌶️🌶️🌶️🌶️"); + + console.log(permissionResponse); + + if ( + permissionResponse !== null && + permissionResponse.status !== `granted` + ) { + console.log("Requesting permission.."); + await requestPermission(); + } + + await Audio.setAudioModeAsync({ + allowsRecordingIOS: true, + playsInSilentModeIOS: true, + }); + + console.log("Starting recording.."); + 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 () => { + console.log("Stopping recording.."); + + if (recording) { + await recording.stopAndUnloadAsync(); + await Audio.setAudioModeAsync({ + allowsRecordingIOS: false, + }); + const uri = recording.getURI(); + // console.log("recording uri at ", uri); + setRecording(null); + + if (ws && uri) { + const response = await fetch(uri); + // console.log("fetched audio file", response); + 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, // 'backgroundColor' does not support native driver + }).start(); + Animated.timing(buttonBackgroundColorAnim, { + toValue: shouldPress ? 1 : 0, + duration: 400, + useNativeDriver: false, // 'backgroundColor' does not support native driver + }).start(); + }; + + return ( + ); +}; + +export default RecordButton; From 9505429c5ad9e285b2c2b05c07db46bf38f1d739 Mon Sep 17 00:00:00 2001 From: Ben Xu Date: Thu, 2 May 2024 20:27:47 -0400 Subject: [PATCH 33/39] add readme --- software/source/clients/ios/README.md | 26 +++++++++++++---- .../ios/react-native/src/screens/Main.tsx | 29 ++++++++++++------- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/software/source/clients/ios/README.md b/software/source/clients/ios/README.md index fbb5280..2f65352 100644 --- a/software/source/clients/ios/README.md +++ b/software/source/clients/ios/README.md @@ -1,13 +1,29 @@ # iOS/Android Client -[WORK IN PROGRESS] +***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. +If you want to run it on your own, you will need to install Expo Go on your mobile device. -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 +## 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/ios/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 +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. diff --git a/software/source/clients/ios/react-native/src/screens/Main.tsx b/software/source/clients/ios/react-native/src/screens/Main.tsx index 26f0d97..ecccadd 100644 --- a/software/source/clients/ios/react-native/src/screens/Main.tsx +++ b/software/source/clients/ios/react-native/src/screens/Main.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; -import { View, Text, TouchableOpacity, StyleSheet, Image } from "react-native"; +import { View, Text, TouchableOpacity, StyleSheet, BackHandler, Image } from "react-native"; import * as FileSystem from "expo-file-system"; import { AVPlaybackStatus, AVPlaybackStatusSuccess, Audio } from "expo-av"; import { polyfill as polyfillEncoding } from "react-native-polyfill-globals/src/encoding"; @@ -306,6 +306,23 @@ const Main: React.FC = ({ route }) => { useNativeDriver: false, // 'backgroundColor' does not support native driver }).start(); }; + + 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]); + + return ( {/* = ({ route }) => { > {connectionStatus} - { From 23cc71a3a3c8dffb87701642f3a374aeacc6cc9e Mon Sep 17 00:00:00 2001 From: Ty Fiero Date: Thu, 2 May 2024 17:44:51 -0700 Subject: [PATCH 34/39] The Refactor --- software/source/clients/ios/README.md | 6 +- .../clients/ios/react-native/assets/qr.png | Bin 13745 -> 0 bytes .../ios/react-native/src/screens/Main.tsx | 257 +++++------------- .../react-native/src/utils/RecordButton.ts | 180 ------------ .../react-native/src/utils/RecordButton.tsx | 151 ++++++++++ .../react-native/src/utils/useSoundEffect.ts | 15 +- 6 files changed, 220 insertions(+), 389 deletions(-) delete mode 100644 software/source/clients/ios/react-native/assets/qr.png delete mode 100644 software/source/clients/ios/react-native/src/utils/RecordButton.ts create mode 100644 software/source/clients/ios/react-native/src/utils/RecordButton.tsx diff --git a/software/source/clients/ios/README.md b/software/source/clients/ios/README.md index 2f65352..64ffeaf 100644 --- a/software/source/clients/ios/README.md +++ b/software/source/clients/ios/README.md @@ -1,6 +1,6 @@ # iOS/Android Client -***WORK IN PROGRESS*** +**_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. @@ -9,10 +9,11 @@ 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/ios/react-native # cd into `react-native` +cd software/source/clients/mobile/react-native # cd into `react-native` npm install # install dependencies npx expo start # start local development server ``` @@ -20,6 +21,7 @@ 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 poetry run 01 --mobile # exposes QR code for 01 Light server ``` diff --git a/software/source/clients/ios/react-native/assets/qr.png b/software/source/clients/ios/react-native/assets/qr.png deleted file mode 100644 index 33cf7e07f5b56ae93e8acc2df296da0ae76c5b96..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13745 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 diff --git a/software/source/clients/ios/react-native/src/screens/Main.tsx b/software/source/clients/ios/react-native/src/screens/Main.tsx index ecccadd..f0136dc 100644 --- a/software/source/clients/ios/react-native/src/screens/Main.tsx +++ b/software/source/clients/ios/react-native/src/screens/Main.tsx @@ -1,15 +1,19 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; -import { View, Text, TouchableOpacity, StyleSheet, BackHandler, Image } from "react-native"; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + BackHandler, +} from "react-native"; import * as FileSystem from "expo-file-system"; -import { AVPlaybackStatus, AVPlaybackStatusSuccess, Audio } from "expo-av"; +import { Audio } from "expo-av"; import { polyfill as polyfillEncoding } from "react-native-polyfill-globals/src/encoding"; import { create } from "zustand"; -import useStore from "../utils/state"; import { Animated } from "react-native"; -import * as Haptics from "expo-haptics"; import useSoundEffect from "../utils/useSoundEffect"; import RecordButton from "../utils/RecordButton"; -import { useNavigation } from "@react-navigation/native"; +import { useNavigation } from "@react-navigation/core"; interface MainProps { route: { @@ -45,6 +49,8 @@ const Main: React.FC = ({ route }) => { const [connectionStatus, setConnectionStatus] = useState("Connecting..."); const [ws, setWs] = useState(null); + const [wsUrl, setWsUrl] = useState(""); + const [rescan, setRescan] = useState(false); const [isPressed, setIsPressed] = useState(false); const [recording, setRecording] = useState(null); const addToQueue = useAudioQueueStore((state) => state.addToQueue); @@ -64,13 +70,12 @@ const Main: React.FC = ({ route }) => { const navigation = useNavigation(); const backgroundColor = backgroundColorAnim.interpolate({ inputRange: [0, 1], - outputRange: ["black", "white"], // Change as needed + outputRange: ["black", "white"], }); const buttonBackgroundColor = backgroundColorAnim.interpolate({ inputRange: [0, 1], - outputRange: ["white", "black"], // Inverse of the container + outputRange: ["white", "black"], }); - const constructTempFilePath = async (buffer: string) => { try { await dirExists(); @@ -107,13 +112,8 @@ const Main: React.FC = ({ route }) => { } const playNextAudio = useCallback(async () => { - // console.log( - // `in playNextAudio audioQueue is ${audioQueue.length} and sound is ${sound}` - //); - if (audioQueue.length > 0 && sound == null) { const uri = audioQueue.shift() as string; - // console.log("load audio from", uri); try { const { sound: newSound } = await Audio.Sound.createAsync({ uri }); @@ -126,7 +126,7 @@ const Main: React.FC = ({ route }) => { playNextAudio(); } } else { - // console.log("audioQueue is empty or sound is not null"); + // audioQueue is empty or sound is not null return; } }, [audioQueue, sound, soundUriMap]); @@ -144,6 +144,21 @@ const Main: React.FC = ({ route }) => { [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(); @@ -155,14 +170,13 @@ const Main: React.FC = ({ route }) => { useEffect(() => { let websocket: WebSocket; try { - console.log("Connecting to WebSocket at " + scannedData); + // console.log("Connecting to WebSocket at " + scannedData); + setWsUrl(scannedData); websocket = new WebSocket(scannedData); websocket.binaryType = "blob"; websocket.onopen = () => { setConnectionStatus(`Connected`); - // setConnectionStatus(`Connected to ${scannedData}`); - console.log("WebSocket connected"); }; websocket.onmessage = async (e) => { @@ -170,15 +184,11 @@ const Main: React.FC = ({ route }) => { const message = JSON.parse(e.data); if (message.content && message.type == "audio") { - console.log("✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅ Audio message"); - const buffer = message.content; - // console.log(buffer.length); if (buffer && buffer.length > 0) { const filePath = await constructTempFilePath(buffer); if (filePath !== null) { addToQueue(filePath); - // console.log("audio file written to", filePath); } else { console.error("Failed to create file path"); } @@ -198,7 +208,6 @@ const Main: React.FC = ({ route }) => { websocket.onclose = () => { setConnectionStatus("Disconnected."); - console.log("WebSocket disconnected"); }; setWs(websocket); @@ -212,170 +221,41 @@ const Main: React.FC = ({ route }) => { websocket.close(); } }; - }, [scannedData]); - - useEffect(() => { - console.log("Permission Response:", permissionResponse); - if (permissionResponse?.status !== "granted") { - console.log("Requesting permission.."); - requestPermission(); - } - }, []); - - const startRecording = useCallback(async () => { - if (recording) { - console.log("A recording is already in progress."); - return; - } - - try { - console.log("🌶️🌶️🌶️🌶️🌶️🌶️🌶️🌶️🌶️🌶️"); - - console.log(permissionResponse); - - if ( - permissionResponse !== null && - permissionResponse.status !== `granted` - ) { - console.log("Requesting permission.."); - await requestPermission(); - } - - await Audio.setAudioModeAsync({ - allowsRecordingIOS: true, - playsInSilentModeIOS: true, - }); - - console.log("Starting recording.."); - 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 () => { - console.log("Stopping recording.."); - - if (recording) { - await recording.stopAndUnloadAsync(); - await Audio.setAudioModeAsync({ - allowsRecordingIOS: false, - }); - const uri = recording.getURI(); - // console.log("recording uri at ", uri); - setRecording(null); - - if (ws && uri) { - const response = await fetch(uri); - // console.log("fetched audio file", response); - const blob = await response.blob(); - - const reader = new FileReader(); - reader.readAsArrayBuffer(blob); - reader.onloadend = () => { - const audioBytes = reader.result; - if (audioBytes) { - ws.send(audioBytes); - const audioArray = new Uint8Array(audioBytes as ArrayBuffer); - const decoder = new TextDecoder("utf-8"); - // console.log( - // "sent audio bytes to WebSocket", - // decoder.decode(audioArray).slice(0, 50) - // ); - } - }; - } - } - }, [recording]); - - const toggleRecording = (shouldPress: boolean) => { - Animated.timing(backgroundColorAnim, { - toValue: shouldPress ? 1 : 0, - duration: 400, - useNativeDriver: false, // 'backgroundColor' does not support native driver - }).start(); - Animated.timing(buttonBackgroundColorAnim, { - toValue: shouldPress ? 1 : 0, - duration: 400, - useNativeDriver: false, // 'backgroundColor' does not support native driver - }).start(); - }; - - 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]); - + }, [scannedData, rescan]); return ( - {/* { - console.log("hi!"); - - navigation.navigate("Camera"); - }} - > - - - - */} - {/* */} - - {connectionStatus} - + { - playPip(); - setIsPressed(true); - toggleRecording(true); // Pass true when pressed - startRecording(); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); - }} - onPressOut={() => { - playPop(); - setIsPressed(false); - toggleRecording(false); // Pass false when released - stopRecording(); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + style={styles.statusButton} + onPress={() => { + setRescan(!rescan); }} > - - {/* - Record - */} - + {connectionStatus} + @@ -418,27 +298,14 @@ const styles = StyleSheet.create({ paddingTop: 50, }, - button: { - width: 100, - height: 100, - borderRadius: 50, - justifyContent: "center", - alignItems: "center", - }, - buttonTextDefault: { - color: "black", - fontSize: 16, - }, - buttonTextRecording: { - color: "white", - fontSize: 16, - }, statusText: { + fontSize: 12, + fontWeight: "bold", + }, + statusButton: { position: "absolute", bottom: 20, alignSelf: "center", - fontSize: 12, - fontWeight: "bold", }, }); diff --git a/software/source/clients/ios/react-native/src/utils/RecordButton.ts b/software/source/clients/ios/react-native/src/utils/RecordButton.ts deleted file mode 100644 index 30f6192..0000000 --- a/software/source/clients/ios/react-native/src/utils/RecordButton.ts +++ /dev/null @@ -1,180 +0,0 @@ -import React, { useState, useEffect, useCallback, useRef } from "react"; -import { View, Text, TouchableOpacity, StyleSheet, Image, Touchable } from "react-native"; -import * as FileSystem from "expo-file-system"; -import { AVPlaybackStatus, AVPlaybackStatusSuccess, Audio } from "expo-av"; -import { create } from "zustand"; -import useStore from "../utils/state"; -import { Animated } from "react-native"; -import * as Haptics from "expo-haptics"; -import useSoundEffect from "../utils/useSoundEffect"; - -import { useNavigation } from "@react-navigation/native"; - -interface RecordButtonProps { - playPip: () => void; - playPop: () => void; - recording: Audio.Recording | null; - setRecording: (recording: Audio.Recording | null) => void; - ws: WebSocket | null; - backgroundColorAnim: Animated.Value; - buttonBackgroundColorAnim: Animated.Value; - setIsPressed: (isPressed: boolean) => void; -} - - -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, - }, - - button: { - width: 100, - height: 100, - borderRadius: 50, - justifyContent: "center", - alignItems: "center", - }, - buttonTextDefault: { - color: "black", - fontSize: 16, - }, - buttonTextRecording: { - color: "white", - fontSize: 16, - }, - statusText: { - position: "absolute", - bottom: 20, - alignSelf: "center", - fontSize: 12, - fontWeight: "bold", - }, - }); - - -const RecordButton = ({ playPip, playPop, recording, setRecording, ws, backgroundColorAnim, buttonBackgroundColorAnim, setIsPressed}: RecordButtonProps) => { - const [permissionResponse, requestPermission] = Audio.usePermissions(); - - useEffect(() => { - console.log("Permission Response:", permissionResponse); - if (permissionResponse?.status !== "granted") { - console.log("Requesting permission.."); - requestPermission(); - } - }, []); - - const startRecording = useCallback(async () => { - if (recording) { - console.log("A recording is already in progress."); - return; - } - - try { - console.log("🌶️🌶️🌶️🌶️🌶️🌶️🌶️🌶️🌶️🌶️"); - - console.log(permissionResponse); - - if ( - permissionResponse !== null && - permissionResponse.status !== `granted` - ) { - console.log("Requesting permission.."); - await requestPermission(); - } - - await Audio.setAudioModeAsync({ - allowsRecordingIOS: true, - playsInSilentModeIOS: true, - }); - - console.log("Starting recording.."); - 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 () => { - console.log("Stopping recording.."); - - if (recording) { - await recording.stopAndUnloadAsync(); - await Audio.setAudioModeAsync({ - allowsRecordingIOS: false, - }); - const uri = recording.getURI(); - // console.log("recording uri at ", uri); - setRecording(null); - - if (ws && uri) { - const response = await fetch(uri); - // console.log("fetched audio file", response); - 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, // 'backgroundColor' does not support native driver - }).start(); - Animated.timing(buttonBackgroundColorAnim, { - toValue: shouldPress ? 1 : 0, - duration: 400, - useNativeDriver: false, // 'backgroundColor' does not support native driver - }).start(); - }; - - return ( - ); -}; - -export default RecordButton; diff --git a/software/source/clients/ios/react-native/src/utils/RecordButton.tsx b/software/source/clients/ios/react-native/src/utils/RecordButton.tsx new file mode 100644 index 0000000..ffdaeb0 --- /dev/null +++ b/software/source/clients/ios/react-native/src/utils/RecordButton.tsx @@ -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; + buttonBackgroundColor: Animated.AnimatedInterpolation; + 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 = ({ + 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 ( + { + playPip(); + setIsPressed(true); + toggleRecording(true); + startRecording(); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + }} + onPressOut={() => { + playPop(); + setIsPressed(false); + toggleRecording(false); + stopRecording(); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + }} + > + + + ); +}; + +export default RecordButton; diff --git a/software/source/clients/ios/react-native/src/utils/useSoundEffect.ts b/software/source/clients/ios/react-native/src/utils/useSoundEffect.ts index 250353c..5e73fec 100644 --- a/software/source/clients/ios/react-native/src/utils/useSoundEffect.ts +++ b/software/source/clients/ios/react-native/src/utils/useSoundEffect.ts @@ -1,20 +1,11 @@ import { useEffect, useState } from "react"; -import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from "expo-av"; +import { Audio } from "expo-av"; -const useSoundEffect = (soundFile) => { - const [sound, setSound] = useState(null); // Explicitly set initial state to null +const useSoundEffect = (soundFile: any) => { + const [sound, setSound] = useState(null); // Explicitly set initial state to null useEffect(() => { const loadSound = async () => { - // await Audio.setAudioModeAsync({ - // staysActiveInBackground: true, - // shouldDuckAndroid: true, - // playThroughEarpieceAndroid: false, - // interruptionModeIOS: InterruptionModeIOS.DoNotMix, - // interruptionModeAndroid: InterruptionModeAndroid.DoNotMix, - // allowsRecordingIOS: false, - // playsInSilentModeIOS: true, - // }); const { sound: newSound } = await Audio.Sound.createAsync(soundFile); setSound(newSound); }; From 05fddc29d9129b5c4dfc5b8b67761a6efe09e9f7 Mon Sep 17 00:00:00 2001 From: Ty Fiero Date: Thu, 2 May 2024 17:45:12 -0700 Subject: [PATCH 35/39] Rename directory --- software/source/clients/{ios => mobile}/README.md | 0 .../clients/{ios => mobile}/react-native/App.tsx | 0 .../clients/{ios => mobile}/react-native/app.json | 0 .../react-native/assets/adaptive-icon.png | Bin .../{ios => mobile}/react-native/assets/favicon.png | Bin .../{ios => mobile}/react-native/assets/icon.png | Bin .../{ios => mobile}/react-native/assets/pip.mp3 | Bin .../{ios => mobile}/react-native/assets/pop.mp3 | Bin .../{ios => mobile}/react-native/assets/splash.png | Bin .../{ios => mobile}/react-native/assets/yay.wav | Bin .../{ios => mobile}/react-native/babel.config.js | 0 .../{ios => mobile}/react-native/package-lock.json | 0 .../{ios => mobile}/react-native/package.json | 0 .../react-native/src/screens/Camera.tsx | 0 .../react-native/src/screens/HomeScreen.tsx | 0 .../react-native/src/screens/Main.tsx | 0 .../react-native/src/utils/RecordButton.tsx | 0 .../{ios => mobile}/react-native/src/utils/state.ts | 0 .../react-native/src/utils/useSoundEffect.ts | 0 .../{ios => mobile}/react-native/tsconfig.json | 0 20 files changed, 0 insertions(+), 0 deletions(-) rename software/source/clients/{ios => mobile}/README.md (100%) rename software/source/clients/{ios => mobile}/react-native/App.tsx (100%) rename software/source/clients/{ios => mobile}/react-native/app.json (100%) rename software/source/clients/{ios => mobile}/react-native/assets/adaptive-icon.png (100%) rename software/source/clients/{ios => mobile}/react-native/assets/favicon.png (100%) rename software/source/clients/{ios => mobile}/react-native/assets/icon.png (100%) rename software/source/clients/{ios => mobile}/react-native/assets/pip.mp3 (100%) rename software/source/clients/{ios => mobile}/react-native/assets/pop.mp3 (100%) rename software/source/clients/{ios => mobile}/react-native/assets/splash.png (100%) rename software/source/clients/{ios => mobile}/react-native/assets/yay.wav (100%) rename software/source/clients/{ios => mobile}/react-native/babel.config.js (100%) rename software/source/clients/{ios => mobile}/react-native/package-lock.json (100%) rename software/source/clients/{ios => mobile}/react-native/package.json (100%) rename software/source/clients/{ios => mobile}/react-native/src/screens/Camera.tsx (100%) rename software/source/clients/{ios => mobile}/react-native/src/screens/HomeScreen.tsx (100%) rename software/source/clients/{ios => mobile}/react-native/src/screens/Main.tsx (100%) rename software/source/clients/{ios => mobile}/react-native/src/utils/RecordButton.tsx (100%) rename software/source/clients/{ios => mobile}/react-native/src/utils/state.ts (100%) rename software/source/clients/{ios => mobile}/react-native/src/utils/useSoundEffect.ts (100%) rename software/source/clients/{ios => mobile}/react-native/tsconfig.json (100%) diff --git a/software/source/clients/ios/README.md b/software/source/clients/mobile/README.md similarity index 100% rename from software/source/clients/ios/README.md rename to software/source/clients/mobile/README.md diff --git a/software/source/clients/ios/react-native/App.tsx b/software/source/clients/mobile/react-native/App.tsx similarity index 100% rename from software/source/clients/ios/react-native/App.tsx rename to software/source/clients/mobile/react-native/App.tsx diff --git a/software/source/clients/ios/react-native/app.json b/software/source/clients/mobile/react-native/app.json similarity index 100% rename from software/source/clients/ios/react-native/app.json rename to software/source/clients/mobile/react-native/app.json diff --git a/software/source/clients/ios/react-native/assets/adaptive-icon.png b/software/source/clients/mobile/react-native/assets/adaptive-icon.png similarity index 100% rename from software/source/clients/ios/react-native/assets/adaptive-icon.png rename to software/source/clients/mobile/react-native/assets/adaptive-icon.png diff --git a/software/source/clients/ios/react-native/assets/favicon.png b/software/source/clients/mobile/react-native/assets/favicon.png similarity index 100% rename from software/source/clients/ios/react-native/assets/favicon.png rename to software/source/clients/mobile/react-native/assets/favicon.png diff --git a/software/source/clients/ios/react-native/assets/icon.png b/software/source/clients/mobile/react-native/assets/icon.png similarity index 100% rename from software/source/clients/ios/react-native/assets/icon.png rename to software/source/clients/mobile/react-native/assets/icon.png diff --git a/software/source/clients/ios/react-native/assets/pip.mp3 b/software/source/clients/mobile/react-native/assets/pip.mp3 similarity index 100% rename from software/source/clients/ios/react-native/assets/pip.mp3 rename to software/source/clients/mobile/react-native/assets/pip.mp3 diff --git a/software/source/clients/ios/react-native/assets/pop.mp3 b/software/source/clients/mobile/react-native/assets/pop.mp3 similarity index 100% rename from software/source/clients/ios/react-native/assets/pop.mp3 rename to software/source/clients/mobile/react-native/assets/pop.mp3 diff --git a/software/source/clients/ios/react-native/assets/splash.png b/software/source/clients/mobile/react-native/assets/splash.png similarity index 100% rename from software/source/clients/ios/react-native/assets/splash.png rename to software/source/clients/mobile/react-native/assets/splash.png diff --git a/software/source/clients/ios/react-native/assets/yay.wav b/software/source/clients/mobile/react-native/assets/yay.wav similarity index 100% rename from software/source/clients/ios/react-native/assets/yay.wav rename to software/source/clients/mobile/react-native/assets/yay.wav diff --git a/software/source/clients/ios/react-native/babel.config.js b/software/source/clients/mobile/react-native/babel.config.js similarity index 100% rename from software/source/clients/ios/react-native/babel.config.js rename to software/source/clients/mobile/react-native/babel.config.js diff --git a/software/source/clients/ios/react-native/package-lock.json b/software/source/clients/mobile/react-native/package-lock.json similarity index 100% rename from software/source/clients/ios/react-native/package-lock.json rename to software/source/clients/mobile/react-native/package-lock.json diff --git a/software/source/clients/ios/react-native/package.json b/software/source/clients/mobile/react-native/package.json similarity index 100% rename from software/source/clients/ios/react-native/package.json rename to software/source/clients/mobile/react-native/package.json diff --git a/software/source/clients/ios/react-native/src/screens/Camera.tsx b/software/source/clients/mobile/react-native/src/screens/Camera.tsx similarity index 100% rename from software/source/clients/ios/react-native/src/screens/Camera.tsx rename to software/source/clients/mobile/react-native/src/screens/Camera.tsx diff --git a/software/source/clients/ios/react-native/src/screens/HomeScreen.tsx b/software/source/clients/mobile/react-native/src/screens/HomeScreen.tsx similarity index 100% rename from software/source/clients/ios/react-native/src/screens/HomeScreen.tsx rename to software/source/clients/mobile/react-native/src/screens/HomeScreen.tsx diff --git a/software/source/clients/ios/react-native/src/screens/Main.tsx b/software/source/clients/mobile/react-native/src/screens/Main.tsx similarity index 100% rename from software/source/clients/ios/react-native/src/screens/Main.tsx rename to software/source/clients/mobile/react-native/src/screens/Main.tsx diff --git a/software/source/clients/ios/react-native/src/utils/RecordButton.tsx b/software/source/clients/mobile/react-native/src/utils/RecordButton.tsx similarity index 100% rename from software/source/clients/ios/react-native/src/utils/RecordButton.tsx rename to software/source/clients/mobile/react-native/src/utils/RecordButton.tsx diff --git a/software/source/clients/ios/react-native/src/utils/state.ts b/software/source/clients/mobile/react-native/src/utils/state.ts similarity index 100% rename from software/source/clients/ios/react-native/src/utils/state.ts rename to software/source/clients/mobile/react-native/src/utils/state.ts diff --git a/software/source/clients/ios/react-native/src/utils/useSoundEffect.ts b/software/source/clients/mobile/react-native/src/utils/useSoundEffect.ts similarity index 100% rename from software/source/clients/ios/react-native/src/utils/useSoundEffect.ts rename to software/source/clients/mobile/react-native/src/utils/useSoundEffect.ts diff --git a/software/source/clients/ios/react-native/tsconfig.json b/software/source/clients/mobile/react-native/tsconfig.json similarity index 100% rename from software/source/clients/ios/react-native/tsconfig.json rename to software/source/clients/mobile/react-native/tsconfig.json From 562cfb7206230bd2dbd93026dcdba114a5311110 Mon Sep 17 00:00:00 2001 From: Ben Xu Date: Thu, 2 May 2024 20:53:02 -0400 Subject: [PATCH 36/39] add cd in readme --- software/source/clients/mobile/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/software/source/clients/mobile/README.md b/software/source/clients/mobile/README.md index 64ffeaf..cf5f856 100644 --- a/software/source/clients/mobile/README.md +++ b/software/source/clients/mobile/README.md @@ -23,6 +23,7 @@ In **Expo Go** select _Scan QR code_ to scan the QR code produced by the `npx ex ## Using the App ```shell +cd software # cd into `software` poetry run 01 --mobile # exposes QR code for 01 Light server ``` From 4ecb84d6a2940449f77081f0efb64fa112f249b3 Mon Sep 17 00:00:00 2001 From: Ty Fiero Date: Fri, 3 May 2024 08:12:29 -0700 Subject: [PATCH 37/39] Fix for local mode --- software/source/server/services/tts/piper/tts.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/software/source/server/services/tts/piper/tts.py b/software/source/server/services/tts/piper/tts.py index 8daa158..bd40441 100644 --- a/software/source/server/services/tts/piper/tts.py +++ b/software/source/server/services/tts/piper/tts.py @@ -12,7 +12,7 @@ class Tts: self.piper_directory = "" self.install(config["service_directory"]) - def tts(self, text): + def tts(self, text, mobile): with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_file: output_file = temp_file.name piper_dir = self.piper_directory @@ -34,10 +34,16 @@ class Tts: ) # 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 From 6d8781ab94769cb8af58f882bd9ce78f128ffe92 Mon Sep 17 00:00:00 2001 From: Ben Xu Date: Fri, 3 May 2024 15:14:57 -0400 Subject: [PATCH 38/39] refactor with useRef --- .../mobile/react-native/src/screens/Main.tsx | 67 ++++++------------- 1 file changed, 20 insertions(+), 47 deletions(-) diff --git a/software/source/clients/mobile/react-native/src/screens/Main.tsx b/software/source/clients/mobile/react-native/src/screens/Main.tsx index f0136dc..5574eb3 100644 --- a/software/source/clients/mobile/react-native/src/screens/Main.tsx +++ b/software/source/clients/mobile/react-native/src/screens/Main.tsx @@ -9,7 +9,6 @@ import { 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"; @@ -23,27 +22,6 @@ interface MainProps { }; } -interface AudioQueueState { - audioQueue: string[]; // Define the audio queue type - addToQueue: (uri: string) => void; // Function to set audio queue -} - -const useAudioQueueStore = create((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((set) => ({ - sound: null, // initial state - setSound: (newSound) => set({ sound: newSound }), // action to set sound -})); - const Main: React.FC = ({ route }) => { const { scannedData } = route.params; const [connectionStatus, setConnectionStatus] = @@ -53,10 +31,8 @@ const Main: React.FC = ({ route }) => { const [rescan, setRescan] = useState(false); const [isPressed, setIsPressed] = useState(false); const [recording, setRecording] = useState(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 audioQueueRef = useRef([]); + const soundRef = useRef(null); const [soundUriMap, setSoundUriMap] = useState>( new Map() ); @@ -76,6 +52,7 @@ const Main: React.FC = ({ route }) => { inputRange: [0, 1], outputRange: ["white", "black"], }); + const constructTempFilePath = async (buffer: string) => { try { await dirExists(); @@ -112,37 +89,37 @@ const Main: React.FC = ({ route }) => { } const playNextAudio = useCallback(async () => { - if (audioQueue.length > 0 && sound == null) { - const uri = audioQueue.shift() as string; + if (audioQueueRef.current.length > 0 && soundRef.current == null) { + const uri = audioQueueRef.current.at(0) as string; try { const { sound: newSound } = await Audio.Sound.createAsync({ uri }); - setSound(newSound); + soundRef.current = 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); + audioQueueRef.current.shift(); + await soundRef.current?.unloadAsync(); + if (soundRef.current) { + soundUriMap.delete(soundRef.current); + setSoundUriMap(new Map(soundUriMap)); + } + soundRef.current = null; playNextAudio(); } - }, - [sound, soundUriMap, playNextAudio] - ); + },[]); useEffect(() => { const backAction = () => { @@ -159,14 +136,6 @@ const Main: React.FC = ({ route }) => { return () => backHandler.remove(); }, [navigation]); - useEffect(() => { - if (audioQueue.length > 0 && !sound) { - playNextAudio(); - } - }, [audioQueue, sound, playNextAudio]); - - useEffect(() => {}, [sound]); - useEffect(() => { let websocket: WebSocket; try { @@ -188,7 +157,11 @@ const Main: React.FC = ({ route }) => { if (buffer && buffer.length > 0) { const filePath = await constructTempFilePath(buffer); if (filePath !== null) { - addToQueue(filePath); + audioQueueRef.current.push(filePath); + + if (audioQueueRef.current.length == 1) { + playNextAudio(); + } } else { console.error("Failed to create file path"); } From 01206a0a96b376ffa5c88b628528d4041be0ae39 Mon Sep 17 00:00:00 2001 From: killian <63927363+KillianLucas@users.noreply.github.com> Date: Sat, 4 May 2024 13:32:09 -0700 Subject: [PATCH 39/39] Make PR just be the link to the JP docs --- docs/README_JP.md | 188 ---------------------------------------------- 1 file changed, 188 deletions(-) delete mode 100644 docs/README_JP.md diff --git a/docs/README_JP.md b/docs/README_JP.md deleted file mode 100644 index ceda391..0000000 --- a/docs/README_JP.md +++ /dev/null @@ -1,188 +0,0 @@ -[The 01 Project](https://twitter.com/hellokillian/status/1745875973583896950)の公式プレリリースリポジトリ。 - -> 発売まで残り**3**日 - -

- -
- -
- - | [日本語](README_JP.md) | [English](../README.md) | - -
- -
- -![ポスター](https://pbs.twimg.com/media/GDqTVYzbgAIfLJf?format=png&name=4096x4096) - -
- - - -```shell -git clone https://github.com/OpenInterpreter/01 -cd 01/01OS -``` - - - -```shell -poetry install -poetry run 01 -``` - -
- -**The 01 Project**は、AIデバイス向けのエコシステムを構築しています。 - -私たちの主力オペレーティングシステムは、Rabbit R1、Humane Pin、[Star Trekコンピューター](https://www.youtube.com/watch?v=1ZXugicgn6U)のような会話型デバイスを動作させることができます。 - -私たちは、オープンソース、モジュール性、無料であり続けることを約束することで、この分野のGNU/Linuxになることを目指しています。 - -## 統一API - -統一APIは、01で使用される主要サービスの標準的なPythonインターフェースです。 - -- `/stt` 音声認識用 -- `/llm` 言語モデル用 -- `/tts` 音声合成用 - -## ボディ - -01OSは、さまざまなボディに収容できます。このリストに追加するPRを大歓迎します。 - -**01 Light**は、ESP32ベースの音声インターフェースで、インターネット経由でホームコンピューターを制御します。**01 Server**と組み合わせて使用します。 - -**01 Heavy**は、すべてをローカルで実行するデバイスです。 - -## セットアップ - -### 依存関係のインストール - -```bash -# MacOS -brew install portaudio ffmpeg cmake - -# Ubuntu -sudo apt-get install portaudio19-dev ffmpeg cmake -``` - -Whisperを使用してローカルで音声認識を行う場合は、Rustをインストールしてください。[ここ](https://www.rust-lang.org/tools/install)に記載されている手順に従ってください。 - -### 01 CLIのインストールと実行 - -```shell -pip install 01OS -``` - -```shell -01 --server # ハードウェアデバイスがリスンするサーバーを起動します。 -``` - -# クライアントのセットアップ - -### ESP32ボード用 - -[ESP32セットアップドキュメント](https://github.com/OpenInterpreter/01/tree/main/01OS/01OS/clients/esp32)をご覧ください。 - -### Mac、Windows、Ubuntuマシン用 - -``` -01 # サーバーとクライアントを起動します。 - -01 --server --expose # サーバーを起動し、Ngrok経由で公開します。クライアントが接続するための`server_url`が表示されます。 - -01 --client --server_url your-server.com # クライアントのみを起動します。 -``` - -### サービスプロバイダーの切り替え - -01は、音声認識、音声合成、言語モデルのプロバイダーに依存しません。 - -以下のコマンドを実行して、プロバイダーを選択します。 - -```shell -01 --tts-service openai -01 --llm-service openai -01 --stt-service openai -``` - -[すべてのプロバイダーを見る ↗](https://docs.litellm.ai/docs/providers/)、または[サービスプロバイダーを追加して01チームに参加する。↗]() - -### 01をローカルで実行する - -一部のサービスプロバイダーはインターネット接続を必要としません。 - -次のコマンドを実行すると、ハードウェアに最適なプロバイダーをダウンロードして使用しようとします。 - -```shell -01 --local -``` - -## 仕組み - -01は、言語モデル(音声インターフェースでラップされている)に`exec()`関数を装備し、コードを書いて実行してコンピューターを制御できるようにします。 - -音声はエンドユーザーのデバイスとの間でのみストリーミングされます。 - -# 貢献 - -詳細については、[コントリビューションガイドライン](docs/CONTRIBUTING.md)をご覧ください。 - -### 開発のためのセットアップ - -```bash -# リポジトリをクローン -git clone https://github.com/KillianLucas/01.git - -# 01OSディレクトリに移動 -cd 01OS - -# Pythonの依存関係をインストール -poetry install - -# 実行 -poetry run 01 -``` - -
- -# ロードマップ - -01の未来を見るには、[私たちのロードマップ](https://github.com/KillianLucas/open-interpreter/blob/main/docs/ROADMAP.md)をご覧ください。 - -
- -## 背景 - -### [コンテキスト ↗](https://github.com/KillianLucas/01/blob/main/CONTEXT.md) - -01以前のデバイスの物語。 - -### [インスピレーション ↗](https://github.com/KillianLucas/01/tree/main/INSPIRATION.md) - -素晴らしいアイデアを盗みたいもの。 - -
- -## 方向性 - -### [目標 ↗](https://github.com/KillianLucas/01/blob/main/GOALS.md) - -私たちがやろうとしていること。 - -### [ユースケース ↗](https://github.com/KillianLucas/01/blob/main/USE_CASES.md) - -01ができるようになること。 - -
\ No newline at end of file

- Discord Partner -
-
- オープンソースの言語モデルコンピューター
- -