clay-server 4.3.1-beta.5 → 4.4.0-beta.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/lib/public/app.js CHANGED
@@ -318,6 +318,18 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
318
318
  processing: false,
319
319
  scheduledMessageCorrelation: null,
320
320
  activeSessionId: null,
321
+ speechActive: null,
322
+ speechSettings: null,
323
+ speechSettingsRequest: 0,
324
+ speechSettingsSaving: false,
325
+ speechSettingsError: "",
326
+ speechPopup: null,
327
+ speechRetry: null,
328
+ speechLang: "en-US",
329
+ speechStatuses: {},
330
+ speechErrors: {},
331
+ speechLanguageSaving: false,
332
+ speechLifecycleBound: false,
321
333
  askUserRequests: {},
322
334
  activeBackgroundTasks: [],
323
335
  sessionVendorBound: false,
@@ -1008,11 +1020,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
1008
1020
  });
1009
1021
 
1010
1022
  // --- STT module (voice input via Web Speech API) ---
1011
- initSTT({
1012
- inputEl: inputEl,
1013
- addSystemMessage: addSystemMessage,
1014
- scrollToBottom: scrollToBottom,
1015
- });
1023
+ initSTT();
1016
1024
 
1017
1025
  // --- User profile (Discord-style popover on user island) ---
