@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 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
- 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)
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 providers = makeProviders(
407
- { resolveKey, fetchImpl: fetch, cfg, modelManager },
408
- { text, lang: cfg.autoDetect ? detectLang(text) : cfg.language, signal, models, voices, role },
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
- return out
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 res = await fetchImpl((cfg.openaiBaseUrl || 'https://api.openai.com/v1') + '/audio/speech', {
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 res = await fetchImpl(base + '/audio/speech', {
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 res = await fetchImpl(base + '/chat/completions', {
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 res = await fetchImpl(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, {
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 res = await fetchImpl(
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 res = await fetchImpl(`https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`, {
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 res = await fetchImpl('https://api.groq.com/openai/v1/audio/speech', {
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 res = await fetchImpl(`https://api.deepgram.com/v1/speak?model=${encodeURIComponent(model)}`, {
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 res = await fetchImpl('https://openrouter.ai/api/v1/audio/speech', {
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 source = String(text || '').trim()
165
- if (!source) return []
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.22",
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
+ }
@@ -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
@@ -1,4 +0,0 @@
1
- export class AdaptiveRouter {
2
- constructor({registry, role, lang}){ this.registry=registry; this.role=role; this.lang=lang }
3
- pick(){ const list=this.registry.list(); return list[0]?.id || null }
4
- }