@djangocfg/ui-tools 2.1.381 → 2.1.382
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -899
- package/dist/ChatRoot-6IZFM5HM.mjs +5 -0
- package/dist/{ChatRoot-EJC5Y2YM.cjs.map → ChatRoot-6IZFM5HM.mjs.map} +1 -1
- package/dist/ChatRoot-LW4XNIKP.cjs +14 -0
- package/dist/{ChatRoot-QOSKJPM6.mjs.map → ChatRoot-LW4XNIKP.cjs.map} +1 -1
- package/dist/DictationField-2ZLQWLYV.mjs +4 -0
- package/dist/DictationField-2ZLQWLYV.mjs.map +1 -0
- package/dist/DictationField-IPPJ54CU.cjs +13 -0
- package/dist/DictationField-IPPJ54CU.cjs.map +1 -0
- package/dist/{DocsLayout-2YKPXZYO.mjs → DocsLayout-2P3ONDWJ.mjs} +3 -3
- package/dist/{DocsLayout-2YKPXZYO.mjs.map → DocsLayout-2P3ONDWJ.mjs.map} +1 -1
- package/dist/{DocsLayout-Q4KS3QWW.cjs → DocsLayout-2YZNS5VK.cjs} +8 -8
- package/dist/{DocsLayout-Q4KS3QWW.cjs.map → DocsLayout-2YZNS5VK.cjs.map} +1 -1
- package/dist/chunk-4LXG3NBV.mjs +833 -0
- package/dist/chunk-4LXG3NBV.mjs.map +1 -0
- package/dist/{chunk-XACCHZH2.cjs → chunk-FIRK5CEH.cjs} +42 -4
- package/dist/chunk-FIRK5CEH.cjs.map +1 -0
- package/dist/{chunk-NWUT327A.mjs → chunk-HIK6BPL7.mjs} +38 -5
- package/dist/chunk-HIK6BPL7.mjs.map +1 -0
- package/dist/chunk-KMSBGNVC.cjs +835 -0
- package/dist/chunk-KMSBGNVC.cjs.map +1 -0
- package/dist/chunk-OZAU3QWD.cjs +2493 -0
- package/dist/chunk-OZAU3QWD.cjs.map +1 -0
- package/dist/chunk-UWVP6LCW.mjs +2447 -0
- package/dist/chunk-UWVP6LCW.mjs.map +1 -0
- package/dist/index.cjs +1532 -100
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1148 -107
- package/dist/index.d.ts +1148 -107
- package/dist/index.mjs +1421 -51
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -8
- package/src/audio-assets.d.ts +8 -0
- package/src/components/markdown/MarkdownMessage/CollapseToggle.tsx +3 -1
- package/src/components/markdown/MarkdownMessage/components.tsx +2 -5
- package/src/stories/index.ts +32 -2
- package/src/tools/Chat/README.md +347 -530
- package/src/tools/Chat/components/Attachments.tsx +6 -1
- package/src/tools/Chat/components/ChatRoot.tsx +30 -2
- package/src/tools/Chat/components/Composer.tsx +20 -3
- package/src/tools/Chat/components/ErrorBanner.tsx +7 -3
- package/src/tools/Chat/components/MessageActions.tsx +3 -1
- package/src/tools/Chat/components/MessageBubble.tsx +6 -5
- package/src/tools/Chat/components/MessageList.tsx +87 -1
- package/src/tools/Chat/components/ToolCalls.tsx +21 -3
- package/src/tools/Chat/context/ChatProvider.tsx +21 -3
- package/src/tools/Chat/core/audio/audioBus.ts +10 -163
- package/src/tools/Chat/core/audio/defaults.ts +43 -0
- package/src/tools/Chat/core/audio/index.ts +1 -0
- package/src/tools/Chat/core/audio/preferences.ts +5 -59
- package/src/tools/Chat/core/audio/sounds/error.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/mention.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/notification.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/received.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/sent.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/start.mp3 +0 -0
- package/src/tools/Chat/core/audio/types.ts +28 -0
- package/src/tools/Chat/core/reducer.ts +33 -0
- package/src/tools/Chat/core/transport/index.ts +13 -0
- package/src/tools/Chat/core/transport/mappers/index.ts +6 -0
- package/src/tools/Chat/core/transport/mappers/pydantic-ai.ts +142 -0
- package/src/tools/Chat/core/transport/pydantic-ai-transport.ts +208 -0
- package/src/tools/Chat/core/transport/sse.ts +18 -5
- package/src/tools/Chat/hooks/index.ts +25 -0
- package/src/tools/Chat/hooks/useAutoFocusOnStreamEnd.ts +5 -3
- package/src/tools/Chat/hooks/useChat.ts +28 -0
- package/src/tools/Chat/hooks/useChatAudio.ts +59 -180
- package/src/tools/Chat/hooks/useChatDockPrefs.ts +74 -0
- package/src/tools/Chat/hooks/useChatReset.ts +70 -0
- package/src/tools/Chat/hooks/useChatUnread.ts +87 -0
- package/src/tools/Chat/hooks/useFocusOnEmptyClick.ts +111 -0
- package/src/tools/Chat/hooks/useVisitorFingerprint.ts +48 -0
- package/src/tools/Chat/index.ts +69 -1
- package/src/tools/Chat/launcher/ChatDock.tsx +263 -0
- package/src/tools/Chat/launcher/ChatFAB.tsx +349 -0
- package/src/tools/Chat/launcher/ChatGreeting.tsx +200 -0
- package/src/tools/Chat/launcher/ChatHeader.tsx +76 -0
- package/src/tools/Chat/launcher/ChatHeaderActionButton.tsx +87 -0
- package/src/tools/Chat/launcher/ChatHeaderAudioToggle.tsx +47 -0
- package/src/tools/Chat/launcher/ChatHeaderLanguageButton.tsx +179 -0
- package/src/tools/Chat/launcher/ChatHeaderModeToggle.tsx +57 -0
- package/src/tools/Chat/launcher/ChatHeaderResetButton.tsx +93 -0
- package/src/tools/Chat/launcher/ChatLauncher.tsx +321 -0
- package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +197 -0
- package/src/tools/Chat/launcher/index.ts +46 -0
- package/src/tools/Chat/launcher/useChatPresence.ts +44 -0
- package/src/tools/Chat/stories/01-basic.story.tsx +64 -0
- package/src/tools/Chat/stories/02-bubbles.story.tsx +21 -0
- package/src/tools/Chat/stories/03-tool-calls.story.tsx +59 -0
- package/src/tools/Chat/stories/04-personas.story.tsx +78 -0
- package/src/tools/Chat/stories/05-launcher.story.tsx +321 -0
- package/src/tools/Chat/stories/06-header.story.tsx +147 -0
- package/src/tools/Chat/stories/07-audio-actions.story.tsx +112 -0
- package/src/tools/Chat/stories/shared/Frame.tsx +21 -0
- package/src/tools/Chat/stories/shared/index.ts +5 -0
- package/src/tools/Chat/stories/shared/messages.ts +39 -0
- package/src/tools/Chat/stories/shared/personas.ts +13 -0
- package/src/tools/Chat/stories/shared/seeds.ts +92 -0
- package/src/tools/Chat/stories/shared/transports.ts +36 -0
- package/src/tools/Chat/styles/bubbleTokens.ts +71 -0
- package/src/tools/Chat/styles/index.ts +16 -0
- package/src/tools/Chat/styles/useChatStyles.ts +101 -0
- package/src/tools/Chat/types/attachment.ts +25 -0
- package/src/tools/Chat/types/config.ts +48 -0
- package/src/tools/Chat/types/events.ts +35 -0
- package/src/tools/Chat/types/index.ts +34 -0
- package/src/tools/Chat/types/labels.ts +38 -0
- package/src/tools/Chat/types/message.ts +32 -0
- package/src/tools/Chat/types/persona.ts +31 -0
- package/src/tools/Chat/types/session.ts +43 -0
- package/src/tools/Chat/types/tool-call.ts +17 -0
- package/src/tools/Chat/types/transport.ts +28 -0
- package/src/tools/Chat/types.ts +5 -240
- package/src/tools/MarkdownEditor/MarkdownEditor.tsx +50 -14
- package/src/tools/MarkdownEditor/index.ts +1 -1
- package/src/tools/SpeechRecognition/README.md +336 -0
- package/src/tools/SpeechRecognition/__tests__/ids.test.ts +15 -0
- package/src/tools/SpeechRecognition/__tests__/language.test.ts +59 -0
- package/src/tools/SpeechRecognition/__tests__/reducer.test.ts +71 -0
- package/src/tools/SpeechRecognition/__tests__/transcript.test.ts +52 -0
- package/src/tools/SpeechRecognition/components/DevicePicker.tsx +49 -0
- package/src/tools/SpeechRecognition/components/DictationButton.tsx +93 -0
- package/src/tools/SpeechRecognition/components/EngineBadge.tsx +30 -0
- package/src/tools/SpeechRecognition/components/ErrorBanner.tsx +52 -0
- package/src/tools/SpeechRecognition/components/LanguagePicker.tsx +63 -0
- package/src/tools/SpeechRecognition/components/MicMeter.tsx +63 -0
- package/src/tools/SpeechRecognition/components/PushToTalkHint.tsx +51 -0
- package/src/tools/SpeechRecognition/components/TranscriptView.tsx +55 -0
- package/src/tools/SpeechRecognition/components/index.ts +16 -0
- package/src/tools/SpeechRecognition/context/SpeechRecognitionProvider.tsx +47 -0
- package/src/tools/SpeechRecognition/context/index.ts +6 -0
- package/src/tools/SpeechRecognition/core/audio/defaults.ts +24 -0
- package/src/tools/SpeechRecognition/core/engine/external.ts +222 -0
- package/src/tools/SpeechRecognition/core/engine/http.ts +147 -0
- package/src/tools/SpeechRecognition/core/engine/index.ts +52 -0
- package/src/tools/SpeechRecognition/core/engine/mediarecorder.ts +105 -0
- package/src/tools/SpeechRecognition/core/engine/websocket.ts +211 -0
- package/src/tools/SpeechRecognition/core/engine/webspeech.ts +188 -0
- package/src/tools/SpeechRecognition/core/ids.ts +11 -0
- package/src/tools/SpeechRecognition/core/index.ts +14 -0
- package/src/tools/SpeechRecognition/core/language.ts +78 -0
- package/src/tools/SpeechRecognition/core/languages-catalog.ts +229 -0
- package/src/tools/SpeechRecognition/core/logger.ts +3 -0
- package/src/tools/SpeechRecognition/core/reducer.ts +105 -0
- package/src/tools/SpeechRecognition/core/transcript.ts +36 -0
- package/src/tools/SpeechRecognition/hooks/index.ts +14 -0
- package/src/tools/SpeechRecognition/hooks/useDictation.ts +59 -0
- package/src/tools/SpeechRecognition/hooks/useEnginePrefs.ts +15 -0
- package/src/tools/SpeechRecognition/hooks/useMicDevices.ts +57 -0
- package/src/tools/SpeechRecognition/hooks/useMicLevel.ts +52 -0
- package/src/tools/SpeechRecognition/hooks/usePushToTalk.ts +85 -0
- package/src/tools/SpeechRecognition/hooks/useResolvedLanguage.ts +28 -0
- package/src/tools/SpeechRecognition/hooks/useSpeechLanguageInfo.ts +108 -0
- package/src/tools/SpeechRecognition/hooks/useSpeechRecognition.ts +188 -0
- package/src/tools/SpeechRecognition/hooks/useVoiceSupport.ts +78 -0
- package/src/tools/SpeechRecognition/index.ts +82 -0
- package/src/tools/SpeechRecognition/lazy.tsx +19 -0
- package/src/tools/SpeechRecognition/store/index.ts +2 -0
- package/src/tools/SpeechRecognition/store/prefsStore.ts +54 -0
- package/src/tools/SpeechRecognition/stories/01-basic.story.tsx +32 -0
- package/src/tools/SpeechRecognition/stories/02-dictation-field.story.tsx +32 -0
- package/src/tools/SpeechRecognition/stories/03-push-to-talk.story.tsx +27 -0
- package/src/tools/SpeechRecognition/stories/04-mic-meter.story.tsx +35 -0
- package/src/tools/SpeechRecognition/stories/05-custom-engine-http.story.tsx +40 -0
- package/src/tools/SpeechRecognition/stories/06-custom-engine-ws.story.tsx +48 -0
- package/src/tools/SpeechRecognition/stories/07-language-device.story.tsx +57 -0
- package/src/tools/SpeechRecognition/stories/08-errors-permissions.story.tsx +25 -0
- package/src/tools/SpeechRecognition/stories/09-chat-voice.story.tsx +90 -0
- package/src/tools/SpeechRecognition/stories/shared.tsx +123 -0
- package/src/tools/SpeechRecognition/types.ts +133 -0
- package/src/tools/SpeechRecognition/widgets/DictationField.tsx +105 -0
- package/src/tools/SpeechRecognition/widgets/VoiceComposerSlot.tsx +305 -0
- package/src/tools/SpeechRecognition/widgets/VoiceMessageRecorder.tsx +88 -0
- package/src/tools/SpeechRecognition/widgets/index.ts +6 -0
- package/dist/ChatRoot-EJC5Y2YM.cjs +0 -14
- package/dist/ChatRoot-QOSKJPM6.mjs +0 -5
- package/dist/chunk-NWUT327A.mjs.map +0 -1
- package/dist/chunk-QLMKCSR6.mjs +0 -2420
- package/dist/chunk-QLMKCSR6.mjs.map +0 -1
- package/dist/chunk-SI5RD2GD.cjs +0 -2460
- package/dist/chunk-SI5RD2GD.cjs.map +0 -1
- package/dist/chunk-XACCHZH2.cjs.map +0 -1
- package/src/tools/Chat/Chat.story.tsx +0 -1457
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP engine — records audio with MediaRecorder and POSTs each chunk to
|
|
3
|
+
* a host-supplied URL. The host owns response parsing via `parse()`, so
|
|
4
|
+
* this engine works with OpenAI Whisper REST, custom Django/FastAPI
|
|
5
|
+
* endpoints, or anything else that takes audio and returns text.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { newSegmentId } from '../ids';
|
|
9
|
+
import { sttLogger } from '../logger';
|
|
10
|
+
import { createEngineBus } from './index';
|
|
11
|
+
import { startMicCapture, type MicCaptureHandle } from './mediarecorder';
|
|
12
|
+
import type {
|
|
13
|
+
EngineStartOptions,
|
|
14
|
+
RecognitionEngine,
|
|
15
|
+
RecognitionError,
|
|
16
|
+
Unsub,
|
|
17
|
+
} from '../../types';
|
|
18
|
+
|
|
19
|
+
export interface HttpEngineParseResult {
|
|
20
|
+
text: string;
|
|
21
|
+
isFinal: boolean;
|
|
22
|
+
/** Optional engine-provided confidence 0..1. */
|
|
23
|
+
confidence?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface HttpEngineOptions {
|
|
27
|
+
/** Endpoint URL. Receives `POST` with the audio chunk as the body. */
|
|
28
|
+
url: string | ((language: string) => string);
|
|
29
|
+
/** Per-request headers, awaited each chunk so tokens can be refreshed. */
|
|
30
|
+
headers?: () => Promise<Record<string, string>> | Record<string, string>;
|
|
31
|
+
/** Chunk emission interval, ms. Default 750 — long enough for useful audio. */
|
|
32
|
+
chunkMs?: number;
|
|
33
|
+
/** Preferred MIME for the encoder. Probed against `MediaRecorder` support. */
|
|
34
|
+
mime?: string;
|
|
35
|
+
/** Parse the engine response — return null/undefined to skip emit. */
|
|
36
|
+
parse: (
|
|
37
|
+
resp: Response,
|
|
38
|
+
) => Promise<HttpEngineParseResult | null | undefined> | HttpEngineParseResult | null | undefined;
|
|
39
|
+
/** Stable engine id for telemetry / UI badge. Default 'http'. */
|
|
40
|
+
id?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createHttpEngine(opts: HttpEngineOptions): RecognitionEngine {
|
|
44
|
+
const bus = createEngineBus();
|
|
45
|
+
let capture: MicCaptureHandle | null = null;
|
|
46
|
+
let currentSegmentId: string | null = null;
|
|
47
|
+
let ctrl: AbortController | null = null;
|
|
48
|
+
let stopping = false;
|
|
49
|
+
|
|
50
|
+
async function sendChunk(blob: Blob, language: string): Promise<void> {
|
|
51
|
+
if (stopping) return;
|
|
52
|
+
const url = typeof opts.url === 'function' ? opts.url(language) : opts.url;
|
|
53
|
+
const headers = (await opts.headers?.()) ?? {};
|
|
54
|
+
try {
|
|
55
|
+
const resp = await fetch(url, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers,
|
|
58
|
+
body: blob,
|
|
59
|
+
signal: ctrl?.signal,
|
|
60
|
+
});
|
|
61
|
+
if (!resp.ok) {
|
|
62
|
+
bus.emit('error', {
|
|
63
|
+
code: 'network',
|
|
64
|
+
message: `STT endpoint returned ${resp.status}`,
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const parsed = await opts.parse(resp);
|
|
69
|
+
if (!parsed || !parsed.text) return;
|
|
70
|
+
if (!currentSegmentId) currentSegmentId = newSegmentId();
|
|
71
|
+
if (parsed.isFinal) {
|
|
72
|
+
bus.emit('final', parsed.text, currentSegmentId, parsed.confidence);
|
|
73
|
+
currentSegmentId = null;
|
|
74
|
+
} else {
|
|
75
|
+
bus.emit('partial', parsed.text, currentSegmentId);
|
|
76
|
+
}
|
|
77
|
+
} catch (cause) {
|
|
78
|
+
if ((cause as { name?: string })?.name === 'AbortError') return;
|
|
79
|
+
sttLogger.warn('[http] chunk send failed', cause);
|
|
80
|
+
bus.emit('error', {
|
|
81
|
+
code: 'network',
|
|
82
|
+
message: 'Failed to deliver audio chunk to STT endpoint.',
|
|
83
|
+
cause,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
id: opts.id ?? 'http',
|
|
90
|
+
isSupported:
|
|
91
|
+
typeof navigator !== 'undefined' &&
|
|
92
|
+
!!navigator.mediaDevices?.getUserMedia &&
|
|
93
|
+
typeof MediaRecorder !== 'undefined',
|
|
94
|
+
on(event, cb): Unsub {
|
|
95
|
+
return bus.on(event, cb);
|
|
96
|
+
},
|
|
97
|
+
async start(start: EngineStartOptions): Promise<void> {
|
|
98
|
+
if (capture) return;
|
|
99
|
+
stopping = false;
|
|
100
|
+
ctrl = new AbortController();
|
|
101
|
+
bus.emit('state', 'connecting');
|
|
102
|
+
try {
|
|
103
|
+
capture = await startMicCapture({
|
|
104
|
+
deviceId: start.deviceId,
|
|
105
|
+
mime: opts.mime,
|
|
106
|
+
chunkMs: opts.chunkMs ?? 750,
|
|
107
|
+
onChunk: (chunk) => {
|
|
108
|
+
void sendChunk(chunk, start.language);
|
|
109
|
+
},
|
|
110
|
+
onError: (err) => bus.emit('error', err),
|
|
111
|
+
});
|
|
112
|
+
bus.emit('state', 'listening');
|
|
113
|
+
} catch (cause) {
|
|
114
|
+
const err = cause as RecognitionError;
|
|
115
|
+
bus.emit('error', err);
|
|
116
|
+
bus.emit('state', 'error');
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
start.signal?.addEventListener('abort', () => {
|
|
121
|
+
void this.stop();
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
async stop(): Promise<void> {
|
|
125
|
+
stopping = true;
|
|
126
|
+
bus.emit('state', 'closing');
|
|
127
|
+
ctrl?.abort();
|
|
128
|
+
ctrl = null;
|
|
129
|
+
await capture?.stop();
|
|
130
|
+
capture = null;
|
|
131
|
+
currentSegmentId = null;
|
|
132
|
+
bus.emit('state', 'closed');
|
|
133
|
+
},
|
|
134
|
+
abort(): void {
|
|
135
|
+
stopping = true;
|
|
136
|
+
ctrl?.abort();
|
|
137
|
+
ctrl = null;
|
|
138
|
+
capture?.stop().catch(() => undefined);
|
|
139
|
+
capture = null;
|
|
140
|
+
currentSegmentId = null;
|
|
141
|
+
bus.emit('state', 'closed');
|
|
142
|
+
},
|
|
143
|
+
getStream(): MediaStream | null {
|
|
144
|
+
return capture?.stream ?? null;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny event-bus helper shared by every engine. Lets engine authors avoid
|
|
3
|
+
* re-implementing add/remove listener bookkeeping while keeping the
|
|
4
|
+
* public `RecognitionEngine.on(...)` contract identical across engines.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { EngineEventMap, Unsub } from '../../types';
|
|
8
|
+
|
|
9
|
+
type Listeners = {
|
|
10
|
+
[K in keyof EngineEventMap]: Set<EngineEventMap[K]>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function createEngineBus(): {
|
|
14
|
+
on: <K extends keyof EngineEventMap>(event: K, cb: EngineEventMap[K]) => Unsub;
|
|
15
|
+
emit: <K extends keyof EngineEventMap>(
|
|
16
|
+
event: K,
|
|
17
|
+
...args: Parameters<EngineEventMap[K]>
|
|
18
|
+
) => void;
|
|
19
|
+
clear: () => void;
|
|
20
|
+
} {
|
|
21
|
+
const listeners: Listeners = {
|
|
22
|
+
partial: new Set(),
|
|
23
|
+
final: new Set(),
|
|
24
|
+
error: new Set(),
|
|
25
|
+
state: new Set(),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
on(event, cb) {
|
|
30
|
+
const set = listeners[event] as Set<typeof cb>;
|
|
31
|
+
set.add(cb);
|
|
32
|
+
return () => {
|
|
33
|
+
set.delete(cb);
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
emit(event, ...args) {
|
|
37
|
+
const set = listeners[event];
|
|
38
|
+
for (const cb of set) {
|
|
39
|
+
try {
|
|
40
|
+
(cb as (...a: unknown[]) => void)(...(args as unknown[]));
|
|
41
|
+
} catch {
|
|
42
|
+
// listener errors are isolated — never break the engine loop
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
clear() {
|
|
47
|
+
for (const key of Object.keys(listeners) as Array<keyof Listeners>) {
|
|
48
|
+
listeners[key].clear();
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared mic capture used by the HTTP and WebSocket engines.
|
|
3
|
+
*
|
|
4
|
+
* Probes the browser for a working `MediaRecorder` MIME type and emits
|
|
5
|
+
* `Blob` chunks on a steady interval. Picks the first supported MIME in
|
|
6
|
+
* order: `audio/webm;codecs=opus` → `audio/ogg;codecs=opus` →
|
|
7
|
+
* `audio/mp4;codecs=mp4a`. Falls back to engine default if none match.
|
|
8
|
+
*
|
|
9
|
+
* The capture also exposes the raw `MediaStream` so callers can wire up
|
|
10
|
+
* an `AnalyserNode` for the level meter without owning a second copy.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { sttLogger } from '../logger';
|
|
14
|
+
import type { RecognitionError } from '../../types';
|
|
15
|
+
|
|
16
|
+
const PREFERRED_MIMES = [
|
|
17
|
+
'audio/webm;codecs=opus',
|
|
18
|
+
'audio/ogg;codecs=opus',
|
|
19
|
+
'audio/mp4;codecs=mp4a',
|
|
20
|
+
'audio/webm',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function pickMime(preferred?: string): string | undefined {
|
|
24
|
+
if (typeof MediaRecorder === 'undefined') return undefined;
|
|
25
|
+
const candidates = preferred ? [preferred, ...PREFERRED_MIMES] : PREFERRED_MIMES;
|
|
26
|
+
for (const mime of candidates) {
|
|
27
|
+
if (MediaRecorder.isTypeSupported(mime)) return mime;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MicCaptureOptions {
|
|
33
|
+
deviceId?: string;
|
|
34
|
+
/** Override probed MIME — useful when the backend expects a specific codec. */
|
|
35
|
+
mime?: string;
|
|
36
|
+
/** Chunk emission interval, ms. Default 250. */
|
|
37
|
+
chunkMs?: number;
|
|
38
|
+
onChunk: (chunk: Blob) => void;
|
|
39
|
+
onError?: (err: RecognitionError) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MicCaptureHandle {
|
|
43
|
+
readonly stream: MediaStream;
|
|
44
|
+
readonly mime: string | undefined;
|
|
45
|
+
stop(): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toErr(code: RecognitionError['code'], message: string, cause?: unknown): RecognitionError {
|
|
49
|
+
return { code, message, cause };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function startMicCapture(
|
|
53
|
+
opts: MicCaptureOptions,
|
|
54
|
+
): Promise<MicCaptureHandle> {
|
|
55
|
+
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
|
|
56
|
+
throw toErr('unsupported', 'getUserMedia is not available in this environment.');
|
|
57
|
+
}
|
|
58
|
+
if (typeof MediaRecorder === 'undefined') {
|
|
59
|
+
throw toErr('unsupported', 'MediaRecorder is not available in this environment.');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let stream: MediaStream;
|
|
63
|
+
try {
|
|
64
|
+
stream = await navigator.mediaDevices.getUserMedia({
|
|
65
|
+
audio: opts.deviceId ? { deviceId: { exact: opts.deviceId } } : true,
|
|
66
|
+
video: false,
|
|
67
|
+
});
|
|
68
|
+
} catch (cause) {
|
|
69
|
+
const name = (cause as { name?: string })?.name;
|
|
70
|
+
if (name === 'NotAllowedError' || name === 'SecurityError') {
|
|
71
|
+
throw toErr('permission-denied', 'Microphone permission denied.', cause);
|
|
72
|
+
}
|
|
73
|
+
if (name === 'NotFoundError' || name === 'OverconstrainedError') {
|
|
74
|
+
throw toErr('no-microphone', 'No microphone found matching the constraints.', cause);
|
|
75
|
+
}
|
|
76
|
+
throw toErr('unknown', 'Failed to access microphone.', cause);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const mime = pickMime(opts.mime);
|
|
80
|
+
const rec = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
|
|
81
|
+
|
|
82
|
+
rec.ondataavailable = (e) => {
|
|
83
|
+
if (e.data && e.data.size > 0) opts.onChunk(e.data);
|
|
84
|
+
};
|
|
85
|
+
rec.onerror = (e) => {
|
|
86
|
+
const err = toErr('engine', 'MediaRecorder error.', e);
|
|
87
|
+
sttLogger.warn('[capture] recorder error', e);
|
|
88
|
+
opts.onError?.(err);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
rec.start(opts.chunkMs ?? 250);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
stream,
|
|
95
|
+
mime: mime ?? rec.mimeType,
|
|
96
|
+
async stop() {
|
|
97
|
+
const done = new Promise<void>((resolve) => {
|
|
98
|
+
rec.addEventListener('stop', () => resolve(), { once: true });
|
|
99
|
+
});
|
|
100
|
+
if (rec.state !== 'inactive') rec.stop();
|
|
101
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
102
|
+
await done;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket engine — pushes recorded audio frames over a persistent socket
|
|
3
|
+
* and parses server responses through a host-supplied `parseMessage`
|
|
4
|
+
* callback. Works with Deepgram / AssemblyAI realtime endpoints or any
|
|
5
|
+
* custom gateway that speaks JSON or binary frames.
|
|
6
|
+
*
|
|
7
|
+
* Reconnect: simple exponential backoff capped at 5 s; the engine emits
|
|
8
|
+
* `state: 'connecting'` between attempts so UIs can show "reconnecting…".
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { newSegmentId } from '../ids';
|
|
12
|
+
import { sttLogger } from '../logger';
|
|
13
|
+
import { createEngineBus } from './index';
|
|
14
|
+
import { startMicCapture, type MicCaptureHandle } from './mediarecorder';
|
|
15
|
+
import type {
|
|
16
|
+
EngineStartOptions,
|
|
17
|
+
RecognitionEngine,
|
|
18
|
+
RecognitionError,
|
|
19
|
+
Unsub,
|
|
20
|
+
} from '../../types';
|
|
21
|
+
|
|
22
|
+
export type WsParsedEvent =
|
|
23
|
+
| { kind: 'partial'; text: string; segmentId?: string; confidence?: number }
|
|
24
|
+
| { kind: 'final'; text: string; segmentId?: string; confidence?: number }
|
|
25
|
+
| { kind: 'error'; error: RecognitionError }
|
|
26
|
+
| { kind: 'ignore' };
|
|
27
|
+
|
|
28
|
+
export interface WebSocketEngineOptions {
|
|
29
|
+
url: string | ((language: string) => Promise<string> | string);
|
|
30
|
+
protocols?: string[];
|
|
31
|
+
/** Chunk emission interval, ms. Default 250 for realtime feel. */
|
|
32
|
+
chunkMs?: number;
|
|
33
|
+
mime?: string;
|
|
34
|
+
/** Parse one frame (string or binary) into our normalised event shape. */
|
|
35
|
+
parseMessage: (data: string | ArrayBuffer) => WsParsedEvent;
|
|
36
|
+
/** Stable engine id for telemetry / UI badge. Default 'websocket'. */
|
|
37
|
+
id?: string;
|
|
38
|
+
/** Max reconnect attempts before giving up. Default 5. */
|
|
39
|
+
maxReconnect?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const MIN_BACKOFF = 250;
|
|
43
|
+
const MAX_BACKOFF = 5000;
|
|
44
|
+
|
|
45
|
+
export function createWebSocketEngine(
|
|
46
|
+
opts: WebSocketEngineOptions,
|
|
47
|
+
): RecognitionEngine {
|
|
48
|
+
const bus = createEngineBus();
|
|
49
|
+
let socket: WebSocket | null = null;
|
|
50
|
+
let capture: MicCaptureHandle | null = null;
|
|
51
|
+
let currentSegmentId: string | null = null;
|
|
52
|
+
let stopping = false;
|
|
53
|
+
let attempts = 0;
|
|
54
|
+
|
|
55
|
+
function emitParsed(parsed: WsParsedEvent): void {
|
|
56
|
+
switch (parsed.kind) {
|
|
57
|
+
case 'partial': {
|
|
58
|
+
const id = parsed.segmentId ?? currentSegmentId ?? newSegmentId();
|
|
59
|
+
currentSegmentId = id;
|
|
60
|
+
bus.emit('partial', parsed.text, id);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
case 'final': {
|
|
64
|
+
const id = parsed.segmentId ?? currentSegmentId ?? newSegmentId();
|
|
65
|
+
bus.emit('final', parsed.text, id, parsed.confidence);
|
|
66
|
+
currentSegmentId = null;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
case 'error':
|
|
70
|
+
bus.emit('error', parsed.error);
|
|
71
|
+
return;
|
|
72
|
+
case 'ignore':
|
|
73
|
+
default:
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function openSocket(language: string): Promise<WebSocket> {
|
|
79
|
+
const url =
|
|
80
|
+
typeof opts.url === 'function' ? await opts.url(language) : opts.url;
|
|
81
|
+
const ws = new WebSocket(url, opts.protocols);
|
|
82
|
+
ws.binaryType = 'arraybuffer';
|
|
83
|
+
return ws;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function connect(start: EngineStartOptions): Promise<void> {
|
|
87
|
+
if (stopping) return;
|
|
88
|
+
bus.emit('state', 'connecting');
|
|
89
|
+
let ws: WebSocket;
|
|
90
|
+
try {
|
|
91
|
+
ws = await openSocket(start.language);
|
|
92
|
+
} catch (cause) {
|
|
93
|
+
bus.emit('error', {
|
|
94
|
+
code: 'network',
|
|
95
|
+
message: 'Failed to open STT socket.',
|
|
96
|
+
cause,
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
socket = ws;
|
|
101
|
+
|
|
102
|
+
ws.onopen = () => {
|
|
103
|
+
attempts = 0;
|
|
104
|
+
bus.emit('state', 'listening');
|
|
105
|
+
};
|
|
106
|
+
ws.onmessage = (e) => {
|
|
107
|
+
try {
|
|
108
|
+
const parsed = opts.parseMessage(e.data as string | ArrayBuffer);
|
|
109
|
+
emitParsed(parsed);
|
|
110
|
+
} catch (cause) {
|
|
111
|
+
sttLogger.warn('[ws] parseMessage threw', cause);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
ws.onerror = () => {
|
|
115
|
+
bus.emit('error', { code: 'network', message: 'STT socket error.' });
|
|
116
|
+
};
|
|
117
|
+
ws.onclose = () => {
|
|
118
|
+
socket = null;
|
|
119
|
+
if (stopping) {
|
|
120
|
+
bus.emit('state', 'closed');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
attempts += 1;
|
|
124
|
+
const max = opts.maxReconnect ?? 5;
|
|
125
|
+
if (attempts > max) {
|
|
126
|
+
bus.emit('error', {
|
|
127
|
+
code: 'network',
|
|
128
|
+
message: `STT socket closed; gave up after ${max} attempts.`,
|
|
129
|
+
});
|
|
130
|
+
bus.emit('state', 'closed');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const delay = Math.min(MIN_BACKOFF * 2 ** (attempts - 1), MAX_BACKOFF);
|
|
134
|
+
setTimeout(() => {
|
|
135
|
+
void connect(start);
|
|
136
|
+
}, delay);
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
id: opts.id ?? 'websocket',
|
|
142
|
+
isSupported:
|
|
143
|
+
typeof WebSocket !== 'undefined' &&
|
|
144
|
+
typeof navigator !== 'undefined' &&
|
|
145
|
+
!!navigator.mediaDevices?.getUserMedia &&
|
|
146
|
+
typeof MediaRecorder !== 'undefined',
|
|
147
|
+
on(event, cb): Unsub {
|
|
148
|
+
return bus.on(event, cb);
|
|
149
|
+
},
|
|
150
|
+
async start(start: EngineStartOptions): Promise<void> {
|
|
151
|
+
if (capture) return;
|
|
152
|
+
stopping = false;
|
|
153
|
+
attempts = 0;
|
|
154
|
+
try {
|
|
155
|
+
capture = await startMicCapture({
|
|
156
|
+
deviceId: start.deviceId,
|
|
157
|
+
mime: opts.mime,
|
|
158
|
+
chunkMs: opts.chunkMs ?? 250,
|
|
159
|
+
onChunk: (chunk) => {
|
|
160
|
+
if (socket?.readyState === WebSocket.OPEN) {
|
|
161
|
+
chunk
|
|
162
|
+
.arrayBuffer()
|
|
163
|
+
.then((buf) => socket?.send(buf))
|
|
164
|
+
.catch((cause) => sttLogger.warn('[ws] send failed', cause));
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
onError: (err) => bus.emit('error', err),
|
|
168
|
+
});
|
|
169
|
+
} catch (cause) {
|
|
170
|
+
const err = cause as RecognitionError;
|
|
171
|
+
bus.emit('error', err);
|
|
172
|
+
bus.emit('state', 'error');
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
await connect(start);
|
|
176
|
+
start.signal?.addEventListener('abort', () => {
|
|
177
|
+
void this.stop();
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
async stop(): Promise<void> {
|
|
181
|
+
stopping = true;
|
|
182
|
+
bus.emit('state', 'closing');
|
|
183
|
+
try {
|
|
184
|
+
socket?.close(1000, 'client-stop');
|
|
185
|
+
} catch {
|
|
186
|
+
// ignore
|
|
187
|
+
}
|
|
188
|
+
socket = null;
|
|
189
|
+
await capture?.stop();
|
|
190
|
+
capture = null;
|
|
191
|
+
currentSegmentId = null;
|
|
192
|
+
bus.emit('state', 'closed');
|
|
193
|
+
},
|
|
194
|
+
abort(): void {
|
|
195
|
+
stopping = true;
|
|
196
|
+
try {
|
|
197
|
+
socket?.close(4000, 'client-abort');
|
|
198
|
+
} catch {
|
|
199
|
+
// ignore
|
|
200
|
+
}
|
|
201
|
+
socket = null;
|
|
202
|
+
capture?.stop().catch(() => undefined);
|
|
203
|
+
capture = null;
|
|
204
|
+
currentSegmentId = null;
|
|
205
|
+
bus.emit('state', 'closed');
|
|
206
|
+
},
|
|
207
|
+
getStream(): MediaStream | null {
|
|
208
|
+
return capture?.stream ?? null;
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default engine — wraps the browser's `SpeechRecognition` API.
|
|
3
|
+
*
|
|
4
|
+
* Lives behind the same `RecognitionEngine` contract every other engine
|
|
5
|
+
* implements. When the browser doesn't expose `SpeechRecognition`
|
|
6
|
+
* (Firefox, some mobile WebViews) `isSupported` is `false` and `start()`
|
|
7
|
+
* throws an `unsupported` error.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { newSegmentId } from '../ids';
|
|
11
|
+
import { sttLogger } from '../logger';
|
|
12
|
+
import { createEngineBus } from './index';
|
|
13
|
+
import type {
|
|
14
|
+
EngineStartOptions,
|
|
15
|
+
RecognitionEngine,
|
|
16
|
+
RecognitionError,
|
|
17
|
+
RecognitionErrorCode,
|
|
18
|
+
Unsub,
|
|
19
|
+
} from '../../types';
|
|
20
|
+
|
|
21
|
+
// Minimal subset of the Web Speech API we actually rely on. Browsers
|
|
22
|
+
// expose either `SpeechRecognition` (Edge / Safari new) or the older
|
|
23
|
+
// `webkitSpeechRecognition` (Chrome). Both share the same shape.
|
|
24
|
+
interface BrowserSpeechRecognition extends EventTarget {
|
|
25
|
+
lang: string;
|
|
26
|
+
interimResults: boolean;
|
|
27
|
+
continuous: boolean;
|
|
28
|
+
maxAlternatives: number;
|
|
29
|
+
start(): void;
|
|
30
|
+
stop(): void;
|
|
31
|
+
abort(): void;
|
|
32
|
+
onresult: ((e: BrowserSpeechRecognitionEvent) => void) | null;
|
|
33
|
+
onerror: ((e: BrowserSpeechRecognitionError) => void) | null;
|
|
34
|
+
onstart: (() => void) | null;
|
|
35
|
+
onend: (() => void) | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface BrowserSpeechRecognitionResult {
|
|
39
|
+
isFinal: boolean;
|
|
40
|
+
0: { transcript: string; confidence: number };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface BrowserSpeechRecognitionEvent extends Event {
|
|
44
|
+
resultIndex: number;
|
|
45
|
+
results: ArrayLike<BrowserSpeechRecognitionResult>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface BrowserSpeechRecognitionError extends Event {
|
|
49
|
+
error: string;
|
|
50
|
+
message?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type Ctor = new () => BrowserSpeechRecognition;
|
|
54
|
+
|
|
55
|
+
function resolveCtor(): Ctor | null {
|
|
56
|
+
if (typeof window === 'undefined') return null;
|
|
57
|
+
const w = window as unknown as {
|
|
58
|
+
SpeechRecognition?: Ctor;
|
|
59
|
+
webkitSpeechRecognition?: Ctor;
|
|
60
|
+
};
|
|
61
|
+
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ERROR_MAP: Record<string, RecognitionErrorCode> = {
|
|
65
|
+
'no-speech': 'no-speech',
|
|
66
|
+
aborted: 'aborted',
|
|
67
|
+
'audio-capture': 'no-microphone',
|
|
68
|
+
network: 'network',
|
|
69
|
+
'not-allowed': 'permission-denied',
|
|
70
|
+
'service-not-allowed': 'permission-denied',
|
|
71
|
+
'bad-grammar': 'engine',
|
|
72
|
+
'language-not-supported': 'language',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export interface WebSpeechEngineOptions {
|
|
76
|
+
/** Whether the underlying recognition should be continuous. Default true. */
|
|
77
|
+
continuous?: boolean;
|
|
78
|
+
/** Max alternatives the engine should request. Default 1. */
|
|
79
|
+
maxAlternatives?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createWebSpeechEngine(
|
|
83
|
+
opts: WebSpeechEngineOptions = {},
|
|
84
|
+
): RecognitionEngine {
|
|
85
|
+
const Ctor = resolveCtor();
|
|
86
|
+
const bus = createEngineBus();
|
|
87
|
+
let instance: BrowserSpeechRecognition | null = null;
|
|
88
|
+
let currentSegmentId: string | null = null;
|
|
89
|
+
|
|
90
|
+
function teardown(): void {
|
|
91
|
+
if (!instance) return;
|
|
92
|
+
instance.onresult = null;
|
|
93
|
+
instance.onerror = null;
|
|
94
|
+
instance.onstart = null;
|
|
95
|
+
instance.onend = null;
|
|
96
|
+
instance = null;
|
|
97
|
+
currentSegmentId = null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
id: 'webspeech',
|
|
102
|
+
isSupported: Ctor !== null,
|
|
103
|
+
on(event, cb): Unsub {
|
|
104
|
+
return bus.on(event, cb);
|
|
105
|
+
},
|
|
106
|
+
async start(start: EngineStartOptions): Promise<void> {
|
|
107
|
+
if (!Ctor) {
|
|
108
|
+
const err: RecognitionError = {
|
|
109
|
+
code: 'unsupported',
|
|
110
|
+
message: 'Web Speech API is not available in this browser.',
|
|
111
|
+
};
|
|
112
|
+
bus.emit('error', err);
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
if (instance) {
|
|
116
|
+
sttLogger.debug('[webspeech] start() called while running — ignoring');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
bus.emit('state', 'connecting');
|
|
121
|
+
|
|
122
|
+
const rec = new Ctor();
|
|
123
|
+
rec.lang = start.language;
|
|
124
|
+
rec.interimResults = start.interim;
|
|
125
|
+
rec.continuous = opts.continuous ?? true;
|
|
126
|
+
rec.maxAlternatives = opts.maxAlternatives ?? 1;
|
|
127
|
+
|
|
128
|
+
rec.onstart = () => {
|
|
129
|
+
bus.emit('state', 'listening');
|
|
130
|
+
};
|
|
131
|
+
rec.onend = () => {
|
|
132
|
+
bus.emit('state', 'closed');
|
|
133
|
+
teardown();
|
|
134
|
+
};
|
|
135
|
+
rec.onerror = (e) => {
|
|
136
|
+
const code = ERROR_MAP[e.error] ?? 'engine';
|
|
137
|
+
const err: RecognitionError = {
|
|
138
|
+
code,
|
|
139
|
+
message: e.message || `Web Speech error: ${e.error}`,
|
|
140
|
+
};
|
|
141
|
+
bus.emit('error', err);
|
|
142
|
+
};
|
|
143
|
+
rec.onresult = (e) => {
|
|
144
|
+
for (let i = e.resultIndex; i < e.results.length; i += 1) {
|
|
145
|
+
const res = e.results[i];
|
|
146
|
+
const alt = res[0];
|
|
147
|
+
const text = alt.transcript;
|
|
148
|
+
if (!currentSegmentId) currentSegmentId = newSegmentId();
|
|
149
|
+
if (res.isFinal) {
|
|
150
|
+
bus.emit('final', text, currentSegmentId, alt.confidence);
|
|
151
|
+
currentSegmentId = null;
|
|
152
|
+
} else {
|
|
153
|
+
bus.emit('partial', text, currentSegmentId);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (start.signal) {
|
|
159
|
+
start.signal.addEventListener('abort', () => {
|
|
160
|
+
rec.abort();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
instance = rec;
|
|
165
|
+
try {
|
|
166
|
+
rec.start();
|
|
167
|
+
} catch (cause) {
|
|
168
|
+
const err: RecognitionError = {
|
|
169
|
+
code: 'engine',
|
|
170
|
+
message: 'Failed to start Web Speech recognition.',
|
|
171
|
+
cause,
|
|
172
|
+
};
|
|
173
|
+
bus.emit('error', err);
|
|
174
|
+
teardown();
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
async stop(): Promise<void> {
|
|
179
|
+
if (!instance) return;
|
|
180
|
+
bus.emit('state', 'closing');
|
|
181
|
+
instance.stop();
|
|
182
|
+
},
|
|
183
|
+
abort(): void {
|
|
184
|
+
if (!instance) return;
|
|
185
|
+
instance.abort();
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|