@goodandready/dsh-voice 0.8.26 → 0.8.28

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) {
@@ -485,11 +491,24 @@ window.__ModuleLoader__.load({
485
491
  const payload = { dataBase64, mimeType: mime, mode }
486
492
  const contextWords = extractContextKeywords()
487
493
  if (contextWords && contextWords.length > 0) payload.contextWords = contextWords
488
- const res = await fetch('/dsh-voice/transcribe', {
489
- method: 'POST',
490
- headers: { 'content-type': 'application/json' },
491
- body: JSON.stringify(payload),
492
- })
494
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
495
+ const timer = controller ? setTimeout(() => controller.abort(), 60000) : null
496
+ let res = null
497
+ try {
498
+ res = await fetch('/dsh-voice/transcribe', {
499
+ method: 'POST',
500
+ headers: { 'content-type': 'application/json' },
501
+ body: JSON.stringify(payload),
502
+ signal: controller ? controller.signal : undefined,
503
+ })
504
+ } catch (e) {
505
+ if (e && e.name === 'AbortError') {
506
+ throw new Error('Transcription request timed out (60s)')
507
+ }
508
+ throw e
509
+ } finally {
510
+ if (timer) clearTimeout(timer)
511
+ }
493
512
  let parsed = null
494
513
  try { parsed = await res.json() } catch (e) { /* not json */ }
495
514
  if (!res.ok || !parsed || !parsed.ok) {
@@ -539,7 +558,8 @@ window.__ModuleLoader__.load({
539
558
  // Draft was edited manually — cut the last insert as a substring.
540
559
  const i = draft.lastIndexOf(last.added)
541
560
  if (i < 0) { insertHistory.push(last); return t('nothingToUndo') }
542
- actions.setDraft((draft.slice(0, i) + draft.slice(i + last.added.length)).replace(/\s+$/, ''))
561
+ const spliced = draft.slice(0, i) + draft.slice(i + last.added.length)
562
+ actions.setDraft(spliced.replace(/[ \t]{2,}/g, ' ').replace(/\s+$/, '').trimStart())
543
563
  }
544
564
  return t('undone')
545
565
  }
@@ -706,10 +726,24 @@ window.__ModuleLoader__.load({
706
726
  if (!rec) return
707
727
  try { rec.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
708
728
  if (rec.srcNode) { try { rec.srcNode.disconnect() } catch (e) { /* already disconnected */ } }
729
+ if (rec.filterNode) { try { rec.filterNode.disconnect() } catch (e) { /* already disconnected */ } }
730
+ if (rec.analyser) { try { rec.analyser.disconnect() } catch (e) { /* already disconnected */ } }
709
731
  if (rec.audioCtx) { try { rec.audioCtx.close() } catch (e) { /* already closed */ } }
710
732
  }
711
733
 
734
+ if (typeof window !== 'undefined') {
735
+ const onPageUnload = () => {
736
+ if (voice.rec) teardown(voice.rec)
737
+ if (voice.browser) {
738
+ try { voice.browser.abort() } catch (e) { /* ignore */ }
739
+ }
740
+ }
741
+ window.addEventListener('pagehide', onPageUnload)
742
+ window.addEventListener('beforeunload', onPageUnload)
743
+ }
744
+
712
745
  function waitStop(recorder) {
746
+ if (!recorder || recorder.state === 'inactive') return Promise.resolve()
713
747
  return new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
714
748
  }
715
749
 
@@ -792,21 +826,32 @@ window.__ModuleLoader__.load({
792
826
  if (!rec || rec.cutting || rec.closing) return
793
827
  rec.cutting = true
794
828
  const stopped = waitStop(rec.recorder)
795
- try { rec.recorder.stop() } catch (e) { /* already stopped */ }
829
+ try {
830
+ if (rec.recorder && rec.recorder.state !== 'inactive') {
831
+ rec.recorder.stop()
832
+ }
833
+ } catch (e) {
834
+ rec.cutting = false
835
+ return
836
+ }
796
837
  stopped.then(async () => {
797
838
  const blob = new Blob(rec.chunks, { type: rec.mime })
798
839
  rec.chunks = []
799
840
  rec.silenceMs = 0
800
841
  rec.hadSpeech = false
801
842
  rec.streamMs = 0
802
- if (!rec.closing) {
803
- try { rec.recorder.start() } catch (e) { /* stream already closed */ }
843
+ if (!rec.closing && !rec.cancelled) {
844
+ try {
845
+ if (rec.recorder && rec.recorder.state === 'inactive') rec.recorder.start()
846
+ } catch (e) { /* stream already closed */ }
804
847
  }
805
848
  rec.cutting = false
806
- if (blob.size < 600) return // too short — not speech
849
+ if (rec.cancelled || blob.size < 600) return // cancelled or too short — not speech
807
850
  dictationQueue = dictationQueue.then(async () => {
851
+ if (rec.cancelled) return
808
852
  try {
809
853
  const out = await sendAudio(blob, rec.mime, 'dictation')
854
+ if (rec.cancelled) return
810
855
  const text = out && out.text ? out.text : ''
811
856
  const delay = Number(voice.settings.sendDelayMs) || 0
812
857
  if (text && delay > 0 && !voice.holding) {
@@ -818,7 +863,9 @@ window.__ModuleLoader__.load({
818
863
  }
819
864
  if (text) appendDraft(text)
820
865
  } catch (e) {
821
- voice.set({ error: String(e && e.message ? e.message : e) })
866
+ if (!rec.cancelled) {
867
+ voice.set({ error: String(e && e.message ? e.message : e) })
868
+ }
822
869
  }
823
870
  })
824
871
  })
@@ -979,9 +1026,12 @@ window.__ModuleLoader__.load({
979
1026
  const rec = voice.rec
980
1027
  voice.pending = null
981
1028
  if (!rec) { voice.set({ phase: 'idle', error: '' }); return }
1029
+ rec.cancelled = true
982
1030
  rec.closing = true
983
1031
  const stopped = waitStop(rec.recorder)
984
- try { rec.recorder.stop() } catch (e) { /* already stopped */ }
1032
+ try {
1033
+ if (rec.recorder && rec.recorder.state !== 'inactive') rec.recorder.stop()
1034
+ } catch (e) { /* already stopped */ }
985
1035
  stopped.then(() => { teardown(rec); voice.rec = null; voice.set({ phase: 'idle', error: '' }) })
986
1036
  }
987
1037
 
@@ -1011,7 +1061,9 @@ window.__ModuleLoader__.load({
1011
1061
  rec.closing = true
1012
1062
  const mode = rec.mode
1013
1063
  const stopped = waitStop(rec.recorder)
1014
- try { rec.recorder.stop() } catch (e) { /* already stopped */ }
1064
+ try {
1065
+ if (rec.recorder && rec.recorder.state !== 'inactive') rec.recorder.stop()
1066
+ } catch (e) { /* already stopped */ }
1015
1067
  stopped.then(async () => {
1016
1068
  const blob = new Blob(rec.chunks, { type: rec.mime })
1017
1069
  teardown(rec)
@@ -1053,14 +1105,21 @@ window.__ModuleLoader__.load({
1053
1105
  try {
1054
1106
  const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
1055
1107
  if (draft.trim()) {
1056
- const res = await fetch('/dsh-voice/polish', {
1057
- method: 'POST',
1058
- headers: { 'content-type': 'application/json' },
1059
- body: JSON.stringify({ text: draft }),
1060
- })
1061
- const parsed = await res.json().catch(() => null)
1062
- if (parsed && parsed.ok && typeof parsed.text === 'string' && parsed.text.trim()) {
1063
- actions.setDraft(parsed.text.trim())
1108
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
1109
+ const timer = controller ? setTimeout(() => controller.abort(), 10000) : null
1110
+ try {
1111
+ const res = await fetch('/dsh-voice/polish', {
1112
+ method: 'POST',
1113
+ headers: { 'content-type': 'application/json' },
1114
+ body: JSON.stringify({ text: draft }),
1115
+ signal: controller ? controller.signal : undefined,
1116
+ })
1117
+ const parsed = await res.json().catch(() => null)
1118
+ if (parsed && parsed.ok && typeof parsed.text === 'string' && parsed.text.trim()) {
1119
+ actions.setDraft(parsed.text.trim())
1120
+ }
1121
+ } finally {
1122
+ if (timer) clearTimeout(timer)
1064
1123
  }
1065
1124
  }
1066
1125
  } catch (e) { /* polish is best-effort */ }
@@ -1126,22 +1185,35 @@ window.__ModuleLoader__.load({
1126
1185
  }
1127
1186
 
1128
1187
  // ----------------------------------------------------------- visualizers
1188
+ let _accentColorCache = { color: '', expires: 0 }
1189
+ let _softColorCache = { color: '', expires: 0 }
1190
+
1129
1191
  function accentColor(fallback) {
1192
+ const now = Date.now()
1193
+ if (now < _accentColorCache.expires && _accentColorCache.color) return _accentColorCache.color
1130
1194
  try {
1131
1195
  const root = document.documentElement
1132
1196
  const s = getComputedStyle(root)
1133
1197
  const pick = s.getPropertyValue('--dsw-alias-state-info-primary').trim()
1134
1198
  || s.getPropertyValue('--dsw-alias-label-primary').trim()
1135
- if (pick) return pick
1199
+ if (pick) {
1200
+ _accentColorCache = { color: pick, expires: now + 1000 }
1201
+ return pick
1202
+ }
1136
1203
  } catch (noTheme) { /* canvas-only fallback */ }
1137
1204
  return fallback || voice.waveColor || 'currentColor'
1138
1205
  }
1139
1206
  function softColor(fallback) {
1207
+ const now = Date.now()
1208
+ if (now < _softColorCache.expires && _softColorCache.color) return _softColorCache.color
1140
1209
  try {
1141
1210
  const s = getComputedStyle(document.documentElement)
1142
1211
  const pick = s.getPropertyValue('--dsw-alias-bg-layer-3').trim()
1143
1212
  || s.getPropertyValue('--dsw-alias-label-primary').trim()
1144
- if (pick) return pick
1213
+ if (pick) {
1214
+ _softColorCache = { color: pick, expires: now + 1000 }
1215
+ return pick
1216
+ }
1145
1217
  } catch (noTheme) { /* fallback */ }
1146
1218
  return fallback || voice.waveColor || 'currentColor'
1147
1219
  }
@@ -1528,6 +1600,7 @@ window.__ModuleLoader__.load({
1528
1600
  polishSend: !!(data && data.modes && data.modes.message && data.modes.message.polishSend),
1529
1601
  sessionCommands: !!(data && data.modes && data.modes.message && data.modes.message.sessionCommands),
1530
1602
  noiseSuppression: data && data.noiseSuppression !== false,
1603
+ noiseGateDb: Number(data && data.noiseGateDb !== undefined ? data.noiseGateDb : -45),
1531
1604
  contextGlossary: data && data.contextGlossary !== false,
1532
1605
  visualizerStyle: (data && data.visualizerStyle) || 'liquid-wave',
1533
1606
  })
@@ -1825,7 +1898,7 @@ window.__ModuleLoader__.load({
1825
1898
  const timer = setInterval(() => {
1826
1899
  if (tries >= 15) { clearInterval(timer); return }
1827
1900
  tries += 1
1828
- try { ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).describe().load() } catch (e) { /* service not up yet */ }
1901
+ try { ctx.settingsScope.describe().load() } catch (e) { /* service not up yet */ }
1829
1902
  }, 1000)
1830
1903
  return () => clearInterval(timer)
1831
1904
  }, [ready])
@@ -1841,26 +1914,27 @@ window.__ModuleLoader__.load({
1841
1914
 
1842
1915
  React.useEffect(() => {
1843
1916
  if (!ready) return
1917
+ const val = value || {}
1844
1918
  voice.settings = Object.assign({}, voice.settings, {
1845
- vadSilenceMs: Number(value && value.dictation && value.dictation.vadSilenceMs) || 700,
1846
- autoSendMs: Number(value && value.message && value.message.autoSendMs) || 4000,
1847
- beep: !!(value && value.beep),
1848
- micDeviceId: String((value && value.micDeviceId) || ''),
1849
- historyLimit: Number(value && value.historyLimit) || 20,
1850
- voiceCommands: !!(value && value.voiceCommands),
1851
- sendDelayMs: Number(value && value.dictation && value.dictation.sendDelayMs) || 0,
1852
- stream: !!(value && value.dictation && value.dictation.stream),
1853
- streamChunkMs: Number(value && value.dictation && value.dictation.streamChunkMs) || 1200,
1854
- vadAdapt: Number(value && value.dictation && value.dictation.vadAdapt) || 0,
1855
- wakeWord: String((value && value.wakeWord) || ''),
1856
- bargeIn: !!(value && value.bargeIn),
1857
- polishSend: !!(value && value.message && value.message.polishSend),
1858
- sessionCommands: !!(value && value.message && value.message.sessionCommands),
1859
- polishBaseUrl: String((value && value.polishBaseUrl) || ''),
1860
- noiseSuppression: value.noiseSuppression !== false,
1861
- noiseGateDb: Number((value && value.noiseGateDb) !== undefined ? value.noiseGateDb : -45),
1862
- contextGlossary: value.contextGlossary !== false,
1863
- visualizerStyle: (value && value.visualizerStyle) || 'liquid-wave',
1919
+ vadSilenceMs: Number(val.dictation && val.dictation.vadSilenceMs) || 700,
1920
+ autoSendMs: Number(val.message && val.message.autoSendMs) || 4000,
1921
+ beep: !!val.beep,
1922
+ micDeviceId: String(val.micDeviceId || ''),
1923
+ historyLimit: Number(val.historyLimit) || 20,
1924
+ voiceCommands: !!val.voiceCommands,
1925
+ sendDelayMs: Number(val.dictation && val.dictation.sendDelayMs) || 0,
1926
+ stream: !!(val.dictation && val.dictation.stream),
1927
+ streamChunkMs: Number(val.dictation && val.dictation.streamChunkMs) || 1200,
1928
+ vadAdapt: Number(val.dictation && val.dictation.vadAdapt) || 0,
1929
+ wakeWord: String(val.wakeWord || ''),
1930
+ bargeIn: !!val.bargeIn,
1931
+ polishSend: !!(val.message && val.message.polishSend),
1932
+ sessionCommands: !!(val.message && val.message.sessionCommands),
1933
+ polishBaseUrl: String(val.polishBaseUrl || ''),
1934
+ noiseSuppression: val.noiseSuppression !== false,
1935
+ noiseGateDb: Number(val.noiseGateDb !== undefined ? val.noiseGateDb : -45),
1936
+ contextGlossary: val.contextGlossary !== false,
1937
+ visualizerStyle: val.visualizerStyle || 'liquid-wave',
1864
1938
  })
1865
1939
  }, [ready, value])
1866
1940
 
@@ -1891,9 +1965,18 @@ window.__ModuleLoader__.load({
1891
1965
  const audioCtx = new AC()
1892
1966
  if (audioCtx.state === 'suspended') await audioCtx.resume().catch(() => {})
1893
1967
  const src = audioCtx.createMediaStreamSource(stream)
1968
+ let lastNode = src
1969
+ try {
1970
+ const filter = audioCtx.createBiquadFilter()
1971
+ filter.type = 'highpass'
1972
+ filter.frequency.value = 80
1973
+ filter.Q.value = 0.707
1974
+ src.connect(filter)
1975
+ lastNode = filter
1976
+ } catch (e) {}
1894
1977
  const analyser = audioCtx.createAnalyser()
1895
1978
  analyser.fftSize = 128
1896
- src.connect(analyser)
1979
+ lastNode.connect(analyser)
1897
1980
  testRef.current = { stream, audioCtx, analyser, active: true }
1898
1981
  setTestingMic(true)
1899
1982
 
@@ -1980,21 +2063,22 @@ window.__ModuleLoader__.load({
1980
2063
  }
1981
2064
  }
1982
2065
 
2066
+ const d = draft || {}
1983
2067
  Object.assign(voice.settings, {
1984
- vadSilenceMs: Number(draft.dictation && draft.dictation.vadSilenceMs) || 700,
1985
- autoSendMs: Number(draft.message && draft.message.autoSendMs) || 4000,
1986
- beep: !!draft.beep,
1987
- micDeviceId: String(draft.micDeviceId || ''),
1988
- historyLimit: Number(draft.historyLimit),
1989
- voiceCommands: !!draft.voiceCommands,
1990
- sendDelayMs: Number(draft.dictation && draft.dictation.sendDelayMs) || 0,
1991
- stream: !!(draft.dictation && draft.dictation.stream),
1992
- streamChunkMs: Number(draft.dictation && draft.dictation.streamChunkMs) || 1200,
1993
- vadAdapt: Number(draft.dictation && draft.dictation.vadAdapt) || 0,
1994
- noiseSuppression: draft.noiseSuppression !== false,
1995
- noiseGateDb: Number(draft.noiseGateDb !== undefined ? draft.noiseGateDb : -45),
1996
- contextGlossary: draft.contextGlossary !== false,
1997
- visualizerStyle: draft.visualizerStyle || 'liquid-wave',
2068
+ vadSilenceMs: Number(d.dictation && d.dictation.vadSilenceMs) || 700,
2069
+ autoSendMs: Number(d.message && d.message.autoSendMs) || 4000,
2070
+ beep: !!d.beep,
2071
+ micDeviceId: String(d.micDeviceId || ''),
2072
+ historyLimit: Number(d.historyLimit),
2073
+ voiceCommands: !!d.voiceCommands,
2074
+ sendDelayMs: Number(d.dictation && d.dictation.sendDelayMs) || 0,
2075
+ stream: !!(d.dictation && d.dictation.stream),
2076
+ streamChunkMs: Number(d.dictation && d.dictation.streamChunkMs) || 1200,
2077
+ vadAdapt: Number(d.dictation && d.dictation.vadAdapt) || 0,
2078
+ noiseSuppression: d.noiseSuppression !== false,
2079
+ noiseGateDb: Number(d.noiseGateDb !== undefined ? d.noiseGateDb : -45),
2080
+ contextGlossary: d.contextGlossary !== false,
2081
+ visualizerStyle: d.visualizerStyle || 'liquid-wave',
1998
2082
  })
1999
2083
  try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
2000
2084
 
@@ -2029,7 +2113,7 @@ window.__ModuleLoader__.load({
2029
2113
  React.createElement('span', { className: 'dvs-sub' }, t('hotkeyHint1') + t('hotkeyHint2')),
2030
2114
  )
2031
2115
 
2032
- const gateDbVal = Number((draft && draft.noiseGateDb) !== undefined ? draft.noiseGateDb : (value && value.noiseGateDb) !== undefined ? value.noiseGateDb : -45)
2116
+ const gateDbVal = Number((draft && draft.noiseGateDb !== undefined) ? draft.noiseGateDb : (value && value.noiseGateDb !== undefined) ? value.noiseGateDb : -45)
2033
2117
  const gateThresholdPercent = gateDbVal <= -90 ? 0 : Math.min(100, Math.max(0, Math.round(((gateDbVal + 60) / 40) * 100)))
2034
2118
 
2035
2119
  const micField = () => React.createElement('div', { className: 'cb-field' },
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/normalize.js CHANGED
@@ -17,7 +17,7 @@ export function ensureTrailingPeriod(text) {
17
17
  }
18
18
 
19
19
  const NUM_WORDS = {
20
- ноль: 0, один: 1, одна: 1, два: 2, две: 2, три: 3, четыре: 4, пять: 5,
20
+ ноль: 0, один: 1, одна: 1, одно: 1, два: 2, две: 2, три: 3, четыре: 4, пять: 5,
21
21
  шесть: 6, семь: 7, восемь: 8, девять: 9, десять: 10, одиннадцать: 11,
22
22
  двенадцать: 12, тринадцать: 13, четырнадцать: 14, пятнадцать: 15,
23
23
  шестнадцать: 16, семнадцать: 17, восемнадцать: 18, девятнадцать: 19,
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)
@@ -29,10 +29,10 @@ export function buildProviderOrder({ localOnly, chain, customKeys, knownKeys, de
29
29
  }
30
30
 
31
31
  const SESSION_COMMANDS = [
32
- { re: /^(отправь|отправить|пошли|send)\s*[.!?]*$/i, cmd: 'send' },
33
- { re: /^(отмени|отмена|cancel|отменить)\s*[.!?]*$/i, cmd: 'cancel' },
34
- { re: /^(стоп|stop|хватит)\s*[.!?]*$/i, cmd: 'stop' },
35
- { re: /^(продолжи|continue|продолжай)\s*[.!?]*$/i, cmd: 'continue' },
32
+ { re: /^(отправь|отправить|пошли|send|发送|发出去)\s*[.!?]*$/i, cmd: 'send' },
33
+ { re: /^(отмени|отмена|cancel|отменить|取消|算了)\s*[.!?]*$/i, cmd: 'cancel' },
34
+ { re: /^(стоп|stop|хватит|停止|暂停)\s*[.!?]*$/i, cmd: 'stop' },
35
+ { re: /^(продолжи|continue|продолжай|继续)\s*[.!?]*$/i, cmd: 'continue' },
36
36
  ]
37
37
 
38
38
  /**
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.28",
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",