@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 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
@@ -0,0 +1,2 @@
1
+ export { UseBrowserVoiceAdapterOptions, useBrowserVoiceAdapter } from './use-browser-voice-adapter.js';
2
+ import '@lumea-labs/chat';
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ import {
2
+ useBrowserVoiceAdapter
3
+ } from "./use-browser-voice-adapter";
4
+ export {
5
+ useBrowserVoiceAdapter
6
+ };
@@ -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
+ }