1018
1026
  initProfile({
@@ -589,6 +589,7 @@ body.home-surface-boot-pending #connect-overlay.hidden {
589
589
 
590
590
  #home-mate-chat-input {
591
591
  flex: 1 1 auto;
592
+ align-self: center;
592
593
  min-width: 0;
593
594
  max-height: 108px;
594
595
  padding: 6px 0;
@@ -1910,8 +1910,7 @@ pre.mermaid-error {
1910
1910
  display: none;
1911
1911
  }
1912
1912
 
1913
- /* DM mode: simplify input bar (no voice, no model config, no context, no slash menu) */
1914
- #main-column.dm-mode #stt-btn,
1913
+ /* DM mode: simplify input bar (no model config, no context, no slash menu) */
1915
1914
  #main-column.dm-mode #config-chip-wrap,
1916
1915
  #main-column.dm-mode #context-mini,
1917
1916
  #main-column.dm-mode #context-sources-btn-wrap,
@@ -1,155 +1,106 @@
1
- /* ==========================================================================
2
- Speech-to-Text (Web Speech API)
3
- ========================================================================== */
4
-
5
- /* --- Mic button --- */
6
- #stt-btn {
7
- width: 36px;
8
- height: 36px;
9
- border-radius: 50%;
10
- border: none;
11
- background: transparent;
12
- color: var(--text-muted);
13
- cursor: pointer;
14
- display: flex;
15
- align-items: center;
16
- justify-content: center;
17
- transition: width 0.3s ease, border-radius 0.3s ease, background 0.2s, color 0.2s;
18
- touch-action: manipulation;
19
- position: relative;
20
- overflow: hidden;
21
- gap: 6px;
22
- white-space: nowrap;
23
- flex-shrink: 0;
24
- }
25
-
26
- #stt-btn .lucide { width: 20px; height: 20px; flex-shrink: 0; }
27
- #stt-btn:hover { background: rgba(var(--overlay-rgb), 0.06); color: var(--text); }
28
-
29
- /* Recording active — pill shape */
30
- #stt-btn.stt-active {
31
- width: auto;
32
- padding: 0 14px 0 10px;
33
- border-radius: 18px;
34
- color: #fff;
35
- background: #e53935;
36
- }
37
-
38
- #stt-btn.stt-active:hover {
39
- background: #c62828;
40
- }
41
-
42
- /* --- Waveform bars --- */
43
- .stt-wave {
44
- display: flex;
45
- align-items: center;
46
- gap: 2px;
47
- height: 18px;
48
- flex-shrink: 0;
49
- }
50
-
51
- .stt-wave-bar {
52
- width: 3px;
53
- border-radius: 1.5px;
54
- background: rgba(255, 255, 255, 0.85);
55
- animation: stt-wave-bounce 1.2s ease-in-out infinite;
56
- }
57
-
58
- .stt-wave-bar:nth-child(1) { height: 6px; animation-delay: 0s; }
59
- .stt-wave-bar:nth-child(2) { height: 12px; animation-delay: 0.15s; }
60
- .stt-wave-bar:nth-child(3) { height: 16px; animation-delay: 0.3s; }
61
- .stt-wave-bar:nth-child(4) { height: 12px; animation-delay: 0.45s; }
62
- .stt-wave-bar:nth-child(5) { height: 6px; animation-delay: 0.6s; }
63
-
64
- @keyframes stt-wave-bounce {
65
- 0%, 100% { transform: scaleY(0.4); }
66
- 50% { transform: scaleY(1); }
67
- }
68
-
69
- /* Stop label */
70
- .stt-stop-label {
71
- font-size: 13px;
72
- font-weight: 600;
73
- letter-spacing: 0.02em;
74
- line-height: 1;
75
- }
76
-
77
- /* Loading state */
78
- #stt-btn.stt-loading {
79
- pointer-events: none;
80
- opacity: 0.7;
81
- }
82
-
83
- .stt-spinner {
84
- width: 18px;
85
- height: 18px;
86
- border: 2px solid var(--border);
87
- border-top-color: var(--text-muted);
88
- border-radius: 50%;
89
- animation: spin 0.8s linear infinite;
90
- display: block;
91
- }
92
-
93
- /* --- Language popover --- */
94
- .stt-lang-popover {
95
- position: absolute;
96
- bottom: 100%;
97
- left: 0;
98
- margin-bottom: 8px;
99
- background: var(--bg-alt);
100
- border: 1px solid var(--border);
101
- border-radius: 14px;
102
- box-shadow: 0 -4px 24px rgba(var(--shadow-rgb), 0.4);
103
- z-index: 20;
104
- min-width: 200px;
105
- overflow: hidden;
106
- }
107
-
108
- .stt-lang-title {
109
- padding: 12px 16px 8px;
110
- font-size: 11px;
111
- font-weight: 700;
112
- text-transform: uppercase;
113
- letter-spacing: 0.05em;
114
- color: var(--text-dimmer);
115
- }
116
-
117
- .stt-lang-option {
118
- display: flex;
119
- align-items: center;
120
- justify-content: space-between;
121
- width: 100%;
122
- padding: 10px 16px;
123
- border: none;
124
- background: transparent;
125
- color: var(--text-secondary);
126
- font-size: 14px;
127
- font-family: inherit;
128
- cursor: pointer;
129
- text-align: left;
130
- transition: background 0.12s;
131
- gap: 8px;
132
- }
133
-
134
- .stt-lang-option:hover {
135
- background: rgba(var(--overlay-rgb), 0.05);
136
- }
137
-
138
- .stt-lang-option.stt-lang-active {
139
- color: var(--accent);
140
- font-weight: 600;
141
- }
142
-
143
- .stt-lang-name {
144
- flex: 1;
145
- }
146
-
147
- .stt-lang-hint {
148
- font-size: 11px;
149
- color: var(--text-dimmer);
150
- flex-shrink: 0;
151
- }
152
-
153
- .stt-lang-option:last-child {
154
- border-radius: 0 0 14px 14px;
155
- }
1
+ /* Voice controls use the same small overlapping vendor badge as mention avatars. */
2
+ .stt-controls { display: inline-flex; align-items: center; gap: 0; flex: 0 0 auto; }
3
+ .stt-button, .stt-picker-toggle {
4
+ position: relative; width: 32px; height: 36px; padding: 0; border: 0; border-radius: 8px;
5
+ background: transparent; color: var(--text-muted); cursor: pointer; display: inline-flex;
6
+ align-items: center; justify-content: center; touch-action: manipulation; flex-shrink: 0;
7
+ }
8
+ .stt-button .lucide { width: 20px; height: 20px; }
9
+ .stt-picker-toggle { width: 20px; }
10
+ .stt-picker-toggle .lucide { width: 12px; height: 12px; }
11
+ .stt-button:hover, .stt-picker-toggle:hover { background: rgba(var(--overlay-rgb), .06); color: var(--text); }
12
+ .stt-button.stt-active { color: var(--text); background: rgba(var(--overlay-rgb), .08); border: 1px solid var(--border); border-radius: 50%; }
13
+ .stt-button.stt-active .lucide { width: 15px; height: 15px; fill: currentColor; }
14
+ .stt-controls [hidden] { display: none; }
15
+ .stt-vendor-badge {
16
+ position: absolute; bottom: 2px; right: 0; width: 12px; height: 12px;
17
+ border-radius: 50%; border: 1.5px solid var(--sidebar-bg); background: #fff;
18
+ object-fit: contain; box-sizing: content-box;
19
+ }
20
+ .stt-button:disabled, .stt-picker-toggle:disabled { opacity: .45; cursor: default; }
21
+ .stt-progress { display: flex; gap: 8px; align-items: center; padding: 6px 10px; flex-wrap: wrap; font-size: 11px; color: var(--text-muted); min-width: 0; }
22
+ .stt-progress[hidden], .stt-progress [hidden] { display: none; }
23
+ .stt-status { flex: 1; min-width: 100px; line-height: 1.5; }
24
+ .stt-progress-error { margin: 2px 2px 6px; padding: 10px 12px; border: 1px solid #c83737; border-radius: 8px; background: rgba(200, 55, 55, .1); color: var(--text); }
25
+ .stt-progress-error .stt-status { font-size: 12px; font-weight: 500; }
26
+ .stt-progress-error a { color: inherit; text-decoration: underline; }
27
+ .stt-timer { font-variant-numeric: tabular-nums; color: var(--text); }
28
+ .stt-progress button { border: 1px solid var(--border); border-radius: 6px; background: transparent; color: var(--text); font: inherit; cursor: pointer; padding: 5px 8px; }
29
+ .stt-progress-active { flex: 1 1 0; flex-wrap: nowrap; padding: 0 8px 0 0; height: 62px; }
30
+ .stt-progress-active .stt-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); min-width: 0; }
31
+ .stt-progress-pending .stt-status { position: static; width: auto; height: auto; clip-path: none; }
32
+ .stt-progress-pending .stt-meter { display: none; }
33
+ .stt-meter canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
34
+ .stt-progress-active .stt-cancel { flex: 0 0 30px; width: 30px; height: 30px; border-radius: 50%; padding: 6px; }
35
+ .stt-cancel .lucide { width: 16px; height: 16px; }
36
+ .stt-meter { position: relative; display: block; flex: 1; min-width: 24px; height: 58px; overflow: hidden; color: var(--text); }
37
+ .stt-recording #attach-wrap, .stt-recording #config-chip-wrap, .stt-recording #active-vendor-indicator { display: none; }
38
+ .stt-recording #input-bottom { flex-wrap: nowrap; }
39
+ .home-mate-chat-composer { flex-wrap: wrap; }
40
+ .home-mate-chat-composer.stt-recording { align-items: center; }
41
+ .home-mate-chat-composer.stt-recording .stt-button,
42
+ .home-mate-chat-composer.stt-recording .stt-cancel { width: 34px; height: 34px; flex-basis: 34px; }
43
+ .home-mate-chat-composer > .stt-progress { flex-basis: 100%; }
44
+ #home-mate-chat-composer.stt-recording > #home-mate-chat-input { flex-basis: 100%; order: -1; }
45
+ .home-mate-chat-composer.stt-recording > .stt-progress-active { flex: 1 1 0; }
46
+ .home-mate-chat-composer > .stt-progress-error { margin: 0 0 4px; }
47
+ .stt-picker {
48
+ position: fixed; z-index: 10010; width: 324px; max-width: calc(100vw - 16px); overflow-y: auto;
49
+ padding: 14px; box-sizing: border-box; background: var(--sidebar-bg); color: var(--text);
50
+ border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 12px 40px rgba(var(--shadow-rgb), .24);
51
+ font-size: 12px;
52
+ }
53
+ .stt-picker button, .stt-picker input, .stt-picker select { font: inherit; color: inherit; }
54
+ .stt-picker button { cursor: pointer; }
55
+ .stt-picker button:disabled { opacity: .5; cursor: default; }
56
+ .stt-picker-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; font-size: 13px; }
57
+ .stt-picker-heading button { background: transparent; border: 0; padding: 4px; border-radius: 4px; }
58
+ .stt-picker-heading .lucide { width: 16px; height: 16px; }
59
+ .stt-picker p { color: var(--text-muted); line-height: 1.5; margin: 10px 0; }
60
+ .stt-mode-tabs { display: flex; background: rgba(var(--overlay-rgb), .04); padding: 3px; border-radius: 8px; gap: 3px; }
61
+ .stt-mode-tabs button { flex: 1; background: transparent; border: 0; border-radius: 6px; padding: 7px 4px; }
62
+ .stt-mode-tabs button[aria-pressed="true"] { background: var(--bg); box-shadow: 0 1px 3px rgba(var(--shadow-rgb), .15); color: var(--text); }
63
+ .stt-model-list { max-height: 270px; overflow-y: auto; overscroll-behavior: contain; margin: 0 -5px; }
64
+ .stt-model { display: flex; width: 100%; text-align: left; align-items: center; gap: 8px; border: 0; background: transparent; padding: 9px 7px; border-radius: 7px; }
65
+ .stt-model:hover, .stt-model[aria-pressed="true"] { background: rgba(var(--overlay-rgb), .06); }
66
+ .stt-model > span:not(.stt-model-check) { flex: 1; min-width: 0; }
67
+ .stt-model strong { font-size: 12px; font-weight: 500; }
68
+ .stt-model small { font-size: 10px; color: var(--accent); flex-shrink: 0; }
69
+ .stt-model-check { width: 14px; flex-shrink: 0; }
70
+ .stt-model-check .lucide { width: 13px; height: 13px; }
71
+ .stt-model img { width: 20px; height: 20px; object-fit: contain; background: white; border-radius: 50%; }
72
+ .stt-picker-footer { border-top: 1px solid var(--border); margin-top: 12px; padding-top: 12px; }
73
+ .stt-picker-footer label { display: flex; align-items: center; justify-content: space-between; }
74
+ .stt-picker select { max-width: 150px; padding: 5px; background: var(--bg); border: 1px solid var(--border); border-radius: 5px; }
75
+ .stt-key-form label { display: block; margin-bottom: 6px; }
76
+ .stt-key-form input { width: 100%; box-sizing: border-box; padding: 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); }
77
+ .stt-key-form a { color: var(--accent); }
78
+ .stt-key-actions { display: flex; justify-content: space-between; margin-top: 16px; gap: 8px; }
79
+ .stt-key-actions button { border: 1px solid var(--border); border-radius: 6px; padding: 7px 10px; background: var(--bg); }
80
+ .stt-picker .stt-picker-error { color: var(--error, #c83737); }
81
+ .stt-controls button:focus-visible, .stt-picker :focus-visible, .stt-progress button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
82
+ #home-mate-chat-input { min-width: 0; }
83
+ @media (max-width: 600px) {
84
+ .stt-picker { left: 8px !important; right: 8px; bottom: max(8px, env(safe-area-inset-bottom)) !important; width: auto; max-height: 75dvh !important; border-radius: 16px; }
85
+ .stt-picker-toggle { width: 24px; }
86
+ .stt-picker button { min-height: 36px; }
87
+ }
88
+ /* Compact provider rows keep setup beside the model it belongs to. */
89
+ .stt-mode-tabs button { text-align: left; padding: 8px; }
90
+ .stt-mode-tabs strong { display: block; font-size: 12px; font-weight: 500; }
91
+ .stt-mode-tabs button span { display: block; font-size: 10px; line-height: 1.4; color: var(--text-muted); margin-top: 4px; white-space: normal; }
92
+ .stt-model-list { margin-top: 10px; max-height: 340px; }
93
+ .stt-model-row { display: flex; align-items: center; gap: 2px; }
94
+ .stt-model-row .stt-model { flex: 1; min-width: 0; gap: 10px; min-height: 38px; }
95
+ .stt-model-row .stt-model > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
96
+ .stt-key-toggle { border: 0; background: transparent; padding: 8px; color: var(--text-muted); border-radius: 6px; font-size: 10px !important; }
97
+ .stt-key-toggle:hover { background: rgba(var(--overlay-rgb), .06); }
98
+ .stt-key-toggle .lucide, .stt-browser-icon .lucide { width: 16px; height: 16px; }
99
+ .stt-model .stt-browser-icon { flex: 0 0 20px !important; display: flex; justify-content: center; }
100
+ .stt-model .stt-model-check { flex: 0 0 14px; }
101
+ .stt-key-form { padding: 10px; margin: 2px 4px 8px; background: rgba(var(--overlay-rgb), .03); border: 1px solid var(--border); border-radius: 8px; }
102
+ .stt-key-actions { align-items: center; margin-top: 8px; }
103
+ .stt-key-actions a { margin-right: auto; }
104
+ .stt-picker { display: flex; flex-direction: column; overflow: hidden; }
105
+ .stt-picker-heading, .stt-mode-tabs, .stt-picker-footer { flex-shrink: 0; }
106
+ .stt-model-list { min-height: 0; flex-shrink: 1; }
@@ -0,0 +1,5 @@
1
+ Speech provider marks identify the service receiving a user's audio.
2
+ ElevenLabs and Deepgram SVGs: Simple Icons (CC0-1.0), https://github.com/simple-icons/simple-icons
3
+ Soniox PNG: https://soniox.com/icons/favicon-32x32.png
4
+ Groq SVG: https://groq.com/favicon.svg
5
+ Retrieved 2026-09-25. Brand marks remain subject to their respective owners' trademark rights.
@@ -0,0 +1 @@
1
+ <svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Deepgram</title><path d="M11.203 24H1.517a.364.364 0 0 1-.258-.62l6.239-6.275a.366.366 0 0 1 .259-.108h3.52c2.723 0 5.025-2.127 5.107-4.845a5.004 5.004 0 0 0-4.999-5.148H7.613v4.646c0 .2-.164.364-.365.364H.968a.365.365 0 0 1-.363-.364V.364C.605.164.768 0 .969 0h10.416c6.684 0 12.111 5.485 12.01 12.187C23.293 18.77 17.794 24 11.202 24z"/></svg>
@@ -0,0 +1 @@
1
+ <svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>ElevenLabs</title><path d="M4.6035 0v24h4.9317V0zm9.8613 0v24h4.9317V0z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" width="33" height="33" viewBox="0 0 33 33"><g clip-path="url(#a)"><path fill="#F43E01" d="M.54.39h32v32h-32z"/><path fill="#fff" d="m18.445 4.406-9.468 13.74 7.341.665-1.69 9.578 9.469-13.74-7.342-.664 1.69-9.579Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M.54.39h32v32h-32z"/></clipPath></defs></svg>
@@ -183,9 +183,10 @@
183
183
  </section>
184
184
  <div class="home-mate-chat-messages" id="home-mate-chat-messages"></div>
185
185
  <button id="home-chat-new-activity" class="home-chat-new-activity" type="button" hidden><i data-lucide="arrow-down" aria-hidden="true"></i><span>New activity</span></button>
186
- <div class="home-mate-chat-composer-frame">
186
+ <div id="home-mate-chat-composer-frame" class="home-mate-chat-composer-frame">
187
187
  <div id="home-mate-chat-composer" class="home-mate-chat-composer">
188
188
  <textarea id="home-mate-chat-input" rows="1" placeholder="Message"></textarea>
189
+ <button id="home-stt-btn" type="button" aria-label="Start voice input"><i data-lucide="mic"></i></button>
189
190
  <button id="home-mate-chat-send" aria-label="Send message"><i data-lucide="arrow-up"></i></button>
190
191
  </div>
191
192
  <div id="home-mate-chat-session-model" class="home-mate-chat-session-model" role="status" aria-live="polite" title="Model for this conversation: Loading model…">
@@ -564,7 +565,6 @@
564
565
  <button id="input-more-btn" type="button" aria-label="More options" title="More options" class="mobile-only"><i data-lucide="plus"></i></button>
565
566
  <button id="attach-file-btn" type="button" aria-label="Attach file" title="Attach file" class="desktop-only"><i data-lucide="paperclip"></i></button>
566
567
  <button id="attach-image-btn" type="button" aria-label="Attach image" title="Attach image" class="desktop-only"><i data-lucide="image"></i></button>
567
- <button id="stt-btn" type="button" aria-label="Voice input" title="Voice input"><i data-lucide="mic"></i></button>
568
568
  <div id="context-sources-btn-wrap" class="desktop-only">
569
569
  <button id="context-sources-add" type="button" aria-label="Add context" title="Add context sources"><i data-lucide="library"></i></button>
570
570
  <div id="context-sources-picker" class="hidden">
@@ -640,6 +640,7 @@
640
640
  </div>
641
641
  </div>
642
642
  </div>
643
+ <button id="stt-btn" type="button" aria-label="Voice input" title="Voice input"><i data-lucide="mic"></i></button>
643
644
  <button id="send-btn" disabled aria-label="Send"><i data-lucide="arrow-up"></i></button>
644
645
  </div>
645
646
  </div>
@@ -1,3 +1,4 @@
1
+ import { stopSTT } from './stt-controller.js';
1
2
  // home-mate-chat.js - Embedded conversation surface for the selected mate.
2
3
  import { store } from './store.js';
3
4
  import { getWs } from './ws-ref.js';
@@ -85,6 +86,7 @@ function renderSessionModel() {
85
86
  sessionModelEl.setAttribute("aria-label", "Model for this conversation: " + label);
86
87
  }
87
88
  function submitMessage() {
89
+ stopSTT();
88
90
  var mateId = store.get('homeChatMateId');
89
91
  var text = inputEl ? inputEl.value.trim() : "";
90
92
  if (!mateId || !text || streaming || hasPendingHomeDebateQuestion(messages) || !hasCommittedSessionModel()) return false;
@@ -105,6 +107,7 @@ function submitSuggestion(text) {
105
107
  submitMessage();
106
108
  }
107
109
  export function startNewHomeConversation() {
110
+ stopSTT();
108
111
  setHomeSubSurface("chat");
109
112
  var mateId = store.get('homeChatMateId');
110
113
  if (!mateId || streaming) return;
@@ -118,6 +121,7 @@ export function startNewHomeConversation() {
118
121
  renderHomeChat();
119
122
  }
120
123
  export function startHomeCapsuleCreation(mateId, description) {
124
+ stopSTT();
121
125
  if (!mateId || mateId !== store.get('homeChatMateId') || !description || streaming) return false;
122
126
  setHomeSubSurface("chat"); resetHomeDebateLaunch();
123
127
  mateCreationActive = false;
@@ -232,6 +236,7 @@ export function renderHomeChat() {
232
236
  restoreHomeChatScroll(scrollSnapshot);
233
237
  }
234
238
  export function openHomeChat(mateId) {
239
+ stopSTT();
235
240
  if (!mateId) return;
236
241
  setHomeSubSurface("chat");
237
242
  resetHomeDebateLaunch();
@@ -250,6 +255,7 @@ export function openHomeChat(mateId) {
250
255
  if (store.get('homeSurfaceLoaded')) resumeHomeChat();
251
256
  }
252
257
  export function openHomeConversation(mateId, sessionId) {
258
+ stopSTT();
253
259
  if (!mateId || !sessionId) return;
254
260
  setHomeSubSurface("chat");
255
261
  resetHomeDebateLaunch();
@@ -268,6 +274,7 @@ export function openHomeConversation(mateId, sessionId) {
268
274
  if (store.get('homeSurfaceLoaded')) resumeHomeChat();
269
275
  }
270
276
  export function openHomeMateAction(kind, initialTopic) {
277
+ stopSTT();
271
278
  if (kind !== "debate" && kind !== "mate") return false;
272
279
  var mates = store.get('cachedMatesList') || [];
273
280
  var clay = null;
@@ -297,6 +304,7 @@ export function resumeHomeChat() {
297
304
  else requestHomeSession({ type: "home_mate_open", mateId: mateId }, null);
298
305
  }
299
306
  export function closeHomeChat() {
307
+ stopSTT();
300
308
  resetHomeDebateLaunch();
301
309
  sendMessage({ type: "home_mate_close" });
302
310
  streaming = false;
@@ -1,3 +1,4 @@
1
+ import { stopSTT } from './stt-controller.js';
1
2
  import { iconHtml, refreshIcons } from './icons.js';
2
3
  import { setRewindMode, isRewindMode } from './rewind.js';
3
4
  import { renderPicker as renderContextPicker } from './context-sources.js';
@@ -111,6 +112,7 @@ function showPreThinkingForTurn() {
111
112
 
112
113
  // --- Send ---
113
114
  export function sendMessage() {
115
+ stopSTT();
114
116
  // Debate ended mode intercept: route to resume handler
115
117
  if (ctx.isDebateEndedMode && ctx.isDebateEndedMode() && ctx.handleDebateEndedSend) {
116
118
  ctx.handleDebateEndedSend();
@@ -3,7 +3,6 @@
3
3
  // Generated identities use deterministic local pixel identicons.
4
4
 
5
5
  import { iconHtml, refreshIcons } from './icons.js';
6
- import { setSTTLang } from './stt.js';
7
6
  import { avatarUrl, mateAvatarUrl, mateMarkUrl, userAvatarSeed } from './avatar.js';
8
7
  import { store } from './store.js';
9
8
  import { applyTerminalFont } from './terminal-prefs.js';
@@ -519,9 +518,6 @@ export function initProfile(_ctx) {
519
518
 
520
519
  applyToIsland();
521
520
 
522
- if (profile.lang) {
523
- setSTTLang(profile.lang);
524
- }
525
521
 
526
522
  // Hydrate terminal font prefs at boot. Previously this only happened
527
523
  // when the user opened the Settings modal (user-settings.js's
@@ -0,0 +1,32 @@
1
+ // Downsample microphone audio to mono 16 kHz PCM without retaining the recording.
2
+ class SpeechPCMProcessor extends AudioWorkletProcessor {
3
+ constructor() {
4
+ super();
5
+ this.samples = []; this.phase = 0; this.sum = 0; this.count = 0;
6
+ this.port.onmessage = function (event) {
7
+ if (event.data === 'flush') { this.flush(); this.port.postMessage({ flushed: true }); }
8
+ }.bind(this);
9
+ }
10
+ flush() {
11
+ if (!this.samples.length) return;
12
+ var pcm = new Int16Array(this.samples); this.samples = [];
13
+ this.port.postMessage({ pcm: pcm.buffer }, [pcm.buffer]);
14
+ }
15
+ process(inputs) {
16
+ var channels = inputs[0];
17
+ if (!channels || !channels[0]) return true;
18
+ for (var i = 0; i < channels[0].length; i++) {
19
+ var value = 0;
20
+ for (var c = 0; c < channels.length; c++) value += channels[c][i] / channels.length;
21
+ this.sum += value; this.count++; this.phase += 16000;
22
+ if (this.phase >= sampleRate) {
23
+ var sample = Math.max(-1, Math.min(1, this.sum / this.count));
24
+ this.samples.push(Math.round(sample * (sample < 0 ? 32768 : 32767)));
25
+ this.phase -= sampleRate; this.sum = 0; this.count = 0;
26
+ }
27
+ if (this.samples.length >= 1600) this.flush();
28
+ }
29
+ return true;
30
+ }
31
+ }
32
+ registerProcessor('clay-speech-pcm', SpeechPCMProcessor);
@@ -0,0 +1,68 @@
1
+ export async function captureSpeech(onAudio, trace) {
2
+ trace = trace || function () {};
3
+ var stage = 'capability-check';
4
+ var Constructor = window.AudioContext || window.webkitAudioContext;
5
+ trace(stage, { audioContext: !!Constructor, mediaDevices: !!navigator.mediaDevices, secureContext: window.isSecureContext });
6
+ if (!Constructor || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) throw new Error('Microphone recording is unavailable in this browser.');
7
+ var context = new Constructor();
8
+ trace('audio-context-created', { state: context.state, sampleRate: context.sampleRate });
9
+ var stream; var node; var source; var sink; var closed = false;
10
+ function close() {
11
+ if (closed) return; closed = true;
12
+ trace('audio-cleanup', { stage: stage, hasStream: !!stream, hasWorklet: !!node });
13
+ if (node) node.disconnect(); if (source) source.disconnect(); if (sink) sink.disconnect();
14
+ if (stream) stream.getTracks().forEach(function (track) { track.stop(); });
15
+ context.close().catch(function () {});
16
+ }
17
+ try {
18
+ stage = 'audio-context-resume'; trace(stage);
19
+ await context.resume();
20
+ stage = 'microphone-permission'; trace(stage, { state: context.state });
21
+ stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
22
+ trace('microphone-granted', { tracks: stream.getTracks().length });
23
+ stream.getTracks().forEach(function (track) {
24
+ if (!track.addEventListener) return;
25
+ ['ended', 'mute', 'unmute'].forEach(function (event) {
26
+ track.addEventListener(event, function () { trace('microphone-track-' + event, { state: track.readyState }); });
27
+ });
28
+ });
29
+ stage = 'audio-worklet-load'; trace(stage);
30
+ await context.audioWorklet.addModule('/modules/stt-audio-worklet.js');
31
+ stage = 'audio-worklet-create'; trace(stage);
32
+ node = new AudioWorkletNode(context, 'clay-speech-pcm');
33
+ var flushResolve;
34
+ node.port.onmessage = function (event) {
35
+ if (closed) return;
36
+ if (event.data.pcm) onAudio(event.data.pcm);
37
+ if (event.data.flushed && flushResolve) flushResolve();
38
+ };
39
+ source = context.createMediaStreamSource(stream);
40
+ sink = context.createGain(); sink.gain.value = 0;
41
+ source.connect(node); node.connect(sink); sink.connect(context.destination);
42
+ stage = 'capturing'; trace('audio-connected', { state: context.state, sampleRate: context.sampleRate });
43
+ return {
44
+ abort: close,
45
+ finish: async function () {
46
+ if (closed) return;
47
+ source.disconnect();
48
+ await new Promise(function (resolve) {
49
+ var timer = setTimeout(resolve, 250);
50
+ flushResolve = function () { clearTimeout(timer); resolve(); };
51
+ node.port.postMessage('flush');
52
+ });
53
+ close();
54
+ }
55
+ };
56
+ } catch (error) { trace('audio-start-failed', { stage: stage, errorName: error.name }); close(); throw error; }
57
+ }
58
+
59
+ export function wavRecording(chunks) {
60
+ var length = chunks.reduce(function (sum, chunk) { return sum + chunk.byteLength; }, 0);
61
+ var header = new ArrayBuffer(44); var view = new DataView(header);
62
+ function string(at, text) { for (var i = 0; i < text.length; i++) view.setUint8(at + i, text.charCodeAt(i)); }
63
+ string(0, 'RIFF'); view.setUint32(4, 36 + length, true); string(8, 'WAVE'); string(12, 'fmt ');
64
+ view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true);
65
+ view.setUint32(24, 16000, true); view.setUint32(28, 32000, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true);
66
+ string(36, 'data'); view.setUint32(40, length, true);
67
+ return new Blob([header].concat(chunks), { type: 'audio/wav' });
68
+ }