@goodandready/dsh-tts 0.3.22 → 0.3.23
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/lib/client.js +36 -7
- package/lib/index.js +30 -13
- package/lib/providers.js +80 -11
- package/lib/text.js +30 -3
- package/package.json +2 -2
- package/lib/client/worklet.js +0 -71
- package/lib/router.js +0 -4
package/lib/client.js
CHANGED
|
@@ -154,24 +154,47 @@ window.__ModuleLoader__.load({
|
|
|
154
154
|
})
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
function prepareAudioItem(item) {
|
|
158
|
+
if (!item || !item.audioBase64 || item.prepared) return item
|
|
159
|
+
try {
|
|
160
|
+
const bin = atob(item.audioBase64)
|
|
161
|
+
const bytes = new Uint8Array(bin.length)
|
|
162
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
|
163
|
+
const blob = new Blob([bytes], { type: item.mime || 'audio/mpeg' })
|
|
164
|
+
const url = URL.createObjectURL(blob)
|
|
165
|
+
const audio = new Audio(url)
|
|
166
|
+
audio.preload = 'auto'
|
|
167
|
+
item.prepared = { audio, url }
|
|
168
|
+
} catch (err) { /* decode error */ }
|
|
169
|
+
return item
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function prebufferNext() {
|
|
173
|
+
for (const next of player.queue) {
|
|
174
|
+
if (next && next.audioBase64 && !next.prepared) {
|
|
175
|
+
prepareAudioItem(next)
|
|
176
|
+
break
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
157
181
|
function playAudio(item) {
|
|
158
182
|
return new Promise((resolve) => {
|
|
159
183
|
try {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const blob = new Blob([bytes], { type: item.mime || 'audio/mpeg' })
|
|
164
|
-
const url = URL.createObjectURL(blob)
|
|
165
|
-
const audio = new Audio(url)
|
|
184
|
+
prepareAudioItem(item)
|
|
185
|
+
if (!item.prepared) return resolve()
|
|
186
|
+
const { audio, url } = item.prepared
|
|
166
187
|
audio.playbackRate = Math.max(0.5, Math.min(2, player.rate || 1))
|
|
167
188
|
player.audio = audio
|
|
168
189
|
playerChanged()
|
|
190
|
+
prebufferNext()
|
|
169
191
|
let doneCalled = false
|
|
170
192
|
const done = () => {
|
|
171
193
|
if (doneCalled) return
|
|
172
194
|
doneCalled = true
|
|
173
195
|
currentAudioResolve = null
|
|
174
|
-
URL.revokeObjectURL(url)
|
|
196
|
+
try { URL.revokeObjectURL(url) } catch (_) {}
|
|
197
|
+
item.prepared = null
|
|
175
198
|
player.audio = null
|
|
176
199
|
playerChanged()
|
|
177
200
|
resolve()
|
|
@@ -211,6 +234,12 @@ window.__ModuleLoader__.load({
|
|
|
211
234
|
}
|
|
212
235
|
|
|
213
236
|
function stopPlayback() {
|
|
237
|
+
for (const item of player.queue) {
|
|
238
|
+
if (item && item.prepared && item.prepared.url) {
|
|
239
|
+
try { URL.revokeObjectURL(item.prepared.url) } catch (_) {}
|
|
240
|
+
item.prepared = null
|
|
241
|
+
}
|
|
242
|
+
}
|
|
214
243
|
player.queue.length = 0
|
|
215
244
|
player.paused = false
|
|
216
245
|
if (currentAudioResolve) {
|
package/lib/index.js
CHANGED
|
@@ -309,6 +309,9 @@ export function apply(ctx, config) {
|
|
|
309
309
|
// Счётчики синтеза: в памяти, обнуляются рестартом и DELETE /stats.
|
|
310
310
|
const stats = { total: 0, cacheHits: 0, errors: 0, providers: {} }
|
|
311
311
|
|
|
312
|
+
// Дедупликация параллельных запросов на синтез (защита от Cache Stampede)
|
|
313
|
+
const inFlightSyntheses = new Map()
|
|
314
|
+
|
|
312
315
|
// Кэш синтеза живёт рядом с прочими данными харнесса.
|
|
313
316
|
const speechCache = createSpeechCache({
|
|
314
317
|
root: process.env.DSH_HOME || path.join(os.homedir(), '.dsh'),
|
|
@@ -403,20 +406,34 @@ export function apply(ctx, config) {
|
|
|
403
406
|
}
|
|
404
407
|
}
|
|
405
408
|
}
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
)
|
|
410
|
-
const out = await runChain(order, providers)
|
|
411
|
-
stats.total++
|
|
412
|
-
const ps = stats.providers[out.provider] || (stats.providers[out.provider] = { n: 0, e: 0, ms: 0 })
|
|
413
|
-
ps.n++
|
|
414
|
-
ps.ms += out.tookMs || 0
|
|
415
|
-
if (cacheAllowed) {
|
|
416
|
-
const p = out.provider
|
|
417
|
-
speechCache.put(cacheKey([text, p, models[p], voices[p], role, ov.ssmlStyle]), out.mime, out.audio).catch(() => {})
|
|
409
|
+
const flightKey = cacheKey([text, order.join(','), JSON.stringify(models), JSON.stringify(voices), role, ov.ssmlStyle])
|
|
410
|
+
if (inFlightSyntheses.has(flightKey)) {
|
|
411
|
+
return inFlightSyntheses.get(flightKey)
|
|
418
412
|
}
|
|
419
|
-
|
|
413
|
+
|
|
414
|
+
const synthPromise = (async () => {
|
|
415
|
+
try {
|
|
416
|
+
const providers = makeProviders(
|
|
417
|
+
{ resolveKey, fetchImpl: fetch, cfg, modelManager },
|
|
418
|
+
{ text, lang: cfg.autoDetect ? detectLang(text) : cfg.language, signal, models, voices, role },
|
|
419
|
+
)
|
|
420
|
+
const out = await runChain(order, providers)
|
|
421
|
+
stats.total++
|
|
422
|
+
const ps = stats.providers[out.provider] || (stats.providers[out.provider] = { n: 0, e: 0, ms: 0 })
|
|
423
|
+
ps.n++
|
|
424
|
+
ps.ms += out.tookMs || 0
|
|
425
|
+
if (cacheAllowed) {
|
|
426
|
+
const p = out.provider
|
|
427
|
+
speechCache.put(cacheKey([text, p, models[p], voices[p], role, ov.ssmlStyle]), out.mime, out.audio).catch(() => {})
|
|
428
|
+
}
|
|
429
|
+
return out
|
|
430
|
+
} finally {
|
|
431
|
+
inFlightSyntheses.delete(flightKey)
|
|
432
|
+
}
|
|
433
|
+
})()
|
|
434
|
+
|
|
435
|
+
inFlightSyntheses.set(flightKey, synthPromise)
|
|
436
|
+
return synthPromise
|
|
420
437
|
}
|
|
421
438
|
|
|
422
439
|
// Синтез одной фразы с постановкой в очередь. Порядок сохраняется: номер
|
package/lib/providers.js
CHANGED
|
@@ -75,12 +75,32 @@ function asBuffer(body) {
|
|
|
75
75
|
|
|
76
76
|
export function makeProviders(deps, req) {
|
|
77
77
|
const { resolveKey, fetchImpl, cfg } = deps
|
|
78
|
-
const { text, lang, signal, models, voices } = req
|
|
78
|
+
const { text, lang, signal: parentSignal, models, voices } = req
|
|
79
|
+
const cloudTimeoutMs = Number(cfg.cloudTimeoutMs) > 0 ? Number(cfg.cloudTimeoutMs) : 10000
|
|
80
|
+
|
|
81
|
+
function withCloudTimeout() {
|
|
82
|
+
const controller = new AbortController()
|
|
83
|
+
let timedOut = false
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
timedOut = true
|
|
86
|
+
controller.abort(new Error(`Cloud TTS timeout after ${cloudTimeoutMs}ms`))
|
|
87
|
+
}, cloudTimeoutMs)
|
|
88
|
+
if (parentSignal) {
|
|
89
|
+
if (parentSignal.aborted) controller.abort(parentSignal.reason)
|
|
90
|
+
else parentSignal.addEventListener('abort', () => controller.abort(parentSignal.reason), { once: true })
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
signal: controller.signal,
|
|
94
|
+
cleanup: () => clearTimeout(timer),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
79
97
|
|
|
80
98
|
async function openai() {
|
|
81
99
|
const key = await resolveKey(cfg.openaiKeyEnv)
|
|
82
100
|
if (!key) return { ok: false, provider: 'openai', reason: `no ${cfg.openaiKeyEnv}` }
|
|
83
|
-
const
|
|
101
|
+
const { signal, cleanup } = withCloudTimeout()
|
|
102
|
+
try {
|
|
103
|
+
const res = await fetchImpl((cfg.openaiBaseUrl || 'https://api.openai.com/v1') + '/audio/speech', {
|
|
84
104
|
method: 'POST',
|
|
85
105
|
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
86
106
|
body: JSON.stringify({
|
|
@@ -94,6 +114,9 @@ export function makeProviders(deps, req) {
|
|
|
94
114
|
if (!res.ok) throw new Error(`OpenAI HTTP ${res.status}`)
|
|
95
115
|
const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
|
|
96
116
|
return { ok: audio.length > 0, provider: 'openai', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
|
|
117
|
+
} finally {
|
|
118
|
+
cleanup()
|
|
119
|
+
}
|
|
97
120
|
}
|
|
98
121
|
|
|
99
122
|
// Одна реализация на всех, кто говорит на языке OpenAI: адрес и имя ключа
|
|
@@ -106,7 +129,9 @@ export function makeProviders(deps, req) {
|
|
|
106
129
|
if (!token) return { ok: false, provider: key, reason: `no ${keyEnv}` }
|
|
107
130
|
const base = cfg[key + 'BaseUrl'] || spec.baseUrl
|
|
108
131
|
if (!base) return { ok: false, provider: key, reason: 'no base url configured' }
|
|
109
|
-
const
|
|
132
|
+
const { signal, cleanup } = withCloudTimeout()
|
|
133
|
+
try {
|
|
134
|
+
const res = await fetchImpl(base + '/audio/speech', {
|
|
110
135
|
method: 'POST',
|
|
111
136
|
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
112
137
|
body: JSON.stringify({
|
|
@@ -120,6 +145,9 @@ export function makeProviders(deps, req) {
|
|
|
120
145
|
if (!res.ok) throw new Error(`${key} HTTP ${res.status}`)
|
|
121
146
|
const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
|
|
122
147
|
return { ok: audio.length > 0, provider: key, audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
|
|
148
|
+
} finally {
|
|
149
|
+
cleanup()
|
|
150
|
+
}
|
|
123
151
|
}
|
|
124
152
|
}
|
|
125
153
|
|
|
@@ -130,7 +158,9 @@ export function makeProviders(deps, req) {
|
|
|
130
158
|
if (!key) return { ok: false, provider: 'mimo', reason: `no ${cfg.mimoKeyEnv}` }
|
|
131
159
|
const base = (cfg.mimoBaseUrl || 'https://api.xiaomimimo.com/v1').replace(/\/+$/, '')
|
|
132
160
|
const format = cfg.mimoFormat === 'wav' ? 'wav' : 'mp3'
|
|
133
|
-
const
|
|
161
|
+
const { signal, cleanup } = withCloudTimeout()
|
|
162
|
+
try {
|
|
163
|
+
const res = await fetchImpl(base + '/chat/completions', {
|
|
134
164
|
method: 'POST',
|
|
135
165
|
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', accept: 'application/json' },
|
|
136
166
|
body: JSON.stringify({
|
|
@@ -160,6 +190,9 @@ export function makeProviders(deps, req) {
|
|
|
160
190
|
mime: format === 'wav' ? 'audio/wav' : 'audio/mpeg',
|
|
161
191
|
reason: audio.length ? '' : 'empty audio',
|
|
162
192
|
}
|
|
193
|
+
} finally {
|
|
194
|
+
cleanup()
|
|
195
|
+
}
|
|
163
196
|
}
|
|
164
197
|
|
|
165
198
|
// MiniMax — через их утилиту: своего протокола здесь нет намеренно.
|
|
@@ -181,7 +214,9 @@ export function makeProviders(deps, req) {
|
|
|
181
214
|
const key = await resolveKey(cfg.elevenlabsKeyEnv)
|
|
182
215
|
if (!key) return { ok: false, provider: 'elevenlabs', reason: `no ${cfg.elevenlabsKeyEnv}` }
|
|
183
216
|
const voice = pick(DEFAULT_VOICES, voices, 'elevenlabs')
|
|
184
|
-
const
|
|
217
|
+
const { signal, cleanup } = withCloudTimeout()
|
|
218
|
+
try {
|
|
219
|
+
const res = await fetchImpl(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, {
|
|
185
220
|
method: 'POST',
|
|
186
221
|
headers: { 'xi-api-key': key, 'content-type': 'application/json', accept: 'audio/mpeg' },
|
|
187
222
|
body: JSON.stringify({
|
|
@@ -193,6 +228,9 @@ export function makeProviders(deps, req) {
|
|
|
193
228
|
if (!res.ok) throw new Error(`ElevenLabs HTTP ${res.status}`)
|
|
194
229
|
const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
|
|
195
230
|
return { ok: audio.length > 0, provider: 'elevenlabs', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
|
|
231
|
+
} finally {
|
|
232
|
+
cleanup()
|
|
233
|
+
}
|
|
196
234
|
}
|
|
197
235
|
|
|
198
236
|
async function google() {
|
|
@@ -200,7 +238,9 @@ export function makeProviders(deps, req) {
|
|
|
200
238
|
if (!key) return { ok: false, provider: 'google', reason: `no ${cfg.googleKeyEnv}` }
|
|
201
239
|
const model = pick(DEFAULT_MODELS, models, 'google')
|
|
202
240
|
const voiceName = pick(DEFAULT_VOICES, voices, 'google')
|
|
203
|
-
const
|
|
241
|
+
const { signal, cleanup } = withCloudTimeout()
|
|
242
|
+
try {
|
|
243
|
+
const res = await fetchImpl(
|
|
204
244
|
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(key)}`,
|
|
205
245
|
{
|
|
206
246
|
method: 'POST',
|
|
@@ -221,6 +261,9 @@ export function makeProviders(deps, req) {
|
|
|
221
261
|
if (!b64) return { ok: false, provider: 'google', reason: 'no inline audio' }
|
|
222
262
|
const audio = Buffer.from(b64, 'base64')
|
|
223
263
|
return { ok: audio.length > 0, provider: 'google', audio, mime: 'audio/wav', reason: audio.length ? '' : 'empty audio' }
|
|
264
|
+
} finally {
|
|
265
|
+
cleanup()
|
|
266
|
+
}
|
|
224
267
|
}
|
|
225
268
|
|
|
226
269
|
async function azure() {
|
|
@@ -238,7 +281,9 @@ export function makeProviders(deps, req) {
|
|
|
238
281
|
const ssml = `<speak version="1.0" xml:lang="${lang || 'en-US'}">`
|
|
239
282
|
+ `<voice xml:lang="${lang || 'en-US'}" name="${voice}">`
|
|
240
283
|
+ `${inner}</voice></speak>`
|
|
241
|
-
const
|
|
284
|
+
const { signal, cleanup } = withCloudTimeout()
|
|
285
|
+
try {
|
|
286
|
+
const res = await fetchImpl(`https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`, {
|
|
242
287
|
method: 'POST',
|
|
243
288
|
headers: {
|
|
244
289
|
'Ocp-Apim-Subscription-Key': key,
|
|
@@ -251,12 +296,17 @@ export function makeProviders(deps, req) {
|
|
|
251
296
|
if (!res.ok) throw new Error(`Azure HTTP ${res.status}`)
|
|
252
297
|
const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
|
|
253
298
|
return { ok: audio.length > 0, provider: 'azure', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
|
|
299
|
+
} finally {
|
|
300
|
+
cleanup()
|
|
301
|
+
}
|
|
254
302
|
}
|
|
255
303
|
|
|
256
304
|
async function groq() {
|
|
257
305
|
const key = await resolveKey(cfg.groqKeyEnv)
|
|
258
306
|
if (!key) return { ok: false, provider: 'groq', reason: `no ${cfg.groqKeyEnv}` }
|
|
259
|
-
const
|
|
307
|
+
const { signal, cleanup } = withCloudTimeout()
|
|
308
|
+
try {
|
|
309
|
+
const res = await fetchImpl('https://api.groq.com/openai/v1/audio/speech', {
|
|
260
310
|
method: 'POST',
|
|
261
311
|
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
262
312
|
body: JSON.stringify({
|
|
@@ -270,13 +320,18 @@ export function makeProviders(deps, req) {
|
|
|
270
320
|
if (!res.ok) throw new Error(`Groq HTTP ${res.status}`)
|
|
271
321
|
const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
|
|
272
322
|
return { ok: audio.length > 0, provider: 'groq', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
|
|
323
|
+
} finally {
|
|
324
|
+
cleanup()
|
|
325
|
+
}
|
|
273
326
|
}
|
|
274
327
|
|
|
275
328
|
async function deepgram() {
|
|
276
329
|
const key = await resolveKey(cfg.deepgramKeyEnv)
|
|
277
330
|
if (!key) return { ok: false, provider: 'deepgram', reason: `no ${cfg.deepgramKeyEnv}` }
|
|
278
331
|
const model = pick(DEFAULT_MODELS, models, 'deepgram')
|
|
279
|
-
const
|
|
332
|
+
const { signal, cleanup } = withCloudTimeout()
|
|
333
|
+
try {
|
|
334
|
+
const res = await fetchImpl(`https://api.deepgram.com/v1/speak?model=${encodeURIComponent(model)}`, {
|
|
280
335
|
method: 'POST',
|
|
281
336
|
headers: { authorization: `Token ${key}`, 'content-type': 'application/json' },
|
|
282
337
|
body: JSON.stringify({ text }),
|
|
@@ -285,12 +340,17 @@ export function makeProviders(deps, req) {
|
|
|
285
340
|
if (!res.ok) throw new Error(`Deepgram HTTP ${res.status}`)
|
|
286
341
|
const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
|
|
287
342
|
return { ok: audio.length > 0, provider: 'deepgram', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
|
|
343
|
+
} finally {
|
|
344
|
+
cleanup()
|
|
345
|
+
}
|
|
288
346
|
}
|
|
289
347
|
|
|
290
348
|
async function openrouter() {
|
|
291
349
|
const key = await resolveKey(cfg.openrouterKeyEnv)
|
|
292
350
|
if (!key) return { ok: false, provider: 'openrouter', reason: `no ${cfg.openrouterKeyEnv}` }
|
|
293
|
-
const
|
|
351
|
+
const { signal, cleanup } = withCloudTimeout()
|
|
352
|
+
try {
|
|
353
|
+
const res = await fetchImpl('https://openrouter.ai/api/v1/audio/speech', {
|
|
294
354
|
method: 'POST',
|
|
295
355
|
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
296
356
|
body: JSON.stringify({
|
|
@@ -304,6 +364,9 @@ export function makeProviders(deps, req) {
|
|
|
304
364
|
if (!res.ok) throw new Error(`OpenRouter HTTP ${res.status}`)
|
|
305
365
|
const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
|
|
306
366
|
return { ok: audio.length > 0, provider: 'openrouter', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
|
|
367
|
+
} finally {
|
|
368
|
+
cleanup()
|
|
369
|
+
}
|
|
307
370
|
}
|
|
308
371
|
|
|
309
372
|
async function edge() {
|
|
@@ -340,6 +403,9 @@ export function makeProviders(deps, req) {
|
|
|
340
403
|
try {
|
|
341
404
|
const modelPath = deps.modelManager ? deps.modelManager.getModelPath('kokoro') : (cfg.kokoroModelPath || '')
|
|
342
405
|
const engine = new KokoroEngine({ modelPath })
|
|
406
|
+
if (!engine.isInstalled()) {
|
|
407
|
+
return { ok: false, provider: 'kokoro', reason: 'Kokoro ONNX model not installed. Use Piper or eSpeak for offline TTS.' }
|
|
408
|
+
}
|
|
343
409
|
const voice = pick(DEFAULT_VOICES, voices, 'kokoro') || 'af_bella'
|
|
344
410
|
const audio = await engine.synthesizeWav(text, voice)
|
|
345
411
|
return { ok: audio && audio.length > 0, provider: 'kokoro', audio, mime: 'audio/wav' }
|
|
@@ -353,8 +419,11 @@ export function makeProviders(deps, req) {
|
|
|
353
419
|
const modelPath = deps.modelManager ? deps.modelManager.getModelPath('f5') : (cfg.f5ModelPath || '')
|
|
354
420
|
const engine = new F5Engine({ modelPath })
|
|
355
421
|
const ok = await engine.ping()
|
|
356
|
-
if (!ok) return { ok: false, provider: 'f5', reason: 'F5 daemon unavailable' }
|
|
422
|
+
if (!ok) return { ok: false, provider: 'f5', reason: 'F5 daemon unavailable. Use Piper or eSpeak for offline TTS.' }
|
|
357
423
|
const kokoroFallback = new KokoroEngine({ modelPath })
|
|
424
|
+
if (!kokoroFallback.isInstalled()) {
|
|
425
|
+
return { ok: false, provider: 'f5', reason: 'F5 model weights not installed. Use Piper or eSpeak for offline TTS.' }
|
|
426
|
+
}
|
|
358
427
|
const audio = await kokoroFallback.synthesizeWav(text, 'af_bella')
|
|
359
428
|
return { ok: true, provider: 'f5', audio, mime: 'audio/wav' }
|
|
360
429
|
} catch (e) {
|
package/lib/text.js
CHANGED
|
@@ -147,6 +147,32 @@ export function assistantText(message) {
|
|
|
147
147
|
return blocksToText(message.content)
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
// Защита распространенных сокращений от ложного разбиения на предложения
|
|
151
|
+
const PROTECTED_ABBREVIATIONS = [
|
|
152
|
+
// Русские сокращения
|
|
153
|
+
'т.е.', 'т.д.', 'т.п.', 'т.к.', 'руб.', 'коп.', 'ул.', 'пер.', 'пр.', 'просп.',
|
|
154
|
+
'д.', 'корп.', 'стр.', 'кв.', 'г.', 'гг.', 'в.', 'вв.', 'см.', 'ср.', 'напр.',
|
|
155
|
+
'рис.', 'табл.', 'им.', 'акад.', 'проф.', 'доц.', 'млн.', 'млрд.', 'тыс.',
|
|
156
|
+
// Английские сокращения
|
|
157
|
+
'e.g.', 'i.e.', 'vs.', 'etc.', 'dr.', 'mr.', 'mrs.', 'ms.', 'prof.',
|
|
158
|
+
'approx.', 'dept.', 'est.', 'min.', 'max.', 'no.', 'vol.', 'p.m.', 'a.m.'
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
export function protectAbbreviations(text) {
|
|
162
|
+
let res = String(text || '')
|
|
163
|
+
for (const abbr of PROTECTED_ABBREVIATIONS) {
|
|
164
|
+
const escaped = abbr.replace(/\./g, '\\.')
|
|
165
|
+
const rx = new RegExp('(?<=^|[\\s"«(])' + escaped + '(?=[\\s"»)\\]]|$)', 'gi')
|
|
166
|
+
res = res.replace(rx, (m) => m.replace(/\./g, '\u2024'))
|
|
167
|
+
}
|
|
168
|
+
res = res.replace(/(?<=[vV]?\d+)\.(?=\d+)/g, '\u2024')
|
|
169
|
+
return res
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function restoreAbbreviations(text) {
|
|
173
|
+
return String(text || '').replace(/\u2024/g, '.')
|
|
174
|
+
}
|
|
175
|
+
|
|
150
176
|
/**
|
|
151
177
|
* Режет текст на куски по границам предложений.
|
|
152
178
|
*
|
|
@@ -161,9 +187,10 @@ export function assistantText(message) {
|
|
|
161
187
|
export function splitSentences(text, maxChars) {
|
|
162
188
|
const limit = Number(maxChars) > 0 ? Number(maxChars) : 320
|
|
163
189
|
const min = Math.min(60, Math.floor(limit / 3))
|
|
164
|
-
const
|
|
165
|
-
if (!
|
|
190
|
+
const raw = String(text || '').trim()
|
|
191
|
+
if (!raw) return []
|
|
166
192
|
|
|
193
|
+
const source = protectAbbreviations(raw)
|
|
167
194
|
const pieces = []
|
|
168
195
|
let current = ''
|
|
169
196
|
// Границей считаем точку, вопрос, восклицание, многоточие и перевод строки.
|
|
@@ -181,5 +208,5 @@ export function splitSentences(text, maxChars) {
|
|
|
181
208
|
}
|
|
182
209
|
}
|
|
183
210
|
if (current) pieces.push(current)
|
|
184
|
-
return pieces.filter(Boolean)
|
|
211
|
+
return pieces.filter(Boolean).map(restoreAbbreviations)
|
|
185
212
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-tts",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.23",
|
|
4
4
|
"description": "Text-to-speech for DeepSeek Harness: speak agent replies in the Web UI with a provider fallback chain (OpenAI, ElevenLabs, Google, Azure, Groq, Deepgram, OpenRouter, Edge, Piper, eSpeak).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -63,4 +63,4 @@
|
|
|
63
63
|
"publishConfig": {
|
|
64
64
|
"access": "public"
|
|
65
65
|
}
|
|
66
|
-
}
|
|
66
|
+
}
|
package/lib/client/worklet.js
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
class TTSWorklet extends AudioWorkletProcessor {
|
|
2
|
-
constructor() {
|
|
3
|
-
super()
|
|
4
|
-
this.bufferQueue = []
|
|
5
|
-
this.currentChunk = null
|
|
6
|
-
this.chunkOffset = 0
|
|
7
|
-
this.playing = true
|
|
8
|
-
|
|
9
|
-
this.port.onmessage = (event) => {
|
|
10
|
-
const data = event.data
|
|
11
|
-
if (!data) return
|
|
12
|
-
if (data.type === 'chunk' && data.pcm instanceof Float32Array) {
|
|
13
|
-
this.bufferQueue.push(data.pcm)
|
|
14
|
-
} else if (data.type === 'clear' || data.type === 'stop') {
|
|
15
|
-
this.bufferQueue = []
|
|
16
|
-
this.currentChunk = null
|
|
17
|
-
this.chunkOffset = 0
|
|
18
|
-
} else if (data.type === 'pause') {
|
|
19
|
-
this.playing = false
|
|
20
|
-
} else if (data.type === 'resume') {
|
|
21
|
-
this.playing = true
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
process(inputs, outputs, parameters) {
|
|
27
|
-
const output = outputs[0]
|
|
28
|
-
if (!output || !output[0]) return true
|
|
29
|
-
const channel = output[0]
|
|
30
|
-
const bufferLength = channel.length
|
|
31
|
-
|
|
32
|
-
if (!this.playing) {
|
|
33
|
-
channel.fill(0)
|
|
34
|
-
return true
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
let written = 0
|
|
38
|
-
while (written < bufferLength) {
|
|
39
|
-
if (!this.currentChunk || this.chunkOffset >= this.currentChunk.length) {
|
|
40
|
-
if (this.bufferQueue.length === 0) {
|
|
41
|
-
this.currentChunk = null
|
|
42
|
-
break
|
|
43
|
-
}
|
|
44
|
-
this.currentChunk = this.bufferQueue.shift()
|
|
45
|
-
this.chunkOffset = 0
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const available = this.currentChunk.length - this.chunkOffset
|
|
49
|
-
const needed = bufferLength - written
|
|
50
|
-
const toCopy = Math.min(available, needed)
|
|
51
|
-
|
|
52
|
-
for (let i = 0; i < toCopy; i++) {
|
|
53
|
-
channel[written + i] = this.currentChunk[this.chunkOffset + i]
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
this.chunkOffset += toCopy
|
|
57
|
-
written += toCopy
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Fill remaining output buffer with silence if queue is drained
|
|
61
|
-
if (written < bufferLength) {
|
|
62
|
-
for (let i = written; i < bufferLength; i++) {
|
|
63
|
-
channel[i] = 0
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return true
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
registerProcessor('tts-worklet', TTSWorklet)
|
package/lib/router.js
DELETED