use-voice-control 0.1.85 → 0.1.87

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.
Files changed (44) hide show
  1. package/README.md +206 -21
  2. package/bin/use-voice-control.mjs +20 -0
  3. package/dist/cli.js +374 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/client/index.d.ts +1 -0
  6. package/dist/client/read-aloud.d.ts +14 -0
  7. package/dist/client.js +132 -157
  8. package/dist/client.js.map +1 -1
  9. package/dist/core/kokoro-node.d.ts +51 -0
  10. package/dist/core/kokoro.d.ts +6 -2
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +33 -76
  13. package/dist/index.js.map +1 -1
  14. package/dist/kokoro-node-DJ_Rxp_N.js +144 -0
  15. package/dist/kokoro-node-DJ_Rxp_N.js.map +1 -0
  16. package/dist/markdown.js +213 -0
  17. package/dist/markdown.js.map +1 -0
  18. package/dist/node/cli.d.ts +52 -0
  19. package/dist/node/document.d.ts +45 -0
  20. package/dist/node/index.d.ts +18 -0
  21. package/dist/node/render.d.ts +30 -0
  22. package/dist/node.js +27 -0
  23. package/dist/node.js.map +1 -0
  24. package/dist/react.js +13 -11
  25. package/dist/react.js.map +1 -1
  26. package/dist/semantic-split-CXhk-k1F.js +44 -0
  27. package/dist/semantic-split-CXhk-k1F.js.map +1 -0
  28. package/dist/types/types.d.ts +14 -1
  29. package/dist/utils/markdown-to-speech.d.ts +76 -0
  30. package/dist/utils/wav.d.ts +27 -0
  31. package/package.json +17 -4
  32. package/speech/client/index.ts +10 -0
  33. package/speech/client/read-aloud.ts +27 -1
  34. package/speech/core/kokoro-node.ts +164 -0
  35. package/speech/core/kokoro.ts +13 -66
  36. package/speech/index.ts +14 -0
  37. package/speech/node/cli.ts +509 -0
  38. package/speech/node/document.ts +126 -0
  39. package/speech/node/index.ts +64 -0
  40. package/speech/node/render.ts +79 -0
  41. package/speech/react/useReadAloud.ts +2 -0
  42. package/speech/types/types.ts +34 -5
  43. package/speech/utils/markdown-to-speech.ts +426 -0
  44. package/speech/utils/wav.ts +89 -0
