devglide 0.1.1
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/LICENSE +21 -0
- package/README.md +338 -0
- package/bin/claude-md-template.js +94 -0
- package/bin/devglide.js +387 -0
- package/package.json +85 -0
- package/pnpm-workspace.yaml +3 -0
- package/src/apps/coder/.turbo/turbo-lint.log +5 -0
- package/src/apps/coder/package.json +16 -0
- package/src/apps/coder/public/favicon.svg +7 -0
- package/src/apps/coder/public/page.css +275 -0
- package/src/apps/coder/public/page.js +528 -0
- package/src/apps/coder/server.js +3 -0
- package/src/apps/documentation/public/page.css +597 -0
- package/src/apps/documentation/public/page.js +609 -0
- package/src/apps/kanban/.turbo/turbo-lint.log +97 -0
- package/src/apps/kanban/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/kanban/package.json +32 -0
- package/src/apps/kanban/public/favicon.svg +7 -0
- package/src/apps/kanban/public/page.css +1010 -0
- package/src/apps/kanban/public/page.js +1730 -0
- package/src/apps/kanban/public/vendor/marked.min.js +6 -0
- package/src/apps/kanban/public/vendor/sortable.min.js +2 -0
- package/src/apps/kanban/src/db.ts +319 -0
- package/src/apps/kanban/src/index.ts +14 -0
- package/src/apps/kanban/src/mcp-helpers.test.ts +88 -0
- package/src/apps/kanban/src/mcp-helpers.ts +60 -0
- package/src/apps/kanban/src/mcp.ts +59 -0
- package/src/apps/kanban/src/routes/attachments.ts +161 -0
- package/src/apps/kanban/src/routes/features.ts +233 -0
- package/src/apps/kanban/src/routes/issues.ts +373 -0
- package/src/apps/kanban/src/tools/feature-tools.ts +164 -0
- package/src/apps/kanban/src/tools/item-tools.ts +307 -0
- package/src/apps/kanban/src/tools/versioned-entry-tools.ts +72 -0
- package/src/apps/kanban/tsconfig.check.json +9 -0
- package/src/apps/kanban/tsconfig.json +9 -0
- package/src/apps/keymap/.turbo/turbo-lint.log +5 -0
- package/src/apps/keymap/package.json +16 -0
- package/src/apps/keymap/public/page.css +275 -0
- package/src/apps/keymap/public/page.js +294 -0
- package/src/apps/keymap/server.js +25 -0
- package/src/apps/log/.turbo/turbo-build.log +5 -0
- package/src/apps/log/.turbo/turbo-lint.log +45 -0
- package/src/apps/log/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/log/node_modules/.bin/tsc +21 -0
- package/src/apps/log/node_modules/.bin/tsserver +21 -0
- package/src/apps/log/node_modules/.bin/tsx +21 -0
- package/src/apps/log/package.json +36 -0
- package/src/apps/log/public/console-sniffer.js +221 -0
- package/src/apps/log/public/favicon.svg +7 -0
- package/src/apps/log/public/page.css +322 -0
- package/src/apps/log/public/page.js +463 -0
- package/src/apps/log/src/index.ts +9 -0
- package/src/apps/log/src/mcp.ts +122 -0
- package/src/apps/log/src/routes/log.ts +333 -0
- package/src/apps/log/src/routes/status.ts +25 -0
- package/src/apps/log/src/server-sniffer.ts +118 -0
- package/src/apps/log/src/services/file-patterns.ts +39 -0
- package/src/apps/log/src/services/file-tailer.ts +228 -0
- package/src/apps/log/src/services/line-parser.ts +94 -0
- package/src/apps/log/src/services/log-writer.ts +39 -0
- package/src/apps/log/tsconfig.json +8 -0
- package/src/apps/prompts/.turbo/turbo-build.log +5 -0
- package/src/apps/prompts/.turbo/turbo-lint.log +24 -0
- package/src/apps/prompts/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/prompts/mcp.ts +175 -0
- package/src/apps/prompts/node_modules/.bin/tsc +21 -0
- package/src/apps/prompts/node_modules/.bin/tsserver +21 -0
- package/src/apps/prompts/node_modules/.bin/tsx +21 -0
- package/src/apps/prompts/package.json +25 -0
- package/src/apps/prompts/public/page.css +315 -0
- package/src/apps/prompts/public/page.js +541 -0
- package/src/apps/prompts/services/prompt-store.ts +212 -0
- package/src/apps/prompts/src/index.ts +9 -0
- package/src/apps/prompts/tsconfig.json +8 -0
- package/src/apps/prompts/types.ts +27 -0
- package/src/apps/shell/.turbo/turbo-build.log +5 -0
- package/src/apps/shell/.turbo/turbo-lint.log +34 -0
- package/src/apps/shell/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/shell/package.json +35 -0
- package/src/apps/shell/public/favicon.svg +7 -0
- package/src/apps/shell/public/page.css +407 -0
- package/src/apps/shell/public/page.js +1577 -0
- package/src/apps/shell/src/index.ts +150 -0
- package/src/apps/shell/src/mcp.ts +398 -0
- package/src/apps/shell/src/shell-types.ts +41 -0
- package/src/apps/shell/tsconfig.json +8 -0
- package/src/apps/test/.turbo/turbo-build.log +5 -0
- package/src/apps/test/.turbo/turbo-lint.log +27 -0
- package/src/apps/test/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/test/node_modules/.bin/tsc +21 -0
- package/src/apps/test/node_modules/.bin/tsserver +21 -0
- package/src/apps/test/node_modules/.bin/tsx +21 -0
- package/src/apps/test/node_modules/.bin/uuid +21 -0
- package/src/apps/test/package.json +35 -0
- package/src/apps/test/public/favicon.svg +7 -0
- package/src/apps/test/public/page.css +499 -0
- package/src/apps/test/public/page.js +417 -0
- package/src/apps/test/public/scenario-runner.js +450 -0
- package/src/apps/test/src/index.ts +9 -0
- package/src/apps/test/src/mcp.ts +192 -0
- package/src/apps/test/src/routes/trigger.ts +285 -0
- package/src/apps/test/src/services/scenario-broadcaster.ts +60 -0
- package/src/apps/test/src/services/scenario-manager.ts +361 -0
- package/src/apps/test/src/services/scenario-store.ts +145 -0
- package/src/apps/test/tsconfig.json +8 -0
- package/src/apps/vocabulary/.turbo/turbo-build.log +5 -0
- package/src/apps/vocabulary/.turbo/turbo-lint.log +25 -0
- package/src/apps/vocabulary/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/vocabulary/mcp.ts +173 -0
- package/src/apps/vocabulary/node_modules/.bin/tsc +21 -0
- package/src/apps/vocabulary/node_modules/.bin/tsserver +21 -0
- package/src/apps/vocabulary/node_modules/.bin/tsx +21 -0
- package/src/apps/vocabulary/package.json +25 -0
- package/src/apps/vocabulary/public/page.css +247 -0
- package/src/apps/vocabulary/public/page.js +444 -0
- package/src/apps/vocabulary/services/vocabulary-store.ts +179 -0
- package/src/apps/vocabulary/src/index.ts +10 -0
- package/src/apps/vocabulary/tsconfig.json +8 -0
- package/src/apps/vocabulary/types.ts +22 -0
- package/src/apps/voice/.turbo/turbo-build.log +5 -0
- package/src/apps/voice/.turbo/turbo-lint.log +43 -0
- package/src/apps/voice/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/voice/node_modules/.bin/openai +21 -0
- package/src/apps/voice/node_modules/.bin/tsc +21 -0
- package/src/apps/voice/node_modules/.bin/tsserver +21 -0
- package/src/apps/voice/node_modules/.bin/tsx +21 -0
- package/src/apps/voice/package.json +35 -0
- package/src/apps/voice/public/favicon.svg +7 -0
- package/src/apps/voice/public/page.css +388 -0
- package/src/apps/voice/public/page.js +718 -0
- package/src/apps/voice/src/index.ts +10 -0
- package/src/apps/voice/src/mcp.ts +70 -0
- package/src/apps/voice/src/providers/index.ts +85 -0
- package/src/apps/voice/src/providers/openai-compatible.ts +94 -0
- package/src/apps/voice/src/providers/types.ts +27 -0
- package/src/apps/voice/src/routes/config.ts +118 -0
- package/src/apps/voice/src/routes/transcribe.ts +90 -0
- package/src/apps/voice/src/services/config-store.ts +129 -0
- package/src/apps/voice/src/services/stats.ts +108 -0
- package/src/apps/voice/src/transcribe.ts +11 -0
- package/src/apps/voice/src/utils/mime.ts +16 -0
- package/src/apps/voice/tsconfig.json +8 -0
- package/src/apps/workflow/.turbo/turbo-build.log +5 -0
- package/src/apps/workflow/.turbo/turbo-lint.log +96 -0
- package/src/apps/workflow/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/workflow/engine/executors/decision-executor.ts +87 -0
- package/src/apps/workflow/engine/executors/file-executor.ts +90 -0
- package/src/apps/workflow/engine/executors/git-executor.ts +137 -0
- package/src/apps/workflow/engine/executors/http-executor.ts +65 -0
- package/src/apps/workflow/engine/executors/index.ts +28 -0
- package/src/apps/workflow/engine/executors/kanban-executor.ts +154 -0
- package/src/apps/workflow/engine/executors/llm-executor.ts +46 -0
- package/src/apps/workflow/engine/executors/log-executor.ts +62 -0
- package/src/apps/workflow/engine/executors/loop-executor.ts +14 -0
- package/src/apps/workflow/engine/executors/shell-executor.ts +107 -0
- package/src/apps/workflow/engine/executors/sub-workflow-executor.ts +61 -0
- package/src/apps/workflow/engine/executors/test-executor.ts +73 -0
- package/src/apps/workflow/engine/executors/trigger-executor.ts +39 -0
- package/src/apps/workflow/engine/expression-evaluator.ts +117 -0
- package/src/apps/workflow/engine/graph-runner.ts +438 -0
- package/src/apps/workflow/engine/node-executor.ts +104 -0
- package/src/apps/workflow/engine/node-registry.ts +15 -0
- package/src/apps/workflow/engine/variable-resolver.ts +109 -0
- package/src/apps/workflow/mcp.ts +223 -0
- package/src/apps/workflow/node_modules/.bin/tsc +21 -0
- package/src/apps/workflow/node_modules/.bin/tsserver +21 -0
- package/src/apps/workflow/node_modules/.bin/tsx +21 -0
- package/src/apps/workflow/package.json +25 -0
- package/src/apps/workflow/public/editor/canvas.js +366 -0
- package/src/apps/workflow/public/editor/drag-manager.js +326 -0
- package/src/apps/workflow/public/editor/edge-renderer.js +235 -0
- package/src/apps/workflow/public/editor/history-manager.js +147 -0
- package/src/apps/workflow/public/editor/layout-engine.js +159 -0
- package/src/apps/workflow/public/editor/node-renderer.js +199 -0
- package/src/apps/workflow/public/editor/selection-manager.js +193 -0
- package/src/apps/workflow/public/favicon.svg +7 -0
- package/src/apps/workflow/public/models/node-types.js +300 -0
- package/src/apps/workflow/public/models/workflow-model.js +257 -0
- package/src/apps/workflow/public/page.css +406 -0
- package/src/apps/workflow/public/page.js +658 -0
- package/src/apps/workflow/public/panels/inspector.js +360 -0
- package/src/apps/workflow/public/panels/palette.js +106 -0
- package/src/apps/workflow/public/panels/run-view.js +275 -0
- package/src/apps/workflow/public/panels/toolbar.js +232 -0
- package/src/apps/workflow/public/panels/workflow-list.js +237 -0
- package/src/apps/workflow/public/state/store.js +47 -0
- package/src/apps/workflow/services/custom-node-loader.ts +48 -0
- package/src/apps/workflow/services/legacy-converter.ts +72 -0
- package/src/apps/workflow/services/run-manager.ts +190 -0
- package/src/apps/workflow/services/workflow-store.ts +424 -0
- package/src/apps/workflow/services/workflow-validator.test.ts +103 -0
- package/src/apps/workflow/services/workflow-validator.ts +98 -0
- package/src/apps/workflow/src/index.ts +10 -0
- package/src/apps/workflow/templates/ci-pipeline.json +18 -0
- package/src/apps/workflow/templates/code-review.json +22 -0
- package/src/apps/workflow/templates/kanban-testing.json +24 -0
- package/src/apps/workflow/tsconfig.json +8 -0
- package/src/apps/workflow/types.ts +268 -0
- package/src/packages/auth-middleware.ts +14 -0
- package/src/packages/design-tokens/.turbo/turbo-build.log +10 -0
- package/src/packages/design-tokens/STYLEGUIDE.md +414 -0
- package/src/packages/design-tokens/build.js +413 -0
- package/src/packages/design-tokens/demo/index.html +1367 -0
- package/src/packages/design-tokens/demo/proposition-a.html +717 -0
- package/src/packages/design-tokens/demo/proposition-b.html +1239 -0
- package/src/packages/design-tokens/demo/proposition-c.html +1049 -0
- package/src/packages/design-tokens/dist/tailwind-preset.js +115 -0
- package/src/packages/design-tokens/dist/tokens.css +345 -0
- package/src/packages/design-tokens/dist/tokens.d.ts +229 -0
- package/src/packages/design-tokens/dist/tokens.js +386 -0
- package/src/packages/design-tokens/package.json +25 -0
- package/src/packages/design-tokens/tokens.json +228 -0
- package/src/packages/devtools-middleware.ts +22 -0
- package/src/packages/eslint-config/index.js +63 -0
- package/src/packages/eslint-config/node_modules/.bin/eslint +21 -0
- package/src/packages/eslint-config/package.json +18 -0
- package/src/packages/json-file-store.ts +232 -0
- package/src/packages/mcp-utils/.turbo/turbo-build.log +5 -0
- package/src/packages/mcp-utils/dist/index.d.ts +33 -0
- package/src/packages/mcp-utils/dist/index.d.ts.map +1 -0
- package/src/packages/mcp-utils/dist/index.js +126 -0
- package/src/packages/mcp-utils/dist/index.js.map +1 -0
- package/src/packages/mcp-utils/node_modules/.bin/tsc +21 -0
- package/src/packages/mcp-utils/node_modules/.bin/tsserver +21 -0
- package/src/packages/mcp-utils/package.json +32 -0
- package/src/packages/mcp-utils/src/index.ts +171 -0
- package/src/packages/mcp-utils/tsconfig.json +9 -0
- package/src/packages/paths.ts +18 -0
- package/src/packages/project-context/index.js +55 -0
- package/src/packages/project-context/package.json +13 -0
- package/src/packages/project-store.ts +127 -0
- package/src/packages/server-sniffer.ts +132 -0
- package/src/packages/shared-assets/favicon.svg +7 -0
- package/src/packages/shared-assets/keymap-registry.js +512 -0
- package/src/packages/shared-assets/logo.svg +6 -0
- package/src/packages/shared-assets/package.json +11 -0
- package/src/packages/shared-assets/ui-utils.js +48 -0
- package/src/packages/shared-assets/voice-widget.d.ts +37 -0
- package/src/packages/shared-assets/voice-widget.js +695 -0
- package/src/packages/shared-types/.turbo/turbo-build.log +5 -0
- package/src/packages/shared-types/dist/index.d.ts +39 -0
- package/src/packages/shared-types/dist/index.d.ts.map +1 -0
- package/src/packages/shared-types/node_modules/.bin/tsc +21 -0
- package/src/packages/shared-types/node_modules/.bin/tsserver +21 -0
- package/src/packages/shared-types/package.json +25 -0
- package/src/packages/shared-types/src/index.ts +41 -0
- package/src/packages/shared-types/tsconfig.json +11 -0
- package/src/packages/tsconfig/base.json +15 -0
- package/src/packages/tsconfig/next.json +14 -0
- package/src/packages/tsconfig/node.json +11 -0
- package/src/packages/tsconfig/package.json +10 -0
- package/turbo.json +25 -0
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* VoiceWidget — hold-to-record voice input widget
|
|
3
|
+
* No external dependencies. Plain JS + Web APIs.
|
|
4
|
+
*/
|
|
5
|
+
(function (root, factory) {
|
|
6
|
+
if (typeof module === 'object' && module.exports) {
|
|
7
|
+
module.exports = factory();
|
|
8
|
+
} else {
|
|
9
|
+
root.VoiceWidget = factory();
|
|
10
|
+
}
|
|
11
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
// CSS — KITT SCANNER (Knight Rider-inspired, Material Design sensibility)
|
|
16
|
+
// Matte black panel · red Larson scanner strip · subtle elevation
|
|
17
|
+
// scanner freezes on malfunction · clean system-ui typography
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
var CSS = [
|
|
20
|
+
// ── Wrapper — NERV aesthetic ──
|
|
21
|
+
'.vw-wrap{',
|
|
22
|
+
'position:relative;display:inline-flex;z-index:0;',
|
|
23
|
+
'filter:drop-shadow(0 2px 4px rgba(0,0,0,.7)) drop-shadow(0 1px 2px rgba(0,0,0,.5));',
|
|
24
|
+
'transition:filter .25s ease;',
|
|
25
|
+
'}',
|
|
26
|
+
'.vw-wrap:hover{',
|
|
27
|
+
'filter:var(--df-glow-accent,drop-shadow(0 0 8px rgba(255,140,0,.35))) drop-shadow(0 2px 4px rgba(0,0,0,.6));',
|
|
28
|
+
'}',
|
|
29
|
+
// Corner brackets — NERV card pattern
|
|
30
|
+
'.vw-wrap::before,.vw-wrap::after{',
|
|
31
|
+
"content:'';position:absolute;width:8px;height:8px;",
|
|
32
|
+
'border-color:var(--df-color-accent-default,#ff8c00);border-style:solid;pointer-events:none;opacity:.4;z-index:1;',
|
|
33
|
+
'}',
|
|
34
|
+
'.vw-wrap::before{top:0;left:0;border-width:2px 0 0 2px;}',
|
|
35
|
+
'.vw-wrap::after{bottom:0;right:0;border-width:0 2px 2px 0;}',
|
|
36
|
+
'.vw-wrap[data-state="recording"]{animation:vw-wrap-pulse 1.2s ease-in-out infinite alternate}',
|
|
37
|
+
'@keyframes vw-wrap-pulse{',
|
|
38
|
+
'0%{filter:drop-shadow(0 4px 10px rgba(255,21,0,.4)) drop-shadow(0 2px 4px rgba(0,0,0,.7))}',
|
|
39
|
+
'100%{filter:drop-shadow(0 4px 18px rgba(255,21,0,.7)) drop-shadow(0 2px 6px rgba(0,0,0,.8))}',
|
|
40
|
+
'}',
|
|
41
|
+
'.vw-wrap[data-state="error"]{',
|
|
42
|
+
'filter:var(--df-glow-error,drop-shadow(0 4px 14px rgba(255,30,0,.6))) drop-shadow(0 2px 4px rgba(0,0,0,.7));',
|
|
43
|
+
'}',
|
|
44
|
+
|
|
45
|
+
// ── Button — NERV angular panel, fixed dimensions ──
|
|
46
|
+
'.vw-btn{',
|
|
47
|
+
'position:relative;display:inline-flex;align-items:center;justify-content:center;gap:8px;',
|
|
48
|
+
'width:120px;height:36px;box-sizing:border-box;',
|
|
49
|
+
'padding:0 14px 4px;', // 4px bottom for scanner strip
|
|
50
|
+
'background:var(--df-color-bg-surface,#0f0c00);',
|
|
51
|
+
'border:1px solid var(--df-color-border-default,#2a2000);',
|
|
52
|
+
'border-radius:0;',
|
|
53
|
+
'clip-path:var(--df-clip-sm,polygon(0 0,calc(100% - 8px) 0,100% 8px,100% 100%,8px 100%,0 calc(100% - 8px)));',
|
|
54
|
+
'color:var(--df-color-text-primary,#e8dcc8);cursor:pointer;',
|
|
55
|
+
'font-size:11px;font-weight:400;',
|
|
56
|
+
"font-family:var(--df-font-mono,'Courier New',Courier,monospace);",
|
|
57
|
+
'letter-spacing:var(--df-letter-spacing-wide,0.12em);text-transform:uppercase;white-space:nowrap;',
|
|
58
|
+
'user-select:none;-webkit-user-select:none;touch-action:none;',
|
|
59
|
+
'outline:none;overflow:hidden;',
|
|
60
|
+
'transition:background .25s ease,color .25s ease,border-color .25s ease;',
|
|
61
|
+
'}',
|
|
62
|
+
|
|
63
|
+
// ── Hover / active ──
|
|
64
|
+
'.vw-btn:hover{background:var(--df-color-bg-raised,#1a1400);color:var(--df-color-accent-default,#ff8c00);border-color:var(--df-color-accent-default,#ff8c00)}',
|
|
65
|
+
'.vw-btn:active{background:var(--df-color-bg-surface,#0f0c00)}',
|
|
66
|
+
'.vw-btn:hover .vw-icon{transform:scale(1.08)}',
|
|
67
|
+
|
|
68
|
+
// ── NERV scanner strip — orange in idle, red in recording ──
|
|
69
|
+
'.vw-bars{',
|
|
70
|
+
'position:absolute;bottom:0;left:0;right:0;height:4px;',
|
|
71
|
+
'background:var(--df-color-bg-raised,#1a1400);overflow:hidden;',
|
|
72
|
+
'}',
|
|
73
|
+
'.vw-bar{display:none}',
|
|
74
|
+
|
|
75
|
+
// ── Scanner beam — accent Larson sweep (idle, theme-aware) ──
|
|
76
|
+
'.vw-bars::before{',
|
|
77
|
+
"content:'';position:absolute;",
|
|
78
|
+
'top:0;left:-20%;width:35%;height:100%;',
|
|
79
|
+
'background:radial-gradient(ellipse at center,color-mix(in srgb,var(--df-color-accent-default,#ff8c00) 70%,transparent) 0%,color-mix(in srgb,var(--df-color-accent-default,#ff8c00) 30%,transparent) 50%,transparent 100%);',
|
|
80
|
+
'animation:vw-kitt 2.8s ease-in-out infinite alternate;',
|
|
81
|
+
'}',
|
|
82
|
+
'@keyframes vw-kitt{0%{left:-20%}100%{left:85%}}',
|
|
83
|
+
|
|
84
|
+
// ── Recording — fast red scanner, state-recording color ──
|
|
85
|
+
'.vw-btn[data-state="recording"]{background:var(--df-color-bg-surface,#0f0c00);color:var(--df-color-state-recording,#ff1500);border-color:var(--df-color-state-recording,#ff1500)}',
|
|
86
|
+
'.vw-btn[data-state="recording"] .vw-bars::before{',
|
|
87
|
+
'animation-duration:.65s;',
|
|
88
|
+
'background:radial-gradient(ellipse at center,#ff1500 0%,rgba(255,21,0,.6) 40%,transparent 100%);',
|
|
89
|
+
'filter:blur(.4px);',
|
|
90
|
+
'}',
|
|
91
|
+
'.vw-btn[data-state="recording"] .vw-icon{display:none}',
|
|
92
|
+
'.vw-btn[data-state="recording"] .vw-label{display:none}',
|
|
93
|
+
|
|
94
|
+
// ── KITT voice modulator — segmented LED bars ──
|
|
95
|
+
'.vw-modulator{display:none;align-items:flex-end;gap:3px;height:18px;position:relative;z-index:2}',
|
|
96
|
+
'.vw-btn[data-state="recording"] .vw-modulator{display:inline-flex}',
|
|
97
|
+
'.vw-modulator span{',
|
|
98
|
+
'width:5px;border-radius:0;',
|
|
99
|
+
'background:repeating-linear-gradient(to top,#ff4400 0,#ff4400 3px,#1a0500 3px,#1a0500 5px);',
|
|
100
|
+
'box-shadow:0 0 4px rgba(255,80,0,.8),0 0 10px rgba(255,40,0,.4);',
|
|
101
|
+
'animation:vw-mod .55s ease-in-out infinite alternate;',
|
|
102
|
+
'}',
|
|
103
|
+
'.vw-modulator span:nth-child(1){animation-delay:0s;min-height:4px}',
|
|
104
|
+
'.vw-modulator span:nth-child(2){animation-delay:.07s;min-height:6px}',
|
|
105
|
+
'.vw-modulator span:nth-child(3){animation-delay:.14s;min-height:3px}',
|
|
106
|
+
'.vw-modulator span:nth-child(4){animation-delay:.21s;min-height:8px}',
|
|
107
|
+
'.vw-modulator span:nth-child(5){animation-delay:.28s;min-height:4px}',
|
|
108
|
+
'.vw-modulator span:nth-child(6){animation-delay:.35s;min-height:6px}',
|
|
109
|
+
'@keyframes vw-mod{from{height:3px}to{height:18px}}',
|
|
110
|
+
|
|
111
|
+
// ── Transcribing — processing amber, medium scanner ──
|
|
112
|
+
'.vw-btn[data-state="transcribing"]{background:var(--df-color-bg-surface,#0f0c00);color:var(--df-color-state-processing,#cc8800);cursor:wait;border-color:var(--df-color-state-processing,#cc8800)}',
|
|
113
|
+
'.vw-btn[data-state="transcribing"] .vw-bars{background:var(--df-color-bg-raised,#1a1400)}',
|
|
114
|
+
'.vw-btn[data-state="transcribing"] .vw-bars::before{',
|
|
115
|
+
'animation-duration:1.3s;',
|
|
116
|
+
'background:radial-gradient(ellipse at center,color-mix(in srgb,var(--df-color-state-processing,#cc8800) 85%,transparent) 0%,color-mix(in srgb,var(--df-color-state-processing,#cc8800) 40%,transparent) 40%,transparent 100%);',
|
|
117
|
+
'}',
|
|
118
|
+
'.vw-btn[data-state="transcribing"] .vw-icon{display:none}',
|
|
119
|
+
'.vw-btn[data-state="transcribing"] .vw-label{animation:vw-process 1.6s ease-in-out infinite}',
|
|
120
|
+
'@keyframes vw-process{0%,100%{opacity:1}40%,60%{opacity:.3}}',
|
|
121
|
+
|
|
122
|
+
// ── Spinner ──
|
|
123
|
+
'.vw-spinner{',
|
|
124
|
+
'display:none;width:12px;height:12px;flex-shrink:0;',
|
|
125
|
+
'border:2px solid rgba(200,120,0,.2);border-top-color:var(--df-color-state-processing,#cc8800);',
|
|
126
|
+
'border-radius:50%;animation:vw-spin .65s linear infinite;',
|
|
127
|
+
'}',
|
|
128
|
+
'.vw-btn[data-state="transcribing"] .vw-spinner{display:block}',
|
|
129
|
+
'@keyframes vw-spin{to{transform:rotate(360deg)}}',
|
|
130
|
+
|
|
131
|
+
// ── Error — scanner frozen, shake, error red ──
|
|
132
|
+
'.vw-btn[data-state="error"]{',
|
|
133
|
+
'background:var(--df-color-bg-surface,#0f0c00);color:var(--df-color-state-error,#ff3333);',
|
|
134
|
+
'border-color:var(--df-color-state-error,#ff3333);',
|
|
135
|
+
'animation:vw-quake .45s ease;',
|
|
136
|
+
'}',
|
|
137
|
+
'@keyframes vw-quake{',
|
|
138
|
+
'0%,100%{transform:translate(0)}',
|
|
139
|
+
'15%{transform:translate(-4px,1px)}',
|
|
140
|
+
'30%{transform:translate(4px,-1px)}',
|
|
141
|
+
'45%{transform:translate(-3px,0)}',
|
|
142
|
+
'60%{transform:translate(3px,1px)}',
|
|
143
|
+
'75%{transform:translate(-2px,-1px)}',
|
|
144
|
+
'}',
|
|
145
|
+
'.vw-btn[data-state="error"] .vw-bars::before{',
|
|
146
|
+
'animation-play-state:paused;',
|
|
147
|
+
'background:radial-gradient(ellipse at center,rgba(255,51,51,.9) 0%,rgba(255,51,51,.4) 40%,transparent 100%);',
|
|
148
|
+
'}',
|
|
149
|
+
|
|
150
|
+
// ── Error flash overlay ──
|
|
151
|
+
'.vw-flash{',
|
|
152
|
+
'position:absolute;inset:0;',
|
|
153
|
+
'background:rgba(255,30,0,.25);opacity:0;pointer-events:none;z-index:5;',
|
|
154
|
+
'}',
|
|
155
|
+
'.vw-wrap[data-state="error"] .vw-flash{animation:vw-flashpop .5s ease-out}',
|
|
156
|
+
'@keyframes vw-flashpop{0%{opacity:1}100%{opacity:0}}',
|
|
157
|
+
|
|
158
|
+
// ── Icon & label ──
|
|
159
|
+
'.vw-icon{display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:transform .2s ease;position:relative;z-index:2}',
|
|
160
|
+
'.vw-label{font-size:11px;line-height:1;position:relative;z-index:2;letter-spacing:var(--df-letter-spacing-wide,0.12em);text-transform:uppercase}',
|
|
161
|
+
].join('\n');
|
|
162
|
+
|
|
163
|
+
var ICONS = {
|
|
164
|
+
// Standard mic
|
|
165
|
+
idle: '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>',
|
|
166
|
+
// Red filled circle (recording indicator — KITT's active signal dot)
|
|
167
|
+
recording: '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12"><circle cx="6" cy="6" r="5" fill="#ff1500"/></svg>',
|
|
168
|
+
transcribing: '',
|
|
169
|
+
// Warning triangle
|
|
170
|
+
error: '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
var LABELS = {
|
|
174
|
+
idle: 'Speak',
|
|
175
|
+
recording: 'Transmitting\u2026',
|
|
176
|
+
transcribing: 'Processing\u2026',
|
|
177
|
+
error: 'Malfunction',
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
var _styleInjected = false;
|
|
181
|
+
function injectStyles() {
|
|
182
|
+
if (_styleInjected) return;
|
|
183
|
+
_styleInjected = true;
|
|
184
|
+
var el = document.createElement('style');
|
|
185
|
+
el.textContent = CSS;
|
|
186
|
+
document.head.appendChild(el);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function blobToBase64(blob) {
|
|
190
|
+
return new Promise(function (resolve, reject) {
|
|
191
|
+
var reader = new FileReader();
|
|
192
|
+
reader.onloadend = function () {
|
|
193
|
+
if (reader.readyState !== FileReader.DONE) {
|
|
194
|
+
reject(new Error('FileReader did not complete'));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
var result = reader.result;
|
|
198
|
+
if (typeof result !== 'string') {
|
|
199
|
+
reject(new Error('FileReader produced non-string result'));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
var base64 = result.split(',')[1];
|
|
203
|
+
if (!base64) {
|
|
204
|
+
reject(new Error('Failed to extract base64 from data URL'));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
resolve(base64);
|
|
208
|
+
};
|
|
209
|
+
reader.onerror = function () {
|
|
210
|
+
reject(reader.error || new Error('Failed to read audio blob'));
|
|
211
|
+
};
|
|
212
|
+
reader.readAsDataURL(blob);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function mimeToExt(mimeType) {
|
|
217
|
+
if (!mimeType) return 'webm';
|
|
218
|
+
if (mimeType.includes('ogg')) return 'ogg';
|
|
219
|
+
if (mimeType.includes('mp4')) return 'mp4';
|
|
220
|
+
return 'webm';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function pickMimeType() {
|
|
224
|
+
var candidates = [
|
|
225
|
+
'audio/webm;codecs=opus',
|
|
226
|
+
'audio/webm',
|
|
227
|
+
'audio/ogg;codecs=opus',
|
|
228
|
+
'audio/mp4',
|
|
229
|
+
];
|
|
230
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
231
|
+
if (MediaRecorder.isTypeSupported(candidates[i])) return candidates[i];
|
|
232
|
+
}
|
|
233
|
+
return '';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Create a VoiceWidget instance.
|
|
238
|
+
*
|
|
239
|
+
* @param {object} opts
|
|
240
|
+
* @param {string} opts.voiceUrl - Base URL of the voice app (e.g. 'http://localhost:7004')
|
|
241
|
+
* @param {function} opts.onResult - Called with transcribed text string
|
|
242
|
+
* @param {function} [opts.onError] - Called with Error object
|
|
243
|
+
* @param {string} [opts.language] - BCP-47 language tag (e.g. 'en')
|
|
244
|
+
* @param {string} [opts.hotkey] - Single key character to toggle recording (e.g. 'v')
|
|
245
|
+
* @param {string} [opts.label] - Idle label override (default: 'Speak')
|
|
246
|
+
*/
|
|
247
|
+
function create(opts) {
|
|
248
|
+
if (!opts || !opts.voiceUrl) throw new Error('VoiceWidget: voiceUrl is required');
|
|
249
|
+
if (typeof opts.onResult !== 'function') throw new Error('VoiceWidget: onResult callback is required');
|
|
250
|
+
|
|
251
|
+
var voiceUrl = opts.voiceUrl.replace(/\/$/, '');
|
|
252
|
+
var onResult = opts.onResult;
|
|
253
|
+
var onError = opts.onError || function (err) { console.error('[VoiceWidget]', err); };
|
|
254
|
+
var language = opts.language || null;
|
|
255
|
+
var hotkey = opts.hotkey ? opts.hotkey.toLowerCase() : null;
|
|
256
|
+
var idleLabel = opts.label || LABELS.idle;
|
|
257
|
+
|
|
258
|
+
var state = 'idle'; // idle | recording | transcribing | error
|
|
259
|
+
var mediaRecorder = null;
|
|
260
|
+
var chunks = [];
|
|
261
|
+
var stream = null;
|
|
262
|
+
var errorTimer = null;
|
|
263
|
+
var pendingStop = false; // stop requested while getUserMedia pending
|
|
264
|
+
var recordingStartedAt = 0; // timestamp when recording began
|
|
265
|
+
var chimeTimer = null; // setTimeout id during chime-before-record delay
|
|
266
|
+
var MIN_RECORDING_MS = 350; // minimum recording duration to capture audio
|
|
267
|
+
var CHIME_LEAD_MS = 550; // ms to wait for start chime + echo-cancellation settling before opening mic
|
|
268
|
+
|
|
269
|
+
// ── audio feedback ─────────────────────────────────────────────────────────
|
|
270
|
+
var _audioCtx = null;
|
|
271
|
+
|
|
272
|
+
function _getAudioCtx() {
|
|
273
|
+
if (!_audioCtx) {
|
|
274
|
+
try { _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) {}
|
|
275
|
+
}
|
|
276
|
+
if (_audioCtx && _audioCtx.state === 'suspended') { _audioCtx.resume(); }
|
|
277
|
+
return _audioCtx;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// "Her" OS-inspired chime — warm, layered, ethereal tones.
|
|
281
|
+
// Soft sine layers at consonant intervals with gentle attack and long decay.
|
|
282
|
+
function _playTone(ctx, freq, startTime, duration, vol) {
|
|
283
|
+
var osc = ctx.createOscillator();
|
|
284
|
+
var g = ctx.createGain();
|
|
285
|
+
osc.connect(g);
|
|
286
|
+
g.connect(ctx.destination);
|
|
287
|
+
osc.type = 'sine';
|
|
288
|
+
osc.frequency.setValueAtTime(freq, startTime);
|
|
289
|
+
g.gain.setValueAtTime(0, startTime);
|
|
290
|
+
g.gain.linearRampToValueAtTime(vol, startTime + 0.04);
|
|
291
|
+
g.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
|
|
292
|
+
osc.start(startTime);
|
|
293
|
+
osc.stop(startTime + duration + 0.01);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Start chime — single soft rising tone
|
|
297
|
+
function _playStartChime() {
|
|
298
|
+
var ctx = _getAudioCtx();
|
|
299
|
+
if (!ctx) return;
|
|
300
|
+
try {
|
|
301
|
+
var t = ctx.currentTime;
|
|
302
|
+
_playTone(ctx, 523.25, t, 0.40, 0.06); // C5 fundamental
|
|
303
|
+
_playTone(ctx, 659.25, t + 0.12, 0.35, 0.05); // E5 gentle rise
|
|
304
|
+
} catch (e) {}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Stop chime — single soft descending tone
|
|
308
|
+
function _playStopChime() {
|
|
309
|
+
var ctx = _getAudioCtx();
|
|
310
|
+
if (!ctx) return;
|
|
311
|
+
try {
|
|
312
|
+
var t = ctx.currentTime;
|
|
313
|
+
_playTone(ctx, 659.25, t, 0.30, 0.05); // E5
|
|
314
|
+
_playTone(ctx, 523.25, t + 0.10, 0.35, 0.04); // C5 resolve down
|
|
315
|
+
} catch (e) {}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// DOM refs
|
|
319
|
+
var wrap = null;
|
|
320
|
+
var btn = null;
|
|
321
|
+
var iconEl = null;
|
|
322
|
+
var labelEl = null;
|
|
323
|
+
var spinnerEl = null;
|
|
324
|
+
|
|
325
|
+
// ── state machine ──────────────────────────────────────────────────────────
|
|
326
|
+
function setState(s) {
|
|
327
|
+
state = s;
|
|
328
|
+
if (!btn) return;
|
|
329
|
+
btn.setAttribute('data-state', s);
|
|
330
|
+
wrap.setAttribute('data-state', s);
|
|
331
|
+
iconEl.innerHTML = ICONS[s] || '';
|
|
332
|
+
var lbl = s === 'idle' ? idleLabel : (LABELS[s] || s);
|
|
333
|
+
labelEl.textContent = lbl;
|
|
334
|
+
btn.setAttribute('aria-label', lbl);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── recording ──────────────────────────────────────────────────────────────
|
|
338
|
+
function startRecording() {
|
|
339
|
+
if (state !== 'idle' && state !== 'error') return;
|
|
340
|
+
|
|
341
|
+
clearTimeout(errorTimer);
|
|
342
|
+
pendingStop = false;
|
|
343
|
+
_getAudioCtx(); // init within user gesture so AudioContext is unlocked
|
|
344
|
+
|
|
345
|
+
// Guard: secure context and mediaDevices availability
|
|
346
|
+
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
347
|
+
handleError(new Error(
|
|
348
|
+
window.isSecureContext === false
|
|
349
|
+
? 'Microphone requires a secure (HTTPS) connection'
|
|
350
|
+
: 'Microphone API not available in this browser'
|
|
351
|
+
));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
navigator.mediaDevices.getUserMedia({ audio: true, video: false })
|
|
356
|
+
.then(function (s) {
|
|
357
|
+
stream = s;
|
|
358
|
+
chunks = [];
|
|
359
|
+
var mime = pickMimeType();
|
|
360
|
+
mediaRecorder = new MediaRecorder(stream, mime ? { mimeType: mime } : {});
|
|
361
|
+
|
|
362
|
+
mediaRecorder.ondataavailable = function (e) {
|
|
363
|
+
if (e.data && e.data.size > 0) chunks.push(e.data);
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
mediaRecorder.onstop = function () {
|
|
367
|
+
stopStream();
|
|
368
|
+
var usedMime = mediaRecorder.mimeType || 'audio/webm';
|
|
369
|
+
var blob = new Blob(chunks, { type: usedMime });
|
|
370
|
+
chunks = [];
|
|
371
|
+
if (blob.size === 0) {
|
|
372
|
+
handleError(new Error('No audio captured'));
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
transcribe(blob, usedMime);
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
mediaRecorder.onstart = function () {
|
|
379
|
+
recordingStartedAt = Date.now();
|
|
380
|
+
|
|
381
|
+
// If stop was requested during chime delay, honour it
|
|
382
|
+
if (pendingStop) {
|
|
383
|
+
pendingStop = false;
|
|
384
|
+
setTimeout(doStop, MIN_RECORDING_MS);
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
// Play the chime BEFORE opening the mic so the beep never enters
|
|
389
|
+
// the recording. This avoids browser echo-cancellation suppressing
|
|
390
|
+
// the first moments of speech that follow the beep.
|
|
391
|
+
_playStartChime();
|
|
392
|
+
setState('recording');
|
|
393
|
+
chimeTimer = setTimeout(function () {
|
|
394
|
+
chimeTimer = null;
|
|
395
|
+
if (pendingStop) {
|
|
396
|
+
// Stop requested during chime — abort without recording
|
|
397
|
+
pendingStop = false;
|
|
398
|
+
stopStream();
|
|
399
|
+
setState('idle');
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
mediaRecorder.start();
|
|
403
|
+
}, CHIME_LEAD_MS);
|
|
404
|
+
})
|
|
405
|
+
.catch(function (err) {
|
|
406
|
+
pendingStop = false;
|
|
407
|
+
var name = err.name || '';
|
|
408
|
+
var msg;
|
|
409
|
+
if (name === 'NotAllowedError' || name === 'PermissionDeniedError') {
|
|
410
|
+
msg = 'Microphone permission denied — check browser site settings';
|
|
411
|
+
} else if (name === 'AbortError') {
|
|
412
|
+
msg = 'Microphone permission dismissed — click the mic and allow access';
|
|
413
|
+
} else if (name === 'NotFoundError') {
|
|
414
|
+
msg = 'No microphone found — connect a mic and retry';
|
|
415
|
+
} else if (name === 'NotReadableError') {
|
|
416
|
+
msg = 'Microphone is in use by another application';
|
|
417
|
+
} else if (name === 'SecurityError') {
|
|
418
|
+
msg = 'Microphone requires a secure (HTTPS) connection';
|
|
419
|
+
} else if (name === 'TypeError') {
|
|
420
|
+
msg = 'Microphone API not available — check browser compatibility';
|
|
421
|
+
} else {
|
|
422
|
+
msg = 'Microphone unavailable — check permissions and try again';
|
|
423
|
+
}
|
|
424
|
+
handleError(new Error(msg));
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function doStop() {
|
|
429
|
+
if (state !== 'recording') return;
|
|
430
|
+
_playStopChime(); // descending 2-note: done speaking
|
|
431
|
+
setState('transcribing');
|
|
432
|
+
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
433
|
+
mediaRecorder.stop();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function stopRecording() {
|
|
438
|
+
// Stop requested before getUserMedia resolved — defer
|
|
439
|
+
if (state === 'idle') {
|
|
440
|
+
pendingStop = true;
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (state !== 'recording') return;
|
|
444
|
+
|
|
445
|
+
// Stop during chime delay — mic hasn't opened yet, cancel gracefully
|
|
446
|
+
if (chimeTimer) {
|
|
447
|
+
clearTimeout(chimeTimer);
|
|
448
|
+
chimeTimer = null;
|
|
449
|
+
stopStream();
|
|
450
|
+
setState('idle');
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Ensure minimum recording time so the blob isn't empty
|
|
455
|
+
var elapsed = Date.now() - recordingStartedAt;
|
|
456
|
+
if (elapsed < MIN_RECORDING_MS) {
|
|
457
|
+
setTimeout(doStop, MIN_RECORDING_MS - elapsed);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
doStop();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function stopStream() {
|
|
464
|
+
if (stream) {
|
|
465
|
+
stream.getTracks().forEach(function (t) { t.stop(); });
|
|
466
|
+
stream = null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ── transcription ──────────────────────────────────────────────────────────
|
|
471
|
+
function transcribe(blob, mimeType) {
|
|
472
|
+
// Guard against recordings that exceed the server's 25MB JSON body limit
|
|
473
|
+
// (base64 adds ~33% overhead, so cap the raw blob at 18MB)
|
|
474
|
+
if (blob.size > 18 * 1024 * 1024) {
|
|
475
|
+
handleError(new Error('Recording too large — try a shorter message'));
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
var abortCtrl = new AbortController();
|
|
480
|
+
var fetchTimeout = setTimeout(function () { abortCtrl.abort(); }, 30000);
|
|
481
|
+
|
|
482
|
+
blobToBase64(blob)
|
|
483
|
+
.then(function (base64) {
|
|
484
|
+
var ext = mimeToExt(mimeType);
|
|
485
|
+
var payload = {
|
|
486
|
+
audioBase64: base64,
|
|
487
|
+
filename: 'recording.' + ext,
|
|
488
|
+
};
|
|
489
|
+
if (language) payload.language = language;
|
|
490
|
+
var body = JSON.stringify(payload);
|
|
491
|
+
|
|
492
|
+
return fetch(voiceUrl + '/api/voice/transcribe', {
|
|
493
|
+
method: 'POST',
|
|
494
|
+
headers: { 'Content-Type': 'application/json' },
|
|
495
|
+
body: body,
|
|
496
|
+
signal: abortCtrl.signal,
|
|
497
|
+
});
|
|
498
|
+
})
|
|
499
|
+
.then(function (res) {
|
|
500
|
+
if (!res.ok) {
|
|
501
|
+
return res.text().then(function (body) {
|
|
502
|
+
try {
|
|
503
|
+
var d = JSON.parse(body);
|
|
504
|
+
throw new Error(d.error || res.status + ' ' + res.statusText);
|
|
505
|
+
} catch (parseErr) {
|
|
506
|
+
if (parseErr instanceof SyntaxError) {
|
|
507
|
+
throw new Error(res.status + ' ' + (res.statusText || 'error') + (body ? ': ' + body.slice(0, 120) : ''));
|
|
508
|
+
}
|
|
509
|
+
throw parseErr;
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
return res.json();
|
|
514
|
+
})
|
|
515
|
+
.then(function (data) {
|
|
516
|
+
clearTimeout(fetchTimeout);
|
|
517
|
+
if (!data.ok) throw new Error(data.error || 'Transcription failed');
|
|
518
|
+
setState('idle');
|
|
519
|
+
onResult(data.text || '');
|
|
520
|
+
})
|
|
521
|
+
.catch(function (err) {
|
|
522
|
+
clearTimeout(fetchTimeout);
|
|
523
|
+
if (err.name === 'AbortError') {
|
|
524
|
+
handleError(new Error('Transcription timed out — server did not respond within 30 seconds'));
|
|
525
|
+
} else {
|
|
526
|
+
handleError(err);
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ── error handling ─────────────────────────────────────────────────────────
|
|
532
|
+
function handleError(err) {
|
|
533
|
+
if (chimeTimer) { clearTimeout(chimeTimer); chimeTimer = null; }
|
|
534
|
+
stopStream();
|
|
535
|
+
setState('error');
|
|
536
|
+
onError(err);
|
|
537
|
+
errorTimer = setTimeout(function () {
|
|
538
|
+
if (state === 'error') setState('idle');
|
|
539
|
+
}, 3000);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ── pointer / touch events ─────────────────────────────────────────────────
|
|
543
|
+
function onPointerDown(e) {
|
|
544
|
+
e.preventDefault();
|
|
545
|
+
startRecording();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function onPointerUp(e) {
|
|
549
|
+
e.preventDefault();
|
|
550
|
+
stopRecording();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ── keyboard shortcut ──────────────────────────────────────────────────────
|
|
554
|
+
var _keydownHandler = null;
|
|
555
|
+
var _keyupHandler = null;
|
|
556
|
+
|
|
557
|
+
function setupHotkey() {
|
|
558
|
+
if (!hotkey) return;
|
|
559
|
+
|
|
560
|
+
_keydownHandler = function (e) {
|
|
561
|
+
if (e.repeat) return;
|
|
562
|
+
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable)) return;
|
|
563
|
+
if (e.key.toLowerCase() === hotkey) {
|
|
564
|
+
e.preventDefault();
|
|
565
|
+
startRecording();
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
_keyupHandler = function (e) {
|
|
570
|
+
if (e.key.toLowerCase() === hotkey) {
|
|
571
|
+
e.preventDefault();
|
|
572
|
+
stopRecording();
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
document.addEventListener('keydown', _keydownHandler);
|
|
577
|
+
document.addEventListener('keyup', _keyupHandler);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function teardownHotkey() {
|
|
581
|
+
if (_keydownHandler) document.removeEventListener('keydown', _keydownHandler);
|
|
582
|
+
if (_keyupHandler) document.removeEventListener('keyup', _keyupHandler);
|
|
583
|
+
_keydownHandler = null;
|
|
584
|
+
_keyupHandler = null;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ── public API ─────────────────────────────────────────────────────────────
|
|
588
|
+
return {
|
|
589
|
+
/**
|
|
590
|
+
* Render the mic button into containerEl.
|
|
591
|
+
* @param {HTMLElement} containerEl
|
|
592
|
+
*/
|
|
593
|
+
mount: function (containerEl) {
|
|
594
|
+
if (!containerEl) throw new Error('VoiceWidget.mount: containerEl is required');
|
|
595
|
+
injectStyles();
|
|
596
|
+
|
|
597
|
+
// ── Wrapper ──
|
|
598
|
+
wrap = document.createElement('div');
|
|
599
|
+
wrap.className = 'vw-wrap';
|
|
600
|
+
wrap.setAttribute('data-state', 'idle');
|
|
601
|
+
|
|
602
|
+
// ── Button ──
|
|
603
|
+
btn = document.createElement('button');
|
|
604
|
+
btn.type = 'button';
|
|
605
|
+
btn.className = 'vw-btn';
|
|
606
|
+
btn.setAttribute('aria-label', idleLabel);
|
|
607
|
+
btn.setAttribute('data-state', 'idle');
|
|
608
|
+
|
|
609
|
+
spinnerEl = document.createElement('span');
|
|
610
|
+
spinnerEl.className = 'vw-spinner';
|
|
611
|
+
|
|
612
|
+
iconEl = document.createElement('span');
|
|
613
|
+
iconEl.className = 'vw-icon';
|
|
614
|
+
iconEl.innerHTML = ICONS.idle;
|
|
615
|
+
|
|
616
|
+
// Scanner strip container (Larson scanner bar)
|
|
617
|
+
var barsEl = document.createElement('span');
|
|
618
|
+
barsEl.className = 'vw-bars';
|
|
619
|
+
for (var i = 0; i < 7; i++) {
|
|
620
|
+
var bar = document.createElement('span');
|
|
621
|
+
bar.className = 'vw-bar';
|
|
622
|
+
barsEl.appendChild(bar);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
labelEl = document.createElement('span');
|
|
626
|
+
labelEl.className = 'vw-label';
|
|
627
|
+
labelEl.setAttribute('aria-live', 'polite');
|
|
628
|
+
labelEl.textContent = idleLabel;
|
|
629
|
+
|
|
630
|
+
var modulatorEl = document.createElement('span');
|
|
631
|
+
modulatorEl.className = 'vw-modulator';
|
|
632
|
+
for (var m = 0; m < 6; m++) {
|
|
633
|
+
modulatorEl.appendChild(document.createElement('span'));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
btn.appendChild(spinnerEl);
|
|
637
|
+
btn.appendChild(iconEl);
|
|
638
|
+
btn.appendChild(barsEl);
|
|
639
|
+
btn.appendChild(modulatorEl);
|
|
640
|
+
btn.appendChild(labelEl);
|
|
641
|
+
|
|
642
|
+
// pointer events
|
|
643
|
+
btn.addEventListener('pointerdown', onPointerDown);
|
|
644
|
+
btn.addEventListener('pointerup', onPointerUp);
|
|
645
|
+
btn.addEventListener('pointercancel', onPointerUp);
|
|
646
|
+
btn.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
|
647
|
+
|
|
648
|
+
wrap.appendChild(btn);
|
|
649
|
+
|
|
650
|
+
// ── Error flash overlay ──
|
|
651
|
+
var flash = document.createElement('span');
|
|
652
|
+
flash.className = 'vw-flash';
|
|
653
|
+
flash.setAttribute('aria-live', 'polite');
|
|
654
|
+
flash.setAttribute('role', 'status');
|
|
655
|
+
wrap.appendChild(flash);
|
|
656
|
+
|
|
657
|
+
containerEl.appendChild(wrap);
|
|
658
|
+
|
|
659
|
+
setupHotkey();
|
|
660
|
+
},
|
|
661
|
+
|
|
662
|
+
/** Remove the widget and all event listeners. */
|
|
663
|
+
destroy: function () {
|
|
664
|
+
clearTimeout(errorTimer);
|
|
665
|
+
if (chimeTimer) { clearTimeout(chimeTimer); chimeTimer = null; }
|
|
666
|
+
teardownHotkey();
|
|
667
|
+
stopStream();
|
|
668
|
+
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
669
|
+
mediaRecorder.stop();
|
|
670
|
+
}
|
|
671
|
+
if (btn) {
|
|
672
|
+
btn.removeEventListener('pointerdown', onPointerDown);
|
|
673
|
+
btn.removeEventListener('pointerup', onPointerUp);
|
|
674
|
+
btn.removeEventListener('pointercancel', onPointerUp);
|
|
675
|
+
}
|
|
676
|
+
if (_audioCtx) { _audioCtx.close(); _audioCtx = null; }
|
|
677
|
+
if (wrap && wrap.parentNode) {
|
|
678
|
+
wrap.parentNode.removeChild(wrap);
|
|
679
|
+
}
|
|
680
|
+
wrap = btn = iconEl = labelEl = spinnerEl = null;
|
|
681
|
+
},
|
|
682
|
+
|
|
683
|
+
/** Programmatically start recording. */
|
|
684
|
+
startRecording: startRecording,
|
|
685
|
+
|
|
686
|
+
/** Programmatically stop recording and trigger transcription. */
|
|
687
|
+
stopRecording: stopRecording,
|
|
688
|
+
|
|
689
|
+
/** Current state: 'idle' | 'recording' | 'transcribing' | 'error' */
|
|
690
|
+
get state() { return state; },
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
return { create: create };
|
|
695
|
+
});
|