From af3b9b56d080cb66b9e8ff7c94354b9580c64718 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 28 Mar 2022 15:04:28 -0400 Subject: [PATCH] Re-factor HTTP server code --- mimic3-http/README.md | 3 + mimic3-http/mimic3_http/__main__.py | 324 +--------------------------- mimic3-http/mimic3_http/app.py | 282 ++++++++++++++++++++++++ mimic3-http/mimic3_http/args.py | 84 ++++++++ 4 files changed, 376 insertions(+), 317 deletions(-) create mode 100644 mimic3-http/mimic3_http/app.py create mode 100644 mimic3-http/mimic3_http/args.py diff --git a/mimic3-http/README.md b/mimic3-http/README.md index cfac3c6..b55ab29 100644 --- a/mimic3-http/README.md +++ b/mimic3-http/README.md @@ -3,5 +3,8 @@ ## Server +``` sh + +``` ## Client diff --git a/mimic3-http/mimic3_http/__main__.py b/mimic3-http/mimic3_http/__main__.py index e400681..e6919ba 100644 --- a/mimic3-http/mimic3_http/__main__.py +++ b/mimic3-http/mimic3_http/__main__.py @@ -14,96 +14,22 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # -import argparse import asyncio -import dataclasses -import hashlib -import io import logging import tempfile -import typing -import wave -from dataclasses import dataclass -from pathlib import Path -from urllib.parse import parse_qs -from uuid import uuid4 import hypercorn -import quart_cors -from mimic3_tts import ( - AudioResult, - Mimic3Settings, - Mimic3TextToSpeechSystem, - SSMLSpeaker, -) -from quart import ( - Quart, - Response, - jsonify, - render_template, - request, - send_from_directory, -) +from mimic3_tts import Mimic3Settings, Mimic3TextToSpeechSystem -from ._resources import _PACKAGE, __version__, _DIR +from .app import get_app +from .args import get_args _LOGGER = logging.getLogger(__name__) -_MISSING = object() -_TEMP_DIR: typing.Optional[Path] = None - # ----------------------------------------------------------------------------- -parser = argparse.ArgumentParser(prog=_PACKAGE) -parser.add_argument( - "--voices-dir", action="append", help="Directory with / structure", -) -parser.add_argument("--voice", help="Default voice (name of model directory)") -parser.add_argument( - "--host", default="0.0.0.0", help="Host of HTTP server (default: 0.0.0.0)" -) -parser.add_argument( - "--port", type=int, default=59125, help="Port of HTTP server (default: 59125)" -) -parser.add_argument("--speaker", type=int, help="Default speaker to use (name or id)") -parser.add_argument( - "--noise-scale", type=float, help="Noise scale [0-1], default is 0.667", -) -parser.add_argument( - "--length-scale", - type=float, - help="Length scale (1.0 is default speed, 0.5 is 2x faster)", -) -parser.add_argument( - "--noise-w", type=float, help="Variation in cadence [0-1], default is 0.8", -) -parser.add_argument( - "--cache-dir", - nargs="?", - default=_MISSING, - help="Enable WAV cache with optional directory (default: no cache)", -) -parser.add_argument( - "--preload-voice", action="append", help="Preload voice when starting up" -) -# parser.add_argument( -# "--max-loaded-models", -# type=int, -# default=0, -# help="Maximum number of voice models that can be loaded simultaneously (0 for no limit)", -# ) -parser.add_argument( - "--debug", action="store_true", help="Print DEBUG messages to console" -) -# parser.add_argument( -# "--version", action="store_true", help="Print version to console and exit" -# ) -args = parser.parse_args() - -# if args.version: -# print(__version__) -# sys.exit(0) +args = get_args() if args.debug: logging.basicConfig(level=logging.DEBUG) @@ -115,31 +41,9 @@ _LOGGER.debug(args) # ----------------------------------------------------------------------------- - - -@dataclass(frozen=True) # must be hashable -class TextToWavParams: - text: str - voice: str = args.voice - noise_scale: float = args.noise_scale - noise_w: float = args.noise_w - length_scale: float = args.length_scale - ssml: bool = False - text_language: typing.Optional[str] = None - - @property - def cache_key(self) -> str: - return hashlib.md5(repr(self).encode()).hexdigest() - - -# params -> Path -_WAV_CACHE: typing.Dict[TextToWavParams, Path] = {} - - +# Load Mimic 3 # ----------------------------------------------------------------------------- - -# TODO: XDG voice directories # TODO: args.voices_dir mimic3 = Mimic3TextToSpeechSystem( @@ -153,215 +57,12 @@ mimic3 = Mimic3TextToSpeechSystem( ) if args.preload_voice: + # Ensure voices are preloaded for voice_key in args.preload_voice: _LOGGER.debug("Preloading voice: %s", voice_key) mimic3.preload_voice(voice_key) -def text_to_wav(params: TextToWavParams, no_cache: bool = False) -> bytes: - - _LOGGER.debug(params) - - if _TEMP_DIR and (not no_cache): - # Look up in cache - maybe_wav_path = _TEMP_DIR / f"{params.cache_key}.wav" - if maybe_wav_path.is_file(): - _LOGGER.debug("Loading WAV from cache: %s", maybe_wav_path) - wav_bytes = maybe_wav_path.read_bytes() - return wav_bytes - - mimic3.voice = params.voice - - mimic3.settings.length_scale = params.length_scale - mimic3.settings.noise_scale = params.noise_scale - mimic3.settings.noise_w = params.noise_w - - with io.BytesIO() as wav_io: - wav_file: wave.Wave_write = wave.open(wav_io, "wb") - wav_params_set = False - - with wav_file: - try: - if params.ssml: - results = SSMLSpeaker(mimic3).speak(params.text) - else: - mimic3.begin_utterance() - mimic3.speak_text(params.text, text_language=params.text_language) - results = mimic3.end_utterance() - - for result in results: - # TODO: Marks - if isinstance(result, AudioResult): - if not wav_params_set: - wav_file.setframerate(result.sample_rate_hz) - wav_file.setsampwidth(result.sample_width_bytes) - wav_file.setnchannels(result.num_channels) - wav_params_set = True - - wav_file.writeframes(result.audio_bytes) - except Exception as e: - if not wav_params_set: - # Set default parameters so exception can propagate - wav_file.setframerate(22050) - wav_file.setsampwidth(2) - wav_file.setnchannels(1) - - raise e - - wav_bytes = wav_io.getvalue() - - if _TEMP_DIR and (not no_cache): - # Store in cache - wav_path = _TEMP_DIR / f"{params.cache_key}.wav" - wav_path.write_bytes(wav_bytes) - - _LOGGER.debug("Cached WAV at %s", wav_path.absolute()) - - return wav_bytes - - -# ----------------------------------------------------------------------------- - -_TEMPLATES_DIR = _DIR / "templates" - -app = Quart(_PACKAGE, template_folder=str(_TEMPLATES_DIR)) -app.secret_key = str(uuid4()) - -if args.debug: - app.config["TEMPLATES_AUTO_RELOAD"] = True - -app = quart_cors.cors(app) - - -# ----------------------------------------------------------------------------- - -_CSS_DIR = _DIR / "css" -_IMG_DIR = _DIR / "img" - - -def _to_bool(s: str) -> bool: - return s.strip().lower() in {"true", "1", "yes", "on"} - - -@app.route("/img/", methods=["GET"]) -async def img(filename) -> Response: - """Image static endpoint.""" - return await send_from_directory(_IMG_DIR, filename) - - -@app.route("/css/", methods=["GET"]) -async def css(filename) -> Response: - """CSS static endpoint.""" - return await send_from_directory(_CSS_DIR, filename) - - -@app.route("/") -async def app_index(): - """Main page.""" - return await render_template("index.html") - - -@app.route("/api/tts", methods=["GET", "POST"]) -async def app_tts() -> Response: - """Speak text to WAV.""" - tts_args: typing.Dict[str, typing.Any] = {} - - _LOGGER.debug("Request args: %s", request.args) - - voice = request.args.get("voice") - if voice is not None: - tts_args["voice"] = str(voice) - - # TTS settings - noise_scale = request.args.get("noiseScale") - if noise_scale is not None: - tts_args["noise_scale"] = float(noise_scale) - - noise_w = request.args.get("noiseW") - if noise_w is not None: - tts_args["noise_w"] = float(noise_w) - - length_scale = request.args.get("lengthScale") - if length_scale is not None: - tts_args["length_scale"] = float(length_scale) - - # Set SSML flag either from arg or content type - ssml_str = request.args.get("ssml") - if ssml_str is not None: - tts_args["ssml"] = _to_bool(ssml_str) - elif request.content_type == "application/ssml+xml": - tts_args["ssml"] = True - - text_language = request.args.get("textLanguage") - if text_language is not None: - tts_args["text_language"] = str(text_language) - - # Text can come from POST body or GET ?text arg - if request.method == "POST": - text = (await request.data).decode() - else: - text = request.args.get("text", "") - - assert text, "No text provided" - - # Cache settings - no_cache_str = request.args.get("noCache", "") - no_cache = _to_bool(no_cache_str) - - wav_bytes = text_to_wav(TextToWavParams(text=text, **tts_args), no_cache=no_cache) - - return Response(wav_bytes, mimetype="audio/wav") - - -@app.route("/api/voices", methods=["GET"]) -async def api_voices(): - voices_dict = {v.key: v for v in mimic3.get_voices()} - voices = sorted(voices_dict.values(), key=lambda v: v.key) - return jsonify([dataclasses.asdict(v) for v in voices]) - - -@app.route("/process", methods=["GET", "POST"]) -async def api_process(): - """MaryTTS-compatible /process endpoint""" - voice = args.voice - - if request.method == "POST": - data = parse_qs((await request.data).decode()) - text = data.get("INPUT_TEXT", [""])[0] - - if "VOICE" in data: - voice = str(data.get("VOICE", [voice])[0]).strip() - else: - text = request.args.get("INPUT_TEXT", "") - voice = str(request.args.get("VOICE", voice)).strip() - - voice = voice or args.voice - - # Assume SSML if text begins with an angle bracket - ssml = text.strip().startswith("<") - - _LOGGER.debug("Speaking with voice '%s': %s", voice, text) - wav_bytes = text_to_wav( - TextToWavParams( - text=text, - voice=voice, - ssml=ssml, - length_scale=args.length_scale, - noise_scale=args.noise_scale, - noise_w=args.noise_w, - ) - ) - - return Response(wav_bytes, mimetype="audio/wav") - - -@app.errorhandler(Exception) -async def handle_error(err) -> typing.Tuple[str, int]: - """Return error as text.""" - _LOGGER.exception(err) - return (f"{err.__class__.__name__}: {err}", 500) - - # ----------------------------------------------------------------------------- # Run Web Server # ----------------------------------------------------------------------------- @@ -372,16 +73,5 @@ hyp_config = hypercorn.config.Config() hyp_config.bind = [f"{args.host}:{args.port}"] with mimic3, tempfile.TemporaryDirectory(prefix="mimic3") as temp_dir: - if args.cache_dir != _MISSING: - if args.cache_dir is None: - # Use temporary directory - _TEMP_DIR = Path(temp_dir) - else: - # Use user-supplied cache directory - _TEMP_DIR = Path(args.cache_dir) - _TEMP_DIR.mkdir(parents=True, exist_ok=True) - - if _TEMP_DIR: - _LOGGER.debug("Cache directory: %s", _TEMP_DIR) - + app = get_app(args, mimic3, temp_dir) asyncio.run(hypercorn.asyncio.serve(app, hyp_config)) diff --git a/mimic3-http/mimic3_http/app.py b/mimic3-http/mimic3_http/app.py new file mode 100644 index 0000000..2932f7a --- /dev/null +++ b/mimic3-http/mimic3_http/app.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +# Copyright 2022 Mycroft AI Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +import argparse +import dataclasses +import hashlib +import io +import logging +import typing +import wave +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import parse_qs +from uuid import uuid4 + +import quart_cors +from mimic3_tts import AudioResult, Mimic3TextToSpeechSystem, SSMLSpeaker +from quart import ( + Quart, + Response, + jsonify, + render_template, + request, + send_from_directory, +) + +from ._resources import _DIR, _PACKAGE +from .args import _MISSING + +_LOGGER = logging.getLogger(__name__) + + +def get_app(args: argparse.Namespace, mimic3: Mimic3TextToSpeechSystem, temp_dir: str): + """Create and return Quart application for Mimic 3 HTTP server""" + + _TEMP_DIR: typing.Optional[Path] = None + + if args.cache_dir != _MISSING: + if args.cache_dir is None: + # Use temporary directory + _TEMP_DIR = Path(temp_dir) + else: + # Use user-supplied cache directory + _TEMP_DIR = Path(args.cache_dir) + _TEMP_DIR.mkdir(parents=True, exist_ok=True) + + if _TEMP_DIR: + _LOGGER.debug("Cache directory: %s", _TEMP_DIR) + + @dataclass + class TextToWavParams: + """Synthesis parameters used for caching""" + + text: str + voice: str = args.voice + noise_scale: float = args.noise_scale + noise_w: float = args.noise_w + length_scale: float = args.length_scale + ssml: bool = False + text_language: typing.Optional[str] = None + + @property + def cache_key(self) -> str: + return hashlib.md5(repr(self).encode()).hexdigest() + + def text_to_wav(params: TextToWavParams, no_cache: bool = False) -> bytes: + """Synthesize text into audio. + + Returns: WAV bytes + """ + + _LOGGER.debug(params) + + if _TEMP_DIR and (not no_cache): + # Look up in cache + maybe_wav_path = _TEMP_DIR / f"{params.cache_key}.wav" + if maybe_wav_path.is_file(): + _LOGGER.debug("Loading WAV from cache: %s", maybe_wav_path) + wav_bytes = maybe_wav_path.read_bytes() + return wav_bytes + + mimic3.voice = params.voice + + mimic3.settings.length_scale = params.length_scale + mimic3.settings.noise_scale = params.noise_scale + mimic3.settings.noise_w = params.noise_w + + with io.BytesIO() as wav_io: + wav_file: wave.Wave_write = wave.open(wav_io, "wb") + wav_params_set = False + + with wav_file: + try: + if params.ssml: + # SSML + results = SSMLSpeaker(mimic3).speak(params.text) + else: + # Plain text + mimic3.begin_utterance() + mimic3.speak_text( + params.text, text_language=params.text_language + ) + results = mimic3.end_utterance() + + for result in results: + # Add audio to existing WAV file + if isinstance(result, AudioResult): + if not wav_params_set: + wav_file.setframerate(result.sample_rate_hz) + wav_file.setsampwidth(result.sample_width_bytes) + wav_file.setnchannels(result.num_channels) + wav_params_set = True + + wav_file.writeframes(result.audio_bytes) + except Exception as e: + if not wav_params_set: + # Set default parameters so exception can propagate + wav_file.setframerate(22050) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + + raise e + + wav_bytes = wav_io.getvalue() + + if _TEMP_DIR and (not no_cache): + # Store in cache + wav_path = _TEMP_DIR / f"{params.cache_key}.wav" + wav_path.write_bytes(wav_bytes) + + _LOGGER.debug("Cached WAV at %s", wav_path.absolute()) + + return wav_bytes + + # ----------------------------------------------------------------------------- + + _TEMPLATES_DIR = _DIR / "templates" + + app = Quart(_PACKAGE, template_folder=str(_TEMPLATES_DIR)) + app.secret_key = str(uuid4()) + + if args.debug: + app.config["TEMPLATES_AUTO_RELOAD"] = True + + app = quart_cors.cors(app) + + # ----------------------------------------------------------------------------- + + _CSS_DIR = _DIR / "css" + _IMG_DIR = _DIR / "img" + + def _to_bool(s: str) -> bool: + return s.strip().lower() in {"true", "1", "yes", "on"} + + @app.route("/img/", methods=["GET"]) + async def img(filename) -> Response: + """Image static endpoint.""" + return await send_from_directory(_IMG_DIR, filename) + + @app.route("/css/", methods=["GET"]) + async def css(filename) -> Response: + """CSS static endpoint.""" + return await send_from_directory(_CSS_DIR, filename) + + @app.route("/") + async def app_index(): + """Main page.""" + return await render_template("index.html") + + @app.route("/api/tts", methods=["GET", "POST"]) + async def app_tts() -> Response: + """Speak text to WAV.""" + tts_args: typing.Dict[str, typing.Any] = {} + + _LOGGER.debug("Request args: %s", request.args) + + voice = request.args.get("voice") + if voice is not None: + tts_args["voice"] = str(voice) + + # TTS settings + noise_scale = request.args.get("noiseScale") + if noise_scale is not None: + tts_args["noise_scale"] = float(noise_scale) + + noise_w = request.args.get("noiseW") + if noise_w is not None: + tts_args["noise_w"] = float(noise_w) + + length_scale = request.args.get("lengthScale") + if length_scale is not None: + tts_args["length_scale"] = float(length_scale) + + # Set SSML flag either from arg or content type + ssml_str = request.args.get("ssml") + if ssml_str is not None: + tts_args["ssml"] = _to_bool(ssml_str) + elif request.content_type == "application/ssml+xml": + tts_args["ssml"] = True + + text_language = request.args.get("textLanguage") + if text_language is not None: + tts_args["text_language"] = str(text_language) + + # Text can come from POST body or GET ?text arg + if request.method == "POST": + text = (await request.data).decode() + else: + text = request.args.get("text", "") + + assert text, "No text provided" + + # Cache settings + no_cache_str = request.args.get("noCache", "") + no_cache = _to_bool(no_cache_str) + + wav_bytes = text_to_wav( + TextToWavParams(text=text, **tts_args), no_cache=no_cache + ) + + return Response(wav_bytes, mimetype="audio/wav") + + @app.route("/api/voices", methods=["GET"]) + async def api_voices(): + voices_dict = {v.key: v for v in mimic3.get_voices()} + voices = sorted(voices_dict.values(), key=lambda v: v.key) + return jsonify([dataclasses.asdict(v) for v in voices]) + + @app.route("/process", methods=["GET", "POST"]) + async def api_process(): + """MaryTTS-compatible /process endpoint""" + voice = args.voice + + if request.method == "POST": + data = parse_qs((await request.data).decode()) + text = data.get("INPUT_TEXT", [""])[0] + + if "VOICE" in data: + voice = str(data.get("VOICE", [voice])[0]).strip() + else: + text = request.args.get("INPUT_TEXT", "") + voice = str(request.args.get("VOICE", voice)).strip() + + voice = voice or args.voice + + # Assume SSML if text begins with an angle bracket + ssml = text.strip().startswith("<") + + _LOGGER.debug("Speaking with voice '%s': %s", voice, text) + wav_bytes = text_to_wav( + TextToWavParams( + text=text, + voice=voice, + ssml=ssml, + length_scale=args.length_scale, + noise_scale=args.noise_scale, + noise_w=args.noise_w, + ) + ) + + return Response(wav_bytes, mimetype="audio/wav") + + @app.errorhandler(Exception) + async def handle_error(err) -> typing.Tuple[str, int]: + """Return error as text.""" + _LOGGER.exception(err) + return (f"{err.__class__.__name__}: {err}", 500) + + return app diff --git a/mimic3-http/mimic3_http/args.py b/mimic3-http/mimic3_http/args.py new file mode 100644 index 0000000..4a95485 --- /dev/null +++ b/mimic3-http/mimic3_http/args.py @@ -0,0 +1,84 @@ +# Copyright 2022 Mycroft AI Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +import argparse +import sys + +from ._resources import _PACKAGE, __version__ + +_MISSING = object() + + +def get_args() -> argparse.Namespace: + """Parse and return command-line arguments""" + parser = argparse.ArgumentParser(prog=_PACKAGE) + parser.add_argument( + "--voices-dir", + action="append", + help="Directory with / structure", + ) + parser.add_argument("--voice", help="Default voice (name of model directory)") + parser.add_argument( + "--host", default="0.0.0.0", help="Host of HTTP server (default: 0.0.0.0)" + ) + parser.add_argument( + "--port", type=int, default=59125, help="Port of HTTP server (default: 59125)" + ) + parser.add_argument( + "--speaker", type=int, help="Default speaker to use (name or id)" + ) + parser.add_argument( + "--noise-scale", + type=float, + help="Noise scale [0-1], default is 0.667", + ) + parser.add_argument( + "--length-scale", + type=float, + help="Length scale (1.0 is default speed, 0.5 is 2x faster)", + ) + parser.add_argument( + "--noise-w", + type=float, + help="Variation in cadence [0-1], default is 0.8", + ) + parser.add_argument( + "--cache-dir", + nargs="?", + default=_MISSING, + help="Enable WAV cache with optional directory (default: no cache)", + ) + parser.add_argument( + "--preload-voice", action="append", help="Preload voice when starting up" + ) + # parser.add_argument( + # "--max-loaded-models", + # type=int, + # default=0, + # help="Maximum number of voice models that can be loaded simultaneously (0 for no limit)", + # ) + parser.add_argument( + "--debug", action="store_true", help="Print DEBUG messages to console" + ) + parser.add_argument( + "--version", action="store_true", help="Print version to console and exit" + ) + args = parser.parse_args() + + if args.version: + print(__version__) + sys.exit(0) + + return args