pySpeechModule 1.0.0__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.
- pyspeechmodule-1.0.0/LICENSE +21 -0
- pyspeechmodule-1.0.0/PKG-INFO +19 -0
- pyspeechmodule-1.0.0/README.md +4 -0
- pyspeechmodule-1.0.0/pyproject.toml +21 -0
- pyspeechmodule-1.0.0/setup.cfg +4 -0
- pyspeechmodule-1.0.0/src/pySpeechModule/__init__.py +15 -0
- pyspeechmodule-1.0.0/src/pySpeechModule/speechd.py +156 -0
- pyspeechmodule-1.0.0/src/pySpeechModule/speechd_action_worker.py +41 -0
- pyspeechmodule-1.0.0/src/pySpeechModule/speechd_execution_worker.py +34 -0
- pyspeechmodule-1.0.0/src/pySpeechModule/speechd_server.py +396 -0
- pyspeechmodule-1.0.0/src/pySpeechModule/speechd_utilities.py +93 -0
- pyspeechmodule-1.0.0/src/pySpeechModule.egg-info/PKG-INFO +19 -0
- pyspeechmodule-1.0.0/src/pySpeechModule.egg-info/SOURCES.txt +14 -0
- pyspeechmodule-1.0.0/src/pySpeechModule.egg-info/dependency_links.txt +1 -0
- pyspeechmodule-1.0.0/src/pySpeechModule.egg-info/requires.txt +1 -0
- pyspeechmodule-1.0.0/src/pySpeechModule.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John Settlemyer
|
|
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,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pySpeechModule
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A framework for creating speechd modules
|
|
5
|
+
Author-email: John Settlemyer <author@example.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jsett/py-speech-module
|
|
8
|
+
Project-URL: Issues, https://github.com/jsett/py-speech-module/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: soundfile>=0.14.0
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# pySpeechModule
|
|
17
|
+
A framework for writing python speechd modules.
|
|
18
|
+
|
|
19
|
+
documentation: https://pyspeechmodule.readthedocs.io/en/latest/index.html
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pySpeechModule"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
authors = [
|
|
5
|
+
{ name="John Settlemyer", email="author@example.com" },
|
|
6
|
+
]
|
|
7
|
+
dependencies = [
|
|
8
|
+
"soundfile>=0.14.0",
|
|
9
|
+
]
|
|
10
|
+
description = "A framework for creating speechd modules"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
]
|
|
16
|
+
license = "MIT"
|
|
17
|
+
license-files = ["LICEN[CS]E*"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/jsett/py-speech-module"
|
|
21
|
+
Issues = "https://github.com/jsett/py-speech-module/issues"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .speechd_server import SpeechServer
|
|
2
|
+
from .speechd import SpeechDispatch
|
|
3
|
+
from .speechd_action_worker import ActionWorker
|
|
4
|
+
from .speechd_execution_worker import ExecutionWorker
|
|
5
|
+
from .speechd_utilities import hdlc_escape, parse_ssml, strip_ssml
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"SpeechServer",
|
|
9
|
+
"SpeechDispatch",
|
|
10
|
+
"ActionWorker",
|
|
11
|
+
"ExecutionWorker",
|
|
12
|
+
"hdlc_escape",
|
|
13
|
+
"parse_ssml",
|
|
14
|
+
"strip_ssml",
|
|
15
|
+
]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
import soundfile as sf
|
|
4
|
+
|
|
5
|
+
class SpeechDispatch():
|
|
6
|
+
def __init__(self):
|
|
7
|
+
"""
|
|
8
|
+
you should place the voices list here in __init__
|
|
9
|
+
|
|
10
|
+
.. code-block:: python
|
|
11
|
+
|
|
12
|
+
self.voices = [{"name": "Testing", "language": "en", "variant": "none"},
|
|
13
|
+
{"name": "Testing2", "language": "en", "variant": "none"}]
|
|
14
|
+
|
|
15
|
+
You should also set your max_ahead and min_ahead here.
|
|
16
|
+
|
|
17
|
+
.. code-block:: python
|
|
18
|
+
|
|
19
|
+
self.max_ahead = 60 # this sets the max number of second your generation will get ahead of played audio.
|
|
20
|
+
self.min_ahead = 30 # this sets how small the ahead number maybe before starting generation again.
|
|
21
|
+
|
|
22
|
+
You can also place model init or what ever else you would like to init here.
|
|
23
|
+
"""
|
|
24
|
+
pass
|
|
25
|
+
def _set_init(self,queue, server):
|
|
26
|
+
"""
|
|
27
|
+
Set the action_queue and server.
|
|
28
|
+
|
|
29
|
+
:meta private:
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
self.action_queue = queue
|
|
33
|
+
self._server = server
|
|
34
|
+
def configure(self, configure_file: str):
|
|
35
|
+
"""
|
|
36
|
+
This method get passed in the file path to the modules dot conf file
|
|
37
|
+
If you need to use it you should open the file and parse here.
|
|
38
|
+
"""
|
|
39
|
+
pass
|
|
40
|
+
def change_voice(self, voice: str):
|
|
41
|
+
"""
|
|
42
|
+
This method gets passed the name of a voice when the user has requested to change the voice.
|
|
43
|
+
|
|
44
|
+
:param voice: String containing the voice name.
|
|
45
|
+
:type String: String
|
|
46
|
+
"""
|
|
47
|
+
pass
|
|
48
|
+
def change_language(self, language: str):
|
|
49
|
+
"""
|
|
50
|
+
This method gets passed a language when the user has requested to change the language.
|
|
51
|
+
|
|
52
|
+
:param voice: String containing the language.
|
|
53
|
+
:type String: String
|
|
54
|
+
"""
|
|
55
|
+
pass
|
|
56
|
+
def change_speed(self, speed: str):
|
|
57
|
+
"""
|
|
58
|
+
This method gets passed a speed when the user requests to change the speed.
|
|
59
|
+
The speed will be between -100 and 100
|
|
60
|
+
|
|
61
|
+
:param voice: String containing the new speed.
|
|
62
|
+
:type String: String
|
|
63
|
+
"""
|
|
64
|
+
pass
|
|
65
|
+
def change_pitch(self, pitch: str):
|
|
66
|
+
"""
|
|
67
|
+
This method gets passed a pitch when the user requests to change the pitch
|
|
68
|
+
|
|
69
|
+
:param voice: String containing the new pitch.
|
|
70
|
+
:type String: String
|
|
71
|
+
"""
|
|
72
|
+
pass
|
|
73
|
+
def _parse_settings(self,options: dict):
|
|
74
|
+
"""
|
|
75
|
+
When passed the settings it calls the convenience methods associated with that setting.
|
|
76
|
+
|
|
77
|
+
:meta private:
|
|
78
|
+
"""
|
|
79
|
+
if 'synthesis_voice' in options:
|
|
80
|
+
self.change_voice(options['synthesis_voice'])
|
|
81
|
+
if 'language' in options:
|
|
82
|
+
self.change_language(options['language'])
|
|
83
|
+
if 'rate' in options:
|
|
84
|
+
self.change_speed(options['rate'])
|
|
85
|
+
if 'pitch' in options:
|
|
86
|
+
self.change_pitch(options['pitch'])
|
|
87
|
+
def _settings(self,options):
|
|
88
|
+
"""
|
|
89
|
+
Passed the setting, sends it to the parsers.
|
|
90
|
+
|
|
91
|
+
:meta private:
|
|
92
|
+
"""
|
|
93
|
+
self._parse_settings(options)
|
|
94
|
+
self.settings(options)
|
|
95
|
+
|
|
96
|
+
def settings(self,options: dict):
|
|
97
|
+
"""
|
|
98
|
+
This gets passed the full settings options
|
|
99
|
+
|
|
100
|
+
:param options: Dict containing all the settings options that were sent.
|
|
101
|
+
:type Dictionary: Dictionary.
|
|
102
|
+
"""
|
|
103
|
+
pass
|
|
104
|
+
def _speak(self,text: str):
|
|
105
|
+
"""
|
|
106
|
+
This is called to generate speech
|
|
107
|
+
It then iterate's through speech chunks returned by the user.
|
|
108
|
+
|
|
109
|
+
:meta private:
|
|
110
|
+
"""
|
|
111
|
+
self._server.stop_set(False)
|
|
112
|
+
self._ahead = 0
|
|
113
|
+
self.action_queue.put({"command": "begin"})
|
|
114
|
+
it = self.speak(text)
|
|
115
|
+
while (True):
|
|
116
|
+
try:
|
|
117
|
+
if (self._server.stop_get()):
|
|
118
|
+
break
|
|
119
|
+
start_time = time.time()
|
|
120
|
+
item = next(it) # get the next audio chunk
|
|
121
|
+
end_time = time.time()
|
|
122
|
+
total_time = end_time-start_time # total time it took to generate that audio chunk
|
|
123
|
+
produced_time = (len(item['audio']) / item['rate']) # amount of audio generate
|
|
124
|
+
logging.debug(f"Speak chunk took {total_time} to produce {produced_time} for mark {item['mark']}")
|
|
125
|
+
self._ahead = self._ahead + produced_time
|
|
126
|
+
self._ahead = self._ahead - total_time
|
|
127
|
+
logging.debug(f"Ahead: {self._ahead}")
|
|
128
|
+
self.action_queue.put({"command": "speak", "val": item})
|
|
129
|
+
# sleep if we have too much audio generated, only do this is max_ahead and min_ahead are set.
|
|
130
|
+
if (hasattr(self, "max_ahead") and hasattr(self, "min_ahead")):
|
|
131
|
+
if (self._ahead >= self.max_ahead):
|
|
132
|
+
logging.debug(f"Ahead by too much sleeping")
|
|
133
|
+
while (self._ahead >= self.min_ahead):
|
|
134
|
+
time.sleep(1)
|
|
135
|
+
self._ahead = self._ahead - 1
|
|
136
|
+
if (self._server.stop_get()):
|
|
137
|
+
break;
|
|
138
|
+
except StopIteration:
|
|
139
|
+
logging.debug("finished speak chunking")
|
|
140
|
+
break
|
|
141
|
+
self.action_queue.put({"command": "end"})
|
|
142
|
+
def speak(self,text: str):
|
|
143
|
+
"""
|
|
144
|
+
This method get called to get the user to generate audio.
|
|
145
|
+
Data must be yield'ed in the format
|
|
146
|
+
|
|
147
|
+
.. code-block:: python
|
|
148
|
+
|
|
149
|
+
yield {"audio": <data>, "rate": <samplerate>, "mark": <mark>}
|
|
150
|
+
|
|
151
|
+
:param text: String containing ssml text.
|
|
152
|
+
:type String: String
|
|
153
|
+
:yields: Dict containing keys for "audio", "rate", "mark"
|
|
154
|
+
:ytype: Dict
|
|
155
|
+
"""
|
|
156
|
+
pass
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
import queue
|
|
4
|
+
|
|
5
|
+
class ActionWorker(threading.Thread):
|
|
6
|
+
"""
|
|
7
|
+
Worker for running speak commands on the server
|
|
8
|
+
|
|
9
|
+
:meta private:
|
|
10
|
+
"""
|
|
11
|
+
def __init__(self, server):
|
|
12
|
+
super().__init__()
|
|
13
|
+
self.queue = queue.Queue()
|
|
14
|
+
self.server = server
|
|
15
|
+
self.daemon = True
|
|
16
|
+
|
|
17
|
+
def begin(self):
|
|
18
|
+
self.server._begin()
|
|
19
|
+
|
|
20
|
+
def end(self):
|
|
21
|
+
self.server._end()
|
|
22
|
+
|
|
23
|
+
def speak(self,val):
|
|
24
|
+
self.server._send_audio(val)
|
|
25
|
+
|
|
26
|
+
def stop(self):
|
|
27
|
+
"""Sends the sentinel value to trigger a graceful shutdown."""
|
|
28
|
+
self.queue.put(None)
|
|
29
|
+
|
|
30
|
+
def run(self):
|
|
31
|
+
while True:
|
|
32
|
+
item = self.queue.get()
|
|
33
|
+
logging.debug(f"action worker: got item {item}")
|
|
34
|
+
if item is None:
|
|
35
|
+
break
|
|
36
|
+
if (item['command'] == 'begin'):
|
|
37
|
+
self.begin()
|
|
38
|
+
if (item['command'] == 'end'):
|
|
39
|
+
self.end()
|
|
40
|
+
if (item['command'] == 'speak'):
|
|
41
|
+
self.speak(item['val'])
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
import queue
|
|
4
|
+
|
|
5
|
+
class ExecutionWorker(threading.Thread):
|
|
6
|
+
"""
|
|
7
|
+
Worker for running the execution callbacks.
|
|
8
|
+
|
|
9
|
+
:meta private:
|
|
10
|
+
"""
|
|
11
|
+
def __init__(self):
|
|
12
|
+
super().__init__()
|
|
13
|
+
self.queue = queue.Queue()
|
|
14
|
+
self.daemon = True
|
|
15
|
+
|
|
16
|
+
def set_callback(self,callback):
|
|
17
|
+
self._callback = callback
|
|
18
|
+
|
|
19
|
+
def stop(self):
|
|
20
|
+
"""Sends the sentinel value to trigger a graceful shutdown."""
|
|
21
|
+
self.queue.put(None)
|
|
22
|
+
|
|
23
|
+
def run(self):
|
|
24
|
+
while True:
|
|
25
|
+
item = self.queue.get()
|
|
26
|
+
logging.debug(f"execution worker: got item {item}")
|
|
27
|
+
if item is None:
|
|
28
|
+
break
|
|
29
|
+
elif (item['command'] == 'speak'):
|
|
30
|
+
self._callback._speak(item['args'])
|
|
31
|
+
elif (item['command'] == 'settings'):
|
|
32
|
+
self._callback._settings(item['args'])
|
|
33
|
+
elif (item['command'] == 'configure'):
|
|
34
|
+
self._callback.configure(item['args'])
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
import select
|
|
5
|
+
import soundfile as sf
|
|
6
|
+
import queue
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from .speechd_action_worker import ActionWorker
|
|
10
|
+
from .speechd_execution_worker import ExecutionWorker
|
|
11
|
+
from .speechd_utilities import hdlc_escape
|
|
12
|
+
|
|
13
|
+
stdout = sys.stdout
|
|
14
|
+
stdin = sys.stdin
|
|
15
|
+
sys.stdout = sys.stderr
|
|
16
|
+
|
|
17
|
+
logging.basicConfig(
|
|
18
|
+
stream=sys.stderr,
|
|
19
|
+
level=logging.DEBUG,
|
|
20
|
+
format="[%(asctime)s] %(levelname)s: %(message)s",
|
|
21
|
+
datefmt="%Y-%m-%d %H:%M:%S"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
class SpeechServer():
|
|
25
|
+
"""
|
|
26
|
+
This is the server class, it handles running the protocal.
|
|
27
|
+
|
|
28
|
+
.. note:: we set ``sys.stdout = sys.stderr``, this is to ensure that users don't accedentally break the protocal. Anything printed to stdout may break the server.
|
|
29
|
+
"""
|
|
30
|
+
def __init__(self):
|
|
31
|
+
"""
|
|
32
|
+
:meta private:
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
self.line_count = 0
|
|
36
|
+
|
|
37
|
+
self.execution_worker = ExecutionWorker()
|
|
38
|
+
self.action_worker = ActionWorker(server=self)
|
|
39
|
+
|
|
40
|
+
self.voices = []
|
|
41
|
+
self.server_lock = threading.RLock()
|
|
42
|
+
self.stop_lock = threading.RLock()
|
|
43
|
+
self.stop = False # make sure you use stop_lock when using this.
|
|
44
|
+
|
|
45
|
+
def _readline(self, block=True):
|
|
46
|
+
"""
|
|
47
|
+
This is basically pythons readline but with the added ability to do it none blocking.
|
|
48
|
+
|
|
49
|
+
:meta private:
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
if (not block):
|
|
54
|
+
timeout = 0.0
|
|
55
|
+
ready, _, _ = select.select([stdin], [], [], timeout)
|
|
56
|
+
if ready:
|
|
57
|
+
output = stdin.readline()
|
|
58
|
+
logging.debug(f"line {self.line_count}: {output[:-1]}")
|
|
59
|
+
self.line_count = self.line_count + 1
|
|
60
|
+
return output
|
|
61
|
+
return None
|
|
62
|
+
else:
|
|
63
|
+
output = stdin.readline()
|
|
64
|
+
logging.debug(f"line {self.line_count}: {output[:-1]}")
|
|
65
|
+
self.line_count = self.line_count + 1
|
|
66
|
+
return output
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logging.error(f"_readline: {e}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _begin(self):
|
|
72
|
+
"""
|
|
73
|
+
Send command telling speechd we are starting to send audio data.
|
|
74
|
+
|
|
75
|
+
:meta private:
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
with self.server_lock:
|
|
79
|
+
stdout.write("701 BEGIN\n")
|
|
80
|
+
stdout.flush()
|
|
81
|
+
def _end(self):
|
|
82
|
+
"""
|
|
83
|
+
Tells speechd we are done sending audio data.
|
|
84
|
+
|
|
85
|
+
:meta private:
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
with self.server_lock:
|
|
89
|
+
stdout.write("702 END\n")
|
|
90
|
+
stdout.flush()
|
|
91
|
+
def _mark(self, mark):
|
|
92
|
+
"""
|
|
93
|
+
Tells speechd which mark we are on.
|
|
94
|
+
|
|
95
|
+
:meta private:
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
if (mark==""):
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
with self.server_lock:
|
|
102
|
+
stdout.write(f"700-{mark}\n700 INDEX MARK\n")
|
|
103
|
+
stdout.flush()
|
|
104
|
+
|
|
105
|
+
def _send_audio(self, val):
|
|
106
|
+
"""
|
|
107
|
+
Send audio data to speechd.
|
|
108
|
+
|
|
109
|
+
:meta private:
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
with self.server_lock:
|
|
113
|
+
logging.debug("server: _send_audio")
|
|
114
|
+
data = val['audio']
|
|
115
|
+
sample_rate = val['rate']
|
|
116
|
+
mark = val['mark']
|
|
117
|
+
|
|
118
|
+
chunk_size = 5000
|
|
119
|
+
for i in range(0, len(data), chunk_size):
|
|
120
|
+
start_time = time.time()
|
|
121
|
+
tmp_data = data[i : i + chunk_size]
|
|
122
|
+
num_samples = len(tmp_data)
|
|
123
|
+
tmp_data = tmp_data.tobytes()
|
|
124
|
+
stdout.write(f"705-bits=16\n")
|
|
125
|
+
stdout.flush()
|
|
126
|
+
stdout.write(f"705-num_channels=1\n")
|
|
127
|
+
stdout.flush()
|
|
128
|
+
stdout.write(f"705-sample_rate={sample_rate}\n")
|
|
129
|
+
stdout.flush()
|
|
130
|
+
stdout.write(f"705-num_samples={num_samples}\n")
|
|
131
|
+
stdout.flush()
|
|
132
|
+
stdout.write(f"705-big_endian=0\n")
|
|
133
|
+
stdout.flush()
|
|
134
|
+
stdout.write(f"705-AUDIO")
|
|
135
|
+
stdout.flush()
|
|
136
|
+
stdout.buffer.write(b'\x00')
|
|
137
|
+
|
|
138
|
+
tmp_data2 = hdlc_escape(tmp_data)
|
|
139
|
+
stdout.buffer.write(tmp_data2)
|
|
140
|
+
stdout.write("\n")
|
|
141
|
+
stdout.write("705 AUDIO\n")
|
|
142
|
+
stdout.flush()
|
|
143
|
+
end_time = time.time()
|
|
144
|
+
total_time = end_time-start_time
|
|
145
|
+
self._process(block=False)
|
|
146
|
+
self._mark(mark)
|
|
147
|
+
|
|
148
|
+
def _configure(self):
|
|
149
|
+
"""
|
|
150
|
+
Get the config file path and send it to the client.
|
|
151
|
+
|
|
152
|
+
:meta private:
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
if len(sys.argv) > 1:
|
|
156
|
+
configfile = sys.argv[1]
|
|
157
|
+
self.execution_worker.queue.put({"command": "configure", "args": configfile})
|
|
158
|
+
|
|
159
|
+
def parse_set_params(self,ack: int, param_type: str):
|
|
160
|
+
"""
|
|
161
|
+
Get a settings string.
|
|
162
|
+
|
|
163
|
+
:meta private:
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
with self.server_lock:
|
|
167
|
+
stdout.write(f"{ack} OK RECEIVING {param_type} SETTINGS\n")
|
|
168
|
+
stdout.flush()
|
|
169
|
+
|
|
170
|
+
output = {}
|
|
171
|
+
|
|
172
|
+
while (True):
|
|
173
|
+
line = self._readline()
|
|
174
|
+
if (line == ".\n"):
|
|
175
|
+
break
|
|
176
|
+
line_clean = line.rstrip("\n")
|
|
177
|
+
var, val = line_clean.split("=", 1)
|
|
178
|
+
output[var] = val
|
|
179
|
+
|
|
180
|
+
return output
|
|
181
|
+
|
|
182
|
+
def _audio(self):
|
|
183
|
+
"""
|
|
184
|
+
:meta private:
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
tmp = self.parse_set_params(207, "AUDIO")
|
|
188
|
+
with self.server_lock:
|
|
189
|
+
stdout.write("203 OK AUDIO INITIALIZED\n")
|
|
190
|
+
stdout.flush()
|
|
191
|
+
|
|
192
|
+
def _loglevel(self):
|
|
193
|
+
"""
|
|
194
|
+
Sets the logging level from a settings string.
|
|
195
|
+
|
|
196
|
+
The logging levels don't quit match up with speechd but we try and get them as close as possible.
|
|
197
|
+
see this link for the speechd logging levels.
|
|
198
|
+
https://htmlpreview.github.io/?https://github.com/brailcom/speechd/blob/master/doc/speech-dispatcher.html#Log-Levels
|
|
199
|
+
|
|
200
|
+
:meta private:
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
levels = {'5': logging.DEBUG, '4': logging.INFO, '3': logging.WARNING, '2': logging.ERROR, '1': logging.CRITICAL}
|
|
204
|
+
|
|
205
|
+
tmp = self.parse_set_params(207, "LOGLEVEL")
|
|
206
|
+
logging.critical(f"Changing log level to {tmp['log_level']}")
|
|
207
|
+
if (tmp['log_level'] == '0'):
|
|
208
|
+
logging.disable(logging.CRITICAL) # Disable all logging globally.
|
|
209
|
+
else:
|
|
210
|
+
logging.disable(logging.NOTSET) # Enable all logging globally.
|
|
211
|
+
logging.getLogger().setLevel(levels[tmp['log_level']]) # set the logging level.
|
|
212
|
+
|
|
213
|
+
with self.server_lock:
|
|
214
|
+
stdout.write("203 OK LOGLEVEL SET\n")
|
|
215
|
+
stdout.flush()
|
|
216
|
+
|
|
217
|
+
def _list_voices(self):
|
|
218
|
+
"""
|
|
219
|
+
List the voices and send them back to speechd.
|
|
220
|
+
|
|
221
|
+
:meta private:
|
|
222
|
+
"""
|
|
223
|
+
voices = self.voices
|
|
224
|
+
with self.server_lock:
|
|
225
|
+
for voice in voices:
|
|
226
|
+
name = voice['name'] if 'name' in voice else "none"
|
|
227
|
+
language = voice['language'] if 'language' in voice else "none"
|
|
228
|
+
variant = voice['variant'] if 'variant' in voice else "none"
|
|
229
|
+
stdout.write(f"200-{name}\t{language}\t{variant}\n")
|
|
230
|
+
|
|
231
|
+
stdout.write("200 OK VOICE LIST SENT\n")
|
|
232
|
+
stdout.flush()
|
|
233
|
+
|
|
234
|
+
def _setting(self):
|
|
235
|
+
"""
|
|
236
|
+
Receive setting from speechd.
|
|
237
|
+
|
|
238
|
+
:meta private:
|
|
239
|
+
"""
|
|
240
|
+
tmp = self.parse_set_params(203, "")
|
|
241
|
+
self.execution_worker.queue.put({"command": "settings", "args": tmp})
|
|
242
|
+
with self.server_lock:
|
|
243
|
+
stdout.write("203 OK SETTINGS RECEIVED\n")
|
|
244
|
+
stdout.flush()
|
|
245
|
+
|
|
246
|
+
# def _speak_test(self):
|
|
247
|
+
# data, samplerate = sf.read("deep_learning.wav", dtype='int16')
|
|
248
|
+
# val = {"audio": data, "rate": samplerate, "mark": ""}
|
|
249
|
+
# self._begin()
|
|
250
|
+
# self._send_audio(val)
|
|
251
|
+
# self._end()
|
|
252
|
+
|
|
253
|
+
def _speak(self):
|
|
254
|
+
"""
|
|
255
|
+
Get a speak command from speechd.
|
|
256
|
+
|
|
257
|
+
:meta private:
|
|
258
|
+
"""
|
|
259
|
+
with self.server_lock:
|
|
260
|
+
stdout.write("202 OK RECEIVING MESSAGE\n")
|
|
261
|
+
stdout.flush()
|
|
262
|
+
|
|
263
|
+
full_text = ""
|
|
264
|
+
|
|
265
|
+
while (True):
|
|
266
|
+
line = self._readline()
|
|
267
|
+
if (line == ".\n"):
|
|
268
|
+
break
|
|
269
|
+
else:
|
|
270
|
+
full_text = full_text + line[:-1] + " "
|
|
271
|
+
|
|
272
|
+
self.execution_worker.queue.put({"command": "speak", "args": full_text})
|
|
273
|
+
|
|
274
|
+
with self.server_lock:
|
|
275
|
+
stdout.write("200 OK SPEAKING\n")
|
|
276
|
+
stdout.flush()
|
|
277
|
+
|
|
278
|
+
def stop_get(self):
|
|
279
|
+
"""
|
|
280
|
+
Get the value of the stop var.
|
|
281
|
+
|
|
282
|
+
:meta private:
|
|
283
|
+
"""
|
|
284
|
+
with self.stop_lock:
|
|
285
|
+
return self.stop
|
|
286
|
+
def stop_set(self, val):
|
|
287
|
+
"""
|
|
288
|
+
Set the value of the stop var.
|
|
289
|
+
|
|
290
|
+
:meta private:
|
|
291
|
+
"""
|
|
292
|
+
with self.stop_lock:
|
|
293
|
+
self.stop = val
|
|
294
|
+
def _stop(self):
|
|
295
|
+
"""
|
|
296
|
+
Stop the client.
|
|
297
|
+
|
|
298
|
+
:meta private:
|
|
299
|
+
"""
|
|
300
|
+
self.stop_set(True)
|
|
301
|
+
with self.server_lock:
|
|
302
|
+
stdout.write("703 STOP\n")
|
|
303
|
+
stdout.flush()
|
|
304
|
+
def _pause(self):
|
|
305
|
+
"""
|
|
306
|
+
Stop the client.
|
|
307
|
+
|
|
308
|
+
:meta private:
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
self.stop_set(True)
|
|
312
|
+
with self.server_lock:
|
|
313
|
+
stdout.write("704 PAUSE\n")
|
|
314
|
+
stdout.flush()
|
|
315
|
+
|
|
316
|
+
def _process(self, block=True):
|
|
317
|
+
"""
|
|
318
|
+
Process commands from speechd.
|
|
319
|
+
|
|
320
|
+
:meta private:
|
|
321
|
+
"""
|
|
322
|
+
while (True):
|
|
323
|
+
line = self._readline(block)
|
|
324
|
+
if (line == None):
|
|
325
|
+
break
|
|
326
|
+
if (line == ""):
|
|
327
|
+
break
|
|
328
|
+
if (line == "SPEAK\n"):
|
|
329
|
+
self._speak()
|
|
330
|
+
elif (line == "SOUND_ICON\n"):
|
|
331
|
+
self._speak()
|
|
332
|
+
elif (line == "CHAR\n"):
|
|
333
|
+
self._speak()
|
|
334
|
+
elif (line == "KEY\n"):
|
|
335
|
+
self._speak()
|
|
336
|
+
elif (line[:11] == "LIST VOICES"):
|
|
337
|
+
self._list_voices()
|
|
338
|
+
elif (line == "SET\n"):
|
|
339
|
+
self._setting()
|
|
340
|
+
elif (line == "AUDIO\n"):
|
|
341
|
+
self._audio()
|
|
342
|
+
elif (line == "LOGLEVEL\n"):
|
|
343
|
+
self._loglevel()
|
|
344
|
+
elif (line == "STOP\n"):
|
|
345
|
+
self._stop()
|
|
346
|
+
elif (line == "PAUSE\n"):
|
|
347
|
+
self._pause()
|
|
348
|
+
elif (line[:5] == "DEBUG"):
|
|
349
|
+
# TODO: not sure the diffrence between log level and debug
|
|
350
|
+
pass
|
|
351
|
+
elif (line == "QUIT\n"):
|
|
352
|
+
break
|
|
353
|
+
else:
|
|
354
|
+
logging.error("_process: unknown command")
|
|
355
|
+
logging.error(line)
|
|
356
|
+
|
|
357
|
+
def start(self, callback):
|
|
358
|
+
"""
|
|
359
|
+
Starts the mainloop and sets the client callback.
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
self._callback = callback
|
|
363
|
+
# keep a local copy of the voices
|
|
364
|
+
# it should be set at the start and never changed.
|
|
365
|
+
if hasattr(callback, "voices"):
|
|
366
|
+
self.voices = callback.voices
|
|
367
|
+
else:
|
|
368
|
+
self.voices = [{"name": "GenericPythonModule", "language": "en", "variant": "none"}]
|
|
369
|
+
|
|
370
|
+
# Start the worker threads.
|
|
371
|
+
self.execution_worker.set_callback(callback)
|
|
372
|
+
self.execution_worker.start()
|
|
373
|
+
self.action_worker.start()
|
|
374
|
+
self._callback._set_init(self.action_worker.queue, self)
|
|
375
|
+
|
|
376
|
+
# run configure.
|
|
377
|
+
self._configure()
|
|
378
|
+
|
|
379
|
+
line = self._readline()
|
|
380
|
+
if (line != "INIT\n"):
|
|
381
|
+
logging.error("ERROR: Server did not start with INIT\n")
|
|
382
|
+
|
|
383
|
+
msg = "GOOD"
|
|
384
|
+
stdout.write(f"299-{msg}\n")
|
|
385
|
+
stdout.write("299 OK LOADED SUCCESSFULLY\n")
|
|
386
|
+
stdout.flush()
|
|
387
|
+
|
|
388
|
+
logging.info("server: Starting _process()")
|
|
389
|
+
self._process()
|
|
390
|
+
|
|
391
|
+
# let the workers stop before quitting
|
|
392
|
+
self.execution_worker.stop()
|
|
393
|
+
self.action_worker.stop()
|
|
394
|
+
self.execution_worker.join()
|
|
395
|
+
self.action_worker.join()
|
|
396
|
+
logging.debug("server: Quiting")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import xml.etree.ElementTree as ET
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
def hdlc_escape(data: bytes) -> bytes:
|
|
5
|
+
"""
|
|
6
|
+
hdlc_escape is for excaping newline chars using hdlc encoding.
|
|
7
|
+
This function likely has little value to the users but is need internally.
|
|
8
|
+
|
|
9
|
+
:meta private:
|
|
10
|
+
"""
|
|
11
|
+
ESCAPE = 0x7D
|
|
12
|
+
INVERT = 1 << 5
|
|
13
|
+
out = bytearray()
|
|
14
|
+
|
|
15
|
+
for byte in data:
|
|
16
|
+
if byte == ESCAPE or byte == ord("\n"):
|
|
17
|
+
out.append(ESCAPE)
|
|
18
|
+
out.append(byte ^ INVERT)
|
|
19
|
+
else:
|
|
20
|
+
out.append(byte)
|
|
21
|
+
|
|
22
|
+
return bytes(out)
|
|
23
|
+
|
|
24
|
+
def parse_ssml(data: bytes) -> list[dict]:
|
|
25
|
+
"""
|
|
26
|
+
This is a convenience function for parsing SSML into a list of dicts in the format
|
|
27
|
+
|
|
28
|
+
.. code-block:: python
|
|
29
|
+
|
|
30
|
+
[{"mark": "<mark>", "text": "<text>"}]
|
|
31
|
+
|
|
32
|
+
:param data: The bytes string for your SSML text
|
|
33
|
+
:type bytes: bytes
|
|
34
|
+
:return: List of dicts each with mark and text
|
|
35
|
+
:rtype: list
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
output = []
|
|
39
|
+
buffer = []
|
|
40
|
+
|
|
41
|
+
# Parse the SSML XML data
|
|
42
|
+
try:
|
|
43
|
+
root = ET.fromstring(data)
|
|
44
|
+
except ET.ParseError:
|
|
45
|
+
logging.error("parse_ssml: Failed to parse XML")
|
|
46
|
+
return output
|
|
47
|
+
|
|
48
|
+
# Handle text directly inside <speak> before any child elements
|
|
49
|
+
if root.text:
|
|
50
|
+
buffer.append(root.text)
|
|
51
|
+
|
|
52
|
+
# Iterate through direct child elements of <speak>
|
|
53
|
+
for child in root:
|
|
54
|
+
# Check for <mark> element
|
|
55
|
+
if child.tag.endswith("mark"):
|
|
56
|
+
mark_name = child.attrib.get("name", "")
|
|
57
|
+
|
|
58
|
+
output.append(
|
|
59
|
+
{"mark": mark_name, "text": "".join(buffer)}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Reset buffer after encountering a mark
|
|
63
|
+
buffer.clear()
|
|
64
|
+
|
|
65
|
+
# Append trailing text after a child node (XML tail text)
|
|
66
|
+
if child.tail:
|
|
67
|
+
buffer.append(child.tail)
|
|
68
|
+
|
|
69
|
+
# Add any remaining trailing text after the final mark
|
|
70
|
+
if buffer:
|
|
71
|
+
output.append({"mark": "", "text": "".join(buffer)})
|
|
72
|
+
|
|
73
|
+
return output
|
|
74
|
+
|
|
75
|
+
def strip_ssml(ssml_string: bytes):
|
|
76
|
+
"""
|
|
77
|
+
This is a convenience function for stripping out all SSML tags leaving just the text.
|
|
78
|
+
|
|
79
|
+
:param data: The bytes string for your SSML text
|
|
80
|
+
:type bytes: bytes
|
|
81
|
+
:return: extracted text
|
|
82
|
+
:rtype: string
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
# Wrap in a root element if the string is an XML fragment
|
|
86
|
+
try:
|
|
87
|
+
root = ET.fromstring(ssml_string)
|
|
88
|
+
except ET.ParseError:
|
|
89
|
+
# Wrap fragments in a temporary root node to ensure valid parsing
|
|
90
|
+
root = ET.fromstring(f"<root>{ssml_string}</root>")
|
|
91
|
+
|
|
92
|
+
# itertext() yields all text within the element and its children
|
|
93
|
+
return "".join(root.itertext())
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pySpeechModule
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A framework for creating speechd modules
|
|
5
|
+
Author-email: John Settlemyer <author@example.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jsett/py-speech-module
|
|
8
|
+
Project-URL: Issues, https://github.com/jsett/py-speech-module/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: soundfile>=0.14.0
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# pySpeechModule
|
|
17
|
+
A framework for writing python speechd modules.
|
|
18
|
+
|
|
19
|
+
documentation: https://pyspeechmodule.readthedocs.io/en/latest/index.html
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/pySpeechModule/__init__.py
|
|
5
|
+
src/pySpeechModule/speechd.py
|
|
6
|
+
src/pySpeechModule/speechd_action_worker.py
|
|
7
|
+
src/pySpeechModule/speechd_execution_worker.py
|
|
8
|
+
src/pySpeechModule/speechd_server.py
|
|
9
|
+
src/pySpeechModule/speechd_utilities.py
|
|
10
|
+
src/pySpeechModule.egg-info/PKG-INFO
|
|
11
|
+
src/pySpeechModule.egg-info/SOURCES.txt
|
|
12
|
+
src/pySpeechModule.egg-info/dependency_links.txt
|
|
13
|
+
src/pySpeechModule.egg-info/requires.txt
|
|
14
|
+
src/pySpeechModule.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
soundfile>=0.14.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pySpeechModule
|