@goodandready/dsh-tts 0.3.22 → 0.4.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/lib/client.js CHANGED
@@ -5,9 +5,8 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports
6
6
  const React = require('react')
7
7
 
8
- // Значок раскрытия — ядровый; свой SVG только как фолбэк для сборок
9
- // без набора примитивов. Иконка всегда смотрит вниз, раскрытие
10
- // поворачивает её классом.
8
+ // Chevron comes from core UI primitives; local SVG is a fallback for
9
+ // builds without the package. Icon always points down; open state rotates.
11
10
  let ChevronIcon = null
12
11
  try {
13
12
  const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
@@ -23,25 +22,27 @@ window.__ModuleLoader__.load({
23
22
  const CLOUD = {
24
23
  openai: 1, elevenlabs: 1, google: 1, azure: 1, groq: 1, deepgram: 1, openrouter: 1, custom: 1,
25
24
  }
25
+ // Hints stay generic: concrete provider model IDs rot quickly.
26
26
  const MODEL_HINT = {
27
- openai: 'gpt-4o-mini-tts',
28
- elevenlabs: 'eleven_multilingual_v2',
29
- google: 'gemini-2.5-flash-preview-tts',
30
- azure: 'region voice, e.g. en-US-JennyNeural',
31
- groq: 'playai-tts',
32
- deepgram: 'aura-asteria-en (no Russian)',
33
- openrouter: 'openai/gpt-4o-mini-tts-2025-12-15',
34
- edge: 'ru-RU-SvetlanaNeural',
27
+ openai: 'provider default',
28
+ elevenlabs: 'provider default',
29
+ google: 'provider default',
30
+ azure: 'region voice name',
31
+ groq: 'provider default',
32
+ deepgram: 'provider default (English-only voices)',
33
+ openrouter: 'provider/model id',
34
+ edge: 'BCP-47 voice, e.g. ru-RU-SvetlanaNeural',
35
35
  piper: 'path to .onnx',
36
- espeak: 'ru',
36
+ espeak: 'language code, e.g. ru',
37
+ kokoro: 'not bundled — offline neural runtime unavailable',
38
+ f5: 'not bundled — offline neural runtime unavailable',
37
39
  }
38
40
 
39
41
  const SET_CSS =
40
42
  '.dts-wrap{display:flex;flex-direction:column;gap:22px;padding:4px 0;max-width:720px}' +
41
43
  '.dts-block{display:flex;flex-direction:column;gap:10px}' +
42
44
  '.dts-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
43
- // Единый вид карточки «Настройки плагинов»: значения из канона
44
- // (issue #19), цвета только переменными темы.
45
+ // Plugin-settings card matches the core card pattern; theme variables only.
45
46
  '.dts-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}' +
46
47
  '.dts-head{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}' +
47
48
  '.dts-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}' +
@@ -54,7 +55,7 @@ window.__ModuleLoader__.load({
54
55
  '.dts-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px}' +
55
56
  '.dts-foot{border-top:1px solid var(--dsw-alias-border-l2);display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px}' +
56
57
  '.dts-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:5px 14px;font-size:13px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}' +
57
- // Помощники внутренней формы (цепочка провайдеров).
58
+ // Provider-chain form helpers.
58
59
  '.dts-entry{display:flex;flex-direction:column;gap:8px;padding:10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px}' +
59
60
  '.dts-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}' +
60
61
  '.dts-row .dts-model{flex:1;min-width:120px}' +
@@ -67,20 +68,19 @@ window.__ModuleLoader__.load({
67
68
  '.dts-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0}'
68
69
 
69
70
  const setCssId = 'dsh-tts/settings.module.css'
70
- if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="' + setCssId + '"]')) {
71
+ if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-tts"]')) {
71
72
  const tag = document.createElement('style')
72
73
  tag.textContent = SET_CSS
73
- tag.setAttribute('data-plugin', 'dsh-tts')
74
+ tag.setAttribute('data-dsh-plugin', 'dsh-tts')
74
75
  tag.dataset.pluginCss = setCssId
75
76
  document.head.appendChild(tag)
76
77
  }
77
78
 
78
- // Проигрыватель.
79
+ // Player.
79
80
  //
80
- // Раньше каждый пришедший кусок немедленно вытеснял предыдущий: при чтении
81
- // ответа целиком это было незаметно, а при чтении по ходу слышался бы
82
- // только последний. Теперь это очередь: следующее начинается, когда
83
- // закончилось предыдущее.
81
+ // Each incoming chunk used to replace the previous one immediately; while reading
82
+ // For full replies this was invisible; with speak-as-it-goes only the last
83
+ // chunk would be heard. Now items queue: the next starts after the previous ends.
84
84
  const player = {
85
85
  audio: null,
86
86
  after: '',
@@ -111,8 +111,7 @@ window.__ModuleLoader__.load({
111
111
  return player
112
112
  }
113
113
 
114
- // Короткий сигнал рисуем сами: файл не нужен, значит нечего качать,
115
- // хранить и настраивать.
114
+ // Short chime is synthesized in-browser: no file to download, store, or configure.
116
115
  function playChime(kind) {
117
116
  return new Promise((resolve) => {
118
117
  try {
@@ -139,8 +138,8 @@ window.__ModuleLoader__.load({
139
138
  })
140
139
  }
141
140
 
142
- // Провайдер не смог — читаем голосом браузера. Хуже по звучанию, но
143
- // молчание хуже вдвойне: человек не узнает, что ответ готов.
141
+ // Provider failed — fall back to the browser voice. Worse quality, but silence
142
+ // is worse: the user would not know the reply is ready.
144
143
  function speakInBrowser(text) {
145
144
  return new Promise((resolve) => {
146
145
  try {
@@ -154,24 +153,47 @@ window.__ModuleLoader__.load({
154
153
  })
155
154
  }
156
155
 
156
+ function prepareAudioItem(item) {
157
+ if (!item || !item.audioBase64 || item.prepared) return item
158
+ try {
159
+ const bin = atob(item.audioBase64)
160
+ const bytes = new Uint8Array(bin.length)
161
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
162
+ const blob = new Blob([bytes], { type: item.mime || 'audio/mpeg' })
163
+ const url = URL.createObjectURL(blob)
164
+ const audio = new Audio(url)
165
+ audio.preload = 'auto'
166
+ item.prepared = { audio, url }
167
+ } catch (err) { /* decode error */ }
168
+ return item
169
+ }
170
+
171
+ function prebufferNext() {
172
+ for (const next of player.queue) {
173
+ if (next && next.audioBase64 && !next.prepared) {
174
+ prepareAudioItem(next)
175
+ break
176
+ }
177
+ }
178
+ }
179
+
157
180
  function playAudio(item) {
158
181
  return new Promise((resolve) => {
159
182
  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)
183
+ prepareAudioItem(item)
184
+ if (!item.prepared) return resolve()
185
+ const { audio, url } = item.prepared
166
186
  audio.playbackRate = Math.max(0.5, Math.min(2, player.rate || 1))
167
187
  player.audio = audio
168
188
  playerChanged()
189
+ prebufferNext()
169
190
  let doneCalled = false
170
191
  const done = () => {
171
192
  if (doneCalled) return
172
193
  doneCalled = true
173
194
  currentAudioResolve = null
174
- URL.revokeObjectURL(url)
195
+ try { URL.revokeObjectURL(url) } catch (_) {}
196
+ item.prepared = null
175
197
  player.audio = null
176
198
  playerChanged()
177
199
  resolve()
@@ -180,8 +202,7 @@ window.__ModuleLoader__.load({
180
202
  audio.onended = done
181
203
  audio.onerror = done
182
204
  audio.play().catch(() => {
183
- // Браузер не даёт играть без действия человека — не считаем это
184
- // поломкой, просто ничего не звучит.
205
+ // Autoplay without a user gesture is blocked — not a bug; just no sound.
185
206
  done()
186
207
  })
187
208
  } catch (cannotDecode) {
@@ -211,6 +232,12 @@ window.__ModuleLoader__.load({
211
232
  }
212
233
 
213
234
  function stopPlayback() {
235
+ for (const item of player.queue) {
236
+ if (item && item.prepared && item.prepared.url) {
237
+ try { URL.revokeObjectURL(item.prepared.url) } catch (_) {}
238
+ item.prepared = null
239
+ }
240
+ }
214
241
  player.queue.length = 0
215
242
  player.paused = false
216
243
  if (currentAudioResolve) {
@@ -243,11 +270,10 @@ window.__ModuleLoader__.load({
243
270
  }
244
271
  }
245
272
 
246
- // Человек заговорил — замолкаем.
273
+ // User started talking — stop playback.
247
274
  //
248
- // Событие приходит из плагина голосового ввода, но никакой связи с ним
249
- // нет: если его не установили, событие просто никогда не придёт. Слушаем
250
- // окно, а не чужую службу, ровно поэтому.
275
+ // The event may come from a voice-input plugin, but there is no hard dependency:
276
+ // without that plugin the event simply never fires. We listen on window on purpose.
251
277
  function listenForVoice() {
252
278
  if (typeof window === 'undefined') return () => {}
253
279
  const onSpeaking = (event) => {
@@ -277,8 +303,8 @@ window.__ModuleLoader__.load({
277
303
  const data = await res.json()
278
304
  const items = data && Array.isArray(data.items) ? data.items : []
279
305
  for (const item of items) {
280
- // Место в очереди занято, но синтез ещё идёт — подождём следующего
281
- // опроса, иначе порядок фраз рассыплется.
306
+ // Queue slot reserved but synthesis still running — wait for the next poll
307
+ // or phrase order would break.
282
308
  if (item.kind === 'reserved') break
283
309
  player.after = item.id
284
310
  if (seenIds.has(item.id)) continue
@@ -294,7 +320,7 @@ window.__ModuleLoader__.load({
294
320
  } catch (hostRestarting) { /* хост перезапускается */ }
295
321
  }
296
322
 
297
- // Недавние озвучки: тексты последних реплик и избранное (localStorage).
323
+ // Recent utterances: last reply texts and favorites (localStorage).
298
324
  function recentStore() {
299
325
  const KEY = 'dsh-tts/recent'
300
326
  let st
@@ -320,7 +346,7 @@ window.__ModuleLoader__.load({
320
346
  }
321
347
  const recent = recentStore()
322
348
 
323
- // Разовое проигрывание вне очереди: реплай из списка недавних.
349
+ // One-shot playback outside the queue: replay from recents.
324
350
  function playStandalone(text) {
325
351
  fetch('/dsh-tts/speak', {
326
352
  method: 'POST',
@@ -341,7 +367,7 @@ window.__ModuleLoader__.load({
341
367
  }
342
368
 
343
369
  function ChainEditor(props) {
344
- // Переводчик приходит от карточки: слот отдаёт props.t только ей.
370
+ // Translator comes from the card: the slot passes props.t only to it.
345
371
  const t = props.t || ((key) => key)
346
372
  const rows = Array.isArray(props.value) ? props.value : []
347
373
  const change = (i, patch) => {
@@ -408,9 +434,8 @@ window.__ModuleLoader__.load({
408
434
  )
409
435
  }
410
436
 
411
- // Строки карточки живут в реестре локалей: так их переводит отдельный
412
- // пакет, не трогая код этого плагина. Английский — язык по умолчанию,
413
- // на него же приходится откат, если перевода нет.
437
+ // Card strings live in the locale registry so a separate package can translate
438
+ // them without touching this plugin. English is the source language and fallback.
414
439
  const en = {
415
440
  'general': 'General',
416
441
  'loading': 'Loading\u2026',
@@ -644,7 +669,7 @@ window.__ModuleLoader__.load({
644
669
  'removeRegex': 'Регулярка вырезания',
645
670
  'removeRegexHint': 'Глобальная регулярка: совпадения удаляются перед синтезом.',
646
671
  'customBaseUrl': 'Свой TTS endpoint',
647
- 'customBaseUrlHint': 'OpenAI-совместимый адрес, например http://localhost:8880/v1; пусто = выключен.',
672
+ 'customBaseUrlHint': 'OpenAI-compatible base URL, e.g. http://localhost:8880/v1; empty disables it.',
648
673
  'customKeyEnv': 'Имя ключа для endpoint',
649
674
  'customKeyEnvHint': 'Имя credential для Bearer-ключа своего endpointа.',
650
675
 
@@ -775,7 +800,7 @@ window.__ModuleLoader__.load({
775
800
  }
776
801
 
777
802
  function TtsSection(props) {
778
- // Переводчик приходит от слота, потому что в его записи указан locale.
803
+ // Translator comes from the slot because its registration sets locale.
779
804
  const t = (props && props.t) || ((key) => key)
780
805
  const [draft, setDraft] = React.useState(null)
781
806
  const [credentials, setCredentials] = React.useState({})
@@ -801,12 +826,12 @@ window.__ModuleLoader__.load({
801
826
  return () => { alive = false }
802
827
  }, [scope])
803
828
 
804
- // Статус снимка настроек важнее самого значения:
805
- // loading — ответа хоста ещё нет;
806
- // unavailable — хост ответил, но пространство настроек пока не готово;
807
- // ready — значения на месте.
808
- // При unavailable поле writable берётся из документа и остаётся true,
809
- // поэтому без проверки статуса карточка рисует пустую, но с виду рабочую форму.
829
+ // Snapshot status matters more than the value:
830
+ // loading — host has not answered yet;
831
+ // unavailable — host answered but the settings namespace is not ready;
832
+ // ready — values are present.
833
+ // On unavailable, writable defaults true; without a status check the card
834
+ // looks like a working empty form.
810
835
  const ready = !scope || (!!snap && snap.status === 'ready')
811
836
  const writable = !scope ? true : (ready && snap.writable !== false)
812
837
 
@@ -946,7 +971,7 @@ window.__ModuleLoader__.load({
946
971
  }),
947
972
  React.createElement('span', { className: 'dts-sub' }, hint))
948
973
 
949
- // В карточке заголовок рисует шапка — свой блок не дублируем.
974
+ // The card header draws the title — do not duplicate a second title block.
950
975
  const titleLine = props && props.compact ? null : React.createElement('div', { className: 'dts-h' }, t('title'))
951
976
  return React.createElement('div', { className: 'dts-wrap' },
952
977
  React.createElement('div', { className: 'dts-block' },
@@ -1096,7 +1121,7 @@ window.__ModuleLoader__.load({
1096
1121
  { from: 'RAM', to: 'рам', whole: true, lang: 'ru' },
1097
1122
  ]
1098
1123
 
1099
- // Редактор словаря произношения: таблица правил сверху вниз.
1124
+ // Pronunciation dictionary editor: rules apply top-down.
1100
1125
  function PronEditor(props) {
1101
1126
  const t = props.t || ((key) => key)
1102
1127
  const rows = Array.isArray(props.value) ? props.value : []
@@ -1229,7 +1254,7 @@ window.__ModuleLoader__.load({
1229
1254
  style: {
1230
1255
  padding: '10px 14px',
1231
1256
  borderRadius: '8px',
1232
- border: '1px solid var(--dsw-alias-state-warning-primary, #eab308)',
1257
+ border: '1px solid var(--dsw-alias-state-warning-primary)',
1233
1258
  background: 'var(--dsw-alias-bg-layer-2)',
1234
1259
  display: 'flex',
1235
1260
  flexDirection: 'column',
@@ -1237,7 +1262,7 @@ window.__ModuleLoader__.load({
1237
1262
  fontSize: '13px',
1238
1263
  },
1239
1264
  },
1240
- React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary, #eab308)' } },
1265
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary)' } },
1241
1266
  '⚠️ ' + t('voiceNotInstalledTitle')
1242
1267
  ),
1243
1268
  React.createElement('span', { className: 'dts-sub' },
@@ -1271,7 +1296,7 @@ window.__ModuleLoader__.load({
1271
1296
  style: {
1272
1297
  padding: '10px 14px',
1273
1298
  borderRadius: '8px',
1274
- border: '1px solid var(--dsw-alias-state-warning-primary, #eab308)',
1299
+ border: '1px solid var(--dsw-alias-state-warning-primary)',
1275
1300
  background: 'var(--dsw-alias-bg-layer-2)',
1276
1301
  display: 'flex',
1277
1302
  flexDirection: 'column',
@@ -1279,7 +1304,7 @@ window.__ModuleLoader__.load({
1279
1304
  fontSize: '13px',
1280
1305
  },
1281
1306
  },
1282
- React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary, #eab308)' } },
1307
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary)' } },
1283
1308
  '⚠️ ' + t('messengerNotInstalledTitle')
1284
1309
  ),
1285
1310
  React.createElement('span', { className: 'dts-sub' },
@@ -1301,7 +1326,7 @@ window.__ModuleLoader__.load({
1301
1326
  )
1302
1327
  }
1303
1328
 
1304
- // Панель статистики синтеза: живые счётчики хоста.
1329
+ // Synthesis stats panel: live host counters.
1305
1330
  function StatsPanel(props) {
1306
1331
  const t = props.t || ((key) => key)
1307
1332
  const [data, setData] = React.useState(null)
@@ -1322,9 +1347,8 @@ window.__ModuleLoader__.load({
1322
1347
  )
1323
1348
  }
1324
1349
 
1325
- // Карточка во вкладке «Настройки плагинов» — разметка и стили по
1326
- // единому канону (issue #19): li в списке ядра, шапка со сбросом темы,
1327
- // тело с чертой-разделителем. Шеврон — наш, ядро его не даёт.
1350
+ // Plugin-settings tab card follows the shared card pattern: li in the core list,
1351
+ // theme-reset header, body with a divider. Chevron is ours; core does not provide it.
1328
1352
  function TtsCard(props) {
1329
1353
  const t = (props && props.t) || ((key) => key)
1330
1354
  const [open, setOpen] = React.useState(false)
@@ -1357,12 +1381,10 @@ window.__ModuleLoader__.load({
1357
1381
  )
1358
1382
  }
1359
1383
 
1360
- // Кнопка в строке ввода: видно, что сейчас читается, можно остановить и
1361
- // открыть недавние озвучки для реплая.
1384
+ // Input-dock button: shows what is playing, stop control, and recents for replay.
1362
1385
  function SpeakerControl(props) {
1363
- // Слот с `locale` кладёт перевод в props; запасной путь — привязка,
1364
- // сделанная при регистрации. Второй нужен на случай, если слот перевод
1365
- // не донесёт: без него кнопки остались бы без подписей вовсе.
1386
+ // A slot with `locale` injects translations into props; the fallback is the
1387
+ // binder created at registration, in case the slot drops translations.
1366
1388
  const t = (props && props.t) || fallbackDockText
1367
1389
  const p = usePlayer()
1368
1390
  const [showList, setShowList] = React.useState(false)
@@ -1421,8 +1443,8 @@ window.__ModuleLoader__.load({
1421
1443
  let fallbackDockText = (key) => key
1422
1444
 
1423
1445
  function registerSpeaker(ctx) {
1424
- // `locale` в описании слота — то, чем ядро кладёт перевод в props
1425
- // компонента. Без него внутри дока не было бы ничего, кроме ключей.
1446
+ // `locale` in the slot descriptor is how core injects translations into props.
1447
+ // Without it the dock would show raw keys only.
1426
1448
  fallbackDockText = ctx.locale.bind(NS)
1427
1449
  ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
1428
1450
  {
@@ -1437,14 +1459,10 @@ window.__ModuleLoader__.load({
1437
1459
  }
1438
1460
 
1439
1461
  function registerSettings(ctx) {
1440
- // Язык может принести не только плагин: словарные пакеты объявляют
1441
- // русский для чужих пространств. Ядро на повторное объявление той же
1442
- // пары «пространство + язык» бросает исключение, и незащищённый вызов
1443
- // уносил с собой весь плагин — в интерфейсе это выглядело как «Failed to
1444
- // load plugins» с перечнем ни в чём не повинных соседей.
1445
- //
1446
- // Поэтому каждый язык объявляется отдельно и по-хорошему: заняли до нас —
1447
- // уступаем, свой английский при этом всё равно встаёт на место.
1462
+ // Dictionary packages may also register languages for other namespaces.
1463
+ // Core throws on duplicate namespace+language; an unguarded call used to
1464
+ // take down the whole plugin ("Failed to load plugins"). Register each
1465
+ // language separately: skip if taken; English still lands.
1448
1466
  const addLocale = (locale, dictionary) => {
1449
1467
  try {
1450
1468
  return ctx.locale.register(NS, locale, dictionary)
@@ -1456,13 +1474,12 @@ window.__ModuleLoader__.load({
1456
1474
  const undo = [addLocale('en', en), addLocale('ru', ru)]
1457
1475
  return () => { for (const off of undo) off() }
1458
1476
  }, 'dsh-tts: словари')
1459
- // Подпись раздела рисует боковой список, а не наш компонент: props.t
1460
- // туда не доходит, поэтому берём переводчик, привязанный к namespace.
1477
+ // The sidebar draws the section label, not our component: props.t never
1478
+ // reaches it, so bind the translator to the namespace ourselves.
1461
1479
  const t = ctx.locale.bind(NS)
1462
- // Штатное место настроек — карточка во вкладке «Настройки плагинов».
1463
- // Вкладка перебирает пространства настроек и ищет слот по entryKey,
1464
- // равному имени пространства: ключ обязан равняться NS, иначе карточка
1465
- // не появится молча, без единой ошибки в журнале.
1480
+ // Primary settings home is the plugin-settings tab card. The tab looks up
1481
+ // a slot by entryKey equal to the namespace name: key must equal NS or
1482
+ // the card never appears, with no log error.
1466
1483
  const tryPluginItem = () => {
1467
1484
  try {
1468
1485
  const res = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
@@ -1481,8 +1498,8 @@ window.__ModuleLoader__.load({
1481
1498
  }
1482
1499
  }
1483
1500
  if (tryPluginItem()) return
1484
- // Запасной путь для сборок без settings.plugin.item: прежний раздел
1485
- // в боковом списке, чтобы настройки не пропали.
1501
+ // Fallback for builds without settings.plugin.item: keep the sidebar section
1502
+ // so settings cannot disappear.
1486
1503
  ctx.slots.inject('settings.section', () => ctx.slots.register(
1487
1504
  {
1488
1505
  name: 'settings.section',
package/lib/engines/f5.js CHANGED
@@ -1,5 +1,32 @@
1
1
  import { spawn } from 'node:child_process'
2
2
 
3
+ function collectPing(pythonBin, daemonPath) {
4
+ return new Promise((resolve) => {
5
+ const proc = spawn(pythonBin, [daemonPath, '--ping'])
6
+ let out = ''
7
+ let settled = false
8
+ const done = (ok) => {
9
+ if (settled) return
10
+ settled = true
11
+ resolve(ok)
12
+ }
13
+ proc.stdout.on('data', (c) => { out += c })
14
+ proc.on('error', () => done(false))
15
+ proc.on('close', (code) => {
16
+ if (code !== 0) {
17
+ done(false)
18
+ return
19
+ }
20
+ try {
21
+ const res = JSON.parse(out)
22
+ done(!!res.ok)
23
+ } catch {
24
+ done(false)
25
+ }
26
+ })
27
+ })
28
+ }
29
+
3
30
  export class F5Engine {
4
31
  constructor({ daemonPath = 'scripts/f5_daemon.py', pythonBin = 'python3', modelPath = '' } = {}) {
5
32
  this.daemonPath = daemonPath
@@ -7,39 +34,24 @@ export class F5Engine {
7
34
  this.modelPath = modelPath
8
35
  }
9
36
 
37
+ /** Resolve true only after a successful daemon handshake. */
10
38
  async ping() {
11
- return new Promise((resolve) => {
12
- // Try spawning python with daemonPath --ping
13
- const proc = spawn(this.pythonBin, [this.daemonPath, '--ping'])
14
- let out = ''
15
- proc.stdout.on('data', (c) => { out += c })
16
- proc.on('error', () => {
17
- // Fallback to 'python' if 'python3' is not found (e.g. on Windows)
18
- if (this.pythonBin === 'python3') {
19
- const fallback = spawn('python', [this.daemonPath, '--ping'])
20
- let fallbackOut = ''
21
- fallback.stdout.on('data', (c) => { fallbackOut += c })
22
- fallback.on('error', () => resolve(true)) // In test environment without python, return true
23
- fallback.on('close', (code) => {
24
- try {
25
- const res = JSON.parse(fallbackOut)
26
- resolve(!!res.ok)
27
- } catch {
28
- resolve(code === 0)
29
- }
30
- })
31
- return
32
- }
33
- resolve(true)
34
- })
35
- proc.on('close', (code) => {
36
- try {
37
- const res = JSON.parse(out)
38
- resolve(!!res.ok)
39
- } catch {
40
- resolve(code === 0)
41
- }
42
- })
43
- })
39
+ const primary = await collectPing(this.pythonBin, this.daemonPath)
40
+ if (primary) return true
41
+ if (this.pythonBin === 'python3') {
42
+ return collectPing('python', this.daemonPath)
43
+ }
44
+ return false
45
+ }
46
+
47
+ isRuntimeAvailable() {
48
+ return false
49
+ }
50
+
51
+ async synthesize() {
52
+ throw new Error(
53
+ 'F5-TTS runtime is not bundled in @goodandready/dsh-tts. ' +
54
+ 'Use Edge, Piper, or eSpeak for offline TTS.',
55
+ )
44
56
  }
45
57
  }
@@ -1,5 +1,13 @@
1
1
  import fs from 'node:fs'
2
2
 
3
+ /**
4
+ * Local Kokoro engine wrapper.
5
+ *
6
+ * Real ONNX inference is intentionally not bundled in this package: shipping
7
+ * onnxruntime + voice tensors would blow the DSH Store size budget and couple
8
+ * the plugin to native binaries. Until a supported external runtime is wired,
9
+ * synthesis must fail honestly — never emit a synthetic tone.
10
+ */
3
11
  export class KokoroEngine {
4
12
  constructor({ modelPath = '' } = {}) {
5
13
  this.modelPath = modelPath
@@ -10,39 +18,23 @@ export class KokoroEngine {
10
18
  return !!(this.modelPath && fs.existsSync(this.modelPath))
11
19
  }
12
20
 
13
- /**
14
- * Synthesizes audio stream yielding Float32Array PCM chunks (24kHz).
15
- * @param {string} text
16
- * @param {string} voice
17
- * @returns {AsyncGenerator<Float32Array>}
18
- */
19
- async *synthesize(text, voice = 'af_bella') {
20
- if (!text || !text.trim()) return
21
+ /** True only when a real inference backend can produce speech. */
22
+ isRuntimeAvailable() {
23
+ return false
24
+ }
21
25
 
22
- // Generates 1s chunk of PCM frames (24000 samples for test/stub)
23
- const samples = Math.max(2400, Math.min(24000, text.length * 800))
24
- const chunk = new Float32Array(samples)
25
- for (let i = 0; i < samples; i++) {
26
- // Soft synthetic sine wave envelope so it does not pop
27
- chunk[i] = Math.sin((i * 440 * 2 * Math.PI) / this.sampleRate) * 0.2 * (1 - i / samples)
28
- }
29
- yield chunk
26
+ async *synthesize() {
27
+ throw new Error(
28
+ 'Kokoro ONNX runtime is not bundled in @goodandready/dsh-tts. ' +
29
+ 'Install weights does not enable speech. Use Edge, Piper, or eSpeak for offline TTS.',
30
+ )
30
31
  }
31
32
 
32
- async synthesizeWav(text, voice = 'af_bella') {
33
- const chunks = []
34
- for await (const chunk of this.synthesize(text, voice)) {
35
- chunks.push(chunk)
36
- }
37
- let totalLen = 0
38
- for (const c of chunks) totalLen += c.length
39
- const merged = new Float32Array(totalLen)
40
- let offset = 0
41
- for (const c of chunks) {
42
- merged.set(c, offset)
43
- offset += c.length
33
+ async synthesizeWav() {
34
+ for await (const _ of this.synthesize()) {
35
+ // unreachable: synthesize always throws
44
36
  }
45
- return pcmFloat32ToWav(merged, this.sampleRate)
37
+ throw new Error('Kokoro synthesis unavailable')
46
38
  }
47
39
  }
48
40