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.
Files changed (28) hide show
  1. wyoming_piper-1.5.3/LICENSE.md +21 -0
  2. wyoming_piper-1.5.3/PKG-INFO +73 -0
  3. wyoming_piper-1.5.3/README.md +39 -0
  4. wyoming_piper-1.5.3/pyproject.toml +69 -0
  5. wyoming_piper-1.5.3/setup.cfg +21 -0
  6. wyoming_piper-1.5.3/tests/test_piper.py +123 -0
  7. wyoming_piper-1.5.3/wyoming_piper/__init__.py +6 -0
  8. {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper/__main__.py +52 -17
  9. {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper/download.py +36 -18
  10. {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper/handler.py +10 -0
  11. {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper/voices.json +2687 -188
  12. wyoming_piper-1.5.3/wyoming_piper.egg-info/PKG-INFO +73 -0
  13. {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper.egg-info/SOURCES.txt +6 -3
  14. wyoming_piper-1.5.3/wyoming_piper.egg-info/entry_points.txt +2 -0
  15. wyoming_piper-1.5.3/wyoming_piper.egg-info/requires.txt +14 -0
  16. wyoming_piper-1.3.2/MANIFEST.in +0 -2
  17. wyoming_piper-1.3.2/PKG-INFO +0 -17
  18. wyoming_piper-1.3.2/requirements.txt +0 -1
  19. wyoming_piper-1.3.2/setup.cfg +0 -4
  20. wyoming_piper-1.3.2/setup.py +0 -44
  21. wyoming_piper-1.3.2/wyoming_piper/__init__.py +0 -1
  22. wyoming_piper-1.3.2/wyoming_piper.egg-info/PKG-INFO +0 -17
  23. wyoming_piper-1.3.2/wyoming_piper.egg-info/requires.txt +0 -1
  24. {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper/const.py +0 -0
  25. {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper/file_hash.py +0 -0
  26. {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper/process.py +0 -0
  27. {wyoming_piper-1.3.2 → wyoming_piper-1.5.3}/wyoming_piper.egg-info/dependency_links.txt +0 -0
  28. {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
+ [![Show add-on](https://my.home-assistant.io/badges/supervisor_addon.svg)](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
+ [![Show add-on](https://my.home-assistant.io/badges/supervisor_addon.svg)](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
@@ -0,0 +1,6 @@
1
+ """Wyoming server for piper."""
2
+ from importlib.metadata import version
3
+
4
+ __version__ = version("wyoming_piper")
5
+
6
+ __all__ = ["__version__"]
@@ -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(level=logging.DEBUG if args.debug else logging.INFO)
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
- languages=[voice_info["language"]["code"]],
97
- #
98
- # Don't send speakers for now because it overflows StreamReader buffers
99
- # speakers=[
100
- # TtsVoiceSpeaker(name=speaker_name)
101
- # for speaker_name in voice_info["speaker_id_map"]
102
- # ]
103
- # if voice_info.get("speaker_id_map")
104
- # else None,
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
- _custom_voice_path, custom_config_path = find_voice(
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["dataset"]
132
- custom_quality = custom_config["audio"]["quality"]
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=f"{custom_name} ({custom_quality})",
164
+ description=description,
165
+ version=None,
137
166
  attribution=Attribution(name="", url=""),
138
167
  installed=True,
139
- languages=[custom_config["language"]["code"]],
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
- asyncio.run(main())
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 files_to_download:
83
- # Already planning to download
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
- # Download missing files
125
- download_dir = Path(download_dir)
138
+ try:
139
+ # Download missing or update to date files
140
+ download_dir = Path(download_dir)
126
141
 
127
- for file_path in files_to_download:
128
- file_name = Path(file_path).name
129
- if file_name in _SKIP_FILES:
130
- continue
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
- file_url = URL_FORMAT.format(file=file_path)
133
- download_file_path = download_dir / file_name
134
- download_file_path.parent.mkdir(parents=True, exist_ok=True)
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
- _LOGGER.debug("Downloading %s to %s", file_url, download_file_path)
137
- with urlopen(file_url) as response, open(
138
- download_file_path, "wb"
139
- ) as download_file:
140
- shutil.copyfileobj(response, download_file)
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
- _LOGGER.info("Downloaded %s (%s)", download_file_path, file_url)
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