@agfpd/voice-connect 0.1.11

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.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Shared Russian-stress accentuation via ruaccent.
3
+ *
4
+ * ruaccent (an ML model) marks the stressed vowel with a '+' immediately BEFORE
5
+ * it — verified live 2026-06-01 ("Сл+ожная с+интеза. З+амок ..."). This module
6
+ * produces that raw '+'-marked text; the two TTS consumers differ in what they
7
+ * do with it:
8
+ * - F5-TTS understands the '+' marker NATIVELY → uses accentPlus() output as-is.
9
+ * - Supertonic mis-reads a raw '+' as a sound → maps '+<vowel>' → '<vowel>' +
10
+ * U+0301 on top of accentPlus() (see stress.mjs).
11
+ * Gemini stresses Russian correctly on its own and uses neither.
12
+ *
13
+ * ruaccent is pip-installed into the managed Supertonic venv, so the python is
14
+ * resolved from there (or an explicit override / caller hint). Best-effort: any
15
+ * failure returns the text unchanged — stress is an enhancement, never a hard
16
+ * dependency for synthesis.
17
+ */
18
+ import { execFile } from 'node:child_process';
19
+ import { promisify } from 'node:util';
20
+ import { join, dirname } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { access } from 'node:fs/promises';
23
+ import { constants as FS } from 'node:fs';
24
+ import { peerVoiceHome } from './home.mjs';
25
+
26
+ const pexecFile = promisify(execFile);
27
+
28
+ /** The python CLI helper that actually runs ruaccent (shared by both engines). */
29
+ const HELPER = join(dirname(fileURLToPath(import.meta.url)), 'ruaccent_stress.py');
30
+
31
+ /** Canonical home of ruaccent: the managed Supertonic venv (pip installs it there). */
32
+ function managedVenvPython() {
33
+ return join(peerVoiceHome(), 'supertonic-venv', 'bin', 'python');
34
+ }
35
+
36
+ async function exists(p) {
37
+ try { await access(p, FS.X_OK); return true; } catch { return false; }
38
+ }
39
+ function log(msg) { process.stderr.write(`[peer-voice/ruaccent] ${msg}\n`); }
40
+ async function run(bin, args) {
41
+ return pexecFile(bin, args, { maxBuffer: 64 * 1024 * 1024 });
42
+ }
43
+
44
+ let checked = false;
45
+ let resolvedPython = null;
46
+
47
+ /**
48
+ * Resolve a python that can `import ruaccent` (installing it once if a sibling
49
+ * pip is present). Resolution order:
50
+ * 1. PEER_VOICE_RUACCENT_PYTHON (explicit override)
51
+ * 2. caller `hint` (e.g. supertonic's resolved-CLI sibling python)
52
+ * 3. managed venv python ($PEER_VOICE_HOME/supertonic-venv/bin/python,
53
+ * default ~/.iapeer/cache/peer-voice)
54
+ * Returns null — accentuation is skipped gracefully — when disabled, when no
55
+ * candidate python exists, or when install fails. Memoized after the first call,
56
+ * so `hint` only matters on that first resolution (env override covers the rest).
57
+ * @param {string} [hint] preferred python path, tried before the managed venv
58
+ * @returns {Promise<string|null>}
59
+ */
60
+ export async function ensureRuaccentPython(hint) {
61
+ if (process.env.PEER_VOICE_RUACCENT === '0') return null; // operator opt-out
62
+ if (checked) return resolvedPython;
63
+ checked = true;
64
+
65
+ const candidates = [
66
+ process.env.PEER_VOICE_RUACCENT_PYTHON,
67
+ hint,
68
+ managedVenvPython(),
69
+ ].filter(Boolean);
70
+
71
+ for (const py of candidates) {
72
+ if (!(await exists(py))) continue;
73
+ try {
74
+ await run(py, ['-c', 'import ruaccent']);
75
+ resolvedPython = py;
76
+ return py;
77
+ } catch { /* python present but ruaccent missing — try to install below */ }
78
+
79
+ const pip = join(dirname(py), 'pip');
80
+ if (!(await exists(pip))) continue;
81
+ log('pip install ruaccent (one-time; transformers+onnxruntime, no torch)…');
82
+ try {
83
+ await run(pip, ['install', '--quiet', 'ruaccent']);
84
+ await run(py, ['-c', 'import ruaccent']);
85
+ resolvedPython = py;
86
+ return py;
87
+ } catch (e) {
88
+ log(`ruaccent install failed (${e.message}); trying next candidate.`);
89
+ }
90
+ }
91
+ log('no venv python with ruaccent found; Russian stress disabled (synthesis continues).');
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Russian text with ruaccent '+' stress markers, or the original text on any
97
+ * failure. Does NOT map to U+0301 — that is Supertonic's concern (stress.mjs);
98
+ * F5 reads the '+' natively.
99
+ * @param {string} text
100
+ * @param {string} [hint] preferred python path (see ensureRuaccentPython)
101
+ * @returns {Promise<string>}
102
+ */
103
+ export async function accentPlus(text, hint) {
104
+ const py = await ensureRuaccentPython(hint);
105
+ if (!py) return text;
106
+ try {
107
+ const { stdout } = await run(py, [HELPER, text]);
108
+ const marked = (stdout != null ? String(stdout) : '').trim();
109
+ return marked || text;
110
+ } catch (e) {
111
+ log(`ruaccent processing failed (${e.message}); using unaccented text.`);
112
+ return text;
113
+ }
114
+ }
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python3
2
+ """Russian stress marker for the Peer-Voice Supertonic branch.
3
+
4
+ Reads UTF-8 text (argv[1], else stdin), runs ruaccent to place stress, and
5
+ writes the '+'-marked text to stdout — '+' immediately before each stressed
6
+ vowel (ruaccent's native format). The JS side maps '+<vowel>' -> '<vowel>'+
7
+ U+0301 (combining acute): Supertonic mis-reads the raw '+' as a sound but
8
+ honors the combining accent. Verified live 2026-06-01 (Artur confirmed by ear).
9
+
10
+ Only the accented text goes to stdout — model-load/import chatter is redirected
11
+ to stderr so the caller can read stdout cleanly.
12
+ """
13
+ import sys
14
+ import contextlib
15
+
16
+
17
+ def _align_token_type_ids(accentizer):
18
+ """Re-add 'token_type_ids' to each ruaccent sub-model tokenizer that needs it.
19
+
20
+ transformers 5.x dropped 'token_type_ids' from the default
21
+ PreTrainedTokenizer.model_input_names (4.x had it). ruaccent's *accent*
22
+ model is a BERT-architecture ONNX graph (exported under transformers 4.29)
23
+ whose feed REQUIRES token_type_ids, but its CharTokenizer relies on that
24
+ base-class default and so stopped emitting them — onnxruntime then rejects
25
+ the run with "Required inputs (['token_type_ids']) are missing", ruaccent
26
+ raises, and the caller falls back to UNACCENTED text. This only bites words
27
+ absent from the dictionary (the neural path); all-dictionary text masks it.
28
+
29
+ Fix: for any sub-model whose ONNX graph requires token_type_ids, add it back
30
+ to that tokenizer's model_input_names. The tokenizer then fills them with
31
+ zeros (single sequence) — exactly what the 4.29-era export expects.
32
+ Defensive throughout: a probe failure must never block synthesis.
33
+ """
34
+ submodels = ('accent_model', 'omograph_model',
35
+ 'yo_homograph_model', 'stress_usage_predictor')
36
+ for name in submodels:
37
+ model = getattr(accentizer, name, None)
38
+ session = getattr(model, 'session', None)
39
+ tokenizer = getattr(model, 'tokenizer', None)
40
+ if session is None or tokenizer is None:
41
+ continue
42
+ try:
43
+ required = {i.name for i in session.get_inputs()}
44
+ names = list(tokenizer.model_input_names)
45
+ if 'token_type_ids' in required and 'token_type_ids' not in names:
46
+ tokenizer.model_input_names = names + ['token_type_ids']
47
+ except Exception:
48
+ continue
49
+
50
+
51
+ def main():
52
+ text = sys.argv[1] if len(sys.argv) > 1 else sys.stdin.read()
53
+ if not text.strip():
54
+ sys.stdout.write(text)
55
+ return
56
+ with contextlib.redirect_stdout(sys.stderr):
57
+ from ruaccent import RUAccent
58
+ accentizer = RUAccent()
59
+ accentizer.load(omograph_model_size='turbo3.1', use_dictionary=True)
60
+ _align_token_type_ids(accentizer)
61
+ result = accentizer.process_all(text)
62
+ sys.stdout.write(result)
63
+
64
+
65
+ if __name__ == '__main__':
66
+ main()
package/src/server.mjs ADDED
@@ -0,0 +1,278 @@
1
+ /**
2
+ * voice-connect MCP server. Exposes the voice tools any peer can call:
3
+ * - `tts` — turn text into a ready-to-send .ogg/opus voice file.
4
+ * - `stt` — transcribe an audio file to text.
5
+ * - `voice_create` — DEPRECATED alias for `tts` (one release, then removed).
6
+ *
7
+ * Engine routing with fallback lives in voice.mjs (tts) and stt.mjs (stt); this
8
+ * file is just the MCP wiring. Like spawned-peer's server, it separates the pure
9
+ * factory (createServer) from the side-effecting bootstrap (main), so tests can
10
+ * import createServer without touching stdio.
11
+ */
12
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
13
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
+ import {
15
+ ListToolsRequestSchema,
16
+ CallToolRequestSchema,
17
+ } from '@modelcontextprotocol/sdk/types.js';
18
+ import { readFileSync } from 'node:fs';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { dirname, join } from 'node:path';
21
+ import { createVoice } from './voice.mjs';
22
+ import { transcribe as transcribeAudio } from './stt.mjs';
23
+ import { isLongText, dispatchVoiceJob } from './jobs.mjs';
24
+ import { callerPersonality } from './profile.mjs';
25
+
26
+ const here = dirname(fileURLToPath(import.meta.url));
27
+
28
+ export function readVersion() {
29
+ try {
30
+ const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
31
+ return pkg.version ?? '0.0.0';
32
+ } catch {
33
+ return '0.0.0';
34
+ }
35
+ }
36
+
37
+ // Shared voice (TTS) tool surface — used verbatim by `tts` and its deprecated
38
+ // `voice_create` alias, so the two never drift.
39
+ const VOICE_DESCRIPTION =
40
+ `Synthesize speech from text into a ready-to-send .ogg/opus voice file. ` +
41
+ `Give text, get an ogg — one generation pass, smooth speech, no pauses.\n\n` +
42
+ `SYNC vs ASYNC (automatic, by length):\n` +
43
+ `• SHORT text → returns { path, engine, voice, lang?, probe, fallback_from? } ` +
44
+ `synchronously. Attach it: send_to_peer(personality, attachments=[path]).\n` +
45
+ `• LONG text → returns { job_id, status:"started" } in <1s — you are NOT ` +
46
+ `blocked for the (minutes-long) synthesis. A background worker finishes it ` +
47
+ `and sends YOU an IAP message: "voice job <id> done path=<path> note=<note>" ` +
48
+ `on success, or "voice job <id> failed reason=<...>" on error. When that ` +
49
+ `arrives, deliver the ogg yourself: send_to_peer(<recipient>, attachments=[path]). ` +
50
+ `Use the note arg to remind your future self who to send it to / a caption.\n\n` +
51
+ `Delivery is never automatic — the tool only produces the file (or the job).\n\n` +
52
+ `Engine routing (automatic, both modes): Gemini 3.1 Flash TTS primary (mixed ` +
53
+ `ru+en in one pass, natural prosody); gpt-audio over OpenRouter second (cloud ` +
54
+ `quality when the Gemini key is exhausted); F5-TTS third (live-prosody, per-peer ` +
55
+ `voice); Supertonic 3 local floor (offline). Transparent — you normally only ` +
56
+ `pass text (and optionally voice / style / note).`;
57
+
58
+ const VOICE_INPUT_SCHEMA = {
59
+ type: 'object',
60
+ properties: {
61
+ text: {
62
+ type: 'string',
63
+ description:
64
+ `Text to speak. Mixed ru+en is fine — Gemini detects language and ` +
65
+ `reads it in one smooth pass. Long text is synthesized asynchronously ` +
66
+ `(you get a job_id and an IAP "done" message later).`,
67
+ minLength: 1,
68
+ },
69
+ voice: {
70
+ type: 'string',
71
+ description:
72
+ `Gemini prebuilt voice. Default "Aoede" (chosen for the natalya tutor ` +
73
+ `personality). Other good female voices: Kore, Leda, Zephyr.`,
74
+ },
75
+ lang: {
76
+ type: 'string',
77
+ enum: ['ru', 'en', 'na'],
78
+ description:
79
+ `Optional language hint for the FALLBACK engine (Gemini stays primary ` +
80
+ `and reads any language itself — this only matters if Gemini is down). ` +
81
+ `You know your text, so set it: "ru" Russian, including Russian-dominant ` +
82
+ `with a few English words (F5 voices them intelligibly); "en" English; ` +
83
+ `"na" a balanced ru+en mix (Supertonic reads both in one native pass). ` +
84
+ `Omit to auto-detect by character share.`,
85
+ },
86
+ note: {
87
+ type: 'string',
88
+ description:
89
+ `Optional reminder for async (long) jobs, echoed back verbatim in the ` +
90
+ `IAP "done" message — e.g. who to deliver the ogg to or a caption. ` +
91
+ `Ignored for short (synchronous) text, which returns the path directly.`,
92
+ },
93
+ style: {
94
+ type: 'string',
95
+ description:
96
+ `Optional delivery directive — HOW to voice the text (tone, emotion, ` +
97
+ `tempo, accent), separate from WHAT to say. Applies to the cloud engines: ` +
98
+ `gpt-audio (folded into its prompt) and Gemini (natural-language style ` +
99
+ `prefix). The local fallbacks (F5/Supertonic) have no style layer and ` +
100
+ `ignore it gracefully. E.g. "спокойно и тепло", "медленно, шёпотом", ` +
101
+ `"as an excited sports announcer".`,
102
+ },
103
+ engine: {
104
+ type: 'string',
105
+ enum: ['auto', 'gemini', 'gpt-audio', 'supertonic'],
106
+ description:
107
+ `Advanced/testing. "auto" (default): Gemini direct → gpt-audio (OpenRouter) ` +
108
+ `→ F5 → Supertonic, falling on quota/no-key/unavailable. "gemini", ` +
109
+ `"gpt-audio" or "supertonic" forces that engine.`,
110
+ },
111
+ out_path: {
112
+ type: 'string',
113
+ description:
114
+ `Advanced. Absolute output .ogg path. Default: a unique file under ~/.iapeer/cache/peer-voice/out/.`,
115
+ },
116
+ },
117
+ required: ['text'],
118
+ additionalProperties: false,
119
+ };
120
+
121
+ const VOICE_ANNOTATIONS = {
122
+ title: 'Create a voice file from text',
123
+ readOnlyHint: false,
124
+ destructiveHint: false,
125
+ idempotentHint: false,
126
+ openWorldHint: true,
127
+ };
128
+
129
+ const STT_INPUT_SCHEMA = {
130
+ type: 'object',
131
+ properties: {
132
+ audio_path: {
133
+ type: 'string',
134
+ description:
135
+ `Absolute path to the audio file to transcribe (e.g. a received voice ` +
136
+ `.ogg/.wav/.mp3). The transcript is returned as text.`,
137
+ minLength: 1,
138
+ },
139
+ lang: {
140
+ type: 'string',
141
+ description:
142
+ `Optional language hint (ISO-639-1: "en", "ru", …). Omit to auto-detect.`,
143
+ },
144
+ prompt: {
145
+ type: 'string',
146
+ description:
147
+ `Optional decoder-priming prompt — biases spelling/casing of terms ` +
148
+ `(e.g. keep "Claude Code"/"Gemini" in Latin). Not part of the output.`,
149
+ },
150
+ engine: {
151
+ type: 'string',
152
+ enum: ['auto', 'speaches', 'mlx-whisper'],
153
+ description:
154
+ `Advanced/testing. "auto" (default): speaches (when an endpoint is ` +
155
+ `configured) → mlx-whisper local floor. Force one with "speaches" or ` +
156
+ `"mlx-whisper".`,
157
+ },
158
+ },
159
+ required: ['audio_path'],
160
+ additionalProperties: false,
161
+ };
162
+
163
+ export function createServer({ version, voice, dispatch, transcribe } = {}) {
164
+ const mcp = new Server(
165
+ { name: 'voice-connect', version: version ?? readVersion() },
166
+ {
167
+ capabilities: { tools: {} },
168
+ instructions:
169
+ `voice-connect — voice for agents. Two tools over one core:\n` +
170
+ `• tts(text) — synthesize an .ogg/opus voice file. SHORT text → returns ` +
171
+ `{ path, ... } synchronously; LONG text → returns { job_id, status:"started" } ` +
172
+ `and a background worker IAP-messages you "voice job <id> done path=<path> ` +
173
+ `note=<note>" when ready. The tool does NOT deliver — attach the path ` +
174
+ `yourself: send_to_peer(personality, attachments=[path]).\n` +
175
+ `• stt(audio_path) — transcribe an audio file to text.\n` +
176
+ `(voice_create is a DEPRECATED alias for tts.)\n\n` +
177
+ `Routing is automatic with fallback inside each tool — TTS: Gemini → ` +
178
+ `gpt-audio (OpenRouter) → F5 → Supertonic local floor. STT: speaches ` +
179
+ `(when configured) → mlx-whisper local floor.`,
180
+ },
181
+ );
182
+
183
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
184
+ tools: [
185
+ { name: 'tts', description: VOICE_DESCRIPTION, inputSchema: VOICE_INPUT_SCHEMA, annotations: VOICE_ANNOTATIONS },
186
+ {
187
+ name: 'voice_create',
188
+ description: `DEPRECATED — alias for \`tts\`, kept one release for compatibility; use \`tts\`.\n\n${VOICE_DESCRIPTION}`,
189
+ inputSchema: VOICE_INPUT_SCHEMA,
190
+ annotations: VOICE_ANNOTATIONS,
191
+ },
192
+ {
193
+ name: 'stt',
194
+ description:
195
+ `Transcribe an audio file to text. Give a path to an audio file (e.g. a ` +
196
+ `received voice .ogg), get back the transcript. Engine cascade inside the ` +
197
+ `tool: speaches (OpenAI-compatible, when PEER_VOICE_STT_ENDPOINT is set) → ` +
198
+ `mlx-whisper local floor (offline). Returns { text, engine, fallback_from? }.`,
199
+ inputSchema: STT_INPUT_SCHEMA,
200
+ annotations: {
201
+ title: 'Transcribe audio to text',
202
+ readOnlyHint: true,
203
+ destructiveHint: false,
204
+ idempotentHint: true,
205
+ openWorldHint: true,
206
+ },
207
+ },
208
+ ],
209
+ }));
210
+
211
+ const voiceImpl = voice ?? createVoice;
212
+ const dispatchImpl = dispatch ?? dispatchVoiceJob;
213
+ const transcribeImpl = transcribe ?? transcribeAudio;
214
+
215
+ async function handleVoice(raw) {
216
+ const text = typeof raw.text === 'string' ? raw.text : undefined;
217
+ const voiceArg = typeof raw.voice === 'string' ? raw.voice : undefined;
218
+ const engine = ['gemini', 'gpt-audio', 'supertonic', 'auto'].includes(raw.engine) ? raw.engine : 'auto';
219
+ const lang = ['ru', 'en', 'na'].includes(raw.lang) ? raw.lang : undefined;
220
+ const style = typeof raw.style === 'string' ? raw.style : undefined;
221
+ const out_path = typeof raw.out_path === 'string' ? raw.out_path : undefined;
222
+ const note = typeof raw.note === 'string' ? raw.note : undefined;
223
+ // Long text → async: dispatch a detached worker and return {job_id} now, so
224
+ // the agent isn't blocked for the minutes-long synthesis. Short → sync.
225
+ if (isLongText(text)) {
226
+ const job = await dispatchImpl({
227
+ text, voice: voiceArg, engine, lang, style, out_path, note,
228
+ // env → cwd-profile ladder: codex MCP children get no parent env, so
229
+ // the IAP-notify target must be recoverable from the cwd profile.
230
+ personality: callerPersonality(),
231
+ });
232
+ return { content: [{ type: 'text', text: JSON.stringify(job, null, 2) }], structuredContent: job };
233
+ }
234
+ const result = await voiceImpl({ text, voice: voiceArg, engine, lang, style, out_path });
235
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result };
236
+ }
237
+
238
+ async function handleStt(raw) {
239
+ const audioPath = typeof raw.audio_path === 'string' ? raw.audio_path : undefined;
240
+ const engine = ['speaches', 'mlx-whisper', 'auto'].includes(raw.engine) ? raw.engine : 'auto';
241
+ const lang = typeof raw.lang === 'string' ? raw.lang : undefined;
242
+ const prompt = typeof raw.prompt === 'string' ? raw.prompt : undefined;
243
+ const result = await transcribeImpl({ audioPath, engine, lang, prompt });
244
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result };
245
+ }
246
+
247
+ mcp.setRequestHandler(CallToolRequestSchema, async req => {
248
+ const name = req.params.name;
249
+ const raw = req.params.arguments ?? {};
250
+ try {
251
+ if (name === 'tts' || name === 'voice_create') return await handleVoice(raw);
252
+ if (name === 'stt') return await handleStt(raw);
253
+ return { isError: true, content: [{ type: 'text', text: `unknown tool: ${name}` }] };
254
+ } catch (err) {
255
+ const msg = err instanceof Error ? err.message : String(err);
256
+ return { isError: true, content: [{ type: 'text', text: `${name} failed: ${msg}` }] };
257
+ }
258
+ });
259
+
260
+ return mcp;
261
+ }
262
+
263
+ export async function main() {
264
+ const version = readVersion();
265
+ const mcp = createServer({ version });
266
+ await mcp.connect(new StdioServerTransport());
267
+ process.stderr.write(`voice-connect: started (version ${version})\n`);
268
+
269
+ // Self-reap if orphaned (parent died / stdin closed) — same guard as spawned-peer.
270
+ const bootPpid = process.ppid;
271
+ setInterval(() => {
272
+ const orphaned =
273
+ (process.platform !== 'win32' && process.ppid !== bootPpid) ||
274
+ process.stdin.destroyed ||
275
+ process.stdin.readableEnded;
276
+ if (orphaned) process.exit(0);
277
+ }, 5000).unref();
278
+ }
package/src/stress.mjs ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Russian stress mapping for the Supertonic branch.
3
+ *
4
+ * ruaccent emits '+' immediately BEFORE each stressed vowel — verified live
5
+ * 2026-06-01 against real ruaccent output ("Сл+ожная с+интеза. З+амок ...
6
+ * +орган т+ела." — every '+' precedes the stressed vowel). Supertonic mis-reads
7
+ * the raw '+' as a sound (garbles output), but honors a U+0301 combining acute.
8
+ * The combining acute attaches to the PRECEDING base character, so to accent the
9
+ * vowel that follows the '+', we drop the '+' and append U+0301 after that vowel:
10
+ * '+<vowel>' -> '<vowel>' + U+0301. Artur confirmed the U+0301 result by ear.
11
+ * Gemini does not need this (it stresses Russian correctly on its own).
12
+ *
13
+ * The map is intentionally narrow: only a '+' immediately followed by a Russian
14
+ * vowel is rewritten, so a stray '+' in source text ("C++", "2+2") is left
15
+ * untouched, and Latin/digits are never affected.
16
+ */
17
+ const STRESS_VOWELS = 'аеёиоуыэюяАЕЁИОУЫЭЮЯ';
18
+ const COMBINING_ACUTE = '́';
19
+ const STRESS_RE = new RegExp(`\\+([${STRESS_VOWELS}])`, 'g');
20
+
21
+ /** @param {string} text ruaccent '+'-marked text @returns {string} */
22
+ export function mapStressToUnicode(text) {
23
+ if (typeof text !== 'string' || !text) return text;
24
+ return text.replace(STRESS_RE, (_, vowel) => vowel + COMBINING_ACUTE);
25
+ }
package/src/stt.mjs ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * stt — transcribe audio to text. The STT half of the core, symmetric with
3
+ * voice.mjs (tts): one call, an engine cascade with fallback INSIDE the tool.
4
+ *
5
+ * auto: speaches (when an endpoint is configured) → mlx-whisper local floor.
6
+ * forced: a single named engine, whose failure propagates to the caller.
7
+ *
8
+ * Audio in (a file path), text out. No delivery, no encoding — the caller (MCP
9
+ * stt tool in Ф4, HTTP /v1/audio/transcriptions in Ф5) owns I/O.
10
+ */
11
+ import { runCascade } from './router.mjs';
12
+ import { sttProviderByEngine, buildSttCascade } from './providers.mjs';
13
+
14
+ /**
15
+ * @param {object} opts
16
+ * @param {string} opts.audioPath path to the audio file to transcribe
17
+ * @param {'auto'|'speaches'|'mlx-whisper'} [opts.engine] default 'auto'
18
+ * @param {string} [opts.lang] language hint (ISO-639-1: 'en', 'ru', …)
19
+ * @param {string} [opts.prompt] decoder-priming prompt (term spelling/casing)
20
+ * @returns {Promise<{text, engine, fallback_from?}>}
21
+ */
22
+ export async function transcribe(opts = {}) {
23
+ const audioPath = opts.audioPath;
24
+ if (!audioPath || typeof audioPath !== 'string') {
25
+ throw new Error('stt: `audioPath` is required.');
26
+ }
27
+ const engine = ['speaches', 'mlx-whisper', 'auto'].includes(opts.engine) ? opts.engine : 'auto';
28
+ const lang = typeof opts.lang === 'string' && opts.lang.trim() ? opts.lang.trim() : undefined;
29
+ const prompt = typeof opts.prompt === 'string' && opts.prompt.trim() ? opts.prompt.trim() : undefined;
30
+ const ctx = { audioPath, lang, prompt };
31
+
32
+ if (engine === 'auto') {
33
+ const result = await runCascade(buildSttCascade(), ctx, {
34
+ onAdvance: (name, err) =>
35
+ process.stderr.write(`[peer-voice] STT ${name} unavailable (${err.message}); advancing cascade.\n`),
36
+ });
37
+ return {
38
+ text: result.text,
39
+ engine: result.name,
40
+ ...(result.fallbackFrom ? { fallback_from: result.fallbackFrom } : {}),
41
+ };
42
+ }
43
+
44
+ // Forced single engine — no cascade; its failure propagates to the caller.
45
+ const provider = sttProviderByEngine(engine);
46
+ const { text } = await provider.transcribe(ctx);
47
+ return { text, engine: provider.name };
48
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Structured synthesis log — one JSON line per createVoice() call.
3
+ *
4
+ * Why: the async worker logs richly to its per-job <job_id>.log, but the
5
+ * SYNCHRONOUS path (short text, ≤ async threshold) used to leave no trace at
6
+ * all — when Artur reported a "truncated Natalya voice-note", the engine and
7
+ * generation params had to be reconstructed from the author peer after the
8
+ * fact. This closes that gap on the right layer: createVoice (the single
9
+ * synthesis entry point for BOTH sync and async) appends a structured record
10
+ * here for every generation, success or failure.
11
+ *
12
+ * Sink: $PEER_VOICE_HOME/voice.log (default ~/.iapeer/cache/peer-voice/voice.log),
13
+ * append-only JSON Lines. Find the record for a specific call by the output
14
+ * `path` it returned, e.g.:
15
+ * grep '"<that .ogg path>"' ~/.iapeer/cache/peer-voice/voice.log
16
+ *
17
+ * Each record carries: ts, ok, engine, chars (text length), voice, lang,
18
+ * duration (final audio seconds, from ffprobe), fallback_from, and — for Gemini
19
+ * — finishReason. Best-effort: a logging failure is swallowed and never affects
20
+ * the returned audio (same posture as saveRef).
21
+ */
22
+ import { appendFile, mkdir } from 'node:fs/promises';
23
+ import { dirname, join } from 'node:path';
24
+ import { peerVoiceHome } from './home.mjs';
25
+
26
+ /** Absolute path of the JSON-Lines synthesis log. Env-tunable via PEER_VOICE_HOME. */
27
+ export function synthLogPath() {
28
+ return join(peerVoiceHome(), 'voice.log');
29
+ }
30
+
31
+ /**
32
+ * Append one synthesis record as a JSON line. Best-effort, never throws.
33
+ * @param {object} record fields to log (ts is added if absent)
34
+ * @param {string} [nowIso] ISO timestamp (injectable for tests)
35
+ * @returns {Promise<void>}
36
+ */
37
+ export async function logSynthesis(record, nowIso) {
38
+ const line = JSON.stringify({ ts: nowIso ?? new Date().toISOString(), ...record }) + '\n';
39
+ const file = synthLogPath();
40
+ try {
41
+ await mkdir(dirname(file), { recursive: true });
42
+ await appendFile(file, line, 'utf8');
43
+ } catch {
44
+ // Observability must never break synthesis — drop the line silently.
45
+ }
46
+ }