Re-factor HTTP server code
This commit is contained in:
parent
eea1f4b7b3
commit
af3b9b56d0
4 changed files with 376 additions and 317 deletions
|
|
@ -3,5 +3,8 @@
|
||||||
|
|
||||||
## Server
|
## Server
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
## Client
|
## Client
|
||||||
|
|
|
||||||
|
|
@ -14,96 +14,22 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
import argparse
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import dataclasses
|
|
||||||
import hashlib
|
|
||||||
import io
|
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
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 hypercorn
|
||||||
import quart_cors
|
from mimic3_tts import Mimic3Settings, Mimic3TextToSpeechSystem
|
||||||
from mimic3_tts import (
|
|
||||||
AudioResult,
|
|
||||||
Mimic3Settings,
|
|
||||||
Mimic3TextToSpeechSystem,
|
|
||||||
SSMLSpeaker,
|
|
||||||
)
|
|
||||||
from quart import (
|
|
||||||
Quart,
|
|
||||||
Response,
|
|
||||||
jsonify,
|
|
||||||
render_template,
|
|
||||||
request,
|
|
||||||
send_from_directory,
|
|
||||||
)
|
|
||||||
|
|
||||||
from ._resources import _PACKAGE, __version__, _DIR
|
from .app import get_app
|
||||||
|
from .args import get_args
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
_MISSING = object()
|
|
||||||
_TEMP_DIR: typing.Optional[Path] = None
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(prog=_PACKAGE)
|
args = get_args()
|
||||||
parser.add_argument(
|
|
||||||
"--voices-dir", action="append", help="Directory with <language>/<voice> 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)
|
|
||||||
|
|
||||||
if args.debug:
|
if args.debug:
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
@ -115,31 +41,9 @@ _LOGGER.debug(args)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
# Load Mimic 3
|
||||||
|
|
||||||
@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] = {}
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
# TODO: XDG voice directories
|
|
||||||
# TODO: args.voices_dir
|
# TODO: args.voices_dir
|
||||||
|
|
||||||
mimic3 = Mimic3TextToSpeechSystem(
|
mimic3 = Mimic3TextToSpeechSystem(
|
||||||
|
|
@ -153,215 +57,12 @@ mimic3 = Mimic3TextToSpeechSystem(
|
||||||
)
|
)
|
||||||
|
|
||||||
if args.preload_voice:
|
if args.preload_voice:
|
||||||
|
# Ensure voices are preloaded
|
||||||
for voice_key in args.preload_voice:
|
for voice_key in args.preload_voice:
|
||||||
_LOGGER.debug("Preloading voice: %s", voice_key)
|
_LOGGER.debug("Preloading voice: %s", voice_key)
|
||||||
mimic3.preload_voice(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/<path:filename>", methods=["GET"])
|
|
||||||
async def img(filename) -> Response:
|
|
||||||
"""Image static endpoint."""
|
|
||||||
return await send_from_directory(_IMG_DIR, filename)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/css/<path:filename>", 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
|
# Run Web Server
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
@ -372,16 +73,5 @@ hyp_config = hypercorn.config.Config()
|
||||||
hyp_config.bind = [f"{args.host}:{args.port}"]
|
hyp_config.bind = [f"{args.host}:{args.port}"]
|
||||||
|
|
||||||
with mimic3, tempfile.TemporaryDirectory(prefix="mimic3") as temp_dir:
|
with mimic3, tempfile.TemporaryDirectory(prefix="mimic3") as temp_dir:
|
||||||
if args.cache_dir != _MISSING:
|
app = get_app(args, mimic3, temp_dir)
|
||||||
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)
|
|
||||||
|
|
||||||
asyncio.run(hypercorn.asyncio.serve(app, hyp_config))
|
asyncio.run(hypercorn.asyncio.serve(app, hyp_config))
|
||||||
|
|
|
||||||
282
mimic3-http/mimic3_http/app.py
Normal file
282
mimic3-http/mimic3_http/app.py
Normal file
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
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/<path:filename>", methods=["GET"])
|
||||||
|
async def img(filename) -> Response:
|
||||||
|
"""Image static endpoint."""
|
||||||
|
return await send_from_directory(_IMG_DIR, filename)
|
||||||
|
|
||||||
|
@app.route("/css/<path:filename>", 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
|
||||||
84
mimic3-http/mimic3_http/args.py
Normal file
84
mimic3-http/mimic3_http/args.py
Normal file
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
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 <language>/<voice> 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue