@goodandready/dsh-tts 0.4.0 → 0.4.2

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/README.md CHANGED
@@ -187,6 +187,16 @@ dsh-tts:
187
187
 
188
188
  ---
189
189
 
190
+ ### Settings UI coverage
191
+
192
+ The plugin settings card (**Settings → Plugins → Plugin settings**) exposes every user-facing schema field, including an **Advanced** block for `maxChars`, `sentenceChars`, `timeoutMs`, `maxQueue`, `openaiBaseUrl`, `mimoBaseUrl`, `mimoFormat`, and `minimaxBin`.
193
+
194
+ Provider **API keys are never stored in plugin settings**. Paste them in the chain editor; values go to the DSH credential store via `PUT /dsh-tts/credential`.
195
+
196
+ Config-only (not in the card): `*KeyEnv` fields (`openaiKeyEnv`, `elevenlabsKeyEnv`, …). They only rename the credential slot the plugin looks up. Change them in `settings.yaml` if you must rebind a key name; the defaults match the usual environment variable names.
197
+
198
+ The sidebar `settings.section` entry is a **fallback** and is registered only when `settings.plugin.item` is missing from the build.
199
+
190
200
  ## 🤖 HTTP Endpoints Reference
191
201
 
192
202
  * `GET /dsh-tts/stream` — Real-time Server-Sent Events (SSE) audio streaming.
package/README.ru.md CHANGED
@@ -180,6 +180,16 @@ dsh-tts:
180
180
 
181
181
  ---
182
182
 
183
+ ### Покрытие настроек в UI
184
+
185
+ Карточка плагина (**Настройки → Плагины → Настройки плагинов**) показывает все пользовательские поля схемы, включая блок **Дополнительно**: `maxChars`, `sentenceChars`, `timeoutMs`, `maxQueue`, `openaiBaseUrl`, `mimoBaseUrl`, `mimoFormat`, `minimaxBin`.
186
+
187
+ **API-ключи не хранятся в настройках плагина.** Вставляйте их в редакторе цепочки — значение уходит в credential store DSH через `PUT /dsh-tts/credential`.
188
+
189
+ Только в конфиге (нет в карточке): поля `*KeyEnv` (`openaiKeyEnv`, `elevenlabsKeyEnv`, …). Они задают **имя** credential-слота; по умолчанию совпадают с привычными именами env-переменных. Меняйте в `settings.yaml`, если нужно перевязать имя ключа.
190
+
191
+ Пункт `settings.section` в боковом меню — **fallback** и регистрируется, только если в сборке нет `settings.plugin.item`.
192
+
183
193
  ## 🤖 HTTP роуты API
184
194
 
185
195
  * `GET /dsh-tts/stream` — Потоковая передача аудио по SSE в реальном времени.
package/README.zh.md CHANGED
@@ -139,6 +139,14 @@ dsh plugin --profile web add @goodandready/dsh-tts
139
139
 
140
140
  ---
141
141
 
142
+ ### 设置界面覆盖
143
+
144
+ 插件设置卡片(**设置 → 插件 → 插件设置**)展示全部用户向 schema 字段,并含 **Advanced** 区:`maxChars`、`sentenceChars`、`timeoutMs`、`maxQueue`、`openaiBaseUrl`、`mimoBaseUrl`、`mimoFormat`、`minimaxBin`。
145
+
146
+ API 密钥不写入插件设置,只在链编辑器粘贴,经 `PUT /dsh-tts/credential` 写入 DSH credential store。
147
+
148
+ 仅配置文件:`*KeyEnv` 字段(凭证槽位名称)。侧边栏 `settings.section` 仅为 fallback。
149
+
142
150
  ## ⚙️ 配置示例(`settings.yaml`)
143
151
 