@@ -0,0 +1,509 @@
1
+ /**
2
+ * @fileoverview The `use-voice-control` command line tool.
3
+ *
4
+ * Reads a Markdown or text file and speaks it to a WAV file:
5
+ *
6
+ * ```sh
7
+ * npx use-voice-control README.md -o readme.wav
8
+ * ```
9
+ *
10
+ * Argument parsing is a pure function (`parseArgs`) and every effect the command
11
+ * has — writing lines, reading stdin, synthesizing — is injectable, so the whole
12
+ * command is testable without loading a 90 MB model.
13
+ */
14
+ import { describeKokoroVoice, KOKORO_VOICES } from "../types/types";
15
+ import type { KokoroDevice, KokoroDtype } from "../core/kokoro-node";
16
+ import type { MarkdownToSpeechOptions } from "../utils/markdown-to-speech";
17
+ import { loadDocument, type RequestedFormat } from "./document";
18
+ import { renderDocument, type RenderOptions } from "./render";
19
+
20
+ export type CliCommand = "speak" | "print" | "help" | "version" | "list-voices";
21
+
22
+ export interface CliOptions {
23
+ command: CliCommand;
24
+ /** Input path, or `-` for stdin. Undefined when `text` is set. */
25
+ input?: string;
26
+ /** Literal text passed with `--text`. */
27
+ text?: string;
28
+ output?: string;
29
+ format: RequestedFormat;
30
+ voice: string;
31
+ speed: number;
32
+ model?: string;
33
+ dtype?: KokoroDtype;
34
+ device?: KokoroDevice;
35
+ maxChunkLength?: number;
36
+ gapMs?: number;
37
+ markdown: MarkdownToSpeechOptions;
38
+ quiet: boolean;
39
+ /** Everything wrong with the command line. Non-empty means do not run. */
40
+ errors: string[];
41
+ }
42
+
43
+ export interface CliIO {
44
+ /** Writes a line to stdout. */
45
+ log?: (line: string) => void;
46
+ /** Writes a line to stderr — progress and errors go here so `-o -` stays clean. */
47
+ error?: (line: string) => void;
48
+ /** Overrides synthesis, for tests. */
49
+ synthesize?: RenderOptions["synthesize"];
50
+ /** Overrides reading stdin, for tests. */
51
+ readStdin?: () => Promise<string>;
52
+ /** Whether stdin is a terminal. When false and no input is given, stdin is read. */
53
+ stdinIsTTY?: boolean;
54
+ }
55
+
56
+ const FORMATS: Record<string, RequestedFormat> = {
57
+ auto: "auto",
58
+ markdown: "markdown",
59
+ md: "markdown",
60
+ text: "text",
61
+ txt: "text",
62
+ plain: "text",
63
+ };
64
+
65
+ const DTYPES: KokoroDtype[] = ["fp32", "fp16", "q8", "q4", "q4f16"];
66
+ const DEVICES: KokoroDevice[] = ["wasm", "webgpu", "cpu"];
67
+
68
+ export const USAGE = `use-voice-control — read a Markdown or text file aloud into an audio file
69
+
70
+ Usage
71
+ npx use-voice-control <file.md|file.txt|-> [options]
72
+ npx use-voice-control --text "Hello there" -o hello.wav
73
+ cat notes.md | npx use-voice-control - -o notes.wav
74
+
75
+ Markdown is converted before it is spoken: "#", "**" and the rest are not read
76
+ out, headings become their own spoken lines, links keep their text, and fenced
77
+ code blocks are announced instead of being spelled out.
78
+
79
+ Input
80
+ <file> File to read. Use "-" to read stdin.
81
+ --text <string> Speak this string instead of reading a file.
82
+ -f, --format <fmt> auto (default), markdown, or text.
83
+
84
+ Output
85
+ -o, --out <file> Audio file to write. Default: the input path with a
86
+ .wav extension. Use "-" to write the WAV to stdout.
87
+ -p, --print Print the speakable text and exit — no model, no audio.
88
+ Useful for checking the Markdown conversion.
89
+ -q, --quiet No progress output.
90
+
91
+ Voice
92
+ -v, --voice <id> Voice id. Default af_heart. See --list-voices.
93
+ -s, --speed <n> Speaking rate, 0.5-2. Default 1.
94
+ --list-voices Print the available voices and exit.
95
+
96
+ Markdown handling
97
+ --headings <mode> text (default) | announce | skip
98
+ --code <mode> announce (default) | read | skip
99
+ --links <mode> text (default) | text-and-url
100
+ --tables <mode> rows (default) | skip
101
+ --front-matter Read the YAML front matter instead of skipping it.
102
+
103
+ Model
104
+ --model <id> Hugging Face model id.
105
+ Default onnx-community/Kokoro-82M-v1.0-ONNX.
106
+ --dtype <type> fp32 | fp16 | q8 (default) | q4 | q4f16
107
+ --device <device> cpu (default) | wasm | webgpu
108
+ --chunk <chars> Target characters per synthesis chunk. Default 400.
109
+ --gap <ms> Silence between chunks. Default 120.
110
+
111
+ Other
112
+ -h, --help Show this help.
113
+ -V, --version Print the package version.
114
+
115
+ The first run downloads the Kokoro weights (about 90 MB at the default q8) into
116
+ the Hugging Face cache; later runs are offline. Speech is synthesized locally —
117
+ no text leaves the machine.`;
118
+
119
+ /** Reads the next value for a flag, recording an error when it is missing. */
120
+ function takeValue(
121
+ argv: string[],
122
+ index: number,
123
+ flag: string,
124
+ errors: string[]
125
+ ): { value?: string; next: number } {
126
+ const value = argv[index + 1];
127
+ if (value === undefined || value.startsWith("-")) {
128
+ errors.push(`${flag} needs a value`);
129
+ return { next: index + 1 };
130
+ }
131
+ return { value, next: index + 1 };
132
+ }
133
+
134
+ /** Parses a numeric flag, recording an error when it is not a finite number. */
135
+ function toNumber(value: string | undefined, flag: string, errors: string[]): number | undefined {
136
+ if (value === undefined) return undefined;
137
+ const parsed = Number(value);
138
+ if (!Number.isFinite(parsed)) {
139
+ errors.push(`${flag} expects a number, got "${value}"`);
140
+ return undefined;
141
+ }
142
+ return parsed;
143
+ }
144
+
145
+ /** Parses an enum flag, recording an error when the value is not in `allowed`. */
146
+ function toChoice<T extends string>(
147
+ value: string | undefined,
148
+ allowed: readonly T[],
149
+ flag: string,
150
+ errors: string[]
151
+ ): T | undefined {
152
+ if (value === undefined) return undefined;
153
+ if (!allowed.includes(value as T)) {
154
+ errors.push(`${flag} expects one of ${allowed.join(", ")} — got "${value}"`);
155
+ return undefined;
156
+ }
157
+ return value as T;
158
+ }
159
+
160
+ /**
161
+ * Parses the command line. Pure: it reads nothing and writes nothing, so the
162
+ * tests can assert on the whole option set.
163
+ */
164
+ export function parseArgs(argv: string[]): CliOptions {
165
+ const errors: string[] = [];
166
+ const markdown: MarkdownToSpeechOptions = {};
167
+ const options: CliOptions = {
168
+ command: "speak",
169
+ format: "auto",
170
+ voice: "af_heart",
171
+ speed: 1,
172
+ markdown,
173
+ quiet: false,
174
+ errors,
175
+ };
176
+
177
+ let positionalsOnly = false;
178
+
179
+ for (let i = 0; i < argv.length; i += 1) {
180
+ const arg = argv[i];
181
+
182
+ if (positionalsOnly || !arg.startsWith("-") || arg === "-") {
183
+ if (options.input !== undefined) {
184
+ errors.push(`unexpected extra input "${arg}" — pass one file at a time`);
185
+ } else {
186
+ options.input = arg;
187
+ }
188
+ continue;
189
+ }
190
+
191
+ if (arg === "--") {
192
+ positionalsOnly = true;
193
+ continue;
194
+ }
195
+
196
+ // `--voice=af_bella` is the same as `--voice af_bella`.
197
+ const equals = arg.indexOf("=");
198
+ if (equals > 1) {
199
+ argv.splice(i, 1, arg.slice(0, equals), arg.slice(equals + 1));
200
+ i -= 1;
201
+ continue;
202
+ }
203
+
204
+ switch (arg) {
205
+ case "-h":
206
+ case "--help":
207
+ options.command = "help";
208
+ return options;
209
+
210
+ case "-V":
211
+ case "--version":
212
+ options.command = "version";
213
+ return options;
214
+
215
+ case "--list-voices":
216
+ options.command = "list-voices";
217
+ return options;
218
+
219
+ case "-p":
220
+ case "--print":
221
+ case "--dry-run":
222
+ options.command = "print";
223
+ break;
224
+
225
+ case "-q":
226
+ case "--quiet":
227
+ options.quiet = true;
228
+ break;
229
+
230
+ case "--front-matter":
231
+ markdown.frontMatter = true;
232
+ break;
233
+
234
+ case "-o":
235
+ case "--out":
236
+ case "--output": {
237
+ const { value, next } = takeValue(argv, i, arg, errors);
238
+ options.output = value;
239
+ i = next;
240
+ break;
241
+ }
242
+
243
+ case "--text": {
244
+ // Unlike every other value, this one may legitimately start with "-".
245
+ const value = argv[i + 1];
246
+ if (value === undefined) errors.push("--text needs a value");
247
+ else options.text = value;
248
+ i += 1;
249
+ break;
250
+ }
251
+
252
+ case "-f":
253
+ case "--format": {
254
+ const { value, next } = takeValue(argv, i, arg, errors);
255
+ i = next;
256
+ if (value !== undefined) {
257
+ const format = FORMATS[value.toLowerCase()];
258
+ if (!format) {
259
+ errors.push(`--format expects auto, markdown or text — got "${value}"`);
260
+ } else {
261
+ options.format = format;
262
+ }
263
+ }
264
+ break;
265
+ }
266
+
267
+ case "-v":
268
+ case "--voice": {
269
+ const { value, next } = takeValue(argv, i, arg, errors);
270
+ i = next;
271
+ if (value !== undefined) {
272
+ if (!KOKORO_VOICES.includes(value as (typeof KOKORO_VOICES)[number])) {
273
+ errors.push(`unknown voice "${value}" — run --list-voices to see them all`);
274
+ } else {
275
+ options.voice = value;
276
+ }
277
+ }
278
+ break;
279
+ }
280
+
281
+ case "-s":
282
+ case "--speed": {
283
+ const { value, next } = takeValue(argv, i, arg, errors);
284
+ i = next;
285
+ const speed = toNumber(value, arg, errors);
286
+ if (speed !== undefined) {
287
+ if (speed < 0.5 || speed > 2) errors.push("--speed must be between 0.5 and 2");
288
+ else options.speed = speed;
289
+ }
290
+ break;
291
+ }
292
+
293
+ case "--model": {
294
+ const { value, next } = takeValue(argv, i, arg, errors);
295
+ options.model = value;
296
+ i = next;
297
+ break;
298
+ }
299
+
300
+ case "--dtype": {
301
+ const { value, next } = takeValue(argv, i, arg, errors);
302
+ i = next;
303
+ options.dtype = toChoice(value, DTYPES, arg, errors) ?? options.dtype;
304
+ break;
305
+ }
306
+
307
+ case "--device": {
308
+ const { value, next } = takeValue(argv, i, arg, errors);
309
+ i = next;
310
+ options.device = toChoice(value, DEVICES, arg, errors) ?? options.device;
311
+ break;
312
+ }
313
+
314
+ case "--chunk":
315
+ case "--chunk-length": {
316
+ const { value, next } = takeValue(argv, i, arg, errors);
317
+ i = next;
318
+ const chunk = toNumber(value, arg, errors);
319
+ if (chunk !== undefined) {
320
+ if (chunk < 40) errors.push("--chunk must be at least 40 characters");
321
+ else options.maxChunkLength = chunk;
322
+ }
323
+ break;
324
+ }
325
+
326
+ case "--gap": {
327
+ const { value, next } = takeValue(argv, i, arg, errors);
328
+ i = next;
329
+ const gap = toNumber(value, arg, errors);
330
+ if (gap !== undefined) {
331
+ if (gap < 0) errors.push("--gap cannot be negative");
332
+ else options.gapMs = gap;
333
+ }
334
+ break;
335
+ }
336
+
337
+ case "--headings": {
338
+ const { value, next } = takeValue(argv, i, arg, errors);
339
+ i = next;
340
+ markdown.headings =
341
+ toChoice(value, ["text", "announce", "skip"] as const, arg, errors) ??
342
+ markdown.headings;
343
+ break;
344
+ }
345
+
346
+ case "--code":
347
+ case "--code-blocks": {
348
+ const { value, next } = takeValue(argv, i, arg, errors);
349
+ i = next;
350
+ markdown.codeBlocks =
351
+ toChoice(value, ["announce", "read", "skip"] as const, arg, errors) ??
352
+ markdown.codeBlocks;
353
+ break;
354
+ }
355
+
356
+ case "--links": {
357
+ const { value, next } = takeValue(argv, i, arg, errors);
358
+ i = next;
359
+ markdown.links =
360
+ toChoice(value, ["text", "text-and-url"] as const, arg, errors) ?? markdown.links;
361
+ break;
362
+ }
363
+
364
+ case "--tables": {
365
+ const { value, next } = takeValue(argv, i, arg, errors);
366
+ i = next;
367
+ markdown.tables =
368
+ toChoice(value, ["rows", "skip"] as const, arg, errors) ?? markdown.tables;
369
+ break;
370
+ }
371
+
372
+ default:
373
+ errors.push(`unknown option "${arg}" — run --help to see the options`);
374
+ }
375
+ }
376
+
377
+ if (options.text !== undefined && options.input !== undefined) {
378
+ errors.push("pass either a file or --text, not both");
379
+ }
380
+
381
+ return options;
382
+ }
383
+
384
+ /** The `--list-voices` table, derived from the ids so it cannot drift. */
385
+ export function formatVoiceList(): string {
386
+ const rows = KOKORO_VOICES.map((id) => {
387
+ const voice = describeKokoroVoice(id);
388
+ return ` ${id.padEnd(14)}${voice.name.padEnd(12)}${voice.gender.padEnd(8)}${voice.accent}`;
389
+ });
390
+ return [`${KOKORO_VOICES.length} Kokoro voices:`, ...rows].join("\n");
391
+ }
392
+
393
+ /** Reads the package version, so `--version` matches what npx installed. */
394
+ async function readVersion(): Promise<string> {
395
+ try {
396
+ const { readFile } = await import("node:fs/promises");
397
+ // From `dist/cli.js` the manifest is one level up; from a source checkout it
398
+ // is two. Try both before giving up.
399
+ for (const relative of ["../package.json", "../../package.json"]) {
400
+ try {
401
+ const url = new URL(relative, import.meta.url);
402
+ const manifest = JSON.parse(await readFile(url, "utf8"));
403
+ if (manifest?.name === "use-voice-control" && manifest.version) return manifest.version;
404
+ } catch {
405
+ // Try the next candidate.
406
+ }
407
+ }
408
+ } catch {
409
+ // No filesystem (bundled for a runtime without fs) — fall through.
410
+ }
411
+ return "unknown";
412
+ }
413
+
414
+ /**
415
+ * Runs the command line and resolves to the process exit code.
416
+ *
417
+ * @param argv Arguments after the executable and script, i.e. `process.argv.slice(2)`.
418
+ * @param io Injection points for output, stdin and synthesis.
419
+ */
420
+ export async function runCli(argv: string[], io: CliIO = {}): Promise<number> {
421
+ const log = io.log ?? ((line: string) => process.stdout.write(`${line}\n`));
422
+ const warn = io.error ?? ((line: string) => process.stderr.write(`${line}\n`));
423
+
424
+ const options = parseArgs([...argv]);
425
+
426
+ if (options.command === "help") {
427
+ log(USAGE);
428
+ return 0;
429
+ }
430
+
431
+ if (options.command === "version") {
432
+ log(await readVersion());
433
+ return 0;
434
+ }
435
+
436
+ if (options.command === "list-voices") {
437
+ log(formatVoiceList());
438
+ return 0;
439
+ }
440
+
441
+ if (options.errors.length > 0) {
442
+ options.errors.forEach((message) => warn(`use-voice-control: ${message}`));
443
+ warn("Run `npx use-voice-control --help` for usage.");
444
+ return 1;
445
+ }
446
+
447
+ // With nothing named on the command line, a pipe is the intended input.
448
+ const stdinIsTTY = io.stdinIsTTY ?? Boolean(process.stdin.isTTY);
449
+ const input =
450
+ options.input ?? (options.text === undefined && !stdinIsTTY ? "-" : undefined);
451
+
452
+ if (input === undefined && options.text === undefined) {
453
+ warn("use-voice-control: no input — pass a file, pipe text in, or use --text");
454
+ warn("Run `npx use-voice-control --help` for usage.");
455
+ return 1;
456
+ }
457
+
458
+ const shared = {
459
+ file: input,
460
+ text: options.text,
461
+ format: options.format,
462
+ markdown: options.markdown,
463
+ readStdin: io.readStdin,
464
+ };
465
+
466
+ try {
467
+ if (options.command === "print") {
468
+ const document = await loadDocument(shared);
469
+ log(document.text);
470
+ return 0;
471
+ }
472
+
473
+ const started = Date.now();
474
+ const result = await renderDocument({
475
+ ...shared,
476
+ output: options.output,
477
+ voice: options.voice,
478
+ speed: options.speed,
479
+ model: options.model,
480
+ dtype: options.dtype,
481
+ device: options.device,
482
+ maxChunkLength: options.maxChunkLength,
483
+ gapMs: options.gapMs,
484
+ synthesize: io.synthesize,
485
+ onModelProgress: options.quiet
486
+ ? undefined
487
+ : (progress: any) => {
488
+ if (progress?.status === "progress" && progress.file && progress.total) {
489
+ const done = Math.round(((progress.loaded ?? 0) / progress.total) * 100);
490
+ warn(`downloading ${progress.file}: ${done}%`);
491
+ }
492
+ },
493
+ onChunk: options.quiet
494
+ ? undefined
495
+ : ({ index, total }) => warn(`speaking chunk ${index + 1}/${total}`),
496
+ });
497
+
498
+ if (!options.quiet) {
499
+ const seconds = result.durationSeconds.toFixed(1);
500
+ const elapsed = ((Date.now() - started) / 1000).toFixed(1);
501
+ const target = result.output === "-" ? "stdout" : result.output;
502
+ warn(`wrote ${target} — ${seconds}s of audio from ${result.source} in ${elapsed}s`);
503
+ }
504
+ return 0;
505
+ } catch (error) {
506
+ warn(`use-voice-control: ${error instanceof Error ? error.message : String(error)}`);
507
+ return 1;
508
+ }
509
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * @fileoverview Turning a document on disk (or on stdin) into speakable text.
3
+ *
4
+ * A `.md` file is converted with `markdownToSpeech` so the marks become
5
+ * structure rather than words; a `.txt` file is passed through as it is. When
6
+ * the source has no filename — piped stdin, a `--text` string — the format is
7
+ * guessed from the content.
8
+ */
9
+ import {
10
+ looksLikeMarkdown,
11
+ markdownToSpeech,
12
+ type MarkdownToSpeechOptions,
13
+ } from "../utils/markdown-to-speech";
14
+
15
+ export type DocumentFormat = "markdown" | "text";
16
+ /** What the caller asked for; `auto` defers to the extension, then the content. */
17
+ export type RequestedFormat = DocumentFormat | "auto";
18
+
19
+ /** Extensions read as Markdown. Everything else is treated as plain text. */
20
+ export const MARKDOWN_EXTENSIONS = [".md", ".markdown", ".mdown", ".mkd", ".mdx"];
21
+
22
+ /** Extensions the CLI accepts without complaint. Others still work, with a warning. */
23
+ export const TEXT_EXTENSIONS = [".txt", ".text", ""];
24
+
25
+ /** Lowercased extension of a path, including the dot (`""` when there is none). */
26
+ export function extensionOf(filename: string): string {
27
+ const base = filename.split(/[\\/]/).pop() ?? "";
28
+ const dot = base.lastIndexOf(".");
29
+ return dot > 0 ? base.slice(dot).toLowerCase() : "";
30
+ }
31
+
32
+ /**
33
+ * Decides how to read a document.
34
+ *
35
+ * An explicit `requested` format always wins — a `.txt` file full of Markdown is
36
+ * still the user's call. Otherwise the extension decides, and content sniffing
37
+ * is the last resort for input that arrived without a name.
38
+ */
39
+ export function detectFormat(
40
+ content: string,
41
+ filename?: string,
42
+ requested: RequestedFormat = "auto"
43
+ ): DocumentFormat {
44
+ if (requested !== "auto") return requested;
45
+
46
+ if (filename) {
47
+ const extension = extensionOf(filename);
48
+ if (MARKDOWN_EXTENSIONS.includes(extension)) return "markdown";
49
+ if (TEXT_EXTENSIONS.includes(extension)) return "text";
50
+ }
51
+
52
+ return looksLikeMarkdown(content) ? "markdown" : "text";
53
+ }
54
+
55
+ /** Converts document content to the text that should be spoken. */
56
+ export function toSpeechText(
57
+ content: string,
58
+ format: DocumentFormat,
59
+ options: MarkdownToSpeechOptions = {}
60
+ ): string {
61
+ if (format === "text") {
62
+ // Plain text is already speakable; only normalise line endings and the
63
+ // trailing whitespace that would otherwise become an empty final chunk.
64
+ return content.replace(/\r\n?/g, "\n").trim();
65
+ }
66
+ return markdownToSpeech(content, options);
67
+ }
68
+
69
+ export interface LoadedDocument {
70
+ /** The speakable text, Markdown already converted. */
71
+ text: string;
72
+ /** How the content was read. */
73
+ format: DocumentFormat;
74
+ /** Where it came from, for log lines: a path, `stdin`, or `--text`. */
75
+ source: string;
76
+ }
77
+
78
+ export interface LoadDocumentOptions {
79
+ /** Path to read. Use `-` for stdin. Mutually exclusive with `text`. */
80
+ file?: string;
81
+ /** Literal content to speak, instead of reading a file. */
82
+ text?: string;
83
+ format?: RequestedFormat;
84
+ markdown?: MarkdownToSpeechOptions;
85
+ /** Reads stdin. Injected so the CLI tests do not need a real pipe. */
86
+ readStdin?: () => Promise<string>;
87
+ }
88
+
89
+ /**
90
+ * Reads a document from a path, stdin, or an inline string and returns the text
91
+ * to synthesize.
92
+ */
93
+ export async function loadDocument(
94
+ options: LoadDocumentOptions
95
+ ): Promise<LoadedDocument> {
96
+ const { file, text, format = "auto", markdown = {} } = options;
97
+
98
+ if (text !== undefined) {
99
+ const resolved = detectFormat(text, undefined, format);
100
+ return { text: toSpeechText(text, resolved, markdown), format: resolved, source: "--text" };
101
+ }
102
+
103
+ if (!file) throw new Error("No input: pass a file path, `-` for stdin, or --text");
104
+
105
+ if (file === "-") {
106
+ const stdin = options.readStdin
107
+ ? await options.readStdin()
108
+ : await readStdinToString();
109
+ const resolved = detectFormat(stdin, undefined, format);
110
+ return { text: toSpeechText(stdin, resolved, markdown), format: resolved, source: "stdin" };
111
+ }
112
+
113
+ const { readFile } = await import("node:fs/promises");
114
+ const content = await readFile(file, "utf8");
115
+ const resolved = detectFormat(content, file, format);
116
+ return { text: toSpeechText(content, resolved, markdown), format: resolved, source: file };
117
+ }
118
+
119
+ /** Collects all of stdin as UTF-8 text. */
120
+ export async function readStdinToString(): Promise<string> {
121
+ const chunks: Buffer[] = [];
122
+ for await (const chunk of process.stdin) {
123
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
124
+ }
125
+ return Buffer.concat(chunks).toString("utf8");
126
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @fileoverview Server-side entry point: read a document, speak it to a file.
3
+ *
4
+ * ```ts
5
+ * import { renderDocument } from "use-voice-control/node";
6
+ *
7
+ * const result = await renderDocument({ file: "README.md", output: "readme.wav" });
8
+ * console.log(`${result.durationSeconds.toFixed(1)}s of audio`);
9
+ * ```
10
+ *
11
+ * The same code backs the `use-voice-control` command, exported here as
12
+ * `runCli` for hosts that want to wrap it.
13
+ */
14
+ export {
15
+ detectFormat,
16
+ extensionOf,
17
+ loadDocument,
18
+ readStdinToString,
19
+ toSpeechText,
20
+ MARKDOWN_EXTENSIONS,
21
+ TEXT_EXTENSIONS,
22
+ type DocumentFormat,
23
+ type LoadDocumentOptions,
24
+ type LoadedDocument,
25
+ type RequestedFormat,
26
+ } from "./document";
27
+
28
+ export {
29
+ defaultOutputPath,
30
+ renderDocument,
31
+ type RenderOptions,
32
+ type RenderResult,
33
+ } from "./render";
34
+
35
+ export {
36
+ formatVoiceList,
37
+ parseArgs,
38
+ runCli,
39
+ USAGE,
40
+ type CliCommand,
41
+ type CliIO,
42
+ type CliOptions,
43
+ } from "./cli";
44
+
45
+ export {
46
+ loadKokoroTTS,
47
+ resetKokoroCache,
48
+ synthesizeSamples,
49
+ synthesizeWav,
50
+ type KokoroAudio,
51
+ type KokoroDevice,
52
+ type KokoroDtype,
53
+ type KokoroNodeOptions,
54
+ } from "../core/kokoro-node";
55
+
56
+ export {
57
+ looksLikeMarkdown,
58
+ markdownToSpeech,
59
+ markdownToSpeechSegments,
60
+ stripInlineMarkdown,
61
+ type MarkdownToSpeechOptions,
62
+ type SpeechSegment,
63
+ type SpeechSegmentType,
64
+ } from "../utils/markdown-to-speech";