wyoming-piper 1.3.2__tar.gz → 1.5.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wyoming_piper-1.5.3/LICENSE.md +21 -0
- wyoming_piper-1.5.3/PKG-INFO +73 -0
- wyoming_piper-1.5.3/README.md +39 -0
- wyoming_piper-1.5.3/pyproject.toml +69 -0
- wyoming_piper-1.5.3/setup.cfg +21 -0
- wyoming_piper-1.5.3/tests/test_piper.py +123 -0
- wyoming_piper-1.5.3/wyoming_piper/__init__.py +6 -0
- {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper/__main__.py +52 -17
- {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper/download.py +36 -18
- {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper/handler.py +10 -0
- {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper/voices.json +2687 -188
- wyoming_piper-1.5.3/wyoming_piper.egg-info/PKG-INFO +73 -0
- {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper.egg-info/SOURCES.txt +6 -3
- wyoming_piper-1.5.3/wyoming_piper.egg-info/entry_points.txt +2 -0
- wyoming_piper-1.5.3/wyoming_piper.egg-info/requires.txt +14 -0
- wyoming_piper-1.3.2/MANIFEST.in +0 -2
- wyoming_piper-1.3.2/PKG-INFO +0 -17
- wyoming_piper-1.3.2/requirements.txt +0 -1
- wyoming_piper-1.3.2/setup.cfg +0 -4
- wyoming_piper-1.3.2/setup.py +0 -44
- wyoming_piper-1.3.2/wyoming_piper/__init__.py +0 -1
- wyoming_piper-1.3.2/wyoming_piper.egg-info/PKG-INFO +0 -17
- wyoming_piper-1.3.2/wyoming_piper.egg-info/requires.txt +0 -1
- {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper/const.py +0 -0
- {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper/file_hash.py +0 -0
- {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper/process.py +0 -0
- {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper.egg-info/dependency_links.txt +0 -0
- {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Michael Hansen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wyoming-piper
|
|
3
|
+
Version: 1.5.3
|
|
4
|
+
Summary: Wyoming Server for Piper
|
|
5
|
+
Author-email: Michael Hansen <mike@rhasspy.org>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, http://github.com/rhasspy/wyoming-piper
|
|
8
|
+
Keywords: rhasspy,wyoming,piper,tts
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Requires-Python: <3.13,>=3.8.1
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE.md
|
|
20
|
+
Requires-Dist: wyoming>=1.5.3
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: black==22.12.0; extra == "dev"
|
|
23
|
+
Requires-Dist: flake8==6.0.0; extra == "dev"
|
|
24
|
+
Requires-Dist: isort==5.11.3; extra == "dev"
|
|
25
|
+
Requires-Dist: mypy==0.991; extra == "dev"
|
|
26
|
+
Requires-Dist: pylint==2.15.9; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest==7.4.4; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio==0.23.3; extra == "dev"
|
|
29
|
+
Requires-Dist: build==1.2.2.post1; extra == "dev"
|
|
30
|
+
Requires-Dist: scipy<2,>=1.10; extra == "dev"
|
|
31
|
+
Requires-Dist: numpy<2,>=1.20; extra == "dev"
|
|
32
|
+
Requires-Dist: python-speech-features==0.6; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# Wyoming Piper
|
|
36
|
+
|
|
37
|
+
[Wyoming protocol](https://github.com/rhasspy/wyoming) server for the [Piper](https://github.com/rhasspy/piper/) text to speech system.
|
|
38
|
+
|
|
39
|
+
## Home Assistant Add-on
|
|
40
|
+
|
|
41
|
+
[](https://my.home-assistant.io/redirect/supervisor_addon/?addon=core_piper)
|
|
42
|
+
|
|
43
|
+
[Source](https://github.com/home-assistant/addons/tree/master/piper)
|
|
44
|
+
|
|
45
|
+
## Local Install
|
|
46
|
+
|
|
47
|
+
Clone the repository and set up Python virtual environment:
|
|
48
|
+
|
|
49
|
+
``` sh
|
|
50
|
+
git clone https://github.com/rhasspy/wyoming-piper.git
|
|
51
|
+
cd wyoming-piper
|
|
52
|
+
script/setup
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Install Piper
|
|
56
|
+
```sh
|
|
57
|
+
curl -L -s "https://github.com/rhasspy/piper/releases/download/v1.2.0/piper_amd64.tar.gz" | tar -zxvf - -C /usr/share
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Run a server that anyone can connect to:
|
|
61
|
+
|
|
62
|
+
``` sh
|
|
63
|
+
script/run --piper '/usr/share/piper/piper' --voice en_US-lessac-medium --uri 'tcp://0.0.0.0:10200' --data-dir /data --download-dir /data
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Docker Image
|
|
67
|
+
|
|
68
|
+
``` sh
|
|
69
|
+
docker run -it -p 10200:10200 -v /path/to/local/data:/data rhasspy/wyoming-piper \
|
|
70
|
+
--voice en_US-lessac-medium
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
[Source](https://github.com/rhasspy/wyoming-addons/tree/master/piper)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Wyoming Piper
|
|
2
|
+
|
|
3
|
+
[Wyoming protocol](https://github.com/rhasspy/wyoming) server for the [Piper](https://github.com/rhasspy/piper/) text to speech system.
|
|
4
|
+
|
|
5
|
+
## Home Assistant Add-on
|
|
6
|
+
|
|
7
|
+
[](https://my.home-assistant.io/redirect/supervisor_addon/?addon=core_piper)
|
|
8
|
+
|
|
9
|
+
[Source](https://github.com/home-assistant/addons/tree/master/piper)
|
|
10
|
+
|
|
11
|
+
## Local Install
|
|
12
|
+
|
|
13
|
+
Clone the repository and set up Python virtual environment:
|
|
14
|
+
|
|
15
|
+
``` sh
|
|
16
|
+
git clone https://github.com/rhasspy/wyoming-piper.git
|
|
17
|
+
cd wyoming-piper
|
|
18
|
+
script/setup
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Install Piper
|
|
22
|
+
```sh
|
|
23
|
+
curl -L -s "https://github.com/rhasspy/piper/releases/download/v1.2.0/piper_amd64.tar.gz" | tar -zxvf - -C /usr/share
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Run a server that anyone can connect to:
|
|
27
|
+
|
|
28
|
+
``` sh
|
|
29
|
+
script/run --piper '/usr/share/piper/piper' --voice en_US-lessac-medium --uri 'tcp://0.0.0.0:10200' --data-dir /data --download-dir /data
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Docker Image
|
|
33
|
+
|
|
34
|
+
``` sh
|
|
35
|
+
docker run -it -p 10200:10200 -v /path/to/local/data:/data rhasspy/wyoming-piper \
|
|
36
|
+
--voice en_US-lessac-medium
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
[Source](https://github.com/rhasspy/wyoming-addons/tree/master/piper)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "wyoming-piper"
|
|
3
|
+
version = "1.5.3"
|
|
4
|
+
description = "Wyoming Server for Piper"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.8.1,<3.13"
|
|
7
|
+
license = {text = "MIT"}
|
|
8
|
+
authors = [
|
|
9
|
+
{name = "Michael Hansen", email = "mike@rhasspy.org"}
|
|
10
|
+
]
|
|
11
|
+
keywords = ["rhasspy", "wyoming", "piper", "tts"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Topic :: Text Processing :: Linguistic",
|
|
16
|
+
"Programming Language :: Python :: 3.8",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"wyoming>=1.5.3",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "http://github.com/rhasspy/wyoming-piper"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
wyoming-piper = "wyoming_piper.__main__:run"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
include = ["wyoming_piper"]
|
|
34
|
+
exclude = ["tests", "tests.*"]
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.package-data]
|
|
37
|
+
wyoming_piper = ["voices.json"]
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["setuptools>=42", "wheel"]
|
|
41
|
+
build-backend = "setuptools.build_meta"
|
|
42
|
+
|
|
43
|
+
[tool.black]
|
|
44
|
+
line-length = 88
|
|
45
|
+
|
|
46
|
+
[tool.isort]
|
|
47
|
+
profile = "black"
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
asyncio_mode = "auto"
|
|
51
|
+
|
|
52
|
+
[tool.mypy]
|
|
53
|
+
check_untyped_defs = true
|
|
54
|
+
disallow_untyped_defs = true
|
|
55
|
+
|
|
56
|
+
[project.optional-dependencies]
|
|
57
|
+
dev = [
|
|
58
|
+
"black==22.12.0",
|
|
59
|
+
"flake8==6.0.0",
|
|
60
|
+
"isort==5.11.3",
|
|
61
|
+
"mypy==0.991",
|
|
62
|
+
"pylint==2.15.9",
|
|
63
|
+
"pytest==7.4.4",
|
|
64
|
+
"pytest-asyncio==0.23.3",
|
|
65
|
+
"build==1.2.2.post1",
|
|
66
|
+
"scipy>=1.10,<2",
|
|
67
|
+
"numpy>=1.20,<2",
|
|
68
|
+
"python-speech-features==0.6",
|
|
69
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[flake8]
|
|
2
|
+
max-line-length = 88
|
|
3
|
+
ignore =
|
|
4
|
+
E501,
|
|
5
|
+
W503,
|
|
6
|
+
E203,
|
|
7
|
+
D202,
|
|
8
|
+
W504
|
|
9
|
+
|
|
10
|
+
[isort]
|
|
11
|
+
multi_line_output = 3
|
|
12
|
+
include_trailing_comma = True
|
|
13
|
+
force_grid_wrap = 0
|
|
14
|
+
use_parentheses = True
|
|
15
|
+
line_length = 88
|
|
16
|
+
indent = " "
|
|
17
|
+
|
|
18
|
+
[egg_info]
|
|
19
|
+
tag_build =
|
|
20
|
+
tag_date = 0
|
|
21
|
+
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Tests for wyoming-piper"""
|
|
2
|
+
import asyncio
|
|
3
|
+
import sys
|
|
4
|
+
import tarfile
|
|
5
|
+
import wave
|
|
6
|
+
from asyncio.subprocess import PIPE
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.request import urlopen
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pytest
|
|
12
|
+
import python_speech_features
|
|
13
|
+
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
|
14
|
+
from wyoming.event import async_read_event, async_write_event
|
|
15
|
+
from wyoming.info import Describe, Info
|
|
16
|
+
from wyoming.tts import Synthesize, SynthesizeVoice
|
|
17
|
+
|
|
18
|
+
from .dtw import compute_optimal_path
|
|
19
|
+
|
|
20
|
+
_DIR = Path(__file__).parent
|
|
21
|
+
_LOCAL_DIR = _DIR.parent / "local"
|
|
22
|
+
_PIPER_URL = (
|
|
23
|
+
"https://github.com/rhasspy/piper/releases/download/v1.2.0/piper_amd64.tar.gz"
|
|
24
|
+
)
|
|
25
|
+
_TIMEOUT = 60
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def download_piper() -> None:
|
|
29
|
+
"""Downloads a binary version of Piper."""
|
|
30
|
+
piper_path = _LOCAL_DIR / "piper"
|
|
31
|
+
if piper_path.exists():
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
_LOCAL_DIR.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
with urlopen(_PIPER_URL) as response:
|
|
36
|
+
with tarfile.open(fileobj=response, mode="r|*") as piper_file:
|
|
37
|
+
piper_file.extractall(_LOCAL_DIR)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.asyncio
|
|
41
|
+
async def test_piper() -> None:
|
|
42
|
+
download_piper()
|
|
43
|
+
|
|
44
|
+
proc = await asyncio.create_subprocess_exec(
|
|
45
|
+
sys.executable,
|
|
46
|
+
"-m",
|
|
47
|
+
"wyoming_piper",
|
|
48
|
+
"--uri",
|
|
49
|
+
"stdio://",
|
|
50
|
+
"--piper",
|
|
51
|
+
str(_LOCAL_DIR / "piper" / "piper"),
|
|
52
|
+
"--voice",
|
|
53
|
+
"en_US-ryan-low",
|
|
54
|
+
"--data-dir",
|
|
55
|
+
str(_LOCAL_DIR),
|
|
56
|
+
stdin=PIPE,
|
|
57
|
+
stdout=PIPE,
|
|
58
|
+
)
|
|
59
|
+
assert proc.stdin is not None
|
|
60
|
+
assert proc.stdout is not None
|
|
61
|
+
|
|
62
|
+
# Check info
|
|
63
|
+
await async_write_event(Describe().event(), proc.stdin)
|
|
64
|
+
while True:
|
|
65
|
+
event = await asyncio.wait_for(async_read_event(proc.stdout), timeout=_TIMEOUT)
|
|
66
|
+
assert event is not None
|
|
67
|
+
|
|
68
|
+
if not Info.is_type(event.type):
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
info = Info.from_event(event)
|
|
72
|
+
assert len(info.tts) == 1, "Expected one tts service"
|
|
73
|
+
tts = info.tts[0]
|
|
74
|
+
assert len(tts.voices) > 0, "Expected at least one voice"
|
|
75
|
+
voice_model = next((v for v in tts.voices if v.name == "en_US-ryan-low"), None)
|
|
76
|
+
assert voice_model is not None, "Expected ryan voice"
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
# Synthesize text
|
|
80
|
+
await async_write_event(
|
|
81
|
+
Synthesize("This is a test.", voice=SynthesizeVoice("en_US-ryan-low")).event(),
|
|
82
|
+
proc.stdin,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
event = await asyncio.wait_for(async_read_event(proc.stdout), timeout=_TIMEOUT)
|
|
86
|
+
assert event is not None
|
|
87
|
+
assert AudioStart.is_type(event.type)
|
|
88
|
+
audio_start = AudioStart.from_event(event)
|
|
89
|
+
|
|
90
|
+
with wave.open(str(_DIR / "this_is_a_test.wav"), "rb") as wav_file:
|
|
91
|
+
assert audio_start.rate == wav_file.getframerate()
|
|
92
|
+
assert audio_start.width == wav_file.getsampwidth()
|
|
93
|
+
assert audio_start.channels == wav_file.getnchannels()
|
|
94
|
+
expected_audio = wav_file.readframes(wav_file.getnframes())
|
|
95
|
+
expected_array = np.frombuffer(expected_audio, dtype=np.int16)
|
|
96
|
+
|
|
97
|
+
actual_audio = bytes()
|
|
98
|
+
while True:
|
|
99
|
+
event = await asyncio.wait_for(async_read_event(proc.stdout), timeout=_TIMEOUT)
|
|
100
|
+
assert event is not None
|
|
101
|
+
if AudioStop.is_type(event.type):
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
if AudioChunk.is_type(event.type):
|
|
105
|
+
chunk = AudioChunk.from_event(event)
|
|
106
|
+
assert chunk.rate == audio_start.rate
|
|
107
|
+
assert chunk.width == audio_start.width
|
|
108
|
+
assert chunk.channels == audio_start.channels
|
|
109
|
+
actual_audio += chunk.audio
|
|
110
|
+
|
|
111
|
+
actual_array = np.frombuffer(actual_audio, dtype=np.int16)
|
|
112
|
+
|
|
113
|
+
# Less than 20% difference in length
|
|
114
|
+
assert (
|
|
115
|
+
abs(len(actual_array) - len(expected_array))
|
|
116
|
+
/ max(len(actual_array), len(expected_array))
|
|
117
|
+
< 0.2
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Compute dynamic time warping (DTW) distance of MFCC features
|
|
121
|
+
expected_mfcc = python_speech_features.mfcc(expected_array, winstep=0.02)
|
|
122
|
+
actual_mfcc = python_speech_features.mfcc(actual_array, winstep=0.02)
|
|
123
|
+
assert compute_optimal_path(actual_mfcc, expected_mfcc) < 10
|
|
@@ -7,9 +7,10 @@ from functools import partial
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Any, Dict, Set
|
|
9
9
|
|
|
10
|
-
from wyoming.info import Attribution, Info, TtsProgram, TtsVoice
|
|
10
|
+
from wyoming.info import Attribution, Info, TtsProgram, TtsVoice, TtsVoiceSpeaker
|
|
11
11
|
from wyoming.server import AsyncServer
|
|
12
12
|
|
|
13
|
+
from . import __version__
|
|
13
14
|
from .download import find_voice, get_voices
|
|
14
15
|
from .handler import PiperEventHandler
|
|
15
16
|
from .process import PiperProcessManager
|
|
@@ -67,13 +68,25 @@ async def main() -> None:
|
|
|
67
68
|
)
|
|
68
69
|
#
|
|
69
70
|
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--log-format", default=logging.BASIC_FORMAT, help="Format for log messages"
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--version",
|
|
76
|
+
action="version",
|
|
77
|
+
version=__version__,
|
|
78
|
+
help="Print version and exit",
|
|
79
|
+
)
|
|
70
80
|
args = parser.parse_args()
|
|
71
81
|
|
|
72
82
|
if not args.download_dir:
|
|
73
83
|
# Default to first data directory
|
|
74
84
|
args.download_dir = args.data_dir[0]
|
|
75
85
|
|
|
76
|
-
logging.basicConfig(
|
|
86
|
+
logging.basicConfig(
|
|
87
|
+
level=logging.DEBUG if args.debug else logging.INFO, format=args.log_format
|
|
88
|
+
)
|
|
89
|
+
_LOGGER.debug(args)
|
|
77
90
|
|
|
78
91
|
# Load voice info
|
|
79
92
|
voices_info = get_voices(args.download_dir, update_voices=args.update_voices)
|
|
@@ -93,15 +106,19 @@ async def main() -> None:
|
|
|
93
106
|
name="rhasspy", url="https://github.com/rhasspy/piper"
|
|
94
107
|
),
|
|
95
108
|
installed=True,
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
109
|
+
version=None,
|
|
110
|
+
languages=[
|
|
111
|
+
voice_info.get("language", {}).get(
|
|
112
|
+
"code",
|
|
113
|
+
voice_info.get("espeak", {}).get("voice", voice_name.split("_")[0]),
|
|
114
|
+
)
|
|
115
|
+
],
|
|
116
|
+
speakers=[
|
|
117
|
+
TtsVoiceSpeaker(name=speaker_name)
|
|
118
|
+
for speaker_name in voice_info["speaker_id_map"]
|
|
119
|
+
]
|
|
120
|
+
if voice_info.get("speaker_id_map")
|
|
121
|
+
else None,
|
|
105
122
|
)
|
|
106
123
|
for voice_name, voice_info in voices_info.items()
|
|
107
124
|
if not voice_info.get("_is_alias", False)
|
|
@@ -123,20 +140,32 @@ async def main() -> None:
|
|
|
123
140
|
|
|
124
141
|
for custom_voice_name in custom_voice_names:
|
|
125
142
|
# Add custom voice info
|
|
126
|
-
|
|
143
|
+
custom_voice_path, custom_config_path = find_voice(
|
|
127
144
|
custom_voice_name, args.data_dir
|
|
128
145
|
)
|
|
129
146
|
with open(custom_config_path, "r", encoding="utf-8") as custom_config_file:
|
|
130
147
|
custom_config = json.load(custom_config_file)
|
|
131
|
-
custom_name = custom_config
|
|
132
|
-
custom_quality = custom_config
|
|
148
|
+
custom_name = custom_config.get("dataset", custom_voice_path.stem)
|
|
149
|
+
custom_quality = custom_config.get("audio", {}).get("quality")
|
|
150
|
+
if custom_quality:
|
|
151
|
+
description = f"{custom_name} ({custom_quality})"
|
|
152
|
+
else:
|
|
153
|
+
description = custom_name
|
|
154
|
+
|
|
155
|
+
lang_code = custom_config.get("language", {}).get("code")
|
|
156
|
+
if not lang_code:
|
|
157
|
+
lang_code = custom_config.get("espeak", {}).get("voice")
|
|
158
|
+
if not lang_code:
|
|
159
|
+
lang_code = custom_voice_path.stem.split("_")[0]
|
|
160
|
+
|
|
133
161
|
voices.append(
|
|
134
162
|
TtsVoice(
|
|
135
163
|
name=custom_name,
|
|
136
|
-
description=
|
|
164
|
+
description=description,
|
|
165
|
+
version=None,
|
|
137
166
|
attribution=Attribution(name="", url=""),
|
|
138
167
|
installed=True,
|
|
139
|
-
languages=[
|
|
168
|
+
languages=[lang_code],
|
|
140
169
|
)
|
|
141
170
|
)
|
|
142
171
|
|
|
@@ -150,6 +179,7 @@ async def main() -> None:
|
|
|
150
179
|
),
|
|
151
180
|
installed=True,
|
|
152
181
|
voices=sorted(voices, key=lambda v: v.name),
|
|
182
|
+
version=__version__,
|
|
153
183
|
)
|
|
154
184
|
],
|
|
155
185
|
)
|
|
@@ -188,8 +218,13 @@ def get_description(voice_info: Dict[str, Any]):
|
|
|
188
218
|
|
|
189
219
|
# -----------------------------------------------------------------------------
|
|
190
220
|
|
|
221
|
+
|
|
222
|
+
def run():
|
|
223
|
+
asyncio.run(main())
|
|
224
|
+
|
|
225
|
+
|
|
191
226
|
if __name__ == "__main__":
|
|
192
227
|
try:
|
|
193
|
-
|
|
228
|
+
run()
|
|
194
229
|
except KeyboardInterrupt:
|
|
195
230
|
pass
|
|
@@ -4,6 +4,8 @@ import logging
|
|
|
4
4
|
import shutil
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from typing import Any, Dict, Iterable, Set, Tuple, Union
|
|
7
|
+
from urllib.error import URLError
|
|
8
|
+
from urllib.parse import quote, urlsplit, urlunsplit
|
|
7
9
|
from urllib.request import urlopen
|
|
8
10
|
|
|
9
11
|
from .file_hash import get_file_hash
|
|
@@ -20,6 +22,13 @@ class VoiceNotFoundError(Exception):
|
|
|
20
22
|
pass
|
|
21
23
|
|
|
22
24
|
|
|
25
|
+
def _quote_url(url: str) -> str:
|
|
26
|
+
"""Quote file part of URL in case it contains UTF-8 characters."""
|
|
27
|
+
parts = list(urlsplit(url))
|
|
28
|
+
parts[2] = quote(parts[2])
|
|
29
|
+
return urlunsplit(parts)
|
|
30
|
+
|
|
31
|
+
|
|
23
32
|
def get_voices(
|
|
24
33
|
download_dir: Union[str, Path], update_voices: bool = False
|
|
25
34
|
) -> Dict[str, Any]:
|
|
@@ -32,7 +41,7 @@ def get_voices(
|
|
|
32
41
|
try:
|
|
33
42
|
voices_url = URL_FORMAT.format(file="voices.json")
|
|
34
43
|
_LOGGER.debug("Downloading %s to %s", voices_url, voices_download)
|
|
35
|
-
with urlopen(voices_url) as response:
|
|
44
|
+
with urlopen(_quote_url(voices_url)) as response:
|
|
36
45
|
with open(voices_download, "wb") as download_file:
|
|
37
46
|
shutil.copyfileobj(response, download_file)
|
|
38
47
|
except Exception:
|
|
@@ -72,6 +81,7 @@ def ensure_voice_exists(
|
|
|
72
81
|
|
|
73
82
|
voice_info = voices_info[name]
|
|
74
83
|
voice_files = voice_info["files"]
|
|
84
|
+
verified_files: Set[str] = set()
|
|
75
85
|
files_to_download: Set[str] = set()
|
|
76
86
|
|
|
77
87
|
for data_dir in data_dirs:
|
|
@@ -79,8 +89,8 @@ def ensure_voice_exists(
|
|
|
79
89
|
|
|
80
90
|
# Check sizes/hashes
|
|
81
91
|
for file_path, file_info in voice_files.items():
|
|
82
|
-
if file_path in
|
|
83
|
-
# Already
|
|
92
|
+
if file_path in verified_files:
|
|
93
|
+
# Already verified this file in a different data directory
|
|
84
94
|
continue
|
|
85
95
|
|
|
86
96
|
file_name = Path(file_path).name
|
|
@@ -118,28 +128,36 @@ def ensure_voice_exists(
|
|
|
118
128
|
files_to_download.add(file_path)
|
|
119
129
|
continue
|
|
120
130
|
|
|
131
|
+
# File exists and has been verified
|
|
132
|
+
verified_files.add(file_path)
|
|
133
|
+
files_to_download.discard(file_path)
|
|
134
|
+
|
|
121
135
|
if (not voice_files) and (not files_to_download):
|
|
122
136
|
raise ValueError(f"Unable to find or download voice: {name}")
|
|
123
137
|
|
|
124
|
-
|
|
125
|
-
|
|
138
|
+
try:
|
|
139
|
+
# Download missing or update to date files
|
|
140
|
+
download_dir = Path(download_dir)
|
|
126
141
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
142
|
+
for file_path in files_to_download:
|
|
143
|
+
file_name = Path(file_path).name
|
|
144
|
+
if file_name in _SKIP_FILES:
|
|
145
|
+
continue
|
|
131
146
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
147
|
+
file_url = URL_FORMAT.format(file=file_path)
|
|
148
|
+
download_file_path = download_dir / file_name
|
|
149
|
+
download_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
135
150
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
151
|
+
_LOGGER.debug("Downloading %s to %s", file_url, download_file_path)
|
|
152
|
+
with urlopen(_quote_url(file_url)) as response, open(
|
|
153
|
+
download_file_path, "wb"
|
|
154
|
+
) as download_file:
|
|
155
|
+
shutil.copyfileobj(response, download_file)
|
|
141
156
|
|
|
142
|
-
|
|
157
|
+
_LOGGER.info("Downloaded %s (%s)", download_file_path, file_url)
|
|
158
|
+
except URLError:
|
|
159
|
+
# find_voice will fail down the line
|
|
160
|
+
_LOGGER.exception("Unexpected error while downloading files for %s", name)
|
|
143
161
|
|
|
144
162
|
|
|
145
163
|
def find_voice(name: str, data_dirs: Iterable[Union[str, Path]]) -> Tuple[Path, Path]:
|
|
@@ -8,6 +8,7 @@ import wave
|
|
|
8
8
|
from typing import Any, Dict, Optional
|
|
9
9
|
|
|
10
10
|
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
|
11
|
+
from wyoming.error import Error
|
|
11
12
|
from wyoming.event import Event
|
|
12
13
|
from wyoming.info import Describe, Info
|
|
13
14
|
from wyoming.server import AsyncEventHandler
|
|
@@ -43,6 +44,15 @@ class PiperEventHandler(AsyncEventHandler):
|
|
|
43
44
|
_LOGGER.warning("Unexpected event: %s", event)
|
|
44
45
|
return True
|
|
45
46
|
|
|
47
|
+
try:
|
|
48
|
+
return await self._handle_event(event)
|
|
49
|
+
except Exception as err:
|
|
50
|
+
await self.write_event(
|
|
51
|
+
Error(text=str(err), code=err.__class__.__name__).event()
|
|
52
|
+
)
|
|
53
|
+
raise err
|
|
54
|
+
|
|
55
|
+
async def _handle_event(self, event: Event) -> bool:
|
|
46
56
|
synthesize = Synthesize.from_event(event)
|
|
47
57
|
_LOGGER.debug(synthesize)
|
|
48
58
|
|