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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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
+ )