@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/README.md
CHANGED
|
@@ -80,6 +80,26 @@ graph LR
|
|
|
80
80
|
|
|
81
81
|
---
|
|
82
82
|
|
|
83
|
+
## 📝 What's New in 0.8.19
|
|
84
|
+
|
|
85
|
+
Quality batch after the v0.8.18 review (Gitea #79–#87, PR #88):
|
|
86
|
+
|
|
87
|
+
| Area | Change |
|
|
88
|
+
|------|--------|
|
|
89
|
+
| Settings placeholders | Model/path hints resolve through locale at **render time** — no frozen i18n keys (#79) |
|
|
90
|
+
| Model field hints | `whisperModel` / `sensevoiceModel` show path-to-model copy, not binary-autostart hints (#82) |
|
|
91
|
+
| Visualizer | Liquid wave / dynamic orb read **DSH theme tokens** instead of hardcoded hex (#80) |
|
|
92
|
+
| Style isolation | Injected CSS uses `data-dsh-plugin="dsh-voice"` so neighbour HMR cleanup cannot strip styles (#81) |
|
|
93
|
+
| Source language | Code comments, errors, and tests are English; EN edit-command phrases added. Spoken RU STT patterns remain for recognition (#86) |
|
|
94
|
+
| Locales | **Changed:** the full inline `ru` dictionary was removed. English is the only bundled locale. Install the translation plugin for Russian UI (#85) |
|
|
95
|
+
| Client source | Browser client is built from ordered `lib/client-src/*.js` fragments via `npm run build:client` (#87) |
|
|
96
|
+
| Process docs | Added `docs/design/DESIGN.md`, `index.md`, project `AGENTS.md`, `docs/testing/unit.md` (#83) |
|
|
97
|
+
|
|
98
|
+
> [!IMPORTANT]
|
|
99
|
+
> **v0.8.19 locale behavior:** without a translation plugin the Web UI stays in English. Session/edit **spoken** command phrases still match Russian speech for STT where documented; interface labels do not ship a second dictionary.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
83
103
|
## 🎮 Four Ways to Speak
|
|
84
104
|
|
|
85
105
|
| Mode | Gesture / Trigger | Behavior |
|
package/README.ru.md
CHANGED
|
@@ -80,6 +80,26 @@ graph LR
|
|
|
80
80
|
|
|
81
81
|
---
|
|
82
82
|
|
|
83
|
+
## 📝 Что нового в 0.8.19
|
|
84
|
+
|
|
85
|
+
Quality-батч после ревью v0.8.18 (Gitea #79–#87, PR #88):
|
|
86
|
+
|
|
87
|
+
| Область | Изменение |
|
|
88
|
+
|---------|-----------|
|
|
89
|
+
| Подсказки в настройках | Хинты моделей/путей резолвятся через locale **в момент рендера** — без «замёрзших» i18n-ключей (#79) |
|
|
90
|
+
| Поля моделей | `whisperModel` / `sensevoiceModel` показывают подсказку пути к модели, а не к бинарю (#82) |
|
|
91
|
+
| Визуализатор | Liquid wave / dynamic orb читают **токены темы DSH**, без жёстких hex-цветов (#80) |
|
|
92
|
+
| Изоляция стилей | CSS-теги несут `data-dsh-plugin="dsh-voice"` — соседний HMR не снимет стили (#81) |
|
|
93
|
+
| Язык исходников | Комментарии, ошибки и тесты — английские; добавлены EN-фразы edit-команд. Устные RU-паттерны для STT сохранены (#86) |
|
|
94
|
+
| Локали | **Изменение:** полный inline-словарь `ru` удалён. В комплекте только English. Для русского UI ставьте translation-плагин (#85) |
|
|
95
|
+
| Исходники клиента | Браузерный клиент собирается из `lib/client-src/*.js` через `npm run build:client` (#87) |
|
|
96
|
+
| Документация процесса | Добавлены `docs/design/DESIGN.md`, `index.md`, `AGENTS.md`, `docs/testing/unit.md` (#83) |
|
|
97
|
+
|
|
98
|
+
> [!IMPORTANT]
|
|
99
|
+
> **Поведение локалей в v0.8.19:** без translation-плагина интерфейс Web UI остаётся английским. Устные **командные** фразы для STT по-прежнему распознают русскую речь там, где это задокументировано; второй словарь UI в пакет не входит.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
83
103
|
## 🎮 4 способа голосового ввода
|
|
84
104
|
|
|
85
105
|
| Режим | Жест / Активация | Поведение |
|
package/README.zh.md
CHANGED
|
@@ -80,6 +80,22 @@ graph LR
|
|
|
80
80
|
|
|
81
81
|
---
|
|
82
82
|
|
|
83
|
+
## 📝 0.8.19 更新说明
|
|
84
|
+
|
|
85
|
+
v0.8.18 质量审查后的修复批次(Gitea #79–#87,PR #88):
|
|
86
|
+
|
|
87
|
+
- 设置占位提示在**渲染时**通过 locale 解析,不再冻结为 i18n key(#79)
|
|
88
|
+
- `whisperModel` / `sensevoiceModel` 使用模型路径提示(#82)
|
|
89
|
+
- 可视化器改用 DSH 主题 token,不再硬编码颜色(#80)
|
|
90
|
+
- 样式标签使用 `data-dsh-plugin="dsh-voice"`,避免邻居插件 HMR 清理误删(#81)
|
|
91
|
+
- 源码语言统一为英文;界面不再内置完整 `ru` 词典(#85)
|
|
92
|
+
- 浏览器端源码拆分为 `lib/client-src/*.js`,由 `npm run build:client` 构建(#87)
|
|
93
|
+
|
|
94
|
+
> [!IMPORTANT]
|
|
95
|
+
> **v0.8.19 语言说明:** 未安装 translation 插件时,Web UI 保持英文。
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
83
99
|
## 🎮 四种语音输入方式
|
|
84
100
|
|
|
85
101
|
| 交互模式 | 触发手势 | 行为效果 |
|
package/lib/chain.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Provider fallback chain. No network and no cordis — a pure function so
|
|
2
|
+
// failure behaviour is unit-testable.
|
|
3
3
|
|
|
4
4
|
function normalizeError(err) {
|
|
5
5
|
const cause = err?.cause?.message || err?.message || String(err)
|
|
@@ -7,9 +7,9 @@ function normalizeError(err) {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* @param order {string[]}
|
|
10
|
+
* @param order {string[]} provider keys in attempt order
|
|
11
11
|
* @param providers {Record<string, () => Promise<{ok, provider?, text?, reason?}>>}
|
|
12
|
-
* @param onAttempt {(provider: string, res: {ok: boolean, tookMs: number, reason?: string}) => void}
|
|
12
|
+
* @param onAttempt {(provider: string, res: {ok: boolean, tookMs: number, reason?: string}) => void} optional stats callback
|
|
13
13
|
* @returns {Promise<{provider: string, text: string, tookMs: number}>}
|
|
14
14
|
*/
|
|
15
15
|
export async function runChain(order, providers, onAttempt) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// dsh-voice — client half (browser).
|
|
2
|
+
//
|
|
3
|
+
// Two buttons in conversation.input.right:
|
|
4
|
+
// mic — dictation: speech is cut on pauses, each chunk is recognized and
|
|
5
|
+
// appended to the composer; send stays with the user;
|
|
6
|
+
// wave — voice message: one whole recording; after recognition the text is
|
|
7
|
+
// sent to the agent when the cancel window expires.
|
|
8
|
+
//
|
|
9
|
+
// The recording pill lives in conversation.input.dock; settings are a card
|
|
10
|
+
// in settings.plugin.item.
|
|
11
|
+
|
|
12
|
+
window.__ModuleLoader__.load({
|
|
13
|
+
id: '@goodandready/dsh-voice',
|
|
14
|
+
factory: (require) => {
|
|
15
|
+
var module = { exports: {} }
|
|
16
|
+
var exports = module.exports
|
|
17
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
18
|
+
let React = require('react')
|
|
19
|
+
|
|
20
|
+
const NS = 'dsh-voice'
|
|
21
|
+
|
|
22
|
+
// UI strings live in the locale registry so a separate package can
|
|
23
|
+
// translate them without touching this plugin. English is the source
|
|
24
|
+
// language, the default, and the fallback.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const en = {
|
|
2
|
+
'saveFailed': 'Some fields were not saved —',
|
|
3
|
+
'cardHint': 'Dictation and voice messages: providers, chains, local whisper',
|
|
4
|
+
'expand': 'Expand',
|
|
5
|
+
'collapse': 'Collapse',
|
|
6
|
+
'composerUnavailable': 'Composer unavailable',
|
|
7
|
+
'keySpace': 'Space',
|
|
8
|
+
'keyUnset': 'not set',
|
|
9
|
+
'recognitionError': 'recognition failed',
|
|
10
|
+
'micUnavailable': 'Microphone unavailable: needs HTTPS or localhost',
|
|
11
|
+
'noRecorder': 'MediaRecorder is not supported by this browser',
|
|
12
|
+
'browserFailed': 'Browser did not recognize: ',
|
|
13
|
+
'nothingHeard': 'Nothing was recognized',
|
|
14
|
+
'dictationBtn': 'Voice typing',
|
|
15
|
+
'messageBtn': 'Voice message — click or hold',
|
|
16
|
+
'dictationPill': 'Dictation — text is appended to the input',
|
|
17
|
+
'messagePill': 'Recording a voice message',
|
|
18
|
+
'cancel': 'Cancel',
|
|
19
|
+
'holdHint': 'Hold — release to send',
|
|
20
|
+
'listening': 'Listening in the browser…',
|
|
21
|
+
'stop': 'Stop',
|
|
22
|
+
'transcribing': 'Transcribing…',
|
|
23
|
+
'sendingIn': 'Sending to the agent in',
|
|
24
|
+
'secondsShort': ' s',
|
|
25
|
+
'keepPending': 'Do not send',
|
|
26
|
+
'hide': 'Hide',
|
|
27
|
+
'title': 'Voice',
|
|
28
|
+
'recordSlot': 'Voice recording',
|
|
29
|
+
'browserHint': 'the browser does the recognition, no key needed',
|
|
30
|
+
'openaiHint': 'whisper-1 · key OPENAI_API_KEY',
|
|
31
|
+
'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · key SILICONFLOW_API_KEY',
|
|
32
|
+
'deepinfraHint': 'openai/whisper-large-v3-turbo · key DEEPINFRA_API_KEY',
|
|
33
|
+
'fireworksHint': 'whisper-v3-turbo · key FIREWORKS_API_KEY',
|
|
34
|
+
'mistralHint': 'voxtral-mini-latest · key MISTRAL_API_KEY',
|
|
35
|
+
'openrouterHint': 'google/gemini-2.5-flash · key OPENROUTER_API_KEY',
|
|
36
|
+
'localHint': 'set when the server is started',
|
|
37
|
+
'up': 'Up',
|
|
38
|
+
'down': 'Down',
|
|
39
|
+
'remove': 'Remove',
|
|
40
|
+
'addProvider': 'Add provider',
|
|
41
|
+
'chainHint': 'Top to bottom is the order they are tried in',
|
|
42
|
+
'customName': 'name used in the chain',
|
|
43
|
+
'customModel': 'model',
|
|
44
|
+
'customKeyName': 'key name',
|
|
45
|
+
'customModelHint': 'model to request (empty means the built-in one)',
|
|
46
|
+
'addCustom': 'Add your own provider',
|
|
47
|
+
'loadingSettings': 'Loading settings…',
|
|
48
|
+
'notReady1': 'The harness has not announced this plugin’s settings yet. If it has just restarted, ',
|
|
49
|
+
'notReady2': 'the section will appear on its own in a few seconds.',
|
|
50
|
+
'hotkey': 'Voice message key',
|
|
51
|
+
'pressKey': 'Press a key…',
|
|
52
|
+
'clearKey': 'Clear the key',
|
|
53
|
+
'hotkeyHint1': 'Hold it to record, release to send to the agent, Esc cancels. ',
|
|
54
|
+
'hotkeyHint2': 'Any key will do: a letter, an F-key or a modifier.',
|
|
55
|
+
'language': 'Language',
|
|
56
|
+
'dictationHint': 'Speech is cut at pauses and the text is appended to the input.',
|
|
57
|
+
'pauseMs': 'Pause that ends a phrase, ms',
|
|
58
|
+
'pauseHint': 'Lower means more frequent chunks and faster text, but a higher risk of cutting a word',
|
|
59
|
+
'speaking': 'You are speaking…',
|
|
60
|
+
'silence': 'Pause…',
|
|
61
|
+
'normalizeTranscript': 'Normalize file transcripts',
|
|
62
|
+
'undo': 'Undo last insert',
|
|
63
|
+
'undone': 'Insert undone',
|
|
64
|
+
'nothingToUndo': 'Nothing to undo',
|
|
65
|
+
'beep': 'Beep on start/stop',
|
|
66
|
+
'localOnly': 'Local whisper only',
|
|
67
|
+
'localOnlyHint': 'Restrict both chains to the local whisper.cpp server: fully offline.',
|
|
68
|
+
'sendDelay': 'Dictation send delay (ms)',
|
|
69
|
+
'sendDelayHint': 'Wait before appending a dictated phrase, with a cancel window. 0 = off',
|
|
70
|
+
'mic': 'Microphone',
|
|
71
|
+
'micDefault': 'System default',
|
|
72
|
+
'vocabulary': 'Custom vocabulary (one word per line)',
|
|
73
|
+
'polish': 'Polish transcript with model',
|
|
74
|
+
'polishHint': 'Fix punctuation and fillers via the harness model before inserting',
|
|
75
|
+
'stream': 'Continuous dictation',
|
|
76
|
+
'streamHint': 'Cut phrases by a timer while you speak instead of waiting for a long pause',
|
|
77
|
+
'streamChunkMs': 'Stream chunk (ms)',
|
|
78
|
+
'vadAdapt': 'Adaptive silence',
|
|
79
|
+
'vadAdaptHint': 'Auto-tune the silence threshold to the pace of your speech. 0 = fixed',
|
|
80
|
+
'wakeWord': 'Wake word',
|
|
81
|
+
'wakeWordHint': 'Browser recognition starts recording when speech begins with this phrase. Empty = off',
|
|
82
|
+
'bargeIn': 'Barge-in',
|
|
83
|
+
'polishSend': 'Polish whole draft before sending',
|
|
84
|
+
'polishSendHint': 'Run the composed draft through the model right before sending',
|
|
85
|
+
'sessionCommands': 'Voice session commands',
|
|
86
|
+
'sessionCommandsHint': '"send", "cancel", "stop", "continue" act on the session instead of becoming text',
|
|
87
|
+
'polishBaseUrl': 'Offline polish endpoint',
|
|
88
|
+
'polishBaseUrlHint': 'OpenAI-compatible /chat/completions base URL, e.g. a local Ollama. Empty = harness model',
|
|
89
|
+
'polishModel': 'Offline polish model',
|
|
90
|
+
'polishKeyEnv': 'Offline polish key credential',
|
|
91
|
+
'voiceCommandsLabel': 'Voice edit commands ("new line", "paragraph")',
|
|
92
|
+
'normalizeTranscriptHint': 'transcribe_audio: spoken numbers to digits, tidy punctuation',
|
|
93
|
+
'messageTitle': 'Voice message',
|
|
94
|
+
'messageHint': 'One whole recording, sent to the agent once it is transcribed.',
|
|
95
|
+
'undoMs': 'Undo window, ms',
|
|
96
|
+
'undoHint': 'How long the automatic send can still be called off',
|
|
97
|
+
'customTitle': 'Your own providers',
|
|
98
|
+
'customHint': 'Any OpenAI-compatible API. The name becomes available in the chains above.',
|
|
99
|
+
'general': 'General',
|
|
100
|
+
'whisperEndpoint': 'Local whisper: endpoint',
|
|
101
|
+
'whisperEndpointHint': 'POST /inference of a whisper.cpp server',
|
|
102
|
+
'deepgramEndpoint': 'Deepgram: base URL',
|
|
103
|
+
'deepgramEndpointHint': 'Base URL for Deepgram or self-hosted deployment (default https://api.deepgram.com)',
|
|
104
|
+
'whisperBin': 'Local whisper: binary',
|
|
105
|
+
'whisperBinHint': 'used when autostart is on',
|
|
106
|
+
'whisperModel': 'Local whisper: model',
|
|
107
|
+
'whisperModelHint': 'Absolute path to the ggml model file',
|
|
108
|
+
'whisperAutostart': 'Autostart the local whisper',
|
|
109
|
+
'save': 'Save',
|
|
110
|
+
'saved': 'Saved ✓',
|
|
111
|
+
'openrouterWarning': 'OpenRouter has no /audio/transcriptions \u2014 use the openai-chat-audio template there',
|
|
112
|
+
'noiseSuppression': 'Hardware noise suppression',
|
|
113
|
+
'noiseSuppressionHint': 'Enable browser noise suppression, echo cancellation, and auto gain control',
|
|
114
|
+
'contextGlossary': 'Context glossary injection',
|
|
115
|
+
'contextGlossaryHint': 'Auto-extract code identifiers and terms from composer to improve STT accuracy',
|
|
116
|
+
'providerDashboard': 'Provider Latency & Health',
|
|
117
|
+
'avgLatency': 'Avg latency',
|
|
118
|
+
'successRate': 'Success',
|
|
119
|
+
'fast': 'Fast',
|
|
120
|
+
'normal': 'Normal',
|
|
121
|
+
'slow': 'Slow',
|
|
122
|
+
'error': 'Error',
|
|
123
|
+
'idle': 'No calls',
|
|
124
|
+
'play': 'Play',
|
|
125
|
+
'pause': 'Pause',
|
|
126
|
+
'listenBack': 'Listen back',
|
|
127
|
+
'lastRecording': 'Last voice note',
|
|
128
|
+
'sensevoiceHint': 'SenseVoice-ONNX / Sherpa-ONNX · ultra-fast local STT (~50ms)',
|
|
129
|
+
'sensevoiceEndpoint': 'SenseVoice: endpoint',
|
|
130
|
+
'sensevoiceEndpointHint': 'POST endpoint of sherpa-onnx or compatible server',
|
|
131
|
+
'sensevoiceBin': 'SenseVoice: binary',
|
|
132
|
+
'sensevoiceBinHint': 'used when autostart is on',
|
|
133
|
+
'sensevoiceModel': 'SenseVoice: model path',
|
|
134
|
+
'sensevoiceModelHint': 'Absolute path or identifier of the SenseVoice / sherpa-onnx model',
|
|
135
|
+
'sensevoiceAutostart': 'Autostart the SenseVoice server',
|
|
136
|
+
'visualizerStyle': 'Audio visualizer style',
|
|
137
|
+
'visualizerStyleHint': 'Waveform animation inside the recording pill',
|
|
138
|
+
'visLiquidWave': 'Liquid Wave',
|
|
139
|
+
'visDynamicOrb': 'Dynamic Orb',
|
|
140
|
+
'visBars': 'Classic Bars',
|
|
141
|
+
'visOff': 'Off',
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Strings are also needed outside components — in recording handlers and
|
|
145
|
+
// slot labels — so the translator is module-level, not only via props.
|
|
146
|
+
let moduleT = (key) => key
|
|
147
|
+
const t = (key) => moduleT(key)
|
|
148
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// ------------------------------------------------------------------ css
|
|
2
|
+
const CSS =
|
|
3
|
+
'.dvo-btn{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0;box-sizing:border-box}' +
|
|
4
|
+
'.dvo-btn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}' +
|
|
5
|
+
'.dvo-btn[data-err="1"]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}' +
|
|
6
|
+
'.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px rgba(0,0,0,.18);box-sizing:border-box}' +
|
|
7
|
+
'.dvo-pbtn{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary)}' +
|
|
8
|
+
'.dvo-pbtn:hover{background:var(--dsw-alias-bg-layer-2)}' +
|
|
9
|
+
'.dvo-wave{flex:1;height:40px;width:100%;color:var(--dsw-alias-label-primary)}' +
|
|
10
|
+
'.dvo-status{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
|
11
|
+
'.dvo-err{color:var(--dsw-alias-state-error-primary)}' +
|
|
12
|
+
'.dvo-count{font-variant-numeric:tabular-nums;font-size:13px;color:var(--dsw-alias-label-secondary)}' +
|
|
13
|
+
'.dvo-spin{animation:dvo-spin 1s linear infinite}' +
|
|
14
|
+
'@keyframes dvo-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
|
|
15
|
+
'.dvo-btn-active{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}' +
|
|
16
|
+
'.dvo-audio-wrap{display:flex;align-items:center;gap:8px;padding:2px 10px;background:var(--dsw-alias-bg-layer-2);border-radius:14px;border:1px solid var(--dsw-alias-border-l1)}' +
|
|
17
|
+
'.dvo-audio-play{display:flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);border:0;cursor:pointer;padding:0}' +
|
|
18
|
+
'.dvo-audio-play:hover{opacity:0.9}' +
|
|
19
|
+
'.dvo-audio-time{font-size:12px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}' +
|
|
20
|
+
'.dvo-dash{display:flex;flex-direction:column;gap:8px;margin-top:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:12px}' +
|
|
21
|
+
'.dvo-dash-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px;margin-top:4px}' +
|
|
22
|
+
'.dvo-dash-item{display:flex;flex-direction:column;gap:4px;padding:8px 10px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l1);border-radius:8px}' +
|
|
23
|
+
'.dvo-dash-name{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
|
|
24
|
+
'.dvo-dash-row{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--dsw-alias-label-secondary)}' +
|
|
25
|
+
'.dvo-badge{display:inline-flex;align-items:center;font-size:11px;padding:1px 6px;border-radius:6px;font-weight:600}' +
|
|
26
|
+
'.dvo-badge-fast{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-success-primary);border:1px solid var(--dsw-alias-state-success-primary)}' +
|
|
27
|
+
'.dvo-badge-norm{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-state-warning-primary)}' +
|
|
28
|
+
'.dvo-badge-slow{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-border-l2)}' +
|
|
29
|
+
'.dvo-badge-err{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-error-primary);border:1px solid var(--dsw-alias-state-error-primary)}' +
|
|
30
|
+
'.dvo-badge-idle{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary)}'
|
|
31
|
+
const cssId = 'dsh-voice/client.module.css'
|
|
32
|
+
if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + cssId + '"]')) {
|
|
33
|
+
const tag = document.createElement('style')
|
|
34
|
+
tag.textContent = CSS
|
|
35
|
+
tag.setAttribute('data-dsh-plugin', 'dsh-voice')
|
|
36
|
+
tag.dataset.pluginCss = cssId
|
|
37
|
+
document.head.appendChild(tag)
|
|
38
|
+
}
|
|
39
|
+
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// ---------------------------------------------------------------- store
|
|
2
|
+
const voice = {
|
|
3
|
+
phase: 'idle', // idle | recording | processing | pending | error
|
|
4
|
+
mode: 'dictation', // dictation | message
|
|
5
|
+
error: '',
|
|
6
|
+
rec: null,
|
|
7
|
+
levels: [],
|
|
8
|
+
pending: null, // {text, leftMs} — message-mode cancel window
|
|
9
|
+
lastNote: null, // {blob, url, mime, text} — last recording
|
|
10
|
+
showPlayer: false,
|
|
11
|
+
inputActions: null,
|
|
12
|
+
input: null,
|
|
13
|
+
settings: { vadSilenceMs: 700, autoSendMs: 4000, stream: false, streamChunkMs: 1200, vadAdapt: 0, noiseSuppression: true, contextGlossary: true },
|
|
14
|
+
listeners: new Set(),
|
|
15
|
+
notify() { this.listeners.forEach((l) => l()) },
|
|
16
|
+
set(patch) { Object.assign(this, patch); this.notify() },
|
|
17
|
+
subscribe(l) { this.listeners.add(l); return () => this.listeners.delete(l) },
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function useVoice() {
|
|
21
|
+
const [, force] = React.useReducer((x) => x + 1, 0)
|
|
22
|
+
React.useEffect(() => voice.subscribe(force), [])
|
|
23
|
+
return voice
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------- icons
|
|
27
|
+
const ic = { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' }
|
|
28
|
+
const playIcon = () => React.createElement('svg', Object.assign({}, ic, { viewBox: '0 0 24 24' }),
|
|
29
|
+
React.createElement('polygon', { points: '6 4 20 12 6 20 6 4', fill: 'currentColor', stroke: 'none' }))
|
|
30
|
+
const pauseIcon = () => React.createElement('svg', Object.assign({}, ic, { viewBox: '0 0 24 24' }),
|
|
31
|
+
React.createElement('rect', { x: 6, y: 4, width: 4, height: 16, fill: 'currentColor', stroke: 'none' }),
|
|
32
|
+
React.createElement('rect', { x: 14, y: 4, width: 4, height: 16, fill: 'currentColor', stroke: 'none' }))
|
|
33
|
+
const micIcon = () => React.createElement('svg', ic,
|
|
34
|
+
React.createElement('path', { d: 'M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z' }),
|
|
35
|
+
React.createElement('path', { d: 'M19 10v2a7 7 0 0 1-14 0v-2' }),
|
|
36
|
+
React.createElement('line', { x1: 12, y1: 19, x2: 12, y2: 23 }))
|
|
37
|
+
const waveIcon = () => React.createElement('svg', ic,
|
|
38
|
+
React.createElement('line', { x1: 4, y1: 10, x2: 4, y2: 14 }),
|
|
39
|
+
React.createElement('line', { x1: 8, y1: 7, x2: 8, y2: 17 }),
|
|
40
|
+
React.createElement('line', { x1: 12, y1: 4, x2: 12, y2: 20 }),
|
|
41
|
+
React.createElement('line', { x1: 16, y1: 7, x2: 16, y2: 17 }),
|
|
42
|
+
React.createElement('line', { x1: 20, y1: 10, x2: 20, y2: 14 }))
|
|
43
|
+
const xIcon = () => React.createElement('svg', ic,
|
|
44
|
+
React.createElement('line', { x1: 18, y1: 6, x2: 6, y2: 18 }),
|
|
45
|
+
React.createElement('line', { x1: 6, y1: 6, x2: 18, y2: 18 }))
|
|
46
|
+
const stopIcon = () => React.createElement('svg', ic,
|
|
47
|
+
React.createElement('rect', { x: 7, y: 7, width: 10, height: 10, rx: 2.5, fill: 'currentColor', stroke: 'none' }))
|
|
48
|
+
const spinIcon = () => React.createElement('svg', Object.assign({}, ic, { className: 'dvo-spin' }),
|
|
49
|
+
React.createElement('path', { d: 'M21 12a9 9 0 1 1-6.219-8.56' }))
|
|
50
|
+
const warnIcon = () => React.createElement('svg', ic,
|
|
51
|
+
React.createElement('path', { d: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z' }),
|
|
52
|
+
React.createElement('line', { x1: 12, y1: 9, x2: 12, y2: 13 }),
|
|
53
|
+
React.createElement('line', { x1: 12, y1: 17, x2: 12.01, y2: 17 }))
|
|
54
|
+
const chevronIcon = () => React.createElement('svg', ic,
|
|
55
|
+
React.createElement('path', { d: 'M6 9l6 6 6-6' }))
|
|
56
|
+
|
|
57
|
+
// ------------------------------------------------------------ transport
|
|
58
|
+
function blobToBase64(blob) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
if (typeof FileReader === 'undefined') { reject(new Error('FileReader not supported')); return }
|
|
61
|
+
const fr = new FileReader()
|
|
62
|
+
fr.onload = () => { const s = String(fr.result || ''); resolve(s.indexOf(',') >= 0 ? s.slice(s.indexOf(',') + 1) : s) }
|
|
63
|
+
fr.onerror = () => reject(new Error('failed to read audio'))
|
|
64
|
+
fr.readAsDataURL(blob)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function extractContextKeywords() {
|
|
69
|
+
if (!voice.settings || voice.settings.contextGlossary === false) return []
|
|
70
|
+
const text = (voice.input && typeof voice.input.draft === 'string') ? voice.input.draft : ''
|
|
71
|
+
if (!text || text.length < 3) return []
|
|
72
|
+
const matches = text.match(/\b[A-Za-z_][A-Za-z0-9_]{2,29}\b/g) || []
|
|
73
|
+
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'])
|
|
74
|
+
const words = []
|
|
75
|
+
const seen = new Set()
|
|
76
|
+
for (const m of matches) {
|
|
77
|
+
const lower = m.toLowerCase()
|
|
78
|
+
if (!stop.has(lower) && !seen.has(lower)) {
|
|
79
|
+
seen.add(lower)
|
|
80
|
+
words.push(m)
|
|
81
|
+
if (words.length >= 30) break
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return words
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function sendAudio(blob, mime, mode) {
|
|
88
|
+
const dataBase64 = await blobToBase64(blob)
|
|
89
|
+
const payload = { dataBase64, mimeType: mime, mode }
|
|
90
|
+
const contextWords = extractContextKeywords()
|
|
91
|
+
if (contextWords && contextWords.length > 0) payload.contextWords = contextWords
|
|
92
|
+
const res = await fetch('/dsh-voice/transcribe', {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { 'content-type': 'application/json' },
|
|
95
|
+
body: JSON.stringify(payload),
|
|
96
|
+
})
|
|
97
|
+
let parsed = null
|
|
98
|
+
try { parsed = await res.json() } catch (e) { /* not json */ }
|
|
99
|
+
if (!res.ok || !parsed || !parsed.ok) {
|
|
100
|
+
throw new Error((parsed && parsed.error && parsed.error.message) || `HTTP ${res.status}`)
|
|
101
|
+
}
|
|
102
|
+
if (parsed.command) return { command: parsed.command, text: '' }
|
|
103
|
+
return { text: String(parsed.text || '').trim(), command: null }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function tidyPhrase(text) {
|
|
107
|
+
let s = String(text || '').trim()
|
|
108
|
+
if (!s) return s
|
|
109
|
+
s = s.replace(/\s*,\s*/g, ', ')
|
|
110
|
+
s = s.replace(/(^|[.!?\n]\s+)([a-zа-яё])/gi, (m, lead, ch) => lead + ch.toUpperCase())
|
|
111
|
+
return s
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Spoken edit commands (#37): "new line" / "с новой строки" -> \n and so on.
|
|
115
|
+
// Applied before normalization, only when enabled in settings.
|
|
116
|
+
// Russian phrases match RU STT output; English covers EN dictation.
|
|
117
|
+
const VOICE_COMMANDS = [
|
|
118
|
+
[/(^|[\s,.!?])с новой строки([\s,.!?]|$)/gi, '$1\n$2'],
|
|
119
|
+
[/(^|[\s,.!?])новая строка([\s,.!?]|$)/gi, '$1\n$2'],
|
|
120
|
+
[/(^|[\s,.!?])абзац([\s,.!?]|$)/gi, '$1\n\n$2'],
|
|
121
|
+
[/(^|\s)тире(\s|$)/gi, '$1—$2'],
|
|
122
|
+
[/(^|[\s,.!?])new line([\s,.!?]|$)/gi, '$1\n$2'],
|
|
123
|
+
[/(^|[\s,.!?])paragraph([\s,.!?]|$)/gi, '$1\n\n$2'],
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
function applyVoiceCommands(text) {
|
|
127
|
+
let s = text
|
|
128
|
+
for (const [re, to] of VOICE_COMMANDS) s = s.replace(re, to)
|
|
129
|
+
return s.replace(/[ \t]+/g, ' ').trim()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Insert history for undo (#29-9). Browser-only storage.
|
|
133
|
+
const insertHistory = []
|
|
134
|
+
|
|
135
|
+
async function undoLastInsert() {
|
|
136
|
+
const last = insertHistory.pop()
|
|
137
|
+
if (!last) return t('nothingToUndo')
|
|
138
|
+
const actions = voice.inputActions
|
|
139
|
+
if (!actions || typeof actions.setDraft !== 'function') return t('composerUnavailable')
|
|
140
|
+
const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
|
|
141
|
+
if (draft === last.after) actions.setDraft(last.before)
|
|
142
|
+
else {
|
|
143
|
+
// Draft was edited manually — cut the last insert as a substring.
|
|
144
|
+
const i = draft.lastIndexOf(last.added)
|
|
145
|
+
if (i < 0) { insertHistory.push(last); return t('nothingToUndo') }
|
|
146
|
+
actions.setDraft((draft.slice(0, i) + draft.slice(i + last.added.length)).replace(/\s+$/, ''))
|
|
147
|
+
}
|
|
148
|
+
return t('undone')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function appendDraft(text) {
|
|
152
|
+
const actions = voice.inputActions
|
|
153
|
+
if (!actions || typeof actions.setDraft !== 'function') {
|
|
154
|
+
voice.set({ phase: 'error', error: t('composerUnavailable') })
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
let clean = voice.settings.voiceCommands ? applyVoiceCommands(text) : tidyPhrase(text)
|
|
158
|
+
if (!clean) return
|
|
159
|
+
const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
|
|
160
|
+
const before = draft
|
|
161
|
+
actions.setDraft(draft ? draft + ' ' + clean : clean)
|
|
162
|
+
const limit = Number(voice.settings.historyLimit)
|
|
163
|
+
if (limit > 0) {
|
|
164
|
+
insertHistory.push({ before, added: draft ? ' ' + clean : clean, after: draft ? draft + ' ' + clean : clean })
|
|
165
|
+
while (insertHistory.length > limit) insertHistory.shift()
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Human-readable key name.
|
|
170
|
+
const KEY_LABELS = {
|
|
171
|
+
Control: 'Ctrl', Alt: 'Alt', Shift: 'Shift', Meta: 'Win',
|
|
172
|
+
Escape: 'Esc',
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function keyLabel(name) {
|
|
176
|
+
if (!name) return t('keyUnset')
|
|
177
|
+
if (name === 'Space') return t('keySpace')
|
|
178
|
+
if (KEY_LABELS[name]) return KEY_LABELS[name]
|
|
179
|
+
// Drop the Key/Digit prefix from codes like KeyR and Digit5.
|
|
180
|
+
return String(name).replace(/^Key/, '').replace(/^Digit/, '')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// What to store on key press. Pure modifiers are remembered by name:
|
|
184
|
+
// left and right have different codes but the user means "either".
|
|
185
|
+
function keyFromEvent(event) {
|
|
186
|
+
if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) return event.key
|
|
187
|
+
if (event.code) return event.code
|
|
188
|
+
return event.key || ''
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Announce that the user started speaking.
|
|
192
|
+
//
|
|
193
|
+
// Playback should mute immediately: listening and talking at once is
|
|
194
|
+
// impossible. There is no plugin-to-plugin API — this broadcasts a window
|
|
195
|
+
// event that anyone may hear, so either side works alone.
|
|
196
|
+
function announceVoice(phase) {
|
|
197
|
+
try {
|
|
198
|
+
window.dispatchEvent(new CustomEvent('dsh-voice:speaking', { detail: { phase } }))
|
|
199
|
+
} catch (noEvents) { /* no window — nobody to hear it */ }
|
|
200
|
+
if (voice.settings.beep) playBeep(phase === 'start' ? 880 : 660)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Short WebAudio beep so start/stop is audible without looking (#29-6).
|
|
204
|
+
function playBeep(freq) {
|
|
205
|
+
try {
|
|
206
|
+
const AC = typeof AudioContext !== 'undefined' ? AudioContext
|
|
207
|
+
: (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
|
|
208
|
+
if (!AC) return
|
|
209
|
+
const ac = new AC()
|
|
210
|
+
const osc = ac.createOscillator()
|
|
211
|
+
const gain = ac.createGain()
|
|
212
|
+
osc.frequency.value = freq
|
|
213
|
+
osc.type = 'sine'
|
|
214
|
+
gain.gain.setValueAtTime(0.12, ac.currentTime)
|
|
215
|
+
gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.09)
|
|
216
|
+
osc.connect(gain); gain.connect(ac.destination)
|
|
217
|
+
osc.start(); osc.stop(ac.currentTime + 0.1)
|
|
218
|
+
osc.onended = () => { try { ac.close() } catch (e) { /* already closed */ } }
|
|
219
|
+
} catch (noAudio) { /* no audio — not critical */ }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ------------------------------------------------- browser recognition
|
|
223
|
+
//
|
|
224
|
+
// A separate leg unlike the others: the browser recognizes speech itself,
|
|
225
|
+
// nothing is uploaded to the host, no keys are needed, and text appears
|
|
226
|
+
// word by word while you speak.
|
|
227
|
+
//
|
|
228
|
+
// Cost: in Chrome audio goes to Google servers. This provider is never
|
|
229
|
+
// enabled automatically — only when placed explicitly in a chain.
|
|
230
|
+
function speechRecognitionCtor() {
|
|
231
|
+
if (typeof window === 'undefined') return null
|
|
232
|
+
return window.SpeechRecognition || window.webkitSpeechRecognition || null
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function browserRecognitionAvailable() {
|
|
236
|
+
return speechRecognitionCtor() !== null
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* @param options {{lang: string, continuous: boolean, onInterim, onFinal, onError}}
|
|
241
|
+
* @returns {{stop: Function, abort: Function}}
|
|
242
|
+
*/
|
|
243
|
+
function startBrowserRecognition(options) {
|
|
244
|
+
const Ctor = speechRecognitionCtor()
|
|
245
|
+
const recognition = new Ctor()
|
|
246
|
+
recognition.lang = options.lang && options.lang !== 'auto' ? options.lang : 'ru-RU'
|
|
247
|
+
recognition.continuous = options.continuous !== false
|
|
248
|
+
recognition.interimResults = true
|
|
249
|
+
let stopped = false
|
|
250
|
+
|
|
251
|
+
recognition.onresult = (event) => {
|
|
252
|
+
let interim = ''
|
|
253
|
+
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
254
|
+
const result = event.results[i]
|
|
255
|
+
const text = String(result[0] && result[0].transcript || '').trim()
|
|
256
|
+
if (!text) continue
|
|
257
|
+
if (result.isFinal) options.onFinal(text)
|
|
258
|
+
else interim += (interim ? ' ' : '') + text
|
|
259
|
+
}
|
|
260
|
+
options.onInterim(interim)
|
|
261
|
+
}
|
|
262
|
+
recognition.onerror = (event) => {
|
|
263
|
+
// no-speech and aborted are normal life, not failures.
|
|
264
|
+
const code = event && event.error
|
|
265
|
+
if (code === 'no-speech' || code === 'aborted') return
|
|
266
|
+
options.onError(code || t('recognitionError'))
|
|
267
|
+
}
|
|
268
|
+
// The browser ends recognition on its own (pauses, timeout). Restart
|
|
269
|
+
// until we stop it, otherwise dictation dies silently on the first pause.
|
|
270
|
+
recognition.onend = () => {
|
|
271
|
+
if (stopped) return
|
|
272
|
+
try { recognition.start() } catch (alreadyRunning) { /* already running */ }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
try { recognition.start() } catch (cannotStart) {
|
|
276
|
+
options.onError(String(cannotStart && cannotStart.message || cannotStart))
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
stop() { stopped = true; try { recognition.stop() } catch (already) { /* already stopped */ } },
|
|
280
|
+
abort() { stopped = true; try { recognition.abort() } catch (already) { /* already stopped */ } },
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Fetch the mode chain from the host once. Only needed to decide whether
|