@goodandready/dsh-voice 0.8.18 → 0.8.19
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 +20 -0
- package/README.ru.md +20 -0
- package/README.zh.md +16 -0
- package/lib/chain.js +4 -4
- package/lib/client-src/00-open.js +24 -0
- package/lib/client-src/10-locale.js +148 -0
- package/lib/client-src/20-css.js +39 -0
- package/lib/client-src/30-core.js +284 -0
- package/lib/client-src/40-recording.js +384 -0
- package/lib/client-src/41-buttons.js +30 -0
- package/lib/client-src/50-visualizers.js +121 -0
- package/lib/client-src/60-composer.js +285 -0
- package/lib/client-src/70-settings.js +640 -0
- package/lib/client-src/90-close.js +23 -0
- package/lib/client.js +218 -334
- package/lib/index.js +41 -42
- package/lib/normalize.js +2 -2
- package/lib/providers.js +39 -43
- package/lib/stats.js +2 -2
- package/lib/wav.js +8 -8
- package/package.json +4 -2
package/lib/index.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
// dsh-voice —
|
|
1
|
+
// dsh-voice — host half.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// dictation —
|
|
5
|
-
//
|
|
6
|
-
// message —
|
|
3
|
+
// Two voice input modes, each with its own provider fallback chain:
|
|
4
|
+
// dictation — the browser cuts speech on pauses and posts chunks; text is
|
|
5
|
+
// appended to the composer;
|
|
6
|
+
// message — one whole recording; text is sent after the cancel window.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
8
|
+
// Routes:
|
|
9
9
|
// POST /dsh-voice/transcribe {dataBase64, mimeType, mode} -> {ok, text, provider, tookMs}
|
|
10
10
|
// GET /dsh-voice/status -> {ok, whisperRunning, modes}
|
|
11
11
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// Provider keys are resolved on the host via ctx.credentials and never reach
|
|
13
|
+
// the browser.
|
|
14
14
|
|
|
15
15
|
import z from '@deepseek-ai/schemastery'
|
|
16
16
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
@@ -42,7 +42,7 @@ const ChainEntry = z.object({
|
|
|
42
42
|
.description('Model override. Empty means the provider default.'),
|
|
43
43
|
})
|
|
44
44
|
|
|
45
|
-
//
|
|
45
|
+
// Custom provider: everything needed to call any OpenAI-compatible API.
|
|
46
46
|
const CustomProvider = z.object({
|
|
47
47
|
key: z.string().default('')
|
|
48
48
|
.description('Name used in the chains above. Must differ from the built-in keys.'),
|
|
@@ -168,7 +168,7 @@ function writeJson(res, code, body) {
|
|
|
168
168
|
try {
|
|
169
169
|
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
|
|
170
170
|
res.end(JSON.stringify(body))
|
|
171
|
-
} catch { /*
|
|
171
|
+
} catch { /* socket may already be closed */ }
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
function readBody(req, maxBytes) {
|
|
@@ -188,11 +188,10 @@ function readBody(req, maxBytes) {
|
|
|
188
188
|
export function apply(ctx, baseConfig) {
|
|
189
189
|
let child = null
|
|
190
190
|
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
// перезапуска процесса.
|
|
191
|
+
// The settings card edits a namespace named after the plugin. Until the
|
|
192
|
+
// host registers it via settings.register the snapshot is empty and
|
|
193
|
+
// read-only. Reading through live() means edits apply to the next request
|
|
194
|
+
// instead of requiring a process restart.
|
|
196
195
|
let getConfig = () => baseConfig
|
|
197
196
|
const live = () => Config(structuredClone(getConfig() ?? {})) ?? baseConfig
|
|
198
197
|
|
|
@@ -206,7 +205,7 @@ export function apply(ctx, baseConfig) {
|
|
|
206
205
|
try {
|
|
207
206
|
const resolved = await ctx.credentials.resolve(credentialRef(ref))
|
|
208
207
|
if (resolved && resolved.value) return resolved.value
|
|
209
|
-
} catch { /*
|
|
208
|
+
} catch { /* fall through to environment */ }
|
|
210
209
|
return process.env[ref] || ''
|
|
211
210
|
}
|
|
212
211
|
|
|
@@ -223,8 +222,8 @@ export function apply(ctx, baseConfig) {
|
|
|
223
222
|
async function startWhisper() {
|
|
224
223
|
const cfg = live()
|
|
225
224
|
if (!cfg.autoStart) return false
|
|
226
|
-
//
|
|
227
|
-
//
|
|
225
|
+
// Without a model path there is nothing to start: the package cannot know
|
|
226
|
+
// where a user's model lives and must not launch a random binary.
|
|
228
227
|
if (!cfg.whisperModel) return false
|
|
229
228
|
if (await whisperAlive()) return true
|
|
230
229
|
try {
|
|
@@ -245,7 +244,7 @@ export function apply(ctx, baseConfig) {
|
|
|
245
244
|
|
|
246
245
|
startWhisper().catch(() => {})
|
|
247
246
|
|
|
248
|
-
// SenseVoice-ONNX / Sherpa-ONNX:
|
|
247
|
+
// SenseVoice-ONNX / Sherpa-ONNX: autostart and health check.
|
|
249
248
|
let sensevoiceChild = null
|
|
250
249
|
|
|
251
250
|
async function sensevoiceAlive() {
|
|
@@ -257,7 +256,7 @@ export function apply(ctx, baseConfig) {
|
|
|
257
256
|
const t = setTimeout(() => controller.abort(), 2000)
|
|
258
257
|
const res = await fetch(base + '/', { signal: controller.signal })
|
|
259
258
|
clearTimeout(t)
|
|
260
|
-
return res.ok || res.status === 404 // sherpa-onnx
|
|
259
|
+
return res.ok || res.status === 404 // sherpa-onnx answers 404 on / but is alive
|
|
261
260
|
} catch { return false }
|
|
262
261
|
}
|
|
263
262
|
|
|
@@ -286,15 +285,15 @@ export function apply(ctx, baseConfig) {
|
|
|
286
285
|
|
|
287
286
|
startSensevoice().catch(() => {})
|
|
288
287
|
|
|
289
|
-
//
|
|
288
|
+
// Provider health and latency stats (Latency & Health Dashboard).
|
|
290
289
|
const statsTracker = createStatsTracker()
|
|
291
290
|
|
|
292
|
-
//
|
|
291
|
+
// Shared recognition path: build providers from the mode chain and run it.
|
|
293
292
|
async function transcribe(modeCfg, bytes, mime, signal, contextWords) {
|
|
294
293
|
const cfg = live()
|
|
295
294
|
const customKeys = (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
|
|
296
295
|
.map((c) => String(c && c.key || '').trim()).filter(Boolean)
|
|
297
|
-
//
|
|
296
|
+
// localOnly mode: restrict the chain to local-whisper.
|
|
298
297
|
const chain = cfg.localOnly
|
|
299
298
|
? (modeCfg.chain || []).filter((e) => e.provider === 'local-whisper')
|
|
300
299
|
: (modeCfg.chain || [])
|
|
@@ -303,8 +302,8 @@ export function apply(ctx, baseConfig) {
|
|
|
303
302
|
for (const entry of chain) {
|
|
304
303
|
if (!KNOWN_KEYS.includes(entry.provider) && !customKeys.includes(entry.provider)) continue
|
|
305
304
|
order.push(entry.provider)
|
|
306
|
-
//
|
|
307
|
-
//
|
|
305
|
+
// Custom provider default models live in their spec; makeProviders fills
|
|
306
|
+
// them in — empty here means "use the provider default".
|
|
308
307
|
models[entry.provider] = entry.model || DEFAULT_MODELS[entry.provider] || ''
|
|
309
308
|
}
|
|
310
309
|
if (cfg.localOnly && order.length === 0) {
|
|
@@ -322,8 +321,8 @@ export function apply(ctx, baseConfig) {
|
|
|
322
321
|
return runChain(order, providers, statsTracker.record)
|
|
323
322
|
}
|
|
324
323
|
|
|
325
|
-
//
|
|
326
|
-
//
|
|
324
|
+
// Transcript polish (#35) with local LLM support (#47).
|
|
325
|
+
// Errors/timeouts do not block: return the raw text.
|
|
327
326
|
async function polishText(text, modeCfg, signal) {
|
|
328
327
|
const enable = modeCfg && (modeCfg.polish === true || modeCfg.polishSend === true)
|
|
329
328
|
if (!enable || !text) return text
|
|
@@ -334,7 +333,7 @@ export function apply(ctx, baseConfig) {
|
|
|
334
333
|
+ '"ээ", "ну", "как бы"). Keep the original language, wording and meaning. '
|
|
335
334
|
+ 'Reply with the polished text only:\n\n' + text
|
|
336
335
|
try {
|
|
337
|
-
//
|
|
336
|
+
// Local OpenAI-compatible endpoint (#47) when configured.
|
|
338
337
|
if (cfg.polishBaseUrl) {
|
|
339
338
|
const headers = { 'content-type': 'application/json' }
|
|
340
339
|
if (cfg.polishKeyEnv) {
|
|
@@ -353,11 +352,11 @@ export function apply(ctx, baseConfig) {
|
|
|
353
352
|
&& data.choices[0].message.content
|
|
354
353
|
return (pick && String(pick).trim()) || text
|
|
355
354
|
}
|
|
356
|
-
//
|
|
355
|
+
// Harness model (DSH 0.1.2-alpha.1 API).
|
|
357
356
|
const llm = ctx.llm
|
|
358
357
|
if (!llm || typeof llm.stream !== 'function') return text
|
|
359
|
-
// provider/model
|
|
360
|
-
// agentDefaultModel
|
|
358
|
+
// provider/model are required in GenerateOptions; fall back to
|
|
359
|
+
// agentDefaultModel when not set explicitly.
|
|
361
360
|
const sel = (ctx.get && ctx.get('agentDefaultModel') && ctx.get('agentDefaultModel').currentSelection)
|
|
362
361
|
? ctx.get('agentDefaultModel').currentSelection()
|
|
363
362
|
: null
|
|
@@ -377,8 +376,8 @@ export function apply(ctx, baseConfig) {
|
|
|
377
376
|
} catch { return text }
|
|
378
377
|
}
|
|
379
378
|
|
|
380
|
-
//
|
|
381
|
-
//
|
|
379
|
+
// Clean session voice commands (#48). Returns the command name or null.
|
|
380
|
+
// Matched on the host so they never enter the composer text.
|
|
382
381
|
const SESSION_COMMANDS = [
|
|
383
382
|
{ re: /^(отправь|отправить|пошли|send)\s*[.!?]*$/i, cmd: 'send' },
|
|
384
383
|
{ re: /^(отмени|отмена|cancel|отменить)\s*[.!?]*$/i, cmd: 'cancel' },
|
|
@@ -403,7 +402,7 @@ export function apply(ctx, baseConfig) {
|
|
|
403
402
|
writeJson(res, 200, {
|
|
404
403
|
ok: true,
|
|
405
404
|
whisperRunning: await whisperAlive(),
|
|
406
|
-
//
|
|
405
|
+
// The hotkey is needed by the browser half: it installs the hold handler.
|
|
407
406
|
hotkey: cfg.hotkey,
|
|
408
407
|
providers: KNOWN_KEYS.concat(
|
|
409
408
|
(Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
|
|
@@ -451,7 +450,7 @@ export function apply(ctx, baseConfig) {
|
|
|
451
450
|
let raw
|
|
452
451
|
try { raw = await readBody(req, cfg.maxFileBytes + 1024 * 1024) } catch (e) { writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return }
|
|
453
452
|
let payload = {}
|
|
454
|
-
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /*
|
|
453
|
+
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
|
|
455
454
|
const text = typeof payload.text === 'string' ? payload.text.trim() : ''
|
|
456
455
|
if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
|
|
457
456
|
const controller = new AbortController()
|
|
@@ -494,12 +493,12 @@ export function apply(ctx, baseConfig) {
|
|
|
494
493
|
writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio is ${bytes.length} bytes, max ${cfg.maxFileBytes}` } }); return
|
|
495
494
|
}
|
|
496
495
|
|
|
497
|
-
//
|
|
498
|
-
//
|
|
496
|
+
// Local whisper in the chain — start the server up front, otherwise the
|
|
497
|
+
// first chunk fails while the server is still booting.
|
|
499
498
|
if ((modeCfg.chain || []).some((e) => e.provider === 'local-whisper') && !(await whisperAlive())) {
|
|
500
499
|
await startWhisper()
|
|
501
500
|
}
|
|
502
|
-
//
|
|
501
|
+
// Same for SenseVoice-ONNX / Sherpa-ONNX.
|
|
503
502
|
if ((modeCfg.chain || []).some((e) => e.provider === 'sensevoice') && !(await sensevoiceAlive())) {
|
|
504
503
|
await startSensevoice()
|
|
505
504
|
}
|
|
@@ -508,8 +507,8 @@ export function apply(ctx, baseConfig) {
|
|
|
508
507
|
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
509
508
|
try {
|
|
510
509
|
const out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
|
|
511
|
-
//
|
|
512
|
-
//
|
|
510
|
+
// Session voice commands (#48): clean "send/cancel/stop/continue"
|
|
511
|
+
// become a command for the browser instead of composer text.
|
|
513
512
|
if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
|
|
514
513
|
const cmd = sessionCommand(out.text)
|
|
515
514
|
if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
|
|
@@ -578,7 +577,7 @@ export function apply(ctx, baseConfig) {
|
|
|
578
577
|
)
|
|
579
578
|
|
|
580
579
|
ctx.effect(() => () => {
|
|
581
|
-
if (child) { try { child.kill && child.kill() } catch { /*
|
|
582
|
-
if (sensevoiceChild) { try { sensevoiceChild.kill && sensevoiceChild.kill() } catch { /*
|
|
580
|
+
if (child) { try { child.kill && child.kill() } catch { /* already dead */ } }
|
|
581
|
+
if (sensevoiceChild) { try { sensevoiceChild.kill && sensevoiceChild.kill() } catch { /* already dead */ } }
|
|
583
582
|
}, 'dsh-voice: stop child processes')
|
|
584
583
|
}
|
package/lib/normalize.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Transcript post-processing before inserting into the composer.
|
|
2
|
+
// No network and no cordis — pure functions for unit tests.
|
|
3
3
|
|
|
4
4
|
export function capitalizeSentences(text) {
|
|
5
5
|
return text.replace(/(^|[.!?\n]\s+)([a-zа-яё])/gi, (m, lead, ch) => lead + ch.toUpperCase())
|
package/lib/providers.js
CHANGED
|
@@ -1,27 +1,24 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Speech recognition providers: built-ins plus custom ones from settings.
|
|
2
|
+
// Pure functions: network arrives as fetchImpl, keys via resolveKey, so
|
|
3
|
+
// everything is testable without real requests.
|
|
4
4
|
|
|
5
|
-
// 'browser'
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// хосте оборвалась бы на первом же шаге вместо перехода к groq.
|
|
5
|
+
// 'browser' is listed for chain validation only: recognition runs in the
|
|
6
|
+
// page and audio never reaches the host. Without this key a ['browser','groq']
|
|
7
|
+
// chain would break on the host instead of falling through to groq.
|
|
9
8
|
export const PROVIDER_KEYS = ['browser', 'deepgram', 'groq', 'hf', 'local-whisper', 'sensevoice']
|
|
10
9
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// /audio/transcriptions вовсе, и распознавание там идёт через чат.
|
|
10
|
+
// Custom providers use one of two templates because OpenAI-compatible APIs
|
|
11
|
+
// diverged: OpenRouter has no /audio/transcriptions and recognizes via chat.
|
|
14
12
|
export const CUSTOM_TEMPLATES = ['openai-transcriptions', 'openai-chat-audio']
|
|
15
13
|
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// Presets: the same as a custom provider with address, model and key name
|
|
15
|
+
// already filled in. Only the key itself is required.
|
|
18
16
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// в строке цепочки, не трогая остальное.
|
|
17
|
+
// Each URL answers 401 without a key, so the path exists. The model is a
|
|
18
|
+
// reasonable starting point and can be overridden per chain row.
|
|
22
19
|
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
20
|
+
// A custom provider with the same name fully replaces a preset: presets are
|
|
21
|
+
// prefilled forms, not special engines.
|
|
25
22
|
export const PRESET_PROVIDERS = {
|
|
26
23
|
openai: {
|
|
27
24
|
template: 'openai-transcriptions',
|
|
@@ -53,7 +50,7 @@ export const PRESET_PROVIDERS = {
|
|
|
53
50
|
model: 'voxtral-mini-latest',
|
|
54
51
|
keyEnv: 'MISTRAL_API_KEY',
|
|
55
52
|
},
|
|
56
|
-
//
|
|
53
|
+
// OpenRouter has no /audio/transcriptions at all — chat only.
|
|
57
54
|
openrouter: {
|
|
58
55
|
template: 'openai-chat-audio',
|
|
59
56
|
baseURL: 'https://openrouter.ai/api/v1',
|
|
@@ -64,15 +61,15 @@ export const PRESET_PROVIDERS = {
|
|
|
64
61
|
|
|
65
62
|
export const PRESET_KEYS = Object.keys(PRESET_PROVIDERS)
|
|
66
63
|
|
|
67
|
-
/**
|
|
64
|
+
/** All names usable in a chain without declaring a custom provider. */
|
|
68
65
|
export const KNOWN_KEYS = PROVIDER_KEYS.concat(PRESET_KEYS)
|
|
69
66
|
|
|
70
67
|
const CHAT_AUDIO_PROMPT =
|
|
71
68
|
'Transcribe the audio verbatim. Reply with the transcript text only, '
|
|
72
69
|
+ 'without comments, quotes or formatting.'
|
|
73
70
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
71
|
+
// Formats the chat template accepts in input_audio. Everything else
|
|
72
|
+
// (including browser webm/opus) is converted to WAV first.
|
|
76
73
|
function chatAudioFormat(mime) {
|
|
77
74
|
if (mime.includes('wav')) return 'wav'
|
|
78
75
|
if (mime.includes('mpeg') || mime.includes('mp3')) return 'mp3'
|
|
@@ -99,8 +96,8 @@ function fileName(mime) {
|
|
|
99
96
|
return 'audio.webm'
|
|
100
97
|
}
|
|
101
98
|
|
|
102
|
-
//
|
|
103
|
-
//
|
|
99
|
+
// Auto language: empty, 'auto', or a list ('ru,en') means the provider
|
|
100
|
+
// detects language; the language field is omitted. whisper.cpp gets -l auto.
|
|
104
101
|
function isAutoLang(lang) {
|
|
105
102
|
return !lang || lang === 'auto' || String(lang).includes(',')
|
|
106
103
|
}
|
|
@@ -167,8 +164,8 @@ export function makeProviders(deps, req) {
|
|
|
167
164
|
return { ok: text.length > 0, provider: 'hf', text, reason: text ? '' : 'empty transcript' }
|
|
168
165
|
}
|
|
169
166
|
|
|
170
|
-
// whisper.cpp
|
|
171
|
-
//
|
|
167
|
+
// whisper.cpp accepts WAV only — browser webm/opus gets "Invalid request".
|
|
168
|
+
// Convert when the format is not WAV.
|
|
172
169
|
async function localWhisper() {
|
|
173
170
|
let sendBytes = bytes
|
|
174
171
|
let sendMime = mime
|
|
@@ -195,11 +192,11 @@ export function makeProviders(deps, req) {
|
|
|
195
192
|
if (vocab) form.append('prompt', vocab)
|
|
196
193
|
form.append('response_format', 'json')
|
|
197
194
|
const res = await fetchImpl(cfg.whisperUrl, { method: 'POST', body: form, signal })
|
|
198
|
-
// whisper.cpp
|
|
199
|
-
//
|
|
195
|
+
// whisper.cpp returns 400 with a JSON body on internal failures (e.g. decode
|
|
196
|
+
// errors) — read the reason instead of throwing.
|
|
200
197
|
if (!res.ok) {
|
|
201
198
|
let detail = `HTTP ${res.status}`
|
|
202
|
-
try { const e = await res.json(); if (e?.error) detail = e.error } catch { /*
|
|
199
|
+
try { const e = await res.json(); if (e?.error) detail = e.error } catch { /* body is not json */ }
|
|
203
200
|
return { ok: false, provider: 'local-whisper', reason: `local whisper: ${detail}` }
|
|
204
201
|
}
|
|
205
202
|
const data = await res.json()
|
|
@@ -207,8 +204,8 @@ export function makeProviders(deps, req) {
|
|
|
207
204
|
return { ok: text.length > 0, provider: 'local-whisper', text, reason: text ? '' : 'empty transcript' }
|
|
208
205
|
}
|
|
209
206
|
|
|
210
|
-
// SenseVoice-ONNX / Sherpa-ONNX:
|
|
211
|
-
//
|
|
207
|
+
// SenseVoice-ONNX / Sherpa-ONNX: ultra-fast local recognition (~50-100ms).
|
|
208
|
+
// Supports sherpa-onnx (/api/v1/asr) and OpenAI-compatible endpoints.
|
|
212
209
|
async function sensevoice() {
|
|
213
210
|
const url = String(cfg.sensevoiceUrl || 'http://127.0.0.1:6006/api/v1/asr').trim()
|
|
214
211
|
let sendBytes = bytes
|
|
@@ -244,7 +241,7 @@ export function makeProviders(deps, req) {
|
|
|
244
241
|
|
|
245
242
|
if (!res.ok) {
|
|
246
243
|
let detail = `HTTP ${res.status}`
|
|
247
|
-
try { const e = await res.json(); if (e?.error) detail = e.error } catch { /*
|
|
244
|
+
try { const e = await res.json(); if (e?.error) detail = e.error } catch { /* body is not json */ }
|
|
248
245
|
return { ok: false, provider: 'sensevoice', reason: `sensevoice: ${detail}` }
|
|
249
246
|
}
|
|
250
247
|
|
|
@@ -256,7 +253,7 @@ export function makeProviders(deps, req) {
|
|
|
256
253
|
}
|
|
257
254
|
|
|
258
255
|
let text = String(data?.text || data?.transcript || '').trim()
|
|
259
|
-
//
|
|
256
|
+
// Strip SenseVoice emotion/event tags (<|NEUTRAL|>, <|HAPPY|>, <|Speech|>, ...).
|
|
260
257
|
text = text.replace(/<\|[^|>]+\|>/g, '').trim()
|
|
261
258
|
return {
|
|
262
259
|
ok: text.length > 0,
|
|
@@ -266,8 +263,8 @@ export function makeProviders(deps, req) {
|
|
|
266
263
|
}
|
|
267
264
|
}
|
|
268
265
|
|
|
269
|
-
//
|
|
270
|
-
//
|
|
266
|
+
// Custom provider. The chain key is its name, so the rest of the code
|
|
267
|
+
// treats it like a built-in.
|
|
271
268
|
function customProvider(spec) {
|
|
272
269
|
const label = spec.key
|
|
273
270
|
const base = String(spec.baseURL || '').replace(/\/+$/, '')
|
|
@@ -338,27 +335,27 @@ export function makeProviders(deps, req) {
|
|
|
338
335
|
? await viaChatAudio(headers)
|
|
339
336
|
: await viaTranscriptions(headers)
|
|
340
337
|
} catch (e) {
|
|
341
|
-
//
|
|
342
|
-
//
|
|
338
|
+
// One provider failure must not abort the chain — the chain decides
|
|
339
|
+
// whether to continue or give up.
|
|
343
340
|
return { ok: false, provider: label, reason: `${label}: ${String(e && e.message || e)}` }
|
|
344
341
|
}
|
|
345
342
|
return { ok: text.length > 0, provider: label, text, reason: text ? '' : 'empty transcript' }
|
|
346
343
|
}
|
|
347
344
|
}
|
|
348
345
|
|
|
349
|
-
//
|
|
350
|
-
//
|
|
346
|
+
// If audio still reached the host with 'browser' in the chain, the browser
|
|
347
|
+
// leg failed: fail clearly and move on.
|
|
351
348
|
async function browser() {
|
|
352
349
|
return {
|
|
353
350
|
ok: false,
|
|
354
351
|
provider: 'browser',
|
|
355
|
-
reason: 'browser:
|
|
352
|
+
reason: 'browser: recognition runs in the page, the host has no browser engine',
|
|
356
353
|
}
|
|
357
354
|
}
|
|
358
355
|
|
|
359
356
|
const out = { browser, deepgram, groq, hf, 'local-whisper': localWhisper, sensevoice }
|
|
360
357
|
|
|
361
|
-
//
|
|
358
|
+
// Presets: custom providers with the form pre-filled.
|
|
362
359
|
for (const key of PRESET_KEYS) {
|
|
363
360
|
if (out[key]) continue
|
|
364
361
|
out[key] = customProvider({ ...PRESET_PROVIDERS[key], key })
|
|
@@ -366,9 +363,8 @@ export function makeProviders(deps, req) {
|
|
|
366
363
|
|
|
367
364
|
for (const spec of Array.isArray(cfg.customProviders) ? cfg.customProviders : []) {
|
|
368
365
|
const key = String(spec && spec.key || '').trim()
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
// для того и заготовка, чтобы её правили.
|
|
366
|
+
// Do not override built-in engines: a typo would silently replace a working
|
|
367
|
+
// provider in a chain. Presets may be overridden — that is their purpose.
|
|
372
368
|
if (!key || PROVIDER_KEYS.includes(key)) continue
|
|
373
369
|
out[key] = customProvider({ ...spec, key })
|
|
374
370
|
}
|
package/lib/stats.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// dsh-voice —
|
|
2
|
-
//
|
|
1
|
+
// dsh-voice — pure helpers for provider metrics and context vocabulary.
|
|
2
|
+
// No cordis and no network — unit-tested.
|
|
3
3
|
|
|
4
4
|
export function createStatsTracker() {
|
|
5
5
|
const stats = new Map()
|
package/lib/wav.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Convert arbitrary audio to 16 kHz mono WAV.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Local whisper.cpp accepts WAV only: browser webm/opus gets "Invalid
|
|
4
|
+
// request". Cloud providers accept webm as-is, so conversion is only needed
|
|
5
|
+
// for the local leg of the chain.
|
|
6
6
|
|
|
7
7
|
import { spawn } from 'node:child_process'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* @param bytes {Buffer|Uint8Array}
|
|
11
|
-
* @param ffmpegBin {string}
|
|
12
|
-
* @returns {Promise<Buffer>}
|
|
10
|
+
* @param bytes {Buffer|Uint8Array} source audio in any container
|
|
11
|
+
* @param ffmpegBin {string} ffmpeg binary path
|
|
12
|
+
* @returns {Promise<Buffer>} 16 kHz mono WAV
|
|
13
13
|
*/
|
|
14
14
|
export function toWav16k(bytes, ffmpegBin = 'ffmpeg') {
|
|
15
15
|
return new Promise((resolve, reject) => {
|
|
@@ -33,7 +33,7 @@ export function toWav16k(bytes, ffmpegBin = 'ffmpeg') {
|
|
|
33
33
|
if (wav.length < 64) { reject(new Error('ffmpeg produced empty output')); return }
|
|
34
34
|
resolve(wav)
|
|
35
35
|
})
|
|
36
|
-
proc.stdin.on('error', () => { /* ffmpeg
|
|
36
|
+
proc.stdin.on('error', () => { /* ffmpeg closed stdin early — the exit code tells the story */ })
|
|
37
37
|
proc.stdin.end(Buffer.from(bytes))
|
|
38
38
|
})
|
|
39
39
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.19",
|
|
4
4
|
"description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -39,7 +39,9 @@
|
|
|
39
39
|
"url": "https://github.com/GooDAnDReaDY/dsh-voice/issues"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
|
-
"test": "node --test test/*.test.mjs"
|
|
42
|
+
"test": "node --test test/*.test.mjs",
|
|
43
|
+
"build:client": "node scripts/build-client.mjs",
|
|
44
|
+
"pretest": "node scripts/build-client.mjs"
|
|
43
45
|
},
|
|
44
46
|
"dsh": {
|
|
45
47
|
"bundle": {
|