@reins-ai/voice 0.0.0-stage → 0.1.0

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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -2
  3. package/dist/barge-in.d.ts +42 -0
  4. package/dist/barge-in.d.ts.map +1 -0
  5. package/dist/barge-in.js +78 -0
  6. package/dist/barge-in.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +100 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/config.d.ts +32 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +54 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/download.d.ts +18 -0
  16. package/dist/download.d.ts.map +1 -0
  17. package/dist/download.js +37 -0
  18. package/dist/download.js.map +1 -0
  19. package/dist/ear.d.ts +158 -0
  20. package/dist/ear.d.ts.map +1 -0
  21. package/dist/ear.js +387 -0
  22. package/dist/ear.js.map +1 -0
  23. package/dist/log.d.ts +6 -0
  24. package/dist/log.d.ts.map +1 -0
  25. package/dist/log.js +13 -0
  26. package/dist/log.js.map +1 -0
  27. package/dist/mic.d.ts +19 -0
  28. package/dist/mic.d.ts.map +1 -0
  29. package/dist/mic.js +43 -0
  30. package/dist/mic.js.map +1 -0
  31. package/dist/piper.d.ts +38 -0
  32. package/dist/piper.d.ts.map +1 -0
  33. package/dist/piper.js +90 -0
  34. package/dist/piper.js.map +1 -0
  35. package/dist/server.d.ts +21 -0
  36. package/dist/server.d.ts.map +1 -0
  37. package/dist/server.js +92 -0
  38. package/dist/server.js.map +1 -0
  39. package/dist/setup.d.ts +15 -0
  40. package/dist/setup.d.ts.map +1 -0
  41. package/dist/setup.js +73 -0
  42. package/dist/setup.js.map +1 -0
  43. package/dist/speak.d.ts +74 -0
  44. package/dist/speak.d.ts.map +1 -0
  45. package/dist/speak.js +188 -0
  46. package/dist/speak.js.map +1 -0
  47. package/dist/speech-check.d.ts +27 -0
  48. package/dist/speech-check.d.ts.map +1 -0
  49. package/dist/speech-check.js +51 -0
  50. package/dist/speech-check.js.map +1 -0
  51. package/dist/stdout-guard.d.ts +2 -0
  52. package/dist/stdout-guard.d.ts.map +1 -0
  53. package/dist/stdout-guard.js +8 -0
  54. package/dist/stdout-guard.js.map +1 -0
  55. package/dist/stt.d.ts +31 -0
  56. package/dist/stt.d.ts.map +1 -0
  57. package/dist/stt.js +86 -0
  58. package/dist/stt.js.map +1 -0
  59. package/dist/turn.d.ts +28 -0
  60. package/dist/turn.d.ts.map +1 -0
  61. package/dist/turn.js +60 -0
  62. package/dist/turn.js.map +1 -0
  63. package/dist/vad.d.ts +53 -0
  64. package/dist/vad.d.ts.map +1 -0
  65. package/dist/vad.js +120 -0
  66. package/dist/vad.js.map +1 -0
  67. package/package.json +31 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oussama Yaagoub
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,82 @@
1
- # Temporary Holding Version
1
+ # @reins-ai/voice
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Local voice for your AI: an MCP server (stdio) with a microphone that listens continuously, Whisper transcription and a natural voice (Piper) to answer, in the language you speak. Everything runs on your machine, with no external services. The AI starts it; the website knows nothing about voice.
4
+
5
+ ## Requirements
6
+
7
+ - Node 22.
8
+ - [ffmpeg](https://ffmpeg.org) on the PATH (or `--ffmpeg <path>`), with its `ffplay` (included in regular ffmpeg builds).
9
+
10
+ The first time, it downloads what it needs and verifies each file's hash. Afterwards it works offline:
11
+
12
+ | What | Purpose | Size | Where |
13
+ | --- | --- | --- | --- |
14
+ | Whisper `whisper-small` | Understanding what you say (about 100 languages) | ~240 MB | `~/.sanoriva-agent/models` |
15
+ | [Silero VAD](https://github.com/snakers4/silero-vad) (MIT) | Telling your voice from noise | ~2 MB | `~/.sanoriva-agent/silero-vad` |
16
+ | [Smart Turn v3](https://github.com/pipecat-ai/smart-turn) (BSD-2) | Telling whether you finished your turn | ~8 MB | `~/.sanoriva-agent` |
17
+ | [Piper](https://github.com/rhasspy/piper) (MIT), pinned version | Speaking with a natural voice | ~25 MB | `~/.sanoriva-agent/piper` |
18
+ | One Piper voice per language | The voice for each language, the first time the AI uses it | 60–110 MB | `~/.sanoriva-agent/voices` |
19
+
20
+ ## Connecting your AI
21
+
22
+ For Claude Code, in `.mcp.json`:
23
+
24
+ ```json
25
+ {
26
+ "mcpServers": {
27
+ "sanoriva-voice": { "type": "stdio", "command": "npx", "args": ["@reins-ai/voice"] }
28
+ }
29
+ }
30
+ ```
31
+
32
+ Then ask it to talk with you by voice, in any language.
33
+
34
+ | Option | Default | Description |
35
+ | --- | --- | --- |
36
+ | `--language <xx>` | (detected per sentence) | Fixes the 2-letter language (`es`, `en`…). Without it, the language of each sentence is detected (about 1 s more per sentence) and the AI answers in the same one. With it, each sentence is transcribed about 1 s sooner. |
37
+ | `--tts piper \| system` | `piper` | `piper`: natural voice. `system`: the operating system voice (Windows System.Speech, macOS `say`, Linux `espeak-ng`). |
38
+ | `--voice <name>` | (one per language) | A fixed voice from the [Piper catalog](https://huggingface.co/rhasspy/piper-voices), e.g. `es_ES-davefx-medium`. |
39
+ | `--mic "<name>"` | Windows: the first one ffmpeg lists; macOS/Linux: the default | The microphone to use. |
40
+ | `--ffmpeg <path>` | `ffmpeg` | Path to ffmpeg. |
41
+ | `--ffplay <path>` | next to ffmpeg | Path to ffplay. |
42
+ | `--model <id>` | `onnx-community/whisper-small` | The Whisper model. |
43
+ | `--device cpu \| dml` | Windows: `dml`; elsewhere: `cpu` | Where Whisper runs. `dml`: the graphics card through DirectML (it ships with ONNX Runtime, nothing to install), about 1 s faster per sentence; if it fails to load, the CPU is used. |
44
+ | `--setup` | off | Only prepares and exits: checks ffmpeg, ffplay and the microphone, and downloads Whisper, Silero, Smart Turn, Piper and the voice for the language (`--language`) now, so the first conversation does not wait. It reports what is fine and how to fix what is missing. It installs nothing on the system. |
45
+ | `--verbose` | off | Logs what happens to each sentence (closed, Silero, detected language, dropped echo, what was heard, how long speaking took) to `~/.sanoriva-agent/voice.log` and stderr. For troubleshooting. |
46
+
47
+ ## Tools the AI sees
48
+
49
+ | Tool | What it does |
50
+ | --- | --- |
51
+ | `listen` | Returns what the user said since the last call, or waits for their next sentence (up to 60 s). The first call opens the microphone. |
52
+ | `speak` | Reads a text out loud and returns right away, while it plays, so the AI can act on the page at the same time. With `wait: true` it returns when playback ends (or answers "Cut off" if the user talked over it): this is for reading while highlighting, sentence by sentence. In each response the AI says the highlighted sentence and highlights the next one, which is then ready; if it is cut off, it stops everything and listens. `language` (`es`, `en`, `es_MX`…) is the language of the text, to play it with a voice for that language. |
53
+ | `stop` | Closes the microphone and clears anything heard. |
54
+
55
+ The AI converses in a loop: `listen` → does what is asked → `speak` → `listen`, answering in the language the user speaks.
56
+
57
+ ## How it listens
58
+
59
+ - **Continuous:** the microphone stays open from the first `listen`, also while the AI talks, so the user can answer or interrupt at once.
60
+ - **End of turn:** at every 0.3 s pause, Smart Turn (about 100 ms on CPU, 23 languages) looks at the intonation and tells whether the user finished or is only pausing. If they finished, the sentence closes there; otherwise after 1.5 s of silence. It is only asked with at least 1 s of voice (so a short question is not split), never while the AI talks (what is heard is its echo), and one question at a time. It is downloaded once, at a pinned version verified with its SHA-256 hash.
61
+ - **"I heard you":** when the user's sentence closes while the AI listens, a short soft tone plays, so the user knows they were heard while the AI thinks. It does not play for the AI's own echo.
62
+ - **Sentences:** `listen` returns whole sentences. If voice keeps playing in the background (a TV, a video), `listen` does not wait forever: it returns what it understood at most 3 s later, and whatever there is after 60 s; anything still playing goes to the next `listen`. Each new tool call (`speak`, `stop`) ends a waiting `listen`, so the AI can always speak.
63
+ - **Room noise:** when the microphone opens it measures 0.3 s of background noise, and that level lasts the whole conversation: it falls fast with silence and rises very slowly, so voice is never mistaken for noise. Voice only needs to be twice as loud as the noise. It is not learned while the AI talks: the AI's echo is not room noise, and after a minute of reading the user would become inaudible.
64
+ - **Echo:** whatever the microphone picks up of the AI's own voice is dropped by comparing it with what the AI just said. When the AI stops talking, the current sentence (its echo) closes there, so what the user says right after arrives as a separate, whole sentence.
65
+ - **Interrupting the AI:** if the user talks while the AI talks, the AI goes quiet at once. To tell the user's voice from the AI's echo through the speakers, it compares what the microphone hears with what is playing (the Geigel double-talk detector used by hands-free phones): during the first half second of each sentence it learns how much of its voice comes back, and if the microphone hears more than twice that for 0.2 s, it stops Piper and drops its queue (without waiting for Silero, which finds too little voice in a short "stop"). If that does not fire (with loudspeakers, the user's voice and the echo may arrive equally loud), there is a second path: a sentence that started while the AI was talking and carries words the AI was not saying is the user, and it cuts the AI off as soon as Whisper transcribes it (about 1 s). Echo leftovers are dropped because they are the AI's own words. The user's sentence arrives whole on the next `listen`, which starts with "(the user interrupted you while you were speaking)". With the system voice (`--tts system`) what plays is unknown, so it does not cut off. With headphones there is no echo and it always works.
66
+ - **Stray noises:** a "sentence" with less than 0.3 s of voice (a knock, a cough) is not even transcribed.
67
+ - **Language of each sentence:** without `--language`, Whisper checks which language it hears before transcribing (like the original Whisper; transformers.js does not do it by itself and, without a language, would transcribe everything as English) and transcribes in that language.
68
+ - **Only human voice reaches Whisper:** over noise, Whisper makes up sentences (from the subtitles it was trained on, such as "Subscribe!"). To avoid it, every sentence first goes through Silero VAD, which tells human voice from noise in any language; without at least 250 ms of voice it is not transcribed. Silero stops as soon as it finds those 250 ms: ONNX Runtime alternates passes of each model, and going through a whole sentence behind Smart Turn once took 30 s. There is no list of phrases: it works for any language.
69
+
70
+ ## How it speaks
71
+
72
+ - **Piper** turns the text into audio and **ffplay** plays it while it is generated, so it starts in under half a second. `speak` returns at once; sentences play one after another, and the echo keeps being dropped until each one finishes.
73
+ - **The voice for each language** comes from the official Piper catalog (`voices.json`, at a pinned version): one for the exact variant if any (`es_MX`), otherwise one for its language (`es`); among several, `medium` quality (natural and fast) before `high`, `low` or `x_low`, and on a tie the first by name. It is downloaded the first time and verified with the catalog hashes.
74
+ - **Piper** is downloaded at a pinned version and verified with its SHA-256 hash: it is an executable, so nothing else is accepted.
75
+ - If Piper fails (offline the first time, a platform without Piper…), it speaks with the system voice and logs it.
76
+
77
+ ## Privacy
78
+
79
+ - Audio never leaves your machine: it is transcribed and spoken locally, then discarded.
80
+ - Only the programs and models in the table above are downloaded, once, and nothing is ever sent.
81
+ - The microphone closes with `stop` (when the user says "stop" or "goodbye") and also by itself if the AI stops listening for 30 s.
82
+ - stdout is the MCP channel; logs go to stderr.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Detects whether the user talks while the AI is talking. The microphone also hears the AI's voice from the
3
+ * speakers (its echo), so hearing a voice is not enough: the microphone level is compared with what is
4
+ * playing. This is the Geigel double-talk detector used by hands-free phones: if the microphone hears clearly
5
+ * more than can come back from the speaker, someone else is talking.
6
+ */
7
+ /**
8
+ * At the start of each AI sentence, how long to learn how much of its voice comes back through the
9
+ * microphone.
10
+ */
11
+ export declare const LEARN_MS = 500;
12
+ /** It is the user if the microphone hears at least this many times what comes back from the speaker… */
13
+ export declare const USER_FACTOR = 2;
14
+ /** …for this long without a break (7 blocks of 30 ms), so a single knock does not cut the AI off. */
15
+ export declare const MIN_USER_MS = 210;
16
+ export declare class EchoGate {
17
+ private played;
18
+ private learnUntil;
19
+ private ratios;
20
+ /**
21
+ * How much of the AI's voice comes back through the microphone (0 while unknown). It lasts the whole
22
+ * conversation.
23
+ */
24
+ private coupling;
25
+ private userFrames;
26
+ /** Without anything played yet (e.g. with the system voice) there is no way to tell what is echo. */
27
+ private heardAI;
28
+ /** A chunk about to play: its level and when it plays. `first` marks the first chunk of a sentence. */
29
+ play(level: number, at: number, first?: boolean): void;
30
+ /**
31
+ * Takes a 30 ms microphone block while the AI talks, with its level and the room's voice threshold. Returns
32
+ * true once the user has been talking above the echo for MIN_USER_MS.
33
+ */
34
+ mic(level: number, now: number, floor: number): boolean;
35
+ /**
36
+ * Learns from the start of the sentence: the loudest level that came back, ignoring the top 10 % in case of
37
+ * a knock. It rises at once and falls slowly, because underestimating it would make the AI cut itself off
38
+ * with its own echo.
39
+ */
40
+ private learn;
41
+ }
42
+ //# sourceMappingURL=barge-in.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"barge-in.d.ts","sourceRoot":"","sources":["../src/barge-in.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;GAGG;AACH,eAAO,MAAM,QAAQ,MAAM,CAAC;AAC5B,wGAAwG;AACxG,eAAO,MAAM,WAAW,IAAI,CAAC;AAC7B,qGAAqG;AACrG,eAAO,MAAM,WAAW,MAAM,CAAC;AAa/B,qBAAa,QAAQ;IACnB,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,MAAM,CAAgB;IAC9B;;;OAGG;IACH,OAAO,CAAC,QAAQ,CAAK;IACrB,OAAO,CAAC,UAAU,CAAK;IACvB,qGAAqG;IACrG,OAAO,CAAC,OAAO,CAAS;IAExB,uGAAuG;IACvG,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,UAAQ,GAAG,IAAI;IASpD;;;OAGG;IACH,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO;IAkBvD;;;;OAIG;IACH,OAAO,CAAC,KAAK;CAMd"}
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Detects whether the user talks while the AI is talking. The microphone also hears the AI's voice from the
3
+ * speakers (its echo), so hearing a voice is not enough: the microphone level is compared with what is
4
+ * playing. This is the Geigel double-talk detector used by hands-free phones: if the microphone hears clearly
5
+ * more than can come back from the speaker, someone else is talking.
6
+ */
7
+ /**
8
+ * At the start of each AI sentence, how long to learn how much of its voice comes back through the
9
+ * microphone.
10
+ */
11
+ export const LEARN_MS = 500;
12
+ /** It is the user if the microphone hears at least this many times what comes back from the speaker… */
13
+ export const USER_FACTOR = 2;
14
+ /** …for this long without a break (7 blocks of 30 ms), so a single knock does not cut the AI off. */
15
+ export const MIN_USER_MS = 210;
16
+ /**
17
+ * What plays reaches the microphone with some delay, and the time each chunk plays is approximate (ffplay
18
+ * takes a while to start), so the loudest level within this window is used.
19
+ */
20
+ const WINDOW_BEFORE_MS = 600;
21
+ const WINDOW_AFTER_MS = 200;
22
+ export class EchoGate {
23
+ played = [];
24
+ learnUntil = 0;
25
+ ratios = [];
26
+ /**
27
+ * How much of the AI's voice comes back through the microphone (0 while unknown). It lasts the whole
28
+ * conversation.
29
+ */
30
+ coupling = 0;
31
+ userFrames = 0;
32
+ /** Without anything played yet (e.g. with the system voice) there is no way to tell what is echo. */
33
+ heardAI = false;
34
+ /** A chunk about to play: its level and when it plays. `first` marks the first chunk of a sentence. */
35
+ play(level, at, first = false) {
36
+ if (first) {
37
+ this.learnUntil = at + LEARN_MS;
38
+ this.userFrames = 0;
39
+ }
40
+ this.played.push({ at, level });
41
+ this.heardAI = true;
42
+ }
43
+ /**
44
+ * Takes a 30 ms microphone block while the AI talks, with its level and the room's voice threshold. Returns
45
+ * true once the user has been talking above the echo for MIN_USER_MS.
46
+ */
47
+ mic(level, now, floor) {
48
+ if (!this.heardAI)
49
+ return false;
50
+ this.played = this.played.filter((p) => p.at > now - 2 * WINDOW_BEFORE_MS);
51
+ let far = 0;
52
+ for (const p of this.played)
53
+ if (p.at >= now - WINDOW_BEFORE_MS && p.at <= now + WINDOW_AFTER_MS)
54
+ far = Math.max(far, p.level);
55
+ if (now < this.learnUntil && far > 0) {
56
+ this.ratios.push(level / far);
57
+ return false;
58
+ }
59
+ if (this.ratios.length)
60
+ this.learn();
61
+ const echo = this.coupling * far;
62
+ const user = level > floor && level > USER_FACTOR * echo;
63
+ this.userFrames = user ? this.userFrames + 1 : 0;
64
+ return this.userFrames * 30 >= MIN_USER_MS;
65
+ }
66
+ /**
67
+ * Learns from the start of the sentence: the loudest level that came back, ignoring the top 10 % in case of
68
+ * a knock. It rises at once and falls slowly, because underestimating it would make the AI cut itself off
69
+ * with its own echo.
70
+ */
71
+ learn() {
72
+ const sorted = [...this.ratios].sort((a, b) => a - b);
73
+ const measured = sorted[Math.floor(sorted.length * 0.9)] ?? sorted.at(-1);
74
+ this.coupling = measured >= this.coupling ? measured : 0.9 * this.coupling + 0.1 * measured;
75
+ this.ratios = [];
76
+ }
77
+ }
78
+ //# sourceMappingURL=barge-in.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"barge-in.js","sourceRoot":"","sources":["../src/barge-in.ts"],"names":[],"mappings":"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"}
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import './stdout-guard.js';
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,OAAO,mBAAmB,CAAC"}
package/dist/cli.js ADDED
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ import './stdout-guard.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { join } from 'node:path';
5
+ import { parseConfig } from './config.js';
6
+ import { fileLog, silent } from './log.js';
7
+ import { Ear } from './ear.js';
8
+ import { createFfmpegMic, findWindowsMic } from './mic.js';
9
+ import { createVoiceServer } from './server.js';
10
+ import { createChime, createPiperSpeaker, createSpeaker, speechQueue } from './speak.js';
11
+ import { runSetup, setupSteps } from './setup.js';
12
+ import { createSileroCheck } from './speech-check.js';
13
+ import { createWhisper } from './stt.js';
14
+ import { createTurnCheck } from './turn.js';
15
+ let config;
16
+ try {
17
+ config = parseConfig(process.argv.slice(2));
18
+ }
19
+ catch (error) {
20
+ console.error(error.message);
21
+ process.exit(1);
22
+ }
23
+ // --setup prepares everything for the first conversation and exits without starting the server.
24
+ if (config.setup) {
25
+ console.error('Preparing the voice (the first time it downloads about 300 MB)…');
26
+ const ok = await runSetup(setupSteps(config));
27
+ console.error(ok ? 'All set.' : 'Something is missing: fix it and run --setup again.');
28
+ process.exit(ok ? 0 : 1);
29
+ }
30
+ const log = config.verbose ? fileLog(join(config.dataDir, 'voice.log')) : silent;
31
+ log('[startup] verbose mode: what happens to each sentence goes to voice.log');
32
+ const device = config.mic ?? (process.platform === 'win32' ? findWindowsMic(config.ffmpeg) : undefined);
33
+ if (process.platform === 'win32' && !device) {
34
+ console.error('No microphone (or ffmpeg) found. Choose one with --mic "<name>" and ffmpeg with --ffmpeg <path>.');
35
+ process.exit(1);
36
+ }
37
+ console.error(`Microphone: ${device ?? 'system default'}`);
38
+ // Whisper loads in the background: the server answers right away and listen waits for the model if needed.
39
+ const transcriber = createWhisper({ model: config.model, language: config.language, cacheDir: config.cacheDir, device: config.device, log });
40
+ transcriber.then(() => console.error(`Whisper ready (${config.model}, ${config.device})`), (error) => console.error(`No puedo cargar Whisper: ${error.message}`));
41
+ // Silero VAD (2 MB) separates human voice from noise before Whisper, in any language.
42
+ const speechCheck = createSileroCheck({ cacheDir: config.dataDir });
43
+ speechCheck.then(() => console.error('Voice detector ready (Silero VAD)'), (error) => console.error(`No voice detector (Silero): ${error.message}`));
44
+ // Smart Turn (8 MB) tells at every 0.3 s pause whether the user has finished, so there is no need to wait for
45
+ // 1.5 s of silence.
46
+ const turnCheck = createTurnCheck({ cacheDir: config.dataDir });
47
+ turnCheck.then(() => console.error('End-of-turn detector ready (Smart Turn)'), (error) => console.error(`No end-of-turn detector (Smart Turn): ${error.message}`));
48
+ // Piper gives a natural voice, with the system voice as a fallback. Piper and the voice download in the
49
+ // background at startup.
50
+ const system = createSpeaker({ lang: config.language });
51
+ // The ear gets what is about to play, to tell the AI's echo from the voice of a user who interrupts it.
52
+ const piper = config.tts === 'piper' ? createPiperSpeaker({ ...config, dataDir: config.dataDir, onLevel: (level, at, first) => ear?.playing(level, at, first) }) : undefined;
53
+ piper?.warm();
54
+ const speaker = async (text, language, signal) => {
55
+ const started = Date.now();
56
+ if (piper) {
57
+ try {
58
+ await piper(text, language, signal);
59
+ return log(`[voice] Piper · ${language ?? 'no language'} · ${Date.now() - started} ms · «${text}»`);
60
+ }
61
+ catch (error) {
62
+ log(`[voice] Piper failed (${error.message}); using the system voice`);
63
+ console.error(`Piper could not speak (${error.message}); using the system voice`);
64
+ }
65
+ }
66
+ await system(text, language, signal);
67
+ log(`[voice] system · ${language ?? 'no language'} · ${Date.now() - started} ms · «${text}»`);
68
+ };
69
+ // Speaking does not block: the AI keeps working while it plays, and each sentence waits for the previous one.
70
+ const say = speechQueue(speaker, (error) => log(`[voice] could not speak: ${error.message}`));
71
+ // The ear starts on the first listen, so the microphone only opens when there is a conversation.
72
+ let ear;
73
+ const server = createVoiceServer({
74
+ listen: async () => {
75
+ ear ??= new Ear({
76
+ openMic: createFfmpegMic({ ffmpeg: config.ffmpeg, device }),
77
+ transcriber: await transcriber,
78
+ speechCheck: await speechCheck.catch(() => undefined),
79
+ turnCheck: await turnCheck.catch(() => undefined),
80
+ // A short tone when a user sentence closes, so the user knows they were heard while the AI thinks.
81
+ onPhrase: createChime(config.ffplay),
82
+ // The user talks over the AI: it goes quiet at once.
83
+ onBargeIn: () => say.cancel(),
84
+ log,
85
+ });
86
+ return ear.next();
87
+ },
88
+ stop: () => ear?.stop(),
89
+ interrupt: () => ear?.interrupt(),
90
+ // Returns right away (or, with wait, when playback ends) so the sentence plays while the AI goes on. The
91
+ // microphone stays open; the ear knows what is being said to discard its echo until playback ends.
92
+ speak: async (text, language, wait) => {
93
+ const done = ear?.saying(text);
94
+ const finished = say(text, language).finally(() => done?.());
95
+ if (wait)
96
+ return finished;
97
+ },
98
+ });
99
+ await server.connect(new StdioServerTransport());
100
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ import { type WhisperDevice } from './stt.js';
2
+ export interface VoiceConfig {
3
+ model: string;
4
+ /** `dml` (the graphics card, the default on Windows) or `cpu`. */
5
+ device: WhisperDevice;
6
+ /**
7
+ * A 2-letter code (es, en…) to fix the language. Without it, Whisper detects the language of each sentence.
8
+ */
9
+ language?: string;
10
+ /** `piper` (default) for natural local voices, `system` for the operating system voice. */
11
+ tts: 'piper' | 'system';
12
+ /** A fixed voice from the Piper catalog, e.g. es_ES-davefx-medium. */
13
+ voice?: string;
14
+ ffplay: string;
15
+ /** On Windows, without it the first microphone ffmpeg lists is used. */
16
+ mic?: string;
17
+ ffmpeg: string;
18
+ cacheDir: string;
19
+ dataDir: string;
20
+ /** Logs what happens to each sentence in ~/.sanoriva-agent/voice.log. */
21
+ verbose: boolean;
22
+ /** Only prepares: checks ffmpeg, ffplay and the microphone, downloads the models and exits. */
23
+ setup: boolean;
24
+ }
25
+ /**
26
+ * Parses the command line. Throws an Error with the reason when an argument is invalid.
27
+ *
28
+ * @example
29
+ * parseConfig(['--language', 'es', '--verbose']);
30
+ */
31
+ export declare function parseConfig(args: string[]): VoiceConfig;
32
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAIA,OAAO,EAAwC,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAEpF,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,kEAAkE;IAClE,MAAM,EAAE,aAAa,CAAC;IACtB;;OAEG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,2FAA2F;IAC3F,GAAG,EAAE,OAAO,GAAG,QAAQ,CAAC;IACxB,sEAAsE;IACtE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,wEAAwE;IACxE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,yEAAyE;IACzE,OAAO,EAAE,OAAO,CAAC;IACjB,+FAA+F;IAC/F,KAAK,EAAE,OAAO,CAAC;CAChB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,WAAW,CA0CvD"}
package/dist/config.js ADDED
@@ -0,0 +1,54 @@
1
+ import { homedir } from 'node:os';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { DEFAULT_WHISPER_MODEL, defaultDevice } from './stt.js';
5
+ /**
6
+ * Parses the command line. Throws an Error with the reason when an argument is invalid.
7
+ *
8
+ * @example
9
+ * parseConfig(['--language', 'es', '--verbose']);
10
+ */
11
+ export function parseConfig(args) {
12
+ const { values } = parseArgs({
13
+ args,
14
+ options: {
15
+ model: { type: 'string' },
16
+ device: { type: 'string' },
17
+ language: { type: 'string' },
18
+ mic: { type: 'string' },
19
+ ffmpeg: { type: 'string' },
20
+ ffplay: { type: 'string' },
21
+ tts: { type: 'string' },
22
+ voice: { type: 'string' },
23
+ verbose: { type: 'boolean' },
24
+ setup: { type: 'boolean' },
25
+ },
26
+ });
27
+ if (values.tts !== undefined && values.tts !== 'piper' && values.tts !== 'system') {
28
+ throw new Error(`Invalid voice: --tts ${values.tts} (use piper or system)`);
29
+ }
30
+ if (values.language !== undefined && !/^[a-z]{2}$/.test(values.language)) {
31
+ throw new Error(`Invalid language: ${values.language} (use 2 letters, e.g. --language es)`);
32
+ }
33
+ if (values.device !== undefined && values.device !== 'cpu' && values.device !== 'dml') {
34
+ throw new Error(`Invalid device: --device ${values.device} (use cpu or dml)`);
35
+ }
36
+ const ffmpeg = values.ffmpeg ?? 'ffmpeg';
37
+ const dataDir = join(homedir(), '.sanoriva-agent');
38
+ return {
39
+ model: values.model ?? DEFAULT_WHISPER_MODEL,
40
+ device: values.device ?? defaultDevice(),
41
+ ...(values.language ? { language: values.language } : {}),
42
+ ...(values.mic ? { mic: values.mic } : {}),
43
+ tts: values.tts ?? 'piper',
44
+ ...(values.voice ? { voice: values.voice } : {}),
45
+ ffmpeg,
46
+ // ffplay ships with ffmpeg, so it sits next to a given ffmpeg.
47
+ ffplay: values.ffplay ?? (values.ffmpeg ? join(dirname(ffmpeg), basename(ffmpeg).replace(/ffmpeg/i, 'ffplay')) : 'ffplay'),
48
+ cacheDir: join(dataDir, 'models'),
49
+ dataDir,
50
+ verbose: values.verbose ?? false,
51
+ setup: values.setup ?? false,
52
+ };
53
+ }
54
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ export interface Expected {
2
+ sha256?: string;
3
+ md5?: string;
4
+ size?: number;
5
+ }
6
+ export type Fetch = (url: string) => Promise<{
7
+ ok: boolean;
8
+ status: number;
9
+ arrayBuffer(): Promise<ArrayBuffer>;
10
+ }>;
11
+ /** Checks size and hashes. Throws an Error on mismatch. */
12
+ export declare function verify(data: Buffer, expected: Expected, name: string): void;
13
+ /**
14
+ * Downloads a file once and stores it at `dest`, only if it matches what is expected; otherwise nothing is
15
+ * left on disk. An existing file is not downloaded again.
16
+ */
17
+ export declare function download(url: string, dest: string, expected: Expected, fetchImpl?: Fetch): Promise<string>;
18
+ //# sourceMappingURL=download.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"download.d.ts","sourceRoot":"","sources":["../src/download.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,QAAQ;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,MAAM,KAAK,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,WAAW,IAAI,OAAO,CAAC,WAAW,CAAC,CAAA;CAAE,CAAC,CAAC;AAEnH,2DAA2D;AAC3D,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAU3E;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,SAAS,GAAE,KAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAWvH"}
@@ -0,0 +1,37 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, rename, writeFile } from 'node:fs/promises';
4
+ import { dirname } from 'node:path';
5
+ /** Checks size and hashes. Throws an Error on mismatch. */
6
+ export function verify(data, expected, name) {
7
+ if (expected.size !== undefined && data.length !== expected.size) {
8
+ throw new Error(`${name}: size ${data.length}, expected ${expected.size}`);
9
+ }
10
+ for (const algorithm of ['sha256', 'md5']) {
11
+ const want = expected[algorithm];
12
+ if (!want)
13
+ continue;
14
+ const got = createHash(algorithm).update(data).digest('hex');
15
+ if (got !== want)
16
+ throw new Error(`${name}: ${algorithm} hash mismatch (corrupted or altered download)`);
17
+ }
18
+ }
19
+ /**
20
+ * Downloads a file once and stores it at `dest`, only if it matches what is expected; otherwise nothing is
21
+ * left on disk. An existing file is not downloaded again.
22
+ */
23
+ export async function download(url, dest, expected, fetchImpl = fetch) {
24
+ if (existsSync(dest))
25
+ return dest;
26
+ console.error(`Descargando ${url}…`);
27
+ const response = await fetchImpl(url);
28
+ if (!response.ok)
29
+ throw new Error(`No puedo descargar ${url} (HTTP ${response.status})`);
30
+ const data = Buffer.from(await response.arrayBuffer());
31
+ verify(data, expected, url);
32
+ await mkdir(dirname(dest), { recursive: true });
33
+ await writeFile(`${dest}.part`, data);
34
+ await rename(`${dest}.part`, dest);
35
+ return dest;
36
+ }
37
+ //# sourceMappingURL=download.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"download.js","sourceRoot":"","sources":["../src/download.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC5D,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAUpC,2DAA2D;AAC3D,MAAM,UAAU,MAAM,CAAC,IAAY,EAAE,QAAkB,EAAE,IAAY;IACnE,IAAI,QAAQ,CAAC,IAAI,KAAK,SAAS,IAAI,IAAI,CAAC,MAAM,KAAK,QAAQ,CAAC,IAAI,EAAE,CAAC;QACjE,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,UAAU,IAAI,CAAC,MAAM,cAAc,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;IAC7E,CAAC;IACD,KAAK,MAAM,SAAS,IAAI,CAAC,QAAQ,EAAE,KAAK,CAAU,EAAE,CAAC;QACnD,MAAM,IAAI,GAAG,QAAQ,CAAC,SAAS,CAAC,CAAC;QACjC,IAAI,CAAC,IAAI;YAAE,SAAS;QACpB,MAAM,GAAG,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QAC7D,IAAI,GAAG,KAAK,IAAI;YAAE,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,KAAK,SAAS,gDAAgD,CAAC,CAAC;IAC3G,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,GAAW,EAAE,IAAY,EAAE,QAAkB,EAAE,YAAmB,KAAK;IACpG,IAAI,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAClC,OAAO,CAAC,KAAK,CAAC,eAAe,GAAG,GAAG,CAAC,CAAC;IACrC,MAAM,QAAQ,GAAG,MAAM,SAAS,CAAC,GAAG,CAAC,CAAC;IACtC,IAAI,CAAC,QAAQ,CAAC,EAAE;QAAE,MAAM,IAAI,KAAK,CAAC,sBAAsB,GAAG,UAAU,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC;IACzF,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,QAAQ,CAAC,WAAW,EAAE,CAAC,CAAC;IACvD,MAAM,CAAC,IAAI,EAAE,QAAQ,EAAE,GAAG,CAAC,CAAC;IAC5B,MAAM,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAChD,MAAM,SAAS,CAAC,GAAG,IAAI,OAAO,EAAE,IAAI,CAAC,CAAC;IACtC,MAAM,MAAM,CAAC,GAAG,IAAI,OAAO,EAAE,IAAI,CAAC,CAAC;IACnC,OAAO,IAAI,CAAC;AACd,CAAC"}