use-voice-control 0.1.86 → 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.
- package/README.md +206 -21
- package/bin/use-voice-control.mjs +20 -0
- package/dist/cli.js +374 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/read-aloud.d.ts +14 -0
- package/dist/client.js +132 -157
- package/dist/client.js.map +1 -1
- package/dist/core/kokoro-node.d.ts +51 -0
- package/dist/core/kokoro.d.ts +6 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +33 -76
- package/dist/index.js.map +1 -1
- package/dist/kokoro-node-DJ_Rxp_N.js +144 -0
- package/dist/kokoro-node-DJ_Rxp_N.js.map +1 -0
- package/dist/markdown.js +213 -0
- package/dist/markdown.js.map +1 -0
- package/dist/node/cli.d.ts +52 -0
- package/dist/node/document.d.ts +45 -0
- package/dist/node/index.d.ts +18 -0
- package/dist/node/render.d.ts +30 -0
- package/dist/node.js +27 -0
- package/dist/node.js.map +1 -0
- package/dist/react.js +13 -11
- package/dist/react.js.map +1 -1
- package/dist/semantic-split-CXhk-k1F.js +44 -0
- package/dist/semantic-split-CXhk-k1F.js.map +1 -0
- package/dist/types/types.d.ts +14 -1
- package/dist/utils/markdown-to-speech.d.ts +76 -0
- package/dist/utils/wav.d.ts +27 -0
- package/package.json +17 -4
- package/speech/client/index.ts +10 -0
- package/speech/client/read-aloud.ts +27 -1
- package/speech/core/kokoro-node.ts +164 -0
- package/speech/core/kokoro.ts +13 -66
- package/speech/index.ts +14 -0
- package/speech/node/cli.ts +509 -0
- package/speech/node/document.ts +126 -0
- package/speech/node/index.ts +64 -0
- package/speech/node/render.ts +79 -0
- package/speech/react/useReadAloud.ts +2 -0
- package/speech/types/types.ts +34 -5
- package/speech/utils/markdown-to-speech.ts +426 -0
- package/speech/utils/wav.ts +89 -0
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
<a href="https://codespaces.new/OpenSourceAGI/qwksearch-research-agent">
|
|
27
27
|
<img src="https://github.com/codespaces/badge.svg" width="150" height="20" />
|
|
28
28
|
</a>
|
|
29
|
+
<a href="https://codecov.io/gh/OpenSourceAGI/qwksearch-research-agent"><img src="https://codecov.io/gh/OpenSourceAGI/qwksearch-research-agent/graph/badge.svg?component=package-use-voice-control" alt="Coverage" /></a>
|
|
29
30
|
</p>
|
|
30
31
|
|
|
31
32
|
<p align="center">
|
|
@@ -42,6 +43,12 @@ React hooks and components for seamless voice control and speech I/O (Speech-to-
|
|
|
42
43
|
npm install use-voice-control
|
|
43
44
|
```
|
|
44
45
|
|
|
46
|
+
Or read a document aloud straight from the terminal, no install and no code:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx use-voice-control README.md -o readme.wav
|
|
50
|
+
```
|
|
51
|
+
|
|
45
52
|
---
|
|
46
53
|
|
|
47
54
|
## 🎤 Features
|
|
@@ -58,6 +65,14 @@ npm install use-voice-control
|
|
|
58
65
|
- **Server & Client Support**: Run on backend or stream to client
|
|
59
66
|
- **Multiple Output Formats**: WAV, PCM, or raw audio buffers
|
|
60
67
|
|
|
68
|
+
### Markdown & Files
|
|
69
|
+
|
|
70
|
+
- **Markdown aware**: `#`, `**` and backticks are never read out — headings are
|
|
71
|
+
spoken as headings, code blocks are announced, links keep their text
|
|
72
|
+
- **Files in, audio out**: `.md` and `.txt` files rendered to WAV from the
|
|
73
|
+
[command line](#-command-line) or from Node
|
|
74
|
+
- **Local synthesis**: Kokoro runs on the CPU, so document text never leaves the machine
|
|
75
|
+
|
|
61
76
|
### React Integration
|
|
62
77
|
- **Custom Hooks**: `useVoiceControl()`, `useSpeechRecognition()`, `useSpeechSynthesis()`
|
|
63
78
|
- **Pre-built Components**: Audio recorder, voice selector, playback controls
|
|
@@ -118,6 +133,154 @@ export function VoiceOutput() {
|
|
|
118
133
|
|
|
119
134
|
---
|
|
120
135
|
|
|
136
|
+
## 🖥️ Command Line
|
|
137
|
+
|
|
138
|
+
Read a Markdown or text file aloud into an audio file, without writing any code:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
npx use-voice-control README.md
|
|
142
|
+
# wrote README.wav — 96.4s of audio from README.md in 41.2s
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The Markdown is **converted before it is spoken**: `#`, `**`, backticks and the
|
|
146
|
+
rest are never read out. Headings become their own spoken lines with a pause
|
|
147
|
+
after them, list markers are dropped (ordered numbers are kept), links keep their
|
|
148
|
+
text and lose their URL, tables are read row by row, and fenced code blocks are
|
|
149
|
+
announced — "TypeScript code block." — instead of being spelled out character by
|
|
150
|
+
character. See [Markdown → speech](#-markdown--speech) for the rules and how to
|
|
151
|
+
change them.
|
|
152
|
+
|
|
153
|
+
Speech is synthesized locally with [Kokoro](https://huggingface.co/hexgrad/Kokoro-82M)
|
|
154
|
+
on the CPU: nothing is sent to a third-party service. The first run downloads the
|
|
155
|
+
weights (about 90 MB at the default `q8`) into the Hugging Face cache; every run
|
|
156
|
+
after that is offline.
|
|
157
|
+
|
|
158
|
+
### Common commands
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
# Markdown or plain text in, WAV out (defaults to <input>.wav, next to the input)
|
|
162
|
+
npx use-voice-control notes.md
|
|
163
|
+
npx use-voice-control notes.txt -o spoken.wav
|
|
164
|
+
|
|
165
|
+
# Pick a voice and a speaking rate
|
|
166
|
+
npx use-voice-control notes.md -v am_michael -s 1.15
|
|
167
|
+
npx use-voice-control --list-voices
|
|
168
|
+
|
|
169
|
+
# Speak a string, no file needed
|
|
170
|
+
npx use-voice-control --text "Build finished, three tests failed." -o alert.wav
|
|
171
|
+
|
|
172
|
+
# Read from a pipe, and write the WAV to stdout
|
|
173
|
+
cat notes.md | npx use-voice-control - -o - > notes.wav
|
|
174
|
+
git log -1 --format=%B | npx use-voice-control - -o commit.wav
|
|
175
|
+
|
|
176
|
+
# Check what will be spoken, without downloading the model
|
|
177
|
+
npx use-voice-control README.md --print
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`--print` is the fastest way to see the Markdown conversion — it converts the
|
|
181
|
+
document, prints the text, and exits before any model is loaded.
|
|
182
|
+
|
|
183
|
+
### Options
|
|
184
|
+
|
|
185
|
+
| Option | Meaning |
|
|
186
|
+
| --- | --- |
|
|
187
|
+
| `<file>` | File to read. `-` reads stdin. `.md`, `.markdown`, `.mdx` are read as Markdown; everything else as plain text. |
|
|
188
|
+
| `--text <string>` | Speak this string instead of reading a file. |
|
|
189
|
+
| `-f, --format <fmt>` | `auto` (default), `markdown`, or `text`. Overrides the extension. |
|
|
190
|
+
| `-o, --out <file>` | Audio file to write. Default: the input path with a `.wav` extension, or `out.wav` when the input is stdin or `--text`. `-` writes the WAV to stdout. |
|
|
191
|
+
| `-p, --print` | Print the speakable text and exit — no model, no audio. |
|
|
192
|
+
| `-q, --quiet` | No progress output. |
|
|
193
|
+
| `-v, --voice <id>` | Voice id, default `af_heart`. See `--list-voices`. |
|
|
194
|
+
| `-s, --speed <n>` | Speaking rate between 0.5 and 2. Default 1. |
|
|
195
|
+
| `--headings <mode>` | `text` (default), `announce` ("Heading: Install"), or `skip`. |
|
|
196
|
+
| `--code <mode>` | `announce` (default), `read`, or `skip`. |
|
|
197
|
+
| `--links <mode>` | `text` (default) or `text-and-url`. |
|
|
198
|
+
| `--tables <mode>` | `rows` (default) or `skip`. |
|
|
199
|
+
| `--front-matter` | Read the YAML front matter instead of skipping it. |
|
|
200
|
+
| `--model <id>` | Hugging Face model id. Default `onnx-community/Kokoro-82M-v1.0-ONNX`. |
|
|
201
|
+
| `--dtype <type>` | `fp32`, `fp16`, `q8` (default), `q4`, `q4f16`. |
|
|
202
|
+
| `--device <device>` | `cpu` (default), `wasm`, `webgpu`. |
|
|
203
|
+
| `--chunk <chars>` | Target characters per synthesis chunk. Default 400. |
|
|
204
|
+
| `--gap <ms>` | Silence inserted between chunks. Default 120. |
|
|
205
|
+
| `-h, --help` | Show the full usage text. |
|
|
206
|
+
| `-V, --version` | Print the package version. |
|
|
207
|
+
|
|
208
|
+
Long documents are split on sentence and paragraph boundaries, synthesized chunk
|
|
209
|
+
by chunk, and joined into one file — Kokoro's context is only a few hundred
|
|
210
|
+
phonemes, so this is what makes a whole README work.
|
|
211
|
+
|
|
212
|
+
The command exits `0` on success and `1` on a bad option, a missing file, a
|
|
213
|
+
document with nothing to say, or a failed model load; progress goes to stderr, so
|
|
214
|
+
`-o -` gives you a clean WAV on stdout.
|
|
215
|
+
|
|
216
|
+
### From code
|
|
217
|
+
|
|
218
|
+
The same thing without the shell:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
import { renderDocument } from 'use-voice-control/node';
|
|
222
|
+
|
|
223
|
+
const result = await renderDocument({
|
|
224
|
+
file: 'README.md',
|
|
225
|
+
output: 'readme.wav',
|
|
226
|
+
voice: 'af_heart',
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
console.log(`${result.durationSeconds.toFixed(1)}s of audio from ${result.source}`);
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`use-voice-control/node` also exports `loadDocument` (file → speakable text),
|
|
233
|
+
`synthesizeSamples` / `synthesizeWav` (text → audio), `runCli`, and the Markdown
|
|
234
|
+
helpers below.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## 📝 Markdown → speech
|
|
239
|
+
|
|
240
|
+
Markdown handed straight to a speech engine is read literally: *"hash hash
|
|
241
|
+
Getting started"*, *"star star important star star"*. `markdownToSpeech` parses
|
|
242
|
+
the document instead and emits only the words, keeping the structure the marks
|
|
243
|
+
encoded.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import { markdownToSpeech } from 'use-voice-control/markdown';
|
|
247
|
+
|
|
248
|
+
markdownToSpeech('## Install\n\nRun `npm i` and see the [docs](https://x.dev).');
|
|
249
|
+
// "Install.
|
|
250
|
+
//
|
|
251
|
+
// Run npm i and see the docs."
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
| Markdown | Spoken as |
|
|
255
|
+
| --- | --- |
|
|
256
|
+
| `# Heading` and setext underlines | The heading text on its own, with a pause after it |
|
|
257
|
+
| `**bold**`, `_italic_`, `~~struck~~` | The words, no marks |
|
|
258
|
+
| `` `code span` `` | The code text, no backticks |
|
|
259
|
+
| A fenced code block | "TypeScript code block." (or the code, or nothing) |
|
|
260
|
+
| `[text](url)` | The link text; the URL is dropped |
|
|
261
|
+
| `` | The alt text |
|
|
262
|
+
| `- item`, `1. item`, `- [x] task` | The item; bullets and checkboxes dropped, numbers kept |
|
|
263
|
+
| `> quote` | The quoted words |
|
|
264
|
+
| `\| a \| b \|` | "a, b." — the delimiter row is dropped |
|
|
265
|
+
| `---`, `<!-- … -->`, `[ref]: url` | Nothing |
|
|
266
|
+
| YAML front matter | Nothing, unless `frontMatter: true` |
|
|
267
|
+
|
|
268
|
+
Options: `headings` (`text` \| `announce` \| `skip`), `codeBlocks` (`announce` \|
|
|
269
|
+
`read` \| `skip`), `links` (`text` \| `text-and-url`), `images` (`alt` \| `skip`),
|
|
270
|
+
`tables` (`rows` \| `skip`), `frontMatter`, and `addTerminalPunctuation`.
|
|
271
|
+
|
|
272
|
+
`markdownToSpeechSegments` returns the same content as typed blocks —
|
|
273
|
+
`{ type: 'heading' | 'paragraph' | 'list-item' | 'quote' | 'code' | 'table-row',
|
|
274
|
+
text, level }` — for callers that want to highlight the current heading or skip
|
|
275
|
+
between sections. `stripInlineMarkdown` handles a single line, and
|
|
276
|
+
`looksLikeMarkdown` is the heuristic behind `--format auto`.
|
|
277
|
+
|
|
278
|
+
The same conversion runs in the browser: `ReadAloudController` and `useReadAloud`
|
|
279
|
+
convert text that looks like Markdown before speaking it, so an editor's raw
|
|
280
|
+
document does not have its syntax read back to the listener.
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
121
284
|
## 🗣️ Read Aloud & Live Dictation
|
|
122
285
|
|
|
123
286
|
Two ready-made browser engines ship from `use-voice-control/client` (framework
|
|
@@ -147,8 +310,11 @@ function ReadButton({ text }: { text: string }) {
|
|
|
147
310
|
}
|
|
148
311
|
```
|
|
149
312
|
|
|
150
|
-
Options: `provider`, `voice`, `endpoint`, `maxChunkLength`,
|
|
151
|
-
|
|
313
|
+
Options: `provider`, `voice`, `endpoint`, `maxChunkLength`, `format`
|
|
314
|
+
(`auto` \| `markdown` \| `text` — `auto` converts text that looks like Markdown so
|
|
315
|
+
`##` and `**` are not read out), `markdown` for the conversion options, and
|
|
316
|
+
`synthesize` to plug in your own TTS. Controls: `speak`, `pause`, `resume`,
|
|
317
|
+
`stop`, `toggle`.
|
|
152
318
|
|
|
153
319
|
### Live dictation
|
|
154
320
|
|
|
@@ -293,30 +459,35 @@ interface VoiceControlOptions {
|
|
|
293
459
|
|
|
294
460
|
#### Kokoro Voices
|
|
295
461
|
|
|
296
|
-
|
|
462
|
+
28 voices optimized for natural speech synthesis. Run `npx use-voice-control
|
|
463
|
+
--list-voices` to print them with their accent and gender. The `a`/`b` prefix is
|
|
464
|
+
the accent (American / British English) and the letter after it is the gender.
|
|
297
465
|
|
|
298
|
-
**
|
|
466
|
+
**American English — female:**
|
|
299
467
|
```
|
|
300
|
-
af_heart - Warm, caring tone
|
|
301
|
-
af_alloy - Neutral, professional
|
|
302
|
-
af_aoede - Bright, energetic
|
|
303
|
-
af_bella - Soft, gentle
|
|
468
|
+
af_heart - Warm, caring tone af_nicole - Clear, articulate
|
|
469
|
+
af_alloy - Neutral, professional af_nova - Even, unhurried
|
|
470
|
+
af_aoede - Bright, energetic af_river - Calm, soothing
|
|
471
|
+
af_bella - Soft, gentle af_sarah - Warm, approachable
|
|
304
472
|
af_jessica - Friendly, conversational
|
|
305
|
-
|
|
306
|
-
af_river - Calm, soothing
|
|
307
|
-
af_sarah - Warm, approachable
|
|
308
|
-
af_sky - Young, vibrant
|
|
473
|
+
af_kore - Steady, measured af_sky - Young, vibrant
|
|
309
474
|
```
|
|
310
475
|
|
|
311
|
-
**
|
|
476
|
+
**American English — male:**
|
|
312
477
|
```
|
|
313
|
-
am_adam - Deep, authoritative
|
|
314
|
-
am_echo - Resonant, smooth
|
|
315
|
-
|
|
316
|
-
am_fenrir - Bold, strong
|
|
478
|
+
am_adam - Deep, authoritative am_michael - Professional, clear
|
|
479
|
+
am_echo - Resonant, smooth am_onyx - Dark, mysterious
|
|
480
|
+
am_eric - Direct, matter-of-fact am_puck - Playful, quick
|
|
481
|
+
am_fenrir - Bold, strong am_santa - Jovial, avuncular
|
|
317
482
|
am_liam - Friendly, warm
|
|
318
|
-
|
|
319
|
-
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
**British English:**
|
|
486
|
+
```
|
|
487
|
+
bf_alice - Crisp, precise bm_daniel - Measured, formal
|
|
488
|
+
bf_emma - Warm, unhurried bm_fable - Narrative, engaging
|
|
489
|
+
bf_isabella - Bright, articulate bm_george - Deep, steady
|
|
490
|
+
bf_lily - Light, gentle bm_lewis - Relaxed, conversational
|
|
320
491
|
```
|
|
321
492
|
|
|
322
493
|
**Example:**
|
|
@@ -400,16 +571,21 @@ Generate speech audio directly from the server or Edge runtime:
|
|
|
400
571
|
```ts
|
|
401
572
|
import { generateSpeech } from 'use-voice-control/speech';
|
|
402
573
|
|
|
403
|
-
// Generate Kokoro speech
|
|
574
|
+
// Generate Kokoro speech — runs the model locally on the CPU via `kokoro-js`
|
|
404
575
|
const audio = await generateSpeech({
|
|
405
576
|
text: "Hello, world!",
|
|
406
577
|
provider: 'kokoro',
|
|
407
578
|
voice: 'af_heart'
|
|
408
579
|
});
|
|
409
580
|
|
|
410
|
-
// Returns: { audio: ArrayBuffer, contentType: string }
|
|
581
|
+
// Returns: { audio: ArrayBuffer, contentType: string } — a 16-bit PCM WAV
|
|
411
582
|
```
|
|
412
583
|
|
|
584
|
+
Text longer than the model's context is chunked on sentence boundaries and joined
|
|
585
|
+
automatically, so a whole document can be passed in one call. Deepgram
|
|
586
|
+
(`provider: 'deepgram'`) goes through a Cloudflare Workers AI binding instead and
|
|
587
|
+
returns MP3; it is not available from the CLI.
|
|
588
|
+
|
|
413
589
|
### TypeScript Support
|
|
414
590
|
|
|
415
591
|
Full type definitions included:
|
|
@@ -516,6 +692,7 @@ const SpeechWorker = require('use-voice-control/speech/worker.js');
|
|
|
516
692
|
## 🔐 Privacy & Security
|
|
517
693
|
|
|
518
694
|
- **Client-side STT**: Moonshine.js runs entirely in the browser—no audio leaves your device
|
|
695
|
+
- **Local CLI TTS**: `npx use-voice-control` runs Kokoro on your CPU; the document text is never uploaded
|
|
519
696
|
- **Optional Server TTS**: Choose Kokoro (server-side) or Deepgram (with API key)
|
|
520
697
|
- **No Tracking**: No analytics or usage telemetry
|
|
521
698
|
- **HTTPS Required**: Microphone access requires secure context
|
|
@@ -548,6 +725,14 @@ const SpeechWorker = require('use-voice-control/speech/worker.js');
|
|
|
548
725
|
export { ReadAloudController, LiveTranscriber, isTranscriptionSupported } from 'use-voice-control/client';
|
|
549
726
|
export { useReadAloud, useLiveTranscription, SpokenPhraseOverlay } from 'use-voice-control/react';
|
|
550
727
|
|
|
728
|
+
// Markdown → speech (implemented)
|
|
729
|
+
export { markdownToSpeech, markdownToSpeechSegments, stripInlineMarkdown, looksLikeMarkdown } from 'use-voice-control/markdown';
|
|
730
|
+
export type { MarkdownToSpeechOptions, SpeechSegment } from 'use-voice-control/markdown';
|
|
731
|
+
|
|
732
|
+
// Files → audio, and the CLI (implemented, Node)
|
|
733
|
+
export { renderDocument, loadDocument, synthesizeSamples, synthesizeWav, runCli } from 'use-voice-control/node';
|
|
734
|
+
export type { RenderOptions, RenderResult, KokoroNodeOptions } from 'use-voice-control/node';
|
|
735
|
+
|
|
551
736
|
// Hooks
|
|
552
737
|
export { useSpeechRecognition } from 'use-voice-control/hooks';
|
|
553
738
|
export { useSpeechSynthesis } from 'use-voice-control/hooks';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview Launcher for the `use-voice-control` command.
|
|
4
|
+
*
|
|
5
|
+
* The command itself is built into `dist/cli.js`; this file exists so the
|
|
6
|
+
* shebang lives in a plain, never-bundled file and so a missing build fails with
|
|
7
|
+
* an instruction rather than a module-resolution error.
|
|
8
|
+
*/
|
|
9
|
+
let runCli;
|
|
10
|
+
try {
|
|
11
|
+
({ runCli } = await import(new URL("../dist/cli.js", import.meta.url).href));
|
|
12
|
+
} catch (error) {
|
|
13
|
+
process.stderr.write(
|
|
14
|
+
"use-voice-control: the package is not built — run `npm run build` in " +
|
|
15
|
+
`packages/use-voice-control first.\n(${error?.message ?? error})\n`
|
|
16
|
+
);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
process.exitCode = await runCli(process.argv.slice(2));
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { looksLikeMarkdown as g, markdownToSpeech as S } from "./markdown.js";
|
|
2
|
+
import { a as $, e as y, w as M, K as x, d as T } from "./kokoro-node-DJ_Rxp_N.js";
|
|
3
|
+
const E = [".md", ".markdown", ".mdown", ".mkd", ".mdx"], O = [".txt", ".text", ""];
|
|
4
|
+
function v(t) {
|
|
5
|
+
const e = t.split(/[\\/]/).pop() ?? "", s = e.lastIndexOf(".");
|
|
6
|
+
return s > 0 ? e.slice(s).toLowerCase() : "";
|
|
7
|
+
}
|
|
8
|
+
function h(t, e, s = "auto") {
|
|
9
|
+
if (s !== "auto") return s;
|
|
10
|
+
if (e) {
|
|
11
|
+
const o = v(e);
|
|
12
|
+
if (E.includes(o)) return "markdown";
|
|
13
|
+
if (O.includes(o)) return "text";
|
|
14
|
+
}
|
|
15
|
+
return g(t) ? "markdown" : "text";
|
|
16
|
+
}
|
|
17
|
+
function m(t, e, s = {}) {
|
|
18
|
+
return e === "text" ? t.replace(/\r\n?/g, `
|
|
19
|
+
`).trim() : S(t, s);
|
|
20
|
+
}
|
|
21
|
+
async function w(t) {
|
|
22
|
+
const { file: e, text: s, format: o = "auto", markdown: r = {} } = t;
|
|
23
|
+
if (s !== void 0) {
|
|
24
|
+
const n = h(s, void 0, o);
|
|
25
|
+
return { text: m(s, n, r), format: n, source: "--text" };
|
|
26
|
+
}
|
|
27
|
+
if (!e) throw new Error("No input: pass a file path, `-` for stdin, or --text");
|
|
28
|
+
if (e === "-") {
|
|
29
|
+
const n = t.readStdin ? await t.readStdin() : await q(), c = h(n, void 0, o);
|
|
30
|
+
return { text: m(n, c, r), format: c, source: "stdin" };
|
|
31
|
+
}
|
|
32
|
+
const { readFile: i } = await import("node:fs/promises"), a = await i(e, "utf8"), l = h(a, e, o);
|
|
33
|
+
return { text: m(a, l, r), format: l, source: e };
|
|
34
|
+
}
|
|
35
|
+
async function q() {
|
|
36
|
+
const t = [];
|
|
37
|
+
for await (const e of process.stdin)
|
|
38
|
+
t.push(Buffer.isBuffer(e) ? e : Buffer.from(e));
|
|
39
|
+
return Buffer.concat(t).toString("utf8");
|
|
40
|
+
}
|
|
41
|
+
function D(t) {
|
|
42
|
+
const e = v(t);
|
|
43
|
+
return `${e ? t.slice(0, -e.length) : t}.wav`;
|
|
44
|
+
}
|
|
45
|
+
async function N(t) {
|
|
46
|
+
const e = await w(t);
|
|
47
|
+
if (!e.text.trim())
|
|
48
|
+
throw new Error(`Nothing to speak: ${e.source} has no readable text`);
|
|
49
|
+
const s = t.synthesize ?? $, { samples: o, sampleRate: r } = await s(e.text, t), i = y(o, r), a = t.output ?? (t.file && t.file !== "-" ? D(t.file) : "out.wav");
|
|
50
|
+
if (a === "-")
|
|
51
|
+
process.stdout.write(Buffer.from(i));
|
|
52
|
+
else {
|
|
53
|
+
const { writeFile: l } = await import("node:fs/promises");
|
|
54
|
+
await l(a, Buffer.from(i));
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
output: a,
|
|
58
|
+
text: e.text,
|
|
59
|
+
format: e.format,
|
|
60
|
+
source: e.source,
|
|
61
|
+
durationSeconds: M(o, r),
|
|
62
|
+
bytes: i.byteLength
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const F = {
|
|
66
|
+
auto: "auto",
|
|
67
|
+
markdown: "markdown",
|
|
68
|
+
md: "markdown",
|
|
69
|
+
text: "text",
|
|
70
|
+
txt: "text",
|
|
71
|
+
plain: "text"
|
|
72
|
+
}, V = ["fp32", "fp16", "q8", "q4", "q4f16"], B = ["wasm", "webgpu", "cpu"], C = `use-voice-control — read a Markdown or text file aloud into an audio file
|
|
73
|
+
|
|
74
|
+
Usage
|
|
75
|
+
npx use-voice-control <file.md|file.txt|-> [options]
|
|
76
|
+
npx use-voice-control --text "Hello there" -o hello.wav
|
|
77
|
+
cat notes.md | npx use-voice-control - -o notes.wav
|
|
78
|
+
|
|
79
|
+
Markdown is converted before it is spoken: "#", "**" and the rest are not read
|
|
80
|
+
out, headings become their own spoken lines, links keep their text, and fenced
|
|
81
|
+
code blocks are announced instead of being spelled out.
|
|
82
|
+
|
|
83
|
+
Input
|
|
84
|
+
<file> File to read. Use "-" to read stdin.
|
|
85
|
+
--text <string> Speak this string instead of reading a file.
|
|
86
|
+
-f, --format <fmt> auto (default), markdown, or text.
|
|
87
|
+
|
|
88
|
+
Output
|
|
89
|
+
-o, --out <file> Audio file to write. Default: the input path with a
|
|
90
|
+
.wav extension. Use "-" to write the WAV to stdout.
|
|
91
|
+
-p, --print Print the speakable text and exit — no model, no audio.
|
|
92
|
+
Useful for checking the Markdown conversion.
|
|
93
|
+
-q, --quiet No progress output.
|
|
94
|
+
|
|
95
|
+
Voice
|
|
96
|
+
-v, --voice <id> Voice id. Default af_heart. See --list-voices.
|
|
97
|
+
-s, --speed <n> Speaking rate, 0.5-2. Default 1.
|
|
98
|
+
--list-voices Print the available voices and exit.
|
|
99
|
+
|
|
100
|
+
Markdown handling
|
|
101
|
+
--headings <mode> text (default) | announce | skip
|
|
102
|
+
--code <mode> announce (default) | read | skip
|
|
103
|
+
--links <mode> text (default) | text-and-url
|
|
104
|
+
--tables <mode> rows (default) | skip
|
|
105
|
+
--front-matter Read the YAML front matter instead of skipping it.
|
|
106
|
+
|
|
107
|
+
Model
|
|
108
|
+
--model <id> Hugging Face model id.
|
|
109
|
+
Default onnx-community/Kokoro-82M-v1.0-ONNX.
|
|
110
|
+
--dtype <type> fp32 | fp16 | q8 (default) | q4 | q4f16
|
|
111
|
+
--device <device> cpu (default) | wasm | webgpu
|
|
112
|
+
--chunk <chars> Target characters per synthesis chunk. Default 400.
|
|
113
|
+
--gap <ms> Silence between chunks. Default 120.
|
|
114
|
+
|
|
115
|
+
Other
|
|
116
|
+
-h, --help Show this help.
|
|
117
|
+
-V, --version Print the package version.
|
|
118
|
+
|
|
119
|
+
The first run downloads the Kokoro weights (about 90 MB at the default q8) into
|
|
120
|
+
the Hugging Face cache; later runs are offline. Speech is synthesized locally —
|
|
121
|
+
no text leaves the machine.`;
|
|
122
|
+
function d(t, e, s, o) {
|
|
123
|
+
const r = t[e + 1];
|
|
124
|
+
return r === void 0 || r.startsWith("-") ? (o.push(`${s} needs a value`), { next: e + 1 }) : { value: r, next: e + 1 };
|
|
125
|
+
}
|
|
126
|
+
function k(t, e, s) {
|
|
127
|
+
if (t === void 0) return;
|
|
128
|
+
const o = Number(t);
|
|
129
|
+
if (!Number.isFinite(o)) {
|
|
130
|
+
s.push(`${e} expects a number, got "${t}"`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
return o;
|
|
134
|
+
}
|
|
135
|
+
function f(t, e, s, o) {
|
|
136
|
+
if (t !== void 0) {
|
|
137
|
+
if (!e.includes(t)) {
|
|
138
|
+
o.push(`${s} expects one of ${e.join(", ")} — got "${t}"`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
return t;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function L(t) {
|
|
145
|
+
const e = [], s = {}, o = {
|
|
146
|
+
command: "speak",
|
|
147
|
+
format: "auto",
|
|
148
|
+
voice: "af_heart",
|
|
149
|
+
speed: 1,
|
|
150
|
+
markdown: s,
|
|
151
|
+
quiet: !1,
|
|
152
|
+
errors: e
|
|
153
|
+
};
|
|
154
|
+
let r = !1;
|
|
155
|
+
for (let i = 0; i < t.length; i += 1) {
|
|
156
|
+
const a = t[i];
|
|
157
|
+
if (r || !a.startsWith("-") || a === "-") {
|
|
158
|
+
o.input !== void 0 ? e.push(`unexpected extra input "${a}" — pass one file at a time`) : o.input = a;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (a === "--") {
|
|
162
|
+
r = !0;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const l = a.indexOf("=");
|
|
166
|
+
if (l > 1) {
|
|
167
|
+
t.splice(i, 1, a.slice(0, l), a.slice(l + 1)), i -= 1;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
switch (a) {
|
|
171
|
+
case "-h":
|
|
172
|
+
case "--help":
|
|
173
|
+
return o.command = "help", o;
|
|
174
|
+
case "-V":
|
|
175
|
+
case "--version":
|
|
176
|
+
return o.command = "version", o;
|
|
177
|
+
case "--list-voices":
|
|
178
|
+
return o.command = "list-voices", o;
|
|
179
|
+
case "-p":
|
|
180
|
+
case "--print":
|
|
181
|
+
case "--dry-run":
|
|
182
|
+
o.command = "print";
|
|
183
|
+
break;
|
|
184
|
+
case "-q":
|
|
185
|
+
case "--quiet":
|
|
186
|
+
o.quiet = !0;
|
|
187
|
+
break;
|
|
188
|
+
case "--front-matter":
|
|
189
|
+
s.frontMatter = !0;
|
|
190
|
+
break;
|
|
191
|
+
case "-o":
|
|
192
|
+
case "--out":
|
|
193
|
+
case "--output": {
|
|
194
|
+
const { value: n, next: c } = d(t, i, a, e);
|
|
195
|
+
o.output = n, i = c;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
case "--text": {
|
|
199
|
+
const n = t[i + 1];
|
|
200
|
+
n === void 0 ? e.push("--text needs a value") : o.text = n, i += 1;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
case "-f":
|
|
204
|
+
case "--format": {
|
|
205
|
+
const { value: n, next: c } = d(t, i, a, e);
|
|
206
|
+
if (i = c, n !== void 0) {
|
|
207
|
+
const u = F[n.toLowerCase()];
|
|
208
|
+
u ? o.format = u : e.push(`--format expects auto, markdown or text — got "${n}"`);
|
|
209
|
+
}
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case "-v":
|
|
213
|
+
case "--voice": {
|
|
214
|
+
const { value: n, next: c } = d(t, i, a, e);
|
|
215
|
+
i = c, n !== void 0 && (x.includes(n) ? o.voice = n : e.push(`unknown voice "${n}" — run --list-voices to see them all`));
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
case "-s":
|
|
219
|
+
case "--speed": {
|
|
220
|
+
const { value: n, next: c } = d(t, i, a, e);
|
|
221
|
+
i = c;
|
|
222
|
+
const u = k(n, a, e);
|
|
223
|
+
u !== void 0 && (u < 0.5 || u > 2 ? e.push("--speed must be between 0.5 and 2") : o.speed = u);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case "--model": {
|
|
227
|
+
const { value: n, next: c } = d(t, i, a, e);
|
|
228
|
+
o.model = n, i = c;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
case "--dtype": {
|
|
232
|
+
const { value: n, next: c } = d(t, i, a, e);
|
|
233
|
+
i = c, o.dtype = f(n, V, a, e) ?? o.dtype;
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case "--device": {
|
|
237
|
+
const { value: n, next: c } = d(t, i, a, e);
|
|
238
|
+
i = c, o.device = f(n, B, a, e) ?? o.device;
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
case "--chunk":
|
|
242
|
+
case "--chunk-length": {
|
|
243
|
+
const { value: n, next: c } = d(t, i, a, e);
|
|
244
|
+
i = c;
|
|
245
|
+
const u = k(n, a, e);
|
|
246
|
+
u !== void 0 && (u < 40 ? e.push("--chunk must be at least 40 characters") : o.maxChunkLength = u);
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
case "--gap": {
|
|
250
|
+
const { value: n, next: c } = d(t, i, a, e);
|
|
251
|
+
i = c;
|
|
252
|
+
const u = k(n, a, e);
|
|
253
|
+
u !== void 0 && (u < 0 ? e.push("--gap cannot be negative") : o.gapMs = u);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
case "--headings": {
|
|
257
|
+
const { value: n, next: c } = d(t, i, a, e);
|
|
258
|
+
i = c, s.headings = f(n, ["text", "announce", "skip"], a, e) ?? s.headings;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
case "--code":
|
|
262
|
+
case "--code-blocks": {
|
|
263
|
+
const { value: n, next: c } = d(t, i, a, e);
|
|
264
|
+
i = c, s.codeBlocks = f(n, ["announce", "read", "skip"], a, e) ?? s.codeBlocks;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
case "--links": {
|
|
268
|
+
const { value: n, next: c } = d(t, i, a, e);
|
|
269
|
+
i = c, s.links = f(n, ["text", "text-and-url"], a, e) ?? s.links;
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case "--tables": {
|
|
273
|
+
const { value: n, next: c } = d(t, i, a, e);
|
|
274
|
+
i = c, s.tables = f(n, ["rows", "skip"], a, e) ?? s.tables;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
default:
|
|
278
|
+
e.push(`unknown option "${a}" — run --help to see the options`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return o.text !== void 0 && o.input !== void 0 && e.push("pass either a file or --text, not both"), o;
|
|
282
|
+
}
|
|
283
|
+
function I() {
|
|
284
|
+
const t = x.map((e) => {
|
|
285
|
+
const s = T(e);
|
|
286
|
+
return ` ${e.padEnd(14)}${s.name.padEnd(12)}${s.gender.padEnd(8)}${s.accent}`;
|
|
287
|
+
});
|
|
288
|
+
return [`${x.length} Kokoro voices:`, ...t].join(`
|
|
289
|
+
`);
|
|
290
|
+
}
|
|
291
|
+
async function K() {
|
|
292
|
+
try {
|
|
293
|
+
const { readFile: t } = await import("node:fs/promises");
|
|
294
|
+
for (const e of ["../package.json", "../../package.json"])
|
|
295
|
+
try {
|
|
296
|
+
const s = new URL(e, import.meta.url), o = JSON.parse(await t(s, "utf8"));
|
|
297
|
+
if ((o == null ? void 0 : o.name) === "use-voice-control" && o.version) return o.version;
|
|
298
|
+
} catch {
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
}
|
|
302
|
+
return "unknown";
|
|
303
|
+
}
|
|
304
|
+
async function z(t, e = {}) {
|
|
305
|
+
const s = e.log ?? ((n) => process.stdout.write(`${n}
|
|
306
|
+
`)), o = e.error ?? ((n) => process.stderr.write(`${n}
|
|
307
|
+
`)), r = L([...t]);
|
|
308
|
+
if (r.command === "help")
|
|
309
|
+
return s(C), 0;
|
|
310
|
+
if (r.command === "version")
|
|
311
|
+
return s(await K()), 0;
|
|
312
|
+
if (r.command === "list-voices")
|
|
313
|
+
return s(I()), 0;
|
|
314
|
+
if (r.errors.length > 0)
|
|
315
|
+
return r.errors.forEach((n) => o(`use-voice-control: ${n}`)), o("Run `npx use-voice-control --help` for usage."), 1;
|
|
316
|
+
const i = e.stdinIsTTY ?? !!process.stdin.isTTY, a = r.input ?? (r.text === void 0 && !i ? "-" : void 0);
|
|
317
|
+
if (a === void 0 && r.text === void 0)
|
|
318
|
+
return o("use-voice-control: no input — pass a file, pipe text in, or use --text"), o("Run `npx use-voice-control --help` for usage."), 1;
|
|
319
|
+
const l = {
|
|
320
|
+
file: a,
|
|
321
|
+
text: r.text,
|
|
322
|
+
format: r.format,
|
|
323
|
+
markdown: r.markdown,
|
|
324
|
+
readStdin: e.readStdin
|
|
325
|
+
};
|
|
326
|
+
try {
|
|
327
|
+
if (r.command === "print") {
|
|
328
|
+
const u = await w(l);
|
|
329
|
+
return s(u.text), 0;
|
|
330
|
+
}
|
|
331
|
+
const n = Date.now(), c = await N({
|
|
332
|
+
...l,
|
|
333
|
+
output: r.output,
|
|
334
|
+
voice: r.voice,
|
|
335
|
+
speed: r.speed,
|
|
336
|
+
model: r.model,
|
|
337
|
+
dtype: r.dtype,
|
|
338
|
+
device: r.device,
|
|
339
|
+
maxChunkLength: r.maxChunkLength,
|
|
340
|
+
gapMs: r.gapMs,
|
|
341
|
+
synthesize: e.synthesize,
|
|
342
|
+
onModelProgress: r.quiet ? void 0 : (u) => {
|
|
343
|
+
if ((u == null ? void 0 : u.status) === "progress" && u.file && u.total) {
|
|
344
|
+
const p = Math.round((u.loaded ?? 0) / u.total * 100);
|
|
345
|
+
o(`downloading ${u.file}: ${p}%`);
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
onChunk: r.quiet ? void 0 : ({ index: u, total: p }) => o(`speaking chunk ${u + 1}/${p}`)
|
|
349
|
+
});
|
|
350
|
+
if (!r.quiet) {
|
|
351
|
+
const u = c.durationSeconds.toFixed(1), p = ((Date.now() - n) / 1e3).toFixed(1), b = c.output === "-" ? "stdout" : c.output;
|
|
352
|
+
o(`wrote ${b} — ${u}s of audio from ${c.source} in ${p}s`);
|
|
353
|
+
}
|
|
354
|
+
return 0;
|
|
355
|
+
} catch (n) {
|
|
356
|
+
return o(`use-voice-control: ${n instanceof Error ? n.message : String(n)}`), 1;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
export {
|
|
360
|
+
E as M,
|
|
361
|
+
O as T,
|
|
362
|
+
C as USAGE,
|
|
363
|
+
h as a,
|
|
364
|
+
N as b,
|
|
365
|
+
D as d,
|
|
366
|
+
v as e,
|
|
367
|
+
I as formatVoiceList,
|
|
368
|
+
w as l,
|
|
369
|
+
L as parseArgs,
|
|
370
|
+
q as r,
|
|
371
|
+
z as runCli,
|
|
372
|
+
m as t
|
|
373
|
+
};
|
|
374
|
+
//# sourceMappingURL=cli.js.map
|