readio 0.1.0__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.
- readio/__init__.py +10 -0
- readio/__main__.py +3 -0
- readio/audio.py +139 -0
- readio/cli.py +614 -0
- readio/config.py +401 -0
- readio/document.py +31 -0
- readio/errors.py +39 -0
- readio/ingest.py +68 -0
- readio/paths.py +87 -0
- readio/reader.py +170 -0
- readio/resources/__init__.py +0 -0
- readio/resources/templates/__init__.py +0 -0
- readio/resources/templates/briefing.ssmd +5 -0
- readio/resources/templates/dialogue.ssmd +9 -0
- readio/resources/templates/podcast.ssmd +13 -0
- readio/spotify.py +140 -0
- readio/ssmd.py +161 -0
- readio/ssmd_authoring.py +127 -0
- readio/templates.py +117 -0
- readio/text.py +25 -0
- readio/wave.py +87 -0
- readio-0.1.0.dist-info/METADATA +151 -0
- readio-0.1.0.dist-info/RECORD +27 -0
- readio-0.1.0.dist-info/WHEEL +5 -0
- readio-0.1.0.dist-info/entry_points.txt +2 -0
- readio-0.1.0.dist-info/licenses/LICENSE +201 -0
- readio-0.1.0.dist-info/top_level.txt +1 -0
readio/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""readio: a terminal streaming text-to-speech reader."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("readio")
|
|
7
|
+
except PackageNotFoundError: # running directly from an unpacked source tree
|
|
8
|
+
__version__ = "0+unknown"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
readio/__main__.py
ADDED
readio/audio.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .config import ReaderSettings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AudioSink(Protocol):
|
|
14
|
+
"""Synchronous destination for one rendered waveform chunk."""
|
|
15
|
+
|
|
16
|
+
def write(self, audio: np.ndarray, sample_rate: int) -> None: ...
|
|
17
|
+
|
|
18
|
+
def close(self) -> None: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class RenderSummary:
|
|
23
|
+
sample_rate: int = 0
|
|
24
|
+
sample_count: int = 0
|
|
25
|
+
channels: int = 0
|
|
26
|
+
document_metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
27
|
+
markers: tuple[dict[str, Any], ...] = ()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PlaybackSink:
|
|
31
|
+
"""Send rendered chunks through one persistent PyKokoro player."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, cfg: ReaderSettings) -> None:
|
|
34
|
+
self._cfg = cfg
|
|
35
|
+
self._player: Any = None
|
|
36
|
+
self._sample_rate: int | None = None
|
|
37
|
+
self._channels: int | None = None
|
|
38
|
+
self._closed = False
|
|
39
|
+
|
|
40
|
+
def write(self, audio: np.ndarray, sample_rate: int) -> None:
|
|
41
|
+
if self._closed:
|
|
42
|
+
raise RuntimeError("audio sink is closed")
|
|
43
|
+
if audio.ndim == 1:
|
|
44
|
+
channels = 1
|
|
45
|
+
elif audio.ndim == 2:
|
|
46
|
+
channels = int(audio.shape[1])
|
|
47
|
+
else:
|
|
48
|
+
channels = 0
|
|
49
|
+
if channels <= 0:
|
|
50
|
+
raise ValueError("rendered audio must be a one- or two-dimensional array")
|
|
51
|
+
if self._player is None:
|
|
52
|
+
from pykokoro.playback import SoundDevicePlayer
|
|
53
|
+
|
|
54
|
+
self._sample_rate = sample_rate
|
|
55
|
+
self._channels = channels
|
|
56
|
+
self._player = SoundDevicePlayer(
|
|
57
|
+
sample_rate,
|
|
58
|
+
device=self._cfg.device,
|
|
59
|
+
queue_size=self._cfg.queue_size,
|
|
60
|
+
channels=channels,
|
|
61
|
+
)
|
|
62
|
+
self._player.start()
|
|
63
|
+
elif sample_rate != self._sample_rate or channels != self._channels:
|
|
64
|
+
raise ValueError("all rendered chunks must use the same sample rate and channel count")
|
|
65
|
+
self._player.submit(audio)
|
|
66
|
+
|
|
67
|
+
def finish(self) -> None:
|
|
68
|
+
if self._player is not None:
|
|
69
|
+
self._player.drain()
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
if not self._closed:
|
|
73
|
+
self._closed = True
|
|
74
|
+
if self._player is not None:
|
|
75
|
+
self._player.close()
|
|
76
|
+
|
|
77
|
+
def __enter__(self) -> PlaybackSink: # noqa: PYI034
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(
|
|
81
|
+
self,
|
|
82
|
+
exc_type: type[BaseException] | None,
|
|
83
|
+
exc_value: BaseException | None,
|
|
84
|
+
traceback: TracebackType | None,
|
|
85
|
+
) -> None:
|
|
86
|
+
self.close()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def render_prepared(
|
|
90
|
+
prepared: Any,
|
|
91
|
+
sink: AudioSink,
|
|
92
|
+
*,
|
|
93
|
+
indices: tuple[int, ...] | None = None,
|
|
94
|
+
) -> RenderSummary:
|
|
95
|
+
sample_rate = 0
|
|
96
|
+
sample_count = 0
|
|
97
|
+
channels = 0
|
|
98
|
+
markers: list[dict[str, Any]] = []
|
|
99
|
+
metadata = dict(getattr(prepared, "document_metadata", {}) or {})
|
|
100
|
+
|
|
101
|
+
for result in prepared.render(indices=indices):
|
|
102
|
+
try:
|
|
103
|
+
audio = result.audio
|
|
104
|
+
if audio.ndim == 1:
|
|
105
|
+
result_channels = 1
|
|
106
|
+
elif audio.ndim == 2:
|
|
107
|
+
result_channels = int(audio.shape[1])
|
|
108
|
+
else:
|
|
109
|
+
result_channels = 0
|
|
110
|
+
if result_channels <= 0:
|
|
111
|
+
raise ValueError("rendered audio must be a one- or two-dimensional array")
|
|
112
|
+
if not sample_count:
|
|
113
|
+
sample_rate = int(result.sample_rate)
|
|
114
|
+
channels = result_channels
|
|
115
|
+
if not metadata:
|
|
116
|
+
metadata = dict(getattr(result, "document_metadata", {}) or {})
|
|
117
|
+
elif result.sample_rate != sample_rate or result_channels != channels:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
"all rendered chunks must use the same sample rate and channel count"
|
|
120
|
+
)
|
|
121
|
+
sink.write(audio, result.sample_rate)
|
|
122
|
+
markers.extend(
|
|
123
|
+
{
|
|
124
|
+
**marker,
|
|
125
|
+
"sample_offset": int(marker["sample_offset"]) + sample_count,
|
|
126
|
+
}
|
|
127
|
+
for marker in getattr(result, "markers", ())
|
|
128
|
+
)
|
|
129
|
+
sample_count += len(audio)
|
|
130
|
+
finally:
|
|
131
|
+
result.release_audio()
|
|
132
|
+
|
|
133
|
+
return RenderSummary(
|
|
134
|
+
sample_rate=sample_rate,
|
|
135
|
+
sample_count=sample_count,
|
|
136
|
+
channels=channels,
|
|
137
|
+
document_metadata=metadata,
|
|
138
|
+
markers=tuple(markers),
|
|
139
|
+
)
|