@goodandready/dsh-voice 0.8.26 → 0.8.27
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 +40 -8
- package/lib/index.js +2 -1
- package/lib/providers.js +9 -8
- package/lib/wav.js +33 -2
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -465,8 +465,14 @@ window.__ModuleLoader__.load({
|
|
|
465
465
|
if (!voice.settings || voice.settings.contextGlossary === false) return []
|
|
466
466
|
const text = (voice.input && typeof voice.input.draft === 'string') ? voice.input.draft : ''
|
|
467
467
|
if (!text || text.length < 3) return []
|
|
468
|
-
const matches = text.match(
|
|
469
|
-
const stop = new Set([
|
|
468
|
+
const matches = text.match(/[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{2,29}/g) || []
|
|
469
|
+
const stop = new Set([
|
|
470
|
+
'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'any', 'can', 'her', 'was',
|
|
471
|
+
'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now',
|
|
472
|
+
'old', 'see', 'two', 'way', 'who', 'boy', 'did', 'its', 'let', 'put', 'say', 'she',
|
|
473
|
+
'too', 'use', 'это', 'как', 'что', 'для', 'или', 'если', 'все', 'при', 'так', 'уже',
|
|
474
|
+
'был', 'быть', 'только', 'тоже', 'под', 'над', 'без', 'нет', 'даже', 'где', 'чем',
|
|
475
|
+
])
|
|
470
476
|
const words = []
|
|
471
477
|
const seen = new Set()
|
|
472
478
|
for (const m of matches) {
|
|
@@ -706,10 +712,13 @@ window.__ModuleLoader__.load({
|
|
|
706
712
|
if (!rec) return
|
|
707
713
|
try { rec.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
|
|
708
714
|
if (rec.srcNode) { try { rec.srcNode.disconnect() } catch (e) { /* already disconnected */ } }
|
|
715
|
+
if (rec.filterNode) { try { rec.filterNode.disconnect() } catch (e) { /* already disconnected */ } }
|
|
716
|
+
if (rec.analyser) { try { rec.analyser.disconnect() } catch (e) { /* already disconnected */ } }
|
|
709
717
|
if (rec.audioCtx) { try { rec.audioCtx.close() } catch (e) { /* already closed */ } }
|
|
710
718
|
}
|
|
711
719
|
|
|
712
720
|
function waitStop(recorder) {
|
|
721
|
+
if (!recorder || recorder.state === 'inactive') return Promise.resolve()
|
|
713
722
|
return new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
|
|
714
723
|
}
|
|
715
724
|
|
|
@@ -792,7 +801,14 @@ window.__ModuleLoader__.load({
|
|
|
792
801
|
if (!rec || rec.cutting || rec.closing) return
|
|
793
802
|
rec.cutting = true
|
|
794
803
|
const stopped = waitStop(rec.recorder)
|
|
795
|
-
try {
|
|
804
|
+
try {
|
|
805
|
+
if (rec.recorder && rec.recorder.state !== 'inactive') {
|
|
806
|
+
rec.recorder.stop()
|
|
807
|
+
}
|
|
808
|
+
} catch (e) {
|
|
809
|
+
rec.cutting = false
|
|
810
|
+
return
|
|
811
|
+
}
|
|
796
812
|
stopped.then(async () => {
|
|
797
813
|
const blob = new Blob(rec.chunks, { type: rec.mime })
|
|
798
814
|
rec.chunks = []
|
|
@@ -800,7 +816,9 @@ window.__ModuleLoader__.load({
|
|
|
800
816
|
rec.hadSpeech = false
|
|
801
817
|
rec.streamMs = 0
|
|
802
818
|
if (!rec.closing) {
|
|
803
|
-
try {
|
|
819
|
+
try {
|
|
820
|
+
if (rec.recorder && rec.recorder.state === 'inactive') rec.recorder.start()
|
|
821
|
+
} catch (e) { /* stream already closed */ }
|
|
804
822
|
}
|
|
805
823
|
rec.cutting = false
|
|
806
824
|
if (blob.size < 600) return // too short — not speech
|
|
@@ -981,7 +999,9 @@ window.__ModuleLoader__.load({
|
|
|
981
999
|
if (!rec) { voice.set({ phase: 'idle', error: '' }); return }
|
|
982
1000
|
rec.closing = true
|
|
983
1001
|
const stopped = waitStop(rec.recorder)
|
|
984
|
-
try {
|
|
1002
|
+
try {
|
|
1003
|
+
if (rec.recorder && rec.recorder.state !== 'inactive') rec.recorder.stop()
|
|
1004
|
+
} catch (e) { /* already stopped */ }
|
|
985
1005
|
stopped.then(() => { teardown(rec); voice.rec = null; voice.set({ phase: 'idle', error: '' }) })
|
|
986
1006
|
}
|
|
987
1007
|
|
|
@@ -1011,7 +1031,9 @@ window.__ModuleLoader__.load({
|
|
|
1011
1031
|
rec.closing = true
|
|
1012
1032
|
const mode = rec.mode
|
|
1013
1033
|
const stopped = waitStop(rec.recorder)
|
|
1014
|
-
try {
|
|
1034
|
+
try {
|
|
1035
|
+
if (rec.recorder && rec.recorder.state !== 'inactive') rec.recorder.stop()
|
|
1036
|
+
} catch (e) { /* already stopped */ }
|
|
1015
1037
|
stopped.then(async () => {
|
|
1016
1038
|
const blob = new Blob(rec.chunks, { type: rec.mime })
|
|
1017
1039
|
teardown(rec)
|
|
@@ -1528,6 +1550,7 @@ window.__ModuleLoader__.load({
|
|
|
1528
1550
|
polishSend: !!(data && data.modes && data.modes.message && data.modes.message.polishSend),
|
|
1529
1551
|
sessionCommands: !!(data && data.modes && data.modes.message && data.modes.message.sessionCommands),
|
|
1530
1552
|
noiseSuppression: data && data.noiseSuppression !== false,
|
|
1553
|
+
noiseGateDb: Number(data && data.noiseGateDb !== undefined ? data.noiseGateDb : -45),
|
|
1531
1554
|
contextGlossary: data && data.contextGlossary !== false,
|
|
1532
1555
|
visualizerStyle: (data && data.visualizerStyle) || 'liquid-wave',
|
|
1533
1556
|
})
|
|
@@ -1825,7 +1848,7 @@ window.__ModuleLoader__.load({
|
|
|
1825
1848
|
const timer = setInterval(() => {
|
|
1826
1849
|
if (tries >= 15) { clearInterval(timer); return }
|
|
1827
1850
|
tries += 1
|
|
1828
|
-
try {
|
|
1851
|
+
try { ctx.settingsScope.describe().load() } catch (e) { /* service not up yet */ }
|
|
1829
1852
|
}, 1000)
|
|
1830
1853
|
return () => clearInterval(timer)
|
|
1831
1854
|
}, [ready])
|
|
@@ -1891,9 +1914,18 @@ window.__ModuleLoader__.load({
|
|
|
1891
1914
|
const audioCtx = new AC()
|
|
1892
1915
|
if (audioCtx.state === 'suspended') await audioCtx.resume().catch(() => {})
|
|
1893
1916
|
const src = audioCtx.createMediaStreamSource(stream)
|
|
1917
|
+
let lastNode = src
|
|
1918
|
+
try {
|
|
1919
|
+
const filter = audioCtx.createBiquadFilter()
|
|
1920
|
+
filter.type = 'highpass'
|
|
1921
|
+
filter.frequency.value = 80
|
|
1922
|
+
filter.Q.value = 0.707
|
|
1923
|
+
src.connect(filter)
|
|
1924
|
+
lastNode = filter
|
|
1925
|
+
} catch (e) {}
|
|
1894
1926
|
const analyser = audioCtx.createAnalyser()
|
|
1895
1927
|
analyser.fftSize = 128
|
|
1896
|
-
|
|
1928
|
+
lastNode.connect(analyser)
|
|
1897
1929
|
testRef.current = { stream, audioCtx, analyser, active: true }
|
|
1898
1930
|
setTestingMic(true)
|
|
1899
1931
|
|
package/lib/index.js
CHANGED
|
@@ -303,7 +303,7 @@ export function apply(ctx, baseConfig) {
|
|
|
303
303
|
: (Array.isArray(cfg.vocabulary) ? cfg.vocabulary : [])
|
|
304
304
|
|
|
305
305
|
const providers = makeProviders(
|
|
306
|
-
{ resolveKey, fetchImpl: fetch, cfg, toWav: (b) => toWav16k(b, cfg.ffmpegBin) },
|
|
306
|
+
{ resolveKey, fetchImpl: fetch, cfg, toWav: (b, sig) => toWav16k(b, cfg.ffmpegBin, sig || signal) },
|
|
307
307
|
{ bytes, mime, lang: modeCfg.language, signal, models, vocabulary: vocab },
|
|
308
308
|
)
|
|
309
309
|
return runChain(order, providers, statsTracker.record)
|
|
@@ -356,6 +356,7 @@ export function apply(ctx, baseConfig) {
|
|
|
356
356
|
beep: cfg.beep,
|
|
357
357
|
localOnly: cfg.localOnly,
|
|
358
358
|
micDeviceId: cfg.micDeviceId,
|
|
359
|
+
noiseGateDb: cfg.noiseGateDb !== undefined ? cfg.noiseGateDb : -45,
|
|
359
360
|
historyLimit: cfg.historyLimit,
|
|
360
361
|
voiceCommands: cfg.voiceCommands,
|
|
361
362
|
wakeWord: String(cfg.wakeWord || ''),
|
package/lib/providers.js
CHANGED
|
@@ -158,6 +158,7 @@ export function makeProviders(deps, req) {
|
|
|
158
158
|
form.append('file', new Blob([bytes], { type: mime }), fileName(mime))
|
|
159
159
|
form.append('model', pickModel(models, 'groq'))
|
|
160
160
|
if (!isAutoLang(lang)) form.append('language', lang)
|
|
161
|
+
if (vocab) form.append('prompt', vocab)
|
|
161
162
|
form.append('response_format', 'json')
|
|
162
163
|
const res = await fetchImpl('https://api.groq.com/openai/v1/audio/transcriptions', {
|
|
163
164
|
method: 'POST',
|
|
@@ -205,7 +206,7 @@ export function makeProviders(deps, req) {
|
|
|
205
206
|
return { ok: false, provider: 'local-whisper', reason: 'local whisper needs WAV, no converter configured' }
|
|
206
207
|
}
|
|
207
208
|
try {
|
|
208
|
-
sendBytes = await deps.toWav(bytes)
|
|
209
|
+
sendBytes = await deps.toWav(bytes, signal)
|
|
209
210
|
sendMime = 'audio/wav'
|
|
210
211
|
} catch (e) {
|
|
211
212
|
return { ok: false, provider: 'local-whisper', reason: `local whisper: ${String(e && e.message || e)}` }
|
|
@@ -249,7 +250,7 @@ export function makeProviders(deps, req) {
|
|
|
249
250
|
return { ok: false, provider: 'sensevoice', reason: 'sensevoice needs WAV, no converter configured' }
|
|
250
251
|
}
|
|
251
252
|
try {
|
|
252
|
-
sendBytes = await deps.toWav(bytes)
|
|
253
|
+
sendBytes = await deps.toWav(bytes, signal)
|
|
253
254
|
sendMime = 'audio/wav'
|
|
254
255
|
} catch (e) {
|
|
255
256
|
return { ok: false, provider: 'sensevoice', reason: `sensevoice: ${String(e && e.message || e)}` }
|
|
@@ -259,12 +260,11 @@ export function makeProviders(deps, req) {
|
|
|
259
260
|
form.append('file', new Blob([sendBytes], { type: sendMime }), fileName(sendMime))
|
|
260
261
|
const isOpenAI = url.includes('/transcriptions')
|
|
261
262
|
if (isOpenAI) {
|
|
262
|
-
|
|
263
|
-
form.append('
|
|
263
|
+
form.append('model', pickModel(models, 'sensevoice'))
|
|
264
|
+
if (!isAutoLang(lang)) form.append('language', lang)
|
|
265
|
+
if (vocab) form.append('prompt', vocab)
|
|
264
266
|
form.append('response_format', 'json')
|
|
265
267
|
}
|
|
266
|
-
if (!isAutoLang(lang)) form.append('language', lang)
|
|
267
|
-
if (vocab) form.append('prompt', vocab)
|
|
268
268
|
|
|
269
269
|
let res
|
|
270
270
|
try {
|
|
@@ -316,6 +316,7 @@ export function makeProviders(deps, req) {
|
|
|
316
316
|
form.append('file', new Blob([bytes], { type: mime }), fileName(mime))
|
|
317
317
|
form.append('model', model)
|
|
318
318
|
if (lang && lang !== 'auto') form.append('language', lang)
|
|
319
|
+
if (vocab) form.append('prompt', vocab)
|
|
319
320
|
form.append('response_format', 'json')
|
|
320
321
|
const res = await fetchImpl(`${base}/audio/transcriptions`, {
|
|
321
322
|
method: 'POST', headers, body: form, signal,
|
|
@@ -330,9 +331,9 @@ export function makeProviders(deps, req) {
|
|
|
330
331
|
let format = chatAudioFormat(mime)
|
|
331
332
|
if (!format) {
|
|
332
333
|
if (typeof deps.toWav !== 'function') {
|
|
333
|
-
throw new Error(`${label} needs wav or mp3,
|
|
334
|
+
throw new Error(`${label}: chat template needs wav or mp3, unsupported format ${mime}`)
|
|
334
335
|
}
|
|
335
|
-
sendBytes = await deps.toWav(bytes)
|
|
336
|
+
sendBytes = await deps.toWav(bytes, signal)
|
|
336
337
|
format = 'wav'
|
|
337
338
|
}
|
|
338
339
|
const ask = (spec.prompt || CHAT_AUDIO_PROMPT)
|
package/lib/wav.js
CHANGED
|
@@ -11,8 +11,12 @@ import { spawn } from 'node:child_process'
|
|
|
11
11
|
* @param ffmpegBin {string} ffmpeg binary path
|
|
12
12
|
* @returns {Promise<Buffer>} 16 kHz mono WAV
|
|
13
13
|
*/
|
|
14
|
-
export function toWav16k(bytes, ffmpegBin = 'ffmpeg') {
|
|
14
|
+
export function toWav16k(bytes, ffmpegBin = 'ffmpeg', signal = null) {
|
|
15
15
|
return new Promise((resolve, reject) => {
|
|
16
|
+
if (signal?.aborted) {
|
|
17
|
+
reject(new Error('ffmpeg conversion aborted before start'))
|
|
18
|
+
return
|
|
19
|
+
}
|
|
16
20
|
const proc = spawn(ffmpegBin, [
|
|
17
21
|
'-hide_banner', '-loglevel', 'error',
|
|
18
22
|
'-i', 'pipe:0',
|
|
@@ -21,10 +25,37 @@ export function toWav16k(bytes, ffmpegBin = 'ffmpeg') {
|
|
|
21
25
|
])
|
|
22
26
|
const out = []
|
|
23
27
|
const err = []
|
|
28
|
+
let done = false
|
|
29
|
+
|
|
30
|
+
const timeout = setTimeout(() => {
|
|
31
|
+
cleanup()
|
|
32
|
+
try { proc.kill('SIGKILL') } catch {}
|
|
33
|
+
reject(new Error('ffmpeg conversion timed out after 30s'))
|
|
34
|
+
}, 30000)
|
|
35
|
+
|
|
36
|
+
const onAbort = () => {
|
|
37
|
+
cleanup()
|
|
38
|
+
try { proc.kill('SIGKILL') } catch {}
|
|
39
|
+
reject(new Error('ffmpeg conversion aborted'))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (signal) signal.addEventListener('abort', onAbort, { once: true })
|
|
43
|
+
|
|
44
|
+
function cleanup() {
|
|
45
|
+
if (done) return
|
|
46
|
+
done = true
|
|
47
|
+
clearTimeout(timeout)
|
|
48
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
49
|
+
}
|
|
50
|
+
|
|
24
51
|
proc.stdout.on('data', (c) => out.push(c))
|
|
25
52
|
proc.stderr.on('data', (c) => err.push(c))
|
|
26
|
-
proc.on('error', (e) =>
|
|
53
|
+
proc.on('error', (e) => {
|
|
54
|
+
cleanup()
|
|
55
|
+
reject(new Error(`ffmpeg unavailable: ${e.message}`))
|
|
56
|
+
})
|
|
27
57
|
proc.on('close', (code) => {
|
|
58
|
+
cleanup()
|
|
28
59
|
if (code !== 0) {
|
|
29
60
|
reject(new Error(`ffmpeg exit ${code}: ${Buffer.concat(err).toString('utf8').slice(0, 200)}`))
|
|
30
61
|
return
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.27",
|
|
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",
|