Added tests
This commit is contained in:
parent
d2496fa8a2
commit
fc97ef6a24
|
@ -17,8 +17,8 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: [macos-latest]
|
# os: [macos-latest]
|
||||||
# os: [ubuntu-latest, windows-latest, macos-latest]
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
python-version: ["3.11"]
|
python-version: ["3.11"]
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
|
@ -31,32 +31,30 @@ jobs:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install poetry
|
|
||||||
run: pipx install poetry
|
|
||||||
|
|
||||||
- name: Setup Python ${{ matrix.python-version }}
|
- name: Setup Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
cache: "poetry"
|
|
||||||
|
|
||||||
# Install Portaudio on Ubuntu
|
- name: Install poetry
|
||||||
- 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
|
|
||||||
|
|
||||||
# Install Poetry and project dependencies
|
|
||||||
- name: Install Poetry Package
|
|
||||||
run: |
|
run: |
|
||||||
pip install --upgrade pip
|
curl -sSL https://install.python-poetry.org | python3 -
|
||||||
pip install poetry==1.3.2
|
|
||||||
poetry config virtualenvs.create false
|
- name: Install dependencies
|
||||||
poetry install --no-interaction --with dev
|
run: |
|
||||||
|
# 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
|
# Run pytest
|
||||||
- name: Run Pytest
|
- name: Run Pytest
|
||||||
|
|
|
@ -59,11 +59,10 @@ class Device:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
data = await self.websocket.recv()
|
data = await self.websocket.recv()
|
||||||
if isinstance(data, bytes) and not self.recording:
|
if self.play_audio and isinstance(data, bytes) and not self.recording:
|
||||||
if self.play_audio:
|
self.output_stream.write(data)
|
||||||
self.output_stream.write(data)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error in receive_audio: {e}")
|
await self.connect_with_retry()
|
||||||
|
|
||||||
def on_press(self, key):
|
def on_press(self, key):
|
||||||
if key == keyboard.Key.space and not self.recording:
|
if key == keyboard.Key.space and not self.recording:
|
||||||
|
|
|
@ -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()
|
|
@ -1,14 +1,11 @@
|
||||||
import importlib
|
|
||||||
import traceback
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from RealtimeTTS import TextToAudioStream, CoquiEngine, OpenAIEngine, ElevenlabsEngine
|
from RealtimeTTS import TextToAudioStream, CoquiEngine, OpenAIEngine, ElevenlabsEngine
|
||||||
from RealtimeSTT import AudioToTextRecorder
|
|
||||||
import types
|
|
||||||
import time
|
|
||||||
import wave
|
|
||||||
import asyncio
|
|
||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
|
from RealtimeSTT import AudioToTextRecorder
|
||||||
|
import importlib
|
||||||
|
import asyncio
|
||||||
|
import types
|
||||||
|
import wave
|
||||||
|
import os
|
||||||
|
|
||||||
def start_server(server_host, server_port, profile, debug, play_audio):
|
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
|
interpreter.stt.stop() # It needs this for some reason
|
||||||
|
|
||||||
|
|
||||||
# TTS
|
# TTS
|
||||||
if not hasattr(interpreter, 'tts'):
|
if not hasattr(interpreter, 'tts'):
|
||||||
print("Setting TTS provider to default: openai")
|
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.verbose = debug
|
||||||
interpreter.server.host = server_host
|
interpreter.server.host = server_host
|
||||||
interpreter.server.port = server_port
|
interpreter.server.port = server_port
|
||||||
|
|
||||||
interpreter.play_audio = play_audio
|
interpreter.play_audio = play_audio
|
||||||
|
|
||||||
|
|
||||||
interpreter.audio_chunks = []
|
interpreter.audio_chunks = []
|
||||||
|
|
||||||
|
|
||||||
old_input = interpreter.input
|
### Swap out the input function for one that supports voice
|
||||||
old_output = interpreter.output
|
|
||||||
|
|
||||||
|
old_input = interpreter.input
|
||||||
|
|
||||||
async def new_input(self, chunk):
|
async def new_input(self, chunk):
|
||||||
await asyncio.sleep(0)
|
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})
|
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):
|
async def new_output(self):
|
||||||
while True:
|
while True:
|
||||||
output = await old_output()
|
output = await old_output()
|
||||||
|
@ -100,25 +97,29 @@ def start_server(server_host, server_port, profile, debug, play_audio):
|
||||||
delimiters = ".?!;,\n…)]}"
|
delimiters = ".?!;,\n…)]}"
|
||||||
|
|
||||||
if output["type"] == "message" and len(output.get("content", "")) > 0:
|
if output["type"] == "message" and len(output.get("content", "")) > 0:
|
||||||
|
|
||||||
self.tts.feed(output.get("content"))
|
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.
|
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}
|
return {"role": "assistant", "type": "audio", "format": "bytes.wav", "start": True}
|
||||||
|
|
||||||
if output == {"role": "assistant", "type": "message", "end": 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^
|
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", "start": True}
|
||||||
return {"role": "assistant", "type": "audio", "format": "bytes.wav", "end": True}
|
return {"role": "assistant", "type": "audio", "format": "bytes.wav", "end": True}
|
||||||
|
|
||||||
def on_tts_chunk(self, chunk):
|
def on_tts_chunk(self, chunk):
|
||||||
self.output_queue.sync_q.put(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.input = types.MethodType(new_input, interpreter)
|
||||||
interpreter.output = types.MethodType(new_output, interpreter)
|
interpreter.output = types.MethodType(new_output, interpreter)
|
||||||
interpreter.on_tts_chunk = types.MethodType(on_tts_chunk, interpreter)
|
interpreter.on_tts_chunk = types.MethodType(on_tts_chunk, interpreter)
|
||||||
|
|
||||||
|
# Add ping route, required by device
|
||||||
@interpreter.server.app.get("/ping")
|
@interpreter.server.app.get("/ping")
|
||||||
async def ping():
|
async def ping():
|
||||||
return PlainTextResponse("pong")
|
return PlainTextResponse("pong")
|
||||||
|
|
|
@ -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()
|
|
|
@ -2,11 +2,29 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason="pytest hanging")
|
import subprocess
|
||||||
def test_ping(client):
|
import time
|
||||||
response = client.get("/ping")
|
|
||||||
assert response.status_code == 200
|
def test_poetry_run_01():
|
||||||
assert response.text == "pong"
|
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):
|
# def test_interpreter_chat(mock_interpreter):
|
||||||
|
|
|
@ -134,11 +134,14 @@ def _run(
|
||||||
signal.signal(signal.SIGINT, handle_exit)
|
signal.signal(signal.SIGINT, handle_exit)
|
||||||
|
|
||||||
if server:
|
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!
|
# Have the server play audio if we're running this on the same device. Needless pops and clicks otherwise!
|
||||||
if client:
|
# if client:
|
||||||
play_audio = True
|
# play_audio = True
|
||||||
else:
|
|
||||||
play_audio = False
|
|
||||||
server_thread = threading.Thread(
|
server_thread = threading.Thread(
|
||||||
target=start_server,
|
target=start_server,
|
||||||
args=(
|
args=(
|
||||||
|
@ -178,11 +181,12 @@ def _run(
|
||||||
f".clients.{client_type}.device", package="source"
|
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!
|
# Have the server play audio if we're running this on the same device. Needless pops and clicks otherwise!
|
||||||
if server:
|
# if server:
|
||||||
play_audio = False
|
# play_audio = False
|
||||||
else:
|
|
||||||
play_audio = True
|
|
||||||
|
|
||||||
client_thread = threading.Thread(target=module.main, args=[server_url, debug, play_audio])
|
client_thread = threading.Thread(target=module.main, args=[server_url, debug, play_audio])
|
||||||
client_thread.start()
|
client_thread.start()
|
||||||
|
|
Loading…
Reference in New Issue