sonara-client 0.21.7__py3-none-any.whl
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.
- sonara_client/__init__.py +35 -0
- sonara_client/client.py +136 -0
- sonara_client/connect.py +164 -0
- sonara_client/connection.py +118 -0
- sonara_client/discovery.py +147 -0
- sonara_client/engines.py +110 -0
- sonara_client/errors.py +23 -0
- sonara_client/extensions.py +122 -0
- sonara_client/version.py +5 -0
- sonara_client-0.21.7.dist-info/METADATA +42 -0
- sonara_client-0.21.7.dist-info/RECORD +14 -0
- sonara_client-0.21.7.dist-info/WHEEL +5 -0
- sonara_client-0.21.7.dist-info/licenses/LICENSE +21 -0
- sonara_client-0.21.7.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""sonara-client: talk to the Sonara runtime (protocol v1). Standard library only.
|
|
2
|
+
|
|
3
|
+
import sonara_client
|
|
4
|
+
|
|
5
|
+
with sonara_client.connect("my-app", runtime_path=r"C:\\path\\to\\sonarad.exe") as sonara:
|
|
6
|
+
item = sonara.speak("Hello.")
|
|
7
|
+
for event in sonara.subscribe(["items"]):
|
|
8
|
+
if event["item_id"] == item and event["phase"] != "started":
|
|
9
|
+
break
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .client import Client, Subscription
|
|
14
|
+
from .connect import connect
|
|
15
|
+
from .discovery import read_runtime, resolve_home
|
|
16
|
+
from .engines import SEND_MODES, Engines
|
|
17
|
+
from .errors import SonaraError
|
|
18
|
+
from .extensions import Agent, Channels, System
|
|
19
|
+
from .version import PROTOCOL, __version__
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Agent",
|
|
23
|
+
"Channels",
|
|
24
|
+
"Client",
|
|
25
|
+
"Engines",
|
|
26
|
+
"PROTOCOL",
|
|
27
|
+
"SEND_MODES",
|
|
28
|
+
"SonaraError",
|
|
29
|
+
"Subscription",
|
|
30
|
+
"System",
|
|
31
|
+
"__version__",
|
|
32
|
+
"connect",
|
|
33
|
+
"read_runtime",
|
|
34
|
+
"resolve_home",
|
|
35
|
+
]
|
sonara_client/client.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""The connected client and its event subscriptions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import socket
|
|
5
|
+
from typing import Any, Callable, Iterator, List, Optional, Sequence
|
|
6
|
+
|
|
7
|
+
from .connection import Connection
|
|
8
|
+
from .errors import E_CLOSED, SonaraError
|
|
9
|
+
from .engines import Engines
|
|
10
|
+
from .extensions import Agent, Channels, System
|
|
11
|
+
|
|
12
|
+
ALL_EVENTS = ("state", "items", "log")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Subscription:
|
|
16
|
+
"""Events on a connection of their own: iterate it, or ``read()`` with a
|
|
17
|
+
timeout. Each event is a dict whose ``event`` is ``state``, ``item`` or
|
|
18
|
+
``log``. Iteration ends when the runtime closes the connection."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, conn: Connection):
|
|
21
|
+
self._conn = conn
|
|
22
|
+
|
|
23
|
+
def __iter__(self) -> Iterator[dict]:
|
|
24
|
+
return self
|
|
25
|
+
|
|
26
|
+
def __next__(self) -> dict:
|
|
27
|
+
event = self._conn.next_event(None)
|
|
28
|
+
if event is None:
|
|
29
|
+
raise StopIteration
|
|
30
|
+
return event
|
|
31
|
+
|
|
32
|
+
def read(self, timeout: Optional[float] = None) -> Optional[dict]:
|
|
33
|
+
"""The next event, or None when none arrived within ``timeout``
|
|
34
|
+
seconds. SonaraError ``E_CLOSED`` once the stream ended."""
|
|
35
|
+
try:
|
|
36
|
+
event = self._conn.next_event(timeout)
|
|
37
|
+
except socket.timeout:
|
|
38
|
+
return None
|
|
39
|
+
if event is None:
|
|
40
|
+
raise SonaraError(E_CLOSED, "the event stream ended")
|
|
41
|
+
return event
|
|
42
|
+
|
|
43
|
+
def close(self) -> None:
|
|
44
|
+
self._conn.close()
|
|
45
|
+
|
|
46
|
+
def __enter__(self) -> "Subscription":
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def __exit__(self, *exc: Any) -> None:
|
|
50
|
+
self.close()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Client:
|
|
54
|
+
"""A connected Sonara client (see ``connect``). Thread-safe: requests on
|
|
55
|
+
the shared connection run one at a time."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, conn: Connection, runtime: dict, info: dict, dial: Callable[[], Connection]):
|
|
58
|
+
self._conn = conn
|
|
59
|
+
self._dial = dial
|
|
60
|
+
self._subs: List[Subscription] = []
|
|
61
|
+
#: ``runtime.json`` of the instance this client talks to.
|
|
62
|
+
self.runtime = runtime
|
|
63
|
+
#: The ``hello`` reply: version, protocol, capabilities, extensions, unavailable.
|
|
64
|
+
self.info = info
|
|
65
|
+
self.channels = Channels(self.request)
|
|
66
|
+
self.agent = Agent(self.request)
|
|
67
|
+
self.system = System(self.request)
|
|
68
|
+
self.engines = Engines(self.request)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def closed(self) -> bool:
|
|
72
|
+
return self._conn.closed
|
|
73
|
+
|
|
74
|
+
def request(self, type_: str, fields: Optional[dict] = None) -> dict:
|
|
75
|
+
"""Send any protocol message; the ``ok: true`` reply."""
|
|
76
|
+
return self._conn.request(type_, fields)
|
|
77
|
+
|
|
78
|
+
def speak(self, text: str, mode: Optional[str] = None, interrupt: Optional[bool] = None,
|
|
79
|
+
label: Optional[str] = None) -> int:
|
|
80
|
+
"""Add ``text`` as one item; its item id. ``mode``: ``append``
|
|
81
|
+
(default) or ``replace``; ``interrupt`` also cuts the current item."""
|
|
82
|
+
fields: dict = {"text": text}
|
|
83
|
+
if mode is not None:
|
|
84
|
+
fields["mode"] = mode
|
|
85
|
+
if interrupt is not None:
|
|
86
|
+
fields["interrupt"] = interrupt
|
|
87
|
+
if label is not None:
|
|
88
|
+
fields["label"] = label
|
|
89
|
+
return self.request("speak", fields)["item_id"]
|
|
90
|
+
|
|
91
|
+
def control(self, action: str) -> None:
|
|
92
|
+
"""play, pause, toggle, stop, skip, previous, next, restart, mute, unmute."""
|
|
93
|
+
self.request("control", {"action": action})
|
|
94
|
+
|
|
95
|
+
def set(self, key: str, value: Any) -> Any:
|
|
96
|
+
"""Change ``volume``, ``rate``, ``voice`` or ``engine``; the value now in force."""
|
|
97
|
+
return self.request("set", {"key": key, "value": value})["value"]
|
|
98
|
+
|
|
99
|
+
def get(self, key: str) -> Any:
|
|
100
|
+
"""Read a setting."""
|
|
101
|
+
return self.request("get", {"key": key})["value"]
|
|
102
|
+
|
|
103
|
+
def voices(self, engine: Optional[str] = None, refresh: bool = False) -> list:
|
|
104
|
+
"""Voices of one engine, or of all. ``refresh`` asks an external
|
|
105
|
+
engine's provider again (protocol 1.2)."""
|
|
106
|
+
fields: dict = {} if engine is None else {"engine": engine}
|
|
107
|
+
if refresh:
|
|
108
|
+
fields["refresh"] = True
|
|
109
|
+
return self.request("voices", fields)["voices"]
|
|
110
|
+
|
|
111
|
+
def subscribe(self, events: Sequence[str] = ALL_EVENTS) -> Subscription:
|
|
112
|
+
"""Open an event stream (``state``, ``items``, ``log``) on a new
|
|
113
|
+
connection. When ``state`` is included the first event is the
|
|
114
|
+
current state."""
|
|
115
|
+
conn = self._dial()
|
|
116
|
+
try:
|
|
117
|
+
conn.request("subscribe", {"events": list(events)})
|
|
118
|
+
except SonaraError:
|
|
119
|
+
conn.close()
|
|
120
|
+
raise
|
|
121
|
+
sub = Subscription(conn)
|
|
122
|
+
self._subs.append(sub)
|
|
123
|
+
return sub
|
|
124
|
+
|
|
125
|
+
def close(self) -> None:
|
|
126
|
+
"""Close this client's connections. The runtime keeps running for
|
|
127
|
+
other clients and exits on its own when idle."""
|
|
128
|
+
for sub in self._subs:
|
|
129
|
+
sub.close()
|
|
130
|
+
self._conn.close()
|
|
131
|
+
|
|
132
|
+
def __enter__(self) -> "Client":
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
def __exit__(self, *exc: Any) -> None:
|
|
136
|
+
self.close()
|
sonara_client/connect.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""``connect()``: find or start the shared runtime (spec section 3)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from typing import Optional, Sequence
|
|
7
|
+
|
|
8
|
+
from .client import Client
|
|
9
|
+
from .connection import Connection
|
|
10
|
+
from .discovery import live_runtime, resolve_home, start_runtime, wait_exit
|
|
11
|
+
from .errors import E_CLOSED, E_NOT_RUNNING, SonaraError
|
|
12
|
+
from .version import PROTOCOL, __version__
|
|
13
|
+
|
|
14
|
+
CONNECT_TIMEOUT = 5.0
|
|
15
|
+
EXIT_WAIT = 5.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Incompatible(Exception):
|
|
19
|
+
"""The instance answers but cannot serve this client."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def connect(
|
|
23
|
+
client_name: str,
|
|
24
|
+
*,
|
|
25
|
+
runtime_path: Optional[str] = None,
|
|
26
|
+
home: Optional[str] = None,
|
|
27
|
+
autostart: bool = True,
|
|
28
|
+
require: Sequence[str] = (),
|
|
29
|
+
extensions: Sequence[str] = (),
|
|
30
|
+
client_version: Optional[str] = None,
|
|
31
|
+
keep_alive: bool = False,
|
|
32
|
+
runtime_args: Sequence[str] = (),
|
|
33
|
+
start_timeout: float = 5.0,
|
|
34
|
+
takeover_timeout: float = 30.0,
|
|
35
|
+
takeover_retry: float = 0.25,
|
|
36
|
+
timeout: float = 10.0,
|
|
37
|
+
) -> Client:
|
|
38
|
+
"""Connect to the shared Sonara runtime.
|
|
39
|
+
|
|
40
|
+
1. Read ``runtime.json`` in the home (``home``, else ``SONARA_HOME``,
|
|
41
|
+
else ``%LOCALAPPDATA%\\Sonara``); when its pid is alive, connect and
|
|
42
|
+
send ``hello``.
|
|
43
|
+
2. Use it when it speaks protocol 1 and offers everything in ``require``.
|
|
44
|
+
3. Otherwise (with ``autostart``), start ``runtime_path --home <home>``
|
|
45
|
+
(default: ``SONARA_RUNTIME``), wait up to ``start_timeout`` seconds
|
|
46
|
+
for its ``runtime.json``, then ``hello``.
|
|
47
|
+
4. An incompatible running instance is first asked to step down
|
|
48
|
+
(``hello`` with ``takeover: true``); while it is busy the takeover is
|
|
49
|
+
retried for up to ``takeover_timeout`` seconds, then
|
|
50
|
+
``E_INCOMPATIBLE``.
|
|
51
|
+
|
|
52
|
+
``runtime_args`` are extra arguments for a started runtime (tests:
|
|
53
|
+
``["--engine", "fake"]``); ``timeout`` bounds each request.
|
|
54
|
+
"""
|
|
55
|
+
if not isinstance(client_name, str) or not client_name:
|
|
56
|
+
raise SonaraError("E_BAD_REQUEST", "connect() needs a client_name")
|
|
57
|
+
home = resolve_home(home)
|
|
58
|
+
runtime_path = runtime_path or os.environ.get("SONARA_RUNTIME")
|
|
59
|
+
can_start = bool(autostart and runtime_path)
|
|
60
|
+
hello = {
|
|
61
|
+
"client": {"name": client_name, "version": client_version or __version__},
|
|
62
|
+
"protocol": dict(PROTOCOL),
|
|
63
|
+
"require": list(require),
|
|
64
|
+
"extensions": list(extensions),
|
|
65
|
+
}
|
|
66
|
+
if keep_alive:
|
|
67
|
+
hello["keep_alive"] = True
|
|
68
|
+
|
|
69
|
+
running = live_runtime(home)
|
|
70
|
+
if running:
|
|
71
|
+
try:
|
|
72
|
+
client = _greet(running, hello, list(require), timeout)
|
|
73
|
+
if client:
|
|
74
|
+
return client
|
|
75
|
+
except _Incompatible as e:
|
|
76
|
+
if not can_start:
|
|
77
|
+
raise SonaraError(
|
|
78
|
+
"E_INCOMPATIBLE",
|
|
79
|
+
f"the running Sonara {running.get('version')} cannot serve this client ({e}) "
|
|
80
|
+
"and there is no runtime to start in its place",
|
|
81
|
+
) from None
|
|
82
|
+
_take_over(home, client_name, client_version, str(e), takeover_timeout, takeover_retry)
|
|
83
|
+
|
|
84
|
+
if not can_start:
|
|
85
|
+
why = "autostart is off" if runtime_path else "no runtime_path to start one"
|
|
86
|
+
raise SonaraError(E_NOT_RUNNING, f"no Sonara runtime is running and {why}")
|
|
87
|
+
started = start_runtime(runtime_path, home, list(runtime_args), start_timeout)
|
|
88
|
+
try:
|
|
89
|
+
client = _greet(started, hello, list(require), timeout)
|
|
90
|
+
except _Incompatible as e:
|
|
91
|
+
raise SonaraError(
|
|
92
|
+
"E_INCOMPATIBLE", f"the bundled Sonara {started.get('version')} cannot serve this client: {e}"
|
|
93
|
+
) from None
|
|
94
|
+
if client is None:
|
|
95
|
+
raise SonaraError(E_CLOSED, "the started runtime closed the connection")
|
|
96
|
+
return client
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _greet(info: dict, hello: dict, require: list, timeout: float) -> Optional[Client]:
|
|
100
|
+
"""Connect and ``hello``. None when the instance cannot be reached;
|
|
101
|
+
_Incompatible when it answers but cannot serve this client."""
|
|
102
|
+
|
|
103
|
+
def dial() -> Connection:
|
|
104
|
+
conn = Connection(info["port"], timeout)
|
|
105
|
+
try:
|
|
106
|
+
conn.request("hello", {**hello, "token": info["token"]})
|
|
107
|
+
except SonaraError:
|
|
108
|
+
conn.close()
|
|
109
|
+
raise
|
|
110
|
+
return conn
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
conn = Connection(info["port"], min(timeout, CONNECT_TIMEOUT))
|
|
114
|
+
except SonaraError:
|
|
115
|
+
return None
|
|
116
|
+
try:
|
|
117
|
+
reply = conn.request("hello", {**hello, "token": info["token"]})
|
|
118
|
+
except SonaraError as e:
|
|
119
|
+
conn.close()
|
|
120
|
+
if e.code in ("E_INCOMPATIBLE", "E_UNSUPPORTED"):
|
|
121
|
+
raise _Incompatible(e.message) from None
|
|
122
|
+
if e.code == E_CLOSED:
|
|
123
|
+
return None
|
|
124
|
+
raise
|
|
125
|
+
offered = set(reply.get("capabilities") or []) | set(reply.get("extensions") or [])
|
|
126
|
+
missing = [r for r in require if r not in offered]
|
|
127
|
+
major = (reply.get("protocol") or {}).get("major")
|
|
128
|
+
if major != PROTOCOL["major"] or missing:
|
|
129
|
+
conn.close()
|
|
130
|
+
raise _Incompatible(f"it does not offer {', '.join(missing)}" if missing else f"it speaks protocol {major}")
|
|
131
|
+
return Client(conn, info, reply, dial)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _take_over(home: str, name: str, version: Optional[str], why: str, limit: float, retry: float) -> None:
|
|
135
|
+
"""Ask the running instance to exit so the bundled runtime can start. It
|
|
136
|
+
accepts only when idle; while busy, retry until ``limit`` seconds. Each
|
|
137
|
+
attempt uses a fresh connection: the runtime closes one that has no
|
|
138
|
+
successful ``hello`` within 5 s."""
|
|
139
|
+
deadline = time.monotonic() + limit
|
|
140
|
+
client = {"name": name, "version": version or __version__}
|
|
141
|
+
while True:
|
|
142
|
+
cur = live_runtime(home)
|
|
143
|
+
if not cur:
|
|
144
|
+
return
|
|
145
|
+
try:
|
|
146
|
+
conn = Connection(cur["port"], CONNECT_TIMEOUT)
|
|
147
|
+
try:
|
|
148
|
+
conn.request("hello", {"token": cur["token"], "client": client, "takeover": True})
|
|
149
|
+
finally:
|
|
150
|
+
conn.close()
|
|
151
|
+
if wait_exit(cur["pid"], EXIT_WAIT):
|
|
152
|
+
return
|
|
153
|
+
except SonaraError as e:
|
|
154
|
+
if e.code not in ("E_BUSY", E_CLOSED):
|
|
155
|
+
raise SonaraError(
|
|
156
|
+
"E_INCOMPATIBLE",
|
|
157
|
+
f"the running Sonara cannot serve this client ({why}) and refused a takeover: {e.message}",
|
|
158
|
+
) from None
|
|
159
|
+
if time.monotonic() >= deadline:
|
|
160
|
+
raise SonaraError(
|
|
161
|
+
"E_INCOMPATIBLE",
|
|
162
|
+
f"the running Sonara cannot serve this client ({why}) and stayed busy; gave up the takeover",
|
|
163
|
+
)
|
|
164
|
+
time.sleep(retry)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""One TCP JSON-lines connection to the runtime."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import itertools
|
|
5
|
+
import json
|
|
6
|
+
import socket
|
|
7
|
+
import threading
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from .errors import E_CLOSED, SonaraError
|
|
11
|
+
|
|
12
|
+
MAX_LINE = 1 << 20
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Connection:
|
|
16
|
+
"""Requests and their replies (blocking, one at a time), plus events
|
|
17
|
+
for a subscribed connection."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, port: int, timeout: float):
|
|
20
|
+
try:
|
|
21
|
+
self._sock = socket.create_connection(("127.0.0.1", port), timeout=timeout)
|
|
22
|
+
except OSError as e:
|
|
23
|
+
raise SonaraError(E_CLOSED, f"cannot connect to 127.0.0.1:{port}: {e}") from None
|
|
24
|
+
self._sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
25
|
+
self._timeout = timeout
|
|
26
|
+
self._buf = b""
|
|
27
|
+
self._ids = itertools.count(1)
|
|
28
|
+
self._lock = threading.Lock()
|
|
29
|
+
self._events: list = []
|
|
30
|
+
self.closed = False
|
|
31
|
+
|
|
32
|
+
def request(self, type_: str, fields: Optional[dict] = None) -> dict:
|
|
33
|
+
"""Send ``{type, **fields}``; the ``ok: true`` reply, or SonaraError."""
|
|
34
|
+
with self._lock:
|
|
35
|
+
if self.closed:
|
|
36
|
+
raise SonaraError(E_CLOSED, "the connection is closed")
|
|
37
|
+
msg = dict(fields or {})
|
|
38
|
+
msg["type"] = type_
|
|
39
|
+
msg["id"] = next(self._ids)
|
|
40
|
+
try:
|
|
41
|
+
self._sock.sendall(json.dumps(msg).encode("utf-8") + b"\n")
|
|
42
|
+
except OSError as e:
|
|
43
|
+
self._mark_closed()
|
|
44
|
+
raise SonaraError(E_CLOSED, f"send failed: {e}") from None
|
|
45
|
+
while True:
|
|
46
|
+
try:
|
|
47
|
+
reply = self._read(self._timeout)
|
|
48
|
+
except socket.timeout:
|
|
49
|
+
# A late reply would answer the next request: give up
|
|
50
|
+
# on this connection instead.
|
|
51
|
+
self._mark_closed()
|
|
52
|
+
raise SonaraError(E_CLOSED, f"no reply within {self._timeout} s") from None
|
|
53
|
+
if reply is None:
|
|
54
|
+
raise SonaraError(E_CLOSED, "the connection closed before the reply")
|
|
55
|
+
if "event" in reply:
|
|
56
|
+
self._events.append(reply)
|
|
57
|
+
continue
|
|
58
|
+
if "ok" not in reply:
|
|
59
|
+
continue
|
|
60
|
+
if reply.get("ok") is True:
|
|
61
|
+
return reply
|
|
62
|
+
err = reply.get("error") or {}
|
|
63
|
+
raise SonaraError(err.get("code", "E_BAD_REQUEST"), err.get("message", "request failed"),
|
|
64
|
+
err.get("reason"))
|
|
65
|
+
|
|
66
|
+
def next_event(self, timeout: Optional[float]) -> Optional[dict]:
|
|
67
|
+
"""The next event; None at the end of the stream. ``socket.timeout``
|
|
68
|
+
(``TimeoutError``) when none arrived within ``timeout`` seconds."""
|
|
69
|
+
if self._events:
|
|
70
|
+
return self._events.pop(0)
|
|
71
|
+
while True:
|
|
72
|
+
msg = self._read(timeout)
|
|
73
|
+
if msg is None or "event" in msg:
|
|
74
|
+
return msg
|
|
75
|
+
|
|
76
|
+
def close(self) -> None:
|
|
77
|
+
self._mark_closed()
|
|
78
|
+
|
|
79
|
+
def _mark_closed(self) -> None:
|
|
80
|
+
if not self.closed:
|
|
81
|
+
self.closed = True
|
|
82
|
+
try:
|
|
83
|
+
self._sock.close()
|
|
84
|
+
except OSError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
def _read(self, timeout: Optional[float]) -> Optional[dict]:
|
|
88
|
+
"""The next JSON object line, or None once the stream ended."""
|
|
89
|
+
while True:
|
|
90
|
+
nl = self._buf.find(b"\n")
|
|
91
|
+
if nl >= 0:
|
|
92
|
+
line, self._buf = self._buf[:nl], self._buf[nl + 1:]
|
|
93
|
+
line = line.strip()
|
|
94
|
+
if not line:
|
|
95
|
+
continue
|
|
96
|
+
try:
|
|
97
|
+
msg = json.loads(line)
|
|
98
|
+
except ValueError:
|
|
99
|
+
continue
|
|
100
|
+
if isinstance(msg, dict):
|
|
101
|
+
return msg
|
|
102
|
+
continue
|
|
103
|
+
if self.closed:
|
|
104
|
+
return None
|
|
105
|
+
self._sock.settimeout(timeout)
|
|
106
|
+
try:
|
|
107
|
+
chunk = self._sock.recv(65536)
|
|
108
|
+
except socket.timeout:
|
|
109
|
+
raise
|
|
110
|
+
except OSError:
|
|
111
|
+
chunk = b""
|
|
112
|
+
if not chunk:
|
|
113
|
+
self._mark_closed()
|
|
114
|
+
return None
|
|
115
|
+
self._buf += chunk
|
|
116
|
+
if len(self._buf) > MAX_LINE * 2:
|
|
117
|
+
self._mark_closed()
|
|
118
|
+
return None
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Find, check and start the runtime (protocol v1, Discovery)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from typing import Optional, Sequence
|
|
9
|
+
|
|
10
|
+
from .errors import E_NOT_RUNNING, E_START_FAILED, SonaraError
|
|
11
|
+
|
|
12
|
+
# Exit code of a second sonarad for the same user and home.
|
|
13
|
+
EXIT_ALREADY_RUNNING = 3
|
|
14
|
+
POLL = 0.05
|
|
15
|
+
CREATE_NO_WINDOW = 0x08000000
|
|
16
|
+
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
|
17
|
+
|
|
18
|
+
# Started runtimes outlive this process (they are shared and exit on their
|
|
19
|
+
# own when idle). Holding the Popen objects keeps Python from warning that a
|
|
20
|
+
# child is still running when they are garbage collected.
|
|
21
|
+
_started: list = []
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_home(home: Optional[str] = None, env=None) -> str:
|
|
25
|
+
"""``home``, else ``SONARA_HOME``, else ``%LOCALAPPDATA%\\Sonara``."""
|
|
26
|
+
env = os.environ if env is None else env
|
|
27
|
+
if home:
|
|
28
|
+
return str(home)
|
|
29
|
+
if env.get("SONARA_HOME"):
|
|
30
|
+
return env["SONARA_HOME"]
|
|
31
|
+
if env.get("LOCALAPPDATA"):
|
|
32
|
+
return os.path.join(env["LOCALAPPDATA"], "Sonara")
|
|
33
|
+
raise SonaraError(E_NOT_RUNNING, "no home folder: pass home, or set SONARA_HOME or LOCALAPPDATA")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_runtime(home: str) -> Optional[dict]:
|
|
37
|
+
"""``runtime.json`` of ``home``, or None when missing or unreadable."""
|
|
38
|
+
try:
|
|
39
|
+
with open(os.path.join(home, "runtime.json"), encoding="utf-8") as f:
|
|
40
|
+
info = json.load(f)
|
|
41
|
+
except (OSError, ValueError):
|
|
42
|
+
return None
|
|
43
|
+
if not isinstance(info, dict):
|
|
44
|
+
return None
|
|
45
|
+
if not isinstance(info.get("pid"), int) or not isinstance(info.get("port"), int):
|
|
46
|
+
return None
|
|
47
|
+
if not isinstance(info.get("token"), str):
|
|
48
|
+
return None
|
|
49
|
+
return info
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def pid_alive(pid: int) -> bool:
|
|
53
|
+
"""True while process ``pid`` exists."""
|
|
54
|
+
if not isinstance(pid, int) or pid <= 0:
|
|
55
|
+
return False
|
|
56
|
+
if os.name == "nt":
|
|
57
|
+
return _pid_alive_windows(pid)
|
|
58
|
+
try:
|
|
59
|
+
os.kill(pid, 0)
|
|
60
|
+
except ProcessLookupError:
|
|
61
|
+
return False
|
|
62
|
+
except PermissionError:
|
|
63
|
+
return True
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _pid_alive_windows(pid: int) -> bool:
|
|
68
|
+
# Not os.kill(pid, 0): on Windows that terminates the process.
|
|
69
|
+
import ctypes
|
|
70
|
+
from ctypes import wintypes
|
|
71
|
+
|
|
72
|
+
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
73
|
+
k32.OpenProcess.restype = wintypes.HANDLE
|
|
74
|
+
k32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
75
|
+
k32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
|
|
76
|
+
k32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
77
|
+
process_query_limited_information = 0x1000
|
|
78
|
+
still_active = 259
|
|
79
|
+
handle = k32.OpenProcess(process_query_limited_information, False, pid)
|
|
80
|
+
if not handle:
|
|
81
|
+
# Access denied: it exists but belongs to someone else.
|
|
82
|
+
return ctypes.get_last_error() == 5
|
|
83
|
+
try:
|
|
84
|
+
code = wintypes.DWORD()
|
|
85
|
+
if not k32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
|
86
|
+
return True
|
|
87
|
+
return code.value == still_active
|
|
88
|
+
finally:
|
|
89
|
+
k32.CloseHandle(handle)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def live_runtime(home: str) -> Optional[dict]:
|
|
93
|
+
"""``runtime.json`` of ``home`` when its pid is alive."""
|
|
94
|
+
info = read_runtime(home)
|
|
95
|
+
return info if info and pid_alive(info["pid"]) else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def start_runtime(runtime_path: str, home: str, args: Sequence[str], timeout: float) -> dict:
|
|
99
|
+
"""Start ``runtime_path --home <home> [args]`` without a console window
|
|
100
|
+
and wait up to ``timeout`` seconds for its ``runtime.json``. When another
|
|
101
|
+
client started one at the same moment (the new process exits with code
|
|
102
|
+
3), the other's ``runtime.json`` is used."""
|
|
103
|
+
kwargs: dict = {
|
|
104
|
+
"stdin": subprocess.DEVNULL,
|
|
105
|
+
"stdout": subprocess.DEVNULL,
|
|
106
|
+
"stderr": subprocess.DEVNULL,
|
|
107
|
+
"close_fds": True,
|
|
108
|
+
}
|
|
109
|
+
if os.name == "nt":
|
|
110
|
+
# Its own process group, so a Ctrl+C in this console does not end
|
|
111
|
+
# a runtime other apps share.
|
|
112
|
+
kwargs["creationflags"] = CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP
|
|
113
|
+
else:
|
|
114
|
+
kwargs["start_new_session"] = True
|
|
115
|
+
# An absolute path: Windows does not find a relative one written with
|
|
116
|
+
# forward slashes ("sonara/sonarad.exe"), which Node's spawn accepts (#274).
|
|
117
|
+
exe = os.path.abspath(str(runtime_path))
|
|
118
|
+
try:
|
|
119
|
+
proc = subprocess.Popen([exe, "--home", str(home), *args], **kwargs)
|
|
120
|
+
except OSError as e:
|
|
121
|
+
raise SonaraError(E_START_FAILED, f"cannot start {runtime_path}: {e}") from None
|
|
122
|
+
_started.append(proc)
|
|
123
|
+
|
|
124
|
+
deadline = time.monotonic() + timeout
|
|
125
|
+
while time.monotonic() < deadline:
|
|
126
|
+
info = read_runtime(home)
|
|
127
|
+
if info and info["pid"] == proc.pid:
|
|
128
|
+
return info
|
|
129
|
+
code = proc.poll()
|
|
130
|
+
if code is not None:
|
|
131
|
+
if code != EXIT_ALREADY_RUNNING:
|
|
132
|
+
raise SonaraError(E_START_FAILED, f"{runtime_path} exited with code {code}")
|
|
133
|
+
other = live_runtime(home)
|
|
134
|
+
if other:
|
|
135
|
+
return other
|
|
136
|
+
time.sleep(POLL)
|
|
137
|
+
raise SonaraError(E_START_FAILED, f"{runtime_path} wrote no runtime.json within {timeout} s")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def wait_exit(pid: int, timeout: float) -> bool:
|
|
141
|
+
"""Wait until process ``pid`` has ended; False if it still runs."""
|
|
142
|
+
deadline = time.monotonic() + timeout
|
|
143
|
+
while time.monotonic() < deadline:
|
|
144
|
+
if not pid_alive(pid):
|
|
145
|
+
return True
|
|
146
|
+
time.sleep(POLL)
|
|
147
|
+
return not pid_alive(pid)
|
sonara_client/engines.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""External engines (protocol 1.2, capability ``engines``): speech engines
|
|
2
|
+
the user adds at run time, such as OpenAI or a local OpenAI-compatible
|
|
3
|
+
server.
|
|
4
|
+
|
|
5
|
+
Each method sends one core message as is. The profile is named by the
|
|
6
|
+
``engine`` field (a request's ``id`` is its correlation id). A runtime that
|
|
7
|
+
refuses external engines (``sonarad --no-external-engines``) answers
|
|
8
|
+
``E_UNSUPPORTED``. A key is sent only in ``add`` (``secret``) or
|
|
9
|
+
``set_key`` (or with a draft profile in ``models``, for that request only);
|
|
10
|
+
the runtime stores it in Windows Credential Manager and never returns it.
|
|
11
|
+
|
|
12
|
+
Sonara names no model or voice of its own (protocol 1.5, runtime 0.19.0):
|
|
13
|
+
``models`` and ``voices`` list the provider's live, and a profile without
|
|
14
|
+
one it needs says so in ``engine_list`` (``missing``).
|
|
15
|
+
|
|
16
|
+
"Send to the engine" (``send_mode`` of a profile, runtime 0.19.0, #235):
|
|
17
|
+
``"message"`` sends a whole released message in one request (the default of
|
|
18
|
+
the cloud kinds), ``"sentence"`` each sentence as it comes (the default of a
|
|
19
|
+
program and a server on this PC). The view in ``engine_list`` tells the mode
|
|
20
|
+
in force, and ``explicit["send_mode"]`` whether the profile chose it.
|
|
21
|
+
|
|
22
|
+
A ``command`` engine (a program on the user's PC) is never added or changed
|
|
23
|
+
through the protocol: ``add`` of one, or replacing one, is ``E_FORBIDDEN``
|
|
24
|
+
(protocol 1.3). The user adds it locally (``sonara engines add <id> --kind
|
|
25
|
+
command``, or ``engines.json``); ``reload`` makes a running runtime read
|
|
26
|
+
``engines.json`` again. Listing, testing, selecting and removing one work.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import Callable, Optional
|
|
31
|
+
|
|
32
|
+
Send = Callable[[str, dict], dict]
|
|
33
|
+
|
|
34
|
+
#: The values of a profile's ``send_mode`` (#235).
|
|
35
|
+
SEND_MODES = ("message", "sentence")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Engines:
|
|
39
|
+
"""``engine_list``, ``engine_add``, ``engine_remove``, ``engine_key``,
|
|
40
|
+
``engine_test``, ``engine_reload``, ``engine_models`` and ``voices`` with
|
|
41
|
+
``refresh``."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, send: Send):
|
|
44
|
+
self._send = send
|
|
45
|
+
|
|
46
|
+
def list(self) -> dict:
|
|
47
|
+
"""``engine_list``: the profiles, the built-in engines, kinds and presets."""
|
|
48
|
+
return self._send("engine_list", {})
|
|
49
|
+
|
|
50
|
+
def add(self, profile: dict, secret: Optional[str] = None, replace: bool = False) -> dict:
|
|
51
|
+
"""``engine_add``. It does not select the engine: ``set engine`` does."""
|
|
52
|
+
fields: dict = {"engine": profile}
|
|
53
|
+
if secret is not None:
|
|
54
|
+
fields["secret"] = secret
|
|
55
|
+
if replace:
|
|
56
|
+
fields["replace"] = True
|
|
57
|
+
return self._send("engine_add", fields)
|
|
58
|
+
|
|
59
|
+
def reload(self) -> dict:
|
|
60
|
+
"""``engine_reload`` (protocol 1.3): the runtime reads ``engines.json``
|
|
61
|
+
again. It takes no profile. Replies like ``list``, plus ``problems``."""
|
|
62
|
+
return self._send("engine_reload", {})
|
|
63
|
+
|
|
64
|
+
def remove(self, engine: str, forget_key: bool = True) -> dict:
|
|
65
|
+
"""``engine_remove``; the stored key goes too unless ``forget_key`` is false."""
|
|
66
|
+
fields: dict = {"engine": engine}
|
|
67
|
+
if not forget_key:
|
|
68
|
+
fields["forget_key"] = False
|
|
69
|
+
return self._send("engine_remove", fields)
|
|
70
|
+
|
|
71
|
+
def set_key(self, engine: str, secret: Optional[str]) -> dict:
|
|
72
|
+
"""``engine_key``: store a key, or delete it with ``None``."""
|
|
73
|
+
return self._send("engine_key", {"engine": engine, "secret": secret})
|
|
74
|
+
|
|
75
|
+
def test(self, engine: str, text: Optional[str] = None, voice: Optional[str] = None,
|
|
76
|
+
play: bool = True) -> dict:
|
|
77
|
+
"""``engine_test``: one synthesis with no fallback, at the current rate."""
|
|
78
|
+
fields: dict = {"engine": engine}
|
|
79
|
+
if text is not None:
|
|
80
|
+
fields["text"] = text
|
|
81
|
+
if voice is not None:
|
|
82
|
+
fields["voice"] = voice
|
|
83
|
+
if not play:
|
|
84
|
+
fields["play"] = False
|
|
85
|
+
return self._send("engine_test", fields)
|
|
86
|
+
|
|
87
|
+
def models(self, engine: Optional[str] = None, refresh: bool = False,
|
|
88
|
+
profile: Optional[dict] = None, secret: Optional[str] = None) -> dict:
|
|
89
|
+
"""``engine_models`` (protocol 1.5): the provider's models now, of a
|
|
90
|
+
saved ``engine`` (``refresh`` asks the provider again) or of a draft
|
|
91
|
+
``profile`` (with ``secret`` for this request only). Replies
|
|
92
|
+
``{models: [{id, name}], list, takes_model, required, error?}``."""
|
|
93
|
+
if profile is not None:
|
|
94
|
+
fields: dict = {"profile": profile}
|
|
95
|
+
if secret is not None:
|
|
96
|
+
fields["secret"] = secret
|
|
97
|
+
return self._send("engine_models", fields)
|
|
98
|
+
if engine is None:
|
|
99
|
+
raise ValueError("models needs an engine or a profile")
|
|
100
|
+
fields = {"engine": engine}
|
|
101
|
+
if refresh:
|
|
102
|
+
fields["refresh"] = True
|
|
103
|
+
return self._send("engine_models", fields)
|
|
104
|
+
|
|
105
|
+
def voices(self, engine: str, refresh: bool = False) -> list:
|
|
106
|
+
"""``voices`` of one engine; ``refresh`` asks the provider again."""
|
|
107
|
+
fields: dict = {"engine": engine}
|
|
108
|
+
if refresh:
|
|
109
|
+
fields["refresh"] = True
|
|
110
|
+
return self._send("voices", fields)["voices"]
|
sonara_client/errors.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Errors of the Sonara client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
# Client-side codes, next to the protocol's E_* codes that come from the runtime.
|
|
7
|
+
E_NOT_RUNNING = "E_NOT_RUNNING" # nothing running, nothing to start
|
|
8
|
+
E_START_FAILED = "E_START_FAILED" # the bundled runtime did not come up in time
|
|
9
|
+
E_CLOSED = "E_CLOSED" # the connection closed before the reply
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SonaraError(Exception):
|
|
13
|
+
"""A coded failure: a protocol error (``E_BUSY``, ``E_NOT_FOUND``...) or
|
|
14
|
+
one of the client's own (``E_NOT_RUNNING``, ``E_START_FAILED``,
|
|
15
|
+
``E_CLOSED``)."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, code: str, message: str, reason: Optional[str] = None):
|
|
18
|
+
super().__init__(f"{code}: {message}")
|
|
19
|
+
self.code = code
|
|
20
|
+
self.message = message
|
|
21
|
+
#: Protocol 1.2: why an external engine failed (``auth``, ``quota``,
|
|
22
|
+
#: ``network``...), on an ``E_ENGINE`` of ``engine_test``.
|
|
23
|
+
self.reason = reason
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Extension namespaces (spec sections 4.2 to 4.4).
|
|
2
|
+
|
|
3
|
+
Each method sends one protocol message as is; all behaviour lives in the
|
|
4
|
+
runtime. A runtime that does not offer the extension (or no client enabled
|
|
5
|
+
it in ``hello``) answers ``E_UNSUPPORTED``. ``extra`` keyword fields are
|
|
6
|
+
sent along unchanged, for fields a later protocol minor adds.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Callable, Optional, Sequence, Union
|
|
11
|
+
|
|
12
|
+
Send = Callable[[str, dict], dict]
|
|
13
|
+
Turn = Union[str, int]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _defined(fields: dict) -> dict:
|
|
17
|
+
return {k: v for k, v in fields.items() if v is not None}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Channels:
|
|
21
|
+
"""``channels``: named sources, each with its own queue and policy."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, send: Send):
|
|
24
|
+
self._send = send
|
|
25
|
+
|
|
26
|
+
def open(self, channel: str, label: Optional[str] = None, host_tab: Optional[str] = None,
|
|
27
|
+
policy: Optional[str] = None, keep_label: Optional[bool] = None,
|
|
28
|
+
**extra: Any) -> dict:
|
|
29
|
+
"""``channel_open`` (policy ``latest`` or ``queue``; ``keep_label``: an
|
|
30
|
+
open channel keeps the label it has, runtime 0.20.3)."""
|
|
31
|
+
return self._send("channel_open", _defined(
|
|
32
|
+
{**extra, "channel": channel, "label": label, "host_tab": host_tab, "policy": policy,
|
|
33
|
+
"keep_label": keep_label}))
|
|
34
|
+
|
|
35
|
+
def close(self, channel: str, **extra: Any) -> dict:
|
|
36
|
+
"""``channel_close``."""
|
|
37
|
+
return self._send("channel_close", _defined({**extra, "channel": channel}))
|
|
38
|
+
|
|
39
|
+
def focus(self, channel: str, **extra: Any) -> dict:
|
|
40
|
+
"""``focus``: bring a channel to the front."""
|
|
41
|
+
return self._send("focus", _defined({**extra, "channel": channel}))
|
|
42
|
+
|
|
43
|
+
def speak(self, channel: str, text: str, mode: Optional[str] = None, interrupt: Optional[bool] = None,
|
|
44
|
+
label: Optional[str] = None, **extra: Any) -> int:
|
|
45
|
+
"""``speak`` on a channel; the item id."""
|
|
46
|
+
r = self._send("speak", _defined({**extra, "channel": channel, "text": text, "mode": mode,
|
|
47
|
+
"interrupt": interrupt, "label": label}))
|
|
48
|
+
return r["item_id"]
|
|
49
|
+
|
|
50
|
+
def control(self, channel: str, action: str, **extra: Any) -> None:
|
|
51
|
+
"""``control`` scoped to a channel."""
|
|
52
|
+
self._send("control", _defined({**extra, "channel": channel, "action": action}))
|
|
53
|
+
|
|
54
|
+
def next_channel(self, **extra: Any) -> None:
|
|
55
|
+
"""``control`` ``next_channel``."""
|
|
56
|
+
self._send("control", _defined({**extra, "action": "next_channel"}))
|
|
57
|
+
|
|
58
|
+
def flush(self, **extra: Any) -> dict:
|
|
59
|
+
"""``control`` ``flush``: stop only the session being read (the flush hotkey, #228)."""
|
|
60
|
+
return self._send("control", _defined({**extra, "action": "flush"}))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Agent:
|
|
64
|
+
"""``agent`` (needs ``channels``): streaming turns, decisions, earcons."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, send: Send):
|
|
67
|
+
self._send = send
|
|
68
|
+
|
|
69
|
+
def stream(self, channel: str, turn: Turn, delta: str, index: int, final: bool,
|
|
70
|
+
t: Optional[float] = None, **extra: Any) -> dict:
|
|
71
|
+
"""``stream``: one delta of a turn's text (``t``: sender start time)."""
|
|
72
|
+
return self._send("stream", _defined({**extra, "channel": channel, "turn": turn, "delta": delta,
|
|
73
|
+
"index": index, "final": final, "t": t}))
|
|
74
|
+
|
|
75
|
+
def turn_start(self, channel: str, turn: Turn, **extra: Any) -> dict:
|
|
76
|
+
"""``turn_start``."""
|
|
77
|
+
return self._send("turn_start", _defined({**extra, "channel": channel, "turn": turn}))
|
|
78
|
+
|
|
79
|
+
def turn_end(self, channel: str, turn: Turn, **extra: Any) -> dict:
|
|
80
|
+
"""``turn_end``."""
|
|
81
|
+
return self._send("turn_end", _defined({**extra, "channel": channel, "turn": turn}))
|
|
82
|
+
|
|
83
|
+
def ask(self, channel: str, kind: str, text: str, options: Optional[Sequence[Any]] = None,
|
|
84
|
+
**extra: Any) -> dict:
|
|
85
|
+
"""``ask``: ``question``, ``permission`` or ``plan``, spoken with priority."""
|
|
86
|
+
return self._send("ask", _defined({**extra, "channel": channel, "kind": kind, "text": text,
|
|
87
|
+
"options": list(options) if options is not None else None}))
|
|
88
|
+
|
|
89
|
+
def earcon(self, kind: str, **extra: Any) -> dict:
|
|
90
|
+
"""``earcon``."""
|
|
91
|
+
return self._send("earcon", _defined({**extra, "kind": kind}))
|
|
92
|
+
|
|
93
|
+
def set_mute_level(self, level: int) -> dict:
|
|
94
|
+
"""``set mute_level`` 0, 1 or 2."""
|
|
95
|
+
return self._send("set", {"key": "mute_level", "value": level})
|
|
96
|
+
|
|
97
|
+
def set_summaries(self, value: dict) -> dict:
|
|
98
|
+
"""``set summaries``."""
|
|
99
|
+
return self._send("set", {"key": "summaries", "value": value})
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class System:
|
|
103
|
+
"""``system`` (Windows): other apps' audio, global hotkeys, the settings page."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, send: Send):
|
|
106
|
+
self._send = send
|
|
107
|
+
|
|
108
|
+
def set_audio_mode(self, mode: str) -> dict:
|
|
109
|
+
"""``set audio_mode`` (``duck``, ``pause`` or ``off``)."""
|
|
110
|
+
return self._send("set", {"key": "audio_mode", "value": mode})
|
|
111
|
+
|
|
112
|
+
def set_duck_level(self, level: int) -> dict:
|
|
113
|
+
"""``set duck_level``."""
|
|
114
|
+
return self._send("set", {"key": "duck_level", "value": level})
|
|
115
|
+
|
|
116
|
+
def set_hotkeys(self, hotkeys: dict) -> dict:
|
|
117
|
+
"""``set hotkeys``."""
|
|
118
|
+
return self._send("set", {"key": "hotkeys", "value": hotkeys})
|
|
119
|
+
|
|
120
|
+
def settings_url(self) -> str:
|
|
121
|
+
"""``get settings_url``."""
|
|
122
|
+
return self._send("get", {"key": "settings_url"})["value"]
|
sonara_client/version.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sonara-client
|
|
3
|
+
Version: 0.21.7
|
|
4
|
+
Summary: Client for the Sonara text-to-speech runtime (protocol v1). Standard library only.
|
|
5
|
+
Maintainer: Max Aubert
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Maxaubert/Sonara
|
|
8
|
+
Project-URL: Documentation, https://github.com/Maxaubert/Sonara/blob/main/docs/bundling.md
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# sonara-client
|
|
19
|
+
|
|
20
|
+
Talk to the Sonara text-to-speech runtime from Python 3.9+. Standard library only.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import sonara_client
|
|
24
|
+
|
|
25
|
+
with sonara_client.connect("my-app", runtime_path=r"C:\path\to\sonarad.exe") as sonara:
|
|
26
|
+
item = sonara.speak("Hello.", label="greeting")
|
|
27
|
+
sonara.set("rate", 240)
|
|
28
|
+
with sonara.subscribe(["state", "items"]) as events:
|
|
29
|
+
for e in events:
|
|
30
|
+
if e["event"] == "item" and e["item_id"] == item and e["phase"] != "started":
|
|
31
|
+
break
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- `connect(client_name, *, runtime_path=None, home=None, autostart=True, require=(), extensions=(), keep_alive=False)`: finds the shared runtime through `runtime.json`, starts the bundled `sonarad.exe` when none is usable (`runtime_path`, default `SONARA_RUNTIME`), takes over an idle incompatible one.
|
|
35
|
+
- `speak(text, mode=None, interrupt=None, label=None)` returns the item id; `control(action)`; `set(key, value)` / `get(key)`; `voices(engine=None)`.
|
|
36
|
+
- `subscribe(events)` opens an event stream on its own connection: iterate it, or `read(timeout)`.
|
|
37
|
+
- `channels`, `agent`, `system`: extension namespaces that send the protocol's extension messages as they are.
|
|
38
|
+
- Errors are `SonaraError` with a `code`. The client is thread-safe.
|
|
39
|
+
|
|
40
|
+
Guide: [Bundle Sonara in your app](https://github.com/Maxaubert/Sonara/blob/main/docs/bundling.md). Tests: `python -m pytest clients/python/tests -q` (the end-to-end ones need `cargo build -p sonarad`).
|
|
41
|
+
|
|
42
|
+
MIT. Ship `THIRD_PARTY_NOTICES.md` with an app that bundles the runtime.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
sonara_client/__init__.py,sha256=7rGPlHXBcTpsjI4pMCm3pHeefVoZ0rlrOS6Bjo3t-dI,981
|
|
2
|
+
sonara_client/client.py,sha256=K7_D1Le9IZ81cEeGWdNJX2SUlOFEXR7CPSDSxjpw_nM,5044
|
|
3
|
+
sonara_client/connect.py,sha256=AxdF43OXhQWmhEcEtUGt7Qjkx5X8Xq9yQ2Jz27HknBY,6640
|
|
4
|
+
sonara_client/connection.py,sha256=C7mOYbT-zsrpmN436S9I_3vKd9Gd4CBuqLltVT8BaUs,4431
|
|
5
|
+
sonara_client/discovery.py,sha256=6NwlW3_v5KtPRHBM-vvNB1_Enw76oqsKlEPqiqKp0kg,5496
|
|
6
|
+
sonara_client/engines.py,sha256=i9ZHpsBuISpmOTZvD1hrNkpkD-3feGULabGU6yH-EJg,5133
|
|
7
|
+
sonara_client/errors.py,sha256=_8-bjcNW9ItXhWnWio4sFJUjxZBCM2dbmDam-tQsWf4,974
|
|
8
|
+
sonara_client/extensions.py,sha256=mvESGvKOeuoLFXpBy81BzPNWH0wg2EckpZuD6qGqR94,5489
|
|
9
|
+
sonara_client/version.py,sha256=AmDOI4GTzDNPVD_JAoKqGqBldArZ8PV4Ja9OjtHdyCA,158
|
|
10
|
+
sonara_client-0.21.7.dist-info/licenses/LICENSE,sha256=Jd33EBnpxUDjmvOo4I4R6kpK8CMf_EJccRSlQDLmHF4,1089
|
|
11
|
+
sonara_client-0.21.7.dist-info/METADATA,sha256=n4USDnTdKuD8CTq8pLqdXF6a5HQnGUUTEKuVmTSfl0Y,2205
|
|
12
|
+
sonara_client-0.21.7.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
sonara_client-0.21.7.dist-info/top_level.txt,sha256=WnnLWWDza1RBQlgqm-ODOPGO1zsQKKzdEn0MOTx2gpg,14
|
|
14
|
+
sonara_client-0.21.7.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nima Hakimi
|
|
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 @@
|
|
|
1
|
+
sonara_client
|