@goodandready/dsh-voice 0.8.17 → 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 +236 -369
- package/lib/index.js +41 -269
- package/lib/normalize.js +3 -3
- 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.'),
|
|
@@ -155,12 +155,6 @@ export const Config = z.object({
|
|
|
155
155
|
.description('SenseVoice / sherpa-onnx model path or model identifier.'),
|
|
156
156
|
sensevoiceAutostart: z.boolean().default(false)
|
|
157
157
|
.description('Start the local SenseVoice / sherpa-onnx server process automatically on startup.'),
|
|
158
|
-
realtimeStreaming: z.boolean().default(false)
|
|
159
|
-
.description('Enable low-latency streaming recognition (OpenAI Realtime API or Sherpa-ONNX).'),
|
|
160
|
-
realtimeProvider: z.string().default('openai')
|
|
161
|
-
.description('Realtime streaming provider: "openai" (OpenAI Realtime API) or "sherpa-onnx" (local streaming).'),
|
|
162
|
-
realtimeModel: z.string().default('gpt-4o-realtime-preview')
|
|
163
|
-
.description('Realtime model identifier.'),
|
|
164
158
|
visualizerStyle: z.union(['liquid-wave', 'dynamic-orb', 'bars', 'off']).default('liquid-wave')
|
|
165
159
|
.description('Audio visualizer animation style in the recording pill: "liquid-wave", "dynamic-orb", "bars", or "off".'),
|
|
166
160
|
})
|
|
@@ -174,7 +168,7 @@ function writeJson(res, code, body) {
|
|
|
174
168
|
try {
|
|
175
169
|
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
|
|
176
170
|
res.end(JSON.stringify(body))
|
|
177
|
-
} catch { /*
|
|
171
|
+
} catch { /* socket may already be closed */ }
|
|
178
172
|
}
|
|
179
173
|
|
|
180
174
|
function readBody(req, maxBytes) {
|
|
@@ -194,11 +188,10 @@ function readBody(req, maxBytes) {
|
|
|
194
188
|
export function apply(ctx, baseConfig) {
|
|
195
189
|
let child = null
|
|
196
190
|
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
// перезапуска процесса.
|
|
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.
|
|
202
195
|
let getConfig = () => baseConfig
|
|
203
196
|
const live = () => Config(structuredClone(getConfig() ?? {})) ?? baseConfig
|
|
204
197
|
|
|
@@ -212,7 +205,7 @@ export function apply(ctx, baseConfig) {
|
|
|
212
205
|
try {
|
|
213
206
|
const resolved = await ctx.credentials.resolve(credentialRef(ref))
|
|
214
207
|
if (resolved && resolved.value) return resolved.value
|
|
215
|
-
} catch { /*
|
|
208
|
+
} catch { /* fall through to environment */ }
|
|
216
209
|
return process.env[ref] || ''
|
|
217
210
|
}
|
|
218
211
|
|
|
@@ -229,8 +222,8 @@ export function apply(ctx, baseConfig) {
|
|
|
229
222
|
async function startWhisper() {
|
|
230
223
|
const cfg = live()
|
|
231
224
|
if (!cfg.autoStart) return false
|
|
232
|
-
//
|
|
233
|
-
//
|
|
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.
|
|
234
227
|
if (!cfg.whisperModel) return false
|
|
235
228
|
if (await whisperAlive()) return true
|
|
236
229
|
try {
|
|
@@ -251,7 +244,7 @@ export function apply(ctx, baseConfig) {
|
|
|
251
244
|
|
|
252
245
|
startWhisper().catch(() => {})
|
|
253
246
|
|
|
254
|
-
// SenseVoice-ONNX / Sherpa-ONNX:
|
|
247
|
+
// SenseVoice-ONNX / Sherpa-ONNX: autostart and health check.
|
|
255
248
|
let sensevoiceChild = null
|
|
256
249
|
|
|
257
250
|
async function sensevoiceAlive() {
|
|
@@ -263,7 +256,7 @@ export function apply(ctx, baseConfig) {
|
|
|
263
256
|
const t = setTimeout(() => controller.abort(), 2000)
|
|
264
257
|
const res = await fetch(base + '/', { signal: controller.signal })
|
|
265
258
|
clearTimeout(t)
|
|
266
|
-
return res.ok || res.status === 404 // sherpa-onnx
|
|
259
|
+
return res.ok || res.status === 404 // sherpa-onnx answers 404 on / but is alive
|
|
267
260
|
} catch { return false }
|
|
268
261
|
}
|
|
269
262
|
|
|
@@ -292,15 +285,15 @@ export function apply(ctx, baseConfig) {
|
|
|
292
285
|
|
|
293
286
|
startSensevoice().catch(() => {})
|
|
294
287
|
|
|
295
|
-
//
|
|
288
|
+
// Provider health and latency stats (Latency & Health Dashboard).
|
|
296
289
|
const statsTracker = createStatsTracker()
|
|
297
290
|
|
|
298
|
-
//
|
|
291
|
+
// Shared recognition path: build providers from the mode chain and run it.
|
|
299
292
|
async function transcribe(modeCfg, bytes, mime, signal, contextWords) {
|
|
300
293
|
const cfg = live()
|
|
301
294
|
const customKeys = (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
|
|
302
295
|
.map((c) => String(c && c.key || '').trim()).filter(Boolean)
|
|
303
|
-
//
|
|
296
|
+
// localOnly mode: restrict the chain to local-whisper.
|
|
304
297
|
const chain = cfg.localOnly
|
|
305
298
|
? (modeCfg.chain || []).filter((e) => e.provider === 'local-whisper')
|
|
306
299
|
: (modeCfg.chain || [])
|
|
@@ -309,8 +302,8 @@ export function apply(ctx, baseConfig) {
|
|
|
309
302
|
for (const entry of chain) {
|
|
310
303
|
if (!KNOWN_KEYS.includes(entry.provider) && !customKeys.includes(entry.provider)) continue
|
|
311
304
|
order.push(entry.provider)
|
|
312
|
-
//
|
|
313
|
-
//
|
|
305
|
+
// Custom provider default models live in their spec; makeProviders fills
|
|
306
|
+
// them in — empty here means "use the provider default".
|
|
314
307
|
models[entry.provider] = entry.model || DEFAULT_MODELS[entry.provider] || ''
|
|
315
308
|
}
|
|
316
309
|
if (cfg.localOnly && order.length === 0) {
|
|
@@ -328,8 +321,8 @@ export function apply(ctx, baseConfig) {
|
|
|
328
321
|
return runChain(order, providers, statsTracker.record)
|
|
329
322
|
}
|
|
330
323
|
|
|
331
|
-
//
|
|
332
|
-
//
|
|
324
|
+
// Transcript polish (#35) with local LLM support (#47).
|
|
325
|
+
// Errors/timeouts do not block: return the raw text.
|
|
333
326
|
async function polishText(text, modeCfg, signal) {
|
|
334
327
|
const enable = modeCfg && (modeCfg.polish === true || modeCfg.polishSend === true)
|
|
335
328
|
if (!enable || !text) return text
|
|
@@ -340,7 +333,7 @@ export function apply(ctx, baseConfig) {
|
|
|
340
333
|
+ '"ээ", "ну", "как бы"). Keep the original language, wording and meaning. '
|
|
341
334
|
+ 'Reply with the polished text only:\n\n' + text
|
|
342
335
|
try {
|
|
343
|
-
//
|
|
336
|
+
// Local OpenAI-compatible endpoint (#47) when configured.
|
|
344
337
|
if (cfg.polishBaseUrl) {
|
|
345
338
|
const headers = { 'content-type': 'application/json' }
|
|
346
339
|
if (cfg.polishKeyEnv) {
|
|
@@ -359,11 +352,11 @@ export function apply(ctx, baseConfig) {
|
|
|
359
352
|
&& data.choices[0].message.content
|
|
360
353
|
return (pick && String(pick).trim()) || text
|
|
361
354
|
}
|
|
362
|
-
//
|
|
355
|
+
// Harness model (DSH 0.1.2-alpha.1 API).
|
|
363
356
|
const llm = ctx.llm
|
|
364
357
|
if (!llm || typeof llm.stream !== 'function') return text
|
|
365
|
-
// provider/model
|
|
366
|
-
// agentDefaultModel
|
|
358
|
+
// provider/model are required in GenerateOptions; fall back to
|
|
359
|
+
// agentDefaultModel when not set explicitly.
|
|
367
360
|
const sel = (ctx.get && ctx.get('agentDefaultModel') && ctx.get('agentDefaultModel').currentSelection)
|
|
368
361
|
? ctx.get('agentDefaultModel').currentSelection()
|
|
369
362
|
: null
|
|
@@ -383,8 +376,8 @@ export function apply(ctx, baseConfig) {
|
|
|
383
376
|
} catch { return text }
|
|
384
377
|
}
|
|
385
378
|
|
|
386
|
-
//
|
|
387
|
-
//
|
|
379
|
+
// Clean session voice commands (#48). Returns the command name or null.
|
|
380
|
+
// Matched on the host so they never enter the composer text.
|
|
388
381
|
const SESSION_COMMANDS = [
|
|
389
382
|
{ re: /^(отправь|отправить|пошли|send)\s*[.!?]*$/i, cmd: 'send' },
|
|
390
383
|
{ re: /^(отмени|отмена|cancel|отменить)\s*[.!?]*$/i, cmd: 'cancel' },
|
|
@@ -409,7 +402,7 @@ export function apply(ctx, baseConfig) {
|
|
|
409
402
|
writeJson(res, 200, {
|
|
410
403
|
ok: true,
|
|
411
404
|
whisperRunning: await whisperAlive(),
|
|
412
|
-
//
|
|
405
|
+
// The hotkey is needed by the browser half: it installs the hold handler.
|
|
413
406
|
hotkey: cfg.hotkey,
|
|
414
407
|
providers: KNOWN_KEYS.concat(
|
|
415
408
|
(Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
|
|
@@ -442,9 +435,6 @@ export function apply(ctx, baseConfig) {
|
|
|
442
435
|
sensevoiceUrl: cfg.sensevoiceUrl,
|
|
443
436
|
sensevoiceAutostart: !!cfg.sensevoiceAutostart,
|
|
444
437
|
deepgramBaseUrl: cfg.deepgramBaseUrl || 'https://api.deepgram.com',
|
|
445
|
-
realtimeStreaming: !!cfg.realtimeStreaming,
|
|
446
|
-
realtimeProvider: cfg.realtimeProvider || 'openai',
|
|
447
|
-
realtimeModel: cfg.realtimeModel || 'gpt-4o-realtime-preview',
|
|
448
438
|
visualizerStyle: cfg.visualizerStyle || 'liquid-wave',
|
|
449
439
|
providerStats: statsTracker.get(),
|
|
450
440
|
})
|
|
@@ -460,7 +450,7 @@ export function apply(ctx, baseConfig) {
|
|
|
460
450
|
let raw
|
|
461
451
|
try { raw = await readBody(req, cfg.maxFileBytes + 1024 * 1024) } catch (e) { writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return }
|
|
462
452
|
let payload = {}
|
|
463
|
-
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /*
|
|
453
|
+
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
|
|
464
454
|
const text = typeof payload.text === 'string' ? payload.text.trim() : ''
|
|
465
455
|
if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
|
|
466
456
|
const controller = new AbortController()
|
|
@@ -503,12 +493,12 @@ export function apply(ctx, baseConfig) {
|
|
|
503
493
|
writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio is ${bytes.length} bytes, max ${cfg.maxFileBytes}` } }); return
|
|
504
494
|
}
|
|
505
495
|
|
|
506
|
-
//
|
|
507
|
-
//
|
|
496
|
+
// Local whisper in the chain — start the server up front, otherwise the
|
|
497
|
+
// first chunk fails while the server is still booting.
|
|
508
498
|
if ((modeCfg.chain || []).some((e) => e.provider === 'local-whisper') && !(await whisperAlive())) {
|
|
509
499
|
await startWhisper()
|
|
510
500
|
}
|
|
511
|
-
//
|
|
501
|
+
// Same for SenseVoice-ONNX / Sherpa-ONNX.
|
|
512
502
|
if ((modeCfg.chain || []).some((e) => e.provider === 'sensevoice') && !(await sensevoiceAlive())) {
|
|
513
503
|
await startSensevoice()
|
|
514
504
|
}
|
|
@@ -517,8 +507,8 @@ export function apply(ctx, baseConfig) {
|
|
|
517
507
|
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
518
508
|
try {
|
|
519
509
|
const out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
|
|
520
|
-
//
|
|
521
|
-
//
|
|
510
|
+
// Session voice commands (#48): clean "send/cancel/stop/continue"
|
|
511
|
+
// become a command for the browser instead of composer text.
|
|
522
512
|
if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
|
|
523
513
|
const cmd = sessionCommand(out.text)
|
|
524
514
|
if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
|
|
@@ -586,226 +576,8 @@ export function apply(ctx, baseConfig) {
|
|
|
586
576
|
}),
|
|
587
577
|
)
|
|
588
578
|
|
|
589
|
-
// ─── Realtime WebSocket Bridge ───────────────────────────────────────
|
|
590
|
-
// Клиент открывает WS на /dsh-voice/realtime, шлёт бинарные аудио-чанки.
|
|
591
|
-
// Хост открывает WS к провайдеру (OpenAI Realtime API или совместимому),
|
|
592
|
-
// пересылает аудио и получает текстовые дельты обратно клиенту.
|
|
593
|
-
// API-ключи никогда не покидают хост.
|
|
594
|
-
const realtimeSessions = new Set()
|
|
595
|
-
|
|
596
|
-
ctx.effect(() => ctx.webServer.register({
|
|
597
|
-
kind: 'exact',
|
|
598
|
-
path: '/dsh-voice/realtime',
|
|
599
|
-
handler: async (req, res) => {
|
|
600
|
-
const cfg = live()
|
|
601
|
-
if (!cfg.realtimeStreaming) {
|
|
602
|
-
writeJson(res, 403, { ok: false, error: { code: 'disabled', message: 'realtime streaming is disabled' } })
|
|
603
|
-
return
|
|
604
|
-
}
|
|
605
|
-
// Только WebSocket upgrade
|
|
606
|
-
if (!req.headers.upgrade || req.headers.upgrade.toLowerCase() !== 'websocket') {
|
|
607
|
-
writeJson(res, 426, { ok: false, error: { code: 'upgrade', message: 'WebSocket upgrade required' } })
|
|
608
|
-
return
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
// Получаем API-ключ для upstream-провайдера
|
|
612
|
-
const provider = cfg.realtimeProvider || 'openai'
|
|
613
|
-
let apiKey = ''
|
|
614
|
-
if (provider === 'openai') {
|
|
615
|
-
apiKey = await resolveKey('OPENAI_API_KEY')
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// Определяем upstream WebSocket URL
|
|
619
|
-
let upstreamUrl
|
|
620
|
-
const model = cfg.realtimeModel || 'gpt-4o-realtime-preview'
|
|
621
|
-
if (provider === 'openai') {
|
|
622
|
-
upstreamUrl = `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(model)}`
|
|
623
|
-
} else if (provider === 'sherpa-onnx') {
|
|
624
|
-
// Локальный sherpa-onnx WebSocket endpoint
|
|
625
|
-
const base = String(cfg.sensevoiceUrl || '').replace(/^http/, 'ws').replace(/\/api\/.*$/, '').replace(/\/v1\/.*$/, '')
|
|
626
|
-
upstreamUrl = base + '/ws/transcribe'
|
|
627
|
-
} else {
|
|
628
|
-
writeJson(res, 400, { ok: false, error: { code: 'provider', message: `unknown realtime provider: ${provider}` } })
|
|
629
|
-
return
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
// Upgrade клиентский запрос в WebSocket через ручной handshake
|
|
633
|
-
// Node.js http.Server не имеет встроенного WS — используем raw socket
|
|
634
|
-
const crypto = await import('node:crypto')
|
|
635
|
-
const wsKey = req.headers['sec-websocket-key']
|
|
636
|
-
if (!wsKey) {
|
|
637
|
-
res.writeHead(400); res.end('Missing Sec-WebSocket-Key'); return
|
|
638
|
-
}
|
|
639
|
-
const accept = crypto.createHash('sha1')
|
|
640
|
-
.update(wsKey + '258EAFA5-E914-47DA-95CA-5AB5ADF35F20')
|
|
641
|
-
.digest('base64')
|
|
642
|
-
|
|
643
|
-
res.writeHead(101, {
|
|
644
|
-
Upgrade: 'websocket',
|
|
645
|
-
Connection: 'Upgrade',
|
|
646
|
-
'Sec-WebSocket-Accept': accept,
|
|
647
|
-
})
|
|
648
|
-
|
|
649
|
-
const clientSocket = req.socket
|
|
650
|
-
|
|
651
|
-
// Простой WS frame parser/writer для бинарных и текстовых сообщений
|
|
652
|
-
function writeWsFrame(socket, data, opcode = 0x01) {
|
|
653
|
-
const payload = typeof data === 'string' ? Buffer.from(data, 'utf8') : data
|
|
654
|
-
const len = payload.length
|
|
655
|
-
let header
|
|
656
|
-
if (len < 126) {
|
|
657
|
-
header = Buffer.alloc(2)
|
|
658
|
-
header[0] = 0x80 | opcode
|
|
659
|
-
header[1] = len
|
|
660
|
-
} else if (len < 65536) {
|
|
661
|
-
header = Buffer.alloc(4)
|
|
662
|
-
header[0] = 0x80 | opcode
|
|
663
|
-
header[1] = 126
|
|
664
|
-
header.writeUInt16BE(len, 2)
|
|
665
|
-
} else {
|
|
666
|
-
header = Buffer.alloc(10)
|
|
667
|
-
header[0] = 0x80 | opcode
|
|
668
|
-
header[1] = 127
|
|
669
|
-
header.writeBigUInt64BE(BigInt(len), 2)
|
|
670
|
-
}
|
|
671
|
-
socket.write(Buffer.concat([header, payload]))
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
// Открываем upstream WebSocket через глобальный WebSocket (Node 22+)
|
|
675
|
-
let upstream = null
|
|
676
|
-
try {
|
|
677
|
-
const headers = {}
|
|
678
|
-
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
|
679
|
-
if (provider === 'openai') headers['OpenAI-Beta'] = 'realtime=v1'
|
|
680
|
-
upstream = new WebSocket(upstreamUrl, { headers })
|
|
681
|
-
} catch (e) {
|
|
682
|
-
writeWsFrame(clientSocket, JSON.stringify({ type: 'error', message: String(e && e.message || e) }))
|
|
683
|
-
clientSocket.end()
|
|
684
|
-
return
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
const sessionId = crypto.randomUUID()
|
|
688
|
-
realtimeSessions.add(sessionId)
|
|
689
|
-
|
|
690
|
-
upstream.addEventListener('open', () => {
|
|
691
|
-
writeWsFrame(clientSocket, JSON.stringify({ type: 'session.created', sessionId }))
|
|
692
|
-
// Для OpenAI Realtime — отправить session.update с конфигурацией
|
|
693
|
-
if (provider === 'openai') {
|
|
694
|
-
upstream.send(JSON.stringify({
|
|
695
|
-
type: 'session.update',
|
|
696
|
-
session: {
|
|
697
|
-
modalities: ['text'],
|
|
698
|
-
input_audio_format: 'pcm16',
|
|
699
|
-
input_audio_transcription: { model: 'whisper-1' },
|
|
700
|
-
turn_detection: { type: 'server_vad' },
|
|
701
|
-
},
|
|
702
|
-
}))
|
|
703
|
-
}
|
|
704
|
-
})
|
|
705
|
-
|
|
706
|
-
upstream.addEventListener('message', (event) => {
|
|
707
|
-
// Пересылаем текстовые сообщения от upstream к клиенту
|
|
708
|
-
const msg = typeof event.data === 'string' ? event.data : event.data.toString()
|
|
709
|
-
try {
|
|
710
|
-
const parsed = JSON.parse(msg)
|
|
711
|
-
// Фильтруем и пересылаем только полезные события
|
|
712
|
-
if (parsed.type === 'conversation.item.input_audio_transcription.completed'
|
|
713
|
-
|| parsed.type === 'response.audio_transcript.delta'
|
|
714
|
-
|| parsed.type === 'response.audio_transcript.done'
|
|
715
|
-
|| parsed.type === 'input_audio_buffer.speech_started'
|
|
716
|
-
|| parsed.type === 'input_audio_buffer.speech_stopped'
|
|
717
|
-
|| parsed.type === 'error') {
|
|
718
|
-
writeWsFrame(clientSocket, msg)
|
|
719
|
-
}
|
|
720
|
-
} catch {
|
|
721
|
-
// Не JSON — пересылаем как есть
|
|
722
|
-
writeWsFrame(clientSocket, msg)
|
|
723
|
-
}
|
|
724
|
-
})
|
|
725
|
-
|
|
726
|
-
upstream.addEventListener('error', () => {
|
|
727
|
-
writeWsFrame(clientSocket, JSON.stringify({ type: 'error', message: 'upstream connection error' }))
|
|
728
|
-
})
|
|
729
|
-
|
|
730
|
-
upstream.addEventListener('close', () => {
|
|
731
|
-
writeWsFrame(clientSocket, JSON.stringify({ type: 'session.closed' }))
|
|
732
|
-
try { clientSocket.end() } catch { /* socket уже закрыт */ }
|
|
733
|
-
realtimeSessions.delete(sessionId)
|
|
734
|
-
})
|
|
735
|
-
|
|
736
|
-
// Парсим входящие WS фреймы от клиента
|
|
737
|
-
let frameBuf = Buffer.alloc(0)
|
|
738
|
-
clientSocket.on('data', (chunk) => {
|
|
739
|
-
frameBuf = Buffer.concat([frameBuf, chunk])
|
|
740
|
-
while (frameBuf.length >= 2) {
|
|
741
|
-
const opcode = frameBuf[0] & 0x0f
|
|
742
|
-
const masked = !!(frameBuf[1] & 0x80)
|
|
743
|
-
let payloadLen = frameBuf[1] & 0x7f
|
|
744
|
-
let offset = 2
|
|
745
|
-
if (payloadLen === 126) {
|
|
746
|
-
if (frameBuf.length < 4) return
|
|
747
|
-
payloadLen = frameBuf.readUInt16BE(2)
|
|
748
|
-
offset = 4
|
|
749
|
-
} else if (payloadLen === 127) {
|
|
750
|
-
if (frameBuf.length < 10) return
|
|
751
|
-
payloadLen = Number(frameBuf.readBigUInt64BE(2))
|
|
752
|
-
offset = 10
|
|
753
|
-
}
|
|
754
|
-
if (masked) offset += 4
|
|
755
|
-
if (frameBuf.length < offset + payloadLen) return
|
|
756
|
-
|
|
757
|
-
let payload = frameBuf.subarray(offset, offset + payloadLen)
|
|
758
|
-
if (masked) {
|
|
759
|
-
const mask = frameBuf.subarray(offset - 4, offset)
|
|
760
|
-
payload = Buffer.from(payload)
|
|
761
|
-
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3]
|
|
762
|
-
}
|
|
763
|
-
frameBuf = frameBuf.subarray(offset + payloadLen)
|
|
764
|
-
|
|
765
|
-
if (opcode === 0x08) {
|
|
766
|
-
// Close frame
|
|
767
|
-
upstream.close()
|
|
768
|
-
try { clientSocket.end() } catch { /* ok */ }
|
|
769
|
-
realtimeSessions.delete(sessionId)
|
|
770
|
-
return
|
|
771
|
-
}
|
|
772
|
-
if (opcode === 0x09) {
|
|
773
|
-
// Ping → Pong
|
|
774
|
-
writeWsFrame(clientSocket, payload, 0x0a)
|
|
775
|
-
continue
|
|
776
|
-
}
|
|
777
|
-
if (opcode === 0x01) {
|
|
778
|
-
// Текст: JSON команда от клиента → upstream
|
|
779
|
-
try { upstream.send(payload.toString('utf8')) } catch { /* upstream закрыт */ }
|
|
780
|
-
} else if (opcode === 0x02) {
|
|
781
|
-
// Бинарные данные: аудио-чанк → upstream как input_audio_buffer.append
|
|
782
|
-
if (provider === 'openai') {
|
|
783
|
-
upstream.send(JSON.stringify({
|
|
784
|
-
type: 'input_audio_buffer.append',
|
|
785
|
-
audio: payload.toString('base64'),
|
|
786
|
-
}))
|
|
787
|
-
} else {
|
|
788
|
-
// Для sherpa-onnx — отправляем бинарные данные напрямую
|
|
789
|
-
try { upstream.send(payload) } catch { /* upstream закрыт */ }
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
})
|
|
794
|
-
|
|
795
|
-
clientSocket.on('close', () => {
|
|
796
|
-
try { upstream.close() } catch { /* ok */ }
|
|
797
|
-
realtimeSessions.delete(sessionId)
|
|
798
|
-
})
|
|
799
|
-
clientSocket.on('error', () => {
|
|
800
|
-
try { upstream.close() } catch { /* ok */ }
|
|
801
|
-
realtimeSessions.delete(sessionId)
|
|
802
|
-
})
|
|
803
|
-
},
|
|
804
|
-
}), 'dsh-voice: /realtime route')
|
|
805
|
-
|
|
806
579
|
ctx.effect(() => () => {
|
|
807
|
-
if (child) { try { child.kill && child.kill() } catch { /*
|
|
808
|
-
if (sensevoiceChild) { try { sensevoiceChild.kill && sensevoiceChild.kill() } catch { /*
|
|
809
|
-
for (const sid of realtimeSessions) realtimeSessions.delete(sid)
|
|
580
|
+
if (child) { try { child.kill && child.kill() } catch { /* already dead */ } }
|
|
581
|
+
if (sensevoiceChild) { try { sensevoiceChild.kill && sensevoiceChild.kill() } catch { /* already dead */ } }
|
|
810
582
|
}, 'dsh-voice: stop child processes')
|
|
811
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())
|
|
@@ -29,7 +29,7 @@ const NUM_WORDS = {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
const NUM_NAMES = Object.keys(NUM_WORDS).sort((a, b) => b.length - a.length).join('|')
|
|
32
|
-
const NUM_SEQ = new RegExp('(' + NUM_NAMES + ')(?:\\s+(' + NUM_NAMES + '))*', 'gi')
|
|
32
|
+
const NUM_SEQ = new RegExp('(?<![а-яёa-z0-9])(?:' + NUM_NAMES + ')(?:\\s+(?:' + NUM_NAMES + '))*(?![а-яёa-z0-9])', 'gi')
|
|
33
33
|
|
|
34
34
|
export function wordsToDigits(text) {
|
|
35
35
|
return text.replace(NUM_SEQ, (m) => {
|