Files

912 lines
26 KiB
Python
Executable File

#!/usr/bin/env python3
import os
import logging
import sys
import subprocess
import json
import re
import gzip
import time
import io
import wave
import tempfile
import threading
import functools
import argparse
import shlex
import time
import atexit
from uuid import uuid4
from collections import defaultdict
from typing import Any, Union, Tuple, Dict, List
from flask import (
Flask,
request,
Response,
jsonify,
safe_join,
send_file,
send_from_directory,
)
from flask_cors import CORS
from flask_sockets import Sockets
import requests
import pydash
import gevent
from gevent import pywsgi
from gevent.queue import Queue as GQueue
from gevent.lock import RLock
from geventwebsocket.handler import WebSocketHandler
from jsgf2fst import read_slots
from rhasspy.profiles import Profile
from rhasspy.core import RhasspyCore
from rhasspy.dialogue import ProfileTrainingFailed
from rhasspy.utils import (
recursive_update,
recursive_remove,
buffer_to_wav,
load_phoneme_examples,
FunctionLoggingHandler,
)
# -----------------------------------------------------------------------------
# Flask Web App Setup
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
logging.root.setLevel(logging.DEBUG)
app = Flask("rhasspy")
app.secret_key = str(uuid4())
CORS(app)
sockets = Sockets(app)
# -----------------------------------------------------------------------------
# Parse Arguments
# -----------------------------------------------------------------------------
parser = argparse.ArgumentParser("Rhasspy")
parser.add_argument(
"--profile", "-p", required=True, type=str, help="Name of profile to load"
)
parser.add_argument("--host", type=str, help="Host for web server", default="0.0.0.0")
parser.add_argument("--port", type=int, help="Port for web server", default=12101)
parser.add_argument(
"--system-profiles",
type=str,
help="Directory with base profile files (read only)",
default=os.path.join(os.getcwd(), "profiles"),
)
parser.add_argument(
"--user-profiles",
type=str,
help="Directory with user profile files (read/write)",
default=os.path.expanduser("~/.config/rhasspy/profiles"),
)
parser.add_argument(
"--set",
"-s",
nargs=2,
action="append",
help="Set a profile setting value",
default=[],
)
parser.add_argument(
"--ssl", nargs=2, help="Use SSL with <CERT_FILE <KEY_FILE>", default=None
)
args = parser.parse_args()
logger.debug(args)
system_profiles_dir = os.path.abspath(args.system_profiles)
user_profiles_dir = os.path.abspath(args.user_profiles)
profiles_dirs = [user_profiles_dir, system_profiles_dir]
# -----------------------------------------------------------------------------
# Dialogue Manager Setup
# -----------------------------------------------------------------------------
core = None
# We really, *really* want shutdown to be called
@atexit.register
def shutdown(*args: Any, **kwargs: Any) -> None:
global core
if core is not None:
core.shutdown()
core = None
def start_rhasspy() -> None:
global core
default_settings = Profile.load_defaults(system_profiles_dir)
# Load core
core = RhasspyCore(args.profile, system_profiles_dir, user_profiles_dir)
# Set environment variables
os.environ["RHASSPY_BASE_DIR"] = os.getcwd()
os.environ["RHASSPY_PROFILE"] = core.profile.name
os.environ["RHASSPY_PROFILE_DIR"] = core.profile.write_dir()
# Add profile settings from the command line
extra_settings = {}
for key, value in args.set:
try:
value = json.loads(value)
except:
pass
logger.debug("Profile: {0}={1}".format(key, value))
extra_settings[key] = value
core.profile.set(key, value)
core.start()
logger.info("Started")
# -----------------------------------------------------------------------------
start_rhasspy()
# -----------------------------------------------------------------------------
# HTTP API
# -----------------------------------------------------------------------------
@app.route("/api/profiles")
def api_profiles() -> Response:
"""Get list of available profiles"""
assert core is not None
profile_names = set()
for profiles_dir in profiles_dirs:
if not os.path.exists(profiles_dir):
continue
for name in os.listdir(profiles_dir):
profile_dir = os.path.join(profiles_dir, name)
if os.path.isdir(profile_dir):
profile_names.add(name)
check_path = core.profile.read_path("check-profile.sh")
assert os.path.exists(check_path), "Missing profile check script"
check_cmd = ["bash", check_path, core.profile.write_path()]
logger.debug(check_cmd)
downloaded = True
try:
output = subprocess.check_output(check_cmd, stderr=subprocess.STDOUT).decode()
except subprocess.CalledProcessError as e:
output = e.output.decode()
logger.warning(output)
downloaded = False
return jsonify(
{
"default_profile": core.profile.name,
"profiles": sorted(list(profile_names)),
"downloaded": downloaded,
}
)
# -----------------------------------------------------------------------------
@app.route("/api/download-profile", methods=["POST"])
def api_download_profile() -> str:
"""Downloads the current profile."""
assert core is not None
delete = request.args.get("delete", "false").lower() == "true"
download_script = os.path.abspath(core.profile.read_path("download-profile.sh"))
logger.debug(download_script)
assert os.path.exists(download_script), "Profile download script is missing."
download_cmd = ["bash", download_script, core.profile.write_path()]
if delete:
download_cmd.append("--delete")
logger.debug(download_cmd)
try:
output = subprocess.check_output(
download_cmd, stderr=subprocess.STDOUT
).decode()
except subprocess.CalledProcessError as e:
logger.exception("download profile")
output = e.output.decode()
logger.error(output)
raise Exception(output)
return output
# -----------------------------------------------------------------------------
@app.route("/api/microphones", methods=["GET"])
def api_microphones() -> Response:
"""Get a dictionary of available recording devices"""
assert core is not None
system = request.args.get("system", None)
return jsonify(core.get_microphones(system))
# -----------------------------------------------------------------------------
@app.route("/api/test-microphones", methods=["GET"])
def api_test_microphones() -> Response:
"""Get a dictionary of available, functioning recording devices"""
assert core is not None
system = request.args.get("system", None)
return jsonify(core.test_microphones(system))
# -----------------------------------------------------------------------------
@app.route("/api/speakers", methods=["GET"])
def api_speakers() -> Response:
"""Get a dictionary of available playback devices"""
assert core is not None
system = request.args.get("system", None)
return jsonify(core.get_speakers(system))
# -----------------------------------------------------------------------------
@app.route("/api/listen-for-wake", methods=["POST"])
def api_listen_for_wake() -> str:
"""Make Rhasspy listen for a wake word"""
assert core is not None
core.listen_for_wake()
return "OK"
# -----------------------------------------------------------------------------
@app.route("/api/listen-for-command", methods=["POST"])
def api_listen_for_command() -> Response:
"""Wake Rhasspy up and listen for a voice command"""
assert core is not None
no_hass = request.args.get("nohass", "false").lower() == "true"
return jsonify(core.listen_for_command(handle=not no_hass))
# -----------------------------------------------------------------------------
@app.route("/api/profile", methods=["GET", "POST"])
def api_profile() -> Union[str, Response]:
"""Read or write profile JSON directly"""
assert core is not None
layers = request.args.get("layers", "all")
if request.method == "POST":
# Ensure that JSON is valid
profile_json = json.loads(request.data)
# from cerberus import Validator
# schema_path = os.path.join(
# os.path.dirname(__file__), "rhasspy", "profile_schema.json"
# )
# with open(schema_path, "r") as schema_file:
# v = Validator(json.load(schema_file))
# profile_dict = json.loads(request.data)
# if not v.validate(profile_dict):
# print(json.dumps(profile_dict, indent=4))
# raise Exception(str(v._errors[0].info))
recursive_remove(core.defaults, profile_json)
profile_path = os.path.abspath(core.profile.write_path("profile.json"))
with open(profile_path, "w") as profile_file:
json.dump(profile_json, profile_file, indent=4)
msg = "Wrote profile to %s" % profile_path
logger.debug(msg)
return msg
if layers == "defaults":
# Read default settings
return jsonify(core.defaults)
elif layers == "profile":
# Local settings only
profile_path = core.profile.read_path("profile.json")
return send_file(open(profile_path, "rb"), mimetype="application/json")
else:
return jsonify(core.profile.json)
# -----------------------------------------------------------------------------
@app.route("/api/lookup", methods=["POST"])
def api_lookup() -> Response:
"""Get CMU phonemes from dictionary or guessed pronunciation(s)"""
assert core is not None
n = int(request.args.get("n", 5))
assert n > 0, "No pronunciations requested"
word = request.data.decode("utf-8").strip().lower()
assert len(word) > 0, "No word to look up"
pronunciations = core.get_word_pronunciations([word], n).pronunciations
return jsonify(pronunciations[word])
# -----------------------------------------------------------------------------
@app.route("/api/pronounce", methods=["POST"])
def api_pronounce() -> Union[Response, str]:
"""Pronounce CMU phonemes or word using eSpeak"""
assert core is not None
download = request.args.get("download", "false").lower() == "true"
pronounce_str = request.data.decode("utf-8").strip()
assert len(pronounce_str) > 0, "No string to pronounce"
# phonemes or word
pronounce_type = request.args.get("type", "phonemes")
if pronounce_type == "phonemes":
# Convert from Sphinx to espeak phonemes
espeak_str = core.get_word_phonemes(pronounce_str).phonemes["espeak"]
else:
# Speak word directly
espeak_str = pronounce_str
result = core.speak_word(espeak_str)
wav_data = result.wav_data
espeak_phonemes = result.phonemes
if download:
# Return WAV
return Response(wav_data, mimetype="audio/wav")
else:
# Play through speakers
core.play_wav_data(wav_data)
return espeak_phonemes
# -----------------------------------------------------------------------------
@app.route("/api/play-wav", methods=["POST"])
def api_play_wav() -> str:
"""Play WAV data through the configured audio output system"""
assert core is not None
# Play through speakers
logger.debug(f"Playing {len(request.data)} byte(s)")
core.play_wav_data(request.data)
return "OK"
# -----------------------------------------------------------------------------
@app.route("/api/phonemes")
def api_phonemes():
"""Get phonemes and example words for a profile"""
assert core is not None
examples_path = core.profile.read_path(
core.profile.get("text_to_speech.phoneme_examples", "phoneme_examples.txt")
)
# phoneme -> { word, phonemes }
logger.debug("Loading phoneme examples from %s" % examples_path)
examples_dict = load_phoneme_examples(examples_path)
return jsonify(examples_dict)
# -----------------------------------------------------------------------------
@app.route("/api/sentences", methods=["GET", "POST"])
def api_sentences():
"""Read or write sentences for a profile"""
assert core is not None
if request.method == "POST":
# Update sentences
sentences_path = core.profile.write_path(
core.profile.get("speech_to_text.sentences_ini")
)
with open(sentences_path, "wb") as sentences_file:
sentences_file.write(request.data)
return "Wrote %s byte(s) to %s" % (len(request.data), sentences_path)
# Return sentences
sentences_path = core.profile.read_path(
core.profile.get("speech_to_text.sentences_ini")
)
if not os.path.exists(sentences_path):
return "" # no sentences yet
# Return file contents
return send_file(open(sentences_path, "rb"), mimetype="text/plain")
# -----------------------------------------------------------------------------
@app.route("/api/custom-words", methods=["GET", "POST"])
def api_custom_words():
"""Read or write custom word dictionary for a profile"""
assert core is not None
if request.method == "POST":
custom_words_path = core.profile.write_path(
core.profile.get("speech_to_text.pocketsphinx.custom_words")
)
# Update custom words
lines_written = 0
with open(custom_words_path, "w") as custom_words_file:
lines = request.data.decode().splitlines()
for line in lines:
line = line.strip()
if len(line) == 0:
continue
print(line, file=custom_words_file)
lines_written += 1
return "Wrote %s line(s) to %s" % (lines_written, custom_words_path)
custom_words_path = core.profile.read_path(
core.profile.get("speech_to_text.pocketsphinx.custom_words")
)
# Return custom_words
if not os.path.exists(custom_words_path):
return "" # no custom_words yet
# Return file contents
return send_file(open(custom_words_path, "rb"), mimetype="text/plain")
# -----------------------------------------------------------------------------
@app.route("/api/train", methods=["POST"])
def api_train() -> str:
assert core is not None
start_time = time.time()
logger.info("Starting training")
result = gevent.spawn(core.train).get()
if isinstance(result, ProfileTrainingFailed):
raise Exception(f"Training failed: {result.reason}")
end_time = time.time()
return "Training completed in %0.2f second(s)" % (end_time - start_time)
# -----------------------------------------------------------------------------
@app.route("/api/restart", methods=["POST"])
def api_restart() -> str:
assert core is not None
logger.debug("Restarting Rhasspy")
# Stop
core.shutdown()
# Start
start_rhasspy()
logger.info("Restarted Rhasspy")
return "Restarted Rhasspy"
# -----------------------------------------------------------------------------
# Get text from a WAV file
@app.route("/api/speech-to-text", methods=["POST"])
def api_speech_to_text() -> str:
"""speech -> text"""
assert core is not None
# Prefer 16-bit 16Khz mono, but will convert with sox if needed
wav_data = request.data
return core.transcribe_wav(wav_data).text
# -----------------------------------------------------------------------------
# Get intent from text
@app.route("/api/text-to-intent", methods=["POST"])
def api_text_to_intent():
"""text -> intent"""
assert core is not None
text = request.data.decode()
no_hass = request.args.get("nohass", "false").lower() == "true"
# Convert text to intent
start_time = time.time()
intent = core.recognize_intent(text).intent
intent["speech_confidence"] = 1
intent_sec = time.time() - start_time
intent["time_sec"] = intent_sec
intent_json = json.dumps(intent)
logger.debug(intent_json)
add_ws_event(WS_EVENT_INTENT, intent_json)
if not no_hass:
# Send intent to Home Assistant
intent = core.handle_intent(intent).intent
return jsonify(intent)
# -----------------------------------------------------------------------------
# Get intent from a WAV file
@app.route("/api/speech-to-intent", methods=["POST"])
def api_speech_to_intent() -> Response:
"""speech -> text -> intent"""
assert core is not None
no_hass = request.args.get("nohass", "false").lower() == "true"
# Prefer 16-bit 16Khz mono, but will convert with sox if needed
wav_data = request.data
# speech -> text
start_time = time.time()
transcription = core.transcribe_wav(wav_data)
text = transcription.text
logger.debug(text)
# text -> intent
intent = core.recognize_intent(text).intent
intent["speech_confidence"] = transcription.confidence
intent_sec = time.time() - start_time
intent["time_sec"] = intent_sec
intent_json = json.dumps(intent)
logger.debug(intent_json)
add_ws_event(WS_EVENT_INTENT, intent_json)
if not no_hass:
# Send intent to Home Assistant
intent = core.handle_intent(intent).intent
return jsonify(intent)
# -----------------------------------------------------------------------------
# Start recording a WAV file to a temporary buffer
@app.route("/api/start-recording", methods=["POST"])
def api_start_recording() -> str:
"""Begin recording voice command"""
assert core is not None
buffer_name = request.args.get("name", "")
core.start_recording_wav(buffer_name)
return "OK"
# Stop recording WAV file, transcribe, and get intent
@app.route("/api/stop-recording", methods=["POST"])
def api_stop_recording() -> Response:
"""End recording voice command. Transcribe and handle."""
assert core is not None
no_hass = request.args.get("nohass", "false").lower() == "true"
buffer_name = request.args.get("name", "")
audio_data = core.stop_recording_wav(buffer_name).data
wav_data = buffer_to_wav(audio_data)
logger.debug("Recorded %s byte(s) of audio data" % len(wav_data))
transcription = core.transcribe_wav(wav_data)
text = transcription.text
logger.debug(text)
intent = core.recognize_intent(text).intent
intent["speech_confidence"] = transcription.confidence
intent_json = json.dumps(intent)
logger.debug(intent_json)
add_ws_event(WS_EVENT_INTENT, intent_json)
if not no_hass:
# Send intent to Home Assistant
intent = core.handle_intent(intent).intent
return jsonify(intent)
# -----------------------------------------------------------------------------
@app.route("/api/unknown_words", methods=["GET"])
def api_unknown_words() -> Response:
"""Get list of unknown words"""
assert core is not None
unknown_words = {}
unknown_path = core.profile.read_path(
core.profile.get("speech_to_text.pocketsphinx.unknown_words")
)
if os.path.exists(unknown_path):
for line in open(unknown_path, "r"):
line = line.strip()
if len(line) > 0:
word, pronunciation = re.split(r"[ ]+", line, maxsplit=1)
unknown_words[word] = pronunciation
return jsonify(unknown_words)
# -----------------------------------------------------------------------------
@app.route("/api/text-to-speech", methods=["POST"])
def api_text_to_speech() -> str:
"""Speaks a sentence with text to speech system"""
sentence = request.data.decode().strip()
assert core is not None
core.speak_sentence(sentence)
return sentence
# -----------------------------------------------------------------------------
@app.route("/api/slots", methods=["GET", "POST"])
def api_slots() -> Union[str, Response]:
"""Get the values of all slots"""
assert core is not None
overwrite_all = request.args.get("overwrite_all", "false").lower() == "true"
new_slot_values = json.loads(request.data)
slots_dir = core.profile.read_path(
core.profile.get("speech_to_text.slots_dir", "slots")
)
if request.method == "POST":
if overwrite_all:
# Remote existing values first
for name in new_slot_values.keys():
slots_path = safe_join(slots_dir, f"{name}")
if os.path.exists(slots_path):
try:
os.unlink(slots_path)
except:
logger.exception("api_slots")
for name, values in new_slot_values.items():
slots_path = core.profile.write_path(
core.profile.get("speech_to_text.slots_dir", "slots"), f"{name}"
)
# Create directories
os.makedirs(os.path.split(slots_path)[0], exist_ok=True)
# Write data
with open(slots_path, "w") as slots_file:
for value in values:
value = value.strip()
if len(value) > 0:
print(value, file=slots_file)
return "OK"
# Load slots values
slots_dir = core.profile.read_path(core.profile.get("speech_to_text.slots_dir"))
return jsonify(read_slots(slots_dir))
@app.route("/api/slots/<name>", methods=["GET", "POST"])
def api_slots_by_name(name: str) -> Union[str, Response]:
"""Get or sets the values of a slot list"""
assert core is not None
overwrite_all = request.args.get("overwrite_all", "false").lower() == "true"
slots_dir = core.profile.read_path(
core.profile.get("speech_to_text.slots_dir", "slots")
)
if request.method == "POST":
if overwrite_all:
# Remote existing values first
slots_path = safe_join(slots_dir, f"{name}")
if os.path.exists(slots_path):
try:
os.unlink(slots_path)
except:
logger.exception("api_slots_by_name")
slots_path = core.profile.write_path(
core.profile.get("speech_to_text.slots_dir", "slots"), f"{name}"
)
# Create directories
os.makedirs(os.path.split(slots_path)[0], exist_ok=True)
# Write data
with open(slots_path, "wb") as slots_file:
slots_file.write(request.data)
return f"Wrote {len(request.data)} byte(s) to {slots_path}"
# Load slots values
slot_values = read_slots(slots_dir)
return "\n".join(slot_values.get(name, []))
# -----------------------------------------------------------------------------
@app.errorhandler(Exception)
def handle_error(err) -> Tuple[str, int]:
logger.exception(err)
return (str(err), 500)
# ---------------------------------------------------------------------
# Static Routes
# ---------------------------------------------------------------------
web_dir = os.path.join(os.getcwd(), "dist")
@app.route("/css/<path:filename>", methods=["GET"])
def css(filename) -> Response:
return send_from_directory(os.path.join(web_dir, "css"), filename)
@app.route("/js/<path:filename>", methods=["GET"])
def js(filename) -> Response:
return send_from_directory(os.path.join(web_dir, "js"), filename)
@app.route("/img/<path:filename>", methods=["GET"])
def img(filename) -> Response:
return send_from_directory(os.path.join(web_dir, "img"), filename)
@app.route("/webfonts/<path:filename>", methods=["GET"])
def webfonts(filename) -> Response:
return send_from_directory(os.path.join(web_dir, "webfonts"), filename)
# ----------------------------------------------------------------------------
# HTML Page Routes
# ----------------------------------------------------------------------------
@app.route("/", methods=["GET"])
def index() -> Response:
return send_file(os.path.join(web_dir, "index.html"))
@app.route("/swagger.yaml", methods=["GET"])
def swagger_yaml() -> Response:
return send_file(os.path.join(web_dir, "swagger.yaml"))
# -----------------------------------------------------------------------------
# Swagger/OpenAPI documentation
from flask_swagger_ui import get_swaggerui_blueprint
SWAGGER_URL = "/api"
API_URL = "/swagger.yaml"
swaggerui_blueprint = get_swaggerui_blueprint(
SWAGGER_URL, API_URL, config={"app_name": "Rhasspy API"}
)
app.register_blueprint(swaggerui_blueprint, url_prefix=SWAGGER_URL)
# -----------------------------------------------------------------------------
# WebSocket API
# -----------------------------------------------------------------------------
WS_EVENT_INTENT = 0
WS_EVENT_LOG = 1
ws_queues: List[Dict[Any, GQueue]] = [{}, {}]
ws_locks: List[RLock] = [RLock(), RLock()]
def add_ws_event(event_type: int, text: str):
with ws_locks[event_type]:
for queue in ws_queues[event_type].values():
queue.put(text)
logging.root.addHandler(
FunctionLoggingHandler(lambda msg: add_ws_event(WS_EVENT_LOG, msg))
)
@sockets.route("/api/events/intent")
def api_events_intent(ws) -> None:
# Add new queue for websocket
q = GQueue()
with ws_locks[WS_EVENT_INTENT]:
ws_queues[WS_EVENT_INTENT][ws] = q
try:
while not ws.closed:
text = q.get()
ws.send(text)
except Exception as e:
logging.exception("api_events_intent")
# Remove queue
with ws_locks[WS_EVENT_INTENT]:
del ws_queues[WS_EVENT_INTENT][ws]
@sockets.route("/api/events/log")
def api_events_log(ws) -> None:
# Add new queue for websocket
q = GQueue()
with ws_locks[WS_EVENT_LOG]:
ws_queues[WS_EVENT_LOG][ws] = q
try:
while not ws.closed:
text = q.get()
ws.send(text)
except Exception as e:
logging.exception("api_events_log")
# Remove queue
with ws_locks[WS_EVENT_LOG]:
del ws_queues[WS_EVENT_LOG][ws]
# -----------------------------------------------------------------------------
# Start web server
protocol = "http"
server_kwargs = {}
if args.ssl is not None:
logging.debug(f"Using SSL with certfile, keyfile = {args.ssl}")
server_kwargs["certfile"] = args.ssl[0]
server_kwargs["keyfile"] = args.ssl[1]
protocol = "https"
logging.debug(f"Starting web server at {protocol}://{args.host}:{args.port}")
server = pywsgi.WSGIServer(
(args.host, args.port), app, handler_class=WebSocketHandler, **server_kwargs
)
logging.getLogger("geventwebsocket").setLevel(logging.INFO)
try:
server.serve_forever()
except KeyboardInterrupt:
pass