From fc97ef6a24ebc6971d2167916b371e88a4a400f5 Mon Sep 17 00:00:00 2001 From: killian <63927363+KillianLucas@users.noreply.github.com> Date: Mon, 22 Jul 2024 12:12:50 -0700 Subject: [PATCH] Added tests --- .github/workflows/test.yml | 40 +++++++------- software/source/clients/base_device.py | 7 ++- software/source/clients/mac/beeps.py | 42 +++++++++++++++ software/source/server/async_server.py | 33 ++++++------ software/source/server/skills/schedule.py | 64 ----------------------- software/source/server/tests/test_run.py | 28 ++++++++-- software/start.py | 20 ++++--- 7 files changed, 116 insertions(+), 118 deletions(-) create mode 100644 software/source/clients/mac/beeps.py delete mode 100644 software/source/server/skills/schedule.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5443bdf..0052676 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,8 +17,8 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest] - # os: [ubuntu-latest, windows-latest, macos-latest] + # os: [macos-latest] + os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.11"] defaults: @@ -31,32 +31,30 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Install poetry - run: pipx install poetry - - name: Setup Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - cache: "poetry" - - # Install Portaudio on Ubuntu - - name: Installing Portaudio in Ubuntu - if: matrix.os == 'ubuntu-latest' - run: sudo apt-get install portaudio19-dev python-all-dev - # Install Portaudio on macOS using Homebrew - - name: Installing Portaudio in Mac - if: matrix.os == 'macos-latest' - run: brew install portaudio + - name: Install poetry + run: | + curl -sSL https://install.python-poetry.org | python3 - - # Install Poetry and project dependencies - - name: Install Poetry Package + - name: Install dependencies run: | - pip install --upgrade pip - pip install poetry==1.3.2 - poetry config virtualenvs.create false - poetry install --no-interaction --with dev + # Ensure dependencies are installed without relying on a lock file. + poetry update + poetry install + + # # Install Portaudio on Ubuntu + # - name: Installing Portaudio in Ubuntu + # if: matrix.os == 'ubuntu-latest' + # run: sudo apt-get install portaudio19-dev python-all-dev + + # # Install Portaudio on macOS using Homebrew + # - name: Installing Portaudio in Mac + # if: matrix.os == 'macos-latest' + # run: brew install portaudio # Run pytest - name: Run Pytest diff --git a/software/source/clients/base_device.py b/software/source/clients/base_device.py index 68ea1cc..4c53f46 100644 --- a/software/source/clients/base_device.py +++ b/software/source/clients/base_device.py @@ -59,11 +59,10 @@ class Device: while True: try: data = await self.websocket.recv() - if isinstance(data, bytes) and not self.recording: - if self.play_audio: - self.output_stream.write(data) + if self.play_audio and isinstance(data, bytes) and not self.recording: + self.output_stream.write(data) except Exception as e: - print(f"Error in receive_audio: {e}") + await self.connect_with_retry() def on_press(self, key): if key == keyboard.Key.space and not self.recording: diff --git a/software/source/clients/mac/beeps.py b/software/source/clients/mac/beeps.py new file mode 100644 index 0000000..2897a86 --- /dev/null +++ b/software/source/clients/mac/beeps.py @@ -0,0 +1,42 @@ +""" +Mac only. +""" + +import subprocess +import threading +import time + +def beep(sound): + if "." not in sound: + sound = sound + ".aiff" + try: + subprocess.Popen(["afplay", f"/System/Library/Sounds/{sound}"]) + except: + pass # No big deal + +class RepeatedBeep: + def __init__(self): + self.sound = "Pop" + self.running = False + self.thread = threading.Thread(target=self._play_sound, daemon=True) + self.thread.start() + + def _play_sound(self): + while True: + if self.running: + try: + subprocess.call(["afplay", f"/System/Library/Sounds/{self.sound}.aiff"]) + except: + pass # No big deal + time.sleep(0.6) + time.sleep(0.05) + + def start(self): + if not self.running: + time.sleep(0.6*4) + self.running = True + + def stop(self): + self.running = False + +beeper = RepeatedBeep() \ No newline at end of file diff --git a/software/source/server/async_server.py b/software/source/server/async_server.py index 79581d6..b4b8b43 100644 --- a/software/source/server/async_server.py +++ b/software/source/server/async_server.py @@ -1,14 +1,11 @@ -import importlib -import traceback -import json -import os from RealtimeTTS import TextToAudioStream, CoquiEngine, OpenAIEngine, ElevenlabsEngine +from fastapi.responses import PlainTextResponse from RealtimeSTT import AudioToTextRecorder +import importlib +import asyncio import types -import time import wave -import asyncio -from fastapi.responses import PlainTextResponse +import os def start_server(server_host, server_port, profile, debug, play_audio): @@ -26,7 +23,6 @@ def start_server(server_host, server_port, profile, debug, play_audio): ) interpreter.stt.stop() # It needs this for some reason - # TTS if not hasattr(interpreter, 'tts'): print("Setting TTS provider to default: openai") @@ -46,16 +42,13 @@ def start_server(server_host, server_port, profile, debug, play_audio): interpreter.verbose = debug interpreter.server.host = server_host interpreter.server.port = server_port - interpreter.play_audio = play_audio - - interpreter.audio_chunks = [] - old_input = interpreter.input - old_output = interpreter.output + ### Swap out the input function for one that supports voice + old_input = interpreter.input async def new_input(self, chunk): await asyncio.sleep(0) @@ -86,6 +79,10 @@ def start_server(server_host, server_port, profile, debug, play_audio): await old_input({"role": "user", "type": "message", "end": True}) + ### Swap out the output function for one that supports voice + + old_output = interpreter.output + async def new_output(self): while True: output = await old_output() @@ -100,25 +97,29 @@ def start_server(server_host, server_port, profile, debug, play_audio): delimiters = ".?!;,\n…)]}" if output["type"] == "message" and len(output.get("content", "")) > 0: + self.tts.feed(output.get("content")) + if not self.tts.is_playing() and any([c in delimiters for c in output.get("content")]): # Start playing once the first delimiter is encountered. - self.tts.play_async(on_audio_chunk=self.on_tts_chunk, muted=not self.play_audio, sentence_fragment_delimiters=delimiters) + self.tts.play_async(on_audio_chunk=self.on_tts_chunk, muted=not self.play_audio, sentence_fragment_delimiters=delimiters, minimum_sentence_length=9) return {"role": "assistant", "type": "audio", "format": "bytes.wav", "start": True} if output == {"role": "assistant", "type": "message", "end": True}: if not self.tts.is_playing(): # We put this here in case it never outputs a delimiter and never triggers play_async^ - self.tts.play_async(on_audio_chunk=self.on_tts_chunk, muted=not self.play_audio, sentence_fragment_delimiters=delimiters) + self.tts.play_async(on_audio_chunk=self.on_tts_chunk, muted=not self.play_audio, sentence_fragment_delimiters=delimiters, minimum_sentence_length=9) return {"role": "assistant", "type": "audio", "format": "bytes.wav", "start": True} return {"role": "assistant", "type": "audio", "format": "bytes.wav", "end": True} def on_tts_chunk(self, chunk): self.output_queue.sync_q.put(chunk) - # Wrap in voice interface + + # Set methods on interpreter object interpreter.input = types.MethodType(new_input, interpreter) interpreter.output = types.MethodType(new_output, interpreter) interpreter.on_tts_chunk = types.MethodType(on_tts_chunk, interpreter) + # Add ping route, required by device @interpreter.server.app.get("/ping") async def ping(): return PlainTextResponse("pong") diff --git a/software/source/server/skills/schedule.py b/software/source/server/skills/schedule.py deleted file mode 100644 index ef65d72..0000000 --- a/software/source/server/skills/schedule.py +++ /dev/null @@ -1,64 +0,0 @@ -import os -from datetime import datetime -from pytimeparse import parse -from crontab import CronTab -from uuid import uuid4 -from platformdirs import user_data_dir - - -def schedule(message="", start=None, interval=None) -> None: - """ - Schedules a task at a particular time, or at a particular interval - """ - if start and interval: - raise ValueError("Cannot specify both start time and interval.") - - if not start and not interval: - raise ValueError("Either start time or interval must be specified.") - - # Read the temp file to see what the current session is - session_file_path = os.path.join(user_data_dir("01"), "01-session.txt") - - with open(session_file_path, "r") as session_file: - file_session_value = session_file.read().strip() - - prefixed_message = "AUTOMATED MESSAGE FROM SCHEDULER: " + message - - # Escape the message and the json, cron is funky with quotes - escaped_question = prefixed_message.replace('"', '\\"') - json_data = f'{{\\"text\\": \\"{escaped_question}\\"}}' - - command = f"""bash -c 'if [ "$(cat "{session_file_path}")" == "{file_session_value}" ]; then /usr/bin/curl -X POST -H "Content-Type: application/json" -d "{json_data}" http://localhost:10001/; fi' """ - - cron = CronTab(user=True) - job = cron.new(command=command) - # Prefix with 01 dev preview so we can delete them all in the future - job_id = "01-dev-preview-" + str(uuid4()) - job.set_comment(job_id) - if start: - try: - start_time = datetime.strptime(start, "%Y-%m-%d %H:%M:%S") - except ValueError: - raise ValueError(f"Invalid datetime format: {start}.") - job.setall(start_time) - print(f"Task scheduled for {start_time.strftime('%Y-%m-%d %H:%M:%S')}") - - elif interval: - seconds = parse(interval) - if seconds <= 60: - job.minute.every(1) - print("Task scheduled every minute") - elif seconds < 3600: - minutes = max(int(seconds / 60), 1) - job.minute.every(minutes) - print(f"Task scheduled every {minutes} minutes") - elif seconds < 86400: - hours = max(int(seconds / 3600), 1) - job.hour.every(hours) - print(f"Task scheduled every {hours} hour(s)") - else: - days = max(int(seconds / 86400), 1) - job.day.every(days) - print(f"Task scheduled every {days} day(s)") - - cron.write() diff --git a/software/source/server/tests/test_run.py b/software/source/server/tests/test_run.py index c1e64fe..3ca7565 100644 --- a/software/source/server/tests/test_run.py +++ b/software/source/server/tests/test_run.py @@ -2,11 +2,29 @@ import pytest -@pytest.mark.skip(reason="pytest hanging") -def test_ping(client): - response = client.get("/ping") - assert response.status_code == 200 - assert response.text == "pong" +import subprocess +import time + +def test_poetry_run_01(): + process = subprocess.Popen(['poetry', 'run', '01'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + timeout = time.time() + 30 # 30 seconds from now + + while True: + output = process.stdout.readline().decode('utf-8') + if "Hold spacebar to record." in output: + assert True + return + if time.time() > timeout: + assert False, "Timeout reached without finding expected output." + return + + + +# @pytest.mark.skip(reason="pytest hanging") +# def test_ping(client): +# response = client.get("/ping") +# assert response.status_code == 200 +# assert response.text == "pong" # def test_interpreter_chat(mock_interpreter): diff --git a/software/start.py b/software/start.py index fab153f..7fd2258 100644 --- a/software/start.py +++ b/software/start.py @@ -134,11 +134,14 @@ def _run( signal.signal(signal.SIGINT, handle_exit) if server: + + play_audio = False + + # (DISABLED) # Have the server play audio if we're running this on the same device. Needless pops and clicks otherwise! - if client: - play_audio = True - else: - play_audio = False + # if client: + # play_audio = True + server_thread = threading.Thread( target=start_server, args=( @@ -178,11 +181,12 @@ def _run( f".clients.{client_type}.device", package="source" ) + play_audio = True + + # (DISABLED) # Have the server play audio if we're running this on the same device. Needless pops and clicks otherwise! - if server: - play_audio = False - else: - play_audio = True + # if server: + # play_audio = False client_thread = threading.Thread(target=module.main, args=[server_url, debug, play_audio]) client_thread.start()