@goodandready/dsh-voice 0.9.12 → 0.9.13

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 CHANGED
@@ -76,7 +76,7 @@ graph LR
76
76
  * 🎮 **Tactile Push-to-Talk**:
77
77
  * **Mouse**: Hold the wave button — releasing sends the message; dragging pointer away discards.
78
78
  * **Keyboard**: Hold <kbd>Ctrl</kbd> (or custom hotkey) for hands-free speaking; press <kbd>Esc</kbd> to cancel.
79
- * ⚡ **Zero-Latency In-Browser Captions (`browser`)**: Chrome Web Speech API recognition runs 100% locally with live floating captions as you speak.
79
+ * ⚡ **Live In-Browser Captions (`browser`)**: Web Speech API speech-to-text with real-time floating captions as you speak (Note: standard browser recognition typically streams audio to vendor servers like Google in Chrome; for true 100% offline privacy, use local `whisper` or `sensevoice`).
80
80
  * 🛡️ **Ironclad Multi-Provider Fallbacks**: If your primary cloud provider runs out of credits or hits a 429 rate limit, requests seamlessly fail over down the chain.
81
81
  * 🧠 **Context Glossary Injection**: Automatically extracts code variables and identifiers from your composer draft to steer STT model accuracy on technical jargon.
82
82
  * 🎵 **Embedded Audio Player**: Preview, scrubber, and playback of your recorded voice message directly in chat and the composer dock.
