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 ADDED
@@ -0,0 +1,3 @@
1
+ """voce: read Markdown aloud in your terminal, highlighting each sentence and word as it is spoken."""
2
+
3
+ __version__ = "0.2.0"
voce/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from voce.cli import main
2
+
3
+ main()
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.")