wyoming-piper 1.5.3__tar.gz → 2.1.2__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 (22) hide show
  1. {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/PKG-INFO +16 -14
  2. {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/pyproject.toml +16 -14
  3. {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/tests/test_piper.py +1 -21
  4. {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper/__init__.py +1 -0
  5. {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper/__main__.py +50 -24
  6. {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper/download.py +41 -38
  7. wyoming_piper-2.1.2/wyoming_piper/handler.py +275 -0
  8. {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper/voices.json +420 -0
  9. {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper.egg-info/PKG-INFO +16 -14
  10. {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper.egg-info/SOURCES.txt +0 -1
  11. wyoming_piper-2.1.2/wyoming_piper.egg-info/requires.txt +16 -0
  12. wyoming_piper-1.5.3/wyoming_piper/handler.py +0 -146
  13. wyoming_piper-1.5.3/wyoming_piper/process.py +0 -171
  14. wyoming_piper-1.5.3/wyoming_piper.egg-info/requires.txt +0 -14
  15. {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/LICENSE.md +0 -0
  16. {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/README.md +0 -0
  17. {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/setup.cfg +0 -0
  18. {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper/const.py +0 -0
  19. {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper/file_hash.py +0 -0
  20. {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper.egg-info/dependency_links.txt +0 -0
  21. {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper.egg-info/entry_points.txt +0 -0
  22. {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wyoming-piper
3
- Version: 1.5.3
3
+ Version: 2.1.2
4
4
  Summary: Wyoming Server for Piper
5
5
  Author-email: Michael Hansen <mike@rhasspy.org>
6
6
  License: MIT
@@ -8,28 +8,30 @@ Project-URL: Homepage, http://github.com/rhasspy/wyoming-piper
8
8
  Keywords: rhasspy,wyoming,piper,tts
9
9
  Classifier: Development Status :: 3 - Alpha
10
10
  Classifier: Intended Audience :: Developers
11
- Classifier: Topic :: Text Processing :: Linguistic
12
- Classifier: Programming Language :: Python :: 3.8
11
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
13
12
  Classifier: Programming Language :: Python :: 3.9
14
13
  Classifier: Programming Language :: Python :: 3.10
15
14
  Classifier: Programming Language :: Python :: 3.11
16
15
  Classifier: Programming Language :: Python :: 3.12
17
- Requires-Python: <3.13,>=3.8.1
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Requires-Python: >=3.9
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE.md
20
- Requires-Dist: wyoming>=1.5.3
20
+ Requires-Dist: wyoming<2,>=1.8
21
+ Requires-Dist: regex>=2024.11.6
22
+ Requires-Dist: piper-tts<2,>=1.3.0
23
+ Requires-Dist: sentence-stream<2,>=1.2.0
21
24
  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"
25
+ Requires-Dist: black; extra == "dev"
26
+ Requires-Dist: flake8; extra == "dev"
27
+ Requires-Dist: mypy; extra == "dev"
28
+ Requires-Dist: pylint; extra == "dev"
29
+ Requires-Dist: pytest; extra == "dev"
30
+ Requires-Dist: pytest-asyncio; extra == "dev"
31
+ Requires-Dist: build; extra == "dev"
30
32
  Requires-Dist: scipy<2,>=1.10; extra == "dev"
31
33
  Requires-Dist: numpy<2,>=1.20; extra == "dev"
32
- Requires-Dist: python-speech-features==0.6; extra == "dev"
34
+ Requires-Dist: python-speech-features<1,>=0.6; extra == "dev"
33
35
  Dynamic: license-file
34
36
 
35
37
  # Wyoming Piper
@@ -1,9 +1,9 @@
1
1
  [project]
2
2
  name = "wyoming-piper"
3
- version = "1.5.3"
3
+ version = "2.1.2"
4
4
  description = "Wyoming Server for Piper"
5
5
  readme = "README.md"
6
- requires-python = ">=3.8.1,<3.13"
6
+ requires-python = ">=3.9"
7
7
  license = {text = "MIT"}
8
8
  authors = [
9
9
  {name = "Michael Hansen", email = "mike@rhasspy.org"}
@@ -12,15 +12,18 @@ keywords = ["rhasspy", "wyoming", "piper", "tts"]
12
12
  classifiers = [
13
13
  "Development Status :: 3 - Alpha",
14
14
  "Intended Audience :: Developers",
15
- "Topic :: Text Processing :: Linguistic",
16
- "Programming Language :: Python :: 3.8",
15
+ "Topic :: Multimedia :: Sound/Audio :: Speech",
17
16
  "Programming Language :: Python :: 3.9",
18
17
  "Programming Language :: Python :: 3.10",
19
18
  "Programming Language :: Python :: 3.11",
20
19
  "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
21
  ]
22
22
  dependencies = [
23
- "wyoming>=1.5.3",
23
+ "wyoming>=1.8,<2",
24
+ "regex>=2024.11.6",
25
+ "piper-tts>=1.3.0,<2",
26
+ "sentence-stream>=1.2.0,<2",
24
27
  ]
25
28
 
26
29
  [project.urls]
@@ -55,15 +58,14 @@ disallow_untyped_defs = true
55
58
 
56
59
  [project.optional-dependencies]
57
60
  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",
61
+ "black",
62
+ "flake8",
63
+ "mypy",
64
+ "pylint",
65
+ "pytest",
66
+ "pytest-asyncio",
67
+ "build",
66
68
  "scipy>=1.10,<2",
67
69
  "numpy>=1.20,<2",
68
- "python-speech-features==0.6",
70
+ "python-speech-features>=0.6,<1",
69
71
  ]
@@ -1,11 +1,10 @@
1
1
  """Tests for wyoming-piper"""
2
+
2
3
  import asyncio
3
4
  import sys
4
- import tarfile
5
5
  import wave
6
6
  from asyncio.subprocess import PIPE
7
7
  from pathlib import Path
8
- from urllib.request import urlopen
9
8
 
10
9
  import numpy as np
11
10
  import pytest
@@ -19,36 +18,17 @@ from .dtw import compute_optimal_path
19
18
 
20
19
  _DIR = Path(__file__).parent
21
20
  _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
21
  _TIMEOUT = 60
26
22
 
27
23
 
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
24
  @pytest.mark.asyncio
41
25
  async def test_piper() -> None:
42
- download_piper()
43
-
44
26
  proc = await asyncio.create_subprocess_exec(
45
27
  sys.executable,
46
28
  "-m",
47
29
  "wyoming_piper",
48
30
  "--uri",
49
31
  "stdio://",
50
- "--piper",
51
- str(_LOCAL_DIR / "piper" / "piper"),
52
32
  "--voice",
53
33
  "en_US-ryan-low",
54
34
  "--data-dir",
@@ -1,4 +1,5 @@
1
1
  """Wyoming server for piper."""
2
+
2
3
  from importlib.metadata import version
3
4
 
4
5
  __version__ = version("wyoming_piper")
@@ -8,12 +8,11 @@ from pathlib import Path
8
8
  from typing import Any, Dict, Set
9
9
 
10
10
  from wyoming.info import Attribution, Info, TtsProgram, TtsVoice, TtsVoiceSpeaker
11
- from wyoming.server import AsyncServer
11
+ from wyoming.server import AsyncServer, AsyncTcpServer
12
12
 
13
13
  from . import __version__
14
- from .download import find_voice, get_voices
14
+ from .download import ensure_voice_exists, find_voice, get_voices
15
15
  from .handler import PiperEventHandler
16
- from .process import PiperProcessManager
17
16
 
18
17
  _LOGGER = logging.getLogger(__name__)
19
18
 
@@ -21,17 +20,20 @@ _LOGGER = logging.getLogger(__name__)
21
20
  async def main() -> None:
22
21
  """Main entry point."""
23
22
  parser = argparse.ArgumentParser()
24
- parser.add_argument(
25
- "--piper",
26
- required=True,
27
- help="Path to piper executable",
28
- )
29
23
  parser.add_argument(
30
24
  "--voice",
31
25
  required=True,
32
26
  help="Default Piper voice to use (e.g., en_US-lessac-medium)",
33
27
  )
34
28
  parser.add_argument("--uri", default="stdio://", help="unix:// or tcp://")
29
+ #
30
+ parser.add_argument(
31
+ "--zeroconf",
32
+ nargs="?",
33
+ const="piper",
34
+ help="Enable discovery over zeroconf with optional name (default: piper)",
35
+ )
36
+ #
35
37
  parser.add_argument(
36
38
  "--data-dir",
37
39
  required=True,
@@ -48,17 +50,18 @@ async def main() -> None:
48
50
  )
49
51
  parser.add_argument("--noise-scale", type=float, help="Generator noise")
50
52
  parser.add_argument("--length-scale", type=float, help="Phoneme length")
51
- parser.add_argument("--noise-w", type=float, help="Phoneme width noise")
53
+ parser.add_argument(
54
+ "--noise-w-scale", "--noise-w", type=float, help="Phoneme width noise"
55
+ )
52
56
  #
53
57
  parser.add_argument(
54
58
  "--auto-punctuation", default=".?!", help="Automatically add punctuation"
55
59
  )
56
60
  parser.add_argument("--samples-per-chunk", type=int, default=1024)
57
61
  parser.add_argument(
58
- "--max-piper-procs",
59
- type=int,
60
- default=1,
61
- help="Maximum number of piper process to run simultaneously (default: 1)",
62
+ "--no-streaming",
63
+ action="store_true",
64
+ help="Disable audio streaming on sentence boundaries",
62
65
  )
63
66
  #
64
67
  parser.add_argument(
@@ -67,6 +70,12 @@ async def main() -> None:
67
70
  help="Download latest voices.json during startup",
68
71
  )
69
72
  #
73
+ parser.add_argument(
74
+ "--use-cuda",
75
+ action="store_true",
76
+ help="Use CUDA if available (requires onnxruntime-gpu)",
77
+ )
78
+ #
70
79
  parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
71
80
  parser.add_argument(
72
81
  "--log-format", default=logging.BASIC_FORMAT, help="Format for log messages"
@@ -113,12 +122,14 @@ async def main() -> None:
113
122
  voice_info.get("espeak", {}).get("voice", voice_name.split("_")[0]),
114
123
  )
115
124
  ],
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,
125
+ speakers=(
126
+ [
127
+ TtsVoiceSpeaker(name=speaker_name)
128
+ for speaker_name in voice_info["speaker_id_map"]
129
+ ]
130
+ if voice_info.get("speaker_id_map")
131
+ else None
132
+ ),
122
133
  )
123
134
  for voice_name, voice_info in voices_info.items()
124
135
  if not voice_info.get("_is_alias", False)
@@ -180,26 +191,41 @@ async def main() -> None:
180
191
  installed=True,
181
192
  voices=sorted(voices, key=lambda v: v.name),
182
193
  version=__version__,
194
+ supports_synthesize_streaming=(not args.no_streaming),
183
195
  )
184
196
  ],
185
197
  )
186
198
 
187
- process_manager = PiperProcessManager(args, voices_info)
199
+ # Ensure default voice is downloaded
200
+ voice_info = voices_info.get(args.voice, {})
201
+ voice_name = voice_info.get("key", args.voice)
202
+ assert voice_name is not None
188
203
 
189
- # Make sure default voice is loaded.
190
- # Other voices will be loaded on-demand.
191
- await process_manager.get_process()
204
+ ensure_voice_exists(voice_name, args.data_dir, args.download_dir, voices_info)
192
205
 
193
206
  # Start server
194
207
  server = AsyncServer.from_uri(args.uri)
195
208
 
209
+ if args.zeroconf:
210
+ if not isinstance(server, AsyncTcpServer):
211
+ raise ValueError("Zeroconf requires tcp:// uri")
212
+
213
+ from wyoming.zeroconf import HomeAssistantZeroconf
214
+
215
+ tcp_server: AsyncTcpServer = server
216
+ hass_zeroconf = HomeAssistantZeroconf(
217
+ name=args.zeroconf, port=tcp_server.port, host=tcp_server.host
218
+ )
219
+ await hass_zeroconf.register_server()
220
+ _LOGGER.debug("Zeroconf discovery enabled")
221
+
196
222
  _LOGGER.info("Ready")
197
223
  await server.run(
198
224
  partial(
199
225
  PiperEventHandler,
200
226
  wyoming_info,
201
227
  args,
202
- process_manager,
228
+ voices_info,
203
229
  )
204
230
  )
205
231
 
@@ -1,4 +1,5 @@
1
1
  """Utility for downloading Piper voices."""
2
+
2
3
  import json
3
4
  import logging
4
5
  import shutil
@@ -8,9 +9,7 @@ from urllib.error import URLError
8
9
  from urllib.parse import quote, urlsplit, urlunsplit
9
10
  from urllib.request import urlopen
10
11
 
11
- from .file_hash import get_file_hash
12
-
13
- URL_FORMAT = "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/{file}"
12
+ URL_FORMAT = "https://huggingface.co/rhasspy/piper-voices/resolve/main/{file}"
14
13
 
15
14
  _DIR = Path(__file__).parent
16
15
  _LOGGER = logging.getLogger(__name__)
@@ -47,20 +46,21 @@ def get_voices(
47
46
  except Exception:
48
47
  _LOGGER.exception("Failed to update voices list")
49
48
 
49
+ voices_embedded = _DIR / "voices.json"
50
+ _LOGGER.debug("Loading %s", voices_embedded)
51
+ with open(voices_embedded, "r", encoding="utf-8") as voices_file:
52
+ voices = json.load(voices_file)
53
+
50
54
  # Prefer downloaded file to embedded
51
55
  if voices_download.exists():
52
56
  try:
53
57
  _LOGGER.debug("Loading %s", voices_download)
54
58
  with open(voices_download, "r", encoding="utf-8") as voices_file:
55
- return json.load(voices_file)
59
+ voices.update(json.load(voices_file))
56
60
  except Exception:
57
61
  _LOGGER.exception("Failed to load %s", voices_download)
58
62
 
59
- # Fall back to embedded
60
- voices_embedded = _DIR / "voices.json"
61
- _LOGGER.debug("Loading %s", voices_embedded)
62
- with open(voices_embedded, "r", encoding="utf-8") as voices_file:
63
- return json.load(voices_file)
63
+ return voices
64
64
 
65
65
 
66
66
  def ensure_voice_exists(
@@ -87,8 +87,7 @@ def ensure_voice_exists(
87
87
  for data_dir in data_dirs:
88
88
  data_dir = Path(data_dir)
89
89
 
90
- # Check sizes/hashes
91
- for file_path, file_info in voice_files.items():
90
+ for file_path, _file_info in voice_files.items():
92
91
  if file_path in verified_files:
93
92
  # Already verified this file in a different data directory
94
93
  continue
@@ -99,34 +98,37 @@ def ensure_voice_exists(
99
98
 
100
99
  data_file_path = data_dir / file_name
101
100
  _LOGGER.debug("Checking %s", data_file_path)
102
- if not data_file_path.exists():
101
+ if (not data_file_path.exists()) or (data_file_path.stat().st_size == 0):
103
102
  _LOGGER.debug("Missing %s", data_file_path)
104
103
  files_to_download.add(file_path)
105
104
  continue
106
105
 
107
- expected_size = file_info["size_bytes"]
108
- actual_size = data_file_path.stat().st_size
109
- if expected_size != actual_size:
110
- _LOGGER.warning(
111
- "Wrong size (expected=%s, actual=%s) for %s",
112
- expected_size,
113
- actual_size,
114
- data_file_path,
115
- )
116
- files_to_download.add(file_path)
117
- continue
118
-
119
- expected_hash = file_info["md5_digest"]
120
- actual_hash = get_file_hash(data_file_path)
121
- if expected_hash != actual_hash:
122
- _LOGGER.warning(
123
- "Wrong hash (expected=%s, actual=%s) for %s",
124
- expected_hash,
125
- actual_hash,
126
- data_file_path,
127
- )
128
- files_to_download.add(file_path)
129
- continue
106
+ # Don't bother validating sizes or hashes.
107
+ # This causes more problems than its worth.
108
+ #
109
+ # expected_size = file_info["size_bytes"]
110
+ # actual_size = data_file_path.stat().st_size
111
+ # if expected_size != actual_size:
112
+ # _LOGGER.warning(
113
+ # "Wrong size (expected=%s, actual=%s) for %s",
114
+ # expected_size,
115
+ # actual_size,
116
+ # data_file_path,
117
+ # )
118
+ # files_to_download.add(file_path)
119
+ # continue
120
+
121
+ # expected_hash = file_info["md5_digest"]
122
+ # actual_hash = get_file_hash(data_file_path)
123
+ # if expected_hash != actual_hash:
124
+ # _LOGGER.warning(
125
+ # "Wrong hash (expected=%s, actual=%s) for %s",
126
+ # expected_hash,
127
+ # actual_hash,
128
+ # data_file_path,
129
+ # )
130
+ # files_to_download.add(file_path)
131
+ # continue
130
132
 
131
133
  # File exists and has been verified
132
134
  verified_files.add(file_path)
@@ -149,9 +151,10 @@ def ensure_voice_exists(
149
151
  download_file_path.parent.mkdir(parents=True, exist_ok=True)
150
152
 
151
153
  _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:
154
+ with (
155
+ urlopen(_quote_url(file_url)) as response,
156
+ open(download_file_path, "wb") as download_file,
157
+ ):
155
158
  shutil.copyfileobj(response, download_file)
156
159
 
157
160
  _LOGGER.info("Downloaded %s (%s)", download_file_path, file_url)