@@ -151,7 +151,7 @@ Quality batch after the v0.8.18 review (Gitea #79–#87, PR #88):
151
151
 
152
152
  | Provider Key | Service Backend | Default Model | Credential Ref | Features & Notes |
153
153
  |---|---|---|---|---|
154
- | `browser` | Web Speech API | Native Browser | *None* | Zero latency, floating live captions in Chrome |
154
+ | `browser` | Web Speech API | Native Browser | *None* | Zero latency, floating live captions in Chrome (vendor server-based; not offline) |
155
155
  | `deepgram` | Deepgram API | `nova-2` | `DEEPGRAM_API_KEY` | Ultra-fast cloud transcription |
156
156
  | `groq` | Groq Whisper | `whisper-large-v3-turbo` | `GROQ_API_KEY` | Near-instant inference speed |
157
157
  | `hf` | HuggingFace Inference | `openai/whisper-large-v3` | `HF_TOKEN` | High-accuracy open Whisper |
package/README.ru.md CHANGED
@@ -76,7 +76,7 @@ graph LR
76
76
  * 🎮 **Тактильный Push-to-Talk**:
77
77
  * **Мышь**: зажмите кнопку волны — запись идёт пока кнопка зажата; отпускание отправляет, увод курсора отменяет запись.
78
78
  * **Клавиатура**: зажмите <kbd>Ctrl</kbd> для записи без мыши; нажмите <kbd>Esc</kbd> для отмены.
79
- * ⚡ **Субтитры браузера в реальном времени (`browser`)**: локальное распознавание Chrome Web Speech API без отправки звука на сервер с плавающими субтитрами.
79
+ * ⚡ **Субтитры браузера в реальном времени (`browser`)**: распознавание Web Speech API с плавающими субтитрами в реальном времени (Внимание: стандартный браузерный движок в Chrome отправляет аудио на серверы Google; для полностью локальной приватной работы без внешних вызовов используйте локальный `whisper` или `sensevoice`).
80
80
  * 🛡️ **Надёжные цепочки фолбеков**: при исчерпании квоты или ошибке 429 плагин мгновенно обращается к следующему провайдеру в списке.
81
81
  * 🧠 **Контекстный словарь (Context Glossary)**: автоматическое извлечение переменных и технических терминов из черновика композера для точного распознавания редких слов и кода.
82
82
  * 🎵 **Встроенный аудиоплеер**: предпросмотр и воспроизведение записанного голосового сообщения в чате и доке перед отправкой или для переслушивания.
@@ -151,7 +151,7 @@ Quality-батч после ревью v0.8.18 (Gitea #79–#87, PR #88):
151
151
 
152
152
  | Ключ | Сервис | Модель по умолчанию | Переменная секрета | Особенности |
153
153
  |---|---|---|---|---|
154
- | `browser` | Web Speech API | Нативная в браузере | *Не требуется* | Нулевая задержка, живые субтитры в Chrome |
154
+ | `browser` | Web Speech API | Нативная в браузере | *Не требуется* | Нулевая задержка, живые субтитры в Chrome (обработка на серверах вендора; не является офлайн) |
155
155
  | `deepgram` | Deepgram API | `nova-2` | `DEEPGRAM_API_KEY` | Сверхбыстрая облачная транскрипция |
156
156
  | `groq` | Groq Whisper | `whisper-large-v3-turbo` | `GROQ_API_KEY` | Мгновенная скорость генерации |
157
157
  | `hf` | HuggingFace Inference | `openai/whisper-large-v3` | `HF_TOKEN` | Высокоточный облачный Whisper |
package/README.zh.md CHANGED
@@ -76,7 +76,7 @@ graph LR
76
76
  * 🎮 **沉浸式 Push-to-Talk 对讲**:
77
77
  * **鼠标操作**:按住声波按钮开始录音,松开发送,拖离按钮取消。
78
78
  * **键盘操作**:按住 <kbd>Ctrl</kbd> 无需鼠标即刻说话,按 <kbd>Esc</kbd> 放弃本次录音。
79
- * ⚡ **浏览器本地零延迟同声字幕 (`browser`)**:Chrome Web Speech API 本地离线解析,说话同时浮动显示实时字幕。
79
+ * ⚡ **浏览器实时同声字幕 (`browser`)**:Web Speech API 实时语音识别与浮动同声字幕(注意:Chrome 等标准浏览器通常将音频发送至服务商云端解析;若需 100% 本地离线隐私保护,请使用本地 `whisper` 或 `sensevoice`)。
80
80
  * 🛡️ **多服务商自动容灾切换**:首选 API 额度耗尽或遭遇 429 限流时,毫秒级顺位切换备用引擎。
81
81
  * 🧠 **上下文术语注入 (Context Glossary)**:自动从输入草稿中提取代码变量名与专业术语,引导 STT 模型精准转写专业词汇。
82
82
  * 🎵 **内嵌音频播放器**:在输入框与录音浮层中随时试听和回放刚刚录制的原始音频片段。
@@ -120,7 +120,7 @@ v0.8.18 质量审查后的修复批次(Gitea #79–#87,PR #88):
120
120
 
121
121
  | 服务商标识 | 对应引擎 | 默认模型 | 环境变量凭据 | 特性说明 |
122
122
  |---|---|---|---|---|
123
- | `browser` | Web Speech API | 浏览器原生引擎 | *无需密钥* | 零延迟同声字幕输出 |
123
+ | `browser` | Web Speech API | 浏览器原生引擎 | *无需密钥* | 零延迟同声字幕输出(依赖浏览器厂商云端解析;非离线本地) |
124
124
  | `deepgram` | Deepgram API | `nova-2` | `DEEPGRAM_API_KEY` | 极速高精云端转写 |
125
125
  | `groq` | Groq Whisper | `whisper-large-v3-turbo` | `GROQ_API_KEY` | 毫秒级极速推理 |
126
126
  | `hf` | HuggingFace Inference | `openai/whisper-large-v3` | `HF_TOKEN` | 经典高精度开源模型 |
package/lib/client.js CHANGED
@@ -235,6 +235,10 @@ window.__ModuleLoader__.load({
235
235
  'jargonFrom': 'Spoken slang',
236
236
  'jargonTo': 'Correct spelling',
237
237
  'jargonPlaceholder': 'k8s -> kubernetes\npr -> pull request',
238
+ 'voiceInput': 'Voice input',
239
+ 'assistantSpeakingGated': 'Assistant speaking (gated mode)',
240
+ 'jargonFormatHint': ' (spoken -> replacement)',
241
+ 'errorPrefix': 'Error',
238
242
  }
239
243
 
240
244
  const zh = {
@@ -438,11 +442,15 @@ window.__ModuleLoader__.load({
438
442
  'jargonFrom': '口语词汇',
439
443
  'jargonTo': '标准拼写',
440
444
  'jargonPlaceholder': 'k8s -> kubernetes\npr -> pull request',
445
+ 'voiceInput': '语音输入',
446
+ 'assistantSpeakingGated': '助手正在发言(门控模式)',
447
+ 'jargonFormatHint': ' (口语 -> 替换文本)',
448
+ 'errorPrefix': '错误',
441
449
  }
442
450
 
443
451
  // Strings are also needed outside components — in recording handlers and
444
452
  // slot labels — so the translator is module-level, not only via props.
445
- let moduleT = (key) => key
453
+ let moduleT = (key) => en[key] || key
446
454
  const t = (key) => moduleT(key)
447
455
  // ------------------------------------------------------------------ css
448
456
  const CSS =
@@ -862,6 +870,50 @@ window.__ModuleLoader__.load({
862
870
  return main
863
871
  }
864
872
 
873
+ function hotkeyMatches(event, name) {
874
+ if (!event || !name) return false
875
+ if (typeof name === 'string' && name.includes('+')) {
876
+ const parts = name.split('+')
877
+ const main = parts[parts.length - 1]
878
+ const needCtrl = parts.includes('Control')
879
+ const needAlt = parts.includes('Alt')
880
+ const needShift = parts.includes('Shift')
881
+ const needMeta = parts.includes('Meta')
882
+ if (needCtrl && !event.ctrlKey) return false
883
+ if (needAlt && !event.altKey) return false
884
+ if (needShift && !event.shiftKey) return false
885
+ if (needMeta && !event.metaKey) return false
886
+ return event.code === main || event.key === main
887
+ }
888
+ if (name === 'Control') return event.key === 'Control'
889
+ if (name === 'Alt') return event.key === 'Alt'
890
+ if (name === 'Shift') return event.key === 'Shift'
891
+ if (name === 'Meta') return event.key === 'Meta'
892
+ return event.code === name || event.key === name
893
+ }
894
+
895
+ function isAllowedInEditable(name) {
896
+ if (!name) return false
897
+ if (['Control', 'Alt', 'Shift', 'Meta'].includes(name)) return true
898
+ if (/^F\d{1,2}$/.test(name)) return true
899
+ if (typeof name === 'string' && name.includes('+')) return true
900
+ if (['Tab', 'Pause', 'ScrollLock', 'Insert'].includes(name)) return true
901
+ return false
902
+ }
903
+
904
+ function isHotkeyRelease(event, name) {
905
+ if (!event || !name) return false
906
+ if (typeof name === 'string' && name.includes('+')) {
907
+ const parts = name.split('+')
908
+ if (event.code === parts[parts.length - 1] || event.key === parts[parts.length - 1]) return true
909
+ for (let i = 0; i < parts.length - 1; i++) {
910
+ if (event.key === parts[i] || event.code === parts[i]) return true
911
+ }
912
+ return false
913
+ }
914
+ return event.code === name || event.key === name
915
+ }
916
+
865
917
  // Announce that the user started speaking.
866
918
  //
867
919
  // Playback should mute immediately: listening and talking at once is
@@ -1216,7 +1268,7 @@ window.__ModuleLoader__.load({
1216
1268
  if (voice.settings.bargeIn) {
1217
1269
  triggerBargeIn()
1218
1270
  } else if (voice.settings.gatedTurnTaking !== false) {
1219
- voice.set({ phase: 'error', error: 'Assistant speaking (gated mode)' })
1271
+ voice.set({ phase: 'error', error: t('assistantSpeakingGated') })
1220
1272
  return
1221
1273
  }
1222
1274
  }
@@ -1281,35 +1333,7 @@ window.__ModuleLoader__.load({
1281
1333
  if (cancelled) cancelCurrent()
1282
1334
  else stopCurrent()
1283
1335
  }
1284
- function hotkeyMatches(event, name) {
1285
- if (!event || !name) return false
1286
- if (typeof name === 'string' && name.includes('+')) {
1287
- const parts = name.split('+')
1288
- const main = parts[parts.length - 1]
1289
- const needCtrl = parts.includes('Control')
1290
- const needAlt = parts.includes('Alt')
1291
- const needShift = parts.includes('Shift')
1292
- const needMeta = parts.includes('Meta')
1293
- if (needCtrl && !event.ctrlKey) return false
1294
- if (needAlt && !event.altKey) return false
1295
- if (needShift && !event.shiftKey) return false
1296
- if (needMeta && !event.metaKey) return false
1297
- return event.code === main || event.key === main
1298
- }
1299
- if (name === 'Control') return event.key === 'Control'
1300
- if (name === 'Alt') return event.key === 'Alt'
1301
- if (name === 'Shift') return event.key === 'Shift'
1302
- if (name === 'Meta') return event.key === 'Meta'
1303
- return event.code === name || event.key === name
1304
- }
1305
- function isAllowedInEditable(name) {
1306
- if (!name) return false
1307
- if (['Control', 'Alt', 'Shift', 'Meta'].includes(name)) return true
1308
- if (/^F\d{1,2}$/.test(name)) return true
1309
- if (typeof name === 'string' && name.includes('+')) return true
1310
- if (['Tab', 'Pause', 'ScrollLock', 'Insert'].includes(name)) return true
1311
- return false
1312
- }
1336
+
1313
1337
  let activeHotkeyCleanup = null
1314
1338
  function clearGlobalHotkey() {
1315
1339
  if (typeof activeHotkeyCleanup === 'function') {
@@ -1336,18 +1360,7 @@ window.__ModuleLoader__.load({
1336
1360
  beginHold(mode)
1337
1361
  }
1338
1362
  }
1339
- function isHotkeyRelease(event, name) {
1340
- if (!event || !name) return false
1341
- if (typeof name === 'string' && name.includes('+')) {
1342
- const parts = name.split('+')
1343
- if (event.code === parts[parts.length - 1] || event.key === parts[parts.length - 1]) return true
1344
- for (let i = 0; i < parts.length - 1; i++) {
1345
- if (event.key === parts[i] || event.code === parts[i]) return true
1346
- }
1347
- return false
1348
- }
1349
- return event.code === name || event.key === name
1350
- }
1363
+
1351
1364
  const up = (event) => {
1352
1365
  if (hold.armed && isHotkeyRelease(event, keyName)) {
1353
1366
  event.preventDefault()
@@ -2386,7 +2399,7 @@ window.__ModuleLoader__.load({
2386
2399
  }
2387
2400
  }
2388
2401
  const rate = item.attempts > 0 ? Math.round((item.successes / item.attempts) * 100) : 100
2389
- return React.createElement('div', { key, className: 'dvo-dash-item', title: item.lastError ? `Error: ${item.lastError}` : '' },
2402
+ return React.createElement('div', { key, className: 'dvo-dash-item', title: item.lastError ? `${t('errorPrefix')}: ${item.lastError}` : '' },
2390
2403
  React.createElement('div', { className: 'dvo-dash-name' }, key),
2391
2404
  React.createElement('div', { className: 'dvo-dash-row' },
2392
2405
  React.createElement('span', { className: 'dvo-badge ' + badgeClass }, badgeText),
@@ -2757,7 +2770,7 @@ window.__ModuleLoader__.load({
2757
2770
  if (!res.ok || data.ok === false) {
2758
2771
  throw new Error(data.error || `HTTP ${res.status}`)
2759
2772
  }
2760
- setUpdaterMsg(data.message || 'Update completed successfully.')
2773
+ setUpdaterMsg(data.message || t('updateSuccess'))
2761
2774
  checkUpdater()
2762
2775
  } catch (err) {
2763
2776
  setUpdaterMsg(err && err.message ? err.message : String(err))
@@ -2791,7 +2804,7 @@ window.__ModuleLoader__.load({
2791
2804
  t('techJargonCorrection'),
2792
2805
  ),
2793
2806
  React.createElement('div', { className: 'cb-field' },
2794
- React.createElement('label', null, t('jargonAdd') + ' (spoken -> replacement)'),
2807
+ React.createElement('label', null, t('jargonAdd') + t('jargonFormatHint')),
2795
2808
  React.createElement('textarea', {
2796
2809
  rows: 3,
2797
2810
  disabled: !writable,
@@ -3551,7 +3564,7 @@ window.__ModuleLoader__.load({
3551
3564
  name: 'plugins.item',
3552
3565
  id: ROW_ID,
3553
3566
  order: 60,
3554
- label: () => 'Voice input',
3567
+ label: () => t('voiceInput'),
3555
3568
  locale: NS,
3556
3569
  inject: () => ({ ctx: ctx }),
3557
3570
  },
@@ -3607,6 +3620,16 @@ window.__ModuleLoader__.load({
3607
3620
  registerComposer(ctx)
3608
3621
  registerSettings(ctx)
3609
3622
  }
3623
+ exports._test = {
3624
+ voice,
3625
+ keyLabel,
3626
+ keyFromEvent,
3627
+ hotkeyMatches,
3628
+ isAllowedInEditable,
3629
+ isHotkeyRelease,
3630
+ extractContextKeywords,
3631
+ resolveBrowserRecognitionLang,
3632
+ }
3610
3633
  return module.exports
3611
3634
  },
3612
3635
  })
@@ -51,6 +51,10 @@ export function registerConfigRoutes(ctx, {
51
51
  try {
52
52
  raw = await readBody(req, 1024 * 1024)
53
53
  } catch (e) {
54
+ if (e && (e.statusCode === 413 || e.message === 'body too large')) {
55
+ writeJson(res, 413, { ok: false, error: { code: 'too-large', message: 'payload too large' } })
56
+ return
57
+ }
54
58
  writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
55
59
  return
56
60
  }
package/lib/http-util.js CHANGED
@@ -1,9 +1,30 @@
1
1
  // Pure HTTP helpers shared by host routes. No cordis, no network.
2
2
 
3
+ export class BodyTooLargeError extends Error {
4
+ constructor(message = 'body too large') {
5
+ super(message)
6
+ this.name = 'BodyTooLargeError'
7
+ this.statusCode = 413
8
+ this.code = 'ERR_BODY_TOO_LARGE'
9
+ }
10
+ }
11
+
3
12
  export function writeJson(res, code, body) {
4
13
  try {
5
- res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
14
+ const headers = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }
15
+ if (code === 413) {
16
+ headers['Connection'] = 'close'
17
+ }
18
+ res.writeHead(code, headers)
6
19
  res.end(JSON.stringify(body))
20
+ if (code === 413 && res && typeof res.on === 'function') {
21
+ res.on('finish', () => {
22
+ const sock = res.socket || (res.req && res.req.socket)
23
+ if (sock && !sock.destroyed && typeof sock.destroy === 'function') {
24
+ sock.destroy()
25
+ }
26
+ })
27
+ }
7
28
  } catch { /* socket may already be closed */ }
8
29
  }
9
30
 
@@ -17,13 +38,30 @@ export function readBody(req, maxBytes) {
17
38
  return new Promise((resolve, reject) => {
18
39
  const chunks = []
19
40
  let size = 0
20
- req.on('data', (c) => {
41
+ let exceeded = false
42
+ const onData = (c) => {
43
+ if (exceeded) return
21
44
  size += c.length
22
- if (size > maxBytes) { reject(new Error('body too large')); req.destroy(); return }
45
+ if (size > maxBytes) {
46
+ exceeded = true
47
+ if (typeof req.removeListener === 'function') {
48
+ req.removeListener('data', onData)
49
+ }
50
+ if (typeof req.resume === 'function') {
51
+ req.resume()
52
+ }
53
+ reject(new BodyTooLargeError('body too large'))
54
+ return
55
+ }
23
56
  chunks.push(c)
57
+ }
58
+ req.on('data', onData)
59
+ req.on('end', () => {
60
+ if (!exceeded) resolve(Buffer.concat(chunks))
61
+ })
62
+ req.on('error', (err) => {
63
+ if (!exceeded) reject(err)
24
64
  })
25
- req.on('end', () => resolve(Buffer.concat(chunks)))
26
- req.on('error', reject)
27
65
  })
28
66
  }
29
67
 
package/lib/index.js CHANGED
@@ -421,7 +421,14 @@ export function apply(ctx, baseConfig) {
421
421
  if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
422
422
  const cfg = live()
423
423
  let raw
424
- try { raw = await readBody(req, 1024 * 1024) } catch (e) { writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return }
424
+ try { raw = await readBody(req, 1024 * 1024) } catch (e) {
425
+ if (e && (e.statusCode === 413 || e.message === 'body too large')) {
426
+ writeJson(res, 413, { ok: false, error: { code: 'too-large', message: 'body exceeds limit of 1MB' } })
427
+ return
428
+ }
429
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
430
+ return
431
+ }
425
432
  let payload = {}
426
433
  try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
427
434
  const text = typeof payload.text === 'string' ? payload.text.trim() : ''
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.12",
3
+ "version": "0.9.13",
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",
@@ -44,8 +44,8 @@
44
44
  "test": "node --test test/*.test.mjs",
45
45
  "build:client": "node scripts/build-client.mjs",
46
46
  "build:check": "node scripts/build-client.mjs --check",
47
- "pretest": "node scripts/build-client.mjs",
48
- "lint": "node --check lib/*.js scripts/*.mjs",
47
+ "pretest": "node scripts/build-client.mjs && node scripts/lint.mjs && node scripts/pack-check.mjs",
48
+ "lint": "node scripts/lint.mjs",
49
49
  "pack:check": "node scripts/pack-check.mjs"
50
50
  },
51
51
  "dsh": {