@goodandready/dsh-tts 0.1.1

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.
@@ -0,0 +1,229 @@
1
+ import { edgeSpeak, espeakSpeak, piperSpeak } from './local.js'
2
+
3
+ export const PROVIDER_KEYS = [
4
+ 'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter',
5
+ 'edge', 'piper', 'espeak',
6
+ ]
7
+
8
+ export const DEFAULT_MODELS = {
9
+ openai: 'gpt-4o-mini-tts',
10
+ elevenlabs: 'eleven_multilingual_v2',
11
+ google: 'gemini-2.5-flash-preview-tts',
12
+ azure: 'en-US-JennyNeural',
13
+ groq: 'playai-tts',
14
+ deepgram: 'aura-asteria-en',
15
+ openrouter: 'openai/gpt-4o-mini-tts-2025-12-15',
16
+ edge: 'ru-RU-SvetlanaNeural',
17
+ piper: '',
18
+ espeak: 'ru',
19
+ }
20
+
21
+ export const DEFAULT_VOICES = {
22
+ openai: 'alloy',
23
+ elevenlabs: '21m00Tcm4TlvDq8ikWAM',
24
+ google: 'Kore',
25
+ azure: 'en-US-JennyNeural',
26
+ groq: 'Fritz-PlayAI',
27
+ deepgram: '',
28
+ openrouter: 'alloy',
29
+ edge: 'ru-RU-SvetlanaNeural',
30
+ piper: '',
31
+ espeak: 'ru',
32
+ }
33
+
34
+ function pick(map, models, key) {
35
+ const chosen = models && typeof models[key] === 'string' ? models[key].trim() : ''
36
+ return chosen || map[key]
37
+ }
38
+
39
+ function asBuffer(body) {
40
+ if (Buffer.isBuffer(body)) return body
41
+ return Buffer.from(body)
42
+ }
43
+
44
+ export function makeProviders(deps, req) {
45
+ const { resolveKey, fetchImpl, cfg } = deps
46
+ const { text, lang, signal, models, voices } = req
47
+
48
+ async function openai() {
49
+ const key = await resolveKey(cfg.openaiKeyEnv)
50
+ if (!key) return { ok: false, provider: 'openai', reason: `no ${cfg.openaiKeyEnv}` }
51
+ const res = await fetchImpl((cfg.openaiBaseUrl || 'https://api.openai.com/v1') + '/audio/speech', {
52
+ method: 'POST',
53
+ headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
54
+ body: JSON.stringify({
55
+ model: pick(DEFAULT_MODELS, models, 'openai'),
56
+ input: text,
57
+ voice: pick(DEFAULT_VOICES, voices, 'openai'),
58
+ response_format: 'mp3',
59
+ }),
60
+ signal,
61
+ })
62
+ if (!res.ok) throw new Error(`OpenAI HTTP ${res.status}`)
63
+ const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
64
+ return { ok: audio.length > 0, provider: 'openai', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
65
+ }
66
+
67
+ async function elevenlabs() {
68
+ const key = await resolveKey(cfg.elevenlabsKeyEnv)
69
+ if (!key) return { ok: false, provider: 'elevenlabs', reason: `no ${cfg.elevenlabsKeyEnv}` }
70
+ const voice = pick(DEFAULT_VOICES, voices, 'elevenlabs')
71
+ const res = await fetchImpl(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, {
72
+ method: 'POST',
73
+ headers: { 'xi-api-key': key, 'content-type': 'application/json', accept: 'audio/mpeg' },
74
+ body: JSON.stringify({
75
+ text,
76
+ model_id: pick(DEFAULT_MODELS, models, 'elevenlabs'),
77
+ }),
78
+ signal,
79
+ })
80
+ if (!res.ok) throw new Error(`ElevenLabs HTTP ${res.status}`)
81
+ const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
82
+ return { ok: audio.length > 0, provider: 'elevenlabs', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
83
+ }
84
+
85
+ async function google() {
86
+ const key = await resolveKey(cfg.googleKeyEnv)
87
+ if (!key) return { ok: false, provider: 'google', reason: `no ${cfg.googleKeyEnv}` }
88
+ const model = pick(DEFAULT_MODELS, models, 'google')
89
+ const voiceName = pick(DEFAULT_VOICES, voices, 'google')
90
+ const res = await fetchImpl(
91
+ `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(key)}`,
92
+ {
93
+ method: 'POST',
94
+ headers: { 'content-type': 'application/json' },
95
+ body: JSON.stringify({
96
+ contents: [{ parts: [{ text }] }],
97
+ generationConfig: {
98
+ responseModalities: ['AUDIO'],
99
+ speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName } } },
100
+ },
101
+ }),
102
+ signal,
103
+ },
104
+ )
105
+ if (!res.ok) throw new Error(`Google HTTP ${res.status}`)
106
+ const data = await res.json()
107
+ const b64 = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData)?.inlineData?.data
108
+ if (!b64) return { ok: false, provider: 'google', reason: 'no inline audio' }
109
+ const audio = Buffer.from(b64, 'base64')
110
+ return { ok: audio.length > 0, provider: 'google', audio, mime: 'audio/wav', reason: audio.length ? '' : 'empty audio' }
111
+ }
112
+
113
+ async function azure() {
114
+ const key = await resolveKey(cfg.azureKeyEnv)
115
+ if (!key) return { ok: false, provider: 'azure', reason: `no ${cfg.azureKeyEnv}` }
116
+ const region = (cfg.azureRegion || '').trim()
117
+ if (!region) return { ok: false, provider: 'azure', reason: 'azureRegion is empty' }
118
+ const voice = pick(DEFAULT_VOICES, voices, 'azure')
119
+ const ssml = `<speak version="1.0" xml:lang="${lang || 'en-US'}">`
120
+ + `<voice xml:lang="${lang || 'en-US'}" name="${voice}">`
121
+ + `${escapeXml(text)}</voice></speak>`
122
+ const res = await fetchImpl(`https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`, {
123
+ method: 'POST',
124
+ headers: {
125
+ 'Ocp-Apim-Subscription-Key': key,
126
+ 'Content-Type': 'application/ssml+xml',
127
+ 'X-Microsoft-OutputFormat': 'audio-16khz-128kbitrate-mono-mp3',
128
+ },
129
+ body: ssml,
130
+ signal,
131
+ })
132
+ if (!res.ok) throw new Error(`Azure HTTP ${res.status}`)
133
+ const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
134
+ return { ok: audio.length > 0, provider: 'azure', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
135
+ }
136
+
137
+ async function groq() {
138
+ const key = await resolveKey(cfg.groqKeyEnv)
139
+ if (!key) return { ok: false, provider: 'groq', reason: `no ${cfg.groqKeyEnv}` }
140
+ const res = await fetchImpl('https://api.groq.com/openai/v1/audio/speech', {
141
+ method: 'POST',
142
+ headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
143
+ body: JSON.stringify({
144
+ model: pick(DEFAULT_MODELS, models, 'groq'),
145
+ input: text,
146
+ voice: pick(DEFAULT_VOICES, voices, 'groq'),
147
+ response_format: 'mp3',
148
+ }),
149
+ signal,
150
+ })
151
+ if (!res.ok) throw new Error(`Groq HTTP ${res.status}`)
152
+ const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
153
+ return { ok: audio.length > 0, provider: 'groq', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
154
+ }
155
+
156
+ async function deepgram() {
157
+ const key = await resolveKey(cfg.deepgramKeyEnv)
158
+ if (!key) return { ok: false, provider: 'deepgram', reason: `no ${cfg.deepgramKeyEnv}` }
159
+ const model = pick(DEFAULT_MODELS, models, 'deepgram')
160
+ const res = await fetchImpl(`https://api.deepgram.com/v1/speak?model=${encodeURIComponent(model)}`, {
161
+ method: 'POST',
162
+ headers: { authorization: `Token ${key}`, 'content-type': 'application/json' },
163
+ body: JSON.stringify({ text }),
164
+ signal,
165
+ })
166
+ if (!res.ok) throw new Error(`Deepgram HTTP ${res.status}`)
167
+ const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
168
+ return { ok: audio.length > 0, provider: 'deepgram', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
169
+ }
170
+
171
+ async function openrouter() {
172
+ const key = await resolveKey(cfg.openrouterKeyEnv)
173
+ if (!key) return { ok: false, provider: 'openrouter', reason: `no ${cfg.openrouterKeyEnv}` }
174
+ const res = await fetchImpl('https://openrouter.ai/api/v1/audio/speech', {
175
+ method: 'POST',
176
+ headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
177
+ body: JSON.stringify({
178
+ model: pick(DEFAULT_MODELS, models, 'openrouter'),
179
+ input: text,
180
+ voice: pick(DEFAULT_VOICES, voices, 'openrouter'),
181
+ response_format: 'mp3',
182
+ }),
183
+ signal,
184
+ })
185
+ if (!res.ok) throw new Error(`OpenRouter HTTP ${res.status}`)
186
+ const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
187
+ return { ok: audio.length > 0, provider: 'openrouter', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
188
+ }
189
+
190
+ async function edge() {
191
+ try {
192
+ const voice = pick(DEFAULT_VOICES, voices, 'edge') || pick(DEFAULT_MODELS, models, 'edge')
193
+ const out = await edgeSpeak(text, { bin: cfg.edgeBin, voice, timeoutMs: cfg.timeoutMs })
194
+ return { ok: true, provider: 'edge', audio: out.audio, mime: out.mime }
195
+ } catch (e) {
196
+ return { ok: false, provider: 'edge', reason: String(e && e.message || e) }
197
+ }
198
+ }
199
+
200
+ async function piper() {
201
+ try {
202
+ const model = pick(DEFAULT_MODELS, models, 'piper') || cfg.piperModel
203
+ const out = await piperSpeak(text, { bin: cfg.piperBin, model, timeoutMs: cfg.timeoutMs })
204
+ return { ok: true, provider: 'piper', audio: out.audio, mime: out.mime }
205
+ } catch (e) {
206
+ return { ok: false, provider: 'piper', reason: String(e && e.message || e) }
207
+ }
208
+ }
209
+
210
+ async function espeak() {
211
+ try {
212
+ const voice = pick(DEFAULT_VOICES, voices, 'espeak') || lang || 'ru'
213
+ const out = await espeakSpeak(text, { bin: cfg.espeakBin, voice, timeoutMs: cfg.timeoutMs })
214
+ return { ok: true, provider: 'espeak', audio: out.audio, mime: out.mime }
215
+ } catch (e) {
216
+ return { ok: false, provider: 'espeak', reason: String(e && e.message || e) }
217
+ }
218
+ }
219
+
220
+ return { openai, elevenlabs, google, azure, groq, deepgram, openrouter, edge, piper, espeak }
221
+ }
222
+
223
+ function escapeXml(s) {
224
+ return String(s)
225
+ .replace(/&/g, '&amp;')
226
+ .replace(/</g, '&lt;')
227
+ .replace(/>/g, '&gt;')
228
+ .replace(/"/g, '&quot;')
229
+ }
package/lib/text.js ADDED
@@ -0,0 +1,31 @@
1
+ // Turn assistant message content into speakable plain text.
2
+
3
+ export function blocksToText(content) {
4
+ if (typeof content === 'string') return content
5
+ if (!Array.isArray(content)) return ''
6
+ const parts = []
7
+ for (const block of content) {
8
+ if (!block || typeof block !== 'object') continue
9
+ if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
10
+ }
11
+ return parts.join('\n')
12
+ }
13
+
14
+ export function stripForSpeech(raw, maxChars) {
15
+ let text = String(raw || '')
16
+ text = text.replace(/```[\s\S]*?```/g, ' ')
17
+ text = text.replace(/`[^`]+`/g, ' ')
18
+ text = text.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
19
+ text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
20
+ text = text.replace(/[#*_>]+/g, ' ')
21
+ text = text.replace(/MEDIA:[^\s]+/g, ' ')
22
+ text = text.replace(/\s+/g, ' ').trim()
23
+ const limit = Number(maxChars) > 0 ? Number(maxChars) : 4000
24
+ if (text.length > limit) text = text.slice(0, limit)
25
+ return text
26
+ }
27
+
28
+ export function assistantText(message) {
29
+ if (!message) return ''
30
+ return blocksToText(message.content)
31
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@goodandready/dsh-tts",
3
+ "version": "0.1.1",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./lib/index.js",
8
+ "exports": {
9
+ ".": "./lib/index.js",
10
+ "./client": "./lib/client.js",
11
+ "./package.json": "./package.json",
12
+ "./cordis.patch.yml": "./cordis.patch.yml"
13
+ },
14
+ "files": [
15
+ "lib/",
16
+ "cordis.patch.yml",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "keywords": [
21
+ "dsh",
22
+ "dsh-plugin",
23
+ "deepseek-harness",
24
+ "tts",
25
+ "speech",
26
+ "voice",
27
+ "openai",
28
+ "elevenlabs",
29
+ "deepgram",
30
+ "openrouter"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/GooDAnDReaDY/dsh-tts.git"
35
+ },
36
+ "homepage": "https://github.com/GooDAnDReaDY/dsh-tts",
37
+ "bugs": {
38
+ "url": "https://github.com/GooDAnDReaDY/dsh-tts/issues"
39
+ },
40
+ "scripts": {
41
+ "test": "node --test test/*.test.mjs"
42
+ },
43
+ "dsh": {
44
+ "bundle": {
45
+ "patch": "./cordis.patch.yml"
46
+ },
47
+ "client": {
48
+ "platform": "web",
49
+ "inject": [
50
+ "@deepseek-ai/dsh-client-runtime",
51
+ "@deepseek-ai/dsh-client-ui-slots"
52
+ ]
53
+ }
54
+ },
55
+ "peerDependencies": {
56
+ "@deepseek-ai/cordis": "^4.0.1",
57
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
58
+ "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
59
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
60
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
61
+ "@deepseek-ai/schemastery": "^3.18.1"
62
+ }
63
+ }