@lumea-labs/chat-web-speech 0.1.0
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 +21 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/use-browser-voice-adapter.d.ts +39 -0
- package/dist/use-browser-voice-adapter.js +216 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @lumea-labs/chat-web-speech
|
|
2
|
+
|
|
3
|
+
Tier 2 voice adapter for Web Speech API — wraps webkitSpeechRecognition behind the VoiceTranscriptionAdapter contract.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @lumea-labs/chat-web-speech
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { /* … */ } from "@lumea-labs/chat-web-speech";
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
See the [Lumea Agents repo](https://github.com/Lumea-Technologies/lumea-agents/tree/main/packages/chat-web-speech) for the full source and a playground example.
|
|
18
|
+
|
|
19
|
+
## License
|
|
20
|
+
|
|
21
|
+
Apache-2.0
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { VoiceTranscriptionAdapter } from '@lumea-labs/chat';
|
|
2
|
+
|
|
3
|
+
interface UseBrowserVoiceAdapterOptions {
|
|
4
|
+
/** BCP-47 language tag — e.g. `"en-US"`, `"it-IT"`. Default: browser default. */
|
|
5
|
+
language?: string;
|
|
6
|
+
/** Keep transcribing across multiple utterances. Default `false` —
|
|
7
|
+
* one utterance per `start()`. */
|
|
8
|
+
continuous?: boolean;
|
|
9
|
+
/** Emit interim (in-progress) transcripts. Default `true`. */
|
|
10
|
+
interim?: boolean;
|
|
11
|
+
/** Fired when the recognition session ends — covers BOTH cases:
|
|
12
|
+
* - the user clicked the mic button to stop (manual)
|
|
13
|
+
* - the browser auto-ended after detecting silence (auto)
|
|
14
|
+
* The Promise returned by `stop()` only resolves on the manual
|
|
15
|
+
* path; subscribe via this callback to also catch auto-ends.
|
|
16
|
+
* Receives the final transcript (may be empty). */
|
|
17
|
+
onResult?: (text: string) => void;
|
|
18
|
+
/** Trailing silence (ms) before the adapter auto-stops on its own.
|
|
19
|
+
* When > 0, the adapter forces `continuous: true` and tracks
|
|
20
|
+
* silence via an internal timer instead of letting the browser
|
|
21
|
+
* auto-end on its short ~1-3s default. Useful for "wait a few
|
|
22
|
+
* seconds before committing the transcript" UX — set e.g. 5000
|
|
23
|
+
* to give the user breathing room between sentences.
|
|
24
|
+
* Default `0` (use the browser's default silence behavior). */
|
|
25
|
+
silenceTimeoutMs?: number;
|
|
26
|
+
/** Hard cap (ms) on a single recording session. The adapter stops
|
|
27
|
+
* itself after this many ms regardless of speech activity, so the
|
|
28
|
+
* user can never accidentally leave the mic open forever. Same
|
|
29
|
+
* emit path as a silence-timeout stop (`onResult` fires with the
|
|
30
|
+
* accumulated transcript). Default `0` (no cap). */
|
|
31
|
+
maxDurationMs?: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Hook returning a `VoiceTranscriptionAdapter` backed by the browser
|
|
35
|
+
* Web Speech API. Drop the result into `<ChatMicButton adapter={…}>`.
|
|
36
|
+
*/
|
|
37
|
+
declare function useBrowserVoiceAdapter(opts?: UseBrowserVoiceAdapterOptions): VoiceTranscriptionAdapter;
|
|
38
|
+
|
|
39
|
+
export { type UseBrowserVoiceAdapterOptions, useBrowserVoiceAdapter };
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
function getSpeechRecognition() {
|
|
4
|
+
if (typeof window === "undefined") return null;
|
|
5
|
+
const w = window;
|
|
6
|
+
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
|
7
|
+
}
|
|
8
|
+
function useBrowserVoiceAdapter(opts = {}) {
|
|
9
|
+
const {
|
|
10
|
+
language,
|
|
11
|
+
continuous = false,
|
|
12
|
+
interim = true,
|
|
13
|
+
silenceTimeoutMs = 0,
|
|
14
|
+
maxDurationMs = 0
|
|
15
|
+
} = opts;
|
|
16
|
+
const onResultRef = useRef(opts.onResult);
|
|
17
|
+
onResultRef.current = opts.onResult;
|
|
18
|
+
const [status, setStatus] = useState("idle");
|
|
19
|
+
const [error, setError] = useState(null);
|
|
20
|
+
const [elapsedMs, setElapsedMs] = useState(0);
|
|
21
|
+
const [interimTranscript, setInterimTranscript] = useState("");
|
|
22
|
+
const [finalTranscript, setFinalTranscript] = useState("");
|
|
23
|
+
const recRef = useRef(null);
|
|
24
|
+
const finalTextRef = useRef("");
|
|
25
|
+
const startTimeRef = useRef(0);
|
|
26
|
+
const tickRef = useRef(null);
|
|
27
|
+
const stopResolverRef = useRef(null);
|
|
28
|
+
const cancelledRef = useRef(false);
|
|
29
|
+
const silenceTickRef = useRef(null);
|
|
30
|
+
const lastSpeechAtRef = useRef(0);
|
|
31
|
+
const maxDurationTimerRef = useRef(null);
|
|
32
|
+
const isSupported = typeof window !== "undefined" && !!getSpeechRecognition();
|
|
33
|
+
const startTicker = useCallback(() => {
|
|
34
|
+
if (tickRef.current !== null) return;
|
|
35
|
+
startTimeRef.current = Date.now();
|
|
36
|
+
setElapsedMs(0);
|
|
37
|
+
tickRef.current = window.setInterval(() => {
|
|
38
|
+
setElapsedMs(Date.now() - startTimeRef.current);
|
|
39
|
+
}, 100);
|
|
40
|
+
}, []);
|
|
41
|
+
const stopTicker = useCallback(() => {
|
|
42
|
+
if (tickRef.current !== null) {
|
|
43
|
+
window.clearInterval(tickRef.current);
|
|
44
|
+
tickRef.current = null;
|
|
45
|
+
}
|
|
46
|
+
}, []);
|
|
47
|
+
const stopSilenceTick = useCallback(() => {
|
|
48
|
+
if (silenceTickRef.current !== null) {
|
|
49
|
+
window.clearInterval(silenceTickRef.current);
|
|
50
|
+
silenceTickRef.current = null;
|
|
51
|
+
}
|
|
52
|
+
}, []);
|
|
53
|
+
const stopMaxDurationTimer = useCallback(() => {
|
|
54
|
+
if (maxDurationTimerRef.current !== null) {
|
|
55
|
+
window.clearTimeout(maxDurationTimerRef.current);
|
|
56
|
+
maxDurationTimerRef.current = null;
|
|
57
|
+
}
|
|
58
|
+
}, []);
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
return () => {
|
|
61
|
+
stopTicker();
|
|
62
|
+
stopSilenceTick();
|
|
63
|
+
stopMaxDurationTimer();
|
|
64
|
+
try {
|
|
65
|
+
recRef.current?.abort();
|
|
66
|
+
} catch {
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}, [stopTicker, stopSilenceTick, stopMaxDurationTimer]);
|
|
70
|
+
const start = useCallback(async () => {
|
|
71
|
+
const Ctor = getSpeechRecognition();
|
|
72
|
+
if (!Ctor) {
|
|
73
|
+
const err = new Error("Web Speech API not supported in this browser");
|
|
74
|
+
setError(err);
|
|
75
|
+
setStatus("error");
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
if (status === "recording" || status === "starting") return;
|
|
79
|
+
setError(null);
|
|
80
|
+
setInterimTranscript("");
|
|
81
|
+
setFinalTranscript("");
|
|
82
|
+
finalTextRef.current = "";
|
|
83
|
+
cancelledRef.current = false;
|
|
84
|
+
setStatus("starting");
|
|
85
|
+
const rec = new Ctor();
|
|
86
|
+
if (language) rec.lang = language;
|
|
87
|
+
rec.continuous = silenceTimeoutMs > 0 ? true : continuous;
|
|
88
|
+
rec.interimResults = interim;
|
|
89
|
+
rec.onstart = () => {
|
|
90
|
+
setStatus("recording");
|
|
91
|
+
startTicker();
|
|
92
|
+
lastSpeechAtRef.current = Date.now();
|
|
93
|
+
if (silenceTimeoutMs > 0) {
|
|
94
|
+
silenceTickRef.current = window.setInterval(() => {
|
|
95
|
+
const idle = Date.now() - lastSpeechAtRef.current;
|
|
96
|
+
if (idle >= silenceTimeoutMs) {
|
|
97
|
+
stopSilenceTick();
|
|
98
|
+
try {
|
|
99
|
+
rec.stop();
|
|
100
|
+
} catch {
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}, 400);
|
|
104
|
+
}
|
|
105
|
+
if (maxDurationMs > 0) {
|
|
106
|
+
maxDurationTimerRef.current = window.setTimeout(() => {
|
|
107
|
+
maxDurationTimerRef.current = null;
|
|
108
|
+
try {
|
|
109
|
+
rec.stop();
|
|
110
|
+
} catch {
|
|
111
|
+
}
|
|
112
|
+
}, maxDurationMs);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
rec.onresult = (event) => {
|
|
116
|
+
let interimAcc = "";
|
|
117
|
+
let appendedFinal = false;
|
|
118
|
+
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
119
|
+
const result = event.results[i];
|
|
120
|
+
const transcript = result[0]?.transcript ?? "";
|
|
121
|
+
if (result.isFinal) {
|
|
122
|
+
finalTextRef.current = (finalTextRef.current + " " + transcript).trim();
|
|
123
|
+
appendedFinal = true;
|
|
124
|
+
} else {
|
|
125
|
+
interimAcc += transcript;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
setInterimTranscript(interimAcc);
|
|
129
|
+
if (appendedFinal) setFinalTranscript(finalTextRef.current);
|
|
130
|
+
lastSpeechAtRef.current = Date.now();
|
|
131
|
+
};
|
|
132
|
+
rec.onerror = (e) => {
|
|
133
|
+
const err = new Error(e.error || "Speech recognition error");
|
|
134
|
+
setError(err);
|
|
135
|
+
setStatus("error");
|
|
136
|
+
stopTicker();
|
|
137
|
+
stopSilenceTick();
|
|
138
|
+
stopMaxDurationTimer();
|
|
139
|
+
stopResolverRef.current?.("");
|
|
140
|
+
stopResolverRef.current = null;
|
|
141
|
+
};
|
|
142
|
+
rec.onend = () => {
|
|
143
|
+
stopTicker();
|
|
144
|
+
stopSilenceTick();
|
|
145
|
+
stopMaxDurationTimer();
|
|
146
|
+
const final = cancelledRef.current ? "" : finalTextRef.current;
|
|
147
|
+
setInterimTranscript("");
|
|
148
|
+
setStatus("idle");
|
|
149
|
+
if (stopResolverRef.current) {
|
|
150
|
+
stopResolverRef.current(final);
|
|
151
|
+
stopResolverRef.current = null;
|
|
152
|
+
} else if (final) {
|
|
153
|
+
onResultRef.current?.(final);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
recRef.current = rec;
|
|
157
|
+
try {
|
|
158
|
+
rec.start();
|
|
159
|
+
} catch (e) {
|
|
160
|
+
const err = e instanceof Error ? e : new Error("Failed to start mic");
|
|
161
|
+
setError(err);
|
|
162
|
+
setStatus("error");
|
|
163
|
+
stopTicker();
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
}, [
|
|
167
|
+
continuous,
|
|
168
|
+
interim,
|
|
169
|
+
language,
|
|
170
|
+
silenceTimeoutMs,
|
|
171
|
+
maxDurationMs,
|
|
172
|
+
startTicker,
|
|
173
|
+
stopTicker,
|
|
174
|
+
stopSilenceTick,
|
|
175
|
+
stopMaxDurationTimer,
|
|
176
|
+
status
|
|
177
|
+
]);
|
|
178
|
+
const stop = useCallback(async () => {
|
|
179
|
+
const rec = recRef.current;
|
|
180
|
+
if (!rec) return "";
|
|
181
|
+
setStatus("processing");
|
|
182
|
+
return new Promise((resolve) => {
|
|
183
|
+
stopResolverRef.current = resolve;
|
|
184
|
+
try {
|
|
185
|
+
rec.stop();
|
|
186
|
+
} catch {
|
|
187
|
+
resolve(finalTextRef.current);
|
|
188
|
+
stopResolverRef.current = null;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}, []);
|
|
192
|
+
const cancel = useCallback(() => {
|
|
193
|
+
cancelledRef.current = true;
|
|
194
|
+
finalTextRef.current = "";
|
|
195
|
+
setInterimTranscript("");
|
|
196
|
+
setFinalTranscript("");
|
|
197
|
+
try {
|
|
198
|
+
recRef.current?.abort();
|
|
199
|
+
} catch {
|
|
200
|
+
}
|
|
201
|
+
}, []);
|
|
202
|
+
return {
|
|
203
|
+
status,
|
|
204
|
+
error,
|
|
205
|
+
elapsedMs,
|
|
206
|
+
interimTranscript,
|
|
207
|
+
finalTranscript,
|
|
208
|
+
isSupported,
|
|
209
|
+
start,
|
|
210
|
+
stop,
|
|
211
|
+
cancel
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
export {
|
|
215
|
+
useBrowserVoiceAdapter
|
|
216
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lumea-labs/chat-web-speech",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tier 2 voice adapter for Web Speech API — wraps webkitSpeechRecognition behind the VoiceTranscriptionAdapter contract.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"peerDependencies": {
|
|
10
|
+
"react": ">=19",
|
|
11
|
+
"lucide-react": "*",
|
|
12
|
+
"@lumea-labs/chat": "*"
|
|
13
|
+
},
|
|
14
|
+
"license": "Apache-2.0",
|
|
15
|
+
"module": "./dist/index.js",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsup",
|
|
28
|
+
"build:watch": "tsup --watch",
|
|
29
|
+
"clean": "rm -rf dist"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/Lumea-Technologies/lumea-agents.git",
|
|
37
|
+
"directory": "packages/chat-web-speech"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/Lumea-Technologies/lumea-agents/tree/main/packages/chat-web-speech"
|
|
40
|
+
}
|