Refactor into a single package
This commit is contained in:
parent
02c3e4c908
commit
5e866e4fca
94 changed files with 198 additions and 2867 deletions
57
mimic3_http/README.md
Normal file
57
mimic3_http/README.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Mimic 3 Web Server
|
||||
|
||||
A small HTTP web server for the [Mimic 3](https://github.com/MycroftAI/mimic3) text to speech system.
|
||||
|
||||
[Available voices](https://github.com/MycroftAI/mimic3-voices)
|
||||
|
||||

|
||||
|
||||
|
||||
## Running the Server
|
||||
|
||||
``` sh
|
||||
mimic3-server
|
||||
```
|
||||
|
||||
This will start a web server at `http://localhost:59125`
|
||||
|
||||
See `mimic3-server --debug` for more options.
|
||||
|
||||
|
||||
### Endpoints
|
||||
|
||||
* `/api/tts`
|
||||
* `POST` text or [SSML](#ssml) and receive WAV audio back
|
||||
* Use `?voice=` to select a different [voice/speaker](#voice-keys)
|
||||
* Set `Content-Type` to `application/ssml+xml` (or use `?ssml=1`) for [SSML](#ssml) input
|
||||
* `/api/voices`
|
||||
* Returns a JSON list of available voices
|
||||
|
||||
An [OpenAPI](https://www.openapis.org/) test page is also available at `http://localhost:59125/openapi`
|
||||
|
||||
|
||||
### CUDA Acceleration
|
||||
|
||||
If you have a GPU with support for CUDA, you can accelerate synthesis with the `--cuda` flag. This requires you to install the [onnxruntime-gpu](https://pypi.org/project/onnxruntime-gpu/) Python package.
|
||||
|
||||
Using [nvidia-docker](https://github.com/NVIDIA/nvidia-docker) is highly recommended. See the `Dockerfile.gpu` file in the parent repository for an example of how to build a compatible container.
|
||||
|
||||
|
||||
## Running the Client
|
||||
|
||||
Assuming you have started `mimic3-server` and can access `http://localhost:59125`, then:
|
||||
|
||||
``` sh
|
||||
mimic3 --remote --voice 'en_UK/apope_low' 'My hovercraft is full of eels.' > hovercraft_eels.wav
|
||||
```
|
||||
|
||||
If your server is somewhere besides `localhost`, use `mimic3 --remote <URL> ...`
|
||||
|
||||
See `mimic3 --help` for more options.
|
||||
|
||||
|
||||
## MaryTTS Compatibility
|
||||
|
||||
Use the Mimic 3 web server as a drop-in replacement for [MaryTTS](http://mary.dfki.de/), for example with [Home Assistant](https://www.home-assistant.io/integrations/marytts/).
|
||||
|
||||
Make sure to use a compatible [voice key](#voice-keys) like `en_UK/apope_low`.
|
||||
1
mimic3_http/VERSION
Normal file
1
mimic3_http/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.1.1
|
||||
18
mimic3_http/__init__.py
Normal file
18
mimic3_http/__init__.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# 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/>.
|
||||
#
|
||||
from ._resources import __version__
|
||||
|
||||
__author__ = "Michael Hansen"
|
||||
86
mimic3_http/__main__.py
Normal file
86
mimic3_http/__main__.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
#!/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 asyncio
|
||||
import logging
|
||||
import tempfile
|
||||
import threading
|
||||
from queue import Queue
|
||||
|
||||
import hypercorn
|
||||
|
||||
from .app import get_app
|
||||
from .args import get_args
|
||||
from .synthesis import do_synthesis_proc
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = get_args(argv)
|
||||
|
||||
if args.debug:
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
# Override epitran
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
else:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
# Override epitran
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
|
||||
_LOGGER.debug(args)
|
||||
|
||||
# Run Web Server
|
||||
_LOGGER.info("Starting web server")
|
||||
request_queue = Queue()
|
||||
threads = [
|
||||
threading.Thread(
|
||||
target=do_synthesis_proc, args=(args, request_queue), daemon=True
|
||||
)
|
||||
for _ in range(args.num_threads)
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
|
||||
hyp_config = hypercorn.config.Config()
|
||||
hyp_config.bind = [f"{args.host}:{args.port}"]
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="mimic3") as temp_dir:
|
||||
app = get_app(args, request_queue, temp_dir)
|
||||
asyncio.run(hypercorn.asyncio.serve(app, hyp_config))
|
||||
finally:
|
||||
# Drain queue
|
||||
while not request_queue.empty():
|
||||
request_queue.get()
|
||||
|
||||
# Stop request threads
|
||||
for _ in range(args.num_threads):
|
||||
request_queue.put(None)
|
||||
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
34
mimic3_http/_resources.py
Normal file
34
mimic3_http/_resources.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# 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/>.
|
||||
#
|
||||
"""Shared access to package resources"""
|
||||
import os
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import importlib.resources
|
||||
|
||||
files = importlib.resources.files
|
||||
except (ImportError, AttributeError):
|
||||
# Backport for Python < 3.9
|
||||
import importlib_resources # type: ignore
|
||||
|
||||
files = importlib_resources.files
|
||||
|
||||
_PACKAGE = "mimic3_http"
|
||||
_DIR = Path(typing.cast(os.PathLike, files(_PACKAGE)))
|
||||
|
||||
__version__ = (_DIR / "VERSION").read_text(encoding="utf-8").strip()
|
||||
279
mimic3_http/app.py
Normal file
279
mimic3_http/app.py
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
#!/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 asyncio
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import typing
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from urllib.parse import parse_qs
|
||||
from uuid import uuid4
|
||||
|
||||
import quart_cors
|
||||
from quart import (
|
||||
Quart,
|
||||
Response,
|
||||
jsonify,
|
||||
render_template,
|
||||
request,
|
||||
send_from_directory,
|
||||
)
|
||||
from swagger_ui import api_doc
|
||||
|
||||
from mimic3_tts import DEFAULT_VOICE, Mimic3Settings, Mimic3TextToSpeechSystem
|
||||
|
||||
from ._resources import _DIR, _PACKAGE
|
||||
from .args import _MISSING
|
||||
from .const import SynthesisRequest, TextToWavParams
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_app(args: argparse.Namespace, request_queue: Queue, temp_dir: str):
|
||||
"""Create and return Quart application for Mimic 3 HTTP server"""
|
||||
|
||||
_TEMP_DIR: typing.Optional[Path] = None
|
||||
|
||||
_MIMIC3 = Mimic3TextToSpeechSystem(
|
||||
Mimic3Settings(voices_directories=args.voices_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)
|
||||
|
||||
async def text_to_wav(params: TextToWavParams, no_cache: bool = False) -> bytes:
|
||||
"""Synthesize text into audio.
|
||||
|
||||
Returns: WAV bytes
|
||||
"""
|
||||
if args.deterministic:
|
||||
# Disable noise
|
||||
_LOGGER.debug("Disabling noise in deterministic mode")
|
||||
params.noise_scale = 0.0
|
||||
params.noise_w = 0.0
|
||||
|
||||
_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
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.create_future()
|
||||
request_queue.put_nowait(
|
||||
SynthesisRequest(
|
||||
params=params,
|
||||
loop=loop,
|
||||
future=future,
|
||||
)
|
||||
)
|
||||
wav_bytes = await future
|
||||
|
||||
if _TEMP_DIR and (not no_cache):
|
||||
# Store in cache
|
||||
wav_path = _TEMP_DIR / f"{params.cache_key}.wav"
|
||||
wav_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
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"}
|
||||
|
||||
class VoiceEncoder(json.JSONEncoder):
|
||||
"""Encode a voice to JSON"""
|
||||
|
||||
def default(self, o):
|
||||
if isinstance(o, set):
|
||||
return list(o)
|
||||
|
||||
return json.JSONEncoder.default(self, o)
|
||||
|
||||
app.json_encoder = VoiceEncoder # type: ignore
|
||||
|
||||
@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)
|
||||
|
||||
show_openapi = True
|
||||
|
||||
@app.route("/")
|
||||
async def app_index():
|
||||
"""Main page."""
|
||||
return await render_template("index.html", show_openapi=show_openapi)
|
||||
|
||||
@app.route("/api/tts", methods=["GET", "POST"])
|
||||
async def app_tts() -> Response:
|
||||
"""Speak text to WAV."""
|
||||
tts_args: typing.Dict[str, typing.Any] = {
|
||||
"length_scale": args.length_scale,
|
||||
"noise_scale": args.noise_scale,
|
||||
"noise_w": args.noise_w,
|
||||
}
|
||||
|
||||
_LOGGER.debug("Request args: %s", request.args)
|
||||
|
||||
voice = request.args.get("voice") or args.voice or DEFAULT_VOICE
|
||||
tts_args["voice"] = str(voice)
|
||||
|
||||
# TTS settings
|
||||
noise_scale = request.args.get("noiseScale")
|
||||
if noise_scale:
|
||||
tts_args["noise_scale"] = float(noise_scale)
|
||||
|
||||
noise_w = request.args.get("noiseW")
|
||||
if noise_w:
|
||||
tts_args["noise_w"] = float(noise_w)
|
||||
|
||||
length_scale = request.args.get("lengthScale")
|
||||
if length_scale:
|
||||
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:
|
||||
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:
|
||||
tts_args["text_language"] = str(text_language)
|
||||
|
||||
# Id used for cache
|
||||
cache_id = request.args.get("cacheId")
|
||||
if cache_id:
|
||||
tts_args["cache_id"] = str(cache_id)
|
||||
|
||||
# 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 = await 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 or DEFAULT_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 = await 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")
|
||||
|
||||
# Swagger UI
|
||||
try:
|
||||
api_doc(
|
||||
app,
|
||||
config_path=_DIR / "swagger.yaml",
|
||||
url_prefix="/openapi",
|
||||
title="Mimic 3",
|
||||
)
|
||||
except Exception:
|
||||
# Fails with PyInstaller for some reason
|
||||
_LOGGER.exception("Error setting up swagger UI page")
|
||||
show_openapi = False
|
||||
|
||||
@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
|
||||
96
mimic3_http/args.py
Normal file
96
mimic3_http/args.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# 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(argv=None) -> argparse.Namespace:
|
||||
"""Parse and return command-line arguments"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog=_PACKAGE, description="Local HTTP web server for Mimic 3"
|
||||
)
|
||||
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(
|
||||
"--cuda",
|
||||
action="store_true",
|
||||
help="Use Onnx CUDA execution provider (requires onnxruntime-gpu)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deterministic",
|
||||
action="store_true",
|
||||
help="Ensure that the same audio is always synthesized from the same text",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-threads",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of synthesis threads (default: 1)",
|
||||
)
|
||||
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(args=argv)
|
||||
|
||||
if args.version:
|
||||
print(__version__)
|
||||
sys.exit(0)
|
||||
|
||||
return args
|
||||
50
mimic3_http/const.py
Normal file
50
mimic3_http/const.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# 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 asyncio
|
||||
import hashlib
|
||||
import typing
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextToWavParams:
|
||||
"""Synthesis parameters used for caching"""
|
||||
|
||||
text: str
|
||||
voice: str
|
||||
noise_scale: float
|
||||
noise_w: float
|
||||
length_scale: float
|
||||
ssml: bool = False
|
||||
text_language: typing.Optional[str] = None
|
||||
cache_id: typing.Optional[str] = None
|
||||
|
||||
@property
|
||||
def cache_key(self) -> str:
|
||||
if self.cache_id:
|
||||
return self.cache_id
|
||||
|
||||
return hashlib.md5(repr(self).encode()).hexdigest()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SynthesisRequest:
|
||||
"""Request to synthesize audio from text"""
|
||||
|
||||
params: TextToWavParams
|
||||
|
||||
loop: asyncio.AbstractEventLoop
|
||||
future: asyncio.Future
|
||||
7
mimic3_http/css/bootstrap.min.css
vendored
Normal file
7
mimic3_http/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
mimic3_http/img/Mimic_color.png
Normal file
BIN
mimic3_http/img/Mimic_color.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5 KiB |
70
mimic3_http/img/Mimic_color.svg
Normal file
70
mimic3_http/img/Mimic_color.svg
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="200"
|
||||
height="200"
|
||||
viewBox="0 0 200 200"
|
||||
version="1.1"
|
||||
id="svg10"
|
||||
sodipodi:docname="Mimic_color.svg"
|
||||
inkscape:export-filename="/home/hansenm/opt/mimic3/mimic3-http/mimic3_http/img/Mimic_color.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
inkscape:version="1.0.2 (e86c870879, 2021-01-15)">
|
||||
<metadata
|
||||
id="metadata14">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title>Mimic_color</dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1351"
|
||||
inkscape:window-height="888"
|
||||
id="namedview12"
|
||||
showgrid="false"
|
||||
inkscape:zoom="2.125"
|
||||
inkscape:cx="112.72998"
|
||||
inkscape:cy="101.76281"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="147"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg10" />
|
||||
<defs
|
||||
id="defs4">
|
||||
<style
|
||||
id="style2">.a{fill:#69deff;}</style>
|
||||
</defs>
|
||||
<title
|
||||
id="title6">Mimic_color</title>
|
||||
<ellipse
|
||||
style="opacity:1;fill:#ffffff;stroke:none;stroke-width:3.77953"
|
||||
id="path839"
|
||||
cx="109.10107"
|
||||
cy="109.12006"
|
||||
rx="58.383949"
|
||||
ry="57.106647" />
|
||||
<path
|
||||
class="a"
|
||||
d="M107.66,25.29A76,76,0,0,0,47,147.06c-3.38,6.08-7.43,11.06-10.16,13.39a52.53,52.53,0,0,1-12,7.63c-4.05,2-6.81,2.54-7.7,4.32A2.66,2.66,0,0,0,18.77,176a29.46,29.46,0,0,0,8.45,1.19,106.15,106.15,0,0,0,16.18-1,132.92,132.92,0,0,0,30-7.13,76,76,0,1,0,34.3-143.77Zm-18.18,95a12,12,0,0,1-24,0V87.34a12,12,0,0,1,24,0Zm30.68,0a12,12,0,1,1-24,0v-18a12,12,0,1,1,24,0Zm30.69,0a12,12,0,0,1-24,0V87.34a12,12,0,0,1,24,0Z"
|
||||
id="path8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
BIN
mimic3_http/img/Mycroft_logo_two_typeonly.png
Normal file
BIN
mimic3_http/img/Mycroft_logo_two_typeonly.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
mimic3_http/img/favicon.png
Normal file
BIN
mimic3_http/img/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 912 B |
0
mimic3_http/py.typed
Normal file
0
mimic3_http/py.typed
Normal file
117
mimic3_http/swagger.yaml
Normal file
117
mimic3_http/swagger.yaml
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
openapi: "3.0.0"
|
||||
info:
|
||||
title: 'Mimic 3'
|
||||
version: '0.1'
|
||||
description: 'A fast and local neural text to speech system for Mycroft'
|
||||
schemes:
|
||||
- http
|
||||
servers:
|
||||
- url: http://localhost:59125
|
||||
description: Local server
|
||||
paths:
|
||||
/api/tts:
|
||||
get:
|
||||
summary: 'Speak text to WAV'
|
||||
parameters:
|
||||
- in: query
|
||||
name: text
|
||||
required: true
|
||||
description: 'Text to speak'
|
||||
schema:
|
||||
type: string
|
||||
example: 'Welcome to the world of speech synthesis!'
|
||||
- in: query
|
||||
name: voice
|
||||
description: 'Voice in the form <lang>/<name>_<quality> optional with #<speaker> at the end'
|
||||
schema:
|
||||
type: string
|
||||
example: 'en_UK/apope_low'
|
||||
- in: query
|
||||
name: noiseScale
|
||||
description: 'Volatility of speaker (0-1, default: 0.667)'
|
||||
schema:
|
||||
type: number
|
||||
example: 0.667
|
||||
- in: query
|
||||
name: noiseW
|
||||
description: 'Volatility of individual phonemes (0-1, default: 0.667)'
|
||||
schema:
|
||||
type: number
|
||||
example: 0.8
|
||||
- in: query
|
||||
name: lengthScale
|
||||
description: 'Speed of speaker (default: 1.0, faster < 1 < slower)'
|
||||
schema:
|
||||
type: number
|
||||
example: 1.0
|
||||
- in: query
|
||||
name: ssml
|
||||
description: 'Input text is SSML'
|
||||
schema:
|
||||
type: boolean
|
||||
example: false
|
||||
produces:
|
||||
- audio/wav
|
||||
responses:
|
||||
'200':
|
||||
description: audio
|
||||
schema:
|
||||
type: binary
|
||||
post:
|
||||
summary: 'Speak text to WAV'
|
||||
requestBody:
|
||||
required: true
|
||||
description: 'Text to speak'
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
example: 'Welcome to the world of speech synthesis!'
|
||||
parameters:
|
||||
- in: query
|
||||
name: voice
|
||||
description: 'Voice in the form <lang>/<name>_<quality> optional with #<speaker> at the end'
|
||||
schema:
|
||||
type: string
|
||||
example: 'en_UK/apope_low'
|
||||
- in: query
|
||||
name: noiseScale
|
||||
description: 'Volatility of speaker (0-1, default: 0.667)'
|
||||
schema:
|
||||
type: number
|
||||
example: 0.667
|
||||
- in: query
|
||||
name: noiseW
|
||||
description: 'Volatility of individual phonemes (0-1, default: 0.667)'
|
||||
schema:
|
||||
type: number
|
||||
example: 0.8
|
||||
- in: query
|
||||
name: lengthScale
|
||||
description: 'Speed of speaker (default: 1.0, faster < 1 < slower)'
|
||||
schema:
|
||||
type: number
|
||||
example: 1.0
|
||||
- in: query
|
||||
name: ssml
|
||||
description: 'Input text is SSML'
|
||||
schema:
|
||||
type: boolean
|
||||
example: false
|
||||
produces:
|
||||
- audio/wav
|
||||
responses:
|
||||
'200':
|
||||
description: audio
|
||||
schema:
|
||||
type: binary
|
||||
/api/voices:
|
||||
get:
|
||||
summary: 'Get available voices'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
'200':
|
||||
description: voices
|
||||
schema:
|
||||
type: object
|
||||
125
mimic3_http/synthesis.py
Normal file
125
mimic3_http/synthesis.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import threading
|
||||
import typing
|
||||
import wave
|
||||
from queue import Queue
|
||||
|
||||
from mimic3_tts import (
|
||||
AudioResult,
|
||||
Mimic3Settings,
|
||||
Mimic3TextToSpeechSystem,
|
||||
SSMLSpeaker,
|
||||
)
|
||||
|
||||
from .const import SynthesisRequest
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def do_synthesis(item: SynthesisRequest, mimic3: Mimic3TextToSpeechSystem) -> bytes:
|
||||
"""Synthesize text into audio.
|
||||
|
||||
Returns: WAV bytes
|
||||
"""
|
||||
params = item.params
|
||||
mimic3.speaker = None
|
||||
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()
|
||||
|
||||
return wav_bytes
|
||||
|
||||
|
||||
def do_synthesis_proc(args: argparse.Namespace, request_queue: Queue):
|
||||
"""Thread handler for synthesis requests"""
|
||||
try:
|
||||
# Load Mimic 3
|
||||
mimic3 = Mimic3TextToSpeechSystem(
|
||||
Mimic3Settings(
|
||||
voice=args.voice,
|
||||
speaker=args.speaker,
|
||||
length_scale=args.length_scale,
|
||||
noise_scale=args.noise_scale,
|
||||
noise_w=args.noise_w,
|
||||
use_cuda=args.cuda,
|
||||
voices_directories=args.voices_dir,
|
||||
use_deterministic_compute=args.deterministic,
|
||||
)
|
||||
)
|
||||
|
||||
with mimic3:
|
||||
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)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Started inference thread %s", threading.current_thread().ident
|
||||
)
|
||||
|
||||
while True:
|
||||
item = request_queue.get()
|
||||
if item is None:
|
||||
# Exit signal
|
||||
break
|
||||
|
||||
item = typing.cast(SynthesisRequest, item)
|
||||
|
||||
try:
|
||||
result = do_synthesis(item, mimic3)
|
||||
|
||||
# Set result on main loop
|
||||
item.loop.call_soon_threadsafe(item.future.set_result, result)
|
||||
except Exception as e:
|
||||
_LOGGER.exception("Error during inference")
|
||||
|
||||
# Signal error on main loop
|
||||
asyncio.get_event_loop().call_soon_threadsafe(
|
||||
item.future.set_exception, e
|
||||
)
|
||||
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error in inference thread")
|
||||
254
mimic3_http/templates/index.html
Normal file
254
mimic3_http/templates/index.html
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="description" content="Mimic 3 text to speech server">
|
||||
<meta name="author" content="Michael Hansen">
|
||||
<link rel="icon" type="image/png" href="img/favicon.png" />
|
||||
|
||||
<title>Mimic 3</title>
|
||||
|
||||
<!-- Bootstrap core CSS -->
|
||||
<link href="css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<!-- Custom styles for this template -->
|
||||
<style>
|
||||
body {
|
||||
padding-top: 0;
|
||||
}
|
||||
@media (min-width: 992px) {
|
||||
body {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#mimic-logo {
|
||||
height: 5rem;
|
||||
}
|
||||
|
||||
#mycroft-logo {
|
||||
height: 2rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Page Content -->
|
||||
<div id="main" class="container">
|
||||
<div class="row">
|
||||
<div class="col-lg-12 text-center">
|
||||
<h1>
|
||||
<img id="mimic-logo" src="img/Mimic_color.png" />
|
||||
Mimic 3
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col">
|
||||
<textarea id="text" placeholder="Type here..." class="form-control" rows="3" name="text" alt="Text to generate speech from"></textarea>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button id="speak-button" name="speak" class="btn btn-lg btn-primary" alt="Generate speech">Speak</button>
|
||||
|
||||
{% if show_openapi %}
|
||||
<br/><br />
|
||||
<a href="/openapi/" title="OpenAPI page" target="_blank" class="badge badge-info">API</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-auto">
|
||||
<label for="voice-list" title="Voice name">Voice:</label>
|
||||
<select id="voice-list" name="voices">
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label for="speaker" title="Name of speaker">Speaker:</label>
|
||||
<select id="speaker-list" name="speaker">
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="checkbox" id="ssml">
|
||||
<label class="ml-1" for="ssml">SSML</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="audio-message" class="row mt-3" hidden>
|
||||
<div class="col">
|
||||
<audio id="audio" preload="none" controls autoplay hidden></audio>
|
||||
<p id="message"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-auto">
|
||||
<label for="noise-scale" title="Voice volatility">Noise:</label>
|
||||
<input type="number" id="noise-scale" name="noiseScale" size="5" min="0" max="1" step="0.001" value="0.667">
|
||||
<label for="noise-w" class="ml-2" title="Voice volatility 2">Noise W:</label>
|
||||
<input type="number" id="noise-w" name="noiseW" size="5" min="0" max="1" step="0.001" value="0.8">
|
||||
<label for="length-scale" class="ml-2" title="Voice speed (< 1 is faster)">Length:</label>
|
||||
<input type="number" id="length-scale" name="lengthScale" size="5" min="0" step="0.001" value="1">
|
||||
</div>
|
||||
</div>
|
||||
<hr class="mt-5" />
|
||||
<div class="row mt-5 justify-content-center">
|
||||
<a href="https://mycroft.ai" title="Mycroft AI">
|
||||
<img id="mycroft-logo" src="img/Mycroft_logo_two_typeonly.png" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="row mt-3 justify-content-center">
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0.en.html" title="AGPLv3">License</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap core JavaScript -->
|
||||
<script>
|
||||
var voicesInfo = {}
|
||||
|
||||
function q(selector) {return document.querySelector(selector)}
|
||||
q('#text').focus()
|
||||
|
||||
function do_tts(e) {
|
||||
text = q('#text').value
|
||||
if (text) {
|
||||
q('#message').textContent = 'Synthesizing...'
|
||||
q('#speak-button').disabled = true
|
||||
q('#audio').hidden = true
|
||||
synthesize(text)
|
||||
}
|
||||
e.preventDefault()
|
||||
return false
|
||||
}
|
||||
|
||||
q('#speak-button').addEventListener('click', do_tts)
|
||||
|
||||
async function synthesize(text) {
|
||||
var voiceList = q('#voice-list')
|
||||
var voice = voiceList.options[voiceList.selectedIndex].value
|
||||
|
||||
var noiseScale = q('#noise-scale').value || '0.667'
|
||||
var noiseW = q('#noise-w').value || '0.8'
|
||||
var lengthScale = q('#length-scale').value || '1.0'
|
||||
|
||||
var speakerList = q('#speaker-list')
|
||||
var speaker = speakerList.options[speakerList.selectedIndex].value
|
||||
if (speaker.length > 0) {
|
||||
voice = voice + "#" + speaker
|
||||
}
|
||||
|
||||
var ssml = q('#ssml').value || 'false'
|
||||
|
||||
q('#audio-message').hidden = false
|
||||
|
||||
var startTime = performance.now()
|
||||
|
||||
res = await fetch(
|
||||
'api/tts?text=' + encodeURIComponent(text) +
|
||||
'&voice=' + encodeURIComponent(voice) +
|
||||
'&noiseScale=' + encodeURIComponent(noiseScale) +
|
||||
'&noiseW=' + encodeURIComponent(noiseW) +
|
||||
'&lengthScale=' + encodeURIComponent(lengthScale) +
|
||||
'&ssml=' + encodeURIComponent(ssml),
|
||||
{cache: 'no-cache'})
|
||||
|
||||
if (res.ok) {
|
||||
blob = await res.blob()
|
||||
var elapsedTime = performance.now() - startTime
|
||||
|
||||
q('#message').textContent = (elapsedTime / 1000) + ' second(s)'
|
||||
q('#speak-button').disabled = false
|
||||
q('#audio').src = URL.createObjectURL(blob)
|
||||
q('#audio').hidden = false
|
||||
} else {
|
||||
message = await res.text()
|
||||
q('#message').textContent = message
|
||||
q('#speak-button').disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
function voiceChanged() {
|
||||
var voiceList = q('#voice-list')
|
||||
|
||||
// Reset audio
|
||||
q('#audio-message').hidden = true
|
||||
q('#message').textContent = ''
|
||||
q('#audio').hidden = true
|
||||
q('#audio').autoplay = true
|
||||
|
||||
// Reset speakers
|
||||
var speakerList = q('#speaker-list')
|
||||
for (var i = speakerList.options.length - 1; i >= 0; i--) {
|
||||
speakerList.options[i].remove()
|
||||
}
|
||||
|
||||
var voiceKey = voiceList.options[voiceList.selectedIndex].value
|
||||
var voice = voicesInfo[voiceKey]
|
||||
|
||||
if (voice.speakers && voice.speakers.length > 0) {
|
||||
voice.speakers.forEach(function(speaker) {
|
||||
speakerList.insertAdjacentHTML(
|
||||
'beforeend', '<option value="' + speaker + '">' + speaker + '</option>'
|
||||
)
|
||||
})
|
||||
|
||||
} else {
|
||||
// Add default speaker
|
||||
speakerList.insertAdjacentHTML(
|
||||
'beforeend', '<option value="">default</option>'
|
||||
)
|
||||
}
|
||||
|
||||
// Update inference settings
|
||||
if (voice.properties) {
|
||||
q('#length-scale').value = voice.properties.length_scale || 1.0
|
||||
q('#noise-scale').value = voice.properties.noise_scale || 0.667
|
||||
q('#noise-w').value = voice.properties.noise_w || 0.8
|
||||
}
|
||||
}
|
||||
|
||||
q('#voice-list').addEventListener('change', voiceChanged)
|
||||
|
||||
function loadVoices() {
|
||||
voicesInfo = {}
|
||||
|
||||
// Remove previous voices
|
||||
var voiceList = q('#voice-list')
|
||||
for (var i = voiceList.options.length - 1; i >= 0; i--) {
|
||||
voiceList.options[i].remove()
|
||||
}
|
||||
|
||||
fetch('api/voices')
|
||||
.then(function(res) {
|
||||
if (!res.ok) throw Error(res.statusText)
|
||||
return res.json()
|
||||
}).then(function(voices) {
|
||||
voicesInfo = {}
|
||||
|
||||
// Populate select
|
||||
var indexToSelect = -1
|
||||
|
||||
voices.forEach(function(voice) {
|
||||
voicesInfo[voice.key] = voice
|
||||
voiceList.insertAdjacentHTML(
|
||||
'beforeend', '<option value="' + voice.key + '">' + voice.language + '/' + voice.name + '</option>'
|
||||
)
|
||||
})
|
||||
|
||||
voiceChanged()
|
||||
}).catch(function(err) {
|
||||
q('#message').textContent = 'Error: ' + err.message
|
||||
q('#speak-button').disabled = false
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('load', function() {
|
||||
loadVoices()
|
||||
})
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue