@yujinapp/yuemail 0.4.1 → 0.6.1

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,125 @@
1
+ /**
2
+ * /api/voice/* routes (v0.6.0 -- camino 1 for hearing + speaking).
3
+ *
4
+ * GET /api/voice/config -> { config, has_key }
5
+ * PUT /api/voice/config -> body partial -> { config, has_key }
6
+ * POST /api/voice/stt -> raw audio bytes (header X-Audio-Format) ->
7
+ * { ok:true, transcript } | { ok:false, reason }
8
+ * POST /api/voice/tts -> body { text, ... } -> binary audio
9
+ * | { ok:false, reason } when camino 1 unavailable
10
+ *
11
+ * A { ok:false, reason } body (HTTP 200) is NOT an error: it tells the
12
+ * client to fall back to the browser's Web Speech API. has_key is a boolean
13
+ * only -- the decrypted Google key never leaves the server.
14
+ *
15
+ * ASCII-only.
16
+ */
17
+ import express from 'express';
18
+ import { readVoiceConfig, patchVoiceConfig, } from '../voice/config.js';
19
+ import { transcribe, synthesize } from '../voice/router.js';
20
+ import { googleVoiceReady, SPEECH_VAULT_SLOT } from '../voice/google.js';
21
+ import { getKey } from '../vault.js';
22
+ const STT_MAX_BYTES = 25 * 1024 * 1024;
23
+ const SUPPORTED_STT = new Set(['webm', 'ogg', 'wav', 'mp3', 'flac']);
24
+ const TTS_MIME = {
25
+ mp3: 'audio/mpeg',
26
+ wav: 'audio/wav',
27
+ ogg: 'audio/ogg',
28
+ };
29
+ async function hasSpeechKey() {
30
+ try {
31
+ const k = await getKey(SPEECH_VAULT_SLOT);
32
+ return typeof k === 'string' && k.length > 0;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ function publicConfig(cfg) {
39
+ return {
40
+ enabled: cfg.enabled,
41
+ language: cfg.language,
42
+ voice: cfg.voice,
43
+ speed: cfg.speed,
44
+ format: cfg.format,
45
+ };
46
+ }
47
+ export function registerVoiceRoutes(app) {
48
+ app.get('/api/voice/config', async (_req, res) => {
49
+ const cfg = await readVoiceConfig();
50
+ res.json({ ok: true, config: publicConfig(cfg), has_key: await hasSpeechKey() });
51
+ });
52
+ app.put('/api/voice/config', async (req, res) => {
53
+ const body = (req.body ?? {});
54
+ const patch = {};
55
+ if (typeof body['enabled'] === 'boolean')
56
+ patch.enabled = body['enabled'];
57
+ if (typeof body['language'] === 'string')
58
+ patch.language = body['language'];
59
+ if (typeof body['voice'] === 'string')
60
+ patch.voice = body['voice'];
61
+ if (typeof body['speed'] === 'number')
62
+ patch.speed = body['speed'];
63
+ if (body['format'] === 'mp3' || body['format'] === 'wav' || body['format'] === 'ogg') {
64
+ patch.format = body['format'];
65
+ }
66
+ const cfg = await patchVoiceConfig(patch);
67
+ res.json({ ok: true, config: publicConfig(cfg), has_key: await hasSpeechKey() });
68
+ });
69
+ /* Hearing. Raw audio in the body; the container is declared in the
70
+ * X-Audio-Format header (the browser sends 'webm'; the test harness
71
+ * sends the format it synthesised). */
72
+ app.post('/api/voice/stt', express.raw({ type: '*/*', limit: STT_MAX_BYTES }), async (req, res) => {
73
+ const fmt = String(req.header('x-audio-format') ?? '').toLowerCase().trim();
74
+ if (!SUPPORTED_STT.has(fmt)) {
75
+ res.status(400).json({ ok: false, reason: 'bad_format', detail: fmt || '(none)' });
76
+ return;
77
+ }
78
+ const audio = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
79
+ if (audio.length === 0) {
80
+ res.status(400).json({ ok: false, reason: 'empty_audio' });
81
+ return;
82
+ }
83
+ const langHeader = String(req.header('x-audio-language') ?? '').trim();
84
+ const outcome = await transcribe({
85
+ audio,
86
+ format: fmt,
87
+ languageHint: langHeader || undefined,
88
+ });
89
+ if (!outcome.ok) {
90
+ res.json({ ok: false, reason: outcome.reason, detail: outcome.detail });
91
+ return;
92
+ }
93
+ res.json({ ok: true, transcript: outcome.result });
94
+ });
95
+ /* Speaking. JSON in, binary audio out. A miss returns JSON so the client
96
+ * knows to use the browser voice instead. */
97
+ app.post('/api/voice/tts', async (req, res) => {
98
+ const body = (req.body ?? {});
99
+ const text = typeof body['text'] === 'string' ? body['text'] : '';
100
+ if (text.trim().length === 0) {
101
+ res.status(400).json({ ok: false, reason: 'empty_text' });
102
+ return;
103
+ }
104
+ const outcome = await synthesize({
105
+ text,
106
+ language: typeof body['language'] === 'string' ? body['language'] : undefined,
107
+ voice: typeof body['voice'] === 'string' ? body['voice'] : undefined,
108
+ speed: typeof body['speed'] === 'number' ? body['speed'] : undefined,
109
+ format: body['format'] === 'mp3' || body['format'] === 'wav' || body['format'] === 'ogg'
110
+ ? body['format'] : undefined,
111
+ });
112
+ if (!outcome.ok) {
113
+ res.json({ ok: false, reason: outcome.reason, detail: outcome.detail });
114
+ return;
115
+ }
116
+ res.setHeader('content-type', TTS_MIME[outcome.result.format]);
117
+ res.setHeader('x-voice-provider', outcome.result.provider);
118
+ res.setHeader('x-voice-voice', outcome.result.voice);
119
+ res.setHeader('x-voice-latency-ms', String(outcome.result.latency_ms));
120
+ res.send(outcome.result.audio);
121
+ });
122
+ }
123
+ /** Tiny helper so unit tests can read whether camino 1 is reachable without
124
+ * a real round-trip. */
125
+ export { googleVoiceReady };
@@ -65,6 +65,23 @@ export const VAULT_KEYS = [
65
65
  'smtp.secure',
66
66
  'identity.from',
67
67
  'identity.name',
68
+ /* Brain provider API keys (v0.5.0). One slot per provider; the router
69
+ * reads them server-side and they never reach the browser, exactly like
70
+ * the mail credentials above. Ollama is local + keyless but kept in the
71
+ * allowlist for symmetry. */
72
+ 'brain.google_ai',
73
+ 'brain.anthropic',
74
+ 'brain.openai',
75
+ 'brain.deepseek',
76
+ 'brain.xai',
77
+ 'brain.mistral',
78
+ 'brain.qwen',
79
+ 'brain.zai',
80
+ 'brain.ollama',
81
+ /* Voice (v0.6.0). One Google Cloud key powers both Speech-to-Text
82
+ * (hearing) and Text-to-Speech (speaking); the voice router reads it
83
+ * server-side and it never reaches the browser, like every slot above. */
84
+ 'speech.google',
68
85
  ];
69
86
  export function isValidVaultKey(name) {
70
87
  return VAULT_KEYS.includes(name);
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Voice config -- how Yuemail hears and speaks (v0.6.0). Mirrors the Brain
3
+ * config shape (server-side, single-user, honours YUEMAIL_HOME).
4
+ *
5
+ * Storage:
6
+ * ~/.yuemail/voice.json
7
+ *
8
+ * Camino 1 (default): Google Cloud for both hearing (STT) and speaking
9
+ * (TTS) -- the more accurate ear and the more natural voice for an audience
10
+ * that depends on them. The browser's Web Speech API stays as camino 2, the
11
+ * safety net the CLIENT falls back to when 'enabled' is false, no key is
12
+ * stored, or a Google round-trip fails. A person who depends on this app is
13
+ * never left without it.
14
+ *
15
+ * ASCII-only.
16
+ */
17
+ import { promises as fs, existsSync, mkdirSync } from 'node:fs';
18
+ import path from 'node:path';
19
+ import os from 'node:os';
20
+ export function defaultVoiceConfig() {
21
+ return {
22
+ version: 1,
23
+ enabled: true,
24
+ language: 'es-AR',
25
+ voice: '',
26
+ speed: 1.0,
27
+ format: 'mp3',
28
+ };
29
+ }
30
+ function homeDir() {
31
+ const env = process.env['YUEMAIL_HOME'];
32
+ return env ? path.resolve(env) : path.join(os.homedir(), '.yuemail');
33
+ }
34
+ function voiceConfigPath() { return path.join(homeDir(), 'voice.json'); }
35
+ function ensureHomeDir() {
36
+ if (!existsSync(homeDir()))
37
+ mkdirSync(homeDir(), { recursive: true, mode: 0o700 });
38
+ }
39
+ function clampSpeed(v, fallback) {
40
+ if (typeof v !== 'number' || Number.isNaN(v))
41
+ return fallback;
42
+ if (v < 0.25)
43
+ return 0.25;
44
+ if (v > 4.0)
45
+ return 4.0;
46
+ return v;
47
+ }
48
+ function normaliseFormat(v, fallback) {
49
+ return v === 'mp3' || v === 'wav' || v === 'ogg' ? v : fallback;
50
+ }
51
+ export async function readVoiceConfig() {
52
+ const def = defaultVoiceConfig();
53
+ try {
54
+ const raw = await fs.readFile(voiceConfigPath(), 'utf-8');
55
+ const j = JSON.parse(raw);
56
+ return {
57
+ version: 1,
58
+ enabled: j.enabled !== false,
59
+ language: typeof j.language === 'string' && j.language.trim() !== '' ? j.language.trim() : def.language,
60
+ voice: typeof j.voice === 'string' ? j.voice.trim() : def.voice,
61
+ speed: clampSpeed(j.speed, def.speed),
62
+ format: normaliseFormat(j.format, def.format),
63
+ };
64
+ }
65
+ catch {
66
+ return def;
67
+ }
68
+ }
69
+ export async function writeVoiceConfig(cfg) {
70
+ ensureHomeDir();
71
+ const p = voiceConfigPath();
72
+ const tmp = p + '.tmp';
73
+ await fs.writeFile(tmp, JSON.stringify(cfg, null, 2), { mode: 0o600 });
74
+ await fs.rename(tmp, p);
75
+ }
76
+ export async function patchVoiceConfig(patch) {
77
+ const cur = await readVoiceConfig();
78
+ const next = {
79
+ version: 1,
80
+ enabled: typeof patch.enabled === 'boolean' ? patch.enabled : cur.enabled,
81
+ language: typeof patch.language === 'string' && patch.language.trim() !== '' ? patch.language.trim() : cur.language,
82
+ voice: typeof patch.voice === 'string' ? patch.voice.trim() : cur.voice,
83
+ speed: patch.speed === undefined ? cur.speed : clampSpeed(patch.speed, cur.speed),
84
+ format: patch.format === undefined ? cur.format : normaliseFormat(patch.format, cur.format),
85
+ };
86
+ await writeVoiceConfig(next);
87
+ return next;
88
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Google Cloud voice provider -- Speech-to-Text (hearing) + Text-to-Speech
3
+ * (speaking). Raw fetch, no SDK (the Forge lesson: SDKs blow the bundle
4
+ * budget). The API key travels in the 'x-goog-api-key' header, never a
5
+ * query string, and is never logged.
6
+ *
7
+ * Ported from c:/yujin-forge/src/voice/providers/google.ts and adapted to
8
+ * read the key from Yuemail's vault (slot 'speech.google') and to use the
9
+ * camino-1 raw-fetch style of server/brain/providers.ts.
10
+ *
11
+ * ASCII-only.
12
+ */
13
+ import { getKey } from '../vault.js';
14
+ /** The single vault slot the Google voice key lives under. The same key
15
+ * powers both Cloud Speech-to-Text and Cloud Text-to-Speech. */
16
+ export const SPEECH_VAULT_SLOT = 'speech.google';
17
+ const STT_ENDPOINT = 'https://speech.googleapis.com/v1/speech:recognize';
18
+ const TTS_ENDPOINT = 'https://texttospeech.googleapis.com/v1/text:synthesize';
19
+ /** Container -> Google STT encoding enum. */
20
+ function sttEncoding(format) {
21
+ switch (format) {
22
+ case 'webm': return 'WEBM_OPUS';
23
+ case 'ogg': return 'OGG_OPUS';
24
+ case 'wav': return 'LINEAR16';
25
+ case 'mp3': return 'MP3';
26
+ case 'flac': return 'FLAC';
27
+ }
28
+ }
29
+ /** Container -> Google TTS audioEncoding enum. */
30
+ function ttsEncoding(format) {
31
+ switch (format) {
32
+ case 'mp3': return 'MP3';
33
+ case 'wav': return 'LINEAR16';
34
+ case 'ogg': return 'OGG_OPUS';
35
+ }
36
+ }
37
+ /**
38
+ * Google's enhanced Spanish STT models are certified for es-US / es-ES /
39
+ * es-419 only. Map the regional variants Yuemail uses (es-AR is the client
40
+ * default) onto es-US so the request always lands on an enhanced model that
41
+ * handles accent + aspirated-s. Non-Spanish locales pass through unchanged.
42
+ */
43
+ function normaliseSpanishLocale(bcp47) {
44
+ const lc = (bcp47 ?? 'es-US').trim();
45
+ if (/^es(-|$)/i.test(lc))
46
+ return 'es-US';
47
+ return lc;
48
+ }
49
+ /* Locale -> a concrete Neural2 voice. Strip the region as a fallback so an
50
+ * unmapped es-XX still gets the Spanish voice. */
51
+ const LOCALE_TO_VOICE = {
52
+ es: 'es-US-Neural2-A',
53
+ 'es-us': 'es-US-Neural2-A',
54
+ 'es-ar': 'es-US-Neural2-A',
55
+ 'es-mx': 'es-US-Neural2-A',
56
+ 'es-es': 'es-ES-Neural2-A',
57
+ en: 'en-US-Neural2-C',
58
+ 'en-us': 'en-US-Neural2-C',
59
+ 'en-gb': 'en-GB-Neural2-A',
60
+ 'pt-br': 'pt-BR-Neural2-A',
61
+ 'fr-fr': 'fr-FR-Neural2-A',
62
+ 'it-it': 'it-IT-Neural2-A',
63
+ 'de-de': 'de-DE-Neural2-B',
64
+ };
65
+ function voiceForLocale(bcp47) {
66
+ const lc = (bcp47 ?? 'es-US').toLowerCase().trim();
67
+ if (LOCALE_TO_VOICE[lc])
68
+ return LOCALE_TO_VOICE[lc];
69
+ const lang = lc.split('-')[0];
70
+ if (lang && LOCALE_TO_VOICE[lang])
71
+ return LOCALE_TO_VOICE[lang];
72
+ return 'es-US-Neural2-A';
73
+ }
74
+ /** A Google voice name embeds its own languageCode (the first two segments,
75
+ * e.g. 'es-US-Neural2-A' -> 'es-US'). Google rejects a synth request when
76
+ * the voice's language disagrees with the supplied languageCode, so the
77
+ * voice's own language always wins. */
78
+ function languageFromVoice(voice) {
79
+ const parts = voice.split('-');
80
+ return parts.length >= 2 ? parts[0] + '-' + parts[1] : 'es-US';
81
+ }
82
+ function clampSpeed(v) {
83
+ if (typeof v !== 'number' || Number.isNaN(v))
84
+ return 1.0;
85
+ if (v < 0.25)
86
+ return 0.25;
87
+ if (v > 4.0)
88
+ return 4.0;
89
+ return v;
90
+ }
91
+ async function readSpeechKey(apiKeyOverride) {
92
+ if (apiKeyOverride !== undefined)
93
+ return apiKeyOverride;
94
+ try {
95
+ return (await getKey(SPEECH_VAULT_SLOT)) ?? null;
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ async function withTimeout(timeoutMs, run) {
102
+ const controller = new AbortController();
103
+ const t = setTimeout(() => controller.abort(), timeoutMs);
104
+ try {
105
+ return await run(controller.signal);
106
+ }
107
+ finally {
108
+ clearTimeout(t);
109
+ }
110
+ }
111
+ /** True when the Google voice key is present, so the router can decide
112
+ * whether camino 1 is even reachable before attempting a round-trip. */
113
+ export async function googleVoiceReady(apiKeyOverride) {
114
+ const key = await readSpeechKey(apiKeyOverride);
115
+ return typeof key === 'string' && key.length > 0;
116
+ }
117
+ /**
118
+ * Transcribe audio with Google Cloud Speech-to-Text. Throws on missing key,
119
+ * transport error, non-2xx, or empty result so the caller can fall back.
120
+ */
121
+ export async function googleTranscribe(req, opts = {}) {
122
+ const fetchImpl = opts.fetchImpl ?? fetch;
123
+ const key = await readSpeechKey(opts.apiKeyOverride);
124
+ if (!key)
125
+ throw new Error('no_key');
126
+ const languageCode = normaliseSpanishLocale(req.languageHint);
127
+ const isSpanish = /^es-/i.test(languageCode);
128
+ const body = {
129
+ config: {
130
+ encoding: sttEncoding(req.format),
131
+ languageCode,
132
+ enableAutomaticPunctuation: true,
133
+ /* Spanish gets the long enhanced model: better with accent + the
134
+ * slower, less articulated speech this audience often produces. */
135
+ ...(isSpanish ? { model: 'latest_long', useEnhanced: true } : {}),
136
+ },
137
+ audio: { content: req.audio.toString('base64') },
138
+ };
139
+ const started = Date.now();
140
+ const j = await withTimeout(opts.timeoutMs ?? 15000, async (signal) => {
141
+ const r = await fetchImpl(STT_ENDPOINT, {
142
+ method: 'POST',
143
+ headers: { 'content-type': 'application/json', 'x-goog-api-key': key },
144
+ body: JSON.stringify(body),
145
+ signal,
146
+ });
147
+ if (!r.ok) {
148
+ let detail = '';
149
+ try {
150
+ detail = (await r.text()).slice(0, 160).replace(/\s+/g, ' ');
151
+ }
152
+ catch { /* ignore */ }
153
+ throw new Error('stt http ' + r.status + (detail ? ' ' + detail : ''));
154
+ }
155
+ return await r.json();
156
+ });
157
+ const top = j.results?.[0]?.alternatives?.[0];
158
+ const text = (top?.transcript ?? '').trim();
159
+ if (!text)
160
+ throw new Error('empty_transcript');
161
+ const result = {
162
+ text,
163
+ latency_ms: Date.now() - started,
164
+ provider: 'google',
165
+ model: isSpanish ? 'latest_long' : undefined,
166
+ };
167
+ if (typeof top?.confidence === 'number')
168
+ result.confidence = top.confidence;
169
+ if (j.results?.[0]?.languageCode)
170
+ result.language = j.results[0].languageCode;
171
+ return result;
172
+ }
173
+ /**
174
+ * Synthesise speech with Google Cloud Text-to-Speech. Throws on missing key,
175
+ * transport error, non-2xx, or empty audio so the caller can fall back to
176
+ * the browser's speechSynthesis.
177
+ */
178
+ export async function googleSynthesize(req, opts = {}) {
179
+ const fetchImpl = opts.fetchImpl ?? fetch;
180
+ const key = await readSpeechKey(opts.apiKeyOverride);
181
+ if (!key)
182
+ throw new Error('no_key');
183
+ const text = (req.text ?? '').trim();
184
+ if (!text)
185
+ throw new Error('empty_text');
186
+ const voice = req.voice && req.voice.trim().length > 0 ? req.voice.trim() : voiceForLocale(req.language);
187
+ const languageCode = languageFromVoice(voice);
188
+ const format = req.format ?? 'mp3';
189
+ const body = {
190
+ input: { text },
191
+ voice: { languageCode, name: voice },
192
+ audioConfig: { audioEncoding: ttsEncoding(format), speakingRate: clampSpeed(req.speed) },
193
+ };
194
+ const started = Date.now();
195
+ const j = await withTimeout(opts.timeoutMs ?? 15000, async (signal) => {
196
+ const r = await fetchImpl(TTS_ENDPOINT, {
197
+ method: 'POST',
198
+ headers: { 'content-type': 'application/json', 'x-goog-api-key': key },
199
+ body: JSON.stringify(body),
200
+ signal,
201
+ });
202
+ if (!r.ok) {
203
+ let detail = '';
204
+ try {
205
+ detail = (await r.text()).slice(0, 160).replace(/\s+/g, ' ');
206
+ }
207
+ catch { /* ignore */ }
208
+ throw new Error('tts http ' + r.status + (detail ? ' ' + detail : ''));
209
+ }
210
+ return await r.json();
211
+ });
212
+ const b64 = j.audioContent ?? '';
213
+ if (!b64)
214
+ throw new Error('empty_audio');
215
+ return {
216
+ audio: Buffer.from(b64, 'base64'),
217
+ format,
218
+ voice,
219
+ latency_ms: Date.now() - started,
220
+ provider: 'google',
221
+ };
222
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Voice router -- the single server-side surface the HTTP routes call to
3
+ * hear and speak (v0.6.0). It reads the voice config, and when camino 1 is
4
+ * enabled and reachable, delegates to the Google provider. On a clean miss
5
+ * (disabled / no key) or a hard failure (transport, timeout, empty result)
6
+ * it returns a structured miss so the CLIENT falls back to the browser.
7
+ *
8
+ * The router never throws for an expected miss: the caller turns a miss into
9
+ * a 'use the browser' signal, never a 500.
10
+ *
11
+ * ASCII-only.
12
+ */
13
+ import { readVoiceConfig } from './config.js';
14
+ import { googleTranscribe, googleSynthesize, googleVoiceReady } from './google.js';
15
+ export async function transcribe(req, opts = {}) {
16
+ const cfg = await readVoiceConfig();
17
+ if (!cfg.enabled)
18
+ return { ok: false, reason: 'disabled' };
19
+ if (!(await googleVoiceReady(opts.apiKeyOverride)))
20
+ return { ok: false, reason: 'no_key' };
21
+ try {
22
+ const result = await googleTranscribe({ ...req, languageHint: req.languageHint ?? cfg.language }, { fetchImpl: opts.fetchImpl, apiKeyOverride: opts.apiKeyOverride });
23
+ return { ok: true, result };
24
+ }
25
+ catch (err) {
26
+ const detail = err instanceof Error ? err.message.slice(0, 160) : String(err);
27
+ if (detail === 'no_key')
28
+ return { ok: false, reason: 'no_key' };
29
+ return { ok: false, reason: 'error', detail };
30
+ }
31
+ }
32
+ export async function synthesize(req, opts = {}) {
33
+ const cfg = await readVoiceConfig();
34
+ if (!cfg.enabled)
35
+ return { ok: false, reason: 'disabled' };
36
+ if (!(await googleVoiceReady(opts.apiKeyOverride)))
37
+ return { ok: false, reason: 'no_key' };
38
+ try {
39
+ const result = await googleSynthesize({
40
+ text: req.text,
41
+ language: req.language ?? cfg.language,
42
+ voice: req.voice ?? (cfg.voice || undefined),
43
+ speed: req.speed ?? cfg.speed,
44
+ format: req.format ?? cfg.format,
45
+ }, { fetchImpl: opts.fetchImpl, apiKeyOverride: opts.apiKeyOverride });
46
+ return { ok: true, result };
47
+ }
48
+ catch (err) {
49
+ const detail = err instanceof Error ? err.message.slice(0, 160) : String(err);
50
+ if (detail === 'no_key')
51
+ return { ok: false, reason: 'no_key' };
52
+ return { ok: false, reason: 'error', detail };
53
+ }
54
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Voice subsystem types (v0.6.0 -- camino 1 for hearing + speaking).
3
+ *
4
+ * Ported from the Yujin-Forge voice subsystem (src/voice) and trimmed to
5
+ * Yuemail's single-user, server-side, BYOK shape. Google Cloud is camino 1
6
+ * for both directions; the browser (Web Speech API) stays as camino 2, the
7
+ * safety net resolved on the client when the server path is unavailable.
8
+ *
9
+ * STT (hearing): browser-captured audio bytes -> Google Speech-to-Text.
10
+ * TTS (speaking): Yuemail text -> Google Text-to-Speech audio bytes.
11
+ *
12
+ * The Google API key lives in the SAME encrypted vault as the mail
13
+ * credentials (slot 'speech.google'); it never reaches the browser.
14
+ *
15
+ * ASCII-only.
16
+ */
17
+ export {};