@goodandready/dsh-tts 0.3.5 → 0.3.7
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/cache.js +83 -0
- package/lib/client.js +30 -0
- package/lib/index.js +64 -6
- package/lib/text.js +37 -3
- package/package.json +1 -1
package/lib/cache.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Кэш синтеза на диске: ключ — хеш (текст, провайдер, модель, голос),
|
|
2
|
+
// значение — ответ провайдера как есть (байты + MIME). Вытеснение самых
|
|
3
|
+
// давних по времени последнего обращения; ограничение задаётся настройкой
|
|
4
|
+
// cacheMaxMb и может меняться на живую, поэтому приходит функцией.
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
export function cacheKey(parts) {
|
|
10
|
+
return createHash('sha1').update(JSON.stringify(parts)).digest('hex')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const AUDIO_EXT = '.audio'
|
|
14
|
+
|
|
15
|
+
export function createSpeechCache({ root, maxBytes }) {
|
|
16
|
+
const dir = path.join(String(root), 'data', 'dsh-tts', 'cache')
|
|
17
|
+
let ready = null
|
|
18
|
+
const ensure = () => {
|
|
19
|
+
if (!ready) ready = fs.promises.mkdir(dir, { recursive: true })
|
|
20
|
+
return ready
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function listEntries() {
|
|
24
|
+
await ensure()
|
|
25
|
+
const out = []
|
|
26
|
+
for (const name of await fs.promises.readdir(dir)) {
|
|
27
|
+
if (!name.endsWith(AUDIO_EXT)) continue
|
|
28
|
+
const full = path.join(dir, name)
|
|
29
|
+
const st = await fs.promises.stat(full)
|
|
30
|
+
let at = st.mtimeMs
|
|
31
|
+
try {
|
|
32
|
+
const meta = JSON.parse(await fs.promises.readFile(path.join(dir, name.slice(0, -AUDIO_EXT.length) + '.json'), 'utf8'))
|
|
33
|
+
if (meta && typeof meta.at === 'number') at = meta.at
|
|
34
|
+
} catch (noMeta) { /* старьё без меты — вытесним первым */ }
|
|
35
|
+
out.push({ full, meta: name.slice(0, -AUDIO_EXT.length) + '.json', size: st.size, at })
|
|
36
|
+
}
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function evict() {
|
|
41
|
+
const limit = typeof maxBytes === 'function' ? maxBytes() : Number(maxBytes)
|
|
42
|
+
if (!(limit > 0)) return
|
|
43
|
+
const entries = (await listEntries()).sort((a, b) => a.at - b.at)
|
|
44
|
+
let total = entries.reduce((acc, e) => acc + e.size, 0)
|
|
45
|
+
for (const e of entries) {
|
|
46
|
+
if (total <= limit) break
|
|
47
|
+
await fs.promises.rm(e.full, { force: true })
|
|
48
|
+
await fs.promises.rm(path.join(dir, e.meta), { force: true }).catch(() => {})
|
|
49
|
+
total -= e.size
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
async put(key, mime, audio) {
|
|
55
|
+
await ensure()
|
|
56
|
+
const buf = Buffer.isBuffer(audio) ? audio : Buffer.from(audio)
|
|
57
|
+
await fs.promises.writeFile(path.join(dir, key + AUDIO_EXT), buf)
|
|
58
|
+
await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime, at: Date.now() }))
|
|
59
|
+
await evict()
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
async get(key) {
|
|
63
|
+
try {
|
|
64
|
+
const meta = JSON.parse(await fs.promises.readFile(path.join(dir, key + '.json'), 'utf8'))
|
|
65
|
+
const audio = await fs.promises.readFile(path.join(dir, key + AUDIO_EXT))
|
|
66
|
+
// Последнее обращение двигаем асинхронно: чтение тормозить нельзя.
|
|
67
|
+
fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: meta.mime, at: Date.now() })).catch(() => {})
|
|
68
|
+
return { mime: meta.mime || 'audio/mpeg', audio }
|
|
69
|
+
} catch (miss) {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async clear() {
|
|
75
|
+
const entries = await listEntries()
|
|
76
|
+
for (const e of entries) {
|
|
77
|
+
await fs.promises.rm(e.full, { force: true })
|
|
78
|
+
await fs.promises.rm(path.join(dir, e.meta), { force: true }).catch(() => {})
|
|
79
|
+
}
|
|
80
|
+
return entries.length
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -366,6 +366,13 @@ window.__ModuleLoader__.load({
|
|
|
366
366
|
'save': 'Save',
|
|
367
367
|
'saved': 'Saved',
|
|
368
368
|
'cardHint': 'Provider chain, voices and playback options.',
|
|
369
|
+
'skipCode': 'Skip code blocks',
|
|
370
|
+
'skipCodeHint': 'Say a short notice instead of reading code aloud.',
|
|
371
|
+
'cache': 'Synthesis cache',
|
|
372
|
+
'cacheHint': 'Reuse synthesized audio for repeated phrases.',
|
|
373
|
+
'cacheMaxMb': 'Cache size limit, MB',
|
|
374
|
+
'cacheMaxMbHint': 'Least recently used items are evicted first.',
|
|
375
|
+
'clearCache': 'Clear cache',
|
|
369
376
|
}
|
|
370
377
|
const ru = {
|
|
371
378
|
'general': 'Общее',
|
|
@@ -414,6 +421,13 @@ window.__ModuleLoader__.load({
|
|
|
414
421
|
'save': 'Сохранить',
|
|
415
422
|
'saved': 'Сохранено',
|
|
416
423
|
'cardHint': 'Цепочка провайдеров, голоса и параметры чтения.',
|
|
424
|
+
'skipCode': 'Пропускать код',
|
|
425
|
+
'skipCodeHint': 'Вместо чтения кода вслух — короткая пометка.',
|
|
426
|
+
'cache': 'Кэш синтеза',
|
|
427
|
+
'cacheHint': 'Повторяющиеся фразы не синтезируются заново.',
|
|
428
|
+
'cacheMaxMb': 'Лимит кэша, МБ',
|
|
429
|
+
'cacheMaxMbHint': 'Сначала вытесняется самое давнее по использованию.',
|
|
430
|
+
'clearCache': 'Очистить кэш',
|
|
417
431
|
}
|
|
418
432
|
|
|
419
433
|
function TtsSection(props) {
|
|
@@ -503,6 +517,16 @@ window.__ModuleLoader__.load({
|
|
|
503
517
|
commitKey(provider, raw).catch((e) => setErr(String(e && e.message ? e.message : e)))
|
|
504
518
|
}
|
|
505
519
|
|
|
520
|
+
const clearCacheNow = async () => {
|
|
521
|
+
setErr('')
|
|
522
|
+
const res = await fetch('/dsh-tts/cache', { method: 'DELETE' })
|
|
523
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
524
|
+
setSaved(true); setTimeout(() => setSaved(false), 2000)
|
|
525
|
+
}
|
|
526
|
+
const onClearCache = () => {
|
|
527
|
+
clearCacheNow().catch((e) => setErr(String(e && e.message ? e.message : e)))
|
|
528
|
+
}
|
|
529
|
+
|
|
506
530
|
const boolField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
|
|
507
531
|
React.createElement('input', {
|
|
508
532
|
type: 'checkbox', checked: !!(draft && draft[key]), disabled: !writable,
|
|
@@ -565,6 +589,11 @@ window.__ModuleLoader__.load({
|
|
|
565
589
|
t('bargeInHint')),
|
|
566
590
|
boolField('announceApproval', t('announce'),
|
|
567
591
|
t('announceHint')),
|
|
592
|
+
boolField('skipCode', t('skipCode'),
|
|
593
|
+
t('skipCodeHint')),
|
|
594
|
+
boolField('cache', t('cache'),
|
|
595
|
+
t('cacheHint')),
|
|
596
|
+
numberField('cacheMaxMb', t('cacheMaxMb'), t('cacheMaxMbHint'), 10),
|
|
568
597
|
textField('approvalText', t('approvalText'), t('approvalHint')),
|
|
569
598
|
textField('questionText', t('questionText'), t('questionHint')),
|
|
570
599
|
textField('chime', t('chime'), t('chimeHint')),
|
|
@@ -576,6 +605,7 @@ window.__ModuleLoader__.load({
|
|
|
576
605
|
textField('azureRegion', t('azureRegion'), t('azureHint')),
|
|
577
606
|
),
|
|
578
607
|
React.createElement('div', { className: 'dts-foot' },
|
|
608
|
+
React.createElement('button', { type: 'button', className: 'dts-link', disabled: !writable, onClick: onClearCache }, t('clearCache')),
|
|
579
609
|
React.createElement('button', { type: 'button', className: 'dts-save', disabled: !writable, onClick: save }, t('save')),
|
|
580
610
|
saved ? React.createElement('span', { className: 'dts-ok' }, t('saved')) : null,
|
|
581
611
|
err ? React.createElement('span', { className: 'dts-bad' }, err) : null,
|
package/lib/index.js
CHANGED
|
@@ -3,7 +3,11 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
3
3
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
4
4
|
import { runChain } from './chain.js'
|
|
5
5
|
import { makeProviders, PROVIDER_KEYS, DEFAULT_MODELS, DEFAULT_VOICES } from './providers.js'
|
|
6
|
-
import { assistantText,
|
|
6
|
+
import { assistantText, speechPhrases, splitSentences, stripForSpeech } from './text.js'
|
|
7
|
+
import { cacheKey, createSpeechCache } from './cache.js'
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import path from 'node:path'
|
|
7
11
|
import {
|
|
8
12
|
CLOUD_PROVIDERS,
|
|
9
13
|
assertCredentialRef,
|
|
@@ -31,6 +35,12 @@ const ChainEntry = z.object({
|
|
|
31
35
|
export const Config = z.object({
|
|
32
36
|
speakReplies: z.boolean().default(false)
|
|
33
37
|
.description('When on, speak each finished agent reply in the Web UI.'),
|
|
38
|
+
skipCode: z.boolean().default(true)
|
|
39
|
+
.description('Replace fenced code blocks with a short spoken notice instead of reading them aloud.'),
|
|
40
|
+
cache: z.boolean().default(true)
|
|
41
|
+
.description('Reuse synthesized audio for repeated phrases instead of paying and waiting again.'),
|
|
42
|
+
cacheMaxMb: z.number().default(100)
|
|
43
|
+
.description('Disk limit for the synthesis cache; least recently used items are evicted first.'),
|
|
34
44
|
language: z.string().default('ru'),
|
|
35
45
|
speakAsItGoes: z
|
|
36
46
|
.boolean()
|
|
@@ -225,8 +235,25 @@ export function apply(ctx, config) {
|
|
|
225
235
|
}
|
|
226
236
|
}
|
|
227
237
|
|
|
238
|
+
// Кэш синтеза живёт рядом с прочими данными харнесса.
|
|
239
|
+
const speechCache = createSpeechCache({
|
|
240
|
+
root: process.env.DSH_HOME || path.join(os.homedir(), '.dsh'),
|
|
241
|
+
maxBytes: () => {
|
|
242
|
+
const mb = Number(live().cacheMaxMb)
|
|
243
|
+
return mb > 0 ? mb * 1024 * 1024 : 100 * 1024 * 1024
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
// Порог осмысленности: уникальные длинные куски (чтение по ходу) в кэш
|
|
247
|
+
// не кладём — иначе он забьётся мусором, который никогда не повторится.
|
|
248
|
+
const CACHE_MAX_TEXT = 200
|
|
249
|
+
|
|
250
|
+
// Настройки чистки текста считаем на каждый вызов: конфиг живой.
|
|
251
|
+
function cleanOpts(cfg) {
|
|
252
|
+
return { skipCode: cfg.skipCode !== false, phrases: speechPhrases(cfg.language) }
|
|
253
|
+
}
|
|
254
|
+
|
|
228
255
|
async function synthesize(rawText, cfg, signal) {
|
|
229
|
-
const text = stripForSpeech(rawText, cfg.maxChars)
|
|
256
|
+
const text = stripForSpeech(rawText, cfg.maxChars, cleanOpts(cfg))
|
|
230
257
|
if (!text) throw new Error('nothing to speak')
|
|
231
258
|
const models = {}
|
|
232
259
|
const voices = {}
|
|
@@ -237,11 +264,25 @@ export function apply(ctx, config) {
|
|
|
237
264
|
models[entry.provider] = entry.model || DEFAULT_MODELS[entry.provider]
|
|
238
265
|
voices[entry.provider] = entry.voice || DEFAULT_VOICES[entry.provider]
|
|
239
266
|
}
|
|
267
|
+
// Скорость воспроизведения в ключ не входит: ею управляет браузер,
|
|
268
|
+
// на синтез она не влияет.
|
|
269
|
+
const cacheAllowed = cfg.cache !== false && text.length <= CACHE_MAX_TEXT
|
|
270
|
+
if (cacheAllowed) {
|
|
271
|
+
for (const provider of order) {
|
|
272
|
+
const hit = await speechCache.get(cacheKey([text, provider, models[provider], voices[provider]]))
|
|
273
|
+
if (hit) return { provider, mime: hit.mime, audio: hit.audio, tookMs: 0, cached: true }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
240
276
|
const providers = makeProviders(
|
|
241
277
|
{ resolveKey, fetchImpl: fetch, cfg },
|
|
242
278
|
{ text, lang: cfg.language, signal, models, voices },
|
|
243
279
|
)
|
|
244
|
-
|
|
280
|
+
const out = await runChain(order, providers)
|
|
281
|
+
if (cacheAllowed) {
|
|
282
|
+
const p = out.provider
|
|
283
|
+
speechCache.put(cacheKey([text, p, models[p], voices[p]]), out.mime, out.audio).catch(() => {})
|
|
284
|
+
}
|
|
285
|
+
return out
|
|
245
286
|
}
|
|
246
287
|
|
|
247
288
|
// Синтез одной фразы с постановкой в очередь. Порядок сохраняется: номер
|
|
@@ -320,7 +361,7 @@ export function apply(ctx, config) {
|
|
|
320
361
|
// предложениями, и очередь проигрывается по порядку. Ждать конца хода
|
|
321
362
|
// значит молчать всё время, пока агент работает.
|
|
322
363
|
if (cfg.speakAsItGoes) {
|
|
323
|
-
const ready = stripForSpeech(text, cfg.maxChars)
|
|
364
|
+
const ready = stripForSpeech(text, cfg.maxChars, cleanOpts(cfg))
|
|
324
365
|
for (const piece of splitSentences(ready, cfg.sentenceChars)) speakPiece(sid, piece, cfg)
|
|
325
366
|
return
|
|
326
367
|
}
|
|
@@ -335,7 +376,7 @@ export function apply(ctx, config) {
|
|
|
335
376
|
const cur = collectors.get(sid)
|
|
336
377
|
collectors.delete(sid)
|
|
337
378
|
const raw = cur && Array.isArray(cur.parts) ? cur.parts.join('\n') : ''
|
|
338
|
-
const text = stripForSpeech(raw, cfg.maxChars)
|
|
379
|
+
const text = stripForSpeech(raw, cfg.maxChars, cleanOpts(cfg))
|
|
339
380
|
if (!text) return
|
|
340
381
|
const id = `u${++utteranceSeq}`
|
|
341
382
|
const controller = new AbortController()
|
|
@@ -380,6 +421,23 @@ export function apply(ctx, config) {
|
|
|
380
421
|
},
|
|
381
422
|
}), 'dsh-tts: /status route')
|
|
382
423
|
|
|
424
|
+
ctx.effect(() => ctx.webServer.register({
|
|
425
|
+
kind: 'exact',
|
|
426
|
+
path: '/dsh-tts/cache',
|
|
427
|
+
handler: async (req, res) => {
|
|
428
|
+
if (req.method !== 'DELETE') {
|
|
429
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE only' } })
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
433
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
const removed = await speechCache.clear()
|
|
437
|
+
writeJson(res, 200, { ok: true, removed })
|
|
438
|
+
},
|
|
439
|
+
}), 'dsh-tts: /cache route')
|
|
440
|
+
|
|
383
441
|
ctx.effect(() => ctx.webServer.register({
|
|
384
442
|
kind: 'exact',
|
|
385
443
|
path: '/dsh-tts/config',
|
|
@@ -502,7 +560,7 @@ export function apply(ctx, config) {
|
|
|
502
560
|
}
|
|
503
561
|
let payload
|
|
504
562
|
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
|
|
505
|
-
const text = stripForSpeech(payload.text, live().maxChars)
|
|
563
|
+
const text = stripForSpeech(payload.text, live().maxChars, cleanOpts(live()))
|
|
506
564
|
if (!text) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
|
|
507
565
|
const cfg = live()
|
|
508
566
|
const controller = new AbortController()
|
package/lib/text.js
CHANGED
|
@@ -11,10 +11,44 @@ export function blocksToText(content) {
|
|
|
11
11
|
return parts.join('\n')
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// Короткие пометки вместо выброшенной разметки. Формулировки выбираются по
|
|
15
|
+
// языку озвучки из настроек; подстановки {n} заполняются числом строк.
|
|
16
|
+
export const SPEECH_PHRASES = {
|
|
17
|
+
en: { codeBlock: 'code block, {n} lines', table: 'table, {n} rows' },
|
|
18
|
+
ru: { codeBlock: 'блок кода, {n} строк', table: 'таблица, {n} строк' },
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function speechPhrases(language) {
|
|
22
|
+
const lang = String(language || '').toLowerCase()
|
|
23
|
+
return lang.startsWith('ru') ? SPEECH_PHRASES.ru : SPEECH_PHRASES.en
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fill(template, n) {
|
|
27
|
+
return String(template).replace('{n}', String(n))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function stripForSpeech(raw, maxChars, opts) {
|
|
31
|
+
const options = opts || {}
|
|
32
|
+
const skipCode = options.skipCode !== false
|
|
33
|
+
const phrases = options.phrases || SPEECH_PHRASES.en
|
|
15
34
|
let text = String(raw || '')
|
|
16
|
-
|
|
17
|
-
|
|
35
|
+
// Чистка обязана идти до нарезки на предложения, иначе половина блока
|
|
36
|
+
// уедет в первый кусок и будет озвучена.
|
|
37
|
+
if (skipCode) {
|
|
38
|
+
text = text.replace(/```[\s\S]*?```/g, (block) => {
|
|
39
|
+
const inner = block.replace(/^```[^\n]*\n?/, '').replace(/\n?```\s*$/, '')
|
|
40
|
+
const lines = inner ? inner.split('\n').length : 1
|
|
41
|
+
return ' ' + fill(phrases.codeBlock, lines) + ' '
|
|
42
|
+
})
|
|
43
|
+
text = text.replace(/`[^`\n]+`/g, ' ')
|
|
44
|
+
}
|
|
45
|
+
// Таблицы Markdown: подряд идущие строки, начинающиеся с |; разделительная
|
|
46
|
+
// шапка |---| в счёт строк не идёт.
|
|
47
|
+
text = text.replace(/(?:^[ \t]*\|.*\|[ \t]*$\n?)+/gm, (run) => {
|
|
48
|
+
const rows = run.trim().split('\n')
|
|
49
|
+
.filter((line) => !/^\s*\|[\s:|-]+\|\s*$/.test(line)).length
|
|
50
|
+
return fill(phrases.table, Math.max(1, rows)) + '\n'
|
|
51
|
+
})
|
|
18
52
|
text = text.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
|
|
19
53
|
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
20
54
|
text = text.replace(/[#*_>]+/g, ' ')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-tts",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
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",
|