144
152
  ```yaml
package/lib/breaker.js ADDED
@@ -0,0 +1,64 @@
1
+ // Soft circuit breaker for provider fallback (runtime state only).
2
+ // Tracks consecutive failures per chain id and skips a provider during cooldown.
3
+
4
+ const DEFAULT_THRESHOLD = 3
5
+ const DEFAULT_COOLDOWN_MS = 60000
6
+
7
+ export function createProviderBreaker(options = {}) {
8
+ const threshold = options.threshold || DEFAULT_THRESHOLD
9
+ const cooldownMs = options.cooldownMs || DEFAULT_COOLDOWN_MS
10
+ const now = options.now || (() => Date.now())
11
+ const state = new Map()
12
+
13
+ function slot(id) {
14
+ let s = state.get(id)
15
+ if (!s) {
16
+ s = { failCount: 0, okCount: 0, lastError: '', lastFailAt: 0, openUntil: 0 }
17
+ state.set(id, s)
18
+ }
19
+ return s
20
+ }
21
+
22
+ function isOpen(id) {
23
+ const s = slot(id)
24
+ return s.openUntil > now()
25
+ }
26
+
27
+ function recordFailure(id, error) {
28
+ const s = slot(id)
29
+ s.failCount += 1
30
+ s.lastError = String((error && error.message) || error || 'failure').slice(0, 240)
31
+ s.lastFailAt = now()
32
+ if (s.failCount >= threshold) {
33
+ s.openUntil = now() + cooldownMs
34
+ }
35
+ }
36
+
37
+ function recordSuccess(id) {
38
+ const s = slot(id)
39
+ s.okCount += 1
40
+ s.failCount = 0
41
+ s.openUntil = 0
42
+ }
43
+
44
+ function snapshot(ids) {
45
+ const list = Array.isArray(ids) ? ids : [...state.keys()]
46
+ return list.map((id) => {
47
+ const s = slot(id)
48
+ return {
49
+ id,
50
+ failCount: s.failCount,
51
+ okCount: s.okCount,
52
+ lastError: s.lastError,
53
+ open: isOpen(id),
54
+ openUntil: s.openUntil || 0,
55
+ }
56
+ })
57
+ }
58
+
59
+ function reset() {
60
+ state.clear()
61
+ }
62
+
63
+ return { isOpen, recordFailure, recordSuccess, snapshot, reset, threshold, cooldownMs }
64
+ }
package/lib/cache.js CHANGED
@@ -35,7 +35,7 @@ export function createSpeechCache({ root, maxBytes }) {
35
35
  try {
36
36
  const meta = JSON.parse(await fs.promises.readFile(path.join(dir, name.slice(0, -AUDIO_EXT.length) + '.json'), 'utf8'))
37
37
  if (meta && typeof meta.at === 'number') at = meta.at
38
- } catch (noMeta) { /* старьё без меты — вытесним первым */ }
38
+ } catch (noMeta) { /* legacy entry without meta — evict as first */ }
39
39
  out.push({ full, meta: name.slice(0, -AUDIO_EXT.length) + '.json', size: st.size, at })
40
40
  }
41
41
  return out
@@ -0,0 +1,85 @@
1
+ window.__ModuleLoader__.load({
2
+ id: '@goodandready/dsh-tts',
3
+ factory: (require) => {
4
+ var module = { exports: {} }
5
+ var exports = module.exports
6
+ const React = require('react')
7
+
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.
10
+ let ChevronIcon = null
11
+ try {
12
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
13
+ ChevronIcon = primitives && primitives.IconChevronDownOutline14
14
+ } catch (noPrimitives) {
15
+ ChevronIcon = null
16
+ }
17
+ const NS = 'dsh-tts'
18
+ const PROVIDERS = [
19
+ 'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter',
20
+ 'edge', 'piper', 'espeak',
21
+ ]
22
+ const CLOUD = {
23
+ openai: 1, elevenlabs: 1, google: 1, azure: 1, groq: 1, deepgram: 1, openrouter: 1, custom: 1,
24
+ }
25
+ // Hints stay generic: concrete provider model IDs rot quickly.
26
+ const MODEL_HINT = {
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
+ piper: 'path to .onnx',
36
+ espeak: 'language code, e.g. ru',
37
+ kokoro: 'not bundled — offline neural runtime unavailable',
38
+ f5: 'not bundled — offline neural runtime unavailable',
39
+ }
40
+
41
+
42
+ function createErrorBoundary() {
43
+ if (!React || typeof React.Component !== 'function') {
44
+ return function NoopBoundary(props) { return (props && props.children) || null }
45
+ }
46
+ return class ErrorBoundary extends React.Component {
47
+ constructor(props) {
48
+ super(props)
49
+ this.state = { hasError: false, error: null }
50
+ }
51
+ static getDerivedStateFromError(error) {
52
+ return { hasError: true, error }
53
+ }
54
+ componentDidCatch(error, errorInfo) {
55
+ console.error('[dsh-tts] React Error:', error, errorInfo)
56
+ }
57
+ render() {
58
+ if (this.state.hasError) {
59
+ return React.createElement(
60
+ 'div',
61
+ {
62
+ className: 'dts-alert dts-alert-err',
63
+ style: { margin: '12px 0', padding: '14px', borderRadius: '8px' },
64
+ },
65
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ TTS UI Error:'),
66
+ React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String((this.state.error && this.state.error.message) || this.state.error)),
67
+ React.createElement(
68
+ 'button',
69
+ {
70
+ type: 'button',
71
+ className: 'dts-btn',
72
+ style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
73
+ onClick: () => this.setState({ hasError: false, error: null }),
74
+ },
75
+ 'Retry',
76
+ ),
77
+ )
78
+ }
79
+ return (this.props && this.props.children) || null
80
+ }
81
+ }
82
+ }
83
+ const ErrorBoundary = createErrorBoundary()
84
+ const SNAPSHOT_READY = Object.freeze({ status: 'ready', value: {} })
85
+ const SNAPSHOT_LOADING = Object.freeze({ status: 'loading', value: {} })
@@ -0,0 +1,54 @@
1
+
2
+ const SET_CSS =
3
+ '.dts-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:900px}' +
4
+ '.dts-header{display:flex;flex-direction:column;gap:8px;padding-bottom:14px;border-bottom:1px solid var(--dsw-alias-border-l2)}' +
5
+ '.dts-page-title{font-size:20px;font-weight:700;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:10px}' +
6
+ '.dts-page-sub{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}' +
7
+ '.dts-block{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:12px}' +
8
+ '.dts-h{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}' +
9
+ '.dts-sub{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4}' +
10
+ '.dts-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}' +
11
+ '.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}' +
12
+ '.dts-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}' +
13
+ '.dts-title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
14
+ '.dts-chev{margin-left:auto;color:var(--dsw-alias-label-secondary);flex:none;transition:transform .16s}' +
15
+ '.dts-chevOpen{transform:rotate(180deg)}' +
16
+ '.dts-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:12px 0}' +
17
+ '.dts-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
18
+ '.dts-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:13px;box-sizing:border-box}' +
19
+ '.dts-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
20
+ '.dts-foot{border-top:1px solid var(--dsw-alias-border-l2);display:flex;justify-content:flex-end;align-items:center;gap:10px;padding:14px 0 4px}' +
21
+ '.dts-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:6px 16px;font-size:13px;font-weight:500;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);transition:all .15s ease}' +
22
+ '.dts-save:hover:not(:disabled){opacity:0.88}' +
23
+ '.dts-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 12px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}' +
24
+ '.dts-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}' +
25
+ '.dts-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}' +
26
+ '.dts-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 50%, transparent)}' +
27
+ '.dts-entry{display:flex;flex-direction:column;gap:8px;padding:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}' +
28
+ '.dts-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}' +
29
+ '.dts-row .dts-model{flex:1;min-width:120px}' +
30
+ '.dts-row .dts-key{flex:1;min-width:180px}' +
31
+ '.dts-mini{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:6px;width:28px;height:28px;cursor:pointer;flex:none;display:inline-flex;align-items:center;justify-content:center;font-size:13px;transition:all .15s ease}' +
32
+ '.dts-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2))}' +
33
+ '.dts-ok{font-size:12px;color:var(--dsw-alias-state-success-primary);font-weight:500}' +
34
+ '.dts-bad{font-size:12px;color:var(--dsw-alias-state-error-primary);font-weight:500}' +
35
+ '.dts-badge{font-size:11px;padding:3px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap;display:inline-flex;align-items:center;gap:4px;font-weight:500}' +
36
+ '.dts-badge-on{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}' +
37
+ '.dts-badge-warn{color:var(--dsw-alias-state-warning-primary);border-color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent)}' +
38
+ '.dts-badge-bad{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}' +
39
+ '.dts-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0;text-decoration:underline}' +
40
+ '.dts-link:hover:not(:disabled){color:var(--dsw-alias-label-primary)}' +
41
+ '.dts-alert{padding:10px 14px;border-radius:8px;font-size:13px}' +
42
+ '.dts-alert-err{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 20%, transparent)}' +
43
+ '.dts-alert-warn{background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 10%, transparent);color:var(--dsw-alias-state-warning-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-warning-primary) 20%, transparent)}' +
44
+ '.dts-alert-ok{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);border:1px solid rgba(16,185,129,0.2)}' +
45
+ '.dts-grow{flex:1;min-width:100px}'
46
+
47
+ const setCssId = 'dsh-tts/settings.module.css'
48
+ if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-tts"]')) {
49
+ const tag = document.createElement('style')
50
+ tag.textContent = SET_CSS
51
+ tag.setAttribute('data-dsh-plugin', 'dsh-tts')
52
+ tag.dataset.pluginCss = setCssId
53
+ document.head.appendChild(tag)
54
+ }
@@ -0,0 +1,328 @@
1
+ // Player.
2
+ //
3
+ // Each incoming chunk used to replace the previous one immediately; while reading
4
+ // For full replies this was invisible; with speak-as-it-goes only the last
5
+ // chunk would be heard. Now items queue: the next starts after the previous ends.
6
+ const autoplay = { blocked: false, listeners: new Set() }
7
+
8
+ function setAutoplayBlocked(next) {
9
+ if (autoplay.blocked === next) return
10
+ autoplay.blocked = next
11
+ for (const l of [...autoplay.listeners]) {
12
+ try { l() } catch { /* listener isolation */ }
13
+ }
14
+ }
15
+
16
+ function unlockAudio() {
17
+ try {
18
+ const AC = window.AudioContext || window.webkitAudioContext
19
+ if (AC) {
20
+ const ctx = new AC()
21
+ if (ctx.state === 'suspended') ctx.resume().catch(() => {})
22
+ setTimeout(() => { try { ctx.close() } catch { /* closed */ } }, 300)
23
+ }
24
+ } catch { /* no AudioContext */ }
25
+ setAutoplayBlocked(false)
26
+ drainQueue()
27
+ }
28
+
29
+ function useAutoplayGate() {
30
+ const [, force] = React.useReducer((n) => n + 1, 0)
31
+ React.useEffect(() => {
32
+ autoplay.listeners.add(force)
33
+ return () => { autoplay.listeners.delete(force) }
34
+ }, [])
35
+ return autoplay
36
+ }
37
+
38
+ const player = {
39
+ audio: null,
40
+ after: '',
41
+ enabled: false,
42
+ rate: 1,
43
+ chime: 'ding',
44
+ queue: [],
45
+ bargeIn: true,
46
+ busy: false,
47
+ paused: false,
48
+ listeners: new Set(),
49
+ unlockAudio,
50
+ useAutoplayGate,
51
+ get autoplayBlocked() { return autoplay.blocked },
52
+ }
53
+ const seenIds = new Set()
54
+ let currentAudioResolve = null
55
+ function playerChanged() {
56
+ for (const listener of [...player.listeners]) {
57
+ try { listener() } catch (listenerFailure) { /* foreign listener failure is not ours */ }
58
+ }
59
+ }
60
+
61
+ function usePlayer() {
62
+ const [, force] = React.useReducer((n) => n + 1, 0)
63
+ React.useEffect(() => {
64
+ player.listeners.add(force)
65
+ return () => { player.listeners.delete(force) }
66
+ }, [])
67
+ return player
68
+ }
69
+
70
+ // Short chime is synthesized in-browser: no file to download, store, or configure.
71
+ function playChime(kind) {
72
+ return new Promise((resolve) => {
73
+ try {
74
+ const AC = window.AudioContext || window.webkitAudioContext
75
+ if (!AC) { resolve(); return }
76
+ const audioCtx = new AC()
77
+ const gain = audioCtx.createGain()
78
+ gain.connect(audioCtx.destination)
79
+ const tones = kind === 'beep' ? [880, 880] : [660, 990]
80
+ let at = audioCtx.currentTime
81
+ for (const freq of tones) {
82
+ const osc = audioCtx.createOscillator()
83
+ osc.frequency.value = freq
84
+ osc.connect(gain)
85
+ gain.gain.setValueAtTime(0.0001, at)
86
+ gain.gain.exponentialRampToValueAtTime(0.25, at + 0.02)
87
+ gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.18)
88
+ osc.start(at)
89
+ osc.stop(at + 0.2)
90
+ at += 0.22
91
+ }
92
+ setTimeout(() => { try { audioCtx.close() } catch (already) { /* already closed */ } resolve() }, 600)
93
+ } catch { resolve() /* no AudioContext */ }
94
+ })
95
+ }
96
+
97
+ // Provider failed — fall back to the browser voice. Worse quality, but silence
98
+ // is worse: the user would not know the reply is ready.
99
+ function speakInBrowser(text) {
100
+ return new Promise((resolve) => {
101
+ try {
102
+ if (!window.speechSynthesis || !text) { resolve(); return }
103
+ const utterance = new SpeechSynthesisUtterance(String(text))
104
+ utterance.rate = Math.max(0.5, Math.min(2, player.rate || 1))
105
+ utterance.onend = () => resolve()
106
+ utterance.onerror = () => resolve()
107
+ window.speechSynthesis.speak(utterance)
108
+ } catch { resolve() /* no speechSynthesis */ }
109
+ })
110
+ }
111
+
112
+ function prepareAudioItem(item) {
113
+ if (!item || !item.audioBase64 || item.prepared) return item
114
+ try {
115
+ const bin = atob(item.audioBase64)
116
+ const bytes = new Uint8Array(bin.length)
117
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
118
+ const blob = new Blob([bytes], { type: item.mime || 'audio/mpeg' })
119
+ const url = URL.createObjectURL(blob)
120
+ const audio = new Audio(url)
121
+ audio.preload = 'auto'
122
+ item.prepared = { audio, url }
123
+ } catch { /* decode error — item stays unprepared */ }
124
+ return item
125
+ }
126
+
127
+ function prebufferNext() {
128
+ for (const next of player.queue) {
129
+ if (next && next.audioBase64 && !next.prepared) {
130
+ prepareAudioItem(next)
131
+ break
132
+ }
133
+ }
134
+ }
135
+
136
+ function playAudio(item) {
137
+ return new Promise((resolve) => {
138
+ try {
139
+ prepareAudioItem(item)
140
+ if (!item.prepared) return resolve()
141
+ const { audio, url } = item.prepared
142
+ audio.playbackRate = Math.max(0.5, Math.min(2, player.rate || 1))
143
+ player.audio = audio
144
+ playerChanged()
145
+ prebufferNext()
146
+ let doneCalled = false
147
+ const done = () => {
148
+ if (doneCalled) return
149
+ doneCalled = true
150
+ currentAudioResolve = null
151
+ try { URL.revokeObjectURL(url) } catch { /* URL already revoked */ }
152
+ item.prepared = null
153
+ player.audio = null
154
+ playerChanged()
155
+ resolve()
156
+ }
157
+ currentAudioResolve = done
158
+ audio.onended = done
159
+ audio.onerror = done
160
+ audio.play().catch((playErr) => {
161
+ // Autoplay without a user gesture is blocked — surface unlock UX, then stop this chunk.
162
+ if (playErr && (playErr.name === 'NotAllowedError' || playErr.name === 'SecurityError')) {
163
+ setAutoplayBlocked(true)
164
+ }
165
+ done()
166
+ })
167
+ } catch { /* cannot decode — skip chunk */
168
+ currentAudioResolve = null
169
+ resolve()
170
+ }
171
+ })
172
+ }
173
+
174
+ async function drainQueue() {
175
+ if (player.busy) return
176
+ player.busy = true
177
+ playerChanged()
178
+ try {
179
+ while (player.queue.length) {
180
+ if (player.paused) break
181
+ const item = player.queue.shift()
182
+ if (!item || item.kind === 'reserved') continue
183
+ if (item.kind === 'chime') { await playChime(item.chime || player.chime); continue }
184
+ if (item.audioBase64) await playAudio(item)
185
+ else if (item.error && item.text) await speakInBrowser(item.text)
186
+ }
187
+ } finally {
188
+ player.busy = false
189
+ playerChanged()
190
+ }
191
+ }
192
+
193
+ function stopPlayback() {
194
+ for (const item of player.queue) {
195
+ if (item && item.prepared && item.prepared.url) {
196
+ try { URL.revokeObjectURL(item.prepared.url) } catch { /* already revoked */ }
197
+ item.prepared = null
198
+ }
199
+ }
200
+ player.queue.length = 0
201
+ player.paused = false
202
+ if (player.audio) {
203
+ try { player.audio.pause() } catch { /* already paused */ }
204
+ }
205
+ if (currentAudioResolve) {
206
+ currentAudioResolve()
207
+ }
208
+ player.audio = null
209
+ try { if (window.speechSynthesis) window.speechSynthesis.cancel() } catch { /* no speechSynthesis */ }
210
+ playerChanged()
211
+ }
212
+
213
+ function togglePause() {
214
+ player.paused = !player.paused
215
+ if (player.audio) {
216
+ try { player.paused ? player.audio.pause() : player.audio.play() } catch (already) { /* ignore play/pause failure */ }
217
+ }
218
+ if (!player.paused) drainQueue()
219
+ playerChanged()
220
+ }
221
+
222
+ function skipCurrent() {
223
+ if (currentAudioResolve) {
224
+ currentAudioResolve()
225
+ } else if (player.audio) {
226
+ try { player.audio.pause() } catch (already) { /* already stopped */ }
227
+ player.audio = null
228
+ playerChanged()
229
+ if (player.queue.length) drainQueue()
230
+ }
231
+ }
232
+
233
+ // User started talking — stop playback.
234
+ //
235
+ // The event may come from a voice-input plugin, but there is no hard dependency:
236
+ // without that plugin the event simply never fires. We listen on window on purpose.
237
+ function listenForVoice() {
238
+ if (typeof window === 'undefined') return () => {}
239
+ const onSpeaking = (event) => {
240
+ const phase = event && event.detail && event.detail.phase
241
+ if (phase !== 'start') return
242
+ if (!player.bargeIn) return
243
+ if (!player.audio && !player.queue.length && !player.busy) return
244
+ stopPlayback()
245
+ }
246
+ window.addEventListener('dsh-voice:speaking', onSpeaking)
247
+ return () => window.removeEventListener('dsh-voice:speaking', onSpeaking)
248
+ }
249
+
250
+ async function pollPending() {
251
+ try {
252
+ const st = await fetch('/dsh-tts/status', { cache: 'no-store' })
253
+ if (!st.ok) return
254
+ const meta = await st.json()
255
+ player.enabled = !!(meta && meta.speakReplies)
256
+ if (meta && typeof meta.rate === 'number') player.rate = meta.rate
257
+ if (meta && meta.chime) player.chime = meta.chime
258
+ if (meta && meta.roles) player.roles = meta.roles
259
+ if (meta && typeof meta.bargeIn === 'boolean') player.bargeIn = meta.bargeIn
260
+ if (!player.enabled) return
261
+ const res = await fetch('/dsh-tts/pending?after=' + encodeURIComponent(player.after || ''), { cache: 'no-store' })
262
+ if (!res.ok) return
263
+ const data = await res.json()
264
+ const items = data && Array.isArray(data.items) ? data.items : []
265
+ for (const item of items) {
266
+ // Queue slot reserved but synthesis still running — wait for the next poll
267
+ // or phrase order would break.
268
+ if (item.kind === 'reserved') break
269
+ player.after = item.id
270
+ if (seenIds.has(item.id)) continue
271
+ seenIds.add(item.id)
272
+ if (seenIds.size > 500) {
273
+ const first = seenIds.values().next().value
274
+ seenIds.delete(first)
275
+ }
276
+ if (item.text) recent.add(item.text)
277
+ player.queue.push(item)
278
+ }
279
+ if (player.queue.length) drainQueue()
280
+ } catch (hostRestarting) { /* host restarting */ }
281
+ }
282
+
283
+ // Recent utterances: last reply texts and favorites (localStorage).
284
+ function recentStore() {
285
+ const KEY = 'dsh-tts/recent'
286
+ let st
287
+ try {
288
+ const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null
289
+ st = JSON.parse(raw) || { items: [], favs: [] }
290
+ } catch (broken) { st = { items: [], favs: [] } }
291
+ const persist = () => { try { if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(st)) } catch (full) { /* private mode / quota */ } }
292
+ return {
293
+ add(text) {
294
+ if (!text || text.length < 2) return
295
+ st.items = [text, ...st.items.filter((x) => x !== text)].slice(0, 30)
296
+ persist()
297
+ },
298
+ toggleFav(text) {
299
+ st.favs = st.favs.includes(text)
300
+ ? st.favs.filter((x) => x !== text)
301
+ : [text, ...st.favs].slice(0, 20)
302
+ persist()
303
+ },
304
+ list() { return { items: st.items, favs: st.favs } },
305
+ }
306
+ }
307
+ const recent = recentStore()
308
+
309
+ // One-shot playback outside the queue: replay from recents.
310
+ function playStandalone(text) {
311
+ fetch('/dsh-tts/speak', {
312
+ method: 'POST',
313
+ headers: { 'Content-Type': 'application/json' },
314
+ body: JSON.stringify({ text }),
315
+ })
316
+ .then((r) => r.json())
317
+ .then((d) => {
318
+ if (!d || !d.ok) return
319
+ const audio = new Audio('data:' + (d.mime || 'audio/mpeg') + ';base64,' + d.audioBase64)
320
+ audio.play().catch(() => {})
321
+ })
322
+ .catch(() => {})
323
+ }
324
+
325
+ function credOf(map, provider) {
326
+ return (map && map[provider]) || { configured: false, writable: true, ref: '' }
327
+ }
328
+