@gotcos/glasses-server 6.14.0 → 6.15.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.
- package/.env.example +11 -0
- package/CHANGELOG.md +39 -0
- package/README.md +29 -7
- package/bin/cli.cjs +17 -4
- package/package.json +3 -3
- package/server/index.ts +3 -0
- package/server/lib/meeting-batch-transcribe.ts +1 -1
- package/server/lib/tts-cache.ts +28 -2
- package/server/lib/tts-engine.ts +230 -0
- package/server/lib/tts-local.ts +375 -0
- package/server/lib/tts-pronounce.ts +53 -0
- package/server/lib/whisper-local.ts +129 -28
- package/server/routes/health.ts +8 -2
- package/server/routes/tts.ts +574 -196
- package/server/tts-sidecar/bootstrap.sh +33 -0
- package/server/tts-sidecar/requirements.txt +9 -0
- package/server/tts-sidecar/server.py +235 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// Local Kokoro TTS sidecar lifecycle — Whisper-shaped, port 8179.
|
|
2
|
+
// Local-first engine with an explicit OpenAI fallback path.
|
|
3
|
+
|
|
4
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
5
|
+
import { randomBytes } from 'node:crypto'
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { arch, homedir, platform } from 'node:os'
|
|
8
|
+
import { dirname, join, resolve } from 'node:path'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
11
|
+
import { dataPath } from './data-dir.js'
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
const SIDECAR_DIR = resolve(__dirname, '..', 'tts-sidecar')
|
|
15
|
+
const SIDECAR_SCRIPT = join(SIDECAR_DIR, 'server.py')
|
|
16
|
+
const BOOTSTRAP = join(SIDECAR_DIR, 'bootstrap.sh')
|
|
17
|
+
|
|
18
|
+
const TTS_PORT = Number(process.env.COS_TTS_LOCAL_PORT || 8179)
|
|
19
|
+
const TTS_HOST = '127.0.0.1'
|
|
20
|
+
const TTS_BASE =
|
|
21
|
+
process.env.COS_TTS_LOCAL_URL?.replace(/\/$/, '') || `http://${TTS_HOST}:${TTS_PORT}`
|
|
22
|
+
const TTS_PROTOCOL = 'cos-tts-v1'
|
|
23
|
+
// Boot-scoped bearer token: an unrelated/orphan process on 8179 cannot be
|
|
24
|
+
// mistaken for COS or receive private text after the owning server restarts.
|
|
25
|
+
const TTS_AUTH_TOKEN = randomBytes(32).toString('hex')
|
|
26
|
+
|
|
27
|
+
const MODEL_DIR = join(process.env.HOME ?? homedir(), '.local/share/cos-tts-models')
|
|
28
|
+
|
|
29
|
+
let serverProcess: ChildProcess | null = null
|
|
30
|
+
let serverAvailable = false
|
|
31
|
+
let serverStarting = false
|
|
32
|
+
let lastError: string | null = null
|
|
33
|
+
let engineVersion: string | null = null
|
|
34
|
+
let localVoice: string | null = null
|
|
35
|
+
let lastFallbackToOpenAI: { at: string; reason: string } | null = null
|
|
36
|
+
let lastHealthProbeAt = 0
|
|
37
|
+
let healthProbeInFlight: Promise<boolean> | null = null
|
|
38
|
+
|
|
39
|
+
const HEALTH_REFRESH_INTERVAL_MS = 2_000
|
|
40
|
+
const HEALTH_REFRESH_TIMEOUT_MS = 400
|
|
41
|
+
|
|
42
|
+
const FALLBACK_STATE_PATH = dataPath('tts-last-fallback.json')
|
|
43
|
+
|
|
44
|
+
function loadPersistedFallback(): void {
|
|
45
|
+
try {
|
|
46
|
+
if (!existsSync(FALLBACK_STATE_PATH)) return
|
|
47
|
+
const raw = JSON.parse(readFileSync(FALLBACK_STATE_PATH, 'utf8')) as {
|
|
48
|
+
at?: string
|
|
49
|
+
reason?: string
|
|
50
|
+
}
|
|
51
|
+
if (typeof raw.at === 'string' && typeof raw.reason === 'string') {
|
|
52
|
+
lastFallbackToOpenAI = { at: raw.at, reason: raw.reason.slice(0, 300) }
|
|
53
|
+
}
|
|
54
|
+
} catch { /* ignore corrupt state */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
loadPersistedFallback()
|
|
58
|
+
|
|
59
|
+
/** Record that Voice Mode escaped Kokoro → OpenAI (survives LaunchAgent restart). */
|
|
60
|
+
export function recordLocalTtsFallbackToOpenAI(reason: string): void {
|
|
61
|
+
lastFallbackToOpenAI = { at: new Date().toISOString(), reason: reason.slice(0, 300) }
|
|
62
|
+
console.warn('[tts-local] FALLBACK TO OPENAI:', reason)
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(dirname(FALLBACK_STATE_PATH), { recursive: true })
|
|
65
|
+
atomicWriteFileSync(FALLBACK_STATE_PATH, `${JSON.stringify(lastFallbackToOpenAI)}\n`)
|
|
66
|
+
} catch {
|
|
67
|
+
try {
|
|
68
|
+
writeFileSync(FALLBACK_STATE_PATH, `${JSON.stringify(lastFallbackToOpenAI)}\n`)
|
|
69
|
+
} catch { /* best-effort persist */ }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Bound hung sidecar so local_first can fall back before session TTL (~60s). */
|
|
74
|
+
export const LOCAL_TTS_SYNTH_TIMEOUT_MS = Number(
|
|
75
|
+
process.env.COS_TTS_LOCAL_TIMEOUT_MS || 12_000,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
function candidatePythons(): string[] {
|
|
79
|
+
const out: string[] = []
|
|
80
|
+
if (process.env.COS_TTS_PYTHON) out.push(process.env.COS_TTS_PYTHON)
|
|
81
|
+
// Pinned product venv only — bootstrap.sh creates it under MODEL_DIR.
|
|
82
|
+
// Do not hardcode repo/Phase-0 paths (breaks other machines + LaunchAgent hygiene).
|
|
83
|
+
out.push(join(MODEL_DIR, '.venv', 'bin', 'python'))
|
|
84
|
+
return out.filter((p) => p && existsSync(p))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolvePython(): string | null {
|
|
88
|
+
const hits = candidatePythons()
|
|
89
|
+
return hits[0] ?? null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function ensureBootstrap(): Promise<string | null> {
|
|
93
|
+
let py = resolvePython()
|
|
94
|
+
if (py) return py
|
|
95
|
+
if (!existsSync(BOOTSTRAP)) {
|
|
96
|
+
lastError = `TTS bootstrap missing at ${BOOTSTRAP}`
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
mkdirSync(MODEL_DIR, { recursive: true })
|
|
101
|
+
console.log('[tts-local] bootstrapping venv via', BOOTSTRAP)
|
|
102
|
+
await new Promise<void>((resolvePromise, rejectPromise) => {
|
|
103
|
+
const child = spawn('/bin/bash', [BOOTSTRAP], {
|
|
104
|
+
stdio: 'inherit',
|
|
105
|
+
detached: false,
|
|
106
|
+
env: process.env,
|
|
107
|
+
})
|
|
108
|
+
child.once('error', rejectPromise)
|
|
109
|
+
child.once('exit', (code, signal) => {
|
|
110
|
+
if (code === 0) {
|
|
111
|
+
resolvePromise()
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
rejectPromise(new Error(`bootstrap exited code=${code} signal=${signal}`))
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
} catch (err) {
|
|
118
|
+
lastError = `TTS bootstrap failed: ${err instanceof Error ? err.message : String(err)}`
|
|
119
|
+
console.error('[tts-local]', lastError)
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
py = resolvePython()
|
|
123
|
+
if (!py) lastError = 'TTS bootstrap finished but python still missing'
|
|
124
|
+
return py
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function probeHealth(timeoutMs = 1500): Promise<boolean> {
|
|
128
|
+
try {
|
|
129
|
+
const res = await fetch(`${TTS_BASE}/health`, {
|
|
130
|
+
headers: { Authorization: `Bearer ${TTS_AUTH_TOKEN}` },
|
|
131
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
132
|
+
})
|
|
133
|
+
if (!res.ok) {
|
|
134
|
+
serverAvailable = false
|
|
135
|
+
lastError = `local TTS health ${res.status}`
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
const body = (await res.json()) as {
|
|
139
|
+
ready?: boolean
|
|
140
|
+
protocol?: string
|
|
141
|
+
engine?: string
|
|
142
|
+
voice?: string
|
|
143
|
+
error?: string | null
|
|
144
|
+
}
|
|
145
|
+
if (body.ready && body.protocol === TTS_PROTOCOL) {
|
|
146
|
+
serverAvailable = true
|
|
147
|
+
engineVersion = body.engine ?? 'kokoro'
|
|
148
|
+
localVoice = body.voice ?? null
|
|
149
|
+
lastError = null
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
serverAvailable = false
|
|
153
|
+
lastError = body.protocol !== TTS_PROTOCOL
|
|
154
|
+
? 'local TTS protocol identity mismatch'
|
|
155
|
+
: body.error || 'local TTS reported not ready'
|
|
156
|
+
return false
|
|
157
|
+
} catch (err) {
|
|
158
|
+
serverAvailable = false
|
|
159
|
+
lastError = err instanceof Error ? err.message : String(err)
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Authenticated liveness refresh used by /api/health; never trusts stale state. */
|
|
165
|
+
export async function refreshLocalTtsHealth(): Promise<void> {
|
|
166
|
+
if (process.env.COS_TTS_LOCAL_DISABLE === '1') {
|
|
167
|
+
serverAvailable = false
|
|
168
|
+
lastError = 'disabled via COS_TTS_LOCAL_DISABLE=1'
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
if (platform() !== 'darwin' || arch() !== 'arm64') {
|
|
172
|
+
serverAvailable = false
|
|
173
|
+
lastError = 'Local Kokoro requires an Apple silicon Mac (darwin/arm64)'
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
const now = Date.now()
|
|
177
|
+
if (now - lastHealthProbeAt < HEALTH_REFRESH_INTERVAL_MS) return
|
|
178
|
+
if (!healthProbeInFlight) {
|
|
179
|
+
healthProbeInFlight = probeHealth(HEALTH_REFRESH_TIMEOUT_MS).finally(() => {
|
|
180
|
+
lastHealthProbeAt = Date.now()
|
|
181
|
+
healthProbeInFlight = null
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
await healthProbeInFlight
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function isLocalTtsReady(): boolean {
|
|
188
|
+
return serverAvailable
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function getLocalTtsHealth(): {
|
|
192
|
+
ready: boolean
|
|
193
|
+
engine: string | null
|
|
194
|
+
version: string | null
|
|
195
|
+
voice: string | null
|
|
196
|
+
ffmpeg: boolean
|
|
197
|
+
espeak: boolean
|
|
198
|
+
port: number
|
|
199
|
+
url: string
|
|
200
|
+
starting: boolean
|
|
201
|
+
error: string | null
|
|
202
|
+
lastFallbackToOpenAI: { at: string; reason: string } | null
|
|
203
|
+
} {
|
|
204
|
+
return {
|
|
205
|
+
ready: serverAvailable,
|
|
206
|
+
engine: engineVersion,
|
|
207
|
+
version: engineVersion,
|
|
208
|
+
voice: localVoice,
|
|
209
|
+
ffmpeg: existsSync('/opt/homebrew/bin/ffmpeg') || existsSync('/usr/local/bin/ffmpeg'),
|
|
210
|
+
espeak: existsSync('/opt/homebrew/bin/espeak-ng') || existsSync('/usr/bin/espeak-ng'),
|
|
211
|
+
port: TTS_PORT,
|
|
212
|
+
url: TTS_BASE,
|
|
213
|
+
starting: serverStarting,
|
|
214
|
+
error: lastError,
|
|
215
|
+
lastFallbackToOpenAI,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function startLocalTtsServer(): Promise<void> {
|
|
220
|
+
if (process.env.COS_TTS_LOCAL_DISABLE === '1') {
|
|
221
|
+
console.log('[tts-local] disabled via COS_TTS_LOCAL_DISABLE=1')
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
if (serverStarting) return
|
|
225
|
+
if (platform() !== 'darwin' || arch() !== 'arm64') {
|
|
226
|
+
lastError = 'Local Kokoro requires an Apple silicon Mac (darwin/arm64)'
|
|
227
|
+
console.warn('[tts-local]', lastError)
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
serverStarting = true
|
|
231
|
+
try {
|
|
232
|
+
if (await probeHealth(1500)) {
|
|
233
|
+
// A healthy listener authenticated with this boot token belongs to this
|
|
234
|
+
// server boot; orphan/foreign listeners cannot pass the probe.
|
|
235
|
+
console.log('[tts-local] already healthy on', TTS_PORT)
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!existsSync(SIDECAR_SCRIPT)) {
|
|
240
|
+
lastError = `sidecar missing: ${SIDECAR_SCRIPT}`
|
|
241
|
+
console.error('[tts-local]', lastError)
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const python = await ensureBootstrap()
|
|
246
|
+
if (!python) return
|
|
247
|
+
|
|
248
|
+
// Clear stale listener if we own a dead handle
|
|
249
|
+
if (serverProcess) {
|
|
250
|
+
try { serverProcess.kill('SIGTERM') } catch { /* ignore */ }
|
|
251
|
+
serverProcess = null
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
console.log(`[tts-local] starting Kokoro sidecar on ${TTS_PORT} via ${python}`)
|
|
255
|
+
const child = spawn(
|
|
256
|
+
python,
|
|
257
|
+
[SIDECAR_SCRIPT, '--host', TTS_HOST, '--port', String(TTS_PORT)],
|
|
258
|
+
{
|
|
259
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
260
|
+
detached: false,
|
|
261
|
+
env: {
|
|
262
|
+
...process.env,
|
|
263
|
+
COS_TTS_AUTH_TOKEN: TTS_AUTH_TOKEN,
|
|
264
|
+
ESPEAK_DATA_PATH: process.env.ESPEAK_DATA_PATH || '/opt/homebrew/share/espeak-ng-data',
|
|
265
|
+
PHONEMIZER_ESPEAK_PATH: process.env.PHONEMIZER_ESPEAK_PATH || '/opt/homebrew/bin/espeak-ng',
|
|
266
|
+
COS_TTS_PORT: String(TTS_PORT),
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
)
|
|
270
|
+
serverProcess = child
|
|
271
|
+
child.on('exit', (code, signal) => {
|
|
272
|
+
if (serverProcess === child) {
|
|
273
|
+
serverProcess = null
|
|
274
|
+
serverAvailable = false
|
|
275
|
+
lastError = `sidecar exited code=${code} signal=${signal}`
|
|
276
|
+
console.warn('[tts-local]', lastError)
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
const maxWaitMs = 120_000
|
|
281
|
+
const started = Date.now()
|
|
282
|
+
while (Date.now() - started < maxWaitMs) {
|
|
283
|
+
if (await probeHealth(1500)) {
|
|
284
|
+
console.log(
|
|
285
|
+
`[tts-local] ready on ${TTS_PORT} engine=${engineVersion} voice=${localVoice} ` +
|
|
286
|
+
`(${((Date.now() - started) / 1000).toFixed(1)}s)`,
|
|
287
|
+
)
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
await new Promise((r) => setTimeout(r, 1500))
|
|
291
|
+
}
|
|
292
|
+
lastError = `sidecar startup timeout (${maxWaitMs / 1000}s)`
|
|
293
|
+
console.error('[tts-local]', lastError)
|
|
294
|
+
try { child.kill('SIGKILL') } catch { /* ignore */ }
|
|
295
|
+
serverProcess = null
|
|
296
|
+
serverAvailable = false
|
|
297
|
+
} finally {
|
|
298
|
+
serverStarting = false
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function stopLocalTtsServer(): void {
|
|
303
|
+
if (!serverProcess) return
|
|
304
|
+
try {
|
|
305
|
+
serverProcess.kill('SIGTERM')
|
|
306
|
+
} catch { /* ignore */ }
|
|
307
|
+
serverProcess = null
|
|
308
|
+
serverAvailable = false
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Synthesize via local OpenAI-shaped speech endpoint. Returns full audio Buffer. */
|
|
312
|
+
export async function synthesizeLocalTts(opts: {
|
|
313
|
+
text: string
|
|
314
|
+
voice: string
|
|
315
|
+
format: string
|
|
316
|
+
signal?: AbortSignal
|
|
317
|
+
}): Promise<Buffer> {
|
|
318
|
+
const timeoutMs = Number.isFinite(LOCAL_TTS_SYNTH_TIMEOUT_MS) && LOCAL_TTS_SYNTH_TIMEOUT_MS > 0
|
|
319
|
+
? LOCAL_TTS_SYNTH_TIMEOUT_MS
|
|
320
|
+
: 12_000
|
|
321
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs)
|
|
322
|
+
const signal =
|
|
323
|
+
opts.signal && typeof AbortSignal.any === 'function'
|
|
324
|
+
? AbortSignal.any([opts.signal, timeoutSignal])
|
|
325
|
+
: opts.signal ?? timeoutSignal
|
|
326
|
+
|
|
327
|
+
let res: Response
|
|
328
|
+
try {
|
|
329
|
+
res = await fetch(`${TTS_BASE}/v1/audio/speech`, {
|
|
330
|
+
method: 'POST',
|
|
331
|
+
headers: {
|
|
332
|
+
Authorization: `Bearer ${TTS_AUTH_TOKEN}`,
|
|
333
|
+
'Content-Type': 'application/json',
|
|
334
|
+
},
|
|
335
|
+
body: JSON.stringify({
|
|
336
|
+
model: 'tts-1',
|
|
337
|
+
input: opts.text,
|
|
338
|
+
voice: opts.voice,
|
|
339
|
+
response_format: opts.format,
|
|
340
|
+
}),
|
|
341
|
+
signal,
|
|
342
|
+
})
|
|
343
|
+
} catch (err) {
|
|
344
|
+
const name = (err as { name?: string })?.name
|
|
345
|
+
// A caller abort means the user/request went away. Preserve that signal so
|
|
346
|
+
// the route returns 499 and never mistakes cancellation for a Kokoro
|
|
347
|
+
// failure eligible for OpenAI fallback.
|
|
348
|
+
if (opts.signal?.aborted) {
|
|
349
|
+
const abortError = new Error('local TTS request aborted')
|
|
350
|
+
abortError.name = 'AbortError'
|
|
351
|
+
throw abortError
|
|
352
|
+
}
|
|
353
|
+
if (timeoutSignal.aborted || name === 'TimeoutError') {
|
|
354
|
+
const timeoutError = new Error(`local TTS timed out after ${timeoutMs}ms`)
|
|
355
|
+
timeoutError.name = 'TimeoutError'
|
|
356
|
+
throw timeoutError
|
|
357
|
+
}
|
|
358
|
+
// Fail closed for an AbortError whose source is unknown. Treating it as a
|
|
359
|
+
// synthesis failure could spend cloud budget after a canceled request.
|
|
360
|
+
if (name === 'AbortError') {
|
|
361
|
+
throw err
|
|
362
|
+
}
|
|
363
|
+
serverAvailable = false
|
|
364
|
+
lastError = err instanceof Error ? err.message : String(err)
|
|
365
|
+
throw err
|
|
366
|
+
}
|
|
367
|
+
if (!res.ok) {
|
|
368
|
+
const errText = await res.text().catch(() => '')
|
|
369
|
+
serverAvailable = false
|
|
370
|
+
lastError = `local TTS ${res.status}: ${errText.slice(0, 160)}`
|
|
371
|
+
throw new Error(`local TTS ${res.status}: ${errText.slice(0, 300)}`)
|
|
372
|
+
}
|
|
373
|
+
const ab = await res.arrayBuffer()
|
|
374
|
+
return Buffer.from(ab)
|
|
375
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Optional, public-safe pronunciation lexicon for local Kokoro and OpenAI TTS.
|
|
2
|
+
// No personal names ship in the npm package. Operators may opt in with:
|
|
3
|
+
// COS_TTS_PRONUNCIATIONS_JSON='{"Exampleco":{"local":"[Exampleco](/ɪgzˈæmpəlkoʊ/)","openai":"ig-ZAM-pul-co"}}'
|
|
4
|
+
|
|
5
|
+
interface PronunciationEntry {
|
|
6
|
+
local?: string
|
|
7
|
+
openai?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function escapeRegExp(value: string): string {
|
|
11
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readLexicon(): Array<{ term: string; local?: string; openai?: string }> {
|
|
15
|
+
const raw = (process.env.COS_TTS_PRONUNCIATIONS_JSON || '').trim()
|
|
16
|
+
if (!raw) return []
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(raw) as Record<string, PronunciationEntry>
|
|
19
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return []
|
|
20
|
+
return Object.entries(parsed)
|
|
21
|
+
.slice(0, 64)
|
|
22
|
+
.flatMap(([term, entry]) => {
|
|
23
|
+
const cleanTerm = term.trim()
|
|
24
|
+
if (!cleanTerm || cleanTerm.length > 64 || !entry || typeof entry !== 'object') return []
|
|
25
|
+
const local = typeof entry.local === 'string' && entry.local.length <= 160 ? entry.local : undefined
|
|
26
|
+
const openai = typeof entry.openai === 'string' && entry.openai.length <= 160 ? entry.openai : undefined
|
|
27
|
+
return local || openai ? [{ term: cleanTerm, local, openai }] : []
|
|
28
|
+
})
|
|
29
|
+
} catch {
|
|
30
|
+
console.warn('[tts-pronounce] invalid COS_TTS_PRONUNCIATIONS_JSON; ignoring')
|
|
31
|
+
return []
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function applyLocalPronunciation(text: string): string {
|
|
36
|
+
let out = text
|
|
37
|
+
for (const entry of readLexicon()) {
|
|
38
|
+
if (!entry.local) continue
|
|
39
|
+
const pattern = new RegExp(`(?<!\\[)\\b${escapeRegExp(entry.term)}\\b`, 'gi')
|
|
40
|
+
out = out.replace(pattern, entry.local)
|
|
41
|
+
}
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function applyOpenAIPronunciation(text: string): string {
|
|
46
|
+
let out = text
|
|
47
|
+
for (const entry of readLexicon()) {
|
|
48
|
+
if (!entry.openai) continue
|
|
49
|
+
const pattern = new RegExp(`\\b${escapeRegExp(entry.term)}\\b`, 'gi')
|
|
50
|
+
out = out.replace(pattern, entry.openai)
|
|
51
|
+
}
|
|
52
|
+
return out
|
|
53
|
+
}
|