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 +13 -5
- package/lib/public/css/home-hub.css +1 -0
- package/lib/public/css/messages.css +1 -2
- package/lib/public/css/stt.css +106 -155
- package/lib/public/icons/speech/SOURCES.txt +5 -0
- package/lib/public/icons/speech/deepgram.svg +1 -0
- package/lib/public/icons/speech/elevenlabs.svg +1 -0
- package/lib/public/icons/speech/groq.svg +1 -0
- package/lib/public/icons/speech/soniox.png +0 -0
- package/lib/public/index.html +3 -2
- package/lib/public/modules/home-mate-chat.js +8 -0
- package/lib/public/modules/input.js +2 -0
- package/lib/public/modules/profile.js +0 -4
- package/lib/public/modules/stt-audio-worklet.js +32 -0
- package/lib/public/modules/stt-audio.js +68 -0
- package/lib/public/modules/stt-controller.js +202 -0
- package/lib/public/modules/stt-debug.js +7 -0
- package/lib/public/modules/stt-meter.js +55 -0
- package/lib/public/modules/stt-picker.js +123 -0
- package/lib/public/modules/stt-recording.js +130 -0
- package/lib/public/modules/stt-settings.js +50 -0
- package/lib/public/modules/stt-signal.js +57 -0
- package/lib/public/modules/stt.js +113 -252
- package/lib/public/modules/user-settings.js +15 -9
- package/lib/public/modules/worker-pane-lock.js +1 -0
- package/lib/server-speech.js +166 -0
- package/lib/server.js +8 -0
- package/lib/speech-catalog.js +25 -0
- package/lib/speech-openai-stream.js +102 -0
- package/lib/speech-store.js +71 -0
- package/lib/speech-stream.js +108 -0
- package/lib/speech-transcribe.js +63 -0
- package/package.json +1 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Shared recognition lifecycle. Recognition callbacks belong to one immutable draft context.
|
|
2
|
+
import { store } from './store.js';
|
|
3
|
+
import { speechTrace } from './stt-debug.js';
|
|
4
|
+
import { startBrowserSpeechSignal } from './stt-signal.js';
|
|
5
|
+
|
|
6
|
+
export function speechConstructor() {
|
|
7
|
+
return window.SpeechRecognition || window.webkitSpeechRecognition || null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function speechContext() {
|
|
11
|
+
var s = store.snap();
|
|
12
|
+
return JSON.stringify([s.currentSlug, s.activeSessionId, s.dmMode, s.dmKey,
|
|
13
|
+
s.dmTargetUser && s.dmTargetUser.id, s.homePrimarySurface, s.homeSubSurface,
|
|
14
|
+
s.homeChatMateId, s.homeChatSessionId]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function setSpeechStatus(id, message, isError) {
|
|
18
|
+
var statuses = Object.assign({}, store.get('speechStatuses'));
|
|
19
|
+
var errors = Object.assign({}, store.get('speechErrors'));
|
|
20
|
+
statuses[id] = message;
|
|
21
|
+
errors[id] = isError ? { message: message } : null;
|
|
22
|
+
store.set({ speechStatuses: statuses, speechErrors: errors });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function failSTT(message) {
|
|
26
|
+
var active = store.get('speechActive');
|
|
27
|
+
if (!active) return;
|
|
28
|
+
stopSTT(message);
|
|
29
|
+
setSpeechStatus(active.id, message, true);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function usableSpeechInput(input) {
|
|
33
|
+
if (!(input && input.isConnected && !input.disabled && !input.readOnly &&
|
|
34
|
+
!input.closest('[hidden], .hidden, [aria-hidden="true"]') && input.getClientRects().length &&
|
|
35
|
+
!document.hidden)) return false;
|
|
36
|
+
try {
|
|
37
|
+
var frame = window.frameElement;
|
|
38
|
+
while (frame) {
|
|
39
|
+
if (!frame.isConnected || !frame.getClientRects().length ||
|
|
40
|
+
frame.closest('[hidden], .hidden, [aria-hidden="true"]')) return false;
|
|
41
|
+
frame = frame.ownerDocument.defaultView.frameElement;
|
|
42
|
+
}
|
|
43
|
+
} catch (error) { return false; }
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function stopSTT(message, reason) {
|
|
48
|
+
var active = store.get('speechActive');
|
|
49
|
+
if (!active) return;
|
|
50
|
+
speechTrace(active, 'stop', { reason: reason || (typeof message === 'string' ? message : 'unspecified caller'), bytes: active.bytes || 0 });
|
|
51
|
+
console.trace('[Clay voice] Stop caller · ' + (active.traceId || 'browser'));
|
|
52
|
+
// Invalidate before abort: late results and end events must never revive an old draft.
|
|
53
|
+
store.set({ speechActive: null });
|
|
54
|
+
clearTimeout(active.timeout);
|
|
55
|
+
if (active.visualCapture) active.visualCapture.abort();
|
|
56
|
+
try { active.recognition.abort(); } catch (error) { /* Already ended. */ }
|
|
57
|
+
setSpeechStatus(active.id, typeof message === 'string' ? message : 'Voice input stopped.');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function currentSpeech(active) {
|
|
61
|
+
if (store.get('speechActive') !== active) return false;
|
|
62
|
+
if (active.context !== speechContext() || !usableSpeechInput(active.input) ||
|
|
63
|
+
active.input.value !== active.lastValue) {
|
|
64
|
+
stopSTT(undefined, active.context !== speechContext() ? 'draft context changed' :
|
|
65
|
+
active.input.value !== active.lastValue ? 'draft text changed outside voice input' : 'input hidden, disabled, detached or document hidden');
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function claimSpeech(id, input, recognition) {
|
|
72
|
+
try { window.top.document.dispatchEvent(new window.top.Event('clay:voice-claim')); }
|
|
73
|
+
catch (error) { stopSTT(); }
|
|
74
|
+
stopSTT();
|
|
75
|
+
var active = { id: id, input: input, recognition: recognition, context: speechContext(),
|
|
76
|
+
before: input.value, lastValue: input.value, writing: false, started: false, timeout: null };
|
|
77
|
+
active.traceId = Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 7);
|
|
78
|
+
active.traceStarted = Date.now();
|
|
79
|
+
store.set({ speechActive: active, speechRetry: null });
|
|
80
|
+
return active;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function writeSpeech(active, transcript) {
|
|
84
|
+
if (!currentSpeech(active)) return false;
|
|
85
|
+
var separator = active.before && transcript && !/\s$/.test(active.before) ? ' ' : '';
|
|
86
|
+
active.lastValue = active.before + separator + transcript;
|
|
87
|
+
active.input.value = active.lastValue;
|
|
88
|
+
active.writing = true;
|
|
89
|
+
try { active.input.dispatchEvent(new Event('input', { bubbles: true })); }
|
|
90
|
+
finally { active.writing = false; }
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function startSTT(id, input) {
|
|
95
|
+
if (!usableSpeechInput(input)) return;
|
|
96
|
+
var Constructor = speechConstructor();
|
|
97
|
+
if (!window.isSecureContext || !Constructor) {
|
|
98
|
+
setSpeechStatus(id, !window.isSecureContext ? 'Voice input requires a secure HTTPS connection.' :
|
|
99
|
+
'Voice input is unavailable in this browser. You can still type your message.');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Same-origin split panes share the top document as a synchronous ownership signal.
|
|
103
|
+
// This event only stops recognition; it can never start a microphone.
|
|
104
|
+
try { window.top.document.dispatchEvent(new window.top.Event('clay:voice-claim')); }
|
|
105
|
+
catch (error) { stopSTT(); }
|
|
106
|
+
stopSTT();
|
|
107
|
+
var recognition;
|
|
108
|
+
try { recognition = new Constructor(); }
|
|
109
|
+
catch (error) { setSpeechStatus(id, 'Voice input could not start. You can still type your message.'); return; }
|
|
110
|
+
var active = { id: id, input: input, recognition: recognition, context: speechContext(),
|
|
111
|
+
before: input.value, lastValue: input.value, writing: false, started: false, timeout: null };
|
|
112
|
+
recognition.lang = store.get('speechLang') || 'en-US';
|
|
113
|
+
recognition.continuous = true;
|
|
114
|
+
recognition.interimResults = true;
|
|
115
|
+
recognition.onstart = function () {
|
|
116
|
+
if (!currentSpeech(active)) return;
|
|
117
|
+
clearTimeout(active.timeout);
|
|
118
|
+
active.started = true;
|
|
119
|
+
store.set({ speechActive: active });
|
|
120
|
+
setSpeechStatus(id, 'Listening. Select the microphone to stop.');
|
|
121
|
+
};
|
|
122
|
+
recognition.onresult = function (event) {
|
|
123
|
+
if (!currentSpeech(active)) return;
|
|
124
|
+
var parts = [];
|
|
125
|
+
for (var i = 0; i < event.results.length; i++) parts.push(event.results[i][0].transcript);
|
|
126
|
+
var transcript = parts.join('');
|
|
127
|
+
var separator = active.before && transcript && !/\s$/.test(active.before) ? ' ' : '';
|
|
128
|
+
active.lastValue = active.before + separator + transcript;
|
|
129
|
+
input.value = active.lastValue;
|
|
130
|
+
active.writing = true;
|
|
131
|
+
try { input.dispatchEvent(new Event('input', { bubbles: true })); }
|
|
132
|
+
finally { active.writing = false; }
|
|
133
|
+
};
|
|
134
|
+
recognition.onerror = function (event) {
|
|
135
|
+
if (store.get('speechActive') !== active) return;
|
|
136
|
+
var messages = {
|
|
137
|
+
'not-allowed': 'Microphone access was denied. Allow microphone access in your browser settings to try again.',
|
|
138
|
+
'service-not-allowed': 'Speech recognition is not allowed by this browser or device.',
|
|
139
|
+
'audio-capture': 'No microphone is available. Check your microphone connection and permissions.',
|
|
140
|
+
'no-speech': 'No speech detected. Select the microphone to try again.',
|
|
141
|
+
'network': 'The speech recognition service could not be reached. Check your connection or try another browser.',
|
|
142
|
+
'language-not-supported': 'This recognition language is unavailable. Choose another language.',
|
|
143
|
+
'aborted': 'Voice input stopped.'
|
|
144
|
+
};
|
|
145
|
+
if (event.error === 'aborted' || event.error === 'no-speech') stopSTT(messages[event.error]);
|
|
146
|
+
else failSTT(messages[event.error] || 'Voice input failed. You can still type your message.');
|
|
147
|
+
};
|
|
148
|
+
recognition.onend = function () {
|
|
149
|
+
if (store.get('speechActive') === active) stopSTT('Voice input finished. Review your draft before sending.');
|
|
150
|
+
};
|
|
151
|
+
store.set({ speechActive: active, speechRetry: null });
|
|
152
|
+
setSpeechStatus(id, 'Starting microphone… Your browser may ask for permission.');
|
|
153
|
+
active.timeout = setTimeout(function () {
|
|
154
|
+
if (store.get('speechActive') === active) stopSTT('Microphone did not start. Check browser permissions and try again.');
|
|
155
|
+
}, 15000);
|
|
156
|
+
try { startBrowserSpeechSignal(active); recognition.start(); }
|
|
157
|
+
catch (error) { if (store.get('speechActive') === active) stopSTT('Voice input could not start. Check microphone permissions and try again.'); }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function initSpeechLifecycle() {
|
|
161
|
+
if (store.get('speechLifecycleBound')) return;
|
|
162
|
+
store.set({ speechLifecycleBound: true });
|
|
163
|
+
store.subscribe(function () {
|
|
164
|
+
var active = store.get('speechActive');
|
|
165
|
+
if (active) currentSpeech(active);
|
|
166
|
+
var retry = store.get('speechRetry');
|
|
167
|
+
if (retry && (retry.context !== speechContext() || !usableSpeechInput(retry.input) || retry.input.value !== retry.value)) store.set({ speechRetry: null });
|
|
168
|
+
});
|
|
169
|
+
document.addEventListener('visibilitychange', function () { if (document.hidden) stopSTT(undefined, 'document visibility changed to hidden'); });
|
|
170
|
+
document.addEventListener('focusin', function (event) {
|
|
171
|
+
var target = event.target;
|
|
172
|
+
var active = store.get('speechActive');
|
|
173
|
+
if (!target || !target.matches || !target.matches('textarea, input, [contenteditable="true"]') ||
|
|
174
|
+
(active && active.input === target)) return;
|
|
175
|
+
try { window.top.document.dispatchEvent(new window.top.Event('clay:voice-claim')); }
|
|
176
|
+
catch (error) { stopSTT(); }
|
|
177
|
+
});
|
|
178
|
+
var stopForClaim = function () { stopSTT(undefined, 'another composer or input claimed voice ownership'); };
|
|
179
|
+
function attachClaim() {
|
|
180
|
+
try { window.top.document.addEventListener('clay:voice-claim', stopForClaim); } catch (error) { /* Standalone cross-origin host. */ }
|
|
181
|
+
}
|
|
182
|
+
attachClaim();
|
|
183
|
+
window.addEventListener('pageshow', attachClaim);
|
|
184
|
+
window.addEventListener('pagehide', function () {
|
|
185
|
+
stopSTT();
|
|
186
|
+
observer.disconnect();
|
|
187
|
+
try { window.top.document.removeEventListener('clay:voice-claim', stopForClaim); } catch (error) { /* Host unavailable. */ }
|
|
188
|
+
});
|
|
189
|
+
var observer = new MutationObserver(function () {
|
|
190
|
+
var active = store.get('speechActive');
|
|
191
|
+
if (active) currentSpeech(active);
|
|
192
|
+
});
|
|
193
|
+
function observeVisibility() {
|
|
194
|
+
var options = { subtree: true, childList: true, attributes: true,
|
|
195
|
+
attributeFilter: ['class', 'style', 'hidden', 'disabled', 'readonly', 'aria-hidden'] };
|
|
196
|
+
observer.observe(document.body, options);
|
|
197
|
+
try { if (window.top !== window) observer.observe(window.top.document.body, options); }
|
|
198
|
+
catch (error) { /* Cross-origin host. */ }
|
|
199
|
+
}
|
|
200
|
+
observeVisibility();
|
|
201
|
+
window.addEventListener('pageshow', observeVisibility);
|
|
202
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Log lifecycle metadata only: never pass keys, audio, transcripts or draft text.
|
|
2
|
+
export function speechTrace(active, event, details) {
|
|
3
|
+
var record = Object.assign({ version: 'voice-debug-v1', attempt: active && active.traceId || 'none', event: event,
|
|
4
|
+
elapsedMs: active && active.traceStarted ? Date.now() - active.traceStarted : 0,
|
|
5
|
+
phase: active && active.phase || 'idle', model: active && active.model && active.model.id || 'browser' }, details || {});
|
|
6
|
+
console.log('[Clay voice] ' + JSON.stringify(record));
|
|
7
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { store } from './store.js';
|
|
2
|
+
|
|
3
|
+
export function createSpeechMeter(id) {
|
|
4
|
+
var element = document.createElement('span'); element.className = 'stt-meter';
|
|
5
|
+
element.setAttribute('aria-hidden', 'true');
|
|
6
|
+
var canvas = document.createElement('canvas'); element.appendChild(canvas);
|
|
7
|
+
var context = canvas.getContext('2d'); var frame = null; var paintedAt = 0; var heights = [];
|
|
8
|
+
var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
9
|
+
function paint(time) {
|
|
10
|
+
frame = null;
|
|
11
|
+
var active = store.get('speechActive');
|
|
12
|
+
if (!active || active.id !== id || !element.isConnected || !context) return;
|
|
13
|
+
frame = requestAnimationFrame(paint);
|
|
14
|
+
if (time - paintedAt < (reduced ? 100 : 32)) return;
|
|
15
|
+
paintedAt = time;
|
|
16
|
+
var width = element.clientWidth; var height = element.clientHeight;
|
|
17
|
+
var ratio = Math.min(window.devicePixelRatio || 1, 2);
|
|
18
|
+
if (canvas.width !== Math.round(width * ratio) || canvas.height !== Math.round(height * ratio)) {
|
|
19
|
+
canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio);
|
|
20
|
+
}
|
|
21
|
+
context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, width, height);
|
|
22
|
+
context.fillStyle = getComputedStyle(element).color;
|
|
23
|
+
var signal = active.audioSignal; var levels = signal ? signal.levels : [];
|
|
24
|
+
var age = signal ? Date.now() - signal.updatedAt : 0;
|
|
25
|
+
var fade = Math.max(0, 1 - Math.max(0, age - 250) / 900);
|
|
26
|
+
var columns = Math.max(2, Math.floor(width / 4));
|
|
27
|
+
var span = Math.round(Math.min(160, Math.max(80, columns)) * 1.25);
|
|
28
|
+
var drift = reduced ? 0 : Math.min(1, age / 20);
|
|
29
|
+
for (var x = 0; x < columns; x++) {
|
|
30
|
+
var position = levels.length - span + x / (columns - 1) * (span - 1) + drift;
|
|
31
|
+
var index = Math.floor(position); var fraction = position - index;
|
|
32
|
+
var a = levels[index] || 0; var b = levels[Math.min(index + 1, levels.length - 1)] || 0;
|
|
33
|
+
var target = (a + (b - a) * fraction) * fade;
|
|
34
|
+
var previous = heights[x] || 0;
|
|
35
|
+
var level = reduced ? target : previous + (target - previous) * (target > previous ? 0.5 : 0.24);
|
|
36
|
+
heights[x] = level;
|
|
37
|
+
var extent = 1 + level * (height * 0.46 - 1);
|
|
38
|
+
var edge = Math.min(1, (x + 1) / 8, (columns - x) / 8);
|
|
39
|
+
for (var y = 0; y <= height * 0.48; y += 3.2) {
|
|
40
|
+
var density = Math.exp(-Math.pow(y / extent, 2) * 1.7);
|
|
41
|
+
if (density < 0.035) continue;
|
|
42
|
+
var radius = 0.35 + density * 0.65;
|
|
43
|
+
context.globalAlpha = (0.12 + density * 0.78) * edge;
|
|
44
|
+
context.beginPath(); context.arc((x + 0.5) * width / columns, height / 2 + y, radius, 0, Math.PI * 2); context.fill();
|
|
45
|
+
if (y) { context.beginPath(); context.arc((x + 0.5) * width / columns, height / 2 - y, radius, 0, Math.PI * 2); context.fill(); }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
context.globalAlpha = 1;
|
|
49
|
+
}
|
|
50
|
+
return { element: element, update: function (active) {
|
|
51
|
+
element.hidden = !active;
|
|
52
|
+
if (active && frame === null) frame = requestAnimationFrame(paint);
|
|
53
|
+
if (!active && frame !== null) { cancelAnimationFrame(frame); frame = null; heights = []; }
|
|
54
|
+
} };
|
|
55
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { store } from './store.js';
|
|
2
|
+
import { escapeHtml } from './utils.js';
|
|
3
|
+
import { iconHtml, refreshIcons } from './icons.js';
|
|
4
|
+
import { speechLanguages, voiceModel, loadSpeechSettings, saveSpeechSettings, saveSpeechLanguage } from './stt-settings.js';
|
|
5
|
+
import { stopSTT } from './stt-controller.js';
|
|
6
|
+
|
|
7
|
+
export function closeSpeechPicker() {
|
|
8
|
+
var state = store.get('speechPopup');
|
|
9
|
+
store.set({ speechPopup: null });
|
|
10
|
+
if (state && state.anchor.isConnected) { state.anchor.setAttribute('aria-expanded', 'false'); state.anchor.focus(); }
|
|
11
|
+
}
|
|
12
|
+
function patch(values) { store.set({ speechPopup: Object.assign({}, store.get('speechPopup'), values) }); }
|
|
13
|
+
async function save(change, close) {
|
|
14
|
+
patch({ busy: true, error: '' });
|
|
15
|
+
try { await saveSpeechSettings(change); if (close) closeSpeechPicker(); else if (store.get('speechPopup')) patch({ provider: null, busy: false }); }
|
|
16
|
+
catch (error) { if (store.get('speechPopup')) patch({ busy: false, error: error.message }); }
|
|
17
|
+
}
|
|
18
|
+
function render() {
|
|
19
|
+
var previous = document.getElementById('speech-picker');
|
|
20
|
+
var focusKey = previous && previous.contains(document.activeElement) && document.activeElement.dataset.focus;
|
|
21
|
+
var keyInput = previous && previous.querySelector('#speech-api-key');
|
|
22
|
+
var unsavedKey = keyInput ? keyInput.value : '';
|
|
23
|
+
var previousProvider = previous && previous.dataset.provider;
|
|
24
|
+
if (previous) previous.remove();
|
|
25
|
+
var state = store.get('speechPopup'); if (!state) return;
|
|
26
|
+
var settings = store.get('speechSettings');
|
|
27
|
+
var popup = document.createElement('div'); popup.id = 'speech-picker'; popup.className = 'stt-picker';
|
|
28
|
+
popup.dataset.provider = state.provider || '';
|
|
29
|
+
popup.setAttribute('role', 'dialog'); popup.setAttribute('aria-modal', 'true'); popup.setAttribute('aria-label', 'Voice input');
|
|
30
|
+
var html = '<div class="stt-picker-heading"><strong>Voice input</strong><button type="button" data-action="close" data-focus="close" aria-label="Close voice input settings">' + iconHtml('x') + '</button></div>';
|
|
31
|
+
html += '<div class="stt-mode-tabs" role="group" aria-label="Transcription mode"><button type="button" data-mode="live" aria-pressed="' + (state.mode === 'live') + '"><strong>Live</strong><span>Text appears as you speak.</span></button><button type="button" data-mode="recorded" aria-pressed="' + (state.mode === 'recorded') + '"><strong>After recording</strong><span>Stop recording to get text.</span></button></div>';
|
|
32
|
+
if (settings) {
|
|
33
|
+
html += '<div class="stt-model-list">';
|
|
34
|
+
settings.models.filter(function (model) { return model.mode === state.mode; }).sort(function (a, b) {
|
|
35
|
+
return Number(b.provider === 'openai') - Number(a.provider === 'openai');
|
|
36
|
+
}).forEach(function (model) {
|
|
37
|
+
var provider = settings.providers.find(function (item) { return item.id === model.provider; });
|
|
38
|
+
var connected = !provider || settings.configured.indexOf(provider.id) >= 0;
|
|
39
|
+
var mark = provider ? '<img src="' + provider.icon + '" alt="">' : '<span class="stt-browser-icon">' + iconHtml('mic') + '</span>';
|
|
40
|
+
var label = provider ? provider.name + ' · ' + model.name : model.name;
|
|
41
|
+
html += '<div class="stt-model-row"><button type="button" class="stt-model" data-model="' + model.id + '" data-focus="' + model.id + '" title="' + escapeHtml(label + ' — ' + model.description) + '" aria-label="' + escapeHtml(label) + '" aria-pressed="' + (model.id === settings.selected) + '">' + mark + '<span>' + escapeHtml(label) + '</span>' + (model.id === settings.selected ? '<span class="stt-model-check">' + iconHtml('check') + '</span>' : '') + '</button>';
|
|
42
|
+
if (provider) html += '<button type="button" class="stt-key-toggle" data-provider="' + provider.id + '" data-focus="key-' + model.id + '" aria-label="' + (connected ? 'Manage ' : 'Connect ') + escapeHtml(provider.name) + ' API key" title="' + escapeHtml(provider.name) + ' API key">' + (connected ? iconHtml('key-round') : 'Connect') + '</button>';
|
|
43
|
+
html += '</div>';
|
|
44
|
+
if (provider && state.provider === provider.id && state.keyModel === model.id) {
|
|
45
|
+
html += '<form class="stt-key-form"><label for="speech-api-key">' + escapeHtml(provider.name) + ' API key</label><input id="speech-api-key" name="key" type="password" autocomplete="off" spellcheck="false" placeholder="Enter API key" required maxlength="4096"><p>Stored privately on your Clay server. Audio is sent to ' + escapeHtml(provider.name) + '; usage is billed to your account.</p><div class="stt-key-actions"><a href="' + escapeHtml(provider.url) + '" target="_blank" rel="noopener noreferrer">Get key ↗</a><button type="button" data-action="remove"' + (!connected ? ' disabled' : '') + '>Remove</button><button type="submit">Save</button></div></form>';
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
html += '</div><div class="stt-picker-footer"><label>Language<select aria-label="Voice input language" data-focus="language">';
|
|
49
|
+
speechLanguages.forEach(function (lang) { html += '<option value="' + lang[0] + '"' + (lang[0] === store.get('speechLang') ? ' selected' : '') + '>' + lang[1] + '</option>'; });
|
|
50
|
+
html += '</select></label></div>';
|
|
51
|
+
} else html += '<p>Loading voice models…</p>';
|
|
52
|
+
if (state.error || store.get('speechSettingsError')) html += '<p role="alert" class="stt-picker-error">' + escapeHtml(state.error || store.get('speechSettingsError')) + '</p><button type="button" data-action="reload">Reload settings</button>';
|
|
53
|
+
popup.innerHTML = html;
|
|
54
|
+
document.body.appendChild(popup); refreshIcons();
|
|
55
|
+
if (state.busy) popup.querySelectorAll('button,input,select').forEach(function (el) { if (el.dataset.action !== 'close') el.disabled = true; });
|
|
56
|
+
var bounds = state.anchor.getBoundingClientRect();
|
|
57
|
+
popup.style.left = Math.max(8, Math.min(bounds.right - 324, window.innerWidth - 332)) + 'px';
|
|
58
|
+
popup.style.bottom = Math.max(8, window.innerHeight - bounds.top + 8) + 'px';
|
|
59
|
+
popup.style.maxHeight = Math.max(150, bounds.top - 16) + 'px';
|
|
60
|
+
popup.addEventListener('click', function (event) {
|
|
61
|
+
var button = event.target.closest('button'); if (!button || button.disabled) return;
|
|
62
|
+
if (button.dataset.mode) patch({ mode: button.dataset.mode, provider: null, keyModel: null });
|
|
63
|
+
else if (button.dataset.provider) {
|
|
64
|
+
var keyModel = button.parentElement.querySelector('[data-model]').dataset.model;
|
|
65
|
+
patch({ provider: state.provider === button.dataset.provider && state.keyModel === keyModel ? null : button.dataset.provider, keyModel: keyModel });
|
|
66
|
+
}
|
|
67
|
+
else if (button.dataset.model) {
|
|
68
|
+
var model = settings.models.find(function (item) { return item.id === button.dataset.model; });
|
|
69
|
+
if (model.provider !== 'browser' && settings.configured.indexOf(model.provider) < 0) patch({ provider: model.provider, keyModel: model.id });
|
|
70
|
+
else save({ selected: model.id }, true);
|
|
71
|
+
} else {
|
|
72
|
+
var action = button.dataset.action;
|
|
73
|
+
if (action === 'close') closeSpeechPicker();
|
|
74
|
+
if (action === 'remove') save({ provider: state.provider, key: null }, false);
|
|
75
|
+
if (action === 'reload') loadSpeechSettings();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
var form = popup.querySelector('form');
|
|
79
|
+
if (form && previousProvider === state.provider) form.elements.key.value = unsavedKey;
|
|
80
|
+
if (form) form.addEventListener('submit', function (event) {
|
|
81
|
+
event.preventDefault(); var key = form.elements.key.value; form.elements.key.value = '';
|
|
82
|
+
save({ provider: state.provider, key: key }, false);
|
|
83
|
+
});
|
|
84
|
+
var select = popup.querySelector('select');
|
|
85
|
+
if (select) select.addEventListener('change', async function () {
|
|
86
|
+
var code = select.value; patch({ busy: true });
|
|
87
|
+
try { await saveSpeechLanguage(code); if (store.get('speechPopup')) patch({ busy: false, error: '' }); }
|
|
88
|
+
catch (error) { if (store.get('speechPopup')) patch({ busy: false, error: error.message }); }
|
|
89
|
+
});
|
|
90
|
+
var focus = Array.from(popup.querySelectorAll('[data-focus]')).find(function (el) { return el.dataset.focus === focusKey && !el.disabled; });
|
|
91
|
+
(form && previousProvider !== state.provider ? form.elements.key : (focus || popup.querySelector('input,button'))).focus();
|
|
92
|
+
}
|
|
93
|
+
export function openSpeechPicker(anchor) {
|
|
94
|
+
if (store.get('speechPopup')) { closeSpeechPicker(); return; }
|
|
95
|
+
stopSTT();
|
|
96
|
+
anchor.setAttribute('aria-expanded', 'true');
|
|
97
|
+
store.set({ speechPopup: { anchor: anchor, mode: voiceModel().mode, error: '', busy: false } });
|
|
98
|
+
loadSpeechSettings();
|
|
99
|
+
}
|
|
100
|
+
export function initSpeechPicker() {
|
|
101
|
+
store.subscribe(function (state, prev) {
|
|
102
|
+
if (state.speechPopup !== prev.speechPopup || state.speechSettings !== prev.speechSettings || state.speechSettingsError !== prev.speechSettingsError || state.speechLang !== prev.speechLang) render();
|
|
103
|
+
if (state.currentSlug !== prev.currentSlug || state.activeSessionId !== prev.activeSessionId || state.homeChatSessionId !== prev.homeChatSessionId) {
|
|
104
|
+
if (state.speechPopup) closeSpeechPicker();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
document.addEventListener('pointerdown', function (event) {
|
|
108
|
+
var state = store.get('speechPopup'); var popup = document.getElementById('speech-picker');
|
|
109
|
+
if (state && popup && !popup.contains(event.target) && !state.anchor.contains(event.target)) closeSpeechPicker();
|
|
110
|
+
});
|
|
111
|
+
document.addEventListener('keydown', function (event) {
|
|
112
|
+
if (!store.get('speechPopup')) return;
|
|
113
|
+
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeSpeechPicker(); }
|
|
114
|
+
if (event.key === 'Tab') {
|
|
115
|
+
var popup = document.getElementById('speech-picker');
|
|
116
|
+
var items = popup.querySelectorAll('button:not(:disabled),input:not(:disabled),select:not(:disabled),a[href]');
|
|
117
|
+
var first = items[0]; var last = items[items.length - 1];
|
|
118
|
+
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
|
119
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
|
120
|
+
}
|
|
121
|
+
}, true);
|
|
122
|
+
window.addEventListener('resize', function () { if (store.get('speechPopup')) closeSpeechPicker(); });
|
|
123
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { sampleSpeechSignal } from './stt-signal.js';
|
|
2
|
+
import { store } from './store.js';
|
|
3
|
+
import { speechTrace } from './stt-debug.js';
|
|
4
|
+
import { captureSpeech, wavRecording } from './stt-audio.js';
|
|
5
|
+
import { claimSpeech, currentSpeech, writeSpeech, usableSpeechInput, setSpeechStatus, stopSTT, failSTT, speechContext } from './stt-controller.js';
|
|
6
|
+
|
|
7
|
+
async function upload(active, blob) {
|
|
8
|
+
if (!currentSpeech(active)) return;
|
|
9
|
+
active.phase = 'transcribing'; store.set({ speechActive: active });
|
|
10
|
+
setSpeechStatus(active.id, 'Transcribing…');
|
|
11
|
+
speechTrace(active, 'upload-start', { bytes: blob.size });
|
|
12
|
+
try {
|
|
13
|
+
var response = await fetch('/api/speech/transcribe', { method: 'POST', signal: active.abortController.signal,
|
|
14
|
+
headers: { 'Content-Type': 'audio/wav', 'X-Speech-Model': active.model.id, 'X-Speech-Language': active.language }, body: blob });
|
|
15
|
+
var result = await response.json();
|
|
16
|
+
speechTrace(active, 'upload-response', { status: response.status, transcriptCharacters: typeof result.text === 'string' ? result.text.length : 0 });
|
|
17
|
+
if (!response.ok) throw new Error(result.error || 'Transcription failed. Please try again.');
|
|
18
|
+
if (!currentSpeech(active)) return;
|
|
19
|
+
writeSpeech(active, result.text || '');
|
|
20
|
+
stopSTT(result.text ? 'Review your draft before sending.' : 'No speech detected. Try recording again.');
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (!currentSpeech(active)) return;
|
|
23
|
+
var retry = { id: active.id, input: active.input, context: active.context, value: active.lastValue,
|
|
24
|
+
model: active.model, language: active.language, blob: blob };
|
|
25
|
+
failSTT(error.name === 'AbortError' ? 'Transcription timed out. Please try again.' : error.message);
|
|
26
|
+
store.set({ speechRetry: retry });
|
|
27
|
+
setTimeout(function () { if (store.get('speechRetry') === retry) store.set({ speechRetry: null }); }, 300000);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function initialize(id, input, model, language) {
|
|
31
|
+
var controller = new AbortController();
|
|
32
|
+
var active = claimSpeech(id, input, { abort: function () {
|
|
33
|
+
controller.abort(); clearInterval(active.clock);
|
|
34
|
+
if (active.capture) active.capture.abort();
|
|
35
|
+
if (active.socket) active.socket.close();
|
|
36
|
+
active.chunks = [];
|
|
37
|
+
} });
|
|
38
|
+
active.model = model; active.language = language; active.abortController = controller;
|
|
39
|
+
active.phase = 'starting'; active.chunks = []; active.bytes = 0;
|
|
40
|
+
speechTrace(active, 'start', { language: language, mode: model.mode, secureContext: window.isSecureContext, documentHidden: document.hidden });
|
|
41
|
+
active.timeout = setTimeout(function () { if (currentSpeech(active)) failSTT('Microphone or provider did not start. Please try again.'); }, 20000);
|
|
42
|
+
return active;
|
|
43
|
+
}
|
|
44
|
+
function liveSocket(active) {
|
|
45
|
+
return new Promise(function (resolve, reject) {
|
|
46
|
+
speechTrace(active, 'websocket-connect');
|
|
47
|
+
var socket = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/api/speech/live');
|
|
48
|
+
active.socket = socket;
|
|
49
|
+
socket.onopen = function () { speechTrace(active, 'websocket-open'); socket.send(JSON.stringify({ type: 'start', model: active.model.id, language: active.language })); };
|
|
50
|
+
socket.onmessage = function (event) {
|
|
51
|
+
if (!currentSpeech(active)) return;
|
|
52
|
+
try {
|
|
53
|
+
var data = JSON.parse(event.data);
|
|
54
|
+
if (data.type === 'diagnostic') { speechTrace(active, 'server-stage', { stage: data.stage, status: data.status, audioChunks: data.audioChunks, bytes: data.bytes }); return; }
|
|
55
|
+
if (data.type !== 'text' || !active.receivedText) speechTrace(active, 'websocket-message', { type: data.type, transcriptCharacters: typeof data.text === 'string' ? data.text.length : 0 });
|
|
56
|
+
if (data.type === 'text') active.receivedText = true;
|
|
57
|
+
if (data.type === 'ready') resolve();
|
|
58
|
+
else if (data.type === 'text') writeSpeech(active, data.text);
|
|
59
|
+
else if (data.type === 'done') { writeSpeech(active, data.text); stopSTT('Review your draft before sending.'); }
|
|
60
|
+
else if (data.type === 'error') { reject(new Error(data.error)); failSTT(data.error); }
|
|
61
|
+
} catch (error) { reject(error); failSTT('Invalid transcription response. Your draft has been preserved.'); }
|
|
62
|
+
};
|
|
63
|
+
socket.onerror = function () { speechTrace(active, 'websocket-error', { readyState: socket.readyState }); reject(new Error('Could not connect to voice input.')); if (currentSpeech(active)) failSTT('Could not connect to voice input.'); };
|
|
64
|
+
socket.onclose = function (event) { speechTrace(active, 'websocket-close', { code: event && event.code, clean: event && event.wasClean, stillActive: store.get('speechActive') === active }); reject(new Error('Voice connection closed.')); if (currentSpeech(active)) failSTT('Voice connection closed. Your draft has been preserved.'); };
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export async function startRecording(id, input, model) {
|
|
68
|
+
if (!usableSpeechInput(input)) { speechTrace(null, 'start-rejected', { reason: 'input not usable', selectedModel: model.id }); return; }
|
|
69
|
+
if (!window.isSecureContext) { setSpeechStatus(id, 'Voice input requires a secure HTTPS connection.'); return; }
|
|
70
|
+
var active = initialize(id, input, model, store.get('speechLang') || 'en-US');
|
|
71
|
+
setSpeechStatus(id, 'Starting microphone…');
|
|
72
|
+
try {
|
|
73
|
+
// Ask for the microphone from the user gesture before waiting on the network.
|
|
74
|
+
active.capture = await captureSpeech(function (pcm) {
|
|
75
|
+
sampleSpeechSignal(active, new Int16Array(pcm), 16000, 32768);
|
|
76
|
+
active.capturedChunks = (active.capturedChunks || 0) + 1;
|
|
77
|
+
if (active.capturedChunks === 1 || active.capturedChunks % 50 === 0) speechTrace(active, 'audio-chunks', { capturedChunks: active.capturedChunks, sentBytes: active.bytes, chunkBytes: pcm.byteLength });
|
|
78
|
+
if (!currentSpeech(active) || (active.phase !== 'recording' && active.phase !== 'finishing')) return;
|
|
79
|
+
active.bytes += pcm.byteLength;
|
|
80
|
+
if (active.bytes > 9600000) { finishRecording(); return; }
|
|
81
|
+
if (model.mode === 'live') {
|
|
82
|
+
if (active.socket.readyState !== 1 || active.socket.bufferedAmount > 1048576) { failSTT('The connection is too slow. Your draft has been preserved.'); return; }
|
|
83
|
+
active.socket.send(pcm);
|
|
84
|
+
} else active.chunks.push(pcm);
|
|
85
|
+
}, function (event, details) { speechTrace(active, event, details); });
|
|
86
|
+
if (!currentSpeech(active)) { active.capture.abort(); return; }
|
|
87
|
+
if (model.mode === 'live') await liveSocket(active);
|
|
88
|
+
if (!currentSpeech(active)) return;
|
|
89
|
+
clearTimeout(active.timeout); active.started = true; active.phase = 'recording'; active.startTime = Date.now();
|
|
90
|
+
speechTrace(active, 'recording-ready');
|
|
91
|
+
active.clock = setInterval(function () {
|
|
92
|
+
if (currentSpeech(active)) {
|
|
93
|
+
store.set({ speechActive: active });
|
|
94
|
+
if (Date.now() - active.startTime >= 300000) finishRecording();
|
|
95
|
+
}
|
|
96
|
+
}, 1000);
|
|
97
|
+
store.set({ speechActive: active });
|
|
98
|
+
setSpeechStatus(id, model.mode === 'live' ? 'Listening…' : 'Recording…');
|
|
99
|
+
} catch (error) {
|
|
100
|
+
speechTrace(active, 'startup-caught-error', { errorName: error.name, stillActive: store.get('speechActive') === active });
|
|
101
|
+
if (currentSpeech(active)) failSTT(error.name === 'NotAllowedError' ? 'Microphone access was denied. Allow it in your browser settings to try again.' : 'Voice input could not start. Check your microphone, API key and connection.');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export async function finishRecording() {
|
|
105
|
+
var active = store.get('speechActive');
|
|
106
|
+
if (!active || !active.model) { stopSTT(); return; }
|
|
107
|
+
if (active.phase !== 'recording') return;
|
|
108
|
+
speechTrace(active, 'finish-requested', { bytes: active.bytes });
|
|
109
|
+
active.phase = 'finishing'; clearInterval(active.clock); store.set({ speechActive: active });
|
|
110
|
+
await active.capture.finish();
|
|
111
|
+
if (!currentSpeech(active)) return;
|
|
112
|
+
active.phase = 'transcribing'; store.set({ speechActive: active });
|
|
113
|
+
if (active.model.mode === 'live') {
|
|
114
|
+
setSpeechStatus(active.id, 'Finishing transcription…');
|
|
115
|
+
active.socket.send(JSON.stringify({ type: 'finish' }));
|
|
116
|
+
active.timeout = setTimeout(function () { if (currentSpeech(active)) failSTT('Transcription timed out. Your draft has been preserved.'); }, 25000);
|
|
117
|
+
} else {
|
|
118
|
+
var blob = wavRecording(active.chunks); active.chunks = [];
|
|
119
|
+
active.timeout = setTimeout(function () { active.abortController.abort(); }, 125000);
|
|
120
|
+
await upload(active, blob);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export function retryRecording() {
|
|
124
|
+
var retry = store.get('speechRetry');
|
|
125
|
+
if (!retry || retry.context !== speechContext() || retry.input.value !== retry.value || !usableSpeechInput(retry.input)) { store.set({ speechRetry: null }); return; }
|
|
126
|
+
var active = initialize(retry.id, retry.input, retry.model, retry.language);
|
|
127
|
+
clearTimeout(active.timeout);
|
|
128
|
+
active.timeout = setTimeout(function () { active.abortController.abort(); }, 125000);
|
|
129
|
+
upload(active, retry.blob);
|
|
130
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { store } from './store.js';
|
|
2
|
+
import { stopSTT } from './stt-controller.js';
|
|
3
|
+
export var speechLanguages = [
|
|
4
|
+
['en-US', 'English'], ['es-ES', 'Spanish'], ['fr-FR', 'French'], ['de-DE', 'German'],
|
|
5
|
+
['pt-BR', 'Portuguese'], ['it-IT', 'Italian'], ['nl-NL', 'Dutch'], ['pl-PL', 'Polish'],
|
|
6
|
+
['ru-RU', 'Russian'], ['uk-UA', 'Ukrainian'], ['tr-TR', 'Turkish'], ['ar-SA', 'Arabic'],
|
|
7
|
+
['hi-IN', 'Hindi'], ['id-ID', 'Indonesian'], ['vi-VN', 'Vietnamese'], ['th-TH', 'Thai'],
|
|
8
|
+
['ja-JP', 'Japanese'], ['ko-KR', 'Korean'], ['zh-CN', 'Chinese']
|
|
9
|
+
];
|
|
10
|
+
export function voiceModel() {
|
|
11
|
+
var settings = store.get('speechSettings');
|
|
12
|
+
return settings && settings.models.find(function (model) { return model.id === settings.selected; }) ||
|
|
13
|
+
{ id: 'browser', provider: 'browser', name: 'Browser speech', mode: 'live' };
|
|
14
|
+
}
|
|
15
|
+
export function voiceProvider(id) {
|
|
16
|
+
var settings = store.get('speechSettings');
|
|
17
|
+
return settings && settings.providers.find(function (provider) { return provider.id === id; });
|
|
18
|
+
}
|
|
19
|
+
export async function loadSpeechSettings() {
|
|
20
|
+
if (store.get('speechSettingsSaving')) return;
|
|
21
|
+
var request = (store.get('speechSettingsRequest') || 0) + 1;
|
|
22
|
+
store.set({ speechSettingsRequest: request });
|
|
23
|
+
try {
|
|
24
|
+
var response = await fetch('/api/speech/settings');
|
|
25
|
+
if (!response.ok) throw new Error('Voice settings unavailable. Please try again.');
|
|
26
|
+
var settings = await response.json();
|
|
27
|
+
if (store.get('speechSettingsRequest') !== request) return;
|
|
28
|
+
var previous = store.get('speechSettings');
|
|
29
|
+
if ((previous && JSON.stringify(previous) !== JSON.stringify(settings)) ||
|
|
30
|
+
(!previous && settings.selected !== 'browser')) stopSTT();
|
|
31
|
+
if (!previous || JSON.stringify(previous) !== JSON.stringify(settings)) store.set({ speechSettings: settings, speechLang: settings.language || 'en-US', speechSettingsError: '' });
|
|
32
|
+
else if (store.get('speechSettingsError')) store.set({ speechSettingsError: '' });
|
|
33
|
+
} catch (error) { if (store.get('speechSettingsRequest') === request) store.set({ speechSettingsError: 'Voice settings unavailable. Please try again.' }); }
|
|
34
|
+
}
|
|
35
|
+
export async function saveSpeechSettings(change) {
|
|
36
|
+
if (store.get('speechSettingsSaving')) throw new Error('Voice settings are being saved. Please wait.');
|
|
37
|
+
stopSTT();
|
|
38
|
+
store.set({ speechRetry: null, speechSettingsSaving: true, speechSettingsRequest: (store.get('speechSettingsRequest') || 0) + 1 });
|
|
39
|
+
try {
|
|
40
|
+
var response = await fetch('/api/speech/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(change) });
|
|
41
|
+
var result = await response.json();
|
|
42
|
+
if (!response.ok) throw new Error(result.error || 'Could not save voice settings.');
|
|
43
|
+
store.set({ speechSettings: result, speechLang: result.language || 'en-US', speechSettingsError: '' });
|
|
44
|
+
} finally { store.set({ speechSettingsSaving: false }); }
|
|
45
|
+
try { window.top.document.dispatchEvent(new window.top.Event('clay:speech-settings-saved')); } catch (error) { /* Standalone host. */ }
|
|
46
|
+
}
|
|
47
|
+
export async function saveSpeechLanguage(code) {
|
|
48
|
+
await saveSpeechSettings({ language: code });
|
|
49
|
+
window.dispatchEvent(new CustomEvent('clay:speech-language-saved', { detail: code }));
|
|
50
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { store } from './store.js';
|
|
2
|
+
|
|
3
|
+
// Keep only a short perceptual envelope, never microphone samples.
|
|
4
|
+
export function sampleSpeechSignal(active, samples, sampleRate, divisor) {
|
|
5
|
+
if (store.get('speechActive') !== active) return;
|
|
6
|
+
var signal = active.audioSignal || (active.audioSignal = { levels: [], envelope: 0, updatedAt: 0 });
|
|
7
|
+
var step = Math.max(1, Math.round(sampleRate / 50));
|
|
8
|
+
for (var offset = 0; offset < samples.length; offset += step) {
|
|
9
|
+
var end = Math.min(samples.length, offset + step); var energy = 0; var peak = 0;
|
|
10
|
+
for (var i = offset; i < end; i++) {
|
|
11
|
+
var value = Math.abs(samples[i] / divisor); energy += value * value; peak = Math.max(peak, value);
|
|
12
|
+
}
|
|
13
|
+
var amplitude = Math.sqrt(energy / (end - offset)) * 0.8 + peak * 0.2;
|
|
14
|
+
var db = 20 * Math.log10(Math.max(0.000001, amplitude));
|
|
15
|
+
var target = Math.max(0, Math.min(1, (db + 58) / 46));
|
|
16
|
+
signal.envelope += (target - signal.envelope) * (target > signal.envelope ? 0.72 : 0.22);
|
|
17
|
+
signal.levels.push(signal.envelope);
|
|
18
|
+
}
|
|
19
|
+
if (signal.levels.length > 240) signal.levels.splice(0, signal.levels.length - 240);
|
|
20
|
+
signal.updatedAt = Date.now();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Native recognition exposes no PCM; this local analyser supplies its visual feedback.
|
|
24
|
+
export function startBrowserSpeechSignal(active) {
|
|
25
|
+
var Constructor = window.AudioContext || window.webkitAudioContext;
|
|
26
|
+
if (!Constructor || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return;
|
|
27
|
+
var context; var source; var stream; var timer; var stopped = false; var closed = false;
|
|
28
|
+
function closeContext() {
|
|
29
|
+
if (context && !closed) { closed = true; context.close().catch(function () {}); }
|
|
30
|
+
}
|
|
31
|
+
function abort() {
|
|
32
|
+
stopped = true; clearInterval(timer);
|
|
33
|
+
if (source) { source.disconnect(); source = null; }
|
|
34
|
+
if (stream) { stream.getTracks().forEach(function (track) { track.stop(); }); stream = null; }
|
|
35
|
+
closeContext();
|
|
36
|
+
}
|
|
37
|
+
active.visualCapture = { abort: abort };
|
|
38
|
+
try {
|
|
39
|
+
context = new Constructor();
|
|
40
|
+
var permission = navigator.mediaDevices.getUserMedia({ audio: true });
|
|
41
|
+
permission.then(function (value) {
|
|
42
|
+
stream = value;
|
|
43
|
+
if (stopped || store.get('speechActive') !== active) abort();
|
|
44
|
+
}, function () {});
|
|
45
|
+
Promise.all([context.resume(), permission]).then(function () {
|
|
46
|
+
if (stopped || store.get('speechActive') !== active) { abort(); return; }
|
|
47
|
+
var analyser = context.createAnalyser(); analyser.fftSize = 2048;
|
|
48
|
+
source = context.createMediaStreamSource(stream); source.connect(analyser);
|
|
49
|
+
var samples = new Float32Array(analyser.fftSize);
|
|
50
|
+
timer = setInterval(function () {
|
|
51
|
+
if (store.get('speechActive') !== active) { abort(); return; }
|
|
52
|
+
analyser.getFloatTimeDomainData(samples);
|
|
53
|
+
sampleSpeechSignal(active, samples, samples.length * 50, 1);
|
|
54
|
+
}, 20);
|
|
55
|
+
}).catch(abort);
|
|
56
|
+
} catch (error) { abort(); }
|
|
57
|
+
}
|