voce 0.2.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.
- voce/__init__.py +3 -0
- voce/__main__.py +3 -0
- voce/cli.py +323 -0
- voce/commands.py +155 -0
- voce/document.py +219 -0
- voce/export.py +115 -0
- voce/highlight.py +195 -0
- voce/markdown/__init__.py +6 -0
- voce/markdown/blocks.py +215 -0
- voce/markdown/inline.py +154 -0
- voce/markdown/tables.py +81 -0
- voce/paths.py +26 -0
- voce/player.py +234 -0
- voce/player.swift +90 -0
- voce/playlist.py +102 -0
- voce/prepared.py +114 -0
- voce/sources/__init__.py +35 -0
- voce/sources/claude.py +78 -0
- voce/sources/clips.py +75 -0
- voce/speech/__init__.py +9 -0
- voce/speech/code.py +341 -0
- voce/speech/prose.py +43 -0
- voce/speech/sentences.py +28 -0
- voce/speech/words.py +217 -0
- voce/state.py +217 -0
- voce/sync.py +176 -0
- voce/ui/__init__.py +1 -0
- voce/ui/layout.py +111 -0
- voce/ui/menu.py +74 -0
- voce/ui/reader.py +507 -0
- voce/ui/session.py +51 -0
- voce/ui/terminal.py +153 -0
- voce/ui/theme.py +141 -0
- voce/ui/view.py +215 -0
- voce/voices.py +767 -0
- voce-0.2.0.dist-info/METADATA +332 -0
- voce-0.2.0.dist-info/RECORD +40 -0
- voce-0.2.0.dist-info/WHEEL +4 -0
- voce-0.2.0.dist-info/entry_points.txt +2 -0
- voce-0.2.0.dist-info/licenses/LICENSE +21 -0
voce/__init__.py
ADDED
voce/__main__.py
ADDED
voce/cli.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""The voce command: the arguments, then which of the sources to read and which command to run with it.
|
|
2
|
+
|
|
3
|
+
parser() the arguments
|
|
4
|
+
main() the command: sources (sources/), then one of the printing commands (commands.py) or the reader
|
|
5
|
+
(ui/session.py)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from voce import __version__, commands, sources, sync, voices
|
|
15
|
+
from voce.document import load
|
|
16
|
+
from voce.export import ExportError
|
|
17
|
+
from voce.player import find_players, make_player
|
|
18
|
+
from voce.playlist import Entry, PlaylistError, find_playlist, part_matches, read_playlist
|
|
19
|
+
from voce.state import State
|
|
20
|
+
from voce.voices import ENGINES, Voice
|
|
21
|
+
|
|
22
|
+
USAGE = """\
|
|
23
|
+
examples:
|
|
24
|
+
voce notes.md read a Markdown file, from where you left off
|
|
25
|
+
voce -r read Claude Code's latest reply in this folder (run it in another terminal)
|
|
26
|
+
voce -c read what's on the clipboard
|
|
27
|
+
cat notes.md | voce - read standard input
|
|
28
|
+
voce a.md b.md --export walk.mp3 one MP3 for a walk, with the same voice and pauses
|
|
29
|
+
voce carry on where you left off: your playlist, or the last thing you read
|
|
30
|
+
voce --playlist DIR use DIR/README.md's "Listen in this order" list from now on
|
|
31
|
+
voce --menu choose a file from the playlist
|
|
32
|
+
voce --sync you/voce keep your places and documents in a private GitHub repository, for other devices
|
|
33
|
+
|
|
34
|
+
keys:
|
|
35
|
+
space pause · ←/→ sentence · ↑/↓ paragraph · [ ] section or file · +/- speed
|
|
36
|
+
n/p next or previous file · v voice · V engine · m menu · r restart · q quit (your place is saved)
|
|
37
|
+
click a sentence or a line of code to jump to it · drag to copy · the wheel scrolls
|
|
38
|
+
|
|
39
|
+
voices:
|
|
40
|
+
Kokoro, a small open voice that runs on your computer, works out of the box and costs nothing. Google (Chirp 3 HD),
|
|
41
|
+
Azure, ElevenLabs and OpenAI are used when their keys are set: VOCE_GOOGLE_API_KEY (or gcloud, signed in,
|
|
42
|
+
with --google-project once), VOCE_AZURE_KEY and VOCE_AZURE_REGION, VOCE_ELEVENLABS_API_KEY,
|
|
43
|
+
VOCE_OPENAI_API_KEY. voce --voices shows what is set up. Every sentence is fetched once and kept.
|
|
44
|
+
|
|
45
|
+
https://github.com/jaystewartuk/voce
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def speed(value: str) -> float:
|
|
50
|
+
try:
|
|
51
|
+
x = float(value)
|
|
52
|
+
except ValueError:
|
|
53
|
+
x = float("nan")
|
|
54
|
+
if not 0.5 <= x <= 3.0: # also false for NaN
|
|
55
|
+
raise argparse.ArgumentTypeError(f"{value!r} isn't a speed from 0.5 to 3")
|
|
56
|
+
return x
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parser() -> argparse.ArgumentParser:
|
|
60
|
+
ap = argparse.ArgumentParser(
|
|
61
|
+
prog="voce", description="Read Markdown aloud in your terminal, highlighting each sentence and word.",
|
|
62
|
+
formatter_class=argparse.RawDescriptionHelpFormatter, epilog=USAGE)
|
|
63
|
+
ap.add_argument("files", nargs="*", metavar="FILE", help="Markdown files to read, or - for standard input")
|
|
64
|
+
ap.add_argument("-r", "--reply", action="store_true",
|
|
65
|
+
help="read Claude Code's latest reply in this folder (the text /copy would take)")
|
|
66
|
+
ap.add_argument("-c", "--clip", action="store_true", help="read what is on the clipboard")
|
|
67
|
+
ap.add_argument("--start", action="store_true", help="start the file from the top")
|
|
68
|
+
ap.add_argument("--one", action="store_true", help="stop at the end of the file instead of carrying on")
|
|
69
|
+
ap.add_argument("--skip-code", action="store_true", help="show code blocks and diffs without reading them")
|
|
70
|
+
ap.add_argument("--speed", type=speed, metavar="X", help="playback speed from 0.5 to 3, for example 1.2")
|
|
71
|
+
|
|
72
|
+
playlist = ap.add_argument_group("playlists")
|
|
73
|
+
playlist.add_argument("--playlist", metavar="DIR", help="use this folder's README.md playlist from now on")
|
|
74
|
+
playlist.add_argument("--menu", action="store_true", help="choose a file from the playlist first")
|
|
75
|
+
playlist.add_argument("--list", action="store_true", help="print the playlist with your progress")
|
|
76
|
+
playlist.add_argument("--quiz", nargs="?", const="", metavar="PART",
|
|
77
|
+
help="quizzes only: all of them, one part (number or name) or a file")
|
|
78
|
+
playlist.add_argument("--minutes", action="store_true", help="update the minutes in the playlist README")
|
|
79
|
+
|
|
80
|
+
voice = ap.add_argument_group("voices")
|
|
81
|
+
voice.add_argument("--engine", choices=list(ENGINES), metavar="ENGINE",
|
|
82
|
+
help="which voices to use: kokoro, mac, google, azure, elevenlabs or openai (saved)")
|
|
83
|
+
voice.add_argument("--voice", metavar="NAME",
|
|
84
|
+
help="a voice, such as af_heart, en-US-Chirp3-HD-Charon, en-GB-RyanNeural, George or ash; "
|
|
85
|
+
"the engine follows from the name (saved)")
|
|
86
|
+
voice.add_argument("--mac", action="store_true", help="the same as --engine mac")
|
|
87
|
+
voice.add_argument("--voices", action="store_true", help="show the engines, which are set up, and their voices")
|
|
88
|
+
voice.add_argument("--google-project", metavar="ID", help="the Google Cloud project to bill (saved)")
|
|
89
|
+
voice.add_argument("--prepare", nargs="*", metavar="PART",
|
|
90
|
+
help="fetch the voice ahead of time: for the files given, or the playlist (or these parts)")
|
|
91
|
+
|
|
92
|
+
tools = ap.add_argument_group("other")
|
|
93
|
+
tools.add_argument("--export", metavar="OUT.mp3",
|
|
94
|
+
help="write the files (or reply, clipboard or playlist) as one MP3 instead of playing")
|
|
95
|
+
tools.add_argument("--check", nargs="*", metavar="FILE",
|
|
96
|
+
help="report how files will read, without audio (default: the playlist)")
|
|
97
|
+
tools.add_argument("--sync", nargs="?", const="", metavar="REPO",
|
|
98
|
+
help="push your places and prepared documents to a git repository and pull the other "
|
|
99
|
+
"devices'; give OWNER/REPO or a git URL the first time")
|
|
100
|
+
tools.add_argument("--plain-player", "--afplay", dest="plain", action="store_true",
|
|
101
|
+
help="play one sentence at a time with afplay, mpv or ffplay, not the built-in player")
|
|
102
|
+
tools.add_argument("--mute", action="store_true", help=argparse.SUPPRESS)
|
|
103
|
+
tools.add_argument("--version", action="version", version=f"voce {__version__}")
|
|
104
|
+
return ap
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------- choosing what to play
|
|
108
|
+
|
|
109
|
+
def playlist_path(args, state: State) -> Path | None:
|
|
110
|
+
if args.playlist:
|
|
111
|
+
path = find_playlist(args.playlist)
|
|
112
|
+
state.set("playlist", str(path))
|
|
113
|
+
state.save(force=True)
|
|
114
|
+
print(f"Playlist: {path}")
|
|
115
|
+
return path
|
|
116
|
+
saved = state.get("playlist")
|
|
117
|
+
return Path(saved) if saved and Path(saved).exists() else None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def choose(args, state: State, entries: list[Entry]) -> tuple[list[Entry], int]:
|
|
121
|
+
"""The files to play and where to start: the file given, or the first unfinished file in the playlist."""
|
|
122
|
+
if args.files:
|
|
123
|
+
paths = existing(args.files)
|
|
124
|
+
if len(paths) == 1:
|
|
125
|
+
for i, e in enumerate(entries):
|
|
126
|
+
if e.path == paths[0]:
|
|
127
|
+
return entries, i
|
|
128
|
+
return [Entry(p, load(p).title) for p in paths], 0
|
|
129
|
+
last = state.get("last")
|
|
130
|
+
if not entries:
|
|
131
|
+
if last and Path(last).exists():
|
|
132
|
+
return [Entry(Path(last), load(last).title)], 0
|
|
133
|
+
sys.exit("Nothing to read yet. Give a Markdown file, use -r for Claude Code's latest reply or -c for the "
|
|
134
|
+
"clipboard, or set a playlist with --playlist DIR. See voce --help.")
|
|
135
|
+
for i, e in enumerate(entries):
|
|
136
|
+
if str(e.path) == last and not state.is_done(e.path):
|
|
137
|
+
return entries, i
|
|
138
|
+
for i, e in enumerate(entries):
|
|
139
|
+
if not state.is_done(e.path):
|
|
140
|
+
return entries, i
|
|
141
|
+
return entries, 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def existing(files: list[str]) -> list[Path]:
|
|
145
|
+
paths = [Path(f).expanduser().resolve() for f in files]
|
|
146
|
+
missing = [f for f, p in zip(files, paths) if not p.is_file()]
|
|
147
|
+
if missing:
|
|
148
|
+
sys.exit(f"No such file: {', '.join(missing)}")
|
|
149
|
+
return paths
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def quizzes(which: str, entries: list[Entry]) -> list[Entry]:
|
|
153
|
+
from voce.document import quiz_pairs
|
|
154
|
+
|
|
155
|
+
files = [e for e in entries if quiz_pairs(load(e.path).blocks)]
|
|
156
|
+
if which and Path(which).exists():
|
|
157
|
+
path = Path(which).resolve()
|
|
158
|
+
files = [e for e in files if e.path == path] or [Entry(path, load(path).title)]
|
|
159
|
+
elif which:
|
|
160
|
+
files = [e for e in files if part_matches(e.part, which)]
|
|
161
|
+
if not files:
|
|
162
|
+
sys.exit("No quizzes found. A quiz is a numbered list under a \"## Quiz\" heading, with the same number "
|
|
163
|
+
"of answers under \"## Answers\".")
|
|
164
|
+
return files
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def pick_voice(args, state: State) -> Voice:
|
|
168
|
+
"""The engine asked for, or the one a voice name belongs to, or the one used last time, or the best set up."""
|
|
169
|
+
kind = args.engine or ("mac" if args.mac else "")
|
|
170
|
+
if not kind and args.voice:
|
|
171
|
+
kind = voices.engine_for(args.voice, state)
|
|
172
|
+
if not kind:
|
|
173
|
+
kind = voices.default_engine(state)
|
|
174
|
+
if not ENGINES[kind].configured(state):
|
|
175
|
+
sys.exit(f"{ENGINES[kind].title} isn't set up. {ENGINES[kind].setup}")
|
|
176
|
+
voice = voices.make(kind, args.voice or "", state)
|
|
177
|
+
if args.engine or args.voice or args.mac:
|
|
178
|
+
voices.remember(state, voice)
|
|
179
|
+
state.save(force=True)
|
|
180
|
+
return voice
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def needs_player(args) -> None:
|
|
184
|
+
if args.mute or find_players():
|
|
185
|
+
return
|
|
186
|
+
if sys.platform == "darwin":
|
|
187
|
+
sys.exit("afplay not found: voce plays audio with macOS's own tools.")
|
|
188
|
+
sys.exit("No audio player found. Install mpv (or ffmpeg, for ffplay), for example: sudo apt install mpv")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------- syncing
|
|
192
|
+
|
|
193
|
+
def pull_places(state: State) -> None:
|
|
194
|
+
"""Before anything else, the other devices' places, when syncing is set up. Being offline is not an error."""
|
|
195
|
+
if not state.get("sync") or not sync.ready(state.root):
|
|
196
|
+
return
|
|
197
|
+
try:
|
|
198
|
+
if sync.pull(state.root, timeout=15):
|
|
199
|
+
state.__init__(state.root) # the files changed under us
|
|
200
|
+
except sync.SyncError as e:
|
|
201
|
+
print(f"Couldn't pull your places: {e}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def push_places(state: State) -> None:
|
|
205
|
+
if not state.get("sync") or not sync.ready(state.root):
|
|
206
|
+
return
|
|
207
|
+
try:
|
|
208
|
+
if sync.commit(state.root):
|
|
209
|
+
sync.pull(state.root, timeout=15)
|
|
210
|
+
sync.push(state.root, timeout=30)
|
|
211
|
+
print("Synced.")
|
|
212
|
+
except sync.SyncError as e:
|
|
213
|
+
print(f"Couldn't sync your places: {e}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------- main
|
|
217
|
+
|
|
218
|
+
def main(argv: list[str] | None = None) -> None:
|
|
219
|
+
args = parser().parse_args(argv)
|
|
220
|
+
state = State()
|
|
221
|
+
|
|
222
|
+
if args.google_project:
|
|
223
|
+
state.set("google_project", args.google_project)
|
|
224
|
+
state.save(force=True)
|
|
225
|
+
print(f"Google project: {args.google_project}")
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
readme = playlist_path(args, state)
|
|
229
|
+
source = sources.resolve(args)
|
|
230
|
+
except (PlaylistError, sources.SourceError) as e:
|
|
231
|
+
sys.exit(str(e))
|
|
232
|
+
if source is not None:
|
|
233
|
+
args.files, args.one = [str(source)], True
|
|
234
|
+
entries = read_playlist(readme)
|
|
235
|
+
if args.sync is not None:
|
|
236
|
+
try:
|
|
237
|
+
commands.sync_up(state, entries, args.sync)
|
|
238
|
+
except sync.SyncError as e:
|
|
239
|
+
sys.exit(f"Sync failed: {e}")
|
|
240
|
+
return
|
|
241
|
+
pull_places(state)
|
|
242
|
+
if args.voices:
|
|
243
|
+
commands.print_voices(state)
|
|
244
|
+
return
|
|
245
|
+
voice = pick_voice(args, state)
|
|
246
|
+
|
|
247
|
+
if args.check is not None:
|
|
248
|
+
targets = [Path(p) for p in args.check] or [Path(f) for f in args.files] or [e.path for e in entries]
|
|
249
|
+
if not targets:
|
|
250
|
+
sys.exit("Nothing to check: give files, or set a playlist with --playlist DIR.")
|
|
251
|
+
commands.check(targets, args.skip_code)
|
|
252
|
+
return
|
|
253
|
+
if args.list:
|
|
254
|
+
if not entries:
|
|
255
|
+
sys.exit("No playlist set. Use --playlist DIR.")
|
|
256
|
+
commands.print_list(state, entries, voice)
|
|
257
|
+
return
|
|
258
|
+
if args.minutes:
|
|
259
|
+
if readme is None:
|
|
260
|
+
sys.exit("No playlist set. Use --playlist DIR.")
|
|
261
|
+
commands.print_minutes(readme)
|
|
262
|
+
return
|
|
263
|
+
if args.export:
|
|
264
|
+
paths = existing(args.files) if args.files else [e.path for e in entries]
|
|
265
|
+
if not paths:
|
|
266
|
+
sys.exit("Nothing to export: give files, use -r or -c, or set a playlist with --playlist DIR.")
|
|
267
|
+
try:
|
|
268
|
+
commands.export(paths, Path(args.export), voice, state, args.skip_code)
|
|
269
|
+
except ExportError as e:
|
|
270
|
+
sys.exit(str(e))
|
|
271
|
+
return
|
|
272
|
+
if args.prepare is not None:
|
|
273
|
+
if args.files:
|
|
274
|
+
paths = existing(args.files)
|
|
275
|
+
else:
|
|
276
|
+
wanted = args.prepare
|
|
277
|
+
paths = [e.path for e in entries if not wanted or any(part_matches(e.part, p) for p in wanted)]
|
|
278
|
+
if not paths:
|
|
279
|
+
sys.exit("Nothing to prepare: give files, use -r or -c, or set a playlist with --playlist DIR.")
|
|
280
|
+
commands.prepare(paths, voice, state, entries)
|
|
281
|
+
push_places(state)
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
needs_player(args)
|
|
285
|
+
try:
|
|
286
|
+
voice.engine.prepare(print)
|
|
287
|
+
except RuntimeError as e:
|
|
288
|
+
if not ENGINES["mac"].configured(state):
|
|
289
|
+
sys.exit(str(e))
|
|
290
|
+
print(f"{e}. Using the Mac voice for now.")
|
|
291
|
+
voice = voices.make("mac", "", state)
|
|
292
|
+
if args.quiz is not None:
|
|
293
|
+
playlist, start, quiz_only = quizzes(args.quiz, entries), 0, True
|
|
294
|
+
else:
|
|
295
|
+
(playlist, start), quiz_only = choose(args, state, entries), False
|
|
296
|
+
if args.start and not quiz_only:
|
|
297
|
+
state.restart(playlist[start].path)
|
|
298
|
+
|
|
299
|
+
from voce.ui.reader import Options
|
|
300
|
+
from voce.ui.session import play
|
|
301
|
+
|
|
302
|
+
message = "" if voice.kind != "mac" or args.mac else \
|
|
303
|
+
"Using the Mac voice; voce --voices shows the better ones."
|
|
304
|
+
options = Options(one=args.one, quiz_only=quiz_only, skip_code=args.skip_code,
|
|
305
|
+
speed=args.speed or state.get("speed", 1.0), message=message)
|
|
306
|
+
|
|
307
|
+
def switch(current: Voice, what: str) -> Voice | None:
|
|
308
|
+
"""The next voice of the same engine, or (what == "engine") the same voice family on the next engine."""
|
|
309
|
+
if what == "voice":
|
|
310
|
+
name = current.engine.next_name()
|
|
311
|
+
return None if name == current.name else voices.make(current.kind, name, state)
|
|
312
|
+
kinds = voices.available(state)
|
|
313
|
+
kind = kinds[(kinds.index(current.kind) + 1) % len(kinds)] if current.kind in kinds else kinds[0]
|
|
314
|
+
return None if kind == current.kind else voices.make(kind, "", state)
|
|
315
|
+
|
|
316
|
+
player = make_player(mute=args.mute, plain=args.plain)
|
|
317
|
+
play(playlist, start, options, state, voice, switch, player, show_menu=args.menu)
|
|
318
|
+
print("Your place is saved." + (f" {voice.usage()}." if voice.usage() else ""))
|
|
319
|
+
push_places(state)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
if __name__ == "__main__":
|
|
323
|
+
main()
|
voce/commands.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""The commands that print instead of playing: --check, --list, --minutes, --prepare, --export and --sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import concurrent.futures
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from voce import prepared, sync, voices
|
|
9
|
+
from voce.document import load, minutes, quiz_pairs, word_count
|
|
10
|
+
from voce.export import export_mp3
|
|
11
|
+
from voce.playlist import Entry, update_minutes
|
|
12
|
+
from voce.state import State
|
|
13
|
+
from voce.voices import Voice
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def relative(path: Path) -> str:
|
|
17
|
+
try:
|
|
18
|
+
return str(path.relative_to(Path.cwd()))
|
|
19
|
+
except ValueError:
|
|
20
|
+
return str(path).replace(str(Path.home()), "~", 1)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check(paths: list[Path], skip_code: bool = False) -> None:
|
|
24
|
+
"""How each file will read: sentences, words, minutes, characters for the voice, quiz pairing, oddities."""
|
|
25
|
+
total_words = total_chars = 0
|
|
26
|
+
for path in paths:
|
|
27
|
+
try:
|
|
28
|
+
doc = load(path, skip_code=skip_code)
|
|
29
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
30
|
+
print(f"{relative(Path(path))}\n couldn't read it: {e}")
|
|
31
|
+
continue
|
|
32
|
+
words, chars = word_count(doc), sum(len(u.say) for u in doc.units)
|
|
33
|
+
total_words += words
|
|
34
|
+
total_chars += chars
|
|
35
|
+
pairs = quiz_pairs(doc.blocks)
|
|
36
|
+
notes = []
|
|
37
|
+
code = sum(1 for b in doc.blocks if b.kind == "code")
|
|
38
|
+
if code:
|
|
39
|
+
how = "shown, not read" if skip_code else "read line by line"
|
|
40
|
+
notes.append(f"{code} code block{'s' * (code != 1)}, {how}")
|
|
41
|
+
long = [u for u in doc.units if len(u.say) > 400]
|
|
42
|
+
if long:
|
|
43
|
+
notes.append(f"{len(long)} sentence{'s' * (len(long) != 1)} over 400 characters")
|
|
44
|
+
if any(b.kind == "h2" and b.text.lower() in ("quiz", "answers") for b in doc.blocks) and not pairs:
|
|
45
|
+
notes.append("the quiz and answers don't pair up")
|
|
46
|
+
quiz = f" · quiz of {len(pairs)}" if pairs else ""
|
|
47
|
+
print(f"{relative(doc.path)}\n {len(doc.units)} sentences · {words:,} words · "
|
|
48
|
+
f"{minutes(words):.0f} min · {chars:,} characters{quiz}"
|
|
49
|
+
+ (f"\n note: {', '.join(notes)}" if notes else ""))
|
|
50
|
+
if len(paths) > 1:
|
|
51
|
+
print(f"\nTotal: {total_words:,} words, about {minutes(total_words) / 60:.1f} hours at 1×, "
|
|
52
|
+
f"{total_chars:,} characters for the voice.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def print_list(state: State, entries: list[Entry], voice: Voice) -> None:
|
|
56
|
+
part = None
|
|
57
|
+
for e in entries:
|
|
58
|
+
if e.part != part:
|
|
59
|
+
part = e.part
|
|
60
|
+
print(f"\n{part}" if part else "")
|
|
61
|
+
print(f" {state.progress(e.path):>5} {e.title}")
|
|
62
|
+
if voice.usage():
|
|
63
|
+
print(f"\n{voice.usage()}.")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def print_minutes(readme: Path) -> None:
|
|
67
|
+
total = update_minutes(readme)
|
|
68
|
+
print(f"Updated {relative(readme)}: {total:,} words, about {minutes(total) / 60:.1f} hours at 1×.")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def prepare(paths: list[Path], voice: Voice, state: State, entries: list[Entry] = ()) -> None:
|
|
72
|
+
"""Fetch the voice for these files ahead of time, so reading them never waits on the network, and keep a
|
|
73
|
+
prepared copy of each for other devices."""
|
|
74
|
+
docs = [load(p) for p in paths]
|
|
75
|
+
for doc in docs:
|
|
76
|
+
prepared.save(doc, state)
|
|
77
|
+
prepared.index(state, list(entries))
|
|
78
|
+
texts = [u.say for doc in docs for u in doc.units]
|
|
79
|
+
todo = [t for t in dict.fromkeys(texts) if not voice.path(t).exists()]
|
|
80
|
+
chars = sum(len(t) for t in todo)
|
|
81
|
+
print(f"{len(paths)} file{'s' * (len(paths) != 1)}, {len(texts)} sentences, {len(todo)} not yet fetched "
|
|
82
|
+
f"({chars:,} characters).")
|
|
83
|
+
if voice.over_free(chars):
|
|
84
|
+
print(f"Warning: that takes this month past the {voice.engine.free_chars:,} free characters.")
|
|
85
|
+
voice.engine.prepare(print)
|
|
86
|
+
done = failed = 0
|
|
87
|
+
try:
|
|
88
|
+
for future in concurrent.futures.as_completed([voice.pool.submit(voice.fetch, t) for t in todo]):
|
|
89
|
+
done += 1
|
|
90
|
+
if future.exception() is not None:
|
|
91
|
+
failed += 1
|
|
92
|
+
if done % 10 == 0 or done == len(todo):
|
|
93
|
+
print(f"\r {done}/{len(todo)}", end="", flush=True)
|
|
94
|
+
state.save()
|
|
95
|
+
except KeyboardInterrupt:
|
|
96
|
+
print("\nStopped; what was fetched is kept.")
|
|
97
|
+
finally:
|
|
98
|
+
voice.close()
|
|
99
|
+
state.save(force=True)
|
|
100
|
+
if failed:
|
|
101
|
+
print(f"\n{failed} sentence{'s' * (failed != 1)} couldn't be fetched; they will be tried again when read.")
|
|
102
|
+
if voice.warning:
|
|
103
|
+
print(f"\n{voice.warning}")
|
|
104
|
+
if voice.usage():
|
|
105
|
+
print(f"\n{voice.usage()}.")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def print_voices(state: State) -> None:
|
|
109
|
+
"""Each engine: whether it is set up, how to set it up if not, and the voices the v key goes through."""
|
|
110
|
+
using = voices.default_engine(state)
|
|
111
|
+
for kind, engine_class in voices.ENGINES.items():
|
|
112
|
+
engine = engine_class(voices.saved_name(state, kind))
|
|
113
|
+
if engine_class.configured(state):
|
|
114
|
+
how = "in use" if kind == using else "set up"
|
|
115
|
+
if kind == "kokoro" and not engine.ready():
|
|
116
|
+
how += f", downloads {engine.SIZE_MB} MB on first use"
|
|
117
|
+
else:
|
|
118
|
+
how = f"not set up. {engine.setup}"
|
|
119
|
+
print(f"{engine.title:<11}{how}")
|
|
120
|
+
if engine_class.configured(state):
|
|
121
|
+
names = engine.voices if kind != "mac" else engine.voices[:12]
|
|
122
|
+
print(f" voices: {', '.join(names) if names else 'the system voice'}"
|
|
123
|
+
+ (" and the others say -v ? lists" if kind == "mac" and names else "")
|
|
124
|
+
+ (f"; any {engine.title} voice name works with --voice" if kind not in ("mac", "kokoro") else "")
|
|
125
|
+
+ (", or any of the 54 names in the model" if kind == "kokoro" else ""))
|
|
126
|
+
usage = Voice(engine, state).usage()
|
|
127
|
+
if usage:
|
|
128
|
+
print(f" {usage}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def export(paths: list[Path], out: Path, voice: Voice, state: State, skip_code: bool = False) -> None:
|
|
132
|
+
export_mp3([load(p, skip_code=skip_code) for p in paths], out, voice, state)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def sync_up(state: State, entries: list[Entry], repo: str) -> None:
|
|
136
|
+
"""Prepare the playlist for other devices, then commit, pull and push the state directory."""
|
|
137
|
+
if repo:
|
|
138
|
+
url = sync.setup(state.root, repo, print)
|
|
139
|
+
state.set("sync", url)
|
|
140
|
+
state.save(force=True)
|
|
141
|
+
print(f"Syncing {relative(state.root)} with {url}.")
|
|
142
|
+
elif not sync.ready(state.root):
|
|
143
|
+
raise sync.SyncError("Not set up yet. Say where to keep your places: voce --sync OWNER/REPO (a private "
|
|
144
|
+
"repository on GitHub, made for you if gh is signed in) or any git URL.")
|
|
145
|
+
for e in entries:
|
|
146
|
+
try:
|
|
147
|
+
prepared.save(load(e.path), state)
|
|
148
|
+
except (OSError, UnicodeDecodeError):
|
|
149
|
+
pass
|
|
150
|
+
prepared.index(state, entries)
|
|
151
|
+
state.save(force=True)
|
|
152
|
+
sync.run(state.root, print)
|
|
153
|
+
if repo:
|
|
154
|
+
print(f"\nOn your phone, open {sync.WEB_APP} and sign in with GitHub.\n"
|
|
155
|
+
f"When it asks which repository voce may reach, choose this one.")
|