@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 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(/\b[A-Za-z_][A-Za-z0-9_]{2,29}\b/g) || []
469
- const stop = new Set(['the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'any', 'can', 'her', 'was', 'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now', 'old', 'see', 'two', 'way', 'who', 'boy', 'did', 'its', 'let', 'put', 'say', 'she', 'too', 'use'])
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 { rec.recorder.stop() } catch (e) { /* already stopped */ }
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 { rec.recorder.start() } catch (e) { /* stream already closed */ }
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 { rec.recorder.stop() } catch (e) { /* already stopped */ }
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 { rec.recorder.stop() } catch (e) { /* already stopped */ }
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 { ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).describe().load() } catch (e) { /* service not up yet */ }
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
- src.connect(analyser)
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
- const model = pickModel(models, 'sensevoice') || 'SenseVoiceSmall'
263
- form.append('model', model)
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, no converter configured`)
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) => reject(new Error(`ffmpeg unavailable: ${e.message}`)))
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.26",